@principal-ai/principal-view-react 0.16.35 → 0.16.36

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.
Files changed (36) hide show
  1. package/dist/graphify/consolidated.d.ts +17 -3
  2. package/dist/graphify/consolidated.d.ts.map +1 -1
  3. package/dist/graphify/index.d.ts +2 -0
  4. package/dist/graphify/index.d.ts.map +1 -1
  5. package/dist/graphify/index.js +1 -1
  6. package/dist/graphify/index.js.map +1 -1
  7. package/dist/graphify/resolve.d.ts +48 -0
  8. package/dist/graphify/resolve.d.ts.map +1 -0
  9. package/dist/graphify/resolve.js +83 -0
  10. package/dist/graphify/resolve.js.map +1 -0
  11. package/dist/index.d.ts +3 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/subsystem/ComponentDetail.d.ts +16 -7
  16. package/dist/subsystem/ComponentDetail.d.ts.map +1 -1
  17. package/dist/subsystem/ComponentDetail.js +124 -82
  18. package/dist/subsystem/ComponentDetail.js.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  20. package/dist/subsystem/SubsystemComponentGraph.js +31 -3
  21. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  22. package/dist/subsystem/model.d.ts +14 -0
  23. package/dist/subsystem/model.d.ts.map +1 -1
  24. package/dist/subsystem/model.js +26 -1
  25. package/dist/subsystem/model.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/graphify/consolidated.ts +17 -3
  28. package/src/graphify/index.ts +9 -0
  29. package/src/graphify/resolve.test.ts +89 -0
  30. package/src/graphify/resolve.ts +115 -0
  31. package/src/index.ts +10 -0
  32. package/src/stories/SubsystemComponentGraph.stories.tsx +331 -11
  33. package/src/subsystem/ComponentDetail.tsx +355 -156
  34. package/src/subsystem/SubsystemComponentGraph.tsx +53 -3
  35. package/src/subsystem/model.test.ts +11 -0
  36. package/src/subsystem/model.ts +25 -1
@@ -1,187 +1,386 @@
1
1
  /**
2
- * ComponentDetail — selection detail panel for a subsystem component: the
3
- * verifiable identity (symbol/file/purpose/purl) plus graphify drill-down
4
- * sections. File content lives in the bottom FileDrawer, not here.
2
+ * ComponentDetail — selection detail panel for a subsystem component, rendered
3
+ * as it would appear in code: a file-path comment + doc comment, then the kind's
4
+ * declaration (`class X extends Y { ... }`, `function f(): T`, ...) with its
5
+ * members as signatures. Graphify relationship drill-downs appear as trailing
6
+ * comments. File content lives in the bottom FileDrawer, not here.
5
7
  */
6
8
 
7
- import type { ReactNode } from 'react';
9
+ import { Fragment, type ReactNode } from 'react';
8
10
  import { useTheme } from '@principal-ade/industry-theme';
9
11
  import {
10
12
  KIND_COLOR,
11
13
  deriveNameFromSymbol,
14
+ formatPurl,
12
15
  type SubsystemComponent,
13
16
  } from './model';
14
- import type { GraphifyComponentDetail } from '../graphify';
15
17
 
