@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.
@@ -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
- * Rendered inside the selected-component detail panel when the component
90
- * has a `file`. Host-injected reader/renderer so this package stays free
91
- * of fs and code-view dependencies.
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, xyflowNodesBase);
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
- [xyflowNodesBase, triggerPass2],
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={{ visibility: layoutReady ? 'visible' : 'hidden', width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
327
- {/* Sidebar with title and description */}
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: 280,
332
- minWidth: 280,
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
- gap: 12,
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={xyflowNodesBase}
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} renderFileView={renderFileView} />}
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
- function ComponentDetail({ component, renderFileView }: { component: SubsystemComponent; renderFileView?: (component: SubsystemComponent) => ReactNode }) {
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
- {renderFileView && component.file && (
560
- <div
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
- marginTop: 8,
563
- maxHeight: 320,
564
- overflow: 'auto',
565
- border: `1px solid ${theme.colors.border}`,
566
- borderRadius: 6,
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
- {renderFileView(component)}
570
- </div>
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
+ }
@@ -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>;
@@ -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: '0 1px 4px rgba(0,0,0,0.25)',
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
  )}