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

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 (36) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/ComponentDetail.d.ts +12 -0
  6. package/dist/subsystem/ComponentDetail.d.ts.map +1 -0
  7. package/dist/subsystem/ComponentDetail.js +95 -0
  8. package/dist/subsystem/ComponentDetail.js.map +1 -0
  9. package/dist/subsystem/EdgeLegendModal.d.ts +18 -0
  10. package/dist/subsystem/EdgeLegendModal.d.ts.map +1 -0
  11. package/dist/subsystem/EdgeLegendModal.js +98 -0
  12. package/dist/subsystem/EdgeLegendModal.js.map +1 -0
  13. package/dist/subsystem/FileDrawer.d.ts +16 -0
  14. package/dist/subsystem/FileDrawer.d.ts.map +1 -0
  15. package/dist/subsystem/FileDrawer.js +72 -0
  16. package/dist/subsystem/FileDrawer.js.map +1 -0
  17. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  18. package/dist/subsystem/SubsystemComponentGraph.js +34 -182
  19. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  20. package/dist/subsystem/SubsystemFileTree.d.ts +10 -15
  21. package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
  22. package/dist/subsystem/SubsystemFileTree.js +96 -93
  23. package/dist/subsystem/SubsystemFileTree.js.map +1 -1
  24. package/dist/subsystem/nodes.d.ts +4 -2
  25. package/dist/subsystem/nodes.d.ts.map +1 -1
  26. package/dist/subsystem/nodes.js +13 -26
  27. package/dist/subsystem/nodes.js.map +1 -1
  28. package/package.json +2 -1
  29. package/src/index.ts +1 -1
  30. package/src/stories/SubsystemComponentGraph.stories.tsx +8 -0
  31. package/src/subsystem/ComponentDetail.tsx +187 -0
  32. package/src/subsystem/EdgeLegendModal.tsx +146 -0
  33. package/src/subsystem/FileDrawer.tsx +105 -0
  34. package/src/subsystem/SubsystemComponentGraph.tsx +49 -316
  35. package/src/subsystem/SubsystemFileTree.tsx +102 -141
  36. package/src/subsystem/nodes.tsx +17 -35
@@ -1,161 +1,107 @@
1
1
  /**
2
- * SubsystemFileTree — read-only folder/file tree derived from the unique
3
- * source files of a subsystem's components. Rendered in the bottom half of
4
- * the SubsystemComponentGraph sidebar as a navigation aid.
2
+ * SubsystemFileTree — sidebar file tree for a subsystem's components, built
3
+ * on @pierre/trees following the panel repo's SessionFileTreePanel pattern:
4
+ * init-once options + latest-value refs, and a suppress flag around
5
+ * programmatic selection so `onSelectionChange` echoes don't re-open files.
5
6
  */
6
7
 
7
- import { useMemo, useState } from 'react';
8
- import type { ReactNode } from 'react';
9
- import { ChevronDown, ChevronRight, FileText, Folder, FolderOpen } from 'lucide-react';
8
+ import { useEffect, useMemo, useRef } from 'react';
9
+ import type { CSSProperties } from 'react';
10
+ import { FileTree, useFileTree } from '@pierre/trees/react';
10
11
  import { useTheme } from '@principal-ade/industry-theme';
11
12
 
12
- interface TreeEntry {
13
- name: string;
14
- path: string;
15
- isFile: boolean;
16
- children: Map<string, TreeEntry>;
17
- }
18
-
19
- /** Build a nested tree from repo-root-relative file paths (deduped). */
20
- export function buildFileTree(files: string[]): TreeEntry {
21
- const root: TreeEntry = { name: '', path: '', isFile: false, children: new Map() };
22
- for (const file of [...new Set(files)].sort()) {
23
- const segments = file.split('/').filter(Boolean);
24
- let node = root;
25
- segments.forEach((segment, index) => {
26
- const isFile = index === segments.length - 1;
27
- const path = node.path ? `${node.path}/${segment}` : segment;
28
- let child = node.children.get(segment);
29
- if (!child || child.isFile !== isFile) {
30
- child = { name: segment, path, isFile, children: new Map() };
31
- node.children.set(segment, child);
32
- }
33
- node = child;
34
- });
35
- }
36
- return root;
37
- }
38
-
39
- function sortEntries(node: TreeEntry): TreeEntry[] {
40
- return Array.from(node.children.values()).sort((a, b) => {
41
- if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
42
- return a.name.localeCompare(b.name);
43
- });
44
- }
45
-
46
13
  export interface SubsystemFileTreeProps {
47
14
  /** Repo-root-relative file paths; duplicates are deduped. */
48
15
  files: string[];
49
- /** File to highlight (e.g. the selected component's file). */
16
+ /** File to select/highlight (the drawer-open file). */
50
17
  selectedFile?: string | null;
51
- /** Called when a file row is clicked. */
18
+ /** File to transiently highlight while its node is hovered on the graph;
19
+ * falls back to `selectedFile` when null. */
20
+ hoveredFile?: string | null;
21
+ /** Called when a file row is clicked; upstream toggles the drawer. */
52
22
  onSelectFile?: (file: string) => void;
53
23
  }