16
- /** Detail panel for the selected component — the verifiable identity + sources
17
- * moved off the canvas so nodes stay minimal. */
18
- export function ComponentDetail({ component }: { component: SubsystemComponent }) {
19
- const { theme } = useTheme();
20
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
21
- const color = KIND_COLOR[component.kind] ?? '#888';
22
- const displayName = deriveNameFromSymbol(component.symbol, component.kind, component.name, component.file);
23
- const rows: Array<[string, string]> = [];
24
- if (component.symbol) rows.push(['Symbol', component.symbol]);
25
- if (component.file) rows.push(['File', component.file]);
26
- if (component.purpose) rows.push(['Purpose', component.purpose]);
27
- rows.push(['PURL', component.purl]);
18
+ /** Shared affordance for clickable text pieces. */
19
+ const clickableStyle = {
20
+ cursor: 'pointer',
21
+ textDecorationLine: 'underline',
22
+ textDecorationStyle: 'dotted',
23
+ textUnderlineOffset: 2,
24
+ } as const;
28
25
 
26
+ /** Clickable detail-panel piece — dotted underline signals interactivity. */
27
+ function DetailLink({ children, onClick }: { children: ReactNode; onClick?: () => void }) {
28
+ if (!onClick) return <>{children}</>;
29
29
  return (
30
- <div
30
+ <span
31
+ onClick={(e) => {
32
+ e.stopPropagation();
33
+ onClick();
34
+ }}
31
35
  style={{
32
- borderTop: `1px solid ${theme.colors.border}`,
33
- background: theme.colors.background,
34
- padding: '10px 12px',
35
- fontFamily: theme.fonts.body,
36
+ cursor: 'pointer',
37
+ textDecorationLine: 'underline',
38
+ textDecorationStyle: 'dotted',
39
+ textUnderlineOffset: 2,
36
40
  }}
37
41
  >
38
- <div
39
- style={{
40
- display: 'flex',
41
- alignItems: 'center',
42
- gap: 8,
43
- fontSize: theme.fontSizes[1],
44
- fontWeight: 600,
45
- marginBottom: 6,
46
- }}
47
- >
48
- <span style={{ color }}>{displayName}</span>
49
- <span
50
- style={{
51
- fontSize: theme.fontSizes[0] * 0.8,
52
- fontFamily: theme.fonts.monospace,
53
- textTransform: 'uppercase',
54
- color,
55
- }}
56
- >
57
- {component.kind}
58
- </span>
59
- </div>
60
- {rows.map(([k, v]) => (
61
- <div
62
- key={k}
63
- style={{ display: 'flex', gap: 8, fontSize: theme.fontSizes[0], lineHeight: 1.5 }}
64
- >
65
- <span
66
- style={{
67
- width: 64,
68
- flexShrink: 0,
69
- color: muted,
70
- fontFamily: theme.fonts.monospace,
71
- textTransform: 'uppercase',
72
- fontSize: theme.fontSizes[0] * 0.8,
73
- }}
74
- >
75
- {k}
76
- </span>
77
- <span style={{ color: theme.colors.text, fontFamily: theme.fonts.monospace }}>
78
- {v}
79
- </span>
80
- </div>
81
- ))}
82
- {component.detail && <GraphifyDetailSections detail={component.detail} />}
83
- </div>
42
+ {children}
43
+ </span>
84
44
  );
85
45
  }
86
46
 
87
- /** Renders the graphify drill-down payload for a component. */
88
- function GraphifyDetailSections({ detail }: { detail: GraphifyComponentDetail }) {
47
+ /** Panel props. The two callbacks are wired by the graph; standalone usage
48
+ * without them renders the same panel with nothing clickable. */
49
+ export interface ComponentDetailProps {
50
+ component: SubsystemComponent;
51
+ /** File-path click → open that file in the bottom drawer. */
52
+ onOpenFile?: (file: string) => void;
53
+ /** Related-name click (types, callers/callees, implementors, …) → select
54
+ * that component if one matches; unmatched refs no-op. */
55
+ onRelatedSelect?: (ref: string) => void;
56
+ }
57
+
58
+ /** Detail panel for the selected component — its declaration, code-style. */
59
+ export function ComponentDetail({ component, onOpenFile, onRelatedSelect }: ComponentDetailProps) {
89
60
  const { theme } = useTheme();
90
61
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
62
+ const color = KIND_COLOR[component.kind] ?? '#888';
63
+ const displayName = deriveNameFromSymbol(component.symbol, component.kind, component.name, component.file);
91
64
 
92
- const label = (k: string) => (
93
- <span
94
- style={{
95
- width: 64,
96
- flexShrink: 0,
97
- color: muted,
98
- fontFamily: theme.fonts.monospace,
99
- textTransform: 'uppercase',
100
- fontSize: theme.fontSizes[0] * 0.8,
101
- }}
102
- >
103
- {k}
104
- </span>
65
+ // Token styles keyword / name / member / type / string / punctuation.
66
+ const kw = (text: string) => <span style={{ color: theme.colors.secondary }}>{text}</span>;
67
+ const nm = (text: string) => <span style={{ color }}>{text}</span>;
68
+ const mb = (text: string) => <span style={{ color: theme.colors.info }}>{text}</span>;
69
+ // Type tokens are links into the graph (other components often own them).
70
+ const ty = (text: string) => (
71
+ <DetailLink onClick={onRelatedSelect ? () => onRelatedSelect(text) : undefined}>
72
+ <span style={{ color: theme.colors.accent }}>{text}</span>
73
+ </DetailLink>
105
74
  );
106
- const chip = (name: string, meta?: string, key?: string | number) => (
107
- <span
108
- key={key}
109
- style={{
110
- fontFamily: theme.fonts.monospace,
111
- fontSize: theme.fontSizes[0],
112
- color: theme.colors.text,
113
- border: `1px solid ${theme.colors.border}`,
114
- borderRadius: 4,
115
- padding: '1px 6px',
116
- marginRight: 6,
117
- marginBottom: 4,
118
- display: 'inline-block',
119
- }}
120
- >
121
- {name}
122
- {meta ? <span style={{ color: muted }}> {meta}</span> : ''}
123
- </span>
124
- );
125
- const row = (k: string, children: ReactNode) => (
126
- <div
127
- style={{ display: 'flex', gap: 8, fontSize: theme.fontSizes[0], lineHeight: 1.5, marginBottom: 4 }}
128
- >
129
- {label(k)}
130
- <div style={{ minWidth: 0 }}>{children}</div>
75
+ const str = (text: string) => <span style={{ color: theme.colors.success }}>{text}</span>;
76
+ const pn = (text: string) => <span style={{ color: muted }}>{text}</span>;
77
+
78
+ const line = (children: ReactNode, key?: string, indent?: boolean) => (
79
+ <div key={key} style={indent ? { paddingLeft: 16 } : undefined}>
80
+ {children}
131
81
  </div>
132
82
  );
83
+ const commentStyle = { color: muted, fontStyle: 'italic' } as const;
84
+ const commentLine = (text: string, key?: string, indent?: boolean, onClick?: () => void) =>
85
+ line(
86
+ <DetailLink onClick={onClick}>
87
+ <span style={{ ...commentStyle, ...(onClick ? clickableStyle : undefined) }}>{text}</span>
88
+ </DetailLink>,
89
+ key,
90
+ indent,
91
+ );
92
+ // Trailing relationship comments whose names are individually clickable.
93
+ const commentListLine = (prefix: string, names: string[], key?: string) =>
94
+ line(
95
+ <>
96
+ <span style={commentStyle}>{prefix}</span>
97
+ {list(
98
+ names.map((n, i) => (
99
+ <Fragment key={i}>
100
+ <DetailLink onClick={onRelatedSelect ? () => onRelatedSelect(n) : undefined}>
101
+ <span style={{ ...commentStyle, ...(onRelatedSelect ? clickableStyle : undefined) }}>{n}</span>
102
+ </DetailLink>
103
+ </Fragment>
104
+ )),
105
+ )}
106
+ </>,
107
+ key,
108
+ );
109
+ // Comma-join rendered tokens without raw-array key churn.
110
+ const list = (items: ReactNode[]) =>
111
+ items.map((item, i) => (
112
+ <Fragment key={i}>
113
+ {i > 0 ? ', ' : null}
114
+ {item}
115
+ </Fragment>
116
+ ));
133
117
 
134
- switch (detail.kind) {
135
- case 'class':
136
- return (
137
- <>
138
- {detail.methods.length > 0 &&
139
- row('methods', detail.methods.map((m, i) => chip(m.name, m.returnType, i)))}
140
- {detail.properties.length > 0 &&
141
- row('props', detail.properties.map((p, i) => chip(p.name, p.type, i)))}
142
- {detail.extends.length > 0 && row('extends', detail.extends.map((e, i) => chip(e, undefined, i)))}
143
- {detail.implements.length > 0 && row('impl', detail.implements.map((e, i) => chip(e, undefined, i)))}
144
- {detail.instantiations.length > 0 &&
145
- row('insts', detail.instantiations.map((i, idx) => chip(i.name, undefined, idx)))}
146
- {detail.references.length > 0 &&
147
- row('refs', detail.references.map((r, idx) => chip(r.name, undefined, idx)))}
148
- </>
118
+ const lines: ReactNode[] = [];
119
+ if (component.purl)
120
+ lines.push(commentLine(`// ${formatPurl(component.purl)}`, 'purl'));
121
+ if (component.file)
122
+ lines.push(commentLine(`// ${component.file}`, 'file', false, onOpenFile ? () => onOpenFile(component.file) : undefined));
123
+ if (component.purpose) lines.push(commentLine(`/** ${component.purpose} */`, 'purpose'));
124
+
125
+ const detail = component.detail;
126
+ switch (detail?.kind ?? component.kind) {
127
+ case 'class': {
128
+ const members: ReactNode[] = [];
129
+ if (detail?.kind === 'class') {
130
+ detail.methods.forEach((m, i) =>
131
+ members.push(
132
+ line(
133
+ <>
134
+ {mb(m.name)}
135
+ {pn('(')}
136
+ {(m.parameters ?? []).length > 0 &&
137
+ list(
138
+ (m.parameters ?? []).map((p, j) => (
139
+ <Fragment key={j}>
140
+ {p.name ? (
141
+ <>
142
+ {mb(p.name)}
143
+ {pn(': ')}
144
+ </>
145
+ ) : null}
146
+ {ty(p.type)}
147
+ </Fragment>
148
+ )),
149
+ )}
150
+ {pn(')')}
151
+ {m.returnType ? (
152
+ <>
153
+ {pn(': ')}
154
+ {ty(m.returnType)}
155
+ </>
156
+ ) : null}
157
+ </>,
158
+ `m${i}`,
159
+ true,
160
+ ),
161
+ ),
162
+ );
163
+ detail.properties.forEach((prop, i) =>
164
+ members.push(
165
+ line(
166
+ <>
167
+ {mb(prop.name)}
168
+ {prop.type ? (
169
+ <>
170
+ {pn(': ')}
171
+ {ty(prop.type)}
172
+ </>
173
+ ) : null}
174
+ </>,
175
+ `p${i}`,
176
+ true,
177
+ ),
178
+ ),
179
+ );
180
+ }
181
+ const cls = detail?.kind === 'class' ? detail : undefined;
182
+ const hasBody = members.length > 0;
183
+ lines.push(
184
+ line(
185
+ <>
186
+ {kw('class')}
187
+ {' '}
188
+ {nm(displayName)}
189
+ {cls && cls.extends.length > 0 && (
190
+ <>
191
+ {' '}
192
+ {kw('extends')}
193
+ {' '}
194
+ {list(cls.extends.map((e, i) => <Fragment key={i}>{ty(e)}</Fragment>))}
195
+ </>
196
+ )}
197
+ {cls && cls.implements.length > 0 && (
198
+ <>
199
+ {' '}
200
+ {kw('implements')}
201
+ {' '}
202
+ {list(cls.implements.map((e, i) => <Fragment key={i}>{ty(e)}</Fragment>))}
203
+ </>
204
+ )}
205
+ {hasBody ? pn(' {') : null}
206
+ </>,
207
+ 'decl',
208
+ ),
149
209
  );
150
- case 'function':
151
- return (
152
- <>
153
- {detail.parameters.length > 0 &&
154
- row('params', detail.parameters.map((p, i) => chip(p.type, undefined, i)))}
155
- {detail.returnType && row('return', <span>{detail.returnType}</span>)}
156
- {detail.callers.length > 0 &&
157
- row('callers', detail.callers.map((c, i) => chip(c.name, undefined, i)))}
158
- {detail.callees.length > 0 &&
159
- row('callees', detail.callees.map((c, i) => chip(c.name, undefined, i)))}
160
- </>
210
+ lines.push(...members);
211
+ if (hasBody) lines.push(line(pn('}'), 'end'));
212
+ if (cls && cls.instantiations.length > 0)
213
+ lines.push(commentListLine('// constructed by: ', cls.instantiations.map((c) => c.name), 'insts'));
214
+ if (cls && cls.references.length > 0)
215
+ lines.push(commentListLine('// referenced by: ', cls.references.map((r) => r.name), 'refs'));
216
+ break;
217
+ }
218
+ case 'function': {
219
+ const fn = detail?.kind === 'function' ? detail : undefined;
220
+ const params = fn?.parameters ?? [];
221
+ lines.push(
222
+ line(
223
+ <>
224
+ {kw('function')}
225
+ {' '}
226
+ {nm(displayName)}
227
+ {pn('(')}
228
+ {params.length > 0 &&
229
+ list(
230
+ params.map((param, i) => (
231
+ <Fragment key={i}>
232
+ {param.name ? (
233
+ <>
234
+ {mb(param.name)}
235
+ {pn(': ')}
236
+ </>
237
+ ) : null}
238
+ {ty(param.type)}
239
+ </Fragment>
240
+ )),
241
+ )}
242
+ {pn(')')}
243
+ {fn?.returnType ? (
244
+ <>
245
+ {pn(': ')}
246
+ {ty(fn.returnType)}
247
+ </>
248
+ ) : null}
249
+ {pn(';')}
250
+ </>,
251
+ 'decl',
252
+ ),
161
253
  );
162
- case 'type':
163
- return (
164
- <>
165
- {detail.properties.length > 0 &&
166
- row('props', detail.properties.map((p, i) => chip(p.name, p.type, i)))}
167
- {detail.usedBy.length > 0 &&
168
- row('used by', detail.usedBy.map((u, i) => chip(u.name, undefined, i)))}
169
- {detail.implementors.length > 0 &&
170
- row('impl', detail.implementors.map((e, i) => chip(e, undefined, i)))}
171
- </>
254
+ if (fn && fn.callers.length > 0)
255
+ lines.push(commentListLine('// called by: ', fn.callers.map((c) => c.name), 'callers'));
256
+ if (fn && fn.callees.length > 0)
257
+ lines.push(commentListLine('// calls: ', fn.callees.map((c) => c.name), 'callees'));
258
+ break;
259
+ }
260
+ case 'type': {
261
+ const tpe = detail?.kind === 'type' ? detail : undefined;
262
+ const hasBody = (tpe?.properties.length ?? 0) > 0;
263
+ lines.push(
264
+ line(
265
+ <>
266
+ {kw('interface')}
267
+ {' '}
268
+ {nm(displayName)}
269
+ {hasBody ? pn(' {') : null}
270
+ </>,
271
+ 'decl',
272
+ ),
172
273
  );
173
- case 'module':
174
- return (
175
- <>
176
- {detail.exports.length > 0 &&
177
- row('exports', detail.exports.map((e, i) => chip(e, undefined, i)))}
178
- {detail.imports.length > 0 &&
179
- row('imports', detail.imports.map((i, idx) => chip(i.name, undefined, idx)))}
180
- {detail.symbols.length > 0 &&
181
- row('symbols', detail.symbols.map((s, i) => chip(s, undefined, i)))}
182
- </>
274
+ if (tpe) {
275
+ tpe.properties.forEach((prop, i) =>
276
+ lines.push(
277
+ line(
278
+ <>
279
+ {mb(prop.name)}
280
+ {prop.type ? (
281
+ <>
282
+ {pn(': ')}
283
+ {ty(prop.type)}
284
+ </>
285
+ ) : null}
286
+ {pn(';')}
287
+ </>,
288
+ `p${i}`,
289
+ true,
290
+ ),
291
+ ),
292
+ );
293
+ }
294
+ if (hasBody) lines.push(line(pn('}'), 'end'));
295
+ if (tpe && tpe.implementors.length > 0)
296
+ lines.push(commentListLine('// implemented by: ', tpe.implementors, 'impl'));
297
+ if (tpe && tpe.usedBy.length > 0)
298
+ lines.push(commentListLine('// used by: ', tpe.usedBy.map((u) => u.name), 'usedBy'));
299
+ break;
300
+ }
301
+ case 'module': {
302
+ const mod = detail?.kind === 'module' ? detail : undefined;
303
+ if (mod) {
304
+ mod.imports.forEach((imp, i) =>
305
+ lines.push(
306
+ line(
307
+ <>
308
+ {kw('import')}
309
+ {' '}
310
+ {pn("'")}
311
+ {str(imp.name)}
312
+ {pn("';")}
313
+ </>,
314
+ `imp${i}`,
315
+ ),
316
+ ),
317
+ );
318
+ if (mod.exports.length > 0)
319
+ lines.push(
320
+ line(
321
+ <>
322
+ {kw('export')}
323
+ {' '}
324
+ {pn('{ ')}
325
+ {list(mod.exports.map((e, i) => <Fragment key={i}>{mb(e)}</Fragment>))}
326
+ {pn(' }')}
327
+ {pn(';')}
328
+ </>,
329
+ 'exports',
330
+ ),
331
+ );
332
+ if (mod.symbols.length > 0)
333
+ lines.push(commentLine(`// defines: ${mod.symbols.join(', ')}`, 'symbols'));
334
+ } else {
335
+ lines.push(
336
+ line(
337
+ <>
338
+ {kw('module')}
339
+ {' '}
340
+ {nm(displayName)}
341
+ </>,
342
+ 'decl',
343
+ ),
344
+ );
345
+ }
346
+ break;
347
+ }
348
+ case 'external': {
349
+ lines.push(
350
+ line(
351
+ <>
352
+ {kw('external')}
353
+ {' '}
354
+ {detail?.kind === 'external' ? (
355
+ <>
356
+ {pn("'")}
357
+ {str(detail.label)}
358
+ {pn("'")}
359
+ </>
360
+ ) : (
361
+ nm(displayName)
362
+ )}
363
+ </>,
364
+ 'decl',
365
+ ),
183
366
  );
184
- case 'external':
185
- return row('source', <span>{detail.label}</span>);
367
+ break;
368
+ }
186
369
  }
370
+
371
+ return (
372
+ <div
373
+ style={{
374
+ borderTop: `1px solid ${theme.colors.border}`,
375
+ background: theme.colors.backgroundSecondary,
376
+ padding: '10px 12px',
377
+ fontFamily: theme.fonts.monospace,
378
+ fontSize: theme.fontSizes[0],
379
+ lineHeight: 1.7,
380
+ wordBreak: 'break-word',
381
+ }}
382
+ >
383
+ {lines}
384
+ </div>
385
+ );
187
386
  }
@@ -12,7 +12,7 @@
12
12
  * injection, onNodeClick) adapted to the subsystem model — read-only.
13
13
  */
14
14
 
15
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
+ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
16
  import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react';
17
17
  import {
18
18
  ReactFlow,
@@ -97,6 +97,21 @@ const edgeTypes: EdgeTypes = {
97
97
  'subsystem-edge': SubsystemEdge,
98
98
  };
99
99
 
100
+ // Memoized drawer body: only rebuilds children when the open file changes.
101
+ // Inner re-renders on every viewport pan/zoom and hover; recreating host
102
+ // elements then would churn their readFile closures and flash the file
103
+ // viewer's loading state on each render.
104
+ const DrawerContent = memo(function DrawerContent({
105
+ render,
106
+ file,
107
+ }: {
108
+ render: (file: string) => ReactNode;
109
+ file: string | null;
110
+ }) {
111
+ if (!file) return null;
112
+ return <>{render(file)}</>;
113
+ });
114
+
100
115
  interface InnerProps extends SubsystemComponentGraphProps {
101
116
  measured: { w: number; h: number } | null;
102
117
  }
@@ -360,6 +375,26 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
360
375
  [onFileSelect],
361
376
  );
362
377
 
378
+ // Detail-panel links: related-name clicks select the matching component —
379
+ // resolved by id, name, or symbol (call labels may carry a trailing `()`).
380
+ const resolveRelatedComponent = useCallback(
381
+ (ref: string) => {
382
+ const clean = ref.replace(/\(\)$/, '');
383
+ const comp = components.find(
384
+ (c) =>
385
+ c.id === clean ||
386
+ c.name === clean ||
387
+ c.symbol === clean ||
388
+ c.symbol?.replace(/\(\)$/, '') === clean,
389
+ );
390
+ if (!comp || comp.id === selectedRef.current?.id) return;
391
+ setSelected(comp);
392
+ setSelectedEdgeId(null);
393
+ onSelect?.(comp.id);
394
+ },
395
+ [components, onSelect],
396
+ );
397
+
363
398
  // Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
364
399
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
365
400
  // the actual edge path (not node-center approximations).
@@ -408,6 +443,15 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
408
443
  return undefined;
409
444
  }, [renderFileViewer, renderFileView, components]);
410
445
 
446
+ // Latest-viewer ref + stable callback so DrawerContent's memo holds across
447
+ // unrelated Inner renders (see comment there).
448
+ const fileViewerRef = useRef(fileViewer);
449
+ fileViewerRef.current = fileViewer;
450
+ const renderDrawerContent = useCallback(
451
+ (file: string) => fileViewerRef.current?.(file) ?? null,
452
+ [],
453
+ );
454
+
411
455
  return (
412
456
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
413
457
  {/* Sidebar: scrollable title/description on top, file tree pinned to the bottom half */}
@@ -596,9 +640,15 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
596
640
  mechanisms={usedMechanisms}
597
641
  onClose={() => setLegendOpen(false)}
598
642
  />
599
- {selected && <ComponentDetail component={selected} />}
643
+ {selected && (
644
+ <ComponentDetail
645
+ component={selected}
646
+ onOpenFile={onTreeSelectFile}
647
+ onRelatedSelect={resolveRelatedComponent}
648
+ />
649
+ )}
600
650
  <FileDrawer file={openFile} onClose={() => setOpenFile(null)}>
601
- {fileViewer && openFile ? fileViewer(openFile) : null}
651
+ <DrawerContent render={renderDrawerContent} file={openFile} />
602
652
  </FileDrawer>
603
653
  {/* Startup cover — hides measurement, layout swap, and camera settle. */}
604
654
  <GraphLayoutCover revealed={layoutReady} />
@@ -4,6 +4,7 @@ import {
4
4
  convertSubsystemToEdges,
5
5
  buildSubsystemGraph,
6
6
  deriveNameFromSymbol,
7
+ formatPurl,
7
8
  packageColor,
8
9
  } from './model';
9
10
  import type { SubsystemComponent, SubsystemComponentEdge } from './model';
@@ -76,4 +77,14 @@ describe('subsystem graph model', () => {
76
77
  // File basename wins over a supplied name for modules (derivation precedence).
77
78
  expect(deriveNameFromSymbol(undefined, 'module', 'MyModule', 'x.ts')).toBe('x');
78
79
  });
80
+
81
+ test('formatPurl renders the human identity', () => {
82
+ expect(formatPurl('pkg:npm/@principal-ai/core')).toBe('@principal-ai/core');
83
+ expect(formatPurl('pkg:github/principal-ai/agent-monitoring')).toBe('principal-ai/agent-monitoring');
84
+ expect(formatPurl('pkg:npm/left-pad@1.3.0')).toBe('left-pad');
85
+ expect(formatPurl('pkg:gitlab/group/proj?arch=amd64')).toBe('group/proj');
86
+ expect(formatPurl('pkg:generic/local--Users-me-my-app')).toBe('Users-me-my-app (local)');
87
+ // Malformed purls pass through untouched.
88
+ expect(formatPurl('not-a-purl')).toBe('not-a-purl');
89
+ });
79
90
  });