@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.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/subsystem/ComponentDetail.d.ts +12 -0
- package/dist/subsystem/ComponentDetail.d.ts.map +1 -0
- package/dist/subsystem/ComponentDetail.js +95 -0
- package/dist/subsystem/ComponentDetail.js.map +1 -0
- package/dist/subsystem/EdgeLegendModal.d.ts +18 -0
- package/dist/subsystem/EdgeLegendModal.d.ts.map +1 -0
- package/dist/subsystem/EdgeLegendModal.js +98 -0
- package/dist/subsystem/EdgeLegendModal.js.map +1 -0
- package/dist/subsystem/FileDrawer.d.ts +16 -0
- package/dist/subsystem/FileDrawer.d.ts.map +1 -0
- package/dist/subsystem/FileDrawer.js +72 -0
- package/dist/subsystem/FileDrawer.js.map +1 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +34 -182
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/SubsystemFileTree.d.ts +10 -15
- package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
- package/dist/subsystem/SubsystemFileTree.js +96 -93
- package/dist/subsystem/SubsystemFileTree.js.map +1 -1
- package/dist/subsystem/nodes.d.ts +4 -2
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +13 -26
- package/dist/subsystem/nodes.js.map +1 -1
- package/package.json +2 -1
- package/src/index.ts +1 -1
- package/src/stories/SubsystemComponentGraph.stories.tsx +8 -0
- package/src/subsystem/ComponentDetail.tsx +187 -0
- package/src/subsystem/EdgeLegendModal.tsx +146 -0
- package/src/subsystem/FileDrawer.tsx +105 -0
- package/src/subsystem/SubsystemComponentGraph.tsx +49 -316
- package/src/subsystem/SubsystemFileTree.tsx +102 -141
- package/src/subsystem/nodes.tsx +17 -35
|
@@ -1,161 +1,107 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SubsystemFileTree —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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,
|
|
8
|
-
import type {
|
|
9
|
-
import {
|
|
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 (
|
|
16
|
+
/** File to select/highlight (the drawer-open file). */
|
|
50
17
|
selectedFile?: string | null;
|
|
51
|
-
/**
|
|
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
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
{
|
|
135
|
+
{paths.length}
|
|
190
136
|
</span>
|
|
191
137
|
</div>
|
|
192
|
-
<
|
|
193
|
-
{
|
|
194
|
-
|
|
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
|
}
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -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
|
-
*
|
|
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={() =>
|
|
77
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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>
|