Merge pull request #127 from pedramamini/graph-visuals

graph visuals update
This commit is contained in:
Pedram Amini
2025-12-30 14:14:45 -06:00
committed by GitHub
16 changed files with 1517 additions and 1355 deletions

View File

@@ -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)
```

187
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
// 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(<GraphLegend {...defaultProps} defaultExpanded />);
// 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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
expect(screen.getByText('Markdown file (size = connections)')).toBeInTheDocument();
expect(screen.getByText('Card with title and description')).toBeInTheDocument();
});
it('external node has correct description', () => {
render(<GraphLegend {...defaultProps} defaultExpanded />);
expect(screen.getByText('External domain (smaller, dimmer)')).toBeInTheDocument();
expect(screen.getByText('Pill showing domain name')).toBeInTheDocument();
});
it('internal edge has correct description', () => {
render(<GraphLegend {...defaultProps} defaultExpanded />);
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(<GraphLegend {...defaultProps} defaultExpanded />);
expect(screen.getByText('Click to select, highlights connections')).toBeInTheDocument();
expect(screen.getByText('Click or navigate to select')).toBeInTheDocument();
});
it('connected edge has correct description', () => {

View File

@@ -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.
});

View File

@@ -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 --- */}
<DocumentGraphView
isOpen={isGraphViewOpen}
onClose={() => 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 && (
<DocumentGraphView
isOpen={isGraphViewOpen}
onClose={() => {
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 <AppModals /> 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);

View File

@@ -823,7 +823,6 @@ export interface AppUtilityModalsProps {
getDocumentTaskCount: (filename: string) => Promise<number>;
onAutoRunRefresh: () => Promise<void>;
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<number>;
onAutoRunRefresh: () => Promise<void>;
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}

View File

@@ -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<ForceGraphNode[]>([]);
const [links, setLinks] = useState<ForceGraphLink[]>([]);
const [nodes, setNodes] = useState<MindMapNode[]>([]);
const [links, setLinks] = useState<MindMapLink[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(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<ForcePhysicsSettings>(DEFAULT_PHYSICS);
// Selection state
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<ForceGraphNode | null>(null);
const [selectedNode, setSelectedNode] = useState<MindMapNode | null>(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<string | null>(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<string | null>(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
</button>
{/* Physics Settings */}
<div className="relative">
<button
onClick={() => setShowPhysicsPanel(!showPhysicsPanel)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded text-sm transition-colors"
style={{
backgroundColor: showPhysicsPanel ? `${theme.colors.accent}25` : `${theme.colors.accent}10`,
color: showPhysicsPanel ? theme.colors.accent : theme.colors.textDim,
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = `${theme.colors.accent}30`)}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = showPhysicsPanel
? `${theme.colors.accent}25`
: `${theme.colors.accent}10`)
}
title="Adjust graph physics settings"
>
<Settings2 className="w-4 h-4" />
Forces
</button>
{showPhysicsPanel && (
<div
className="absolute top-full right-0 mt-2 p-4 rounded-lg shadow-lg z-50"
style={{
backgroundColor: theme.colors.bgActivity,
border: `1px solid ${theme.colors.border}`,
minWidth: 260,
}}
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium" style={{ color: theme.colors.textMain }}>
Force Settings
</span>
<button
onClick={() => setPhysics(DEFAULT_PHYSICS)}
className="flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors"
style={{
backgroundColor: `${theme.colors.accent}15`,
color: theme.colors.textDim,
}}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = `${theme.colors.accent}25`)}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = `${theme.colors.accent}15`)}
title="Reset to default settings"
>
<RotateCcw className="w-3 h-3" />
Reset
</button>
</div>
{/* Center Force */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: theme.colors.textDim }}>
Center Force
</span>
<span className="text-xs font-mono" style={{ color: theme.colors.textMain }}>
{physics.centerForce.toFixed(2)}
</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={physics.centerForce}
onChange={(e) => setPhysics(prev => ({ ...prev, centerForce: parseFloat(e.target.value) }))}
className="w-full"
style={{ accentColor: theme.colors.accent }}
/>
<p className="text-xs mt-0.5" style={{ color: theme.colors.textDim, opacity: 0.7 }}>
Pulls nodes toward the center
</p>
</div>
{/* Repel Force */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: theme.colors.textDim }}>
Repel Force
</span>
<span className="text-xs font-mono" style={{ color: theme.colors.textMain }}>
{physics.repelForce}
</span>
</div>
<input
type="range"
min="0"
max="500"
step="10"
value={physics.repelForce}
onChange={(e) => setPhysics(prev => ({ ...prev, repelForce: parseInt(e.target.value, 10) }))}
className="w-full"
style={{ accentColor: theme.colors.accent }}
/>
<p className="text-xs mt-0.5" style={{ color: theme.colors.textDim, opacity: 0.7 }}>
Pushes nodes apart
</p>
</div>
{/* Link Force */}
<div className="mb-3">
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: theme.colors.textDim }}>
Link Force
</span>
<span className="text-xs font-mono" style={{ color: theme.colors.textMain }}>
{physics.linkForce.toFixed(2)}
</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.05"
value={physics.linkForce}
onChange={(e) => setPhysics(prev => ({ ...prev, linkForce: parseFloat(e.target.value) }))}
className="w-full"
style={{ accentColor: theme.colors.accent }}
/>
<p className="text-xs mt-0.5" style={{ color: theme.colors.textDim, opacity: 0.7 }}>
Pulls connected nodes together
</p>
</div>
{/* Link Distance */}
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: theme.colors.textDim }}>
Link Distance
</span>
<span className="text-xs font-mono" style={{ color: theme.colors.textMain }}>
{physics.linkDistance}px
</span>
</div>
<input
type="range"
min="10"
max="200"
step="5"
value={physics.linkDistance}
onChange={(e) => setPhysics(prev => ({ ...prev, linkDistance: parseInt(e.target.value, 10) }))}
className="w-full"
style={{ accentColor: theme.colors.accent }}
/>
<p className="text-xs mt-0.5" style={{ color: theme.colors.textDim, opacity: 0.7 }}>
Target distance between connected nodes
</p>
</div>
</div>
)}
</div>
{/* Refresh Button */}
<button
onClick={() => loadGraphData()}
@@ -1002,7 +840,7 @@ export function DocumentGraphView({
{selectedNode.nodeType === 'document' ? (
<>
<span style={{ color: theme.colors.accent, fontWeight: 500 }}>
{selectedNode.title}
{selectedNode.label}
</span>
<span style={{ color: theme.colors.textDim }}>
{selectedNode.filePath}
@@ -1103,24 +941,32 @@ export function DocumentGraphView({
<p className="text-lg">No markdown files found</p>
<p className="text-sm opacity-70">This directory doesn't contain any .md files</p>
</div>
) : (
<ForceGraph
) : activeFocusFile ? (
<MindMap
centerFilePath={activeFocusFile}
nodes={nodes}
links={links}
theme={theme}
width={graphDimensions.width}
height={graphDimensions.height}
maxDepth={neighborDepth || 2}
showExternalLinks={includeExternalLinks}
selectedNodeId={selectedNodeId}
onNodeSelect={handleNodeSelect}
onNodeDoubleClick={handleNodeDoubleClick}
onNodeContextMenu={handleNodeContextMenu}
onOpenFile={handleOpenFile}
searchQuery={searchQuery}
showExternalLinks={includeExternalLinks}
neighborDepth={neighborDepth}
focusFilePath={activeFocusFile}
onFocusConsumed={handleFocusConsumed}
physics={physics}
/>
) : (
<div
className="h-full flex flex-col items-center justify-center gap-2"
style={{ color: theme.colors.textDim }}
>
<Network className="w-12 h-12 opacity-30" />
<p className="text-lg">No focus document selected</p>
<p className="text-sm opacity-70">Select a document to view its connections</p>
</div>
)}
{/* Graph Legend */}
@@ -1192,7 +1038,7 @@ export function DocumentGraphView({
)}
</div>
<span style={{ opacity: 0.7 }}>
Click to select Double-click to open Drag to move Scroll to zoom Esc to close
Click to select Double-click to recenter O to open Arrow keys to navigate Esc to close
</span>
</div>
</div>
@@ -1227,13 +1073,10 @@ export function DocumentGraphView({
)}
{/* Click outside dropdowns to close them */}
{(showDepthSlider || showPhysicsPanel) && (
{showDepthSlider && (
<div
className="fixed inset-0 z-40"
onClick={() => {
setShowDepthSlider(false);
setShowPhysicsPanel(false);
}}
onClick={() => setShowDepthSlider(false)}
/>
)}
</div>

View File

@@ -1,687 +0,0 @@
/**
* ForceGraph - Force-directed graph visualization using react-force-graph-2d.
*
* This is a complete rewrite from React Flow to react-force-graph-2d for a smoother,
* more Obsidian-like graph experience. Features:
* - WebGL/Canvas-based rendering for better performance
* - Built-in physics simulation with d3-force
* - Smooth zoom, pan, and node dragging
* - Node size based on connection count
* - Neighbor depth filtering (ego-network mode)
* - Focus mode when opened from file preview
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ForceGraph2D, { ForceGraphMethods } from 'react-force-graph-2d';
import type { Theme } from '../../types';
import type { GraphNodeData, DocumentNodeData, ExternalLinkNodeData } from './graphDataBuilder';
/**
* Node type for the force graph
*/
export interface ForceGraphNode {
id: string;
nodeType: 'document' | 'external';
label: string;
// D3 force simulation fields
x?: number;
y?: number;
vx?: number;
vy?: number;
fx?: number;
fy?: number;
// Allow any additional properties for d3-force compatibility
[key: string]: unknown;
// Document-specific fields
title?: string;
filePath?: string;
description?: string;
lineCount?: number;
wordCount?: number;
size?: string;
brokenLinks?: string[];
isLargeFile?: boolean;
// External-specific fields
domain?: string;
linkCount?: number;
urls?: string[];
// Computed fields
neighbors?: Set<string>;
connectionCount?: number;
// Visual state
isHighlighted?: boolean;
isFocused?: boolean;
isNeighbor?: boolean;
depth?: number; // Distance from focus node (0 = focus, 1 = direct neighbor, etc.)
}
/**
* Link type for the force graph
*/
export interface ForceGraphLink {
source: string | ForceGraphNode;
target: string | ForceGraphNode;
type: 'internal' | 'external';
}
/**
* Physics settings for the force simulation (Obsidian-style)
*/
export interface ForcePhysicsSettings {
/** Center force strength - pulls nodes toward center (0-1, default 0.1) */
centerForce: number;
/** Repel force strength - nodes push each other away (0-500, default 100) */
repelForce: number;
/** Link force strength - connected nodes attract (0-1, default 0.4) */
linkForce: number;
/** Link distance - target distance between connected nodes (10-200, default 50) */
linkDistance: number;
}
/** Default physics settings tuned for Obsidian-like appearance */
export const DEFAULT_PHYSICS: ForcePhysicsSettings = {
centerForce: 0.25, // Strong center pull to keep graph compact (like Obsidian)
repelForce: 60, // Low repel for tighter clusters
linkForce: 0.7, // Strong link attraction to keep connected nodes close
linkDistance: 30, // Short links for close, tight clusters
};
/**
* Props for the ForceGraph component
*/
export interface ForceGraphProps {
/** Graph nodes from graphDataBuilder */
nodes: ForceGraphNode[];
/** Graph edges/links from graphDataBuilder */
links: ForceGraphLink[];
/** Current theme */
theme: Theme;
/** Width of the graph container */
width: number;
/** Height of the graph container */
height: number;
/** Currently selected node ID */
selectedNodeId: string | null;
/** Callback when a node is selected */
onNodeSelect: (node: ForceGraphNode | null) => void;
/** Callback when a node is double-clicked */
onNodeDoubleClick: (node: ForceGraphNode) => void;
/** Callback when a node is right-clicked */
onNodeContextMenu: (node: ForceGraphNode, event: MouseEvent) => void;
/** Search query for filtering/highlighting */
searchQuery: string;
/** Whether to show external link nodes */
showExternalLinks: boolean;
/** Neighbor depth for focus mode (1-5, or 0 for all) */
neighborDepth: number;
/** File path to focus on (ego-network mode) */
focusFilePath: string | null;
/** Callback when focus is consumed */
onFocusConsumed?: () => void;
/** Physics settings for the force simulation */
physics?: ForcePhysicsSettings;
}
/**
* Generate initial positions for nodes in a compact spiral pattern
* This prevents the "explosion" effect when the graph first loads
*/
function generateInitialPositions(count: number): Array<{ x: number; y: number }> {
const positions: Array<{ x: number; y: number }> = [];
const spacing = 25; // Tight initial spacing
// Use a spiral pattern for compact initial layout
for (let i = 0; i < count; i++) {
// Golden angle spiral for even distribution
const angle = i * 2.399963; // Golden angle in radians
const radius = spacing * Math.sqrt(i + 1);
positions.push({
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
});
}
return positions;
}
/**
* Convert graph builder data to force graph format
*/
export function convertToForceGraphData(
graphNodes: Array<{ id: string; data: GraphNodeData }>,
graphEdges: Array<{ source: string; target: string; type?: string }>
): { nodes: ForceGraphNode[]; links: ForceGraphLink[] } {
// Build neighbor map for connection counting
const neighborMap = new Map<string, Set<string>>();
graphEdges.forEach(edge => {
if (!neighborMap.has(edge.source)) {
neighborMap.set(edge.source, new Set());
}
if (!neighborMap.has(edge.target)) {
neighborMap.set(edge.target, new Set());
}
neighborMap.get(edge.source)!.add(edge.target);
neighborMap.get(edge.target)!.add(edge.source);
});
// Generate initial positions for compact starting layout
const initialPositions = generateInitialPositions(graphNodes.length);
// Sort nodes by connection count (most connected first, placed in center)
const sortedNodes = [...graphNodes].map((node) => ({
node,
connectionCount: (neighborMap.get(node.id) || new Set()).size,
})).sort((a, b) => b.connectionCount - a.connectionCount);
const nodes: ForceGraphNode[] = sortedNodes.map(({ node }, posIndex) => {
const neighbors = neighborMap.get(node.id) || new Set();
const connectionCount = neighbors.size;
// Assign position based on sorted order (most connected in center)
const pos = initialPositions[posIndex] || { x: 0, y: 0 };
if (node.data.nodeType === 'document') {
const docData = node.data as DocumentNodeData;
return {
id: node.id,
nodeType: 'document' as const,
label: docData.title,
title: docData.title,
filePath: docData.filePath,
description: docData.description,
lineCount: docData.lineCount,
wordCount: docData.wordCount,
size: docData.size,
brokenLinks: docData.brokenLinks,
isLargeFile: docData.isLargeFile,
neighbors,
connectionCount,
x: pos.x,
y: pos.y,
};
} else {
const extData = node.data as ExternalLinkNodeData;
return {
id: node.id,
nodeType: 'external' as const,
label: extData.domain,
domain: extData.domain,
linkCount: extData.linkCount,
urls: extData.urls,
neighbors,
connectionCount,
x: pos.x,
y: pos.y,
};
}
});
const links: ForceGraphLink[] = graphEdges.map(edge => ({
source: edge.source,
target: edge.target,
type: edge.type === 'external' ? 'external' : 'internal',
}));
return { nodes, links };
}
/**
* Filter nodes based on neighbor depth from a focus node
*/
function filterByNeighborDepth(
nodes: ForceGraphNode[],
links: ForceGraphLink[],
focusNodeId: string,
maxDepth: number
): { nodes: ForceGraphNode[]; links: ForceGraphLink[] } {
if (maxDepth <= 0) {
return { nodes, links };
}
// Build adjacency map
const adjacency = new Map<string, Set<string>>();
links.forEach(link => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
if (!adjacency.has(sourceId)) adjacency.set(sourceId, new Set());
if (!adjacency.has(targetId)) adjacency.set(targetId, new Set());
adjacency.get(sourceId)!.add(targetId);
adjacency.get(targetId)!.add(sourceId);
});
// BFS to find nodes within depth
const visited = new Map<string, number>(); // nodeId -> depth
const queue: Array<{ id: string; depth: number }> = [{ id: focusNodeId, depth: 0 }];
visited.set(focusNodeId, 0);
while (queue.length > 0) {
const { id, depth } = queue.shift()!;
if (depth >= maxDepth) continue;
const neighbors = adjacency.get(id) || new Set();
neighbors.forEach(neighborId => {
if (!visited.has(neighborId)) {
visited.set(neighborId, depth + 1);
queue.push({ id: neighborId, depth: depth + 1 });
}
});
}
// Filter nodes and add depth info
const filteredNodes = nodes
.filter(node => visited.has(node.id))
.map(node => ({
...node,
depth: visited.get(node.id),
isFocused: node.id === focusNodeId,
}));
// Filter links to only include those between visible nodes
const visibleNodeIds = new Set(filteredNodes.map(n => n.id));
const filteredLinks = links.filter(link => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
});
return { nodes: filteredNodes, links: filteredLinks };
}
/**
* ForceGraph component - renders the force-directed graph
*/
export function ForceGraph({
nodes: rawNodes,
links: rawLinks,
theme,
width,
height,
selectedNodeId,
onNodeSelect,
onNodeDoubleClick,
onNodeContextMenu,
searchQuery,
showExternalLinks,
neighborDepth,
focusFilePath,
onFocusConsumed,
physics = DEFAULT_PHYSICS,
}: ForceGraphProps) {
const graphRef = useRef<ForceGraphMethods<ForceGraphNode, ForceGraphLink>>();
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const [hasInitialized, setHasInitialized] = useState(false);
// Double-click detection
const lastClickRef = useRef<{ nodeId: string; time: number } | null>(null);
const DOUBLE_CLICK_THRESHOLD = 300; // ms
// Filter out external nodes if not showing them
const filteredByType = useMemo(() => {
const nodes = showExternalLinks
? rawNodes
: rawNodes.filter(n => n.nodeType === 'document');
const nodeIds = new Set(nodes.map(n => n.id));
const links = rawLinks.filter(l => {
const sourceId = typeof l.source === 'string' ? l.source : l.source.id;
const targetId = typeof l.target === 'string' ? l.target : l.target.id;
return nodeIds.has(sourceId) && nodeIds.has(targetId);
});
return { nodes, links };
}, [rawNodes, rawLinks, showExternalLinks]);
// Apply neighbor depth filtering if focus file is set
const { nodes, links } = useMemo(() => {
if (focusFilePath && neighborDepth > 0) {
const focusNodeId = `doc-${focusFilePath}`;
const focusNode = filteredByType.nodes.find(n => n.id === focusNodeId);
if (focusNode) {
return filterByNeighborDepth(
filteredByType.nodes,
filteredByType.links,
focusNodeId,
neighborDepth
);
}
}
return filteredByType;
}, [filteredByType, focusFilePath, neighborDepth]);
// Check if node matches search query
const nodeMatchesSearch = useCallback((node: ForceGraphNode): boolean => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
if (node.nodeType === 'document') {
return (
(node.title?.toLowerCase().includes(query) ?? false) ||
(node.filePath?.toLowerCase().includes(query) ?? false) ||
(node.description?.toLowerCase().includes(query) ?? false)
);
} else {
return (
(node.domain?.toLowerCase().includes(query) ?? false) ||
(node.urls?.some(url => url.toLowerCase().includes(query)) ?? false)
);
}
}, [searchQuery]);
// Get node color based on state
const getNodeColor = useCallback((node: ForceGraphNode): string => {
const isSearchActive = searchQuery.trim().length > 0;
const matchesSearch = nodeMatchesSearch(node);
// Dimmed if search is active and doesn't match
if (isSearchActive && !matchesSearch) {
return theme.colors.textDim + '40'; // Very transparent
}
// Highlighted states
if (node.id === selectedNodeId) {
return theme.colors.accent;
}
if (node.id === hoveredNodeId) {
return theme.colors.accent + 'CC';
}
if (node.isFocused) {
return theme.colors.accent;
}
// Check if neighbor of selected/hovered
const activeNodeId = hoveredNodeId || selectedNodeId;
if (activeNodeId) {
const activeNode = nodes.find(n => n.id === activeNodeId);
if (activeNode?.neighbors?.has(node.id)) {
return node.nodeType === 'document'
? theme.colors.accent + '99'
: theme.colors.textDim + '99';
}
}
// Default colors by type
if (node.nodeType === 'document') {
// Color intensity based on depth from focus
if (node.depth !== undefined && node.depth > 0) {
const opacity = Math.max(0.4, 1 - (node.depth - 1) * 0.2);
return theme.colors.accent + Math.round(opacity * 255).toString(16).padStart(2, '0');
}
return theme.colors.accent + 'BB';
} else {
return theme.colors.textDim + '88';
}
}, [theme, selectedNodeId, hoveredNodeId, searchQuery, nodeMatchesSearch, nodes]);
// Get node size based on connection count
const getNodeSize = useCallback((node: ForceGraphNode): number => {
const baseSize = node.nodeType === 'document' ? 8 : 5;
const connectionBonus = Math.min((node.connectionCount || 0) * 0.5, 8);
// Make focused node larger
if (node.isFocused) {
return baseSize + connectionBonus + 4;
}
// Slightly larger if selected or hovered
if (node.id === selectedNodeId || node.id === hoveredNodeId) {
return baseSize + connectionBonus + 2;
}
return baseSize + connectionBonus;
}, [selectedNodeId, hoveredNodeId]);
// Get link color based on state
const getLinkColor = useCallback((link: ForceGraphLink): string => {
const sourceId = typeof link.source === 'string' ? link.source : link.source?.id;
const targetId = typeof link.target === 'string' ? link.target : link.target?.id;
const activeNodeId = hoveredNodeId || selectedNodeId;
// Highlight links connected to active node
if (activeNodeId && (sourceId === activeNodeId || targetId === activeNodeId)) {
return theme.colors.accent + 'CC';
}
// External links are dimmer (check the type property we set)
if (link.type === 'external') {
return theme.colors.textDim + '44';
}
return theme.colors.textDim + '88';
}, [theme, selectedNodeId, hoveredNodeId]);
// Get link width based on state
const getLinkWidth = useCallback((link: ForceGraphLink): number => {
const sourceId = typeof link.source === 'string' ? link.source : link.source?.id;
const targetId = typeof link.target === 'string' ? link.target : link.target?.id;
const activeNodeId = hoveredNodeId || selectedNodeId;
if (activeNodeId && (sourceId === activeNodeId || targetId === activeNodeId)) {
return 2.5;
}
return link.type === 'external' ? 1 : 1.5;
}, [selectedNodeId, hoveredNodeId]);
// Draw node with label
const drawNode = useCallback((
node: ForceGraphNode,
ctx: CanvasRenderingContext2D,
globalScale: number
) => {
const size = getNodeSize(node);
const x = node.x ?? 0;
const y = node.y ?? 0;
const color = getNodeColor(node);
// Draw node circle
ctx.beginPath();
ctx.arc(x, y, size, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
// Draw border for selected/focused nodes
if (node.id === selectedNodeId || node.isFocused) {
ctx.strokeStyle = theme.colors.accent;
ctx.lineWidth = 2 / globalScale;
ctx.stroke();
}
// Draw label if zoomed in enough or node is active
const showLabel = globalScale > 0.8 ||
node.id === selectedNodeId ||
node.id === hoveredNodeId ||
node.isFocused;
if (showLabel) {
const label = node.label;
const fontSize = Math.max(10 / globalScale, 3);
ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
// Draw label background for readability
const metrics = ctx.measureText(label);
const labelHeight = fontSize * 1.2;
const padding = 2 / globalScale;
ctx.fillStyle = theme.colors.bgActivity + 'DD';
ctx.fillRect(
x - metrics.width / 2 - padding,
y + size + 2 / globalScale,
metrics.width + padding * 2,
labelHeight + padding
);
// Draw label text
ctx.fillStyle = theme.colors.textMain;
ctx.fillText(label, x, y + size + 3 / globalScale);
}
}, [theme, selectedNodeId, hoveredNodeId, getNodeSize, getNodeColor]);
// Handle node click (with double-click detection)
const handleNodeClick = useCallback((node: ForceGraphNode) => {
const now = Date.now();
const lastClick = lastClickRef.current;
// Check for double-click
if (lastClick && lastClick.nodeId === node.id && (now - lastClick.time) < DOUBLE_CLICK_THRESHOLD) {
// Double-click detected - expand neighbors
onNodeDoubleClick(node);
lastClickRef.current = null; // Reset
} else {
// Single click - select node
onNodeSelect(node);
lastClickRef.current = { nodeId: node.id, time: now };
}
}, [onNodeSelect, onNodeDoubleClick]);
// Handle node hover
const handleNodeHover = useCallback((node: ForceGraphNode | null) => {
setHoveredNodeId(node?.id ?? null);
}, []);
// Handle node right-click
const handleNodeRightClick = useCallback((node: ForceGraphNode, event: MouseEvent) => {
event.preventDefault();
onNodeContextMenu(node, event);
}, [onNodeContextMenu]);
// Handle background click
const handleBackgroundClick = useCallback(() => {
onNodeSelect(null);
}, [onNodeSelect]);
// Center on focus node after initial load
useEffect(() => {
if (!hasInitialized && nodes.length > 0 && graphRef.current) {
setHasInitialized(true);
// If we have a focus file, center on it
if (focusFilePath) {
const focusNodeId = `doc-${focusFilePath}`;
const focusNode = nodes.find(n => n.id === focusNodeId);
if (focusNode) {
// Wait for physics to settle a bit
setTimeout(() => {
graphRef.current?.centerAt(focusNode.x ?? 0, focusNode.y ?? 0, 500);
graphRef.current?.zoom(1.5, 500);
onFocusConsumed?.();
}, 500);
} else {
// Focus file not found, just zoom to fit
setTimeout(() => {
graphRef.current?.zoomToFit(400, 50);
onFocusConsumed?.();
}, 500);
}
} else {
// No focus file, zoom to fit all
setTimeout(() => {
graphRef.current?.zoomToFit(400, 50);
}, 500);
}
}
}, [hasInitialized, nodes, focusFilePath, onFocusConsumed]);
// Re-center when focus file changes
useEffect(() => {
if (focusFilePath && hasInitialized && graphRef.current) {
const focusNodeId = `doc-${focusFilePath}`;
const focusNode = nodes.find(n => n.id === focusNodeId);
if (focusNode && focusNode.x !== undefined && focusNode.y !== undefined) {
graphRef.current.centerAt(focusNode.x, focusNode.y, 500);
graphRef.current.zoom(1.5, 500);
}
}
}, [focusFilePath, hasInitialized, nodes]);
// Configure physics based on settings
useEffect(() => {
if (graphRef.current) {
const fg = graphRef.current;
// Configure forces using Obsidian-style settings
// Charge force controls node repulsion (negative = repel)
fg.d3Force('charge')?.strength(-physics.repelForce);
// Link force controls attraction between connected nodes
const linkForce = fg.d3Force('link');
if (linkForce) {
linkForce.distance(physics.linkDistance);
linkForce.strength(physics.linkForce);
}
// Center force pulls nodes toward center
fg.d3Force('center')?.strength(physics.centerForce);
// Reheat simulation when physics change
fg.d3ReheatSimulation();
}
}, [physics.centerForce, physics.repelForce, physics.linkForce, physics.linkDistance, nodes.length]);
return (
<ForceGraph2D
ref={graphRef}
graphData={{ nodes, links }}
width={width}
height={height}
backgroundColor={theme.colors.bgMain}
// Node rendering
nodeCanvasObject={drawNode}
nodePointerAreaPaint={(node, color, ctx) => {
const size = getNodeSize(node as ForceGraphNode);
ctx.beginPath();
ctx.arc(node.x ?? 0, node.y ?? 0, size + 2, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
}}
// Link rendering - use custom canvas object for full control
linkCanvasObject={(link, ctx, globalScale) => {
const source = link.source as ForceGraphNode;
const target = link.target as ForceGraphNode;
if (!source || !target || source.x === undefined || target.x === undefined) return;
const color = getLinkColor(link);
const width = getLinkWidth(link);
ctx.beginPath();
ctx.moveTo(source.x, source.y ?? 0);
ctx.lineTo(target.x, target.y ?? 0);
ctx.strokeStyle = color;
ctx.lineWidth = width / globalScale;
ctx.stroke();
}}
linkCanvasObjectMode={() => 'replace'}
linkDirectionalParticles={0}
// Interactions
onNodeClick={handleNodeClick}
onNodeHover={handleNodeHover}
onNodeRightClick={handleNodeRightClick}
onNodeDragEnd={(node) => {
// Fix node position after drag
node.fx = node.x;
node.fy = node.y;
}}
onBackgroundClick={handleBackgroundClick}
// Performance
cooldownTicks={100}
warmupTicks={50}
// Enable dragging
enableNodeDrag={true}
// Zoom limits
minZoom={0.1}
maxZoom={4}
/>
);
}
export default ForceGraph;

View File

@@ -1,18 +1,18 @@
/**
* GraphLegend - Component explaining node types, edge types, and colors in the Document Graph.
* GraphLegend - Component explaining node types, edge types, and keyboard shortcuts in the Mind Map.
*
* Displays a collapsible legend panel showing:
* - Document nodes: Markdown files with their distinctive styling
* - External link nodes: Aggregated external URLs by domain
* - Document nodes: Card-style nodes with title and description
* - External link nodes: Smaller pill-shaped nodes with domain
* - Internal edges: Solid lines connecting markdown documents
* - External edges: Dashed lines connecting to external domains
* - Selection state: Highlighted appearance when selected
* - Keyboard shortcuts: Arrow keys to navigate, Enter to recenter, O to open
*
* The legend is theme-aware and uses the same colors as the actual graph elements.
* The legend is theme-aware and uses the same colors as the actual mind map elements.
*/
import React, { useState, memo, useCallback } from 'react';
import { ChevronDown, ChevronUp, ArrowRight, AlertTriangle } from 'lucide-react';
import { ChevronDown, ChevronUp, AlertTriangle, ExternalLink } from 'lucide-react';
import type { Theme } from '../../types';
/**
@@ -45,16 +45,24 @@ interface EdgeLegendItem {
description: string;
}
/**
* Legend item for a keyboard shortcut
*/
interface KeyboardShortcutItem {
keys: string;
description: string;
}
const NODE_ITEMS: NodeLegendItem[] = [
{
type: 'document',
label: 'Document',
description: 'Markdown file (size = connections)',
description: 'Card with title and description',
},
{
type: 'external',
label: 'External Link',
description: 'External domain (smaller, dimmer)',
description: 'Pill showing domain name',
},
];
@@ -62,7 +70,7 @@ const EDGE_ITEMS: EdgeLegendItem[] = [
{
type: 'internal',
label: 'Internal Link',
description: 'Connection between markdown documents',
description: 'Connection between markdown files',
},
{
type: 'external',
@@ -71,8 +79,23 @@ const EDGE_ITEMS: EdgeLegendItem[] = [
},
];
const KEYBOARD_SHORTCUTS: KeyboardShortcutItem[] = [
{
keys: '↑ ↓ ← →',
description: 'Navigate between nodes',
},
{
keys: 'Enter',
description: 'Recenter on focused node',
},
{
keys: 'O',
description: 'Open file in preview',
},
];
/**
* Mini preview of a document node for the legend (force graph style)
* Mini preview of a document node card for the legend (mind map style)
*/
const DocumentNodePreview = memo(function DocumentNodePreview({
theme,
@@ -83,26 +106,62 @@ const DocumentNodePreview = memo(function DocumentNodePreview({
}) {
return (
<svg
width={24}
width={36}
height={24}
viewBox="0 0 24 24"
viewBox="0 0 36 24"
role="img"
aria-label={`Document node${selected ? ' (selected)' : ''}`}
aria-label={`Document node card${selected ? ' (selected)' : ''}`}
>
<circle
cx={12}
cy={12}
r={selected ? 9 : 8}
fill={`${theme.colors.accent}BB`}
stroke={selected ? theme.colors.accent : 'none'}
strokeWidth={selected ? 2 : 0}
{/* Card background */}
<rect
x={1}
y={1}
width={34}
height={22}
rx={4}
fill={selected ? `${theme.colors.accent}30` : theme.colors.bgActivity}
stroke={selected ? theme.colors.accent : theme.colors.border}
strokeWidth={selected ? 1.5 : 1}
/>
{/* Title line */}
<rect
x={5}
y={6}
width={18}
height={3}
rx={1}
fill={theme.colors.textMain}
/>
{/* Description line */}
<rect
x={5}
y={12}
width={22}
height={2}
rx={0.5}
fill={theme.colors.textDim}
opacity={0.6}
/>
{/* Description line 2 */}
<rect
x={5}
y={16}
width={14}
height={2}
rx={0.5}
fill={theme.colors.textDim}
opacity={0.4}
/>
{/* Open icon */}
<g transform="translate(28, 4)">
<ExternalLink size={6} style={{ color: theme.colors.textDim }} />
</g>
</svg>
);
});
/**
* Mini preview of an external link node for the legend (force graph style)
* Mini preview of an external link node pill for the legend (mind map style)
*/
const ExternalNodePreview = memo(function ExternalNodePreview({
theme,
@@ -113,19 +172,32 @@ const ExternalNodePreview = memo(function ExternalNodePreview({
}) {
return (
<svg
width={24}
height={24}
viewBox="0 0 24 24"
width={36}
height={18}
viewBox="0 0 36 18"
role="img"
aria-label={`External link node${selected ? ' (selected)' : ''}`}
aria-label={`External link node pill${selected ? ' (selected)' : ''}`}
>
<circle
cx={12}
cy={12}
r={selected ? 6 : 5}
fill={`${theme.colors.textDim}88`}
stroke={selected ? theme.colors.accent : 'none'}
strokeWidth={selected ? 2 : 0}
{/* Pill background */}
<rect
x={1}
y={2}
width={34}
height={14}
rx={7}
fill={theme.colors.bgMain}
stroke={selected ? theme.colors.accent : `${theme.colors.border}80`}
strokeWidth={1}
/>
{/* Domain text representation */}
<rect
x={8}
y={7}
width={20}
height={4}
rx={1}
fill={theme.colors.textDim}
opacity={0.8}
/>
</svg>
);
@@ -144,8 +216,9 @@ const EdgePreview = memo(function EdgePreview({
highlighted?: boolean;
}) {
const strokeColor = highlighted ? theme.colors.accent : theme.colors.textDim;
const strokeWidth = highlighted ? 2.5 : 1.5;
const strokeWidth = highlighted ? 2 : 1.5;
const isDashed = type === 'external';
const opacity = type === 'external' && !highlighted ? 0.5 : 0.8;
return (
<svg
@@ -155,30 +228,47 @@ const EdgePreview = memo(function EdgePreview({
role="img"
aria-label={`${type === 'internal' ? 'Internal' : 'External'} link edge${highlighted ? ' (highlighted)' : ''}`}
>
<line
x1={4}
y1={8}
x2={36}
y2={8}
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeDasharray={isDashed ? '4 4' : undefined}
/>
{/* Arrow head */}
{/* Curved bezier path to match mind map style */}
<path
d="M32 4 L38 8 L32 12"
d={isDashed ? 'M4,8 C15,8 25,8 36,8' : 'M4,8 C12,3 28,13 36,8'}
fill="none"
stroke={strokeColor}
strokeWidth={strokeWidth}
strokeDasharray={isDashed ? '4 3' : undefined}
opacity={opacity}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
});
/**
* GraphLegend component - Displays an explanation of graph elements
* Keyboard shortcut badge
*/
const KeyboardBadge = memo(function KeyboardBadge({
keys,
theme,
}: {
keys: string;
theme: Theme;
}) {
return (
<span
className="inline-flex items-center justify-center px-1.5 py-0.5 rounded text-[10px] font-mono"
style={{
backgroundColor: `${theme.colors.textDim}15`,
color: theme.colors.textMain,
border: `1px solid ${theme.colors.border}`,
minWidth: 24,
}}
>
{keys}
</span>
);
});
/**
* GraphLegend component - Displays an explanation of mind map elements
*/
export const GraphLegend = memo(function GraphLegend({
theme,
@@ -197,7 +287,7 @@ export const GraphLegend = memo(function GraphLegend({
style={{
backgroundColor: theme.colors.bgActivity,
border: `1px solid ${theme.colors.border}`,
maxWidth: 280,
maxWidth: 300,
zIndex: 10,
// Position at bottom center
bottom: 16,
@@ -205,7 +295,7 @@ export const GraphLegend = memo(function GraphLegend({
transform: 'translateX(-50%)',
}}
role="region"
aria-label="Graph legend"
aria-label="Mind map legend"
>
{/* Header - Always visible */}
<button
@@ -358,7 +448,7 @@ export const GraphLegend = memo(function GraphLegend({
className="text-xs block truncate"
style={{ color: theme.colors.textDim, opacity: 0.8 }}
>
Click to select, highlights connections
Click or navigate to select
</span>
</div>
</div>
@@ -398,7 +488,7 @@ export const GraphLegend = memo(function GraphLegend({
<div
className="flex items-center justify-center rounded"
style={{
width: 24,
width: 36,
height: 24,
backgroundColor: '#f59e0b20',
}}
@@ -425,7 +515,38 @@ export const GraphLegend = memo(function GraphLegend({
</div>
</div>
{/* Interaction hints */}
{/* Keyboard shortcuts */}
<div
className="pt-2"
style={{
borderTop: `1px solid ${theme.colors.border}`,
}}
>
<h4
className="text-xs font-medium mb-2"
style={{ color: theme.colors.textDim }}
>
Keyboard Shortcuts
</h4>
<div className="space-y-1.5">
{KEYBOARD_SHORTCUTS.map((shortcut) => (
<div
key={shortcut.keys}
className="flex items-center justify-between gap-2"
>
<KeyboardBadge keys={shortcut.keys} theme={theme} />
<span
className="text-xs flex-1 text-right"
style={{ color: theme.colors.textDim, opacity: 0.8 }}
>
{shortcut.description}
</span>
</div>
))}
</div>
</div>
{/* Mouse interaction hints */}
<div
className="pt-2 text-xs"
style={{
@@ -434,13 +555,21 @@ export const GraphLegend = memo(function GraphLegend({
opacity: 0.7,
}}
>
<div className="flex items-center gap-1">
<ArrowRight size={10} />
<span>Double-click to open</span>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">Click</span>
<span>Select node</span>
</div>
<div className="flex items-center gap-1">
<ArrowRight size={10} />
<span>Right-click for context menu</span>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">Double-click</span>
<span>Recenter view</span>
</div>
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">Right-click</span>
<span>Context menu</span>
</div>
<div className="flex items-center gap-2">
<span className="font-medium">Scroll</span>
<span>Zoom in/out</span>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useRef, useState, useCallback, useMemo, memo } from 'react';
import { createPortal } from 'react-dom';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronRight, ChevronDown, ChevronUp, Folder, RefreshCw, Check, Eye, EyeOff, GitGraph, Target, Copy, ExternalLink, Server } from 'lucide-react';
import { ChevronRight, ChevronDown, ChevronUp, Folder, RefreshCw, Check, Eye, EyeOff, Target, Copy, ExternalLink, Server } from 'lucide-react';
import type { Session, Theme, FocusArea } from '../types';
import type { FileNode } from '../types/fileTree';
import type { FileTreeChanges } from '../utils/fileExplorer';
@@ -62,7 +62,6 @@ interface FileExplorerPanelProps {
onShowFlash?: (message: string) => void;
showHiddenFiles: boolean;
setShowHiddenFiles: (value: boolean) => void;
onOpenGraphView?: () => void;
/** Callback to open graph view focused on a specific file (relative path to session.cwd) */
onFocusFileInGraph?: (relativePath: string) => void;
}
@@ -73,7 +72,7 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
filteredFileTree, selectedFileIndex, setSelectedFileIndex, activeFocus, activeRightTab,
previewFile, setActiveFocus, fileTreeFilterInputRef, toggleFolder, handleFileClick, expandAllFolders,
collapseAllFolders, updateSessionWorkingDirectory, refreshFileTree, setSessions, onAutoRefreshChange, onShowFlash,
showHiddenFiles, setShowHiddenFiles, onOpenGraphView, onFocusFileInGraph
showHiddenFiles, setShowHiddenFiles, onFocusFileInGraph
} = props;
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
@@ -480,16 +479,6 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
><bdi>{session.cwd}</bdi></span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{onOpenGraphView && (
<button
onClick={onOpenGraphView}
className="p-1 rounded hover:bg-white/10 transition-colors"
title="Document Graph"
style={{ color: theme.colors.textDim }}
>
<GitGraph className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => setShowHiddenFiles(!showHiddenFiles)}
className="p-1 rounded hover:bg-white/10 transition-colors"

View File

@@ -109,8 +109,6 @@ interface QuickActionsModalProps {
onInjectOpenSpecPrompt?: (prompt: string) => void;
// Playbook Exchange
onOpenPlaybookExchange?: () => void;
// Document Graph
onOpenDocumentGraph?: () => void;
}
export function QuickActionsModal(props: QuickActionsModalProps) {
@@ -133,8 +131,7 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
onCloseAllTabs, onCloseOtherTabs, onCloseTabsLeft, onCloseTabsRight,
isFilePreviewOpen, ghCliAvailable, onPublishGist,
onInjectOpenSpecPrompt,
onOpenPlaybookExchange,
onOpenDocumentGraph
onOpenPlaybookExchange
} = props;
const [search, setSearch] = useState('');
@@ -402,8 +399,6 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
{ id: 'goToFiles', label: 'Go to Files Tab', shortcut: shortcuts.goToFiles, action: () => { setRightPanelOpen(true); setActiveRightTab('files'); setQuickActionOpen(false); } },
{ id: 'goToHistory', label: 'Go to History Tab', shortcut: shortcuts.goToHistory, action: () => { setRightPanelOpen(true); setActiveRightTab('history'); setQuickActionOpen(false); } },
{ id: 'goToAutoRun', label: 'Go to Auto Run Tab', shortcut: shortcuts.goToAutoRun, action: () => { setRightPanelOpen(true); setActiveRightTab('autorun'); setQuickActionOpen(false); } },
// Document Graph
...(onOpenDocumentGraph ? [{ id: 'documentGraph', label: 'Document Graph', shortcut: shortcuts.documentGraph, subtext: 'Visualize markdown file relationships', action: () => { onOpenDocumentGraph(); setQuickActionOpen(false); } }] : []),
// Playbook Exchange - browse and import community playbooks
...(onOpenPlaybookExchange ? [{
id: 'openPlaybookExchange',

View File

@@ -99,9 +99,6 @@ interface RightPanelProps {
onFileClick?: (path: string) => void;
// Marketplace modal
onOpenMarketplace?: () => void;
// Graph view state
isGraphViewOpen?: boolean;
onOpenGraphView?: () => void;
/** Callback to open graph view focused on a specific file (relative path to session.cwd) */
onFocusFileInGraph?: (relativePath: string) => void;
}
@@ -125,12 +122,9 @@ export const RightPanel = memo(forwardRef<RightPanelHandle, RightPanelProps>(fun
onJumpToAgentSession, onResumeSession,
onOpenSessionAsTab, onOpenAboutModal, onFileClick,
onOpenMarketplace,
isGraphViewOpen: _isGraphViewOpen, onOpenGraphView, onFocusFileInGraph
onFocusFileInGraph
} = props;
// Mark as intentionally unused - passed through for future use
void _isGraphViewOpen;
const historyPanelRef = useRef<HistoryPanelHandle>(null);
const autoRunRef = useRef<AutoRunHandle>(null);
@@ -417,7 +411,6 @@ export const RightPanel = memo(forwardRef<RightPanelHandle, RightPanelProps>(fun
onShowFlash={onShowFlash}
showHiddenFiles={showHiddenFiles}
setShowHiddenFiles={setShowHiddenFiles}
onOpenGraphView={onOpenGraphView}
onFocusFileInGraph={onFocusFileInGraph}
/>
</div>

View File

@@ -238,8 +238,6 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
setDocumentGraphShowExternalLinks,
documentGraphMaxNodes,
setDocumentGraphMaxNodes,
documentGraphLayoutMode,
setDocumentGraphLayoutMode,
// Stats settings
statsCollectionEnabled,
setStatsCollectionEnabled,
@@ -1594,54 +1592,6 @@ export const SettingsModal = memo(function SettingsModal(props: SettingsModalPro
className="p-3 rounded border space-y-3"
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}
>
{/* Default Layout Mode */}
<div>
<label className="block text-xs opacity-60 mb-2">Default layout mode</label>
<div className="flex gap-2">
<button
onClick={() => setDocumentGraphLayoutMode('force')}
className={`flex-1 px-3 py-2 rounded text-xs font-medium transition-colors ${
documentGraphLayoutMode === 'force' ? 'ring-2' : ''
}`}
style={{
backgroundColor: documentGraphLayoutMode === 'force'
? theme.colors.accent + '20'
: theme.colors.bgActivity,
color: documentGraphLayoutMode === 'force'
? theme.colors.accent
: theme.colors.textDim,
borderColor: documentGraphLayoutMode === 'force'
? theme.colors.accent
: 'transparent',
}}
>
Force-Directed
</button>
<button
onClick={() => setDocumentGraphLayoutMode('hierarchical')}
className={`flex-1 px-3 py-2 rounded text-xs font-medium transition-colors ${
documentGraphLayoutMode === 'hierarchical' ? 'ring-2' : ''
}`}
style={{
backgroundColor: documentGraphLayoutMode === 'hierarchical'
? theme.colors.accent + '20'
: theme.colors.bgActivity,
color: documentGraphLayoutMode === 'hierarchical'
? theme.colors.accent
: theme.colors.textDim,
borderColor: documentGraphLayoutMode === 'hierarchical'
? theme.colors.accent
: 'transparent',
}}
>
Hierarchical
</button>
</div>
<p className="text-xs opacity-50 mt-1">
Layout algorithm used when opening the Document Graph.
</p>
</div>
{/* Show External Links */}
<div className="flex items-center justify-between">
<div>

View File

@@ -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,