@principal-ai/principal-view-react 0.16.37 → 0.16.39
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/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/subsystem/ComponentDeclaration.d.ts +21 -0
- package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -0
- package/dist/subsystem/ComponentDeclaration.js +136 -0
- package/dist/subsystem/ComponentDeclaration.js.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +147 -93
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/SubsystemFileTree.d.ts +4 -1
- package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
- package/dist/subsystem/SubsystemFileTree.js +44 -18
- package/dist/subsystem/SubsystemFileTree.js.map +1 -1
- package/dist/subsystem/paths.d.ts +25 -0
- package/dist/subsystem/paths.d.ts.map +1 -1
- package/dist/subsystem/paths.js +34 -0
- package/dist/subsystem/paths.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +3 -0
- package/src/stories/SubsystemComponentGraph.stories.tsx +40 -0
- package/src/subsystem/{ComponentDetail.tsx → ComponentDeclaration.tsx} +5 -6
- package/src/subsystem/SubsystemComponentGraph.tsx +183 -61
- package/src/subsystem/SubsystemFileTree.tsx +68 -36
- package/src/subsystem/paths.test.ts +62 -1
- package/src/subsystem/paths.ts +58 -0
|
@@ -44,10 +44,10 @@ import {
|
|
|
44
44
|
import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
|
|
45
45
|
import { SubsystemFileTree } from './SubsystemFileTree';
|
|
46
46
|
import { GraphLayoutCover } from './GraphLayoutCover';
|
|
47
|
-
import {
|
|
47
|
+
import { ComponentDeclaration } from './ComponentDeclaration';
|
|
48
48
|
import { FileDrawer } from './FileDrawer';
|
|
49
49
|
import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
|
|
50
|
-
import {
|
|
50
|
+
import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
|
|
51
51
|
|
|
52
52
|
export interface SubsystemComponentGraphProps {
|
|
53
53
|
components: SubsystemComponent[];
|
|
@@ -308,29 +308,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
308
308
|
return () => clearTimeout(t);
|
|
309
309
|
}, [baseNodesKey, baseEdgesKey, fitView]);
|
|
310
310
|
|
|
311
|
-
// When the file drawer opens
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
const prevOpenFileRef = useRef<string | null>(null);
|
|
315
|
-
useEffect(() => {
|
|
316
|
-
const prev = prevOpenFileRef.current;
|
|
317
|
-
prevOpenFileRef.current = openFile;
|
|
318
|
-
if (!layoutReady || prev === openFile) return;
|
|
319
|
-
const t = setTimeout(() => {
|
|
320
|
-
if (openFile) {
|
|
321
|
-
const ids = xyflowNodesBase
|
|
322
|
-
.filter((n) => (n.data as { component?: SubsystemComponent } | undefined)?.component?.file === openFile)
|
|
323
|
-
.map((n) => ({ id: n.id }));
|
|
324
|
-
if (ids.length > 0) {
|
|
325
|
-
fitView({ nodes: ids, padding: 0.35, minZoom: 0.05, maxZoom: 2, duration: 250 });
|
|
326
|
-
}
|
|
327
|
-
} else {
|
|
328
|
-
fitView({ padding: 0.1, includeHiddenNodes: false, minZoom: 0.05, maxZoom: 2, duration: 250 });
|
|
329
|
-
}
|
|
330
|
-
}, 230);
|
|
331
|
-
return () => clearTimeout(t);
|
|
332
|
-
}, [openFile, layoutReady, xyflowNodesBase, fitView]);
|
|
333
|
-
|
|
311
|
+
// When the file drawer opens or closes the canvas is resized but the
|
|
312
|
+
// camera stays put — no refit/zoom. Spotlight + dimming already convey
|
|
313
|
+
// which nodes belong to the open file.
|
|
334
314
|
const onNodeClick: NodeMouseHandler = useCallback(
|
|
335
315
|
(_e, node: Node) => {
|
|
336
316
|
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
@@ -362,14 +342,24 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
362
342
|
setSelectedEdgeId(null);
|
|
363
343
|
}, []);
|
|
364
344
|
|
|
365
|
-
// Sidebar file
|
|
366
|
-
//
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
345
|
+
// Sidebar file trees — one per repo on multi-repo graphs, each under its
|
|
346
|
+
// own owner-avatar header. Clicking a header collapses that repo's tree.
|
|
347
|
+
const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
|
|
348
|
+
const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set());
|
|
349
|
+
const toggleRepoCollapsed = useCallback((key: string) => {
|
|
350
|
+
setCollapsedRepos((prev) => {
|
|
351
|
+
const next = new Set(prev);
|
|
352
|
+
if (next.has(key)) next.delete(key);
|
|
353
|
+
else next.add(key);
|
|
354
|
+
return next;
|
|
355
|
+
});
|
|
356
|
+
}, []);
|
|
357
|
+
const treeFiles = useMemo(
|
|
358
|
+
() => repoGroups.groups.flatMap((g) => g.entries.map((e) => e.file)),
|
|
359
|
+
[repoGroups],
|
|
360
|
+
);
|
|
370
361
|
const onTreeSelectFile = useCallback(
|
|
371
|
-
(
|
|
372
|
-
const file = treeMapping.toFile.get(displayPath) ?? displayPath;
|
|
362
|
+
(file: string) => {
|
|
373
363
|
if (openFileRef.current === file) {
|
|
374
364
|
setOpenFile(null);
|
|
375
365
|
return;
|
|
@@ -377,7 +367,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
377
367
|
setOpenFile(file);
|
|
378
368
|
onFileSelect?.(file);
|
|
379
369
|
},
|
|
380
|
-
[
|
|
370
|
+
[onFileSelect],
|
|
381
371
|
);
|
|
382
372
|
|
|
383
373
|
// Detail-panel links: related-name clicks select the matching component —
|
|
@@ -420,12 +410,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
420
410
|
return new Set(edgeLabels.map((l) => l.mechanism));
|
|
421
411
|
}, [edgeLabels]);
|
|
422
412
|
|
|
423
|
-
// Unique source files across components → sidebar file
|
|
424
|
-
|
|
425
|
-
const treeFilePaths = useMemo(
|
|
426
|
-
() => treeMapping.entries.map((e) => e.displayPath).sort(),
|
|
427
|
-
[treeMapping],
|
|
428
|
-
);
|
|
413
|
+
// Unique source files across components → sidebar file trees.
|
|
414
|
+
const treeFilePaths = treeFiles;
|
|
429
415
|
|
|
430
416
|
// The hovered node's file (null when not hovering / file-less component).
|
|
431
417
|
const hoveredFile = useMemo(() => {
|
|
@@ -516,23 +502,60 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
516
502
|
{sidebarAfterDescription}
|
|
517
503
|
</div>
|
|
518
504
|
{treeFilePaths.length > 0 && (
|
|
519
|
-
<
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
505
|
+
<div
|
|
506
|
+
style={{
|
|
507
|
+
height: '50%',
|
|
508
|
+
minHeight: 160,
|
|
509
|
+
flexShrink: 0,
|
|
510
|
+
borderTop: `1px solid ${theme.colors.border}`,
|
|
511
|
+
display: 'flex',
|
|
512
|
+
flexDirection: 'column',
|
|
513
|
+
overflow: 'hidden',
|
|
514
|
+
}}
|
|
515
|
+
>
|
|
516
|
+
{repoGroups.groups.map((group, i) => {
|
|
517
|
+
const groupKey = group.repoKey ?? '__no-repo__';
|
|
518
|
+
const collapsed = collapsedRepos.has(groupKey);
|
|
519
|
+
return (
|
|
520
|
+
<div
|
|
521
|
+
key={groupKey}
|
|
522
|
+
style={{
|
|
523
|
+
flex: collapsed ? '0 0 auto' : 1,
|
|
524
|
+
minHeight: collapsed ? 0 : undefined,
|
|
525
|
+
display: 'flex',
|
|
526
|
+
flexDirection: 'column',
|
|
527
|
+
borderTop: i > 0 ? `1px solid ${theme.colors.border}` : undefined,
|
|
528
|
+
}}
|
|
529
|
+
>
|
|
530
|
+
{repoGroups.multiRepo && (
|
|
531
|
+
<RepoGroupHeader
|
|
532
|
+
group={group}
|
|
533
|
+
collapsed={collapsed}
|
|
534
|
+
onToggle={() => toggleRepoCollapsed(groupKey)}
|
|
535
|
+
/>
|
|
536
|
+
)}
|
|
537
|
+
{!collapsed && (
|
|
538
|
+
<SubsystemFileTree
|
|
539
|
+
files={group.entries.map((e) => e.displayPath)}
|
|
540
|
+
selectedFile={selected?.file ?? openFile}
|
|
541
|
+
hoveredFile={hoveredFile}
|
|
542
|
+
onSelectFile={onTreeSelectFile}
|
|
543
|
+
headerless={repoGroups.multiRepo}
|
|
544
|
+
/>
|
|
545
|
+
)}
|
|
546
|
+
</div>
|
|
547
|
+
);
|
|
548
|
+
})}
|
|
549
|
+
</div>
|
|
530
550
|
)}
|
|
531
551
|
</div>
|
|
532
552
|
)}
|
|
533
553
|
|
|
534
554
|
{/* Graph area */}
|
|
535
555
|
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
|
556
|
+
{/* Canvas box — the edge-label overlay and legend anchor here, so
|
|
557
|
+
overlays shift with the canvas, not the labels. */}
|
|
558
|
+
<div style={{ position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
|
536
559
|
{/* Edge-label overlay — sits above the ReactFlow pane so clicks land. */}
|
|
537
560
|
{showEdgeLabels !== false && (
|
|
538
561
|
<div
|
|
@@ -646,18 +669,38 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
646
669
|
Legend
|
|
647
670
|
</button>
|
|
648
671
|
)}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
672
|
+
{/* Selected-component declaration — floating card over the canvas
|
|
673
|
+
(top-right, clear of the top-left legend button). The graph never
|
|
674
|
+
moves for it. */}
|
|
675
|
+
{selected && (
|
|
676
|
+
<div
|
|
677
|
+
style={{
|
|
678
|
+
position: 'absolute',
|
|
679
|
+
top: 10,
|
|
680
|
+
right: 10,
|
|
681
|
+
zIndex: 8,
|
|
682
|
+
maxWidth: 'min(560px, 70%)',
|
|
683
|
+
maxHeight: '46%',
|
|
684
|
+
overflowY: 'auto',
|
|
685
|
+
background: theme.colors.background,
|
|
686
|
+
border: `1px solid ${theme.colors.border}`,
|
|
687
|
+
borderRadius: 8,
|
|
688
|
+
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
|
|
689
|
+
}}
|
|
690
|
+
>
|
|
691
|
+
<ComponentDeclaration
|
|
692
|
+
component={selected}
|
|
693
|
+
onOpenFile={onTreeSelectFile}
|
|
694
|
+
onRelatedSelect={resolveRelatedComponent}
|
|
695
|
+
/>
|
|
696
|
+
</div>
|
|
697
|
+
)}
|
|
698
|
+
</div>
|
|
699
|
+
<EdgeLegendModal
|
|
700
|
+
open={legendOpen}
|
|
701
|
+
mechanisms={usedMechanisms}
|
|
702
|
+
onClose={() => setLegendOpen(false)}
|
|
659
703
|
/>
|
|
660
|
-
)}
|
|
661
704
|
<FileDrawer file={openFile} onClose={() => setOpenFile(null)}>
|
|
662
705
|
<DrawerContent render={renderDrawerContent} file={openFile} />
|
|
663
706
|
</FileDrawer>
|
|
@@ -669,6 +712,85 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
669
712
|
);
|
|
670
713
|
}
|
|
671
714
|
|
|
715
|
+
/** Sidebar header for one repo's tree: owner avatar + repo name + file count.
|
|
716
|
+
* Clicking toggles the tree's visibility. */
|
|
717
|
+
function RepoGroupHeader({
|
|
718
|
+
group,
|
|
719
|
+
collapsed,
|
|
720
|
+
onToggle,
|
|
721
|
+
}: {
|
|
722
|
+
group: RepoGroup;
|
|
723
|
+
collapsed: boolean;
|
|
724
|
+
onToggle: () => void;
|
|
725
|
+
}) {
|
|
726
|
+
const { theme } = useTheme();
|
|
727
|
+
const avatar = repoAvatarUrl(group.repoKey);
|
|
728
|
+
const label = group.repo ?? 'No repo';
|
|
729
|
+
return (
|
|
730
|
+
<div
|
|
731
|
+
role="button"
|
|
732
|
+
aria-expanded={!collapsed}
|
|
733
|
+
onClick={(e) => {
|
|
734
|
+
e.stopPropagation();
|
|
735
|
+
onToggle();
|
|
736
|
+
}}
|
|
737
|
+
title={collapsed ? 'Show files' : 'Hide files'}
|
|
738
|
+
style={{
|
|
739
|
+
display: 'flex',
|
|
740
|
+
alignItems: 'center',
|
|
741
|
+
gap: 8,
|
|
742
|
+
padding: '10px 16px 4px',
|
|
743
|
+
flexShrink: 0,
|
|
744
|
+
cursor: 'pointer',
|
|
745
|
+
userSelect: 'none',
|
|
746
|
+
}}
|
|
747
|
+
>
|
|
748
|
+
{avatar ? (
|
|
749
|
+
<img
|
|
750
|
+
src={avatar}
|
|
751
|
+
alt=""
|
|
752
|
+
width={16}
|
|
753
|
+
height={16}
|
|
754
|
+
loading="lazy"
|
|
755
|
+
style={{ borderRadius: 4, flexShrink: 0, background: theme.colors.border }}
|
|
756
|
+
/>
|
|
757
|
+
) : group.owner ? (
|
|
758
|
+
<span
|
|
759
|
+
style={{
|
|
760
|
+
width: 16,
|
|
761
|
+
height: 16,
|
|
762
|
+
borderRadius: 4,
|
|
763
|
+
flexShrink: 0,
|
|
764
|
+
display: 'inline-flex',
|
|
765
|
+
alignItems: 'center',
|
|
766
|
+
justifyContent: 'center',
|
|
767
|
+
fontSize: 9,
|
|
768
|
+
fontWeight: 700,
|
|
769
|
+
fontFamily: theme.fonts.monospace,
|
|
770
|
+
color: theme.colors.text,
|
|
771
|
+
background: theme.colors.border,
|
|
772
|
+
}}
|
|
773
|
+
>
|
|
774
|
+
{group.owner.charAt(0).toUpperCase()}
|
|
775
|
+
</span>
|
|
776
|
+
) : null}
|
|
777
|
+
<span
|
|
778
|
+
style={{
|
|
779
|
+
fontSize: theme.fontSizes[1],
|
|
780
|
+
fontFamily: theme.fonts.monospace,
|
|
781
|
+
color: theme.colors.text,
|
|
782
|
+
fontWeight: 600,
|
|
783
|
+
overflow: 'hidden',
|
|
784
|
+
textOverflow: 'ellipsis',
|
|
785
|
+
whiteSpace: 'nowrap',
|
|
786
|
+
}}
|
|
787
|
+
>
|
|
788
|
+
{label}
|
|
789
|
+
</span>
|
|
790
|
+
</div>
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
672
794
|
export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
|
|
673
795
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
674
796
|
const [size, setSize] = useState<{ w: number; h: number } | null>(null);
|
|
@@ -20,10 +20,13 @@ export interface SubsystemFileTreeProps {
|
|
|
20
20
|
hoveredFile?: string | null;
|
|
21
21
|
/** Called when a file row is clicked; upstream toggles the drawer. */
|
|
22
22
|
onSelectFile?: (file: string) => void;
|
|
23
|
+
/** Hide the built-in "Files N" header row — used when an upstream repo
|
|
24
|
+
* header already labels the tree. */
|
|
25
|
+
headerless?: boolean;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
/** Fills its parent height by design — pin it with a sized flex container. */
|
|
26
|
-
export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFile }: SubsystemFileTreeProps) {
|
|
29
|
+
export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFile, headerless }: SubsystemFileTreeProps) {
|
|
27
30
|
const { theme } = useTheme();
|
|
28
31
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
29
32
|
const paths = useMemo(() => Array.from(new Set(files)).sort(), [files]);
|
|
@@ -57,26 +60,54 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFi
|
|
|
57
60
|
},
|
|
58
61
|
});
|
|
59
62
|
|
|
60
|
-
// Reconcile the tree's native selection with the
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
+
// Reconcile the tree's native selection with the OPEN file only. Hover is
|
|
64
|
+
// deliberately NOT folded into selection — pierre's model is single-select,
|
|
65
|
+
// so routing hover through select()/deselect() would steal the highlight
|
|
66
|
+
// from the open file. The hovered row gets its own transient marker instead.
|
|
63
67
|
const lastSyncedRef = useRef<string | null>(null);
|
|
64
68
|
useEffect(() => {
|
|
65
|
-
|
|
66
|
-
if (lastSyncedRef.current === target) return;
|
|
69
|
+
if (lastSyncedRef.current === (selectedFile ?? null)) return;
|
|
67
70
|
const prev = lastSyncedRef.current;
|
|
68
|
-
lastSyncedRef.current =
|
|
71
|
+
lastSyncedRef.current = selectedFile ?? null;
|
|
69
72
|
suppressSelectRef.current = true;
|
|
70
73
|
try {
|
|
71
74
|
if (prev) model.getItem(prev)?.deselect();
|
|
72
|
-
if (
|
|
73
|
-
model.getItem(
|
|
74
|
-
model.scrollToPath(
|
|
75
|
+
if (selectedFile) {
|
|
76
|
+
model.getItem(selectedFile)?.select();
|
|
77
|
+
model.scrollToPath(selectedFile);
|
|
75
78
|
}
|
|
76
79
|
} finally {
|
|
77
80
|
suppressSelectRef.current = false;
|
|
78
81
|
}
|
|
79
|
-
}, [model, selectedFile
|
|
82
|
+
}, [model, selectedFile]);
|
|
83
|
+
|
|
84
|
+
// Transient hover highlight (e.g. hovering a graph node spotlights its
|
|
85
|
+
// file), independent of selection. Pierre renders rows inside a shadow
|
|
86
|
+
// root, so we resolve the row through shadow boundaries and paint an
|
|
87
|
+
// inline background — document-level <style> tags cannot reach it.
|
|
88
|
+
const treeRef = useRef<HTMLDivElement | null>(null);
|
|
89
|
+
const hoveredRowRef = useRef<HTMLElement | null>(null);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
hoveredRowRef.current?.style.removeProperty('background');
|
|
92
|
+
hoveredRowRef.current = null;
|
|
93
|
+
const target = hoveredFile ?? null;
|
|
94
|
+
if (!target || !pathSetRef.current.has(target)) return;
|
|
95
|
+
|
|
96
|
+
// Depth-first walk across shadow roots — handles nested custom elements.
|
|
97
|
+
const roots: Array<Document | ShadowRoot> = [document];
|
|
98
|
+
while (roots.length > 0) {
|
|
99
|
+
const root = roots.shift()!;
|
|
100
|
+
const row = root.querySelector(`[data-item-path="${CSS.escape(target)}"]`);
|
|
101
|
+
if (row instanceof HTMLElement) {
|
|
102
|
+
row.style.background = 'var(--trees-theme-list-hover-bg, rgba(128, 128, 128, 0.25))';
|
|
103
|
+
hoveredRowRef.current = row;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
|
107
|
+
if (el.shadowRoot) roots.push(el.shadowRoot);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}, [hoveredFile]);
|
|
80
111
|
|
|
81
112
|
// Re-scope the tree in place when the subsystem's file set changes.
|
|
82
113
|
useEffect(() => {
|
|
@@ -101,40 +132,41 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFi
|
|
|
101
132
|
|
|
102
133
|
return (
|
|
103
134
|
<div
|
|
135
|
+
ref={treeRef}
|
|
104
136
|
onClickCapture={handleContainerClick}
|
|
105
137
|
style={{
|
|
106
|
-
|
|
107
|
-
minHeight:
|
|
108
|
-
flexShrink: 0,
|
|
109
|
-
borderTop: `1px solid ${theme.colors.border}`,
|
|
138
|
+
flex: 1,
|
|
139
|
+
minHeight: 0,
|
|
110
140
|
display: 'flex',
|
|
111
141
|
flexDirection: 'column',
|
|
112
142
|
}}
|
|
113
143
|
>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
padding: '10px 16px 4px',
|
|
117
|
-
display: 'flex',
|
|
118
|
-
alignItems: 'baseline',
|
|
119
|
-
gap: 6,
|
|
120
|
-
flexShrink: 0,
|
|
121
|
-
}}
|
|
122
|
-
>
|
|
123
|
-
<span
|
|
144
|
+
{!headerless && (
|
|
145
|
+
<div
|
|
124
146
|
style={{
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
147
|
+
padding: '10px 16px 4px',
|
|
148
|
+
display: 'flex',
|
|
149
|
+
alignItems: 'baseline',
|
|
150
|
+
gap: 6,
|
|
151
|
+
flexShrink: 0,
|
|
130
152
|
}}
|
|
131
153
|
>
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
154
|
+
<span
|
|
155
|
+
style={{
|
|
156
|
+
fontSize: theme.fontSizes[0] * 0.8,
|
|
157
|
+
fontFamily: theme.fonts.monospace,
|
|
158
|
+
textTransform: 'uppercase',
|
|
159
|
+
color: muted,
|
|
160
|
+
fontWeight: 600,
|
|
161
|
+
}}
|
|
162
|
+
>
|
|
163
|
+
Files
|
|
164
|
+
</span>
|
|
165
|
+
<span style={{ fontSize: theme.fontSizes[0] * 0.8, fontFamily: theme.fonts.monospace, color: muted }}>
|
|
166
|
+
{paths.length}
|
|
167
|
+
</span>
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
138
170
|
<FileTree
|
|
139
171
|
model={model}
|
|
140
172
|
style={
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildRepoGroups,
|
|
4
|
+
buildTreePathMapping,
|
|
5
|
+
purlOwnerName,
|
|
6
|
+
purlRepoKey,
|
|
7
|
+
repoAvatarUrl,
|
|
8
|
+
} from './paths';
|
|
3
9
|
|
|
4
10
|
describe('purlRepoKey', () => {
|
|
5
11
|
test('strips fragments and whitespace', () => {
|
|
@@ -60,3 +66,58 @@ describe('buildTreePathMapping', () => {
|
|
|
60
66
|
expect(m.toFile.get('loose.ts')).toBe('loose.ts');
|
|
61
67
|
});
|
|
62
68
|
});
|
|
69
|
+
|
|
70
|
+
describe('buildRepoGroups', () => {
|
|
71
|
+
test('groups by repo preserving first-appearance order', () => {
|
|
72
|
+
const { multiRepo, groups } = buildRepoGroups([
|
|
73
|
+
{ file: 'a.ts', purl: 'pkg:github/acme/widget' },
|
|
74
|
+
{ file: 'g/x.py', purl: 'pkg:github/Graphify-Labs/graphify' },
|
|
75
|
+
{ file: 'b.ts', purl: 'pkg:github/acme/widget' },
|
|
76
|
+
]);
|
|
77
|
+
expect(multiRepo).toBe(true);
|
|
78
|
+
expect(groups.map((g) => g.repoKey)).toEqual(['pkg:github/acme/widget', 'pkg:github/Graphify-Labs/graphify']);
|
|
79
|
+
expect(groups[0]!.owner).toBe('acme');
|
|
80
|
+
expect(groups[0]!.repo).toBe('widget');
|
|
81
|
+
expect(groups[0]!.entries.map((e) => e.file)).toEqual(['a.ts', 'b.ts']);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('entries carry bare files (no synthetic prefixes)', () => {
|
|
85
|
+
const { groups } = buildRepoGroups([
|
|
86
|
+
{ file: 'deep/nested/x.ts', purl: 'pkg:github/acme/widget#deep/nested/x.ts' },
|
|
87
|
+
]);
|
|
88
|
+
expect(groups[0]!.entries[0]).toEqual({ displayPath: 'deep/nested/x.ts', file: 'deep/nested/x.ts' });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('purl-less components land in a trailing unbadged group', () => {
|
|
92
|
+
const { multiRepo, groups } = buildRepoGroups([
|
|
93
|
+
{ file: 'loose.ts' },
|
|
94
|
+
{ file: 'a.ts', purl: 'pkg:github/acme/widget' },
|
|
95
|
+
]);
|
|
96
|
+
expect(multiRepo).toBe(false);
|
|
97
|
+
expect(groups).toHaveLength(2);
|
|
98
|
+
expect(groups[0]!.repoKey).toBeUndefined();
|
|
99
|
+
expect(groups[1]!.repoKey).toBe('pkg:github/acme/widget');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('single-repo graphs yield one group and multiRepo=false', () => {
|
|
103
|
+
const { multiRepo, groups } = buildRepoGroups([
|
|
104
|
+
{ file: 'a.ts', purl: 'pkg:github/acme/widget' },
|
|
105
|
+
{ file: 'b.ts', purl: 'pkg:github/acme/widget' },
|
|
106
|
+
]);
|
|
107
|
+
expect(multiRepo).toBe(false);
|
|
108
|
+
expect(groups).toHaveLength(1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('repoAvatarUrl', () => {
|
|
113
|
+
test('github purls map to the owner avatar', () => {
|
|
114
|
+
expect(repoAvatarUrl('pkg:github/Graphify-Labs/graphify#x/y.py')).toBe(
|
|
115
|
+
'https://github.com/Graphify-Labs.png?size=64',
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('non-github purls have no avatar', () => {
|
|
120
|
+
expect(repoAvatarUrl('pkg:npm/@scope/lib')).toBeUndefined();
|
|
121
|
+
expect(repoAvatarUrl(undefined)).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
});
|
package/src/subsystem/paths.ts
CHANGED
|
@@ -67,3 +67,61 @@ export function buildTreePathMapping(components: ReadonlyArray<{ file?: string;
|
|
|
67
67
|
}
|
|
68
68
|
return { entries, multiRepo, toFile, toDisplay };
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Per-repo tree grouping — one sidebar tree per repo, each under its own
|
|
73
|
+
// styled header (owner avatar + repo name).
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export interface RepoGroup {
|
|
77
|
+
/** Purl repo key (`pkg:github/owner/name`) — undefined for purl-less components. */
|
|
78
|
+
repoKey?: string;
|
|
79
|
+
/** `owner` segment of the purl, when present. */
|
|
80
|
+
owner?: string;
|
|
81
|
+
/** Repository name segment of the purl, when present. */
|
|
82
|
+
repo?: string;
|
|
83
|
+
/** The group's files, bare `file` values (each tree scopes its own paths). */
|
|
84
|
+
entries: TreeEntry[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Group components by repo for the multi-tree sidebar. Groups preserve
|
|
89
|
+
* first-appearance order; purl-less components land in a trailing unbadged
|
|
90
|
+
* group. Entries carry bare `file` values — separate trees scope their own
|
|
91
|
+
* path sets, so no synthetic prefixes are needed.
|
|
92
|
+
*/
|
|
93
|
+
export function buildRepoGroups(components: ReadonlyArray<{ file?: string; purl?: string }>): {
|
|
94
|
+
multiRepo: boolean;
|
|
95
|
+
groups: RepoGroup[];
|
|
96
|
+
} {
|
|
97
|
+
const withFiles = components.filter((c) => c.file);
|
|
98
|
+
const repos = new Set(
|
|
99
|
+
withFiles.map((c) => purlRepoKey(c.purl)).filter((k): k is string => !!k),
|
|
100
|
+
);
|
|
101
|
+
const multiRepo = repos.size > 1;
|
|
102
|
+
|
|
103
|
+
const byKey = new Map<string, RepoGroup>();
|
|
104
|
+
const order: Array<string | undefined> = [];
|
|
105
|
+
for (const c of withFiles) {
|
|
106
|
+
const key = purlRepoKey(c.purl);
|
|
107
|
+
let group = byKey.get(key ?? '');
|
|
108
|
+
if (!group) {
|
|
109
|
+
const ownerName = key ? purlOwnerName(c.purl) : undefined;
|
|
110
|
+
const [owner, repo] = ownerName?.split('/') ?? [];
|
|
111
|
+
group = { repoKey: key, owner, repo, entries: [] };
|
|
112
|
+
byKey.set(key ?? '', group);
|
|
113
|
+
order.push(key);
|
|
114
|
+
}
|
|
115
|
+
if (!group.entries.some((e) => e.file === c.file)) {
|
|
116
|
+
group.entries.push({ displayPath: c.file!, file: c.file! });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { multiRepo, groups: order.map((k) => byKey.get(k ?? '')!) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** GitHub owner-avatar URL for a purl, or undefined for non-github hosts. */
|
|
123
|
+
export function repoAvatarUrl(purl: string | undefined): string | undefined {
|
|
124
|
+
const key = purlRepoKey(purl);
|
|
125
|
+
const match = key?.match(/^pkg:github\/([^/]+)\//);
|
|
126
|
+
return match ? `https://github.com/${match[1]}.png?size=64` : undefined;
|
|
127
|
+
}
|