@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.
- package/dist/graphify/consolidated.d.ts +17 -3
- package/dist/graphify/consolidated.d.ts.map +1 -1
- package/dist/graphify/index.d.ts +2 -0
- package/dist/graphify/index.d.ts.map +1 -1
- package/dist/graphify/index.js +1 -1
- package/dist/graphify/index.js.map +1 -1
- package/dist/graphify/resolve.d.ts +48 -0
- package/dist/graphify/resolve.d.ts.map +1 -0
- package/dist/graphify/resolve.js +83 -0
- package/dist/graphify/resolve.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/subsystem/ComponentDetail.d.ts +21 -0
- package/dist/subsystem/ComponentDetail.d.ts.map +1 -0
- package/dist/subsystem/ComponentDetail.js +137 -0
- package/dist/subsystem/ComponentDetail.js.map +1 -0
- package/dist/subsystem/EdgeLegendModal.d.ts +18 -0
- package/dist/subsystem/EdgeLegendModal.d.ts.map +1 -0
- package/dist/subsystem/EdgeLegendModal.js +98 -0
- package/dist/subsystem/EdgeLegendModal.js.map +1 -0
- package/dist/subsystem/FileDrawer.d.ts +16 -0
- package/dist/subsystem/FileDrawer.d.ts.map +1 -0
- package/dist/subsystem/FileDrawer.js +72 -0
- package/dist/subsystem/FileDrawer.js.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts +0 -3
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +57 -191
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +14 -3
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +26 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts +2 -2
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +3 -21
- package/dist/subsystem/nodes.js.map +1 -1
- package/package.json +1 -1
- package/src/graphify/consolidated.ts +17 -3
- package/src/graphify/index.ts +9 -0
- package/src/graphify/resolve.test.ts +89 -0
- package/src/graphify/resolve.ts +115 -0
- package/src/index.ts +10 -0
- package/src/stories/SubsystemComponentGraph.stories.tsx +333 -13
- package/src/subsystem/ComponentDetail.tsx +386 -0
- package/src/subsystem/EdgeLegendModal.tsx +146 -0
- package/src/subsystem/FileDrawer.tsx +105 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +93 -328
- package/src/subsystem/model.test.ts +11 -0
- package/src/subsystem/model.ts +25 -4
- 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 {
|
|
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
|
-
|
|
52
|
-
|
|
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,
|
|
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
|
-
|
|
252
|
-
|
|
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
|
|
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
|
|
440
|
-
{(title || description ||
|
|
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
|
-
{
|
|
616
|
-
|
|
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={
|
|
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
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
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
|
-
<
|
|
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
|
});
|
package/src/subsystem/model.ts
CHANGED
|
@@ -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',
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -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
|
-
*
|
|
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>
|