@principal-ai/principal-view-react 0.16.34 → 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 (52) 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 +21 -0
  16. package/dist/subsystem/ComponentDetail.d.ts.map +1 -0
  17. package/dist/subsystem/ComponentDetail.js +137 -0
  18. package/dist/subsystem/ComponentDetail.js.map +1 -0
  19. package/dist/subsystem/EdgeLegendModal.d.ts +18 -0
  20. package/dist/subsystem/EdgeLegendModal.d.ts.map +1 -0
  21. package/dist/subsystem/EdgeLegendModal.js +98 -0
  22. package/dist/subsystem/EdgeLegendModal.js.map +1 -0
  23. package/dist/subsystem/FileDrawer.d.ts +16 -0
  24. package/dist/subsystem/FileDrawer.d.ts.map +1 -0
  25. package/dist/subsystem/FileDrawer.js +72 -0
  26. package/dist/subsystem/FileDrawer.js.map +1 -0
  27. package/dist/subsystem/SubsystemComponentGraph.d.ts +0 -3
  28. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  29. package/dist/subsystem/SubsystemComponentGraph.js +57 -191
  30. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  31. package/dist/subsystem/model.d.ts +14 -3
  32. package/dist/subsystem/model.d.ts.map +1 -1
  33. package/dist/subsystem/model.js +26 -1
  34. package/dist/subsystem/model.js.map +1 -1
  35. package/dist/subsystem/nodes.d.ts +2 -2
  36. package/dist/subsystem/nodes.d.ts.map +1 -1
  37. package/dist/subsystem/nodes.js +3 -21
  38. package/dist/subsystem/nodes.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/graphify/consolidated.ts +17 -3
  41. package/src/graphify/index.ts +9 -0
  42. package/src/graphify/resolve.test.ts +89 -0
  43. package/src/graphify/resolve.ts +115 -0
  44. package/src/index.ts +10 -0
  45. package/src/stories/SubsystemComponentGraph.stories.tsx +333 -13
  46. package/src/subsystem/ComponentDetail.tsx +386 -0
  47. package/src/subsystem/EdgeLegendModal.tsx +146 -0
  48. package/src/subsystem/FileDrawer.tsx +105 -0
  49. package/src/subsystem/SubsystemComponentGraph.tsx +93 -328
  50. package/src/subsystem/model.test.ts +11 -0
  51. package/src/subsystem/model.ts +25 -4
  52. package/src/subsystem/nodes.tsx +2 -29
@@ -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,
@@ -32,40 +32,21 @@ 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
+ import { Map as MapIcon } from 'lucide-react';
36
36
  import { IndustryMarkdownSlide } from 'themed-markdown';
37
37
  import {
38
38
  buildSubsystemGraph,
39
- KIND_COLOR,
40
39
  MECHANISM_COLOR,
41
- deriveNameFromSymbol,
42
40
  type SubsystemComponentEdge,
43
41
  type SubsystemComponent,
44
42
  type SubsystemEdgeMechanism,
45
43
  } from './model';
46
- import type { GraphifyComponentDetail } from '../graphify';
47
44
  import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
48
45
  import { SubsystemFileTree } from './SubsystemFileTree';
49
46
  import { GraphLayoutCover } from './GraphLayoutCover';
50
-
51
- const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
52
- ['imports', 'import statement (code-level dependency)', true],
53
- ['imports_from', 'imported by (reverse dependency)', true],
54
- ['re_exports', 're-exports symbols from', true],
55
- ['defines', 'defines / declares symbol', true],
56
- ['calls', 'function/method call (call graph edge)', true],
57
- ['extends', 'class inheritance', true],
58
- ['inherits', 'class inheritance', true],
59
- ['implements', 'implements interface / protocol', true],
60
- ['mixes_in', 'applies mixin', true],
61
- ['uses', 'general dependency (import, call, or reference)', false],
62
- ['method', 'structural: has method / member', true],
63
- ['references', 'type / symbol reference (not a call)', true],
64
- ['contains', 'structural: contains / encapsulates', true],
65
- ['feeds', 'data flow: output feeds into input', false],
66
- ['produces', 'data flow: produces / outputs', false],
67
- ['registers-into', 'registration pattern', false],
68
- ];
47
+ import { ComponentDetail } from './ComponentDetail';
48
+ import { FileDrawer } from './FileDrawer';
49
+ import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
69
50
 
