@principal-ai/principal-view-react 0.16.31 → 0.16.33
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 +25 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +169 -26
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/SubsystemFileTree.d.ts +25 -0
- package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -0
- package/dist/subsystem/SubsystemFileTree.js +121 -0
- package/dist/subsystem/SubsystemFileTree.js.map +1 -0
- package/dist/subsystem/model.d.ts +4 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +10 -3
- package/dist/subsystem/nodes.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/stories/SubsystemComponentGraph.stories.tsx +12 -12
- package/src/subsystem/GraphLayoutCover.tsx +69 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +241 -13
- package/src/subsystem/SubsystemFileTree.tsx +197 -0
- package/src/subsystem/model.ts +4 -0
- package/src/subsystem/nodes.tsx +9 -3
|
@@ -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],
|
|
@@ -79,6 +82,30 @@ export interface SubsystemComponentGraphProps {
|
|
|
79
82
|
title?: string;
|
|
80
83
|
/** Markdown description rendered in the sidebar. */
|
|
81
84
|
description?: string;
|
|
85
|
+
/** Rendered over the graph canvas only (not the title/legend sidebar). */
|
|
86
|
+
canvasOverlay?: ReactNode;
|
|
87
|
+
/** Extra controls at the top of the title/legend sidebar. */
|
|
88
|
+
sidebarExtra?: ReactNode;
|
|
89
|
+
/** Rendered in the sidebar under the description (e.g. selection inspector). */
|
|
90
|
+
sidebarAfterDescription?: ReactNode;
|
|
91
|
+
/**
|
|
92
|
+
* Host-injected reader/renderer for the bottom file drawer, keyed by
|
|
93
|
+
* repo-root-relative path. Opening happens on node click (component with a
|
|
94
|
+
* `file`) and sidebar file-tree click. Keeps this package free of fs and
|
|
95
|
+
* code-view dependencies.
|
|
96
|
+
*/
|
|
97
|
+
renderFileViewer?: (file: string) => ReactNode;
|
|
98
|
+
/**
|
|
99
|
+
* Legacy component-keyed variant, kept for backward compatibility. When
|
|
100
|
+
* `renderFileViewer` is absent, drawer content resolves via the first
|
|
101
|
+
* component whose `file` matches the opened path.
|
|
102
|
+
*/
|
|
103
|
+
renderFileView?: (component: SubsystemComponent) => ReactNode;
|
|
104
|
+
/**
|
|
105
|
+
* Called when a file in the sidebar file tree is clicked (repo-root-relative
|
|
106
|
+
* path). The tree is derived from the components' `file` values.
|
|
107
|
+
*/
|
|
108
|
+
onFileSelect?: (file: string) => void;
|
|
82
109
|
}
|
|
83
110
|
|
|
84
111
|
const nodeTypes: NodeTypes = {
|
|
@@ -93,7 +120,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
93
120
|
measured: { w: number; h: number } | null;
|
|
94
121
|
}
|
|
95
122
|
|
|
96
|
-
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description }: InnerProps) {
|
|
123
|
+
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect }: InnerProps) {
|
|
97
124
|
const { theme } = useTheme();
|
|
98
125
|
const { fitView } = useReactFlow();
|
|
99
126
|
const viewport = useViewport();
|
|
@@ -103,10 +130,15 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
103
130
|
});
|
|
104
131
|
const [layoutReady, setLayoutReady] = useState(false);
|
|
105
132
|
const [selected, setSelected] = useState<SubsystemComponent | null>(null);
|
|
133
|
+
// File currently shown in the bottom drawer (repo-root-relative path).
|
|
134
|
+
const [openFile, setOpenFile] = useState<string | null>(null);
|
|
106
135
|
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
107
136
|
// closure over the effect deps) can toggle without a stale value.
|
|
108
137
|
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
109
138
|
selectedRef.current = selected;
|
|
139
|
+
// Ref mirror of `openFile` for the tree-click toggle.
|
|
140
|
+
const openFileRef = useRef<string | null>(null);
|
|
141
|
+
openFileRef.current = openFile;
|
|
110
142
|
|
|
111
143
|
// Pass 1: build with estimated widths so React Flow can measure the DOM.
|
|
112
144
|
// The pane stays hidden until Pass 2 completes.
|
|
@@ -181,6 +213,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
181
213
|
const comp = components.find((c) => c.id === id);
|
|
182
214
|
if (comp) {
|
|
183
215
|
// Clicking the already-selected node unselects it (toggle off).
|
|
216
|
+
// Selection is independent of the file drawer — nodes never open it.
|
|
184
217
|
if (selectedRef.current?.id === comp.id) {
|
|
185
218
|
setSelected(null);
|
|
186
219
|
setSelectedEdgeId(null);
|
|
@@ -204,6 +237,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
204
237
|
const xyflowNodesBase = nodes as Node[];
|
|
205
238
|
const baseEdges = convertedEdges as Edge[];
|
|
206
239
|
|
|
240
|
+
// While a file is open in the drawer, tag each node with whether its
|
|
241
|
+
// component lives in that file — the node renderer spotlights matches and
|
|
242
|
+
// dims non-matches (mirrors the edge-dimming behavior on selection).
|
|
243
|
+
const dispNodes = useMemo(() => {
|
|
244
|
+
if (!openFile) return xyflowNodesBase;
|
|
245
|
+
return xyflowNodesBase.map((n) => {
|
|
246
|
+
const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
247
|
+
return {
|
|
248
|
+
...n,
|
|
249
|
+
data: { ...(n.data as object), fileMatch: comp?.file === openFile },
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
}, [xyflowNodesBase, openFile]);
|
|
253
|
+
|
|
207
254
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
208
255
|
const baseEdgesKey = useMemo(
|
|
209
256
|
() => convertedEdges.map((e) => e.id).sort().join(','),
|
|
@@ -236,7 +283,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
236
283
|
pendingMeasuredRef.current = true;
|
|
237
284
|
}
|
|
238
285
|
}
|
|
239
|
-
const result = applyNodeChanges(changes,
|
|
286
|
+
const result = applyNodeChanges(changes, dispNodes);
|
|
240
287
|
// After applying changes, check if we should trigger pass 2.
|
|
241
288
|
if (pendingMeasuredRef.current) {
|
|
242
289
|
// Use microtask so the state update from applyNodeChanges commits first.
|
|
@@ -244,7 +291,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
244
291
|
}
|
|
245
292
|
return result;
|
|
246
293
|
},
|
|
247
|
-
[
|
|
294
|
+
[dispNodes, triggerPass2],
|
|
248
295
|
);
|
|
249
296
|
const onEdgesChange = useCallback(
|
|
250
297
|
(_changes: EdgeChange[]) => dispEdges,
|
|
@@ -258,12 +305,36 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
258
305
|
return () => clearTimeout(t);
|
|
259
306
|
}, [baseNodesKey, baseEdgesKey, fitView]);
|
|
260
307
|
|
|
308
|
+
// When the file drawer opens/closes the canvas resizes — reframe once the
|
|
309
|
+
// height transition settles: onto the matching (spotlit) nodes while a file
|
|
310
|
+
// is open, back to the whole graph when it closes.
|
|
311
|
+
const prevOpenFileRef = useRef<string | null>(null);
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
const prev = prevOpenFileRef.current;
|
|
314
|
+
prevOpenFileRef.current = openFile;
|
|
315
|
+
if (!layoutReady || prev === openFile) return;
|
|
316
|
+
const t = setTimeout(() => {
|
|
317
|
+
if (openFile) {
|
|
318
|
+
const ids = xyflowNodesBase
|
|
319
|
+
.filter((n) => (n.data as { component?: SubsystemComponent } | undefined)?.component?.file === openFile)
|
|
320
|
+
.map((n) => ({ id: n.id }));
|
|
321
|
+
if (ids.length > 0) {
|
|
322
|
+
fitView({ nodes: ids, padding: 0.35, minZoom: 0.05, maxZoom: 2, duration: 250 });
|
|
323
|
+
}
|
|
324
|
+
} else {
|
|
325
|
+
fitView({ padding: 0.1, includeHiddenNodes: false, minZoom: 0.05, maxZoom: 2, duration: 250 });
|
|
326
|
+
}
|
|
327
|
+
}, 230);
|
|
328
|
+
return () => clearTimeout(t);
|
|
329
|
+
}, [openFile, layoutReady, xyflowNodesBase, fitView]);
|
|
330
|
+
|
|
261
331
|
const onNodeClick: NodeMouseHandler = useCallback(
|
|
262
332
|
(_e, node: Node) => {
|
|
263
333
|
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
264
334
|
setSelectedEdgeId(null);
|
|
265
335
|
if (node.type === 'subsystem-component' && comp) {
|
|
266
336
|
// Clicking the already-selected node unselects it (toggle off).
|
|
337
|
+
// Selection is independent of the file drawer — nodes never open it.
|
|
267
338
|
if (selected?.id === comp.id) {
|
|
268
339
|
setSelected(null);
|
|
269
340
|
return;
|
|
@@ -288,6 +359,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
288
359
|
setSelectedEdgeId(null);
|
|
289
360
|
}, []);
|
|
290
361
|
|
|
362
|
+
// Sidebar file-tree click → toggle in the bottom drawer (+ host hook only
|
|
363
|
+
// when opening, so hosts don't see close events).
|
|
364
|
+
const onTreeSelectFile = useCallback(
|
|
365
|
+
(file: string) => {
|
|
366
|
+
if (openFileRef.current === file) {
|
|
367
|
+
setOpenFile(null);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
setOpenFile(file);
|
|
371
|
+
onFileSelect?.(file);
|
|
372
|
+
},
|
|
373
|
+
[onFileSelect],
|
|
374
|
+
);
|
|
375
|
+
|
|
291
376
|
// Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
|
|
292
377
|
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
293
378
|
// the actual edge path (not node-center approximations).
|
|
@@ -310,23 +395,55 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
310
395
|
|
|
311
396
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
312
397
|
|
|
398
|
+
// Unique source files across components → sidebar file tree.
|
|
399
|
+
const files = useMemo(
|
|
400
|
+
() => Array.from(new Set(components.map((c) => c.file).filter(Boolean))).sort(),
|
|
401
|
+
[components],
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
// Drawer content renderer: prefer the path-keyed viewer; fall back to the
|
|
405
|
+
// legacy component-keyed one via a file → first-component lookup.
|
|
406
|
+
const fileViewer = useMemo(() => {
|
|
407
|
+
if (renderFileViewer) return renderFileViewer;
|
|
408
|
+
if (renderFileView) {
|
|
409
|
+
const byFile = new Map(
|
|
410
|
+
components.filter((c) => c.file).map((c) => [c.file, c] as const),
|
|
411
|
+
);
|
|
412
|
+
return (file: string) => {
|
|
413
|
+
const comp = byFile.get(file);
|
|
414
|
+
return comp ? renderFileView(comp) : null;
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
return undefined;
|
|
418
|
+
}, [renderFileViewer, renderFileView, components]);
|
|
419
|
+
|
|
313
420
|
return (
|
|
314
|
-
<div style={{
|
|
315
|
-
{/* Sidebar
|
|
316
|
-
{(title || description || usedMechanisms.size > 0) && (
|
|
421
|
+
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
|
|
422
|
+
{/* Sidebar: scrollable title/description/legend on top, file tree pinned to the bottom half */}
|
|
423
|
+
{(title || description || usedMechanisms.size > 0 || sidebarExtra || sidebarAfterDescription || files.length > 0) && (
|
|
317
424
|
<div
|
|
318
425
|
style={{
|
|
319
|
-
width:
|
|
320
|
-
minWidth:
|
|
426
|
+
width: 340,
|
|
427
|
+
minWidth: 340,
|
|
321
428
|
borderRight: `1px solid ${theme.colors.border}`,
|
|
322
429
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
323
|
-
padding: '16px',
|
|
324
|
-
overflowY: 'auto',
|
|
325
430
|
display: 'flex',
|
|
326
431
|
flexDirection: 'column',
|
|
327
|
-
|
|
432
|
+
overflow: 'hidden',
|
|
328
433
|
}}
|
|
329
434
|
>
|
|
435
|
+
<div
|
|
436
|
+
style={{
|
|
437
|
+
flex: 1,
|
|
438
|
+
minHeight: 0,
|
|
439
|
+
overflowY: 'auto',
|
|
440
|
+
padding: '16px',
|
|
441
|
+
display: 'flex',
|
|
442
|
+
flexDirection: 'column',
|
|
443
|
+
gap: 12,
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
{sidebarExtra}
|
|
330
447
|
{title && (
|
|
331
448
|
<h2
|
|
332
449
|
style={{
|
|
@@ -355,6 +472,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
355
472
|
/>
|
|
356
473
|
</div>
|
|
357
474
|
)}
|
|
475
|
+
{sidebarAfterDescription}
|
|
358
476
|
{usedMechanisms.size > 0 && (
|
|
359
477
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
360
478
|
<span style={{ fontSize: theme.fontSizes[0] * 0.8, fontFamily: theme.fonts.monospace, textTransform: 'uppercase', color: muted, fontWeight: 600 }}>
|
|
@@ -378,6 +496,14 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
378
496
|
))}
|
|
379
497
|
</div>
|
|
380
498
|
)}
|
|
499
|
+
</div>
|
|
500
|
+
{files.length > 0 && (
|
|
501
|
+
<SubsystemFileTree
|
|
502
|
+
files={files}
|
|
503
|
+
selectedFile={selected?.file ?? openFile}
|
|
504
|
+
onSelectFile={onTreeSelectFile}
|
|
505
|
+
/>
|
|
506
|
+
)}
|
|
381
507
|
</div>
|
|
382
508
|
)}
|
|
383
509
|
|
|
@@ -435,7 +561,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
435
561
|
)}
|
|
436
562
|
<ReactFlow
|
|
437
563
|
key={`${baseNodesKey}-${baseEdgesKey}`}
|
|
438
|
-
nodes={
|
|
564
|
+
nodes={dispNodes}
|
|
439
565
|
edges={dispEdges}
|
|
440
566
|
nodeTypes={nodeTypes}
|
|
441
567
|
edgeTypes={edgeTypes}
|
|
@@ -469,13 +595,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
469
595
|
<Controls showZoom showFitView showInteractive />
|
|
470
596
|
</ReactFlow>
|
|
471
597
|
{selected && <ComponentDetail component={selected} />}
|
|
598
|
+
<FileDrawer file={openFile} onClose={() => setOpenFile(null)}>
|
|
599
|
+
{fileViewer && openFile ? fileViewer(openFile) : null}
|
|
600
|
+
</FileDrawer>
|
|
601
|
+
{/* Startup cover — hides measurement, layout swap, and camera settle. */}
|
|
602
|
+
<GraphLayoutCover revealed={layoutReady} />
|
|
603
|
+
{canvasOverlay}
|
|
472
604
|
</div>
|
|
473
605
|
</div>
|
|
474
606
|
);
|
|
475
607
|
}
|
|
476
608
|
|
|
477
609
|
/** Detail panel for the selected component — the verifiable identity + sources
|
|
478
|
-
* moved off the canvas so nodes stay minimal.
|
|
610
|
+
* moved off the canvas so nodes stay minimal. File content lives in the
|
|
611
|
+
* bottom FileDrawer, not here. */
|
|
479
612
|
function ComponentDetail({ component }: { component: SubsystemComponent }) {
|
|
480
613
|
const { theme } = useTheme();
|
|
481
614
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
@@ -545,6 +678,101 @@ function ComponentDetail({ component }: { component: SubsystemComponent }) {
|
|
|
545
678
|
);
|
|
546
679
|
}
|
|
547
680
|
|
|
681
|
+
/** Bottom panel that slides up from the bottom of the graph area to show a
|
|
682
|
+
* file's contents — opened by node clicks and sidebar file-tree clicks
|
|
683
|
+
* alike. Sits in normal flow (canvas shrinks while open, nothing covered)
|
|
684
|
+
* and animates via height; stays mounted so open/close animates. */
|
|
685
|
+
function FileDrawer({
|
|
686
|
+
file,
|
|
687
|
+
onClose,
|
|
688
|
+
children,
|
|
689
|
+
}: {
|
|
690
|
+
file: string | null;
|
|
691
|
+
onClose: () => void;
|
|
692
|
+
children?: ReactNode;
|
|
693
|
+
}) {
|
|
694
|
+
const { theme } = useTheme();
|
|
695
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
696
|
+
const open = file !== null;
|
|
697
|
+
|
|
698
|
+
useEffect(() => {
|
|
699
|
+
if (!open) return;
|
|
700
|
+
const onKey = (e: KeyboardEvent) => {
|
|
701
|
+
if (e.key === 'Escape') onClose();
|
|
702
|
+
};
|
|
703
|
+
window.addEventListener('keydown', onKey);
|
|
704
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
705
|
+
}, [open, onClose]);
|
|
706
|
+
|
|
707
|
+
return (
|
|
708
|
+
<div
|
|
709
|
+
style={{
|
|
710
|
+
position: 'relative',
|
|
711
|
+
// Above the absolute edge-label overlay (zIndex 5), which spans the
|
|
712
|
+
// whole graph-area container including this panel's slice.
|
|
713
|
+
zIndex: 6,
|
|
714
|
+
flexShrink: 0,
|
|
715
|
+
height: open ? '45%' : 0,
|
|
716
|
+
minHeight: 0,
|
|
717
|
+
overflow: 'hidden',
|
|
718
|
+
display: 'flex',
|
|
719
|
+
flexDirection: 'column',
|
|
720
|
+
background: theme.colors.background,
|
|
721
|
+
borderTop: open ? `1px solid ${theme.colors.border}` : 'none',
|
|
722
|
+
transition: 'height 200ms ease',
|
|
723
|
+
}}
|
|
724
|
+
>
|
|
725
|
+
<div
|
|
726
|
+
style={{
|
|
727
|
+
display: 'flex',
|
|
728
|
+
alignItems: 'center',
|
|
729
|
+
gap: 8,
|
|
730
|
+
padding: '6px 10px',
|
|
731
|
+
borderBottom: `1px solid ${theme.colors.border}`,
|
|
732
|
+
flexShrink: 0,
|
|
733
|
+
}}
|
|
734
|
+
>
|
|
735
|
+
<span
|
|
736
|
+
title={file ?? undefined}
|
|
737
|
+
style={{
|
|
738
|
+
flex: 1,
|
|
739
|
+
minWidth: 0,
|
|
740
|
+
overflow: 'hidden',
|
|
741
|
+
textOverflow: 'ellipsis',
|
|
742
|
+
whiteSpace: 'nowrap',
|
|
743
|
+
fontFamily: theme.fonts.monospace,
|
|
744
|
+
fontSize: theme.fontSizes[0],
|
|
745
|
+
color: muted,
|
|
746
|
+
}}
|
|
747
|
+
>
|
|
748
|
+
{file}
|
|
749
|
+
</span>
|
|
750
|
+
<button
|
|
751
|
+
type="button"
|
|
752
|
+
onClick={onClose}
|
|
753
|
+
aria-label="Close file"
|
|
754
|
+
style={{
|
|
755
|
+
display: 'inline-flex',
|
|
756
|
+
alignItems: 'center',
|
|
757
|
+
justifyContent: 'center',
|
|
758
|
+
width: 22,
|
|
759
|
+
height: 22,
|
|
760
|
+
padding: 0,
|
|
761
|
+
border: 'none',
|
|
762
|
+
borderRadius: 4,
|
|
763
|
+
background: 'transparent',
|
|
764
|
+
color: theme.colors.text,
|
|
765
|
+
cursor: 'pointer',
|
|
766
|
+
}}
|
|
767
|
+
>
|
|
768
|
+
<X size={14} />
|
|
769
|
+
</button>
|
|
770
|
+
</div>
|
|
771
|
+
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>{children}</div>
|
|
772
|
+
</div>
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
548
776
|
/** Renders the graphify drill-down payload for a component. */
|
|
549
777
|
function GraphifyDetailSections({ detail }: { detail: GraphifyComponentDetail }) {
|
|
550
778
|
const { theme } = useTheme();
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubsystemFileTree — read-only folder/file tree derived from the unique
|
|
3
|
+
* source files of a subsystem's components. Rendered in the bottom half of
|
|
4
|
+
* the SubsystemComponentGraph sidebar as a navigation aid.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useMemo, useState } from 'react';
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
import { ChevronDown, ChevronRight, FileText, Folder, FolderOpen } from 'lucide-react';
|
|
10
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
11
|
+
|
|
12
|
+
interface TreeEntry {
|
|
13
|
+
name: string;
|
|
14
|
+
path: string;
|
|
15
|
+
isFile: boolean;
|
|
16
|
+
children: Map<string, TreeEntry>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Build a nested tree from repo-root-relative file paths (deduped). */
|
|
20
|
+
export function buildFileTree(files: string[]): TreeEntry {
|
|
21
|
+
const root: TreeEntry = { name: '', path: '', isFile: false, children: new Map() };
|
|
22
|
+
for (const file of [...new Set(files)].sort()) {
|
|
23
|
+
const segments = file.split('/').filter(Boolean);
|
|
24
|
+
let node = root;
|
|
25
|
+
segments.forEach((segment, index) => {
|
|
26
|
+
const isFile = index === segments.length - 1;
|
|
27
|
+
const path = node.path ? `${node.path}/${segment}` : segment;
|
|
28
|
+
let child = node.children.get(segment);
|
|
29
|
+
if (!child || child.isFile !== isFile) {
|
|
30
|
+
child = { name: segment, path, isFile, children: new Map() };
|
|
31
|
+
node.children.set(segment, child);
|
|
32
|
+
}
|
|
33
|
+
node = child;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return root;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sortEntries(node: TreeEntry): TreeEntry[] {
|
|
40
|
+
return Array.from(node.children.values()).sort((a, b) => {
|
|
41
|
+
if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
|
|
42
|
+
return a.name.localeCompare(b.name);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SubsystemFileTreeProps {
|
|
47
|
+
/** Repo-root-relative file paths; duplicates are deduped. */
|
|
48
|
+
files: string[];
|
|
49
|
+
/** File to highlight (e.g. the selected component's file). */
|
|
50
|
+
selectedFile?: string | null;
|
|
51
|
+
/** Called when a file row is clicked. */
|
|
52
|
+
onSelectFile?: (file: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Fills its parent height by design — pin it with a sized flex container. */
|
|
56
|
+
export function SubsystemFileTree({ files, selectedFile, onSelectFile }: SubsystemFileTreeProps) {
|
|
57
|
+
const { theme } = useTheme();
|
|
58
|
+
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
59
|
+
const root = useMemo(() => buildFileTree(files), [files]);
|
|
60
|
+
const uniqueCount = useMemo(() => new Set(files).size, [files]);
|
|
61
|
+
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
|
62
|
+
const [hovered, setHovered] = useState<string | null>(null);
|
|
63
|
+
|
|
64
|
+
const toggleFolder = (path: string) => {
|
|
65
|
+
setCollapsed((prev) => {
|
|
66
|
+
const next = new Set(prev);
|
|
67
|
+
if (next.has(path)) next.delete(path);
|
|
68
|
+
else next.add(path);
|
|
69
|
+
return next;
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const renderEntries = (node: TreeEntry, depth: number): ReactNode[] =>
|
|
74
|
+
sortEntries(node).map((child) => {
|
|
75
|
+
if (child.isFile) {
|
|
76
|
+
const isSelected = child.path === selectedFile;
|
|
77
|
+
return (
|
|
78
|
+
<button
|
|
79
|
+
key={child.path}
|
|
80
|
+
type="button"
|
|
81
|
+
onClick={() => onSelectFile?.(child.path)}
|
|
82
|
+
onMouseEnter={() => setHovered(child.path)}
|
|
83
|
+
onMouseLeave={() => setHovered((prev) => (prev === child.path ? null : prev))}
|
|
84
|
+
title={child.path}
|
|
85
|
+
style={{
|
|
86
|
+
display: 'flex',
|
|
87
|
+
alignItems: 'center',
|
|
88
|
+
gap: 5,
|
|
89
|
+
width: '100%',
|
|
90
|
+
padding: '1px 6px',
|
|
91
|
+
border: 'none',
|
|
92
|
+
borderRadius: 4,
|
|
93
|
+
background: isSelected || hovered === child.path
|
|
94
|
+
? theme.colors.border
|
|
95
|
+
: 'transparent',
|
|
96
|
+
color: theme.colors.text,
|
|
97
|
+
fontWeight: isSelected ? 600 : 400,
|
|
98
|
+
fontFamily: theme.fonts.monospace,
|
|
99
|
+
fontSize: theme.fontSizes[0],
|
|
100
|
+
lineHeight: '20px',
|
|
101
|
+
textAlign: 'left',
|
|
102
|
+
cursor: 'pointer',
|
|
103
|
+
whiteSpace: 'nowrap',
|
|
104
|
+
overflow: 'hidden',
|
|
105
|
+
textOverflow: 'ellipsis',
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
<span style={{ width: 12, flexShrink: 0 }} />
|
|
109
|
+
<FileText size={11} style={{ flexShrink: 0, opacity: 0.8 }} />
|
|
110
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{child.name}</span>
|
|
111
|
+
</button>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const isCollapsed = collapsed.has(child.path);
|
|
115
|
+
return (
|
|
116
|
+
<div key={child.path}>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
onClick={() => toggleFolder(child.path)}
|
|
120
|
+
onMouseEnter={() => setHovered(child.path)}
|
|
121
|
+
onMouseLeave={() => setHovered((prev) => (prev === child.path ? null : prev))}
|
|
122
|
+
title={child.path}
|
|
123
|
+
style={{
|
|
124
|
+
display: 'flex',
|
|
125
|
+
alignItems: 'center',
|
|
126
|
+
gap: 5,
|
|
127
|
+
width: '100%',
|
|
128
|
+
padding: '1px 6px',
|
|
129
|
+
border: 'none',
|
|
130
|
+
borderRadius: 4,
|
|
131
|
+
background: hovered === child.path ? theme.colors.border : 'transparent',
|
|
132
|
+
color: muted,
|
|
133
|
+
fontFamily: theme.fonts.monospace,
|
|
134
|
+
fontSize: theme.fontSizes[0],
|
|
135
|
+
lineHeight: '20px',
|
|
136
|
+
textAlign: 'left',
|
|
137
|
+
cursor: 'pointer',
|
|
138
|
+
whiteSpace: 'nowrap',
|
|
139
|
+
overflow: 'hidden',
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<span style={{ width: 12, flexShrink: 0, display: 'inline-flex', justifyContent: 'center' }}>
|
|
143
|
+
{isCollapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
|
|
144
|
+
</span>
|
|
145
|
+
{isCollapsed ? <Folder size={11} style={{ flexShrink: 0 }} /> : <FolderOpen size={11} style={{ flexShrink: 0 }} />}
|
|
146
|
+
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{child.name}</span>
|
|
147
|
+
</button>
|
|
148
|
+
{!isCollapsed && (
|
|
149
|
+
<div style={{ paddingLeft: 12 }}>
|
|
150
|
+
{renderEntries(child, depth + 1)}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return (
|
|
158
|
+
<div
|
|
159
|
+
style={{
|
|
160
|
+
height: '50%',
|
|
161
|
+
minHeight: 120,
|
|
162
|
+
flexShrink: 0,
|
|
163
|
+
borderTop: `1px solid ${theme.colors.border}`,
|
|
164
|
+
display: 'flex',
|
|
165
|
+
flexDirection: 'column',
|
|
166
|
+
}}
|
|
167
|
+
>
|
|
168
|
+
<div
|
|
169
|
+
style={{
|
|
170
|
+
padding: '10px 16px 4px',
|
|
171
|
+
display: 'flex',
|
|
172
|
+
alignItems: 'baseline',
|
|
173
|
+
gap: 6,
|
|
174
|
+
flexShrink: 0,
|
|
175
|
+
}}
|
|
176
|
+
>
|
|
177
|
+
<span
|
|
178
|
+
style={{
|
|
179
|
+
fontSize: theme.fontSizes[0] * 0.8,
|
|
180
|
+
fontFamily: theme.fonts.monospace,
|
|
181
|
+
textTransform: 'uppercase',
|
|
182
|
+
color: muted,
|
|
183
|
+
fontWeight: 600,
|
|
184
|
+
}}
|
|
185
|
+
>
|
|
186
|
+
Files
|
|
187
|
+
</span>
|
|
188
|
+
<span style={{ fontSize: theme.fontSizes[0] * 0.8, fontFamily: theme.fonts.monospace, color: muted }}>
|
|
189
|
+
{uniqueCount}
|
|
190
|
+
</span>
|
|
191
|
+
</div>
|
|
192
|
+
<div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 8px 8px' }}>
|
|
193
|
+
{renderEntries(root, 0)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
package/src/subsystem/model.ts
CHANGED
|
@@ -132,6 +132,10 @@ export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
|
|
|
132
132
|
|
|
133
133
|
export interface SubsystemGraphNodeData extends Record<string, unknown> {
|
|
134
134
|
component: SubsystemComponent;
|
|
135
|
+
/** Set while a file is open in the drawer: true if this node's component
|
|
136
|
+
* lives in that file (spotlighted), false otherwise (dimmed). Absent when
|
|
137
|
+
* no file is open — render neutrally. */
|
|
138
|
+
fileMatch?: boolean;
|
|
135
139
|
}
|
|
136
140
|
|
|
137
141
|
export type SubsystemGraphNode = Node<SubsystemGraphNodeData, SubsystemGraphNodeType>;
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -67,6 +67,9 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
|
|
|
67
67
|
const maxWidth = configuredMax ?? 300;
|
|
68
68
|
// `symbol` is the source of truth; `name` is derived from it consistently.
|
|
69
69
|
const displayName = deriveNameFromSymbol(c.symbol, c.kind, c.name, c.file);
|
|
70
|
+
// Set while a file is open in the drawer: true → spotlight, false → dim,
|
|
71
|
+
// absent (no file open) → neutral.
|
|
72
|
+
const fileMatch = data.fileMatch as boolean | undefined;
|
|
70
73
|
|
|
71
74
|
return (
|
|
72
75
|
<div
|
|
@@ -90,8 +93,12 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
|
|
|
90
93
|
padding: '6px 10px',
|
|
91
94
|
borderRadius: 8,
|
|
92
95
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
93
|
-
border: `2px solid ${selected ? theme.colors.primary : color}`,
|
|
94
|
-
boxShadow:
|
|
96
|
+
border: `2px solid ${selected || fileMatch ? theme.colors.primary : color}`,
|
|
97
|
+
boxShadow: fileMatch
|
|
98
|
+
? `0 1px 4px rgba(0,0,0,0.25), 0 0 12px ${theme.colors.primary}55`
|
|
99
|
+
: '0 1px 4px rgba(0,0,0,0.25)',
|
|
100
|
+
opacity: fileMatch === false ? 0.18 : 1,
|
|
101
|
+
transition: 'opacity 150ms ease',
|
|
95
102
|
cursor: 'pointer',
|
|
96
103
|
fontFamily: theme.fonts.body,
|
|
97
104
|
}}
|
|
@@ -120,7 +127,6 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
|
|
|
120
127
|
}}
|
|
121
128
|
>
|
|
122
129
|
{KIND_LABEL[c.kind] ?? c.kind}
|
|
123
|
-
{c.purl ? ` · ${c.purl}` : ''}
|
|
124
130
|
{c.capture && c.capture !== 'edited' ? ` · ${c.capture}` : ''}
|
|
125
131
|
</div>
|
|
126
132
|
)}
|