mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
Merge pull request #127 from pedramamini/graph-visuals
graph visuals update
This commit is contained in:
@@ -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
187
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
1141
src/renderer/components/DocumentGraph/MindMap.tsx
Normal file
1141
src/renderer/components/DocumentGraph/MindMap.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user