From d2882c7e9fbe3dabd87976cf825c0424be9a0f0b Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:40:18 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Mind=20map=20=E2=86=94=20knowledge=20gr?= =?UTF-8?q?aph=20bidirectional=20sync=20via=20shared=20Zustand=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented bidirectional synchronization between Mind Map (mind-elixir) and Knowledge Graph (graphology) features. - Created `GraphSyncStore` Zustand slice to manage sync state and event queue. - Implemented sync adapters for both Mind Map and Graph to map library-specific operations to shared events. - Integrated sync logic into `MindMapView` and `GraphView` with loop prevention and last-writer-wins conflict resolution. - Added `SyncToggle` UI component to both feature toolbars. - Fixed potential listener leaks and update loops in GraphView. - Added unit and integration tests for sync logic. Co-authored-by: d-oit <6849456+d-oit@users.noreply.github.com> --- package.json | 3 +- pnpm-lock.yaml | 27 ++++++ src/components/SyncToggle.tsx | 46 ++++++++++ .../__tests__/sync-integration.test.tsx | 87 +++++++++++++++++++ src/features/graph/GraphControls.tsx | 2 + src/features/graph/GraphView.tsx | 65 ++++++++++++++ src/features/graph/sync-adapter.ts | 69 +++++++++++++++ src/features/mindmap/MindMapView.tsx | 44 ++++++++++ src/features/mindmap/sync-adapter.ts | 44 ++++++++++ src/store/__tests__/graph-sync-store.test.ts | 59 +++++++++++++ src/store/graph-sync-store.ts | 27 ++++++ src/store/graph-sync-types.ts | 19 ++++ 12 files changed, 491 insertions(+), 1 deletion(-) create mode 100644 src/components/SyncToggle.tsx create mode 100644 src/features/__tests__/sync-integration.test.tsx create mode 100644 src/features/graph/sync-adapter.ts create mode 100644 src/features/mindmap/sync-adapter.ts create mode 100644 src/store/__tests__/graph-sync-store.test.ts create mode 100644 src/store/graph-sync-store.ts create mode 100644 src/store/graph-sync-types.ts diff --git a/package.json b/package.json index d8521598..ea57bad8 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,8 @@ "react-dom": "^19.0.0", "react-router-dom": "^7.15.1", "sigma": "^3.0.0-beta.27", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zustand": "^5.0.14" }, "devDependencies": { "@eslint/js": "9.39.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fe17287..b58ed7bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: zod: specifier: ^3.22.4 version: 3.25.76 + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.15)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) devDependencies: '@eslint/js': specifier: 9.39.4 @@ -3272,6 +3275,24 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.5.0': {} @@ -6538,3 +6559,9 @@ snapshots: zod: 3.25.76 zod@3.25.76: {} + + zustand@5.0.14(@types/react@19.2.15)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): + optionalDependencies: + '@types/react': 19.2.15 + react: 19.2.6 + use-sync-external-store: 1.6.0(react@19.2.6) diff --git a/src/components/SyncToggle.tsx b/src/components/SyncToggle.tsx new file mode 100644 index 00000000..685c2984 --- /dev/null +++ b/src/components/SyncToggle.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { RefreshCw } from 'lucide-react'; +import { useGraphSyncStore } from '../store/graph-sync-store'; + +interface SyncToggleProps { + tooltip?: string; +} + +const SyncToggle: React.FC = ({ + tooltip = "Sync mind map with knowledge graph" +}) => { + const { syncEnabled, setSyncEnabled } = useGraphSyncStore(); + + return ( + + ); +}; + +export default SyncToggle; diff --git a/src/features/__tests__/sync-integration.test.tsx b/src/features/__tests__/sync-integration.test.tsx new file mode 100644 index 00000000..28475691 --- /dev/null +++ b/src/features/__tests__/sync-integration.test.tsx @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, act } from '@testing-library/react'; +import React from 'react'; +import { useGraphSyncStore } from '../../store/graph-sync-store'; +import { setupMindMapSyncListeners } from '../mindmap/sync-adapter'; +import { setupGraphSyncListeners } from '../graph/sync-adapter'; +import Graph from 'graphology'; + +// Mock MindElixir as it's hard to render in Vitest/Happy-dom without a real canvas +const mockMindMap = { + bus: { + addListener: vi.fn(), + }, + findEle: vi.fn(), + addChild: vi.fn(), + updateNodeStyle: vi.fn(), + refresh: vi.fn(), + removeNodes: vi.fn(), +}; + +describe('Sync Integration', () => { + beforeEach(() => { + useGraphSyncStore.getState().clearEvents(); + useGraphSyncStore.getState().setSyncEnabled(true); + vi.clearAllMocks(); + }); + + it('should sync node addition from mind map to graph', () => { + const graph = new Graph(); + setupGraphSyncListeners(graph); + + // Simulate setupMindMapSyncListeners + let mindMapListener: (op: any) => void = () => {}; + mockMindMap.bus.addListener.mockImplementation((name, cb) => { + if (name === 'operation') mindMapListener = cb; + }); + setupMindMapSyncListeners(mockMindMap as any); + + // 1. Add node in mind map + act(() => { + mindMapListener({ + name: 'addChild', + obj: { id: 'test-node', topic: 'Test Node' } + }); + }); + + // Verify event emitted + expect(useGraphSyncStore.getState().pendingEvents).toHaveLength(1); + expect(useGraphSyncStore.getState().pendingEvents[0].type).toBe('node:add'); + + // 2. Consume in graph (simulating the effect in GraphView) + const events = useGraphSyncStore.getState().consumeEvents('graph'); + expect(events).toHaveLength(1); + + act(() => { + const payload: any = events[0].payload; + graph.addNode(payload.id, { label: payload.label }); + }); + + expect(graph.hasNode('test-node')).toBe(true); + expect(graph.getNodeAttribute('test-node', 'label')).toBe('Test Node'); + }); + + it('should sync node update from graph to mind map', () => { + const graph = new Graph(); + setupGraphSyncListeners(graph); + + // 1. Add node in graph + act(() => { + graph.addNode('node-1', { label: 'Initial' }); + }); + + // Should have emitted node:add + expect(useGraphSyncStore.getState().pendingEvents).toHaveLength(1); + useGraphSyncStore.getState().consumeEvents('mindmap'); // Clear + + // 2. Update node in graph + act(() => { + graph.setNodeAttribute('node-1', 'label', 'Updated'); + }); + + const events = useGraphSyncStore.getState().pendingEvents; + expect(events.some(e => e.type === 'node:update')).toBe(true); + const updateEvent = events.find(e => e.type === 'node:update'); + expect((updateEvent?.payload as any).label).toBe('Updated'); + }); +}); diff --git a/src/features/graph/GraphControls.tsx b/src/features/graph/GraphControls.tsx index fec43971..691adb2d 100644 --- a/src/features/graph/GraphControls.tsx +++ b/src/features/graph/GraphControls.tsx @@ -1,5 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import { Focus, Camera, Clock, X, FolderOpen, GitCompare, RotateCcw, Loader2, Layout, LayoutDashboard, Download, CircleDot } from 'lucide-react'; +import SyncToggle from '../../components/SyncToggle'; import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { useMediaQuery } from '../../hooks/useMediaQuery'; @@ -215,6 +216,7 @@ const GraphControls: React.FC = ({ Exit Snapshot )} + {!snapshotMode && } {hasSelection && !isMobile && (
Selected: {selectedName} diff --git a/src/features/graph/GraphView.tsx b/src/features/graph/GraphView.tsx index fde72e01..9a3a7b02 100644 --- a/src/features/graph/GraphView.tsx +++ b/src/features/graph/GraphView.tsx @@ -10,6 +10,9 @@ import { useRepository } from '../../db/useRepository'; import { logger } from '../../lib/logger'; import { perf } from '../../lib/perf'; import { applyCircularLayout, applyForceLayout, applyHierarchicalLayout } from '../../lib/graph-layout'; +import { useGraphSyncStore } from '../../store/graph-sync-store'; +import { setupGraphSyncListeners } from './sync-adapter'; +import type { SharedNode, SharedEdge } from '../../store/graph-sync-types'; import { useGraphKeyboardNavigation } from './GraphKeyboardNav'; import { useGraphTouchGestures } from './GraphTouchHandler'; import { useGraphSnapshotManager } from './GraphSnapshotManager'; @@ -298,6 +301,12 @@ const GraphView: React.FC = ({ }; }, [effectiveData, selectedNode, focusMode, snapshotMode, setFocusMode, setSelectedNode, layout, layoutSettings, links, graphSize]); + useEffect(() => { + if (!graphRef.current) return; + const cleanup = setupGraphSyncListeners(graphRef.current); + return cleanup; + }, []); + useEffect(() => { if (!selectedNode) { setSelectedEntityClaims([]); @@ -330,6 +339,62 @@ const GraphView: React.FC = ({ void sigma.getCamera().animatedReset({ duration: 400 }); }, [layout, effectiveData.entities, links]); + // Sync effect to consume events + useEffect(() => { + const unsubscribe = useGraphSyncStore.subscribe((state) => { + if (!state.syncEnabled || !graphRef.current || state.pendingEvents.length === 0) return; + + const events = useGraphSyncStore.getState().consumeEvents('graph'); + if (events.length === 0) return; + + const graph = graphRef.current; + events.forEach(event => { + if (event.type === 'node:add') { + const payload = event.payload as SharedNode; + if (!graph.hasNode(payload.id)) { + graph.addNode(payload.id, { + label: payload.label, + size: 10, + color: '#2563eb', + x: Math.random(), + y: Math.random() + }); + } + } else if (event.type === 'node:update') { + const payload = event.payload as SharedNode; + if (graph.hasNode(payload.id)) { + const currentLabel = graph.getNodeAttribute(payload.id, 'label'); + if (currentLabel !== payload.label) { + console.warn(`[Sync Conflict] Graph node ${payload.id} has different label. Local: ${currentLabel}, Incoming: ${payload.label}. Applying last-writer wins.`); + graph.mergeNodeAttributes(payload.id, { label: payload.label }); + } + } + } else if (event.type === 'node:remove') { + const payload = event.payload as SharedNode; + if (graph.hasNode(payload.id)) { + graph.dropNode(payload.id); + } + } else if (event.type === 'edge:add') { + const payload = event.payload as SharedEdge; + if (graph.hasNode(payload.from) && graph.hasNode(payload.to) && !graph.hasEdge(payload.id)) { + graph.addEdgeWithKey(payload.id, payload.from, payload.to, { + label: payload.label, + size: 2, + color: '#94a3b8' + }); + } + } else if (event.type === 'edge:remove') { + const payload = event.payload as SharedEdge; + if (graph.hasEdge(payload.id)) { + graph.dropEdge(payload.id); + } + } + }); + if (sigmaInstance.current) sigmaInstance.current.refresh(); + }); + return () => unsubscribe(); + }, []); + useEffect(() => { return () => { sigmaInstance.current?.kill(); diff --git a/src/features/graph/sync-adapter.ts b/src/features/graph/sync-adapter.ts new file mode 100644 index 00000000..5998112d --- /dev/null +++ b/src/features/graph/sync-adapter.ts @@ -0,0 +1,69 @@ +import type Graph from 'graphology'; +import { useGraphSyncStore } from '../../store/graph-sync-store'; +import { SharedNode, SharedEdge } from '../../store/graph-sync-types'; + +export function setupGraphSyncListeners( + graph: Graph +) { + const store = useGraphSyncStore.getState(); + + const onNodeAdded = ({ key, attributes }: any) => { + if (!useGraphSyncStore.getState().syncEnabled) return; + store.emitEvent({ + type: 'node:add', + source: 'graph', + payload: { id: key, label: attributes.label } as SharedNode, + }); + }; + + const onNodeAttributesUpdated = ({ key, attributes }: any) => { + if (!useGraphSyncStore.getState().syncEnabled) return; + store.emitEvent({ + type: 'node:update', + source: 'graph', + payload: { id: key, label: attributes.label } as SharedNode, + }); + }; + + const onNodeDropped = ({ key }: any) => { + if (!useGraphSyncStore.getState().syncEnabled) return; + store.emitEvent({ + type: 'node:remove', + source: 'graph', + payload: { id: key, label: '' } as SharedNode, + }); + }; + + const onEdgeAdded = ({ key, source, target, attributes }: any) => { + if (!useGraphSyncStore.getState().syncEnabled) return; + store.emitEvent({ + type: 'edge:add', + source: 'graph', + payload: { id: key, from: source, to: target, label: attributes.label } as SharedEdge, + }); + }; + + const onEdgeDropped = ({ key }: any) => { + if (!useGraphSyncStore.getState().syncEnabled) return; + // We don't have from/to here, but id should be enough for removal if shared + store.emitEvent({ + type: 'edge:remove', + source: 'graph', + payload: { id: key, from: '', to: '' } as SharedEdge, + }); + }; + + graph.on('nodeAdded', onNodeAdded); + graph.on('nodeAttributesUpdated', onNodeAttributesUpdated); + graph.on('nodeDropped', onNodeDropped); + graph.on('edgeAdded', onEdgeAdded); + graph.on('edgeDropped', onEdgeDropped); + + return () => { + graph.off('nodeAdded', onNodeAdded); + graph.off('nodeAttributesUpdated', onNodeAttributesUpdated); + graph.off('nodeDropped', onNodeDropped); + graph.off('edgeAdded', onEdgeAdded); + graph.off('edgeDropped', onEdgeDropped); + }; +} diff --git a/src/features/mindmap/MindMapView.tsx b/src/features/mindmap/MindMapView.tsx index c2357eb8..0e7b98c7 100644 --- a/src/features/mindmap/MindMapView.tsx +++ b/src/features/mindmap/MindMapView.tsx @@ -1,10 +1,14 @@ import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import MindElixir, { type MindElixirData, type MindElixirInstance } from 'mind-elixir'; +import { useGraphSyncStore } from '../../store/graph-sync-store'; +import { setupMindMapSyncListeners } from './sync-adapter'; +import type { SharedNode } from '../../store/graph-sync-types'; import type { Entity, Link } from '../../lib/validation'; import { repository } from '../../db/repository'; import { logger } from '../../lib/logger'; import { upsertToSearchIndex } from '../../lib/search'; import { perf } from '../../lib/perf'; +import SyncToggle from '../../components/SyncToggle'; import { ChevronDown, Layers, Filter, Info, ChevronRight, Plus, GitBranch, Pencil, Trash2, Image } from 'lucide-react'; const COLLAPSED_BY_DEFAULT_THRESHOLD = 20; @@ -123,6 +127,7 @@ const MindMapView: React.FC = ({ instance.init({ nodeData: treeData }); + setupMindMapSyncListeners(instance); perf.measure('mindmap-init', 'mindmap-mount'); mindInstance.current.bus.addListener('selectNode', (node: { id?: string }) => { @@ -223,6 +228,43 @@ const MindMapView: React.FC = ({ }; }, [treeData, onEntityClick, isLargeMap, rootId, entities]); + // Sync effect to consume events + useEffect(() => { + const unsubscribe = useGraphSyncStore.subscribe((state) => { + if (!state.syncEnabled || !mindInstance.current || state.pendingEvents.length === 0) return; + + const events = useGraphSyncStore.getState().consumeEvents('mindmap'); + if (events.length === 0) return; + + events.forEach(event => { + const payload = event.payload as SharedNode; + if (event.type === 'node:add') { + if (!mindInstance.current?.findEle(payload.id)) { + // Add as child of root for simplicity if not specified + mindInstance.current?.addChild(mindInstance.current.findEle(rootId), { + id: payload.id, + topic: payload.label + }); + } + } else if (event.type === 'node:update') { + const el = mindInstance.current?.findEle(payload.id); + if (el && el.nodeObj.topic !== payload.label) { + console.warn(`[Sync Conflict] Mind map node ${payload.id} has different label. Local: ${el.nodeObj.topic}, Incoming: ${payload.label}. Applying last-writer wins.`); + mindInstance.current?.updateNodeStyle(payload.id); // Refresh + el.nodeObj.topic = payload.label; + mindInstance.current?.refresh(); + } + } else if (event.type === 'node:remove') { + const el = mindInstance.current?.findEle(payload.id); + if (el) { + mindInstance.current?.removeNodes([el]); + } + } + }); + }); + return () => unsubscribe(); + }, [rootId]); + const handleResetRoot = useCallback(() => { setRootId(propsRootEntity.id || ''); }, [propsRootEntity.id]); @@ -372,6 +414,8 @@ const MindMapView: React.FC = ({ + +