70
51
  export interface SubsystemComponentGraphProps {
71
52
  components: SubsystemComponent[];
@@ -78,9 +59,6 @@ export interface SubsystemComponentGraphProps {
78
59
  maxNodeWidth?: number;
79
60
  /** Show edge labels (mechanism names) on the graph. @default true */
80
61
  showEdgeLabels?: boolean;
81
- /** Show the click-to-open file chip under each component name.
82
- * @default true */
83
- showFileBadges?: boolean;
84
62
  /** Subsystem title displayed in the sidebar. */
85
63
  title?: string;
86
64
  /** Markdown description rendered in the sidebar. */
@@ -119,11 +97,26 @@ const edgeTypes: EdgeTypes = {
119
97
  'subsystem-edge': SubsystemEdge,
120
98
  };
121
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
+
122
115
  interface InnerProps extends SubsystemComponentGraphProps {
123
116
  measured: { w: number; h: number } | null;
124
117
  }
125
118
 
126
- function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showFileBadges, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect }: InnerProps) {
119
+ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect }: InnerProps) {
127
120
  const { theme } = useTheme();
128
121
  const { fitView } = useReactFlow();
129
122
  const viewport = useViewport();
@@ -135,6 +128,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
135
128
  const [selected, setSelected] = useState<SubsystemComponent | null>(null);
136
129
  // File currently shown in the bottom drawer (repo-root-relative path).
137
130
  const [openFile, setOpenFile] = useState<string | null>(null);
131
+ // Edge-legend modal visibility (opened from the canvas's top-left button).
132
+ const [legendOpen, setLegendOpen] = useState(false);
138
133
  // Component the pointer is over (null on leave) → transient tree highlight.
139
134
  const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
140
135
  // Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
@@ -248,19 +243,15 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
248
243
  // component lives in that file — the node renderer spotlights matches and
249
244
  // dims non-matches (mirrors the edge-dimming behavior on selection).
250
245
  const dispNodes = useMemo(() => {
251
- let ns = xyflowNodesBase.map((n) => ({
252
- ...n,
253
- data: { ...(n.data as object), showFileBadges },
254
- }));
255
- if (!openFile) return ns;
256
- return ns.map((n) => {
246
+ if (!openFile) return xyflowNodesBase;
247
+ return xyflowNodesBase.map((n) => {
257
248
  const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
258
249
  return {
259
250
  ...n,
260
251
  data: { ...(n.data as object), fileMatch: comp?.file === openFile },
261
252
  };
262
253
  });
263
- }, [xyflowNodesBase, openFile, showFileBadges]);
254
+ }, [xyflowNodesBase, openFile]);
264
255
 
265
256
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
266
257
  const baseEdgesKey = useMemo(
@@ -384,6 +375,26 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
384
375
  [onFileSelect],
385
376
  );
386
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
+
387
398
  // Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
388
399
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
389
400
  // the actual edge path (not node-center approximations).
@@ -404,8 +415,6 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
404
415
  return new Set(edgeLabels.map((l) => l.mechanism));
405
416
  }, [edgeLabels]);
406
417
 
407
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
408
-
409
418
  // Unique source files across components → sidebar file tree.
410
419
  const files = useMemo(
411
420
  () => Array.from(new Set(components.map((c) => c.file).filter(Boolean))).sort(),
@@ -434,10 +443,19 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
434
443
  return undefined;
435
444
  }, [renderFileViewer, renderFileView, components]);
