@principal-ai/principal-view-react 0.16.32 → 0.16.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/subsystem/GraphLayoutCover.d.ts +19 -0
- package/dist/subsystem/GraphLayoutCover.d.ts.map +1 -0
- package/dist/subsystem/GraphLayoutCover.js +42 -0
- package/dist/subsystem/GraphLayoutCover.js.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts +18 -3
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +185 -34
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/SubsystemFileTree.d.ts +20 -0
- package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -0
- package/dist/subsystem/SubsystemFileTree.js +124 -0
- package/dist/subsystem/SubsystemFileTree.js.map +1 -0
- package/dist/subsystem/model.d.ts +7 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts +2 -0
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +21 -9
- package/dist/subsystem/nodes.js.map +1 -1
- package/package.json +2 -1
- package/src/index.ts +4 -0
- package/src/stories/SubsystemComponentGraph.stories.tsx +22 -14
- package/src/subsystem/GraphLayoutCover.tsx +69 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +246 -28
- package/src/subsystem/SubsystemFileTree.tsx +158 -0
- package/src/subsystem/model.ts +7 -0
- package/src/subsystem/nodes.tsx +26 -11
|
@@ -9,7 +9,7 @@ const meta = {
|
|
|
9
9
|
title: 'Subsystem/Component Graph',
|
|
10
10
|
component: SubsystemComponentGraph,
|
|
11
11
|
parameters: {
|
|
12
|
-
layout: '
|
|
12
|
+
layout: 'fullscreen',
|
|
13
13
|
},
|
|
14
14
|
tags: ['autodocs'],
|
|
15
15
|
decorators: [
|
|
@@ -70,13 +70,14 @@ function TwoNodeDemo({ showEdgeLabels = true }: { showEdgeLabels?: boolean }) {
|
|
|
70
70
|
const [selected, setSelected] = useState<string | null>(null);
|
|
71
71
|
const [selectedEdge, setSelectedEdge] = useState<SubsystemComponentEdge | null>(null);
|
|
72
72
|
return (
|
|
73
|
-
<div style={{ width: '100%',
|
|
73
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
74
74
|
<SubsystemComponentGraph
|
|
75
75
|
components={twoNodeComponents}
|
|
76
76
|
edges={twoNodeEdges}
|
|
77
77
|
onSelect={(id) => setSelected(id)}
|
|
78
78
|
onEdgeSelect={(e) => setSelectedEdge(e)}
|
|
79
79
|
showEdgeLabels={showEdgeLabels}
|
|
80
|
+
showFileBadges={false}
|
|
80
81
|
/>
|
|
81
82
|
<div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
|
|
82
83
|
{selectedEdge
|
|
@@ -134,8 +135,8 @@ function PlaygroundDemo({ leftCount, rightCount, showEdgeLabels }: { leftCount:
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
return (
|
|
137
|
-
<div style={{ width: '100%', height:
|
|
138
|
-
<SubsystemComponentGraph components={comps} edges={edgeList} showEdgeLabels={showEdgeLabels} />
|
|
138
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
139
|
+
<SubsystemComponentGraph components={comps} edges={edgeList} showEdgeLabels={showEdgeLabels} showFileBadges={false} />
|
|
139
140
|
</div>
|
|
140
141
|
);
|
|
141
142
|
}
|
|
@@ -194,12 +195,13 @@ function V2ReaderDemo() {
|
|
|
194
195
|
const [selected, setSelected] = useState<string | null>(null);
|
|
195
196
|
const [selectedEdge, setSelectedEdge] = useState<SubsystemComponentEdge | null>(null);
|
|
196
197
|
return (
|
|
197
|
-
<div style={{ width: '100%',
|
|
198
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
198
199
|
<SubsystemComponentGraph
|
|
199
200
|
components={v2ReaderComponents}
|
|
200
201
|
edges={v2ReaderEdges}
|
|
201
202
|
onSelect={(id) => setSelected(id)}
|
|
202
203
|
onEdgeSelect={(e) => setSelectedEdge(e)}
|
|
204
|
+
showFileBadges={false}
|
|
203
205
|
/>
|
|
204
206
|
<div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
|
|
205
207
|
{selectedEdge
|
|
@@ -221,7 +223,7 @@ export const V2ReaderSubsystem: Story = {
|
|
|
221
223
|
// ---------------------------------------------------------------------------
|
|
222
224
|
export const Empty: Story = {
|
|
223
225
|
render: () => (
|
|
224
|
-
<div style={{ width: '100%' }}>
|
|
226
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
225
227
|
<SubsystemComponentGraph components={[]} edges={[]} />
|
|
226
228
|
</div>
|
|
227
229
|
),
|
|
@@ -309,8 +311,8 @@ const investigateOnlyEdges = edges([
|
|
|
309
311
|
|
|
310
312
|
export const InvestigateOnly: Story = {
|
|
311
313
|
render: () => (
|
|
312
|
-
<div style={{ width: '100%', height:
|
|
313
|
-
<SubsystemComponentGraph components={investigateOnlyComponents} edges={investigateOnlyEdges} />
|
|
314
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
315
|
+
<SubsystemComponentGraph components={investigateOnlyComponents} edges={investigateOnlyEdges} showFileBadges={false} />
|
|
314
316
|
</div>
|
|
315
317
|
),
|
|
316
318
|
};
|
|
@@ -318,11 +320,12 @@ export const InvestigateOnly: Story = {
|
|
|
318
320
|
/** Same pipeline with a narrower `maxNodeWidth` (140) — long names wrap. */
|
|
319
321
|
export const NarrowMaxWidth: Story = {
|
|
320
322
|
render: () => (
|
|
321
|
-
<div style={{ width: '100%', height:
|
|
323
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
322
324
|
<SubsystemComponentGraph
|
|
323
325
|
components={investigateOnlyComponents}
|
|
324
326
|
edges={investigateOnlyEdges}
|
|
325
327
|
maxNodeWidth={140}
|
|
328
|
+
showFileBadges={false}
|
|
326
329
|
/>
|
|
327
330
|
</div>
|
|
328
331
|
),
|
|
@@ -386,11 +389,12 @@ const namingConventionComponents: SubsystemComponent[] = [
|
|
|
386
389
|
* at their word boundaries (camelCase, snake_case, PascalCase, acronyms). */
|
|
387
390
|
export const NamingConventions: Story = {
|
|
388
391
|
render: () => (
|
|
389
|
-
<div style={{ width: '100%', height:
|
|
392
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
390
393
|
<SubsystemComponentGraph
|
|
391
394
|
components={namingConventionComponents}
|
|
392
395
|
edges={[]}
|
|
393
396
|
maxNodeWidth={180}
|
|
397
|
+
showFileBadges={false}
|
|
394
398
|
/>
|
|
395
399
|
</div>
|
|
396
400
|
),
|
|
@@ -500,11 +504,12 @@ const detailKindComponents: SubsystemComponent[] = [
|
|
|
500
504
|
function DetailKindsDemo() {
|
|
501
505
|
const [selected, setSelected] = useState<string | null>(null);
|
|
502
506
|
return (
|
|
503
|
-
<div style={{ width: '100%',
|
|
507
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
504
508
|
<SubsystemComponentGraph
|
|
505
509
|
components={detailKindComponents}
|
|
506
510
|
edges={[]}
|
|
507
511
|
onSelect={(id) => setSelected(id)}
|
|
512
|
+
showFileBadges={false}
|
|
508
513
|
/>
|
|
509
514
|
<div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
|
|
510
515
|
{selected ? `selected: ${selected}` : 'click a component to see its GraphifyComponentDetail'}
|
|
@@ -575,11 +580,12 @@ const resolvedComponents: SubsystemComponent[] = [
|
|
|
575
580
|
function MinimalVsResolvedDemo({ resolved }: { resolved: boolean }) {
|
|
576
581
|
const [selected, setSelected] = useState<string | null>(null);
|
|
577
582
|
return (
|
|
578
|
-
<div style={{ width: '100%',
|
|
583
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
579
584
|
<SubsystemComponentGraph
|
|
580
585
|
components={resolved ? resolvedComponents : minimalComponents}
|
|
581
586
|
edges={sharedEdges}
|
|
582
587
|
onSelect={(id) => setSelected(id)}
|
|
588
|
+
showFileBadges={false}
|
|
583
589
|
/>
|
|
584
590
|
<div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
|
|
585
591
|
{resolved
|
|
@@ -651,11 +657,12 @@ const investigationEdges = edges([
|
|
|
651
657
|
function InvestigationDemo() {
|
|
652
658
|
const [selected, setSelected] = useState<string | null>(null);
|
|
653
659
|
return (
|
|
654
|
-
<div style={{ width: '100%',
|
|
660
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
655
661
|
<SubsystemComponentGraph
|
|
656
662
|
components={investigationComponents}
|
|
657
663
|
edges={investigationEdges}
|
|
658
664
|
onSelect={(id) => setSelected(id)}
|
|
665
|
+
showFileBadges={false}
|
|
659
666
|
/>
|
|
660
667
|
<div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
|
|
661
668
|
read-only investigation snapshot (grok 019fd2a9) — components analyzed, not edited
|
|
@@ -742,11 +749,12 @@ const mermaidEdges = edges([
|
|
|
742
749
|
function MermaidDemo() {
|
|
743
750
|
const [selected, setSelected] = useState<string | null>(null);
|
|
744
751
|
return (
|
|
745
|
-
<div style={{ width: '100%',
|
|
752
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
746
753
|
<SubsystemComponentGraph
|
|
747
754
|
components={mermaidComponents}
|
|
748
755
|
edges={mermaidEdges}
|
|
749
756
|
onSelect={(id) => setSelected(id)}
|
|
757
|
+
showFileBadges={false}
|
|
750
758
|
/>
|
|
751
759
|
<div style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 12, color: '#aaa' }}>
|
|
752
760
|
mermaid diagram rendering pipeline — lazy → render → zoom → modal
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphLayoutCover — opaque overlay that hides a graph's startup sequence
|
|
3
|
+
* (initial measurement render, layout swap, camera settle animation).
|
|
4
|
+
*
|
|
5
|
+
* Must sit inside a `position: relative` ancestor sized to the canvas; it
|
|
6
|
+
* covers exactly that box. Renders covered from mount and fades out
|
|
7
|
+
* `holdMs` after `revealed` flips true, then stays transparent until the
|
|
8
|
+
* next `revealed → false` reset.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useEffect, useState } from 'react';
|
|
12
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
13
|
+
|
|
14
|
+
export interface GraphLayoutCoverProps {
|
|
15
|
+
/** True once the final layout is ready and the camera has settled. */
|
|
16
|
+
revealed: boolean;
|
|
17
|
+
/** How long to stay fully opaque after `revealed`. @default 650 */
|
|
18
|
+
holdMs?: number;
|
|
19
|
+
/** Label shown while covered. @default 'Laying out graph…' */
|
|
20
|
+
label?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function GraphLayoutCover({
|
|
24
|
+
revealed,
|
|
25
|
+
holdMs = 650,
|
|
26
|
+
label = 'Laying out graph…',
|
|
27
|
+
}: GraphLayoutCoverProps) {
|
|
28
|
+
const { theme } = useTheme();
|
|
29
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
30
|
+
const [coverUp, setCoverUp] = useState(true);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (!revealed) {
|
|
34
|
+
setCoverUp(true);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const t = setTimeout(() => setCoverUp(false), holdMs);
|
|
38
|
+
return () => clearTimeout(t);
|
|
39
|
+
}, [revealed, holdMs]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
style={{
|
|
44
|
+
position: 'absolute',
|
|
45
|
+
inset: 0,
|
|
46
|
+
zIndex: 15,
|
|
47
|
+
background: theme.colors.background,
|
|
48
|
+
display: 'flex',
|
|
49
|
+
alignItems: 'center',
|
|
50
|
+
justifyContent: 'center',
|
|
51
|
+
opacity: coverUp ? 1 : 0,
|
|
52
|
+
transition: 'opacity 200ms ease',
|
|
53
|
+
pointerEvents: 'none',
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
{coverUp && (
|
|
57
|
+
<span
|
|
58
|
+
style={{
|
|
59
|
+
color: muted,
|
|
60
|
+
fontFamily: theme.fonts.monospace,
|
|
61
|
+
fontSize: theme.fontSizes[0],
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
{label}
|
|
65
|
+
</span>
|
|
66
|
+
)}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
applyNodeChanges,
|
|
33
33
|
} from '@xyflow/react';
|
|
34
34
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
35
|
+
import { X } from 'lucide-react';
|
|
35
36
|
import { IndustryMarkdownSlide } from 'themed-markdown';
|
|
36
37
|
import {
|
|
37
38
|
buildSubsystemGraph,
|
|
@@ -44,6 +45,8 @@ import {
|
|
|
44
45
|
} from './model';
|
|
45
46
|
import type { GraphifyComponentDetail } from '../graphify';
|
|
46
47
|
import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
|
|
48
|
+
import { SubsystemFileTree } from './SubsystemFileTree';
|
|
49
|
+
import { GraphLayoutCover } from './GraphLayoutCover';
|
|
47
50
|
|
|
48
51
|
const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
|
|
49
52
|
['imports', 'import statement (code-level dependency)', true],
|
|
@@ -75,6 +78,9 @@ export interface SubsystemComponentGraphProps {
|
|
|
75
78
|
maxNodeWidth?: number;
|
|
76
79
|
/** Show edge labels (mechanism names) on the graph. @default true */
|
|
77
80
|
showEdgeLabels?: boolean;
|
|
81
|
+
/** Show the click-to-open file chip under each component name.
|
|
82
|
+
* @default true */
|
|
83
|
+
showFileBadges?: boolean;
|
|
78
84
|
/** Subsystem title displayed in the sidebar. */
|
|
79
85
|
title?: string;
|
|
80
86
|
/** Markdown description rendered in the sidebar. */
|
|
@@ -86,11 +92,23 @@ export interface SubsystemComponentGraphProps {
|
|
|
86
92
|
/** Rendered in the sidebar under the description (e.g. selection inspector). */
|
|
87
93
|
sidebarAfterDescription?: ReactNode;
|
|
88
94
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
95
|
+
* Host-injected reader/renderer for the bottom file drawer, keyed by
|
|
96
|
+
* repo-root-relative path. Opening happens on node click (component with a
|
|
97
|
+
* `file`) and sidebar file-tree click. Keeps this package free of fs and
|
|
98
|
+
* code-view dependencies.
|
|
99
|
+
*/
|
|
100
|
+
renderFileViewer?: (file: string) => ReactNode;
|
|
101
|
+
/**
|
|
102
|
+
* Legacy component-keyed variant, kept for backward compatibility. When
|
|
103
|
+
* `renderFileViewer` is absent, drawer content resolves via the first
|
|
104
|
+
* component whose `file` matches the opened path.
|
|
92
105
|
*/
|
|
93
106
|
renderFileView?: (component: SubsystemComponent) => ReactNode;
|
|
107
|
+
/**
|
|
108
|
+
* Called when a file in the sidebar file tree is clicked (repo-root-relative
|
|
109
|
+
* path). The tree is derived from the components' `file` values.
|
|
110
|
+
*/
|
|
111
|
+
onFileSelect?: (file: string) => void;
|
|
94
112
|
}
|
|
95
113
|
|
|
96
114
|
const nodeTypes: NodeTypes = {
|
|
@@ -105,7 +123,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
105
123
|
measured: { w: number; h: number } | null;
|
|
106
124
|
}
|
|
107
125
|
|
|
108
|
-
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView }: InnerProps) {
|
|
126
|
+
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showFileBadges, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect }: InnerProps) {
|
|
109
127
|
const { theme } = useTheme();
|
|
110
128
|
const { fitView } = useReactFlow();
|
|
111
129
|
const viewport = useViewport();
|
|
@@ -115,10 +133,17 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
115
133
|
});
|
|
116
134
|
const [layoutReady, setLayoutReady] = useState(false);
|
|
117
135
|
const [selected, setSelected] = useState<SubsystemComponent | null>(null);
|
|
136
|
+
// File currently shown in the bottom drawer (repo-root-relative path).
|
|
137
|
+
const [openFile, setOpenFile] = useState<string | null>(null);
|
|
138
|
+
// Component the pointer is over (null on leave) → transient tree highlight.
|
|
139
|
+
const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
|
|
118
140
|
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
119
141
|
// closure over the effect deps) can toggle without a stale value.
|
|
120
142
|
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
121
143
|
selectedRef.current = selected;
|
|
144
|
+
// Ref mirror of `openFile` for the tree-click toggle.
|
|
145
|
+
const openFileRef = useRef<string | null>(null);
|
|
146
|
+
openFileRef.current = openFile;
|
|
122
147
|
|
|
123
148
|
// Pass 1: build with estimated widths so React Flow can measure the DOM.
|
|
124
149
|
// The pane stays hidden until Pass 2 completes.
|
|
@@ -193,6 +218,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
193
218
|
const comp = components.find((c) => c.id === id);
|
|
194
219
|
if (comp) {
|
|
195
220
|
// Clicking the already-selected node unselects it (toggle off).
|
|
221
|
+
// Selection is independent of the file drawer — nodes never open it.
|
|
196
222
|
if (selectedRef.current?.id === comp.id) {
|
|
197
223
|
setSelected(null);
|
|
198
224
|
setSelectedEdgeId(null);
|
|
@@ -205,10 +231,12 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
205
231
|
};
|
|
206
232
|
SUBSYSTEM_CALLBACKS.onEdgeSelect = selectEdge;
|
|
207
233
|
SUBSYSTEM_CALLBACKS.maxNodeWidth = maxNodeWidth;
|
|
234
|
+
SUBSYSTEM_CALLBACKS.onHover = setHoveredComponentId;
|
|
208
235
|
return () => {
|
|
209
236
|
SUBSYSTEM_CALLBACKS.onSelect = undefined;
|
|
210
237
|
SUBSYSTEM_CALLBACKS.onEdgeSelect = undefined;
|
|
211
238
|
SUBSYSTEM_CALLBACKS.maxNodeWidth = undefined;
|
|
239
|
+
SUBSYSTEM_CALLBACKS.onHover = undefined;
|
|
212
240
|
};
|
|
213
241
|
}, [components, onSelect, maxNodeWidth, selectEdge]);
|
|
214
242
|
|
|
@@ -216,6 +244,24 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
216
244
|
const xyflowNodesBase = nodes as Node[];
|
|
217
245
|
const baseEdges = convertedEdges as Edge[];
|
|
218
246
|
|
|
247
|
+
// While a file is open in the drawer, tag each node with whether its
|
|
248
|
+
// component lives in that file — the node renderer spotlights matches and
|
|
249
|
+
// dims non-matches (mirrors the edge-dimming behavior on selection).
|
|
250
|
+
const dispNodes = useMemo(() => {
|
|
251
|
+
let ns = xyflowNodesBase.map((n) => ({
|
|
252
|
+
...n,
|
|
253
|
+
data: { ...(n.data as object), showFileBadges },
|
|
254
|
+
}));
|
|
255
|
+
if (!openFile) return ns;
|
|
256
|
+
return ns.map((n) => {
|
|
257
|
+
const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
258
|
+
return {
|
|
259
|
+
...n,
|
|
260
|
+
data: { ...(n.data as object), fileMatch: comp?.file === openFile },
|
|
261
|
+
};
|
|
262
|
+
});
|
|
263
|
+
}, [xyflowNodesBase, openFile, showFileBadges]);
|
|
264
|
+
|
|
219
265
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
220
266
|
const baseEdgesKey = useMemo(
|
|
221
267
|
() => convertedEdges.map((e) => e.id).sort().join(','),
|
|
@@ -248,7 +294,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
248
294
|
pendingMeasuredRef.current = true;
|
|
249
295
|
}
|
|
250
296
|
}
|
|
251
|
-
const result = applyNodeChanges(changes,
|
|
297
|
+
const result = applyNodeChanges(changes, dispNodes);
|
|
252
298
|
// After applying changes, check if we should trigger pass 2.
|
|
253
299
|
if (pendingMeasuredRef.current) {
|
|
254
300
|
// Use microtask so the state update from applyNodeChanges commits first.
|
|
@@ -256,7 +302,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
256
302
|
}
|
|
257
303
|
return result;
|
|
258
304
|
},
|
|
259
|
-
[
|
|
305
|
+
[dispNodes, triggerPass2],
|
|
260
306
|
);
|
|
261
307
|
const onEdgesChange = useCallback(
|
|
262
308
|
(_changes: EdgeChange[]) => dispEdges,
|
|
@@ -270,12 +316,36 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
270
316
|
return () => clearTimeout(t);
|
|
271
317
|
}, [baseNodesKey, baseEdgesKey, fitView]);
|
|
272
318
|
|
|
319
|
+
// When the file drawer opens/closes the canvas resizes — reframe once the
|
|
320
|
+
// height transition settles: onto the matching (spotlit) nodes while a file
|
|
321
|
+
// is open, back to the whole graph when it closes.
|
|
322
|
+
const prevOpenFileRef = useRef<string | null>(null);
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
const prev = prevOpenFileRef.current;
|
|
325
|
+
prevOpenFileRef.current = openFile;
|
|
326
|
+
if (!layoutReady || prev === openFile) return;
|
|
327
|
+
const t = setTimeout(() => {
|
|
328
|
+
if (openFile) {
|
|
329
|
+
const ids = xyflowNodesBase
|
|
330
|
+
.filter((n) => (n.data as { component?: SubsystemComponent } | undefined)?.component?.file === openFile)
|
|
331
|
+
.map((n) => ({ id: n.id }));
|
|
332
|
+
if (ids.length > 0) {
|
|
333
|
+
fitView({ nodes: ids, padding: 0.35, minZoom: 0.05, maxZoom: 2, duration: 250 });
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
fitView({ padding: 0.1, includeHiddenNodes: false, minZoom: 0.05, maxZoom: 2, duration: 250 });
|
|
337
|
+
}
|
|
338
|
+
}, 230);
|
|
339
|
+
return () => clearTimeout(t);
|
|
340
|
+
}, [openFile, layoutReady, xyflowNodesBase, fitView]);
|
|
341
|
+
|
|
273
342
|
const onNodeClick: NodeMouseHandler = useCallback(
|
|
274
343
|
(_e, node: Node) => {
|
|
275
344
|
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
276
345
|
setSelectedEdgeId(null);
|
|
277
346
|
if (node.type === 'subsystem-component' && comp) {
|
|
278
347
|
// Clicking the already-selected node unselects it (toggle off).
|
|
348
|
+
// Selection is independent of the file drawer — nodes never open it.
|
|
279
349
|
if (selected?.id === comp.id) {
|
|
280
350
|
setSelected(null);
|
|
281
351
|
return;
|
|
@@ -300,6 +370,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
300
370
|
setSelectedEdgeId(null);
|
|
301
371
|
}, []);
|
|
302
372
|
|
|
373
|
+
// Sidebar file-tree click → toggle in the bottom drawer (+ host hook only
|
|
374
|
+
// when opening, so hosts don't see close events).
|
|
375
|
+
const onTreeSelectFile = useCallback(
|
|
376
|
+
(file: string) => {
|
|
377
|
+
if (openFileRef.current === file) {
|
|
378
|
+
setOpenFile(null);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
setOpenFile(file);
|
|
382
|
+
onFileSelect?.(file);
|
|
383
|
+
},
|
|
384
|
+
[onFileSelect],
|
|
385
|
+
);
|
|
386
|
+
|
|
303
387
|
// Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
|
|
304
388
|
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
305
389
|
// the actual edge path (not node-center approximations).
|
|
@@ -322,23 +406,60 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
322
406
|
|
|
323
407
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
324
408
|
|
|
409
|
+
// Unique source files across components → sidebar file tree.
|
|
410
|
+
const files = useMemo(
|
|
411
|
+
() => Array.from(new Set(components.map((c) => c.file).filter(Boolean))).sort(),
|
|
412
|
+
[components],
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
// The hovered node's file (null when not hovering / file-less component).
|
|
416
|
+
const hoveredFile = useMemo(() => {
|
|
417
|
+
if (!hoveredComponentId) return null;
|
|
418
|
+
return components.find((c) => c.id === hoveredComponentId)?.file ?? null;
|
|
419
|
+
}, [components, hoveredComponentId]);
|
|
420
|
+
|
|
421
|
+
// Drawer content renderer: prefer the path-keyed viewer; fall back to the
|
|
422
|
+
// legacy component-keyed one via a file → first-component lookup.
|
|
423
|
+
const fileViewer = useMemo(() => {
|
|
424
|
+
if (renderFileViewer) return renderFileViewer;
|
|
425
|
+
if (renderFileView) {
|
|
426
|
+
const byFile = new Map(
|
|
427
|
+
components.filter((c) => c.file).map((c) => [c.file, c] as const),
|
|
428
|
+
);
|
|
429
|
+
return (file: string) => {
|
|
430
|
+
const comp = byFile.get(file);
|
|
431
|
+
return comp ? renderFileView(comp) : null;
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
return undefined;
|
|
435
|
+
}, [renderFileViewer, renderFileView, components]);
|
|
436
|
+
|
|
325
437
|
return (
|
|
326
|
-
<div style={{
|
|
327
|
-
{/* Sidebar
|
|
328
|
-
{(title || description || usedMechanisms.size > 0 || sidebarExtra || sidebarAfterDescription) && (
|
|
438
|
+
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
|
|
439
|
+
{/* Sidebar: scrollable title/description/legend on top, file tree pinned to the bottom half */}
|
|
440
|
+
{(title || description || usedMechanisms.size > 0 || sidebarExtra || sidebarAfterDescription || files.length > 0) && (
|
|
329
441
|
<div
|
|
330
442
|
style={{
|
|
331
|
-
width:
|
|
332
|
-
minWidth:
|
|
443
|
+
width: 340,
|
|
444
|
+
minWidth: 340,
|
|
333
445
|
borderRight: `1px solid ${theme.colors.border}`,
|
|
334
446
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
335
|
-
padding: '16px',
|
|
336
|
-
overflowY: 'auto',
|
|
337
447
|
display: 'flex',
|
|
338
448
|
flexDirection: 'column',
|
|
339
|
-
|
|
449
|
+
overflow: 'hidden',
|
|
340
450
|
}}
|
|
341
451
|
>
|
|
452
|
+
<div
|
|
453
|
+
style={{
|
|
454
|
+
flex: 1,
|
|
455
|
+
minHeight: 0,
|
|
456
|
+
overflowY: 'auto',
|
|
457
|
+
padding: '16px',
|
|
458
|
+
display: 'flex',
|
|
459
|
+
flexDirection: 'column',
|
|
460
|
+
gap: 12,
|
|
461
|
+
}}
|
|
462
|
+
>
|
|
342
463
|
{sidebarExtra}
|
|
343
464
|
{title && (
|
|
344
465
|
<h2
|
|
@@ -392,6 +513,15 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
392
513
|
))}
|
|
393
514
|
</div>
|
|
394
515
|
)}
|
|
516
|
+
</div>
|
|
517
|
+
{files.length > 0 && (
|
|
518
|
+
<SubsystemFileTree
|
|
519
|
+
files={files}
|
|
520
|
+
selectedFile={selected?.file ?? openFile}
|
|
521
|
+
hoveredFile={hoveredFile}
|
|
522
|
+
onSelectFile={onTreeSelectFile}
|
|
523
|
+
/>
|
|
524
|
+
)}
|
|
395
525
|
</div>
|
|
396
526
|
)}
|
|
397
527
|
|
|
@@ -449,7 +579,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
449
579
|
)}
|
|
450
580
|
<ReactFlow
|
|
451
581
|
key={`${baseNodesKey}-${baseEdgesKey}`}
|
|
452
|
-
nodes={
|
|
582
|
+
nodes={dispNodes}
|
|
453
583
|
edges={dispEdges}
|
|
454
584
|
nodeTypes={nodeTypes}
|
|
455
585
|
edgeTypes={edgeTypes}
|
|
@@ -482,7 +612,12 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
482
612
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
|
483
613
|
<Controls showZoom showFitView showInteractive />
|
|
484
614
|
</ReactFlow>
|
|
485
|
-
{selected && <ComponentDetail component={selected}
|
|
615
|
+
{selected && <ComponentDetail component={selected} />}
|
|
616
|
+
<FileDrawer file={openFile} onClose={() => setOpenFile(null)}>
|
|
617
|
+
{fileViewer && openFile ? fileViewer(openFile) : null}
|
|
618
|
+
</FileDrawer>
|
|
619
|
+
{/* Startup cover — hides measurement, layout swap, and camera settle. */}
|
|
620
|
+
<GraphLayoutCover revealed={layoutReady} />
|
|
486
621
|
{canvasOverlay}
|
|
487
622
|
</div>
|
|
488
623
|
</div>
|
|
@@ -490,8 +625,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
490
625
|
}
|
|
491
626
|
|
|
492
627
|
/** Detail panel for the selected component — the verifiable identity + sources
|
|
493
|
-
* moved off the canvas so nodes stay minimal.
|
|
494
|
-
|
|
628
|
+
* moved off the canvas so nodes stay minimal. File content lives in the
|
|
629
|
+
* bottom FileDrawer, not here. */
|
|
630
|
+
function ComponentDetail({ component }: { component: SubsystemComponent }) {
|
|
495
631
|
const { theme } = useTheme();
|
|
496
632
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
497
633
|
const color = KIND_COLOR[component.kind] ?? '#888';
|
|
@@ -556,19 +692,101 @@ function ComponentDetail({ component, renderFileView }: { component: SubsystemCo
|
|
|
556
692
|
</div>
|
|
557
693
|
))}
|
|
558
694
|
{component.detail && <GraphifyDetailSections detail={component.detail} />}
|
|
559
|
-
|
|
560
|
-
|
|
695
|
+
</div>
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Bottom panel that slides up from the bottom of the graph area to show a
|
|
700
|
+
* file's contents — opened by node clicks and sidebar file-tree clicks
|
|
701
|
+
* alike. Sits in normal flow (canvas shrinks while open, nothing covered)
|
|
702
|
+
* and animates via height; stays mounted so open/close animates. */
|
|
703
|
+
function FileDrawer({
|
|
704
|
+
file,
|
|
705
|
+
onClose,
|
|
706
|
+
children,
|
|
707
|
+
}: {
|
|
708
|
+
file: string | null;
|
|
709
|
+
onClose: () => void;
|
|
710
|
+
children?: ReactNode;
|
|
711
|
+
}) {
|
|
712
|
+
const { theme } = useTheme();
|
|
713
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
714
|
+
const open = file !== null;
|
|
715
|
+
|
|
716
|
+
useEffect(() => {
|
|
717
|
+
if (!open) return;
|
|
718
|
+
const onKey = (e: KeyboardEvent) => {
|
|
719
|
+
if (e.key === 'Escape') onClose();
|
|
720
|
+
};
|
|
721
|
+
window.addEventListener('keydown', onKey);
|
|
722
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
723
|
+
}, [open, onClose]);
|
|
724
|
+
|
|
725
|
+
return (
|
|
726
|
+
<div
|
|
727
|
+
style={{
|
|
728
|
+
position: 'relative',
|
|
729
|
+
// Above the absolute edge-label overlay (zIndex 5), which spans the
|
|
730
|
+
// whole graph-area container including this panel's slice.
|
|
731
|
+
zIndex: 6,
|
|
732
|
+
flexShrink: 0,
|
|
733
|
+
height: open ? '45%' : 0,
|
|
734
|
+
minHeight: 0,
|
|
735
|
+
overflow: 'hidden',
|
|
736
|
+
display: 'flex',
|
|
737
|
+
flexDirection: 'column',
|
|
738
|
+
background: theme.colors.background,
|
|
739
|
+
borderTop: open ? `1px solid ${theme.colors.border}` : 'none',
|
|
740
|
+
transition: 'height 200ms ease',
|
|
741
|
+
}}
|
|
742
|
+
>
|
|
743
|
+
<div
|
|
744
|
+
style={{
|
|
745
|
+
display: 'flex',
|
|
746
|
+
alignItems: 'center',
|
|
747
|
+
gap: 8,
|
|
748
|
+
padding: '6px 10px',
|
|
749
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
750
|
+
flexShrink: 0,
|
|
751
|
+
}}
|
|
752
|
+
>
|
|
753
|
+
<span
|
|
754
|
+
title={file ?? undefined}
|
|
561
755
|
style={{
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
overflow: '
|
|
565
|
-
|
|
566
|
-
|
|
756
|
+
flex: 1,
|
|
757
|
+
minWidth: 0,
|
|
758
|
+
overflow: 'hidden',
|
|
759
|
+
textOverflow: 'ellipsis',
|
|
760
|
+
whiteSpace: 'nowrap',
|
|
761
|
+
fontFamily: theme.fonts.monospace,
|
|
762
|
+
fontSize: theme.fontSizes[0],
|
|
763
|
+
color: muted,
|
|
567
764
|
}}
|
|
568
765
|
>
|
|
569
|
-
{
|
|
570
|
-
</
|
|
571
|
-
|
|
766
|
+
{file}
|
|
767
|
+
</span>
|
|
768
|
+
<button
|
|
769
|
+
type="button"
|
|
770
|
+
onClick={onClose}
|
|
771
|
+
aria-label="Close file"
|
|
772
|
+
style={{
|
|
773
|
+
display: 'inline-flex',
|
|
774
|
+
alignItems: 'center',
|
|
775
|
+
justifyContent: 'center',
|
|
776
|
+
width: 22,
|
|
777
|
+
height: 22,
|
|
778
|
+
padding: 0,
|
|
779
|
+
border: 'none',
|
|
780
|
+
borderRadius: 4,
|
|
781
|
+
background: 'transparent',
|
|
782
|
+
color: theme.colors.text,
|
|
783
|
+
cursor: 'pointer',
|
|
784
|
+
}}
|
|
785
|
+
>
|
|
786
|
+
<X size={14} />
|
|
787
|
+
</button>
|
|
788
|
+
</div>
|
|
789
|
+
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>{children}</div>
|
|
572
790
|
</div>
|
|
573
791
|
);
|
|
574
792
|
}
|