@principal-ai/principal-view-react 0.16.35 → 0.16.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/graphify/consolidated.d.ts +17 -3
  2. package/dist/graphify/consolidated.d.ts.map +1 -1
  3. package/dist/graphify/index.d.ts +2 -0
  4. package/dist/graphify/index.d.ts.map +1 -1
  5. package/dist/graphify/index.js +1 -1
  6. package/dist/graphify/index.js.map +1 -1
  7. package/dist/graphify/resolve.d.ts +48 -0
  8. package/dist/graphify/resolve.d.ts.map +1 -0
  9. package/dist/graphify/resolve.js +83 -0
  10. package/dist/graphify/resolve.js.map +1 -0
  11. package/dist/index.d.ts +5 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/subsystem/ComponentDetail.d.ts +16 -7
  16. package/dist/subsystem/ComponentDetail.d.ts.map +1 -1
  17. package/dist/subsystem/ComponentDetail.js +124 -82
  18. package/dist/subsystem/ComponentDetail.js.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  20. package/dist/subsystem/SubsystemComponentGraph.js +47 -10
  21. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  22. package/dist/subsystem/model.d.ts +14 -0
  23. package/dist/subsystem/model.d.ts.map +1 -1
  24. package/dist/subsystem/model.js +26 -1
  25. package/dist/subsystem/model.js.map +1 -1
  26. package/dist/subsystem/paths.d.ts +38 -0
  27. package/dist/subsystem/paths.d.ts.map +1 -0
  28. package/dist/subsystem/paths.js +52 -0
  29. package/dist/subsystem/paths.js.map +1 -0
  30. package/package.json +1 -1
  31. package/src/graphify/consolidated.ts +17 -3
  32. package/src/graphify/index.ts +9 -0
  33. package/src/graphify/resolve.test.ts +89 -0
  34. package/src/graphify/resolve.ts +115 -0
  35. package/src/index.ts +19 -0
  36. package/src/stories/SubsystemComponentGraph.stories.tsx +331 -11
  37. package/src/subsystem/ComponentDetail.tsx +355 -156
  38. package/src/subsystem/SubsystemComponentGraph.tsx +76 -15
  39. package/src/subsystem/model.test.ts +11 -0
  40. package/src/subsystem/model.ts +25 -1
  41. package/src/subsystem/paths.test.ts +62 -0
  42. package/src/subsystem/paths.ts +69 -0
@@ -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,
@@ -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 { buildTreePathMapping } from './paths';
50
51
 
51
52
  export interface SubsystemComponentGraphProps {
52
53
  components: SubsystemComponent[];
@@ -97,6 +98,21 @@ const edgeTypes: EdgeTypes = {
97
98
  'subsystem-edge': SubsystemEdge,
98
99
  };
99
100
 
101
+ // Memoized drawer body: only rebuilds children when the open file changes.
102
+ // Inner re-renders on every viewport pan/zoom and hover; recreating host
103
+ // elements then would churn their readFile closures and flash the file
104
+ // viewer's loading state on each render.
105
+ const DrawerContent = memo(function DrawerContent({
106
+ render,
107
+ file,
108
+ }: {
109
+ render: (file: string) => ReactNode;
110
+ file: string | null;
111
+ }) {
112
+ if (!file) return null;
113
+ return <>{render(file)}</>;
114
+ });
115
+
100
116
  interface InnerProps extends SubsystemComponentGraphProps {
101
117
  measured: { w: number; h: number } | null;
102
118
  }
@@ -347,9 +363,13 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
347
363
  }, []);
348
364
 
349
365
  // Sidebar file-tree click → toggle in the bottom drawer (+ host hook only
350
- // when opening, so hosts don't see close events).
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]);
351
370
  const onTreeSelectFile = useCallback(
352
- (file: string) => {
371
+ (displayPath: string) => {
372
+ const file = treeMapping.toFile.get(displayPath) ?? displayPath;
353
373
  if (openFileRef.current === file) {
354
374
  setOpenFile(null);
355
375
  return;
@@ -357,7 +377,27 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
357
377
  setOpenFile(file);
358
378
  onFileSelect?.(file);
359
379
  },
360
- [onFileSelect],
380
+ [treeMapping, onFileSelect],
381
+ );
382
+
383
+ // Detail-panel links: related-name clicks select the matching component —
384
+ // resolved by id, name, or symbol (call labels may carry a trailing `()`).
385
+ const resolveRelatedComponent = useCallback(
386
+ (ref: string) => {
387
+ const clean = ref.replace(/\(\)$/, '');
388
+ const comp = components.find(
389
+ (c) =>
390
+ c.id === clean ||
391
+ c.name === clean ||
392
+ c.symbol === clean ||
393
+ c.symbol?.replace(/\(\)$/, '') === clean,
394
+ );
395
+ if (!comp || comp.id === selectedRef.current?.id) return;
396
+ setSelected(comp);
397
+ setSelectedEdgeId(null);
398
+ onSelect?.(comp.id);
399
+ },
400
+ [components, onSelect],
361
401
  );
362
402
 
363
403
  // Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
@@ -380,10 +420,11 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
380
420
  return new Set(edgeLabels.map((l) => l.mechanism));
381
421
  }, [edgeLabels]);