436
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
+
437
455
  return (
438
456
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
439
- {/* Sidebar: scrollable title/description/legend on top, file tree pinned to the bottom half */}
440
- {(title || description || usedMechanisms.size > 0 || sidebarExtra || sidebarAfterDescription || files.length > 0) && (
457
+ {/* Sidebar: scrollable title/description on top, file tree pinned to the bottom half */}
458
+ {(title || description || sidebarExtra || sidebarAfterDescription || files.length > 0) && (
441
459
  <div
442
460
  style={{
443
461
  width: 340,
@@ -490,29 +508,6 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
490
508
  </div>
491
509
  )}
492
510
  {sidebarAfterDescription}
493
- {usedMechanisms.size > 0 && (
494
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
495
- <span style={{ fontSize: theme.fontSizes[0] * 0.8, fontFamily: theme.fonts.monospace, textTransform: 'uppercase', color: muted, fontWeight: 600 }}>
496
- Edge Legend
497
- </span>
498
- {MECHANISM_DESCRIPTIONS.filter(([m]) => usedMechanisms.has(m)).map(([mechanism, desc, verifiable]) => (
499
- <div key={mechanism} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: theme.fontSizes[0] }}>
500
- <span
501
- style={{
502
- width: 8,
503
- height: 8,
504
- borderRadius: verifiable ? '50%' : '30% 70% 70% 30% / 30% 30% 70% 70%',
505
- background: MECHANISM_COLOR[mechanism],
506
- border: verifiable ? 'none' : `1px dashed ${MECHANISM_COLOR[mechanism]}`,
507
- flexShrink: 0,
508
- }}
509
- />
510
- <span style={{ fontFamily: theme.fonts.monospace, color: MECHANISM_COLOR[mechanism], fontWeight: 500 }}>{mechanism}</span>
511
- <span style={{ color: muted }}>{desc}</span>
512
- </div>
513
- ))}
514
- </div>
515
- )}
516
511
  </div>
517
512
  {files.length > 0 && (
518
513
  <SubsystemFileTree
@@ -612,287 +607,57 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
612
607
  <Background variant={BackgroundVariant.Dots} gap={16} size={1} />
613
608
  <Controls showZoom showFitView showInteractive />
614
609
  </ReactFlow>
615
- {selected && <ComponentDetail component={selected} />}
616
- <FileDrawer file={openFile} onClose={() => setOpenFile(null)}>
617
- {fileViewer && openFile ? fileViewer(openFile) : null}
618
- </FileDrawer>
619
- {/* Startup cover — hides measurement, layout swap, and camera settle. */}
620
- <GraphLayoutCover revealed={layoutReady} />
621
- {canvasOverlay}
622
- </div>
623
- </div>
624
- );
625
- }
626
-
627
- /** Detail panel for the selected component — the verifiable identity + sources
628
- * moved off the canvas so nodes stay minimal. File content lives in the
629
- * bottom FileDrawer, not here. */
630
- function ComponentDetail({ component }: { component: SubsystemComponent }) {
631
- const { theme } = useTheme();
632
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
633
- const color = KIND_COLOR[component.kind] ?? '#888';
634
- const displayName = deriveNameFromSymbol(component.symbol, component.kind, component.name, component.file);
635
- const rows: Array<[string, string]> = [];
636
- if (component.symbol) rows.push(['Symbol', component.symbol]);
637
- if (component.file) rows.push(['File', component.file]);
638
- if (component.purpose) rows.push(['Purpose', component.purpose]);
639
- rows.push(['PURL', component.purl]);
640
-
641
- return (
642
- <div
643
- style={{
644
- borderTop: `1px solid ${theme.colors.border}`,
645
- background: theme.colors.background,
646
- padding: '10px 12px',
647
- fontFamily: theme.fonts.body,
648
- }}
649
- >
650
- <div
651
- style={{
652
- display: 'flex',
653
- alignItems: 'center',
654
- gap: 8,
655
- fontSize: theme.fontSizes[1],
656
- fontWeight: 600,
657
- marginBottom: 6,
658
- }}
659
- >
660
- <span style={{ color }}>{displayName}</span>
661
- <span
662
- style={{
663
- fontSize: theme.fontSizes[0] * 0.8,
664
- fontFamily: theme.fonts.monospace,
665
- textTransform: 'uppercase',
666
- color,
667
- }}
668
- >
669
- {component.kind}
670
- </span>
671
- </div>
672
- {rows.map(([k, v]) => (
673
- <div
674
- key={k}
675
- style={{ display: 'flex', gap: 8, fontSize: theme.fontSizes[0], lineHeight: 1.5 }}
676
- >
677
- <span
678
- style={{
679
- width: 64,
680
- flexShrink: 0,
681
- color: muted,
682
- fontFamily: theme.fonts.monospace,
683
- textTransform: 'uppercase',
684
- fontSize: theme.fontSizes[0] * 0.8,
685
- }}
686
- >
687
- {k}
688
- </span>
689
- <span style={{ color: theme.colors.text, fontFamily: theme.fonts.monospace }}>
690
- {v}
691
- </span>
692
- </div>
693
- ))}
694
- {component.detail && <GraphifyDetailSections detail={component.detail} />}
695
- </div>
696
- );
697
- }
698
-
699
- /** Bottom panel that slides up from the bottom of the graph area to show a
700
- * file's contents — opened by node clicks and sidebar file-tree clicks
701
- * alike. Sits in normal flow (canvas shrinks while open, nothing covered)
702
- * and animates via height; stays mounted so open/close animates. */
703
- function FileDrawer({
704
- file,
705
- onClose,
706
- children,
707
- }: {
708
- file: string | null;
709
- onClose: () => void;
710
- children?: ReactNode;
711
- }) {
712
- const { theme } = useTheme();
713
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
714
- const open = file !== null;
715
-
716
- useEffect(() => {
717
- if (!open) return;
718
- const onKey = (e: KeyboardEvent) => {
719
- if (e.key === 'Escape') onClose();
720
- };
721
- window.addEventListener('keydown', onKey);
722
- return () => window.removeEventListener('keydown', onKey);
723
- }, [open, onClose]);
724
-
725
- return (
726
- <div
727
- style={{
728
- position: 'relative',
729
- // Above the absolute edge-label overlay (zIndex 5), which spans the
730
- // whole graph-area container including this panel's slice.
731
- zIndex: 6,
732
- flexShrink: 0,
733
- height: open ? '45%' : 0,
734
- minHeight: 0,
735
- overflow: 'hidden',
736
- display: 'flex',
737
- flexDirection: 'column',
738
- background: theme.colors.background,
739
- borderTop: open ? `1px solid ${theme.colors.border}` : 'none',
740
- transition: 'height 200ms ease',
741
- }}
742
- >
743
- <div
744
- style={{
745
- display: 'flex',
746
- alignItems: 'center',
747
- gap: 8,
748
- padding: '6px 10px',
749
- borderBottom: `1px solid ${theme.colors.border}`,
750
- flexShrink: 0,
751
- }}
752
- >
753
- <span
754
- title={file ?? undefined}
755
- style={{
756
- flex: 1,
757
- minWidth: 0,
758
- overflow: 'hidden',
759
- textOverflow: 'ellipsis',
760
- whiteSpace: 'nowrap',
761
- fontFamily: theme.fonts.monospace,
762
- fontSize: theme.fontSizes[0],
763
- color: muted,
764
- }}
765
- >
766
- {file}
767
- </span>
610
+ {/* Legend button top-left overlay on the canvas; opens the modal. */}
611
+ {usedMechanisms.size > 0 && (
768
612
  <button
769
613
  type="button"
770
- onClick={onClose}
771
- aria-label="Close file"
614
+ onClick={() => setLegendOpen(true)}
772
615
  style={{
616
+ position: 'absolute',
617
+ top: 10,
618
+ left: 10,
619
+ zIndex: 7,
773
620
  display: 'inline-flex',
774
621
  alignItems: 'center',
775
- justifyContent: 'center',
776
- width: 22,
777
- height: 22,
778
- padding: 0,
779
- border: 'none',
780
- borderRadius: 4,
781
- background: 'transparent',
622
+ gap: 6,
623
+ padding: '4px 10px',
624
+ fontSize: theme.fontSizes[0],
625
+ fontFamily: theme.fonts.body,
782
626
  color: theme.colors.text,
627
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
628
+ border: `1px solid ${theme.colors.border}`,
629
+ borderRadius: 6,
783
630
  cursor: 'pointer',
631
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
784
632
  }}
785
633
  >
786
- <X size={14} />
634
+ <MapIcon size={13} />
635
+ Legend
787
636
  </button>
637
+ )}
638
+ <EdgeLegendModal
639
+ open={legendOpen}
640
+ mechanisms={usedMechanisms}
641
+ onClose={() => setLegendOpen(false)}
642
+ />
643
+ {selected && (
644
+ <ComponentDetail
645
+ component={selected}
646
+ onOpenFile={onTreeSelectFile}
647
+ onRelatedSelect={resolveRelatedComponent}
648
+ />
649
+ )}
650
+ <FileDrawer file={openFile} onClose={() => setOpenFile(null)}>
651
+ <DrawerContent render={renderDrawerContent} file={openFile} />
652
+ </FileDrawer>
653
+ {/* Startup cover — hides measurement, layout swap, and camera settle. */}
654
+ <GraphLayoutCover revealed={layoutReady} />
655
+ {canvasOverlay}
788
656
  </div>
789
- <div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>{children}</div>
790
657
  </div>
791
658
  );
792
659
  }
793
660
 
794
- /** Renders the graphify drill-down payload for a component. */
795
- function GraphifyDetailSections({ detail }: { detail: GraphifyComponentDetail }) {
796
- const { theme } = useTheme();
797
- const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
798
-
799
- const label = (k: string) => (
800
- <span
801
- style={{
802
- width: 64,
803
- flexShrink: 0,
804
- color: muted,
805
- fontFamily: theme.fonts.monospace,
806
- textTransform: 'uppercase',
807
- fontSize: theme.fontSizes[0] * 0.8,
808
- }}
809
- >
810
- {k}
811
- </span>
812
- );
813
- const chip = (name: string, meta?: string, key?: string | number) => (
814
- <span
815
- key={key}
816
- style={{
817
- fontFamily: theme.fonts.monospace,
818
- fontSize: theme.fontSizes[0],
819
- color: theme.colors.text,
820
- border: `1px solid ${theme.colors.border}`,
821
- borderRadius: 4,
822
- padding: '1px 6px',
823
- marginRight: 6,
824
- marginBottom: 4,
825
- display: 'inline-block',
826
- }}
827
- >
828
- {name}
829
- {meta ? <span style={{ color: muted }}> {meta}</span> : ''}
830
- </span>
831
- );
832
- const row = (k: string, children: ReactNode) => (
833
- <div
834
- style={{ display: 'flex', gap: 8, fontSize: theme.fontSizes[0], lineHeight: 1.5, marginBottom: 4 }}
835
- >
836
- {label(k)}
837
- <div style={{ minWidth: 0 }}>{children}</div>
838
- </div>
839
- );
840
-
841
- switch (detail.kind) {
842
- case 'class':
843
- return (
844
- <>
845
- {detail.methods.length > 0 &&
846
- row('methods', detail.methods.map((m, i) => chip(m.name, m.returnType, i)))}
847
- {detail.properties.length > 0 &&
848
- row('props', detail.properties.map((p, i) => chip(p.name, p.type, i)))}
849
- {detail.extends.length > 0 && row('extends', detail.extends.map((e, i) => chip(e, undefined, i)))}
850
- {detail.implements.length > 0 && row('impl', detail.implements.map((e, i) => chip(e, undefined, i)))}
851
- {detail.instantiations.length > 0 &&
852
- row('insts', detail.instantiations.map((i, idx) => chip(i.name, undefined, idx)))}
853
- {detail.references.length > 0 &&
854
- row('refs', detail.references.map((r, idx) => chip(r.name, undefined, idx)))}
855
- </>
856
- );
857
- case 'function':
858
- return (
859
- <>
860
- {detail.parameters.length > 0 &&
861
- row('params', detail.parameters.map((p, i) => chip(p.type, undefined, i)))}
862
- {detail.returnType && row('return', <span>{detail.returnType}</span>)}
863
- {detail.callers.length > 0 &&
864
- row('callers', detail.callers.map((c, i) => chip(c.name, undefined, i)))}
865
- {detail.callees.length > 0 &&
866
- row('callees', detail.callees.map((c, i) => chip(c.name, undefined, i)))}
867
- </>
868
- );
869
- case 'type':
870
- return (
871
- <>
872
- {detail.properties.length > 0 &&
873
- row('props', detail.properties.map((p, i) => chip(p.name, p.type, i)))}
874
- {detail.usedBy.length > 0 &&
875
- row('used by', detail.usedBy.map((u, i) => chip(u.name, undefined, i)))}
876
- {detail.implementors.length > 0 &&
877
- row('impl', detail.implementors.map((e, i) => chip(e, undefined, i)))}
878
- </>
879
- );
880
- case 'module':
881
- return (
882
- <>
883
- {detail.exports.length > 0 &&
884
- row('exports', detail.exports.map((e, i) => chip(e, undefined, i)))}
885
- {detail.imports.length > 0 &&
886
- row('imports', detail.imports.map((i, idx) => chip(i.name, undefined, idx)))}
887
- {detail.symbols.length > 0 &&
888
- row('symbols', detail.symbols.map((s, i) => chip(s, undefined, i)))}
889
- </>
890
- );
891
- case 'external':
892
- return row('source', <span>{detail.label}</span>);
893
- }
894
- }
895
-
896
661
  export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
897
662
  const wrapRef = useRef<HTMLDivElement>(null);
898
663
  const [size, setSize] = useState<{ w: number; h: number } | null>(null);
@@ -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
  });
@@ -119,6 +119,30 @@ export function deriveNameFromSymbol(
119
119
  return existingName ?? 'untitled';
120
120
  }
121
121
 
122
+ /**
123
+ * Human-readable purl identity — drops the `pkg:<type>/` scheme wrapper and
124
+ * trailing version, keeping the package / owner-repo identity:
125
+ *
126
+ * pkg:npm/@principal-ai/core → `@principal-ai/core`
127
+ * pkg:github/principal-ai/my-repo → `principal-ai/my-repo`
128
+ * pkg:npm/left-pad@1.3.0 → `left-pad`
129
+ * pkg:generic/local--Users-me-my-app → `Users-me-my-app (local)`
130
+ *
131
+ * Local purls encode the absolute path with dashes for slashes, which is not
132
+ * losslessly decodable — shown as-is with a `(local)` marker.
133
+ */
134
+ export function formatPurl(purl: string): string {
135
+ const match = /^pkg:[^/#?]+\/(.+)$/.exec(purl);
136
+ if (!match) return purl;
137
+ let identity = match[1].split(/[?#]/)[0];
138
+ const at = identity.indexOf('@');
139
+ if (at > 0) identity = identity.slice(0, at); // trailing @version
140
+ if (identity.startsWith('local--')) {
141
+ return `${identity.slice('local--'.length)} (local)`;
142
+ }
143
+ return identity;
144
+ }
145
+
122
146
  export interface SubsystemGraphDocument {
123
147
  components: SubsystemComponent[];
124
148
  edges: SubsystemComponentEdge[];
@@ -136,9 +160,6 @@ export interface SubsystemGraphNodeData extends Record<string, unknown> {
136
160
  * lives in that file (spotlighted), false otherwise (dimmed). Absent when
137
161
  * no file is open — render neutrally. */
138
162
  fileMatch?: boolean;
139
- /** False hides the click-to-open file chip under the component name.
140
- * Absent/true renders it (default). */
141
- showFileBadges?: boolean;
142
163
  }
143
164
 
144
165
  export type SubsystemGraphNode = Node<SubsystemGraphNodeData, SubsystemGraphNodeType>;
@@ -177,7 +198,7 @@ export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
177
198
  'registers-into': '#ff6b35', // orange
178
199
  };
179
200
 
180
- const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed' | 'dotted'> = {
201
+ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed' | 'dotted'> = {
181
202
  imports: 'solid',
182
203
  imports_from: 'solid',
183
204
  re_exports: 'solid',
@@ -2,8 +2,8 @@
2
2
  * Subsystem component/group node + edge renderers.
3
3
  *
4
4
  * Lightweight, purpose-built for the subsystem component graph: a component
5
- * renders its name (kind-tagged, colored by package) plus its symbol/purpose
6
- * and a click-to-open file chip. Groups render as package containers. Clicking
5
+ * renders its name (kind-tagged, colored by package) plus its symbol/purpose.
6
+ * Groups render as package containers. Clicking
7
7
  * a component calls `onSelect` (to open the entry point / file).
8
8
  */
9
9
 
@@ -72,7 +72,6 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
72
72
  // Set while a file is open in the drawer: true → spotlight, false → dim,
73
73
  // absent (no file open) → neutral.
74
74
  const fileMatch = data.fileMatch as boolean | undefined;
75
- const showFileBadges = data.showFileBadges !== false;
76
75
 
77
76
  return (
78
77
  <div
@@ -204,32 +203,6 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
204
203
  </div>
205
204
  )}
206
205
 
207
- {c.file && showFileBadges && (
208
- <div
209
- onClick={(e) => {
210
- e.stopPropagation();
211
- SUBSYSTEM_CALLBACKS.onSelect?.(c.id);
212
- }}
213
- style={{
214
- alignSelf: 'center',
215
- marginTop: 3,
216
- fontSize: theme.fontSizes[0] * 0.78,
217
- fontFamily: theme.fonts.monospace,
218
- color: theme.colors.primary,
219
- border: `1px solid ${theme.colors.border}`,
220
- borderRadius: 4,
221
- padding: '1px 5px',
222
- cursor: 'pointer',
223
- maxWidth: '100%',
224
- overflow: 'hidden',
225
- textOverflow: 'ellipsis',
226
- whiteSpace: 'nowrap',
227
- }}
228
- >
229
- {c.file.split('/').pop()}
230
- </div>
231
- )}
232
-
233
206
  <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
234
207
  <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
235
208
  </div>