diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e9713e..d262891 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -55,7 +55,7 @@ import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' export default function App() { - const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore() + const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() @@ -142,7 +142,10 @@ export default function App() { delaySeconds: autosave.delay, hasUnsavedChanges, designId: loadedDesignId, - changeSignals: [nodes, edges], + // Debounce resets on each real user edit (editSeq), not on raw nodes/edges + // identity — live status polling churns those arrays without a user edit and + // would otherwise keep re-arming (and starving) the timer during monitoring. + changeSignals: [editSeq], getLiveDesignId: () => loadedDesignIdRef.current, onSave: (designId) => { void handleSaveRef.current(designId, { silent: true }) }, }) diff --git a/frontend/src/stores/__tests__/canvasStore/nodes.test.ts b/frontend/src/stores/__tests__/canvasStore/nodes.test.ts index 7bc3412..e2239f6 100644 --- a/frontend/src/stores/__tests__/canvasStore/nodes.test.ts +++ b/frontend/src/stores/__tests__/canvasStore/nodes.test.ts @@ -9,6 +9,7 @@ function resetStore() { nodes: [], edges: [], hasUnsavedChanges: false, + editSeq: 0, selectedNodeId: null, selectedNodeIds: [], editingGroupRectId: null, @@ -97,6 +98,34 @@ describe('canvasStore — nodes', () => { expect(useCanvasStore.getState().hasUnsavedChanges).toBe(false) }) + it('editSeq bumps on a real user edit but not on status/select/measure churn', () => { + const seq = () => useCanvasStore.getState().editSeq + + // Real edit bumps. + const before = seq() + useCanvasStore.getState().addNode(makeNode('n1')) + expect(seq()).toBe(before + 1) + + // Live status update: no bump (not a user edit). + const afterAdd = seq() + useCanvasStore.getState().setNodeStatus('n1', { status: 'online' }) + expect(seq()).toBe(afterAdd) + + // Select-only change: no bump. + useCanvasStore.getState().onNodesChange([{ id: 'n1', type: 'select', selected: true }]) + expect(seq()).toBe(afterAdd) + + // Initial dimensions measure: no bump. + useCanvasStore.getState().onNodesChange([ + { id: 'n1', type: 'dimensions', dimensions: { width: 120, height: 40 } }, + ]) + expect(seq()).toBe(afterAdd) + + // Another real edit bumps again. + useCanvasStore.getState().updateNode('n1', { label: 'renamed' }) + expect(seq()).toBe(afterAdd + 1) + }) + it('setEditingTextId sets and clears editing text id', () => { const { setEditingTextId } = useCanvasStore.getState() setEditingTextId('t1') diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index c8c8c91..519d54d 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -109,6 +109,14 @@ interface CanvasState { nodes: Node[] edges: Edge[] hasUnsavedChanges: boolean + /** + * Monotonic counter incremented on every real user edit (auto-bumped whenever + * an action sets hasUnsavedChanges to true). Consumers that need to react to + * *edits specifically* — e.g. the autosave debounce — key off this instead of + * the nodes/edges array identity, which also churns on live status updates and + * selection changes that must NOT reset the debounce. + */ + editSeq: number selectedNodeId: string | null selectedNodeIds: string[] scanEventTs: number @@ -187,10 +195,33 @@ interface CanvasState { applyAllCustomStyles: (def: CustomStyleDef) => void } -export const useCanvasStore = create((set) => ({ +export const useCanvasStore = create((rawSet) => { + // Wrap set so any update that flips hasUnsavedChanges to true also bumps + // editSeq. This centralises the "an edit happened" signal instead of touching + // every one of the ~two dozen mutating actions. Actions that update state + // without dirtying (setNodeStatus, markSaved, loadCanvas, selection) omit + // hasUnsavedChanges or set it false, so they never bump the counter. + const set: typeof rawSet = ((partial, replace) => { + rawSet((state) => { + const next = typeof partial === 'function' + ? (partial as (s: CanvasState) => Partial)(state) + : partial + if ( + next && + typeof next === 'object' && + (next as Partial).hasUnsavedChanges === true && + !('editSeq' in next) + ) { + return { ...next, editSeq: state.editSeq + 1 } + } + return next + }, replace as false | undefined) + }) as typeof rawSet + return { nodes: [], edges: [], hasUnsavedChanges: false, + editSeq: 0, selectedNodeId: null, selectedNodeIds: [], editingGroupRectId: null, @@ -347,19 +378,26 @@ export const useCanvasStore = create((set) => ({ // they don't follow a moved node on their own. Translate them by the same // delta the node moved so a clean routing stays clean after a drag (#279). const edges = translateWaypointsForMovedNodes(changes, state.nodes, nodes, state.edges) + // Only set hasUnsavedChanges when a real edit occurred, so the set() wrapper + // bumps editSeq only then. Selection- or measure-only changes leave the flag + // untouched (carried over) and must not reset the autosave debounce. + const edited = changes.some(isUserNodeEdit) return { nodes, edges, selectedNodeIds, - hasUnsavedChanges: state.hasUnsavedChanges || changes.some(isUserNodeEdit), + ...(edited ? { hasUnsavedChanges: true } : {}), } }), onEdgesChange: (changes) => - set((state) => ({ - edges: applyEdgeChanges(changes, state.edges), - hasUnsavedChanges: state.hasUnsavedChanges || changes.some((c) => c.type !== 'select'), - })), + set((state) => { + const edited = changes.some((c) => c.type !== 'select') + return { + edges: applyEdgeChanges(changes, state.edges), + ...(edited ? { hasUnsavedChanges: true } : {}), + } + }), onConnect: (connection) => set((state) => { @@ -1013,4 +1051,5 @@ export const useCanvasStore = create((set) => ({ }) return { nodes, edges, hasUnsavedChanges: true } }), -})) + } +})