@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.
@@ -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, xyflowNodesBase);
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
- [xyflowNodesBase, triggerPass2],
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={{ visibility: layoutReady ? 'visible' : 'hidden', width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
315
- {/* Sidebar with title and description */}
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: 280,
320
- minWidth: 280,
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
- gap: 12,
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={xyflowNodesBase}
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
+ }
@@ -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
  )}