382
422
 
383
- // Unique source files across components → sidebar file tree.
384
- const files = useMemo(
385
- () => Array.from(new Set(components.map((c) => c.file).filter(Boolean))).sort(),
386
- [components],
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],
387
428
  );
388
429
 
389
430
  // The hovered node's file (null when not hovering / file-less component).
@@ -408,10 +449,19 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
408
449
  return undefined;
409
450
  }, [renderFileViewer, renderFileView, components]);
410
451
 
452
+ // Latest-viewer ref + stable callback so DrawerContent's memo holds across
453
+ // unrelated Inner renders (see comment there).
454
+ const fileViewerRef = useRef(fileViewer);
455
+ fileViewerRef.current = fileViewer;
456
+ const renderDrawerContent = useCallback(
457
+ (file: string) => fileViewerRef.current?.(file) ?? null,
458
+ [],
459
+ );
460
+
411
461
  return (
412
462
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
413
463
  {/* Sidebar: scrollable title/description on top, file tree pinned to the bottom half */}
414
- {(title || description || sidebarExtra || sidebarAfterDescription || files.length > 0) && (
464
+ {(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0) && (
415
465
  <div
416
466
  style={{
417
467
  width: 340,
@@ -465,11 +515,16 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
465
515
  )}
466
516
  {sidebarAfterDescription}
467
517
  </div>
468
- {files.length > 0 && (
518
+ {treeFilePaths.length > 0 && (
469
519
  <SubsystemFileTree
470
- files={files}
471
- selectedFile={selected?.file ?? openFile}
472
- hoveredFile={hoveredFile}
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}
473
528
  onSelectFile={onTreeSelectFile}
474
529
  />
475
530
  )}
@@ -596,9 +651,15 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
596
651
  mechanisms={usedMechanisms}
597
652
  onClose={() => setLegendOpen(false)}
598
653
  />
599
- {selected && <ComponentDetail component={selected} />}
654
+ {selected && (
655
+ <ComponentDetail
656
+ component={selected}
657
+ onOpenFile={onTreeSelectFile}
658
+ onRelatedSelect={resolveRelatedComponent}
659
+ />
660
+ )}
600
661
  <FileDrawer file={openFile} onClose={() => setOpenFile(null)}>
601
- {fileViewer && openFile ? fileViewer(openFile) : null}
662
+ <DrawerContent render={renderDrawerContent} file={openFile} />
602
663
  </FileDrawer>
603
664
  {/* Startup cover — hides measurement, layout swap, and camera settle. */}
604
665
  <GraphLayoutCover revealed={layoutReady} />
@@ -4,6 +4,7 @@ import {
4
4
  convertSubsystemToEdges,
5
5
  buildSubsystemGraph,
6
6
  deriveNameFromSymbol,
7
+ formatPurl,
7
8
  packageColor,
8
9
  } from './model';
9
10
  import type { SubsystemComponent, SubsystemComponentEdge } from './model';
@@ -76,4 +77,14 @@ describe('subsystem graph model', () => {
76
77
  // File basename wins over a supplied name for modules (derivation precedence).
77
78
  expect(deriveNameFromSymbol(undefined, 'module', 'MyModule', 'x.ts')).toBe('x');
78
79
  });
80
+
81
+ test('formatPurl renders the human identity', () => {
82
+ expect(formatPurl('pkg:npm/@principal-ai/core')).toBe('@principal-ai/core');
83
+ expect(formatPurl('pkg:github/principal-ai/agent-monitoring')).toBe('principal-ai/agent-monitoring');
84
+ expect(formatPurl('pkg:npm/left-pad@1.3.0')).toBe('left-pad');
85
+ expect(formatPurl('pkg:gitlab/group/proj?arch=amd64')).toBe('group/proj');
86
+ expect(formatPurl('pkg:generic/local--Users-me-my-app')).toBe('Users-me-my-app (local)');
87
+ // Malformed purls pass through untouched.
88
+ expect(formatPurl('not-a-purl')).toBe('not-a-purl');
89
+ });
79
90
  });
@@ -119,6 +119,30 @@ export function deriveNameFromSymbol(
119
119
  return existingName ?? 'untitled';
120
120
  }
121
121
 
122
+ /**
123
+ * Human-readable purl identity — drops the `pkg:<type>/` scheme wrapper and
124
+ * trailing version, keeping the package / owner-repo identity:
125
+ *
126
+ * pkg:npm/@principal-ai/core → `@principal-ai/core`
127
+ * pkg:github/principal-ai/my-repo → `principal-ai/my-repo`
128
+ * pkg:npm/left-pad@1.3.0 → `left-pad`
129
+ * pkg:generic/local--Users-me-my-app → `Users-me-my-app (local)`
130
+ *
131
+ * Local purls encode the absolute path with dashes for slashes, which is not
132
+ * losslessly decodable — shown as-is with a `(local)` marker.
133
+ */
134
+ export function formatPurl(purl: string): string {
135
+ const match = /^pkg:[^/#?]+\/(.+)$/.exec(purl);
136
+ if (!match) return purl;
137
+ let identity = match[1].split(/[?#]/)[0];
138
+ const at = identity.indexOf('@');
139
+ if (at > 0) identity = identity.slice(0, at); // trailing @version
140
+ if (identity.startsWith('local--')) {
141
+ return `${identity.slice('local--'.length)} (local)`;
142
+ }
143
+ return identity;
144
+ }
145
+
122
146
  export interface SubsystemGraphDocument {
123
147
  components: SubsystemComponent[];
124
148
  edges: SubsystemComponentEdge[];
@@ -174,7 +198,7 @@ export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
174
198
  'registers-into': '#ff6b35', // orange
175
199
  };
176
200
 
177
- const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed' | 'dotted'> = {
201
+ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed' | 'dotted'> = {
178
202
  imports: 'solid',
179
203
  imports_from: 'solid',
180
204
  re_exports: 'solid',
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { buildTreePathMapping, purlOwnerName, purlRepoKey } from './paths';
3
+
4
+ describe('purlRepoKey', () => {
5
+ test('strips fragments and whitespace', () => {
6
+ expect(purlRepoKey('pkg:github/acme/widget#src/x.ts')).toBe('pkg:github/acme/widget');
7
+ expect(purlRepoKey(' pkg:github/acme/widget ')).toBe('pkg:github/acme/widget');
8
+ expect(purlRepoKey(undefined)).toBeUndefined();
9
+ expect(purlRepoKey('#only-a-fragment')).toBeUndefined();
10
+ });
11
+ });
12
+
13
+ describe('purlOwnerName', () => {
14
+ test('extracts the last two path segments', () => {
15
+ expect(purlOwnerName('pkg:github/acme/widget#src/x.ts')).toBe('acme/widget');
16
+ expect(purlOwnerName('pkg:npm/@scope/lib')).toBe('@scope/lib');
17
+ });
18
+ });
19
+
20
+ describe('buildTreePathMapping', () => {
21
+ test('single-repo graphs keep bare file paths', () => {
22
+ const m = buildTreePathMapping([
23
+ { file: 'src/a.ts', purl: 'pkg:github/acme/widget' },
24
+ { file: 'src/b.ts', purl: 'pkg:github/acme/widget' },
25
+ ]);
26
+ expect(m.multiRepo).toBe(false);
27
+ expect(m.entries.map((e) => e.displayPath)).toEqual(['src/a.ts', 'src/b.ts']);
28
+ expect(m.toFile.get('src/a.ts')).toBe('src/a.ts');
29
+ });
30
+
31
+ test('multi-repo graphs prefix entries with owner/name', () => {
32
+ const m = buildTreePathMapping([
33
+ { file: 'graphify/ids.py', purl: 'pkg:github/Graphify-Labs/graphify' },
34
+ { file: 'packages/react/src/x.ts', purl: 'pkg:github/principal-ai/core-lib#packages/react/src/x.ts' },
35
+ ]);
36
+ expect(m.multiRepo).toBe(true);
37
+ expect(m.toFile.get('Graphify-Labs/graphify/graphify/ids.py')).toBe('graphify/ids.py');
38
+ expect(m.toDisplay.get('packages/react/src/x.ts')).toBe(
39
+ 'principal-ai/core-lib/packages/react/src/x.ts',
40
+ );
41
+ });
42
+
43
+ test('same-named files in different repos stay distinct', () => {
44
+ const m = buildTreePathMapping([
45
+ { file: 'README.md', purl: 'pkg:github/acme/widget' },
46
+ { file: 'README.md', purl: 'pkg:github/acme/other' },
47
+ ]);
48
+ expect(m.entries).toHaveLength(2);
49
+ });
50
+
51
+ test('components without files are skipped; purl-less components stay unprefixed', () => {
52
+ const m = buildTreePathMapping([
53
+ { file: 'a.ts', purl: 'pkg:github/acme/widget' },
54
+ { file: '', purl: 'pkg:github/acme/widget' },
55
+ { purl: 'pkg:github/acme/widget' },
56
+ { file: 'loose.ts' },
57
+ ]);
58
+ // Two repos' worth of purls? No — the purl-less one has no repo key.
59
+ expect(m.multiRepo).toBe(false);
60
+ expect(m.toFile.get('loose.ts')).toBe('loose.ts');
61
+ });
62
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Multi-repo path handling for subsystem graphs.
3
+ *
4
+ * A component's `file` is relative to ITS OWN repo root — the repo identified
5
+ * by its `purl` (`pkg:github/<owner>/<name>[#<path>]`) — never to the machine.
6
+ * When a graph spans several repos the sidebar tree needs synthetic
7
+ * `<owner>/<name>/` prefixes to keep same-named paths apart; hosts resolve
8
+ * each file against `repoRoots[purlRepo] ?? repoRoot`.
9
+ */
10
+
11
+ /** Normalize a purl to its repo key: fragment and surrounding whitespace stripped. */
12
+ export function purlRepoKey(purl: string | undefined): string | undefined {
13
+ if (!purl) return undefined;
14
+ const base = purl.split('#')[0]?.trim();
15
+ return base || undefined;
16
+ }
17
+
18
+ /** `owner/name` from a purl (`pkg:github/acme/widget#x/y.ts` → `acme/widget`). */
19
+ export function purlOwnerName(purl: string | undefined): string | undefined {
20
+ const key = purlRepoKey(purl);
21
+ if (!key) return undefined;
22
+ // pkg:<type>/<owner>/<name> → take the last two segments.
23
+ const parts = key.split('/').filter(Boolean);
24
+ if (parts.length < 2) return undefined;
25
+ return `${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
26
+ }
27
+
28
+ export interface TreeEntry {
29
+ /** Path as shown in the sidebar tree (repo-prefixed when multi-repo). */
30
+ displayPath: string;
31
+ /** The component's own file value — what hosts resolve + drawers open. */
32
+ file: string;
33
+ }
34
+
35
+ export interface TreePathMapping {
36
+ entries: TreeEntry[];
37
+ /** True when components span more than one repo (prefixes are shown). */
38
+ multiRepo: boolean;
39
+ /** displayPath → file. */
40
+ toFile: Map<string, string>;
41
+ /** file → displayPath (first component wins). */
42
+ toDisplay: Map<string, string>;
43
+ }
44
+
45
+ /**
46
+ * Build the sidebar tree's path set from components. Single-repo graphs (and
47
+ * purl-less components) keep bare `file` values; mixed-repo graphs prefix each
48
+ * entry with its purl's `owner/name` so the tree groups by repo.
49
+ */
50
+ export function buildTreePathMapping(components: ReadonlyArray<{ file?: string; purl?: string }>): TreePathMapping {
51
+ const withFiles = components.filter((c) => c.file);
52
+ const repos = new Set(
53
+ withFiles.map((c) => purlRepoKey(c.purl)).filter((k): k is string => !!k),
54
+ );
55
+ const multiRepo = repos.size > 1;
56
+
57
+ const entries: TreeEntry[] = [];
58
+ const toFile = new Map<string, string>();
59
+ const toDisplay = new Map<string, string>();
60
+ for (const c of withFiles) {
61
+ const ownerName = multiRepo ? purlOwnerName(c.purl) : undefined;
62
+ const displayPath = ownerName ? `${ownerName}/${c.file}` : c.file!;
63
+ if (toFile.has(displayPath)) continue;
64
+ entries.push({ displayPath, file: c.file! });
65
+ toFile.set(displayPath, c.file!);
66
+ if (!toDisplay.has(c.file!)) toDisplay.set(c.file!, displayPath);
67
+ }
68
+ return { entries, multiRepo, toFile, toDisplay };
69
+ }