@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.
@@ -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 { ComponentDetail } from './ComponentDetail';
47
+ import { ComponentDeclaration } from './ComponentDeclaration';
48
48
  import { FileDrawer } from './FileDrawer';
49
49
  import { EdgeLegendModal, MECHANISM_DESCRIPTIONS } from './EdgeLegendModal';
50
- import { buildTreePathMapping } from './paths';
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/closes the canvas resizes reframe once the
312
- // height transition settles: onto the matching (spotlit) nodes while a file
313
- // is open, back to the whole graph when it closes.
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-tree click toggle in the bottom drawer (+ host hook only
366
- // when opening, so hosts don't see close events). The tree shows
367
- // repo-prefixed display paths on multi-repo graphs; map back to the
368
- // component's own `file` before touching drawer/host state.
369
- const treeMapping = useMemo(() => buildTreePathMapping(components), [components]);
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
- (displayPath: string) => {
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
- [treeMapping, onFileSelect],
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 tree. Multi-repo
424
- // graphs render `<owner>/<name>/…` display paths, grouped by repo.
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
- <SubsystemFileTree
520
- files={treeFilePaths}
521
- selectedFile={
522
- treeMapping.toDisplay.get(selected?.file ?? openFile ?? '') ??
523
- selected?.file ??
524
- openFile ??
525
- null
526
- }
527
- hoveredFile={hoveredFile ? (treeMapping.toDisplay.get(hoveredFile) ?? hoveredFile) : null}
528
- onSelectFile={onTreeSelectFile}
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
- <EdgeLegendModal
650
- open={legendOpen}
651
- mechanisms={usedMechanisms}
652
- onClose={() => setLegendOpen(false)}
653
- />
654
- {selected && (
655
- <ComponentDetail
656
- component={selected}
657
- onOpenFile={onTreeSelectFile}
658
- onRelatedSelect={resolveRelatedComponent}
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 desired highlight: the
61
- // hovered node's file while hovering, else the drawer-open file. Deselects
62
- // the previous target and scrolls the new one into view.
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
- const target = hoveredFile ?? selectedFile ?? null;
66
- if (lastSyncedRef.current === target) return;
69
+ if (lastSyncedRef.current === (selectedFile ?? null)) return;
67
70
  const prev = lastSyncedRef.current;
68
- lastSyncedRef.current = target;
71
+ lastSyncedRef.current = selectedFile ?? null;
69
72
  suppressSelectRef.current = true;
70
73
  try {
71
74
  if (prev) model.getItem(prev)?.deselect();
72
- if (target) {
73
- model.getItem(target)?.select();
74
- model.scrollToPath(target);
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, hoveredFile]);
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
- height: '50%',
107
- minHeight: 120,
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
- <div
115
- style={{
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
- fontSize: theme.fontSizes[0] * 0.8,
126
- fontFamily: theme.fonts.monospace,
127
- textTransform: 'uppercase',
128
- color: muted,
129
- fontWeight: 600,
147
+ padding: '10px 16px 4px',
148
+ display: 'flex',
149
+ alignItems: 'baseline',
150
+ gap: 6,
151
+ flexShrink: 0,
130
152
  }}
131
153
  >
132
- Files
133
- </span>
134
- <span style={{ fontSize: theme.fontSizes[0] * 0.8, fontFamily: theme.fonts.monospace, color: muted }}>
135
- {paths.length}
136
- </span>
137
- </div>
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 { buildTreePathMapping, purlOwnerName, purlRepoKey } from './paths';
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
+ });
@@ -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
+ }