diff --git a/CLAUDE.md b/CLAUDE.md index ce34dc32..24d0e621 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -846,7 +846,6 @@ const LOAD_MORE_INCREMENT = 25; ```typescript // In useSettings.ts -documentGraphLayoutMode: 'force' | 'hierarchical' // Saved layout preference documentGraphShowExternalLinks: boolean // Show external link nodes (default: false) documentGraphMaxNodes: number // Initial pagination limit (50-1000, default: 200) ``` diff --git a/package-lock.json b/package-lock.json index 57707f06..3569c9be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,7 +42,6 @@ "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", - "react-force-graph-2d": "^1.29.0", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", "reactflow": "^11.11.4", @@ -3620,12 +3619,6 @@ "node": ">= 10" } }, - "node_modules/@tweenjs/tween.js": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", - "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", - "license": "MIT" - }, "node_modules/@types/adm-zip": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", @@ -4746,15 +4739,6 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, - "node_modules/accessor-fn": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", - "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -5737,16 +5721,6 @@ "node": "20.x || 22.x || 23.x || 24.x || 25.x" } }, - "node_modules/bezier-js": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", - "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", - "license": "MIT", - "funding": { - "type": "individual", - "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" - } - }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -6227,18 +6201,6 @@ "node": "^18.12.0 || >= 20.9.0" } }, - "node_modules/canvas-color-tracker": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", - "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", - "license": "MIT", - "dependencies": { - "tinycolor2": "^1.6.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/canvas-confetti": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", @@ -7183,12 +7145,6 @@ "node": ">=12" } }, - "node_modules/d3-binarytree": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", - "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", - "license": "MIT" - }, "node_modules/d3-brush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", @@ -7341,22 +7297,6 @@ "node": ">=12" } }, - "node_modules/d3-force-3d": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", - "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", - "license": "MIT", - "dependencies": { - "d3-binarytree": "1", - "d3-dispatch": "1 - 3", - "d3-octree": "1", - "d3-quadtree": "1 - 3", - "d3-timer": "1 - 3" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -7399,12 +7339,6 @@ "node": ">=12" } }, - "node_modules/d3-octree": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", - "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", - "license": "MIT" - }, "node_modules/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", @@ -9921,20 +9855,6 @@ "dev": true, "license": "ISC" }, - "node_modules/float-tooltip": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", - "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", - "license": "MIT", - "dependencies": { - "d3-selection": "2 - 3", - "kapsule": "^1.16", - "preact": "10" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9951,32 +9871,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/force-graph": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.0.tgz", - "integrity": "sha512-aTnihCmiMA0ItLJLCbrQYS9mzriopW24goFPgUnKAAmAlPogTSmFWqoBPMXzIfPb7bs04Hur5zEI4WYgLW3Sig==", - "license": "MIT", - "dependencies": { - "@tweenjs/tween.js": "18 - 25", - "accessor-fn": "1", - "bezier-js": "3 - 6", - "canvas-color-tracker": "^1.3", - "d3-array": "1 - 3", - "d3-drag": "2 - 3", - "d3-force-3d": "2 - 3", - "d3-scale": "1 - 4", - "d3-scale-chromatic": "1 - 3", - "d3-selection": "2 - 3", - "d3-zoom": "2 - 3", - "float-tooltip": "^1.7", - "index-array-by": "1", - "kapsule": "^1.16", - "lodash-es": "4" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -11070,15 +10964,6 @@ "node": ">=8" } }, - "node_modules/index-array-by": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", - "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/infer-owner": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", @@ -11839,15 +11724,6 @@ "node": ">=10" } }, - "node_modules/jerrypick": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", - "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -12060,18 +11936,6 @@ "node": ">=4.0" } }, - "node_modules/kapsule": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", - "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", - "license": "MIT", - "dependencies": { - "lodash-es": "4" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/katex": { "version": "0.16.25", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.25.tgz", @@ -14199,6 +14063,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15091,16 +14956,6 @@ "node": ">=0.10.0" } }, - "node_modules/preact": { - "version": "10.28.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.28.1.tgz", - "integrity": "sha512-u1/ixq/lVQI0CakKNvLDEcW5zfCjUQfZdK9qqWuIJtsezuyG6pk9TWj75GMuI/EzRSZB/VAE43sNWWZfiy8psw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -15230,6 +15085,7 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -15241,6 +15097,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, "node_modules/property-information": { @@ -15539,23 +15396,6 @@ "react": "^18.3.1" } }, - "node_modules/react-force-graph-2d": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.0.tgz", - "integrity": "sha512-Xv5IIk+hsZmB3F2ibja/t6j/b0/1T9dtFOQacTUoLpgzRHrO6wPu1GtQ2LfRqI/imgtaapnXUgQaE8g8enPo5w==", - "license": "MIT", - "dependencies": { - "force-graph": "^1.51", - "prop-types": "15", - "react-kapsule": "^2.5" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "react": "*" - } - }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -15563,21 +15403,6 @@ "license": "MIT", "peer": true }, - "node_modules/react-kapsule": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz", - "integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==", - "license": "MIT", - "dependencies": { - "jerrypick": "^1.1.1" - }, - "engines": { - "node": ">=12" - }, - "peerDependencies": { - "react": ">=16.13.1" - } - }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -17498,12 +17323,6 @@ "dev": true, "license": "MIT" }, - "node_modules/tinycolor2": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", - "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", - "license": "MIT" - }, "node_modules/tinyexec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", diff --git a/package.json b/package.json index 05b5ffbb..16060704 100644 --- a/package.json +++ b/package.json @@ -233,7 +233,6 @@ "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react-diff-view": "^3.3.2", - "react-force-graph-2d": "^1.29.0", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.0", "reactflow": "^11.11.4", diff --git a/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx b/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx index e21cff82..2ee3491f 100644 --- a/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx +++ b/src/__tests__/renderer/components/DocumentGraph/GraphLegend.test.tsx @@ -2,7 +2,7 @@ * Tests for the GraphLegend component * * The GraphLegend displays a collapsible panel explaining node types, edge types, - * and colors in the Document Graph visualization. + * keyboard shortcuts, and interaction hints in the Mind Map visualization. */ import { describe, it, expect, vi } from 'vitest'; @@ -123,11 +123,32 @@ describe('GraphLegend', () => { expect(screen.getByText('Connected Edge')).toBeInTheDocument(); }); + it('renders keyboard shortcuts section', () => { + render(); + + expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument(); + expect(screen.getByText('↑ ↓ ← →')).toBeInTheDocument(); + expect(screen.getByText('Navigate between nodes')).toBeInTheDocument(); + expect(screen.getByText('Enter')).toBeInTheDocument(); + expect(screen.getByText('Recenter on focused node')).toBeInTheDocument(); + expect(screen.getByText('O')).toBeInTheDocument(); + expect(screen.getByText('Open file in preview')).toBeInTheDocument(); + }); + it('renders interaction hints', () => { render(); - expect(screen.getByText('Double-click to open')).toBeInTheDocument(); - expect(screen.getByText('Right-click for context menu')).toBeInTheDocument(); + expect(screen.getByText('Recenter view')).toBeInTheDocument(); + expect(screen.getByText('Context menu')).toBeInTheDocument(); + expect(screen.getByText('Zoom in/out')).toBeInTheDocument(); + }); + + it('renders status indicators section', () => { + render(); + + expect(screen.getByText('Status Indicators')).toBeInTheDocument(); + expect(screen.getByText('Broken Links')).toBeInTheDocument(); + expect(screen.getByText('Links to non-existent files')).toBeInTheDocument(); }); }); @@ -181,7 +202,8 @@ describe('GraphLegend', () => { it('has region role with aria-label', () => { render(); - expect(screen.getByRole('region', { name: /graph legend/i })).toBeInTheDocument(); + // Mind map legend uses "Mind map legend" as aria-label + expect(screen.getByRole('region', { name: /mind map legend/i })).toBeInTheDocument(); }); it('toggle button has aria-expanded attribute', () => { @@ -220,22 +242,28 @@ describe('GraphLegend', () => { it('node previews have aria-label', () => { render(); - // Document node appears twice: once in Node Types, once in Selection (as selected) - const docNodes = screen.getAllByRole('img', { name: /document node/i }); + // Document node card appears in Node Types and Selection (as selected) + const docNodes = screen.getAllByRole('img', { name: /document node card/i }); expect(docNodes.length).toBeGreaterThanOrEqual(1); - expect(screen.getByRole('img', { name: /external link node/i })).toBeInTheDocument(); - expect(screen.getByRole('img', { name: /document node \(selected\)/i })).toBeInTheDocument(); + expect(screen.getByRole('img', { name: /external link node pill/i })).toBeInTheDocument(); + expect(screen.getByRole('img', { name: /document node card \(selected\)/i })).toBeInTheDocument(); }); it('edge previews have aria-label', () => { render(); - // Internal link edge appears twice: once in Connection Types, once in Selection (as highlighted) + // Internal link edge appears in Connection Types and Selection (as highlighted) const internalEdges = screen.getAllByRole('img', { name: /internal link edge/i }); expect(internalEdges.length).toBeGreaterThanOrEqual(1); expect(screen.getByRole('img', { name: /external link edge/i })).toBeInTheDocument(); expect(screen.getByRole('img', { name: /internal link edge \(highlighted\)/i })).toBeInTheDocument(); }); + + it('broken links indicator has aria-label', () => { + render(); + + expect(screen.getByRole('img', { name: /broken links warning indicator/i })).toBeInTheDocument(); + }); }); describe('Theme Styling', () => { @@ -286,120 +314,111 @@ describe('GraphLegend', () => { it('item descriptions use dim text color with opacity', () => { render(); - const description = screen.getByText('Markdown file (size = connections)'); + const description = screen.getByText('Card with title and description'); expect(description).toHaveStyle({ color: mockTheme.colors.textDim, opacity: '0.8' }); }); }); - describe('Node Preview Styling', () => { - it('document node preview renders as SVG circle', () => { + describe('Node Preview Styling (Mind Map Cards)', () => { + it('document node preview renders as SVG card with rect', () => { render(); - const docNode = screen.getByRole('img', { name: /^document node$/i }); - const circle = docNode.querySelector('circle'); - expect(circle).toBeInTheDocument(); - // Document nodes have radius 8 when not selected - expect(circle).toHaveAttribute('r', '8'); + const docNode = screen.getByRole('img', { name: /^document node card$/i }); + const rect = docNode.querySelector('rect'); + expect(rect).toBeInTheDocument(); + // Card has rounded corners (rx=4) + expect(rect).toHaveAttribute('rx', '4'); }); - it('external node preview renders as smaller SVG circle', () => { + it('external node preview renders as pill-shaped SVG', () => { render(); - const extNode = screen.getByRole('img', { name: /^external link node$/i }); - const circle = extNode.querySelector('circle'); - expect(circle).toBeInTheDocument(); - // External nodes have radius 5 when not selected - expect(circle).toHaveAttribute('r', '5'); + const extNode = screen.getByRole('img', { name: /^external link node pill$/i }); + const rect = extNode.querySelector('rect'); + expect(rect).toBeInTheDocument(); + // Pill has high rx value for rounded ends (rx=7) + expect(rect).toHaveAttribute('rx', '7'); }); it('selected document node preview has accent stroke', () => { render(); - const selectedNode = screen.getByRole('img', { name: /document node \(selected\)/i }); - const circle = selectedNode.querySelector('circle'); - expect(circle).toBeInTheDocument(); - // Selected nodes have radius 9 and accent stroke - expect(circle).toHaveAttribute('r', '9'); - expect(circle).toHaveAttribute('stroke', mockTheme.colors.accent); - expect(circle).toHaveAttribute('stroke-width', '2'); + const selectedNode = screen.getByRole('img', { name: /document node card \(selected\)/i }); + const rect = selectedNode.querySelector('rect'); + expect(rect).toBeInTheDocument(); + // Selected nodes have accent stroke color + expect(rect).toHaveAttribute('stroke', mockTheme.colors.accent); }); - it('document node preview uses accent fill color', () => { + it('document node preview uses bgActivity fill color', () => { render(); - const docNode = screen.getByRole('img', { name: /^document node$/i }); - const circle = docNode.querySelector('circle'); - expect(circle).toBeInTheDocument(); - expect(circle).toHaveAttribute('fill', `${mockTheme.colors.accent}BB`); + const docNode = screen.getByRole('img', { name: /^document node card$/i }); + const rect = docNode.querySelector('rect'); + expect(rect).toBeInTheDocument(); + expect(rect).toHaveAttribute('fill', mockTheme.colors.bgActivity); }); - it('external node preview uses dim fill color', () => { + it('external node preview uses bgMain fill color', () => { render(); - const extNode = screen.getByRole('img', { name: /^external link node$/i }); - const circle = extNode.querySelector('circle'); - expect(circle).toBeInTheDocument(); - expect(circle).toHaveAttribute('fill', `${mockTheme.colors.textDim}88`); + const extNode = screen.getByRole('img', { name: /^external link node pill$/i }); + const rect = extNode.querySelector('rect'); + expect(rect).toBeInTheDocument(); + expect(rect).toHaveAttribute('fill', mockTheme.colors.bgMain); }); }); - describe('Edge Preview Styling', () => { - it('internal edge preview is solid line', () => { + describe('Edge Preview Styling (Bezier Curves)', () => { + it('internal edge preview uses bezier path', () => { render(); const internalEdge = screen.getByRole('img', { name: /^internal link edge$/i }); - const line = internalEdge.querySelector('line'); - expect(line).toBeInTheDocument(); - expect(line).not.toHaveAttribute('stroke-dasharray'); + const path = internalEdge.querySelector('path'); + expect(path).toBeInTheDocument(); + // Should NOT have stroke-dasharray (solid line) + expect(path).not.toHaveAttribute('stroke-dasharray'); }); - it('external edge preview is dashed line', () => { + it('external edge preview is dashed bezier path', () => { render(); const externalEdge = screen.getByRole('img', { name: /external link edge(?! \(highlighted\))/i }); - const line = externalEdge.querySelector('line'); - expect(line).toBeInTheDocument(); - expect(line).toHaveAttribute('stroke-dasharray', '4 4'); + const path = externalEdge.querySelector('path'); + expect(path).toBeInTheDocument(); + expect(path).toHaveAttribute('stroke-dasharray', '4 3'); }); it('internal edge uses dim text color for stroke', () => { render(); const internalEdge = screen.getByRole('img', { name: /^internal link edge$/i }); - const line = internalEdge.querySelector('line'); - expect(line).toHaveAttribute('stroke', mockTheme.colors.textDim); + const path = internalEdge.querySelector('path'); + expect(path).toHaveAttribute('stroke', mockTheme.colors.textDim); }); it('highlighted edge uses accent color for stroke', () => { render(); const highlightedEdge = screen.getByRole('img', { name: /internal link edge \(highlighted\)/i }); - const line = highlightedEdge.querySelector('line'); - expect(line).toHaveAttribute('stroke', mockTheme.colors.accent); + const path = highlightedEdge.querySelector('path'); + expect(path).toHaveAttribute('stroke', mockTheme.colors.accent); }); it('highlighted edge has thicker stroke width', () => { render(); const highlightedEdge = screen.getByRole('img', { name: /internal link edge \(highlighted\)/i }); - const line = highlightedEdge.querySelector('line'); - expect(line).toHaveAttribute('stroke-width', '2.5'); + const path = highlightedEdge.querySelector('path'); + expect(path).toHaveAttribute('stroke-width', '2'); }); it('normal edge has thinner stroke width', () => { render(); const normalEdge = screen.getByRole('img', { name: /^internal link edge$/i }); - const line = normalEdge.querySelector('line'); - expect(line).toHaveAttribute('stroke-width', '1.5'); - }); - - it('edge preview includes arrow head', () => { - render(); - - const internalEdge = screen.getByRole('img', { name: /^internal link edge$/i }); - const arrowPath = internalEdge.querySelector('path'); - expect(arrowPath).toBeInTheDocument(); + const path = normalEdge.querySelector('path'); + expect(path).toHaveAttribute('stroke-width', '1.5'); }); }); @@ -427,7 +446,7 @@ describe('GraphLegend', () => { const { container } = render(); const legend = container.querySelector('.graph-legend'); - expect(legend).toHaveStyle({ maxWidth: '280px' }); + expect(legend).toHaveStyle({ maxWidth: '300px' }); }); it('has z-index for stacking above graph', () => { @@ -456,19 +475,19 @@ describe('GraphLegend', () => { it('document node has correct description', () => { render(); - expect(screen.getByText('Markdown file (size = connections)')).toBeInTheDocument(); + expect(screen.getByText('Card with title and description')).toBeInTheDocument(); }); it('external node has correct description', () => { render(); - expect(screen.getByText('External domain (smaller, dimmer)')).toBeInTheDocument(); + expect(screen.getByText('Pill showing domain name')).toBeInTheDocument(); }); it('internal edge has correct description', () => { render(); - expect(screen.getByText('Connection between markdown documents')).toBeInTheDocument(); + expect(screen.getByText('Connection between markdown files')).toBeInTheDocument(); }); it('external edge has correct description', () => { @@ -480,7 +499,7 @@ describe('GraphLegend', () => { it('selected node has correct description', () => { render(); - expect(screen.getByText('Click to select, highlights connections')).toBeInTheDocument(); + expect(screen.getByText('Click or navigate to select')).toBeInTheDocument(); }); it('connected edge has correct description', () => { diff --git a/src/__tests__/renderer/components/DocumentGraph/graphDataBuilder.test.ts b/src/__tests__/renderer/components/DocumentGraph/graphDataBuilder.test.ts index 38825722..85d50ff3 100644 --- a/src/__tests__/renderer/components/DocumentGraph/graphDataBuilder.test.ts +++ b/src/__tests__/renderer/components/DocumentGraph/graphDataBuilder.test.ts @@ -557,7 +557,7 @@ describe('graphDataBuilder', () => { }); // Note: Node positions are no longer set by graphDataBuilder. - // Positions are computed by the D3 force simulation in ForceGraph.tsx. + // Positions are computed by the layout algorithm in MindMap.tsx. // The graphDataBuilder now returns GraphNode[] without position property. }); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 442fd383..ee9a6eb7 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -329,7 +329,6 @@ function MaestroConsoleInner() { defaultStatsTimeRange, documentGraphShowExternalLinks, documentGraphMaxNodes, - documentGraphLayoutMode, } = settings; @@ -8621,7 +8620,6 @@ function MaestroConsoleInner() { getDocumentTaskCount={getDocumentTaskCount} onAutoRunRefresh={handleAutoRunRefresh} onOpenMarketplace={handleOpenMarketplace} - onOpenDocumentGraph={() => setIsGraphViewOpen(true)} tabSwitcherOpen={tabSwitcherOpen} onCloseTabSwitcher={handleCloseTabSwitcher} onTabSelect={handleUtilityTabSelect} @@ -8775,35 +8773,38 @@ function MaestroConsoleInner() { /> )} - {/* --- DOCUMENT GRAPH VIEW --- */} - setIsGraphViewOpen(false)} - theme={theme} - rootPath={activeSession?.cwd ?? ''} - onDocumentOpen={(filePath) => { - // Open the document in file preview - const fullPath = `${activeSession?.cwd ?? ''}/${filePath}`; - window.maestro.fs.readFile(fullPath, activeSession?.sshRemoteId).then((content) => { - if (content !== null) { - setPreviewFile({ name: filePath.split('/').pop() || filePath, content, path: fullPath }); - } - }); - setIsGraphViewOpen(false); - }} - onExternalLinkOpen={(url) => { - // Open external URL in default browser - window.maestro.shell.openExternal(url); - }} - focusFilePath={graphFocusFilePath} - onFocusFileConsumed={() => setGraphFocusFilePath(undefined)} - savedLayoutMode={documentGraphLayoutMode} - onLayoutModeChange={settings.setDocumentGraphLayoutMode} - defaultShowExternalLinks={documentGraphShowExternalLinks} - onExternalLinksChange={settings.setDocumentGraphShowExternalLinks} - defaultMaxNodes={documentGraphMaxNodes} - sshRemoteId={activeSession?.sshRemoteId} - /> + {/* --- DOCUMENT GRAPH VIEW (Mind Map) --- */} + {/* Only render when a focus file is provided - mind map requires a center document */} + {graphFocusFilePath && ( + { + setIsGraphViewOpen(false); + setGraphFocusFilePath(undefined); + }} + theme={theme} + rootPath={activeSession?.cwd ?? ''} + onDocumentOpen={(filePath) => { + // Open the document in file preview + const fullPath = `${activeSession?.cwd ?? ''}/${filePath}`; + window.maestro.fs.readFile(fullPath, activeSession?.sshRemoteId).then((content) => { + if (content !== null) { + setPreviewFile({ name: filePath.split('/').pop() || filePath, content, path: fullPath }); + } + }); + setIsGraphViewOpen(false); + }} + onExternalLinkOpen={(url) => { + // Open external URL in default browser + window.maestro.shell.openExternal(url); + }} + focusFilePath={graphFocusFilePath} + defaultShowExternalLinks={documentGraphShowExternalLinks} + onExternalLinksChange={settings.setDocumentGraphShowExternalLinks} + defaultMaxNodes={documentGraphMaxNodes} + sshRemoteId={activeSession?.sshRemoteId} + /> + )} {/* NOTE: All modals are now rendered via the unified component above */} @@ -9739,8 +9740,6 @@ function MaestroConsoleInner() { console.error('[onFileClick] Failed to read file:', error); } }} - isGraphViewOpen={isGraphViewOpen} - onOpenGraphView={() => setIsGraphViewOpen(true)} onFocusFileInGraph={(relativePath: string) => { setGraphFocusFilePath(relativePath); setIsGraphViewOpen(true); diff --git a/src/renderer/components/AppModals.tsx b/src/renderer/components/AppModals.tsx index 8558330c..2aff131f 100644 --- a/src/renderer/components/AppModals.tsx +++ b/src/renderer/components/AppModals.tsx @@ -823,7 +823,6 @@ export interface AppUtilityModalsProps { getDocumentTaskCount: (filename: string) => Promise; onAutoRunRefresh: () => Promise; onOpenMarketplace?: () => void; - onOpenDocumentGraph?: () => void; // TabSwitcherModal tabSwitcherOpen: boolean; @@ -989,7 +988,6 @@ export function AppUtilityModals({ getDocumentTaskCount, onAutoRunRefresh, onOpenMarketplace, - onOpenDocumentGraph, // TabSwitcherModal tabSwitcherOpen, onCloseTabSwitcher, @@ -1106,7 +1104,6 @@ export function AppUtilityModals({ onPublishGist={onPublishGist} onInjectOpenSpecPrompt={onInjectOpenSpecPrompt} onOpenPlaybookExchange={onOpenMarketplace} - onOpenDocumentGraph={onOpenDocumentGraph} /> )} @@ -1835,7 +1832,6 @@ export interface AppModalsProps { getDocumentTaskCount: (filename: string) => Promise; onAutoRunRefresh: () => Promise; onOpenMarketplace?: () => void; - onOpenDocumentGraph?: () => void; tabSwitcherOpen: boolean; onCloseTabSwitcher: () => void; onTabSelect: (tabId: string) => void; @@ -2119,7 +2115,6 @@ export function AppModals(props: AppModalsProps) { getDocumentTaskCount, onAutoRunRefresh, onOpenMarketplace, - onOpenDocumentGraph, tabSwitcherOpen, onCloseTabSwitcher, onTabSelect, @@ -2413,7 +2408,6 @@ export function AppModals(props: AppModalsProps) { getDocumentTaskCount={getDocumentTaskCount} onAutoRunRefresh={onAutoRunRefresh} onOpenMarketplace={onOpenMarketplace} - onOpenDocumentGraph={onOpenDocumentGraph} tabSwitcherOpen={tabSwitcherOpen} onCloseTabSwitcher={onCloseTabSwitcher} onTabSelect={onTabSelect} diff --git a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx index c92d6b75..8fb9cb5b 100644 --- a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx +++ b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx @@ -1,14 +1,15 @@ /** * DocumentGraphView - Main container component for the markdown document graph visualization. * - * Rewritten to use react-force-graph-2d for a smoother, Obsidian-like experience. + * Uses a canvas-based MindMap component with deterministic layout. * * Features: - * - Force-directed graph with smooth physics simulation + * - Centered mind map layout with focus document in the middle + * - Left/right columns for alphabetized document links + * - External URLs clustered at the bottom * - Neighbor depth slider for focused ego-network views - * - Node size based on connection count * - Search highlighting - * - External links toggle + * - Keyboard navigation (arrow keys, Enter to recenter, O to open) * - Theme-aware styling throughout */ @@ -24,8 +25,6 @@ import { Sliders, Focus, AlertCircle, - Settings2, - RotateCcw, } from 'lucide-react'; import type { Theme } from '../../types'; import { useLayerStack } from '../../contexts/LayerStackContext'; @@ -33,7 +32,7 @@ import { MODAL_PRIORITIES } from '../../constants/modalPriorities'; import { Modal, ModalFooter } from '../ui/Modal'; import { useDebouncedCallback } from '../../hooks/utils'; import { buildGraphData, ProgressData, GraphNodeData } from './graphDataBuilder'; -import { ForceGraph, ForceGraphNode, ForceGraphLink, convertToForceGraphData, ForcePhysicsSettings, DEFAULT_PHYSICS } from './ForceGraph'; +import { MindMap, MindMapNode, MindMapLink, convertToMindMapData } from './MindMap'; import { NodeContextMenu } from './NodeContextMenu'; import { GraphLegend } from './GraphLegend'; @@ -60,14 +59,10 @@ export interface DocumentGraphViewProps { onDocumentOpen?: (filePath: string) => void; /** Optional callback when an external link node is double-clicked */ onExternalLinkOpen?: (url: string) => void; - /** Optional file path (relative to rootPath) to focus on when the graph opens */ - focusFilePath?: string; + /** Required file path (relative to rootPath) to focus on - the center of the mind map */ + focusFilePath: string; /** Callback when focus file is consumed (cleared after focusing) */ onFocusFileConsumed?: () => void; - /** Saved layout mode preference */ - savedLayoutMode?: 'force' | 'hierarchical'; - /** Callback to persist layout mode changes */ - onLayoutModeChange?: (mode: 'force' | 'hierarchical') => void; /** Default setting for showing external links (from settings) */ defaultShowExternalLinks?: boolean; /** Callback to persist external links toggle changes */ @@ -93,7 +88,7 @@ export function DocumentGraphView({ onDocumentOpen, onExternalLinkOpen, focusFilePath, - onFocusFileConsumed, + onFocusFileConsumed: _onFocusFileConsumed, defaultShowExternalLinks = false, onExternalLinksChange, defaultMaxNodes = DEFAULT_MAX_NODES, @@ -102,8 +97,8 @@ export function DocumentGraphView({ sshRemoteId, }: DocumentGraphViewProps) { // Graph data state - const [nodes, setNodes] = useState([]); - const [links, setLinks] = useState([]); + const [nodes, setNodes] = useState([]); + const [links, setLinks] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [error, setError] = useState(null); @@ -113,12 +108,10 @@ export function DocumentGraphView({ const [includeExternalLinks, setIncludeExternalLinks] = useState(defaultShowExternalLinks); const [neighborDepth, setNeighborDepth] = useState(defaultNeighborDepth); const [showDepthSlider, setShowDepthSlider] = useState(false); - const [showPhysicsPanel, setShowPhysicsPanel] = useState(false); - const [physics, setPhysics] = useState(DEFAULT_PHYSICS); // Selection state const [selectedNodeId, setSelectedNodeId] = useState(null); - const [selectedNode, setSelectedNode] = useState(null); + const [selectedNode, setSelectedNode] = useState(null); const [searchQuery, setSearchQuery] = useState(''); // Pagination state @@ -154,10 +147,9 @@ export function DocumentGraphView({ const hasLoadedDataRef = useRef(false); const prevRootPathRef = useRef(rootPath); - // Focus file tracking - const [activeFocusFile, setActiveFocusFile] = useState(null); - const focusFilePathRef = useRef(focusFilePath); - focusFilePathRef.current = focusFilePath; + // Focus file tracking - activeFocusFile is the current center of the mind map + // Initially set from props, but can change when user double-clicks a node + const [activeFocusFile, setActiveFocusFile] = useState(focusFilePath); /** * Handle escape - show confirmation modal @@ -245,26 +237,24 @@ export function DocumentGraphView({ setLoadedDocuments(graphData.loadedDocuments); setHasMore(graphData.hasMore); - // Convert to force graph format - const { nodes: forceNodes, links: forceLinks } = convertToForceGraphData( + // Convert to mind map format + const { nodes: mindMapNodes, links: mindMapLinks } = convertToMindMapData( graphData.nodes.map(n => ({ id: n.id, data: n.data })), graphData.edges.map(e => ({ source: e.source, target: e.target, type: e.type })) ); - setNodes(forceNodes); - setLinks(forceLinks); + setNodes(mindMapNodes); + setLinks(mindMapLinks); - // Set active focus file if provided - if (focusFilePathRef.current) { - setActiveFocusFile(focusFilePathRef.current); - } + // Set active focus file from the required focusFilePath prop + setActiveFocusFile(focusFilePath); } catch (err) { console.error('Failed to build graph data:', err); setError(err instanceof Error ? err.message : 'Failed to load document graph'); } finally { setLoading(false); } - }, [rootPath, includeExternalLinks, maxNodes, defaultMaxNodes, handleProgress]); + }, [rootPath, includeExternalLinks, maxNodes, defaultMaxNodes, handleProgress, focusFilePath]); /** * Debounced version of loadGraphData for settings changes @@ -334,18 +324,18 @@ export function DocumentGraphView({ /** * Handle node selection */ - const handleNodeSelect = useCallback((node: ForceGraphNode | null) => { + const handleNodeSelect = useCallback((node: MindMapNode | null) => { setSelectedNodeId(node?.id ?? null); setSelectedNode(node); setContextMenu(null); }, []); /** - * Handle node double-click - focus on node to expand its neighbors + * Handle node double-click - recenter on this document */ - const handleNodeDoubleClick = useCallback((node: ForceGraphNode) => { + const handleNodeDoubleClick = useCallback((node: MindMapNode) => { if (node.nodeType === 'document' && node.filePath) { - // Set this node as the focus to show its ego-network + // Set this node as the new center of the mind map setActiveFocusFile(node.filePath); // Ensure neighbor depth is set if it was 0 (show all) if (neighborDepth === 0) { @@ -360,7 +350,7 @@ export function DocumentGraphView({ /** * Handle node context menu */ - const handleNodeContextMenu = useCallback((node: ForceGraphNode, event: MouseEvent) => { + const handleNodeContextMenu = useCallback((node: MindMapNode, event: MouseEvent) => { event.preventDefault(); setContextMenu({ x: event.clientX, @@ -369,7 +359,7 @@ export function DocumentGraphView({ nodeData: node.nodeType === 'document' ? { nodeType: 'document', - title: node.title || '', + title: node.label || '', filePath: node.filePath || '', description: node.description, lineCount: node.lineCount || 0, @@ -379,7 +369,7 @@ export function DocumentGraphView({ : { nodeType: 'external', domain: node.domain || '', - linkCount: node.linkCount || 0, + linkCount: node.connectionCount || 0, urls: node.urls || [], }, }); @@ -404,14 +394,6 @@ export function DocumentGraphView({ setNeighborDepth(newDepth); onNeighborDepthChange?.(newDepth); }, [onNeighborDepthChange]); - - /** - * Handle focus file consumed - */ - const handleFocusConsumed = useCallback(() => { - onFocusFileConsumed?.(); - }, [onFocusFileConsumed]); - /** * Clear focus mode */ @@ -453,13 +435,13 @@ export function DocumentGraphView({ setLoadedDocuments(graphData.loadedDocuments); setHasMore(graphData.hasMore); - const { nodes: forceNodes, links: forceLinks } = convertToForceGraphData( + const { nodes: mindMapNodes, links: mindMapLinks } = convertToMindMapData( graphData.nodes.map(n => ({ id: n.id, data: n.data })), graphData.edges.map(e => ({ source: e.source, target: e.target, type: e.type })) ); - setNodes(forceNodes); - setLinks(forceLinks); + setNodes(mindMapNodes); + setLinks(mindMapLinks); } catch (err) { console.error('Failed to load more documents:', err); } finally { @@ -499,6 +481,15 @@ export function DocumentGraphView({ setContextMenu(null); }, [nodes, neighborDepth]); + /** + * Handle open file from mind map (clicking open icon or pressing O key) + */ + const handleOpenFile = useCallback((filePath: string) => { + if (onDocumentOpen) { + onDocumentOpen(filePath); + } + }, [onDocumentOpen]); + if (!isOpen) return null; // Show unavailable message for remote sessions - Document Graph cannot scan remote filesystems @@ -600,7 +591,7 @@ export function DocumentGraphView({ const query = searchQuery.toLowerCase(); if (n.nodeType === 'document') { return ( - (n.title?.toLowerCase().includes(query) ?? false) || + (n.label?.toLowerCase().includes(query) ?? false) || (n.filePath?.toLowerCase().includes(query) ?? false) ); } else { @@ -810,159 +801,6 @@ export function DocumentGraphView({ External - {/* Physics Settings */} -
- - - {showPhysicsPanel && ( -
-
- - Force Settings - - -
- - {/* Center Force */} -
-
- - Center Force - - - {physics.centerForce.toFixed(2)} - -
- setPhysics(prev => ({ ...prev, centerForce: parseFloat(e.target.value) }))} - className="w-full" - style={{ accentColor: theme.colors.accent }} - /> -

- Pulls nodes toward the center -

-
- - {/* Repel Force */} -
-
- - Repel Force - - - {physics.repelForce} - -
- setPhysics(prev => ({ ...prev, repelForce: parseInt(e.target.value, 10) }))} - className="w-full" - style={{ accentColor: theme.colors.accent }} - /> -

- Pushes nodes apart -

-
- - {/* Link Force */} -
-
- - Link Force - - - {physics.linkForce.toFixed(2)} - -
- setPhysics(prev => ({ ...prev, linkForce: parseFloat(e.target.value) }))} - className="w-full" - style={{ accentColor: theme.colors.accent }} - /> -

- Pulls connected nodes together -

-
- - {/* Link Distance */} -
-
- - Link Distance - - - {physics.linkDistance}px - -
- setPhysics(prev => ({ ...prev, linkDistance: parseInt(e.target.value, 10) }))} - className="w-full" - style={{ accentColor: theme.colors.accent }} - /> -

- Target distance between connected nodes -

-
-
- )} -
- {/* Refresh Button */} - )} - - -

