@principal-ai/principal-view-react 0.16.32 → 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 +15 -3
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +171 -34
- 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 +228 -28
- 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],
|
|
@@ -86,11 +89,23 @@ export interface SubsystemComponentGraphProps {
|
|
|
86
89
|
/** Rendered in the sidebar under the description (e.g. selection inspector). */
|
|
87
90
|
sidebarAfterDescription?: ReactNode;
|
|
88
91
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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.
|
|
92
102
|
*/
|
|
93
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;
|
|
94
109
|
}
|
|
95
110
|
|
|
96
111
|
const nodeTypes: NodeTypes = {
|
|
@@ -105,7 +120,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
|
|
|
105
120
|
measured: { w: number; h: number } | null;
|
|
106
121
|
}
|
|
107
122
|
|
|
108
|
-
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView }: InnerProps) {
|
|
123
|
+
function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect }: InnerProps) {
|
|
109
124
|
const { theme } = useTheme();
|
|
110
125
|
const { fitView } = useReactFlow();
|
|
111
126
|
const viewport = useViewport();
|
|
@@ -115,10 +130,15 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
115
130
|
});
|
|
116
131
|
const [layoutReady, setLayoutReady] = useState(false);
|
|
117
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);
|
|
118
135
|
// Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
|
|
119
136
|
// closure over the effect deps) can toggle without a stale value.
|
|
120
137
|
const selectedRef = useRef<SubsystemComponent | null>(null);
|
|
121
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;
|
|
122
142
|
|
|
123
143
|
// Pass 1: build with estimated widths so React Flow can measure the DOM.
|
|
124
144
|
// The pane stays hidden until Pass 2 completes.
|
|
@@ -193,6 +213,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
193
213
|
const comp = components.find((c) => c.id === id);
|
|
194
214
|
if (comp) {
|
|
195
215
|
// Clicking the already-selected node unselects it (toggle off).
|
|
216
|
+
// Selection is independent of the file drawer — nodes never open it.
|
|
196
217
|
if (selectedRef.current?.id === comp.id) {
|
|
197
218
|
setSelected(null);
|
|
198
219
|
setSelectedEdgeId(null);
|
|
@@ -216,6 +237,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
216
237
|
const xyflowNodesBase = nodes as Node[];
|
|
217
238
|
const baseEdges = convertedEdges as Edge[];
|
|
218
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
|
+
|
|
219
254
|
const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
|
|
220
255
|
const baseEdgesKey = useMemo(
|
|
221
256
|
() => convertedEdges.map((e) => e.id).sort().join(','),
|
|
@@ -248,7 +283,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
248
283
|
pendingMeasuredRef.current = true;
|
|
249
284
|
}
|
|
250
285
|
}
|
|
251
|
-
const result = applyNodeChanges(changes,
|
|
286
|
+
const result = applyNodeChanges(changes, dispNodes);
|
|
252
287
|
// After applying changes, check if we should trigger pass 2.
|
|
253
288
|
if (pendingMeasuredRef.current) {
|
|
254
289
|
// Use microtask so the state update from applyNodeChanges commits first.
|
|
@@ -256,7 +291,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
256
291
|
}
|
|
257
292
|
return result;
|
|
258
293
|
},
|
|
259
|
-
[
|
|
294
|
+
[dispNodes, triggerPass2],
|
|
260
295
|
);
|
|
261
296
|
const onEdgesChange = useCallback(
|
|
262
297
|
(_changes: EdgeChange[]) => dispEdges,
|
|
@@ -270,12 +305,36 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
270
305
|
return () => clearTimeout(t);
|
|
271
306
|
}, [baseNodesKey, baseEdgesKey, fitView]);
|
|
272
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
|
+
|
|
273
331
|
const onNodeClick: NodeMouseHandler = useCallback(
|
|
274
332
|
(_e, node: Node) => {
|
|
275
333
|
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
276
334
|
setSelectedEdgeId(null);
|
|
277
335
|
if (node.type === 'subsystem-component' && comp) {
|
|
278
336
|
// Clicking the already-selected node unselects it (toggle off).
|
|
337
|
+
// Selection is independent of the file drawer — nodes never open it.
|
|
279
338
|
if (selected?.id === comp.id) {
|
|
280
339
|
setSelected(null);
|
|
281
340
|
return;
|
|
@@ -300,6 +359,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
300
359
|
setSelectedEdgeId(null);
|
|
301
360
|
}, []);
|
|
302
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
|
+
|
|
303
376
|
// Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
|
|
304
377
|
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
305
378
|
// the actual edge path (not node-center approximations).
|
|
@@ -322,23 +395,54 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
322
395
|
|
|
323
396
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
324
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
|
+
|
|
325
420
|
return (
|
|
326
|
-
<div style={{
|
|
327
|
-
{/* Sidebar
|
|
328
|
-
{(title || description || usedMechanisms.size > 0 || sidebarExtra || sidebarAfterDescription) && (
|
|
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) && (
|
|
329
424
|
<div
|
|
330
425
|
style={{
|
|
331
|
-
width:
|
|
332
|
-
minWidth:
|
|
426
|
+
width: 340,
|
|
427
|
+
minWidth: 340,
|
|
333
428
|
borderRight: `1px solid ${theme.colors.border}`,
|
|
334
429
|
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
335
|
-
padding: '16px',
|
|
336
|
-
overflowY: 'auto',
|
|
337
430
|
display: 'flex',
|
|
338
431
|
flexDirection: 'column',
|
|
339
|
-
|
|
432
|
+
overflow: 'hidden',
|
|
340
433
|
}}
|
|
341
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
|
+
>
|
|
342
446
|
{sidebarExtra}
|
|
343
447
|
{title && (
|
|
344
448
|
<h2
|
|
@@ -392,6 +496,14 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
392
496
|
))}
|
|
393
497
|
</div>
|
|
394
498
|
)}
|
|
499
|
+
</div>
|
|
500
|
+
{files.length > 0 && (
|
|
501
|
+
<SubsystemFileTree
|
|
502
|
+
files={files}
|
|
503
|
+
selectedFile={selected?.file ?? openFile}
|
|
504
|
+
onSelectFile={onTreeSelectFile}
|
|
505
|
+
/>
|
|
506
|
+
)}
|
|
395
507
|
</div>
|
|
396
508
|
)}
|
|
397
509
|
|
|
@@ -449,7 +561,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
449
561
|
)}
|
|
450
562
|
<ReactFlow
|
|
451
563
|
key={`${baseNodesKey}-${baseEdgesKey}`}
|
|
452
|
-
nodes={
|
|
564
|
+
nodes={dispNodes}
|
|
453
565
|
edges={dispEdges}
|
|
454
566
|
nodeTypes={nodeTypes}
|
|
455
567
|
edgeTypes={edgeTypes}
|
|
@@ -482,7 +594,12 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
482
594
|
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
|
|
483
595
|
<Controls showZoom showFitView showInteractive />
|
|
484
596
|
</ReactFlow>
|
|
485
|
-
{selected && <ComponentDetail component={selected}
|
|
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} />
|
|
486
603
|
{canvasOverlay}
|
|
487
604
|
</div>
|
|
488
605
|
</div>
|
|
@@ -490,8 +607,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
490
607
|
}
|
|
491
608
|
|
|
492
609
|
/** Detail panel for the selected component — the verifiable identity + sources
|
|
493
|
-
* moved off the canvas so nodes stay minimal.
|
|
494
|
-
|
|
610
|
+
* moved off the canvas so nodes stay minimal. File content lives in the
|
|
611
|
+
* bottom FileDrawer, not here. */
|
|
612
|
+
function ComponentDetail({ component }: { component: SubsystemComponent }) {
|
|
495
613
|
const { theme } = useTheme();
|
|
496
614
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
497
615
|
const color = KIND_COLOR[component.kind] ?? '#888';
|
|
@@ -556,19 +674,101 @@ function ComponentDetail({ component, renderFileView }: { component: SubsystemCo
|
|
|
556
674
|
</div>
|
|
557
675
|
))}
|
|
558
676
|
{component.detail && <GraphifyDetailSections detail={component.detail} />}
|
|
559
|
-
|
|
560
|
-
|
|
677
|
+
</div>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
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}
|
|
561
737
|
style={{
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
overflow: '
|
|
565
|
-
|
|
566
|
-
|
|
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,
|
|
567
746
|
}}
|
|
568
747
|
>
|
|
569
|
-
{
|
|
570
|
-
</
|
|
571
|
-
|
|
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>
|
|
572
772
|
</div>
|
|
573
773
|
);
|
|
574
774
|
}
|
|
@@ -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
|
)}
|