@principal-ai/principal-view-react 0.16.36 → 0.16.38
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 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +81 -30
- 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 +63 -0
- package/dist/subsystem/paths.d.ts.map +1 -0
- package/dist/subsystem/paths.js +86 -0
- package/dist/subsystem/paths.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +12 -0
- package/src/stories/SubsystemComponentGraph.stories.tsx +40 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +148 -38
- package/src/subsystem/SubsystemFileTree.tsx +68 -36
- package/src/subsystem/paths.test.ts +123 -0
- package/src/subsystem/paths.ts +127 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"paths.js","sourceRoot":"","sources":["../../src/subsystem/paths.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -256,6 +256,18 @@ export {
|
|
|
256
256
|
export { SubsystemComponentGraph } from './subsystem/SubsystemComponentGraph';
|
|
257
257
|
export type { SubsystemComponentGraphProps } from './subsystem/SubsystemComponentGraph';
|
|
258
258
|
export { MECHANISM_COLOR, KIND_COLOR, MECHANISM_STYLE } from './subsystem/model';
|
|
259
|
+
export {
|
|
260
|
+
purlRepoKey,
|
|
261
|
+
purlOwnerName,
|
|
262
|
+
buildTreePathMapping,
|
|
263
|
+
buildRepoGroups,
|
|
264
|
+
repoAvatarUrl,
|
|
265
|
+
} from './subsystem/paths';
|
|
266
|
+
export type {
|
|
267
|
+
TreeEntry,
|
|
268
|
+
TreePathMapping,
|
|
269
|
+
RepoGroup,
|
|
270
|
+
} from './subsystem/paths';
|
|
259
271
|
export { SubsystemFileTree } from './subsystem/SubsystemFileTree';
|
|
260
272
|
export type { SubsystemFileTreeProps } from './subsystem/SubsystemFileTree';
|
|
261
273
|
export { GraphLayoutCover } from './subsystem/GraphLayoutCover';
|
|
@@ -1087,3 +1087,43 @@ function MermaidDemo() {
|
|
|
1087
1087
|
export const MermaidPipeline: Story = {
|
|
1088
1088
|
render: () => <MermaidDemo />,
|
|
1089
1089
|
};
|
|
1090
|
+
|
|
1091
|
+
// ---------------------------------------------------------------------------
|
|
1092
|
+
// Multi-repo: two repos → two sidebar trees, one per repo, each under its own
|
|
1093
|
+
// owner-avatar + repo-name header. Exercises buildRepoGroups grouping,
|
|
1094
|
+
// cross-repo edges (external stub targets), and per-tree selection.
|
|
1095
|
+
// ---------------------------------------------------------------------------
|
|
1096
|
+
const coreLibPurl = 'pkg:github/principal-ai/principal-view-core-library';
|
|
1097
|
+
const graphifyPurl = 'pkg:github/Graphify-Labs/graphify';
|
|
1098
|
+
|
|
1099
|
+
const multiRepoComponents = components([
|
|
1100
|
+
['resolver', 'resolve.ts', 'module', 'packages/react/src/graphify/resolve.ts', coreLibPurl, 'consumer-side resolution mirroring graphify rewire tiers', 'createGraphifyTypeResolver'],
|
|
1101
|
+
['reftypes', 'consolidated.ts', 'module', 'packages/react/src/graphify/consolidated.ts', coreLibPurl, 'drill-down payload types carrying ref.nodeId', 'GraphifyParamInfo'],
|
|
1102
|
+
['detail', 'ComponentDetail.tsx', 'module', 'packages/react/src/subsystem/ComponentDetail.tsx', coreLibPurl, 'declaration-style detail panel with clickable type tokens', 'ComponentDetail'],
|
|
1103
|
+
['engine', 'engine.py', 'module', 'graphify/extractors/engine.py', graphifyPurl, 'tree-sitter walk emitting references[parameter_type] edges + sourceless stubs', 'ensure_named_node'],
|
|
1104
|
+
['rewire', 'extract.py', 'module', 'graphify/extract.py', graphifyPurl, 'corpus pass folding unique-label stubs onto definitions', '_rewire_unique_stub_nodes'],
|
|
1105
|
+
]);
|
|
1106
|
+
|
|
1107
|
+
const multiRepoEdges = edges([
|
|
1108
|
+
['detail', 'reftypes', 'imports'],
|
|
1109
|
+
['resolver', 'reftypes', 'imports'],
|
|
1110
|
+
['resolver', 'engine', 'references'],
|
|
1111
|
+
['rewire', 'resolver', 'calls'],
|
|
1112
|
+
]);
|
|
1113
|
+
|
|
1114
|
+
function MultiRepoDemo() {
|
|
1115
|
+
return (
|
|
1116
|
+
<div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
|
1117
|
+
<SubsystemComponentGraph
|
|
1118
|
+
title="Type-ref resolution across repos"
|
|
1119
|
+
description="Two repos → two file trees. Each tree is scoped to its repo's files and headed by the owner avatar + repo name. Cross-repo edges land on external stubs."
|
|
1120
|
+
components={multiRepoComponents}
|
|
1121
|
+
edges={multiRepoEdges}
|
|
1122
|
+
/>
|
|
1123
|
+
</div>
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
export const MultiRepoTrees: Story = {
|
|
1128
|
+
render: () => <MultiRepoDemo />,
|
|
1129
|
+
};
|
|
@@ -47,6 +47,7 @@ import { GraphLayoutCover } from './GraphLayoutCover';
|
|
|
47
47
|
import { ComponentDetail } from './ComponentDetail';
|
|
48
48
|
import { FileDrawer } from './FileDrawer';
|
|
49
49
|
import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
|
|
50
|
+
import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
|
|
50
51
|
|
|
51
52
|
export interface SubsystemComponentGraphProps {
|
|
52
53
|
components: SubsystemComponent[];
|
|
@@ -307,29 +308,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
307
308
|
return () => clearTimeout(t);
|
|
308
309
|
}, [baseNodesKey, baseEdgesKey, fitView]);
|
|
309
310
|
|
|
310
|
-
// When the file drawer opens
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
const prevOpenFileRef = useRef<string | null>(null);
|
|
314
|
-
useEffect(() => {
|
|
315
|
-
const prev = prevOpenFileRef.current;
|
|
316
|
-
prevOpenFileRef.current = openFile;
|
|
317
|
-
if (!layoutReady || prev === openFile) return;
|
|
318
|
-
const t = setTimeout(() => {
|
|
319
|
-
if (openFile) {
|
|
320
|
-
const ids = xyflowNodesBase
|
|
321
|
-
.filter((n) => (n.data as { component?: SubsystemComponent } | undefined)?.component?.file === openFile)
|
|
322
|
-
.map((n) => ({ id: n.id }));
|
|
323
|
-
if (ids.length > 0) {
|
|
324
|
-
fitView({ nodes: ids, padding: 0.35, minZoom: 0.05, maxZoom: 2, duration: 250 });
|
|
325
|
-
}
|
|
326
|
-
} else {
|
|
327
|
-
fitView({ padding: 0.1, includeHiddenNodes: false, minZoom: 0.05, maxZoom: 2, duration: 250 });
|
|
328
|
-
}
|
|
329
|
-
}, 230);
|
|
330
|
-
return () => clearTimeout(t);
|
|
331
|
-
}, [openFile, layoutReady, xyflowNodesBase, fitView]);
|
|
332
|
-
|
|
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.
|
|
333
314
|
const onNodeClick: NodeMouseHandler = useCallback(
|
|
334
315
|
(_e, node: Node) => {
|
|
335
316
|
const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
|
|
@@ -361,8 +342,22 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
361
342
|
setSelectedEdgeId(null);
|
|
362
343
|
}, []);
|
|
363
344
|
|
|
364
|
-
// Sidebar file
|
|
365
|
-
//
|
|
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
|
+
);
|
|
366
361
|
const onTreeSelectFile = useCallback(
|
|
367
362
|
(file: string) => {
|
|
368
363
|
if (openFileRef.current === file) {
|
|
@@ -415,11 +410,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
415
410
|
return new Set(edgeLabels.map((l) => l.mechanism));
|
|
416
411
|
}, [edgeLabels]);
|
|
417
412
|
|
|
418
|
-
// Unique source files across components → sidebar file
|
|
419
|
-
const
|
|
420
|
-
() => Array.from(new Set(components.map((c) => c.file).filter(Boolean))).sort(),
|
|
421
|
-
[components],
|
|
422
|
-
);
|
|
413
|
+
// Unique source files across components → sidebar file trees.
|
|
414
|
+
const treeFilePaths = treeFiles;
|
|
423
415
|
|
|
424
416
|
// The hovered node's file (null when not hovering / file-less component).
|
|
425
417
|
const hoveredFile = useMemo(() => {
|
|
@@ -455,7 +447,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
455
447
|
return (
|
|
456
448
|
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
|
|
457
449
|
{/* Sidebar: scrollable title/description on top, file tree pinned to the bottom half */}
|
|
458
|
-
{(title || description || sidebarExtra || sidebarAfterDescription ||
|
|
450
|
+
{(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0) && (
|
|
459
451
|
<div
|
|
460
452
|
style={{
|
|
461
453
|
width: 340,
|
|
@@ -509,13 +501,52 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
509
501
|
)}
|
|
510
502
|
{sidebarAfterDescription}
|
|
511
503
|
</div>
|
|
512
|
-
{
|
|
513
|
-
<
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
504
|
+
{treeFilePaths.length > 0 && (
|
|
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>
|
|
519
550
|
)}
|
|
520
551
|
</div>
|
|
521
552
|
)}
|
|
@@ -658,6 +689,85 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
658
689
|
);
|
|
659
690
|
}
|
|
660
691
|
|
|
692
|
+
/** Sidebar header for one repo's tree: owner avatar + repo name + file count.
|
|
693
|
+
* Clicking toggles the tree's visibility. */
|
|
694
|
+
function RepoGroupHeader({
|
|
695
|
+
group,
|
|
696
|
+
collapsed,
|
|
697
|
+
onToggle,
|
|
698
|
+
}: {
|
|
699
|
+
group: RepoGroup;
|
|
700
|
+
collapsed: boolean;
|
|
701
|
+
onToggle: () => void;
|
|
702
|
+
}) {
|
|
703
|
+
const { theme } = useTheme();
|
|
704
|
+
const avatar = repoAvatarUrl(group.repoKey);
|
|
705
|
+
const label = group.repo ?? 'No repo';
|
|
706
|
+
return (
|
|
707
|
+
<div
|
|
708
|
+
role="button"
|
|
709
|
+
aria-expanded={!collapsed}
|
|
710
|
+
onClick={(e) => {
|
|
711
|
+
e.stopPropagation();
|
|
712
|
+
onToggle();
|
|
713
|
+
}}
|
|
714
|
+
title={collapsed ? 'Show files' : 'Hide files'}
|
|
715
|
+
style={{
|
|
716
|
+
display: 'flex',
|
|
717
|
+
alignItems: 'center',
|
|
718
|
+
gap: 8,
|
|
719
|
+
padding: '10px 16px 4px',
|
|
720
|
+
flexShrink: 0,
|
|
721
|
+
cursor: 'pointer',
|
|
722
|
+
userSelect: 'none',
|
|
723
|
+
}}
|
|
724
|
+
>
|
|
725
|
+
{avatar ? (
|
|
726
|
+
<img
|
|
727
|
+
src={avatar}
|
|
728
|
+
alt=""
|
|
729
|
+
width={16}
|
|
730
|
+
height={16}
|
|
731
|
+
loading="lazy"
|
|
732
|
+
style={{ borderRadius: 4, flexShrink: 0, background: theme.colors.border }}
|
|
733
|
+
/>
|
|
734
|
+
) : group.owner ? (
|
|
735
|
+
<span
|
|
736
|
+
style={{
|
|
737
|
+
width: 16,
|
|
738
|
+
height: 16,
|
|
739
|
+
borderRadius: 4,
|
|
740
|
+
flexShrink: 0,
|
|
741
|
+
display: 'inline-flex',
|
|
742
|
+
alignItems: 'center',
|
|
743
|
+
justifyContent: 'center',
|
|
744
|
+
fontSize: 9,
|
|
745
|
+
fontWeight: 700,
|
|
746
|
+
fontFamily: theme.fonts.monospace,
|
|
747
|
+
color: theme.colors.text,
|
|
748
|
+
background: theme.colors.border,
|
|
749
|
+
}}
|
|
750
|
+
>
|
|
751
|
+
{group.owner.charAt(0).toUpperCase()}
|
|
752
|
+
</span>
|
|
753
|
+
) : null}
|
|
754
|
+
<span
|
|
755
|
+
style={{
|
|
756
|
+
fontSize: theme.fontSizes[1],
|
|
757
|
+
fontFamily: theme.fonts.monospace,
|
|
758
|
+
color: theme.colors.text,
|
|
759
|
+
fontWeight: 600,
|
|
760
|
+
overflow: 'hidden',
|
|
761
|
+
textOverflow: 'ellipsis',
|
|
762
|
+
whiteSpace: 'nowrap',
|
|
763
|
+
}}
|
|
764
|
+
>
|
|
765
|
+
{label}
|
|
766
|
+
</span>
|
|
767
|
+
</div>
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
|
|
661
771
|
export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
|
|
662
772
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
663
773
|
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={
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
buildRepoGroups,
|
|
4
|
+
buildTreePathMapping,
|
|
5
|
+
purlOwnerName,
|
|
6
|
+
purlRepoKey,
|
|
7
|
+
repoAvatarUrl,
|
|
8
|
+
} from './paths';
|
|
9
|
+
|
|
10
|
+
describe('purlRepoKey', () => {
|
|
11
|
+
test('strips fragments and whitespace', () => {
|
|
12
|
+
expect(purlRepoKey('pkg:github/acme/widget#src/x.ts')).toBe('pkg:github/acme/widget');
|
|
13
|
+
expect(purlRepoKey(' pkg:github/acme/widget ')).toBe('pkg:github/acme/widget');
|
|
14
|
+
expect(purlRepoKey(undefined)).toBeUndefined();
|
|
15
|
+
expect(purlRepoKey('#only-a-fragment')).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('purlOwnerName', () => {
|
|
20
|
+
test('extracts the last two path segments', () => {
|
|
21
|
+
expect(purlOwnerName('pkg:github/acme/widget#src/x.ts')).toBe('acme/widget');
|
|
22
|
+
expect(purlOwnerName('pkg:npm/@scope/lib')).toBe('@scope/lib');
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('buildTreePathMapping', () => {
|
|
27
|
+
test('single-repo graphs keep bare file paths', () => {
|
|
28
|
+
const m = buildTreePathMapping([
|
|
29
|
+
{ file: 'src/a.ts', purl: 'pkg:github/acme/widget' },
|
|
30
|
+
{ file: 'src/b.ts', purl: 'pkg:github/acme/widget' },
|
|
31
|
+
]);
|
|
32
|
+
expect(m.multiRepo).toBe(false);
|
|
33
|
+
expect(m.entries.map((e) => e.displayPath)).toEqual(['src/a.ts', 'src/b.ts']);
|
|
34
|
+
expect(m.toFile.get('src/a.ts')).toBe('src/a.ts');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('multi-repo graphs prefix entries with owner/name', () => {
|
|
38
|
+
const m = buildTreePathMapping([
|
|
39
|
+
{ file: 'graphify/ids.py', purl: 'pkg:github/Graphify-Labs/graphify' },
|
|
40
|
+
{ file: 'packages/react/src/x.ts', purl: 'pkg:github/principal-ai/core-lib#packages/react/src/x.ts' },
|
|
41
|
+
]);
|
|
42
|
+
expect(m.multiRepo).toBe(true);
|
|
43
|
+
expect(m.toFile.get('Graphify-Labs/graphify/graphify/ids.py')).toBe('graphify/ids.py');
|
|
44
|
+
expect(m.toDisplay.get('packages/react/src/x.ts')).toBe(
|
|
45
|
+
'principal-ai/core-lib/packages/react/src/x.ts',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('same-named files in different repos stay distinct', () => {
|
|
50
|
+
const m = buildTreePathMapping([
|
|
51
|
+
{ file: 'README.md', purl: 'pkg:github/acme/widget' },
|
|
52
|
+
{ file: 'README.md', purl: 'pkg:github/acme/other' },
|
|
53
|
+
]);
|
|
54
|
+
expect(m.entries).toHaveLength(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('components without files are skipped; purl-less components stay unprefixed', () => {
|
|
58
|
+
const m = buildTreePathMapping([
|
|
59
|
+
{ file: 'a.ts', purl: 'pkg:github/acme/widget' },
|
|
60
|
+
{ file: '', purl: 'pkg:github/acme/widget' },
|
|
61
|
+
{ purl: 'pkg:github/acme/widget' },
|
|
62
|
+
{ file: 'loose.ts' },
|
|
63
|
+
]);
|
|
64
|
+
// Two repos' worth of purls? No — the purl-less one has no repo key.
|
|
65
|
+
expect(m.multiRepo).toBe(false);
|
|
66
|
+
expect(m.toFile.get('loose.ts')).toBe('loose.ts');
|
|
67
|
+
});
|
|
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
|
+
});
|