54
24
 
55
25
  /** Fills its parent height by design — pin it with a sized flex container. */
56
- export function SubsystemFileTree({ files, selectedFile, onSelectFile }: SubsystemFileTreeProps) {
26
+ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFile }: SubsystemFileTreeProps) {
57
27
  const { theme } = useTheme();
58
28
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
59
- const root = useMemo(() => buildFileTree(files), [files]);
60
- const uniqueCount = useMemo(() => new Set(files).size, [files]);
61
- const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
62
- const [hovered, setHovered] = useState<string | null>(null);
29
+ const paths = useMemo(() => Array.from(new Set(files)).sort(), [files]);
63
30
 
64
- const toggleFolder = (path: string) => {
65
- setCollapsed((prev) => {
66
- const next = new Set(prev);
67
- if (next.has(path)) next.delete(path);
68
- else next.add(path);
69
- return next;
70
- });
71
- };
31
+ // Latest-value refs so the stable `onSelectionChange` closure never goes
32
+ // stale — `useFileTree` reads its options once on init.
33
+ const pathSet = useMemo(() => new Set(paths), [paths]);
34
+ const pathSetRef = useRef(pathSet);
35
+ pathSetRef.current = pathSet;
36
+ const onSelectFileRef = useRef(onSelectFile);
37
+ onSelectFileRef.current = onSelectFile;
38
+ const selectedFileRef = useRef<string | null>(selectedFile ?? null);
39
+ selectedFileRef.current = selectedFile ?? null;
40
+ // Suppresses onSelectFile while selection is driven programmatically from
41
+ // the drawer state (`selectedFile`) — no synthetic open/close events.
42
+ const suppressSelectRef = useRef(false);
43
+ const initialPaths = useRef(paths);
44
+
45
+ const { model } = useFileTree({
46
+ paths: initialPaths.current,
47
+ initialExpansion: 'open',
48
+ onSelectionChange: (selected) => {
49
+ if (suppressSelectRef.current) return;
50
+ const raw = selected[selected.length - 1] ?? selected[0];
51
+ if (!raw) return;
52
+ // Directory rows carry a trailing slash; only emit for real files.
53
+ const path = raw.endsWith('/') ? raw.slice(0, -1) : raw;
54
+ if (!pathSetRef.current.has(path)) return;
55
+ // Includes re-clicks on the open row — upstream toggles it closed.
56
+ onSelectFileRef.current?.(path);
57
+ },
58
+ });
72
59
 
73
- const renderEntries = (node: TreeEntry, depth: number): ReactNode[] =>
74
- sortEntries(node).map((child) => {
75
- if (child.isFile) {
76
- const isSelected = child.path === selectedFile;
77
- return (
78
- <button
79
- key={child.path}
80
- type="button"
81
- onClick={() => onSelectFile?.(child.path)}
82
- onMouseEnter={() => setHovered(child.path)}
83
- onMouseLeave={() => setHovered((prev) => (prev === child.path ? null : prev))}
84
- title={child.path}
85
- style={{
86
- display: 'flex',
87
- alignItems: 'center',
88
- gap: 5,
89
- width: '100%',
90
- padding: '1px 6px',
91
- border: 'none',
92
- borderRadius: 4,
93
- background: isSelected || hovered === child.path
94
- ? theme.colors.border
95
- : 'transparent',
96
- color: theme.colors.text,
97
- fontWeight: isSelected ? 600 : 400,
98
- fontFamily: theme.fonts.monospace,
99
- fontSize: theme.fontSizes[0],
100
- lineHeight: '20px',
101
- textAlign: 'left',
102
- cursor: 'pointer',
103
- whiteSpace: 'nowrap',
104
- overflow: 'hidden',
105
- textOverflow: 'ellipsis',
106
- }}
107
- >
108
- <span style={{ width: 12, flexShrink: 0 }} />
109
- <FileText size={11} style={{ flexShrink: 0, opacity: 0.8 }} />
110
- <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{child.name}</span>
111
- </button>
112
- );
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
+ const lastSyncedRef = useRef<string | null>(null);
64
+ useEffect(() => {
65
+ const target = hoveredFile ?? selectedFile ?? null;
66
+ if (lastSyncedRef.current === target) return;
67
+ const prev = lastSyncedRef.current;
68
+ lastSyncedRef.current = target;
69
+ suppressSelectRef.current = true;
70
+ try {
71
+ if (prev) model.getItem(prev)?.deselect();
72
+ if (target) {
73
+ model.getItem(target)?.select();
74
+ model.scrollToPath(target);
113
75
  }
114
- const isCollapsed = collapsed.has(child.path);
115
- return (
116
- <div key={child.path}>
117
- <button
118
- type="button"
119
- onClick={() => toggleFolder(child.path)}
120
- onMouseEnter={() => setHovered(child.path)}
121
- onMouseLeave={() => setHovered((prev) => (prev === child.path ? null : prev))}
122
- title={child.path}
123
- style={{
124
- display: 'flex',
125
- alignItems: 'center',
126
- gap: 5,
127
- width: '100%',
128
- padding: '1px 6px',
129
- border: 'none',
130
- borderRadius: 4,
131
- background: hovered === child.path ? theme.colors.border : 'transparent',
132
- color: muted,
133
- fontFamily: theme.fonts.monospace,
134
- fontSize: theme.fontSizes[0],
135
- lineHeight: '20px',
136
- textAlign: 'left',
137
- cursor: 'pointer',
138
- whiteSpace: 'nowrap',
139
- overflow: 'hidden',
140
- }}
141
- >
142
- <span style={{ width: 12, flexShrink: 0, display: 'inline-flex', justifyContent: 'center' }}>
143
- {isCollapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
144
- </span>
145
- {isCollapsed ? <Folder size={11} style={{ flexShrink: 0 }} /> : <FolderOpen size={11} style={{ flexShrink: 0 }} />}
146
- <span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>{child.name}</span>
147
- </button>
148
- {!isCollapsed && (
149
- <div style={{ paddingLeft: 12 }}>
150
- {renderEntries(child, depth + 1)}
151
- </div>
152
- )}
153
- </div>
154
- );
155
- });
76
+ } finally {
77
+ suppressSelectRef.current = false;
78
+ }
79
+ }, [model, selectedFile, hoveredFile]);
80
+
81
+ // Re-scope the tree in place when the subsystem's file set changes.
82
+ useEffect(() => {
83
+ model.resetPaths(paths);
84
+ }, [model, paths]);
85
+
86
+ // Pierre emits no selection change when the clicked row is already
87
+ // selected — detect re-clicks on the open file's row via focus and let
88
+ // upstream toggle the drawer closed.
89
+ const handleContainerClick = () => {
90
+ const focused = model.getFocusedPath();
91
+ if (!focused) return;
92
+ const path = focused.endsWith('/') ? focused.slice(0, -1) : focused;
93
+ if (path !== selectedFileRef.current) return;
94
+ suppressSelectRef.current = true;
95
+ try {
96
+ onSelectFileRef.current?.(path);
97
+ } finally {
98
+ suppressSelectRef.current = false;
99
+ }
100
+ };
156
101
 
157
102
  return (
158
103
  <div
104
+ onClickCapture={handleContainerClick}
159
105
  style={{
160
106
  height: '50%',
161
107
  minHeight: 120,
@@ -186,12 +132,27 @@ export function SubsystemFileTree({ files, selectedFile, onSelectFile }: Subsyst
186
132
  Files
187
133
  </span>
188
134
  <span style={{ fontSize: theme.fontSizes[0] * 0.8, fontFamily: theme.fonts.monospace, color: muted }}>
189
- {uniqueCount}
135
+ {paths.length}
190
136
  </span>
191
137
  </div>
192
- <div style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 8px 8px' }}>
193
- {renderEntries(root, 0)}
194
- </div>
138
+ <FileTree
139
+ model={model}
140
+ style={
141
+ {
142
+ flex: 1,
143
+ minHeight: 0,
144
+ '--trees-padding-inline-override': '8px',
145
+ '--trees-bg-override': 'transparent',
146
+ '--trees-fg-override': theme.colors.text,
147
+ '--trees-fg-muted-override': muted,
148
+ '--trees-accent-override': theme.colors.primary,
149
+ '--trees-font-family-override': theme.fonts.monospace,
150
+ '--trees-font-size-override': `${theme.fontSizes[0]}px`,
151
+ '--trees-theme-list-hover-bg': theme.colors.border,
152
+ '--trees-theme-list-active-selection-bg': theme.colors.border,
153
+ } as CSSProperties
154
+ }
155
+ />
195
156
  </div>
196
157
  );
197
158
  }
@@ -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
- * and a click-to-open file chip. Groups render as package containers. Clicking
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
 
@@ -50,6 +50,8 @@ export interface SubsystemGraphCallbacks {
50
50
  onSelect?: (componentId: string) => void;
51
51
  /** Click an edge (or its label) — select the relationship. */
52
52
  onEdgeSelect?: (edgeId: string) => void;
53
+ /** Hover a component (null on leave) — associates it with the file tree. */
54
+ onHover?: (componentId: string | null) => void;
53
55
  /** Upper bound for node width; nodes grow with content up to this, then wrap. */
54
56
  maxNodeWidth?: number;
55
57
  }
@@ -73,8 +75,14 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
73
75
 
74
76
  return (
75
77
  <div
76
- onMouseEnter={() => setHover(true)}
77
- onMouseLeave={() => setHover(false)}
78
+ onMouseEnter={() => {
79
+ setHover(true);
80
+ SUBSYSTEM_CALLBACKS.onHover?.(c.id);
81
+ }}
82
+ onMouseLeave={() => {
83
+ setHover(false);
84
+ SUBSYSTEM_CALLBACKS.onHover?.(null);
85
+ }}
78
86
  onClick={(e) => {
79
87
  e.stopPropagation();
80
88
  SUBSYSTEM_CALLBACKS.onSelect?.(c.id);
@@ -117,7 +125,6 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
117
125
  whiteSpace: 'nowrap',
118
126
  fontSize: theme.fontSizes[0] * 0.8,
119
127
  fontFamily: theme.fonts.monospace,
120
- textTransform: 'uppercase',
121
128
  letterSpacing: 0.5,
122
129
  color,
123
130
  background: theme.colors.background,
@@ -126,7 +133,10 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
126
133
  padding: '1px 6px',
127
134
  }}
128
135
  >
129
- {KIND_LABEL[c.kind] ?? c.kind}
136
+ <span style={{ textTransform: 'uppercase' }}>
137
+ {KIND_LABEL[c.kind] ?? c.kind}
138
+ </span>
139
+ {c.file ? ` · ${c.file.split('/').pop()}` : ''}
130
140
  {c.capture && c.capture !== 'edited' ? ` · ${c.capture}` : ''}
131
141
  </div>
132
142
  )}
@@ -168,9 +178,7 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
168
178
  whiteSpace: 'normal',
169
179
  overflowWrap: 'anywhere',
170
180
  maxWidth: '100%',
171
- // Types get a serif name to distinguish them from runtime units.
172
- fontFamily:
173
- c.kind === 'type' ? 'Georgia, "Times New Roman", serif' : theme.fonts.body,
181
+ fontFamily: theme.fonts.body,
174
182
  }}
175
183
  >
176
184
  {breakWords(displayName)}
@@ -195,32 +203,6 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
195
203
  </div>
196
204
  )}
197
205
 
198
- {c.file && (
199
- <div
200
- onClick={(e) => {
201
- e.stopPropagation();
202
- SUBSYSTEM_CALLBACKS.onSelect?.(c.id);
203
- }}
204
- style={{
205
- alignSelf: 'center',
206
- marginTop: 3,
207
- fontSize: theme.fontSizes[0] * 0.78,
208
- fontFamily: theme.fonts.monospace,
209
- color: theme.colors.primary,
210
- border: `1px solid ${theme.colors.border}`,
211
- borderRadius: 4,
212
- padding: '1px 5px',
213
- cursor: 'pointer',
214
- maxWidth: '100%',
215
- overflow: 'hidden',
216
- textOverflow: 'ellipsis',
217
- whiteSpace: 'nowrap',
218
- }}
219
- >
220
- {c.file.split('/').pop()}
221
- </div>
222
- )}
223
-
224
206
  <Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
225
207
  <Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
226
208
  </div>