- Layout algorithm used when opening the Document Graph. -

- - {/* Show External Links */}
diff --git a/src/renderer/hooks/settings/useSettings.ts b/src/renderer/hooks/settings/useSettings.ts index 0aca7244..4c3ee8fc 100644 --- a/src/renderer/hooks/settings/useSettings.ts +++ b/src/renderer/hooks/settings/useSettings.ts @@ -284,8 +284,6 @@ export interface UseSettingsReturn { setColorBlindMode: (value: boolean) => void; // Document Graph settings - documentGraphLayoutMode: 'force' | 'hierarchical'; - setDocumentGraphLayoutMode: (value: 'force' | 'hierarchical') => void; documentGraphShowExternalLinks: boolean; setDocumentGraphShowExternalLinks: (value: boolean) => void; documentGraphMaxNodes: number; @@ -412,7 +410,6 @@ export function useSettings(): UseSettingsReturn { const [colorBlindMode, setColorBlindModeState] = useState(false); // Document Graph settings - const [documentGraphLayoutMode, setDocumentGraphLayoutModeState] = useState<'force' | 'hierarchical'>('force'); const [documentGraphShowExternalLinks, setDocumentGraphShowExternalLinksState] = useState(false); // Default: false const [documentGraphMaxNodes, setDocumentGraphMaxNodesState] = useState(200); // Default: 200 @@ -1105,12 +1102,6 @@ export function useSettings(): UseSettingsReturn { window.maestro.settings.set('colorBlindMode', value); }, []); - // Document Graph layout mode - const setDocumentGraphLayoutMode = useCallback((value: 'force' | 'hierarchical') => { - setDocumentGraphLayoutModeState(value); - window.maestro.settings.set('documentGraphLayoutMode', value); - }, []); - // Document Graph show external links const setDocumentGraphShowExternalLinks = useCallback((value: boolean) => { setDocumentGraphShowExternalLinksState(value); @@ -1202,7 +1193,6 @@ export function useSettings(): UseSettingsReturn { const savedContextManagementSettings = await window.maestro.settings.get('contextManagementSettings'); const savedKeyboardMasteryStats = await window.maestro.settings.get('keyboardMasteryStats'); const savedColorBlindMode = await window.maestro.settings.get('colorBlindMode'); - const savedDocumentGraphLayoutMode = await window.maestro.settings.get('documentGraphLayoutMode'); const savedDocumentGraphShowExternalLinks = await window.maestro.settings.get('documentGraphShowExternalLinks'); const savedDocumentGraphMaxNodes = await window.maestro.settings.get('documentGraphMaxNodes'); const savedStatsCollectionEnabled = await window.maestro.settings.get('statsCollectionEnabled'); @@ -1431,12 +1421,6 @@ export function useSettings(): UseSettingsReturn { if (savedColorBlindMode !== undefined) setColorBlindModeState(savedColorBlindMode as boolean); // Document Graph settings - if (savedDocumentGraphLayoutMode !== undefined) { - const validModes = ['force', 'hierarchical']; - if (validModes.includes(savedDocumentGraphLayoutMode as string)) { - setDocumentGraphLayoutModeState(savedDocumentGraphLayoutMode as 'force' | 'hierarchical'); - } - } if (savedDocumentGraphShowExternalLinks !== undefined) { setDocumentGraphShowExternalLinksState(savedDocumentGraphShowExternalLinks as boolean); } @@ -1603,8 +1587,6 @@ export function useSettings(): UseSettingsReturn { getUnacknowledgedKeyboardMasteryLevel, colorBlindMode, setColorBlindMode, - documentGraphLayoutMode, - setDocumentGraphLayoutMode, documentGraphShowExternalLinks, setDocumentGraphShowExternalLinks, documentGraphMaxNodes, @@ -1734,8 +1716,6 @@ export function useSettings(): UseSettingsReturn { getUnacknowledgedKeyboardMasteryLevel, colorBlindMode, setColorBlindMode, - documentGraphLayoutMode, - setDocumentGraphLayoutMode, documentGraphShowExternalLinks, setDocumentGraphShowExternalLinks, documentGraphMaxNodes,