@principal-ai/principal-view-react 0.16.35 → 0.16.36
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/graphify/consolidated.d.ts +17 -3
- package/dist/graphify/consolidated.d.ts.map +1 -1
- package/dist/graphify/index.d.ts +2 -0
- package/dist/graphify/index.d.ts.map +1 -1
- package/dist/graphify/index.js +1 -1
- package/dist/graphify/index.js.map +1 -1
- package/dist/graphify/resolve.d.ts +48 -0
- package/dist/graphify/resolve.d.ts.map +1 -0
- package/dist/graphify/resolve.js +83 -0
- package/dist/graphify/resolve.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/subsystem/ComponentDetail.d.ts +16 -7
- package/dist/subsystem/ComponentDetail.d.ts.map +1 -1
- package/dist/subsystem/ComponentDetail.js +124 -82
- package/dist/subsystem/ComponentDetail.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +31 -3
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/model.d.ts +14 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +26 -1
- package/dist/subsystem/model.js.map +1 -1
- package/package.json +1 -1
- package/src/graphify/consolidated.ts +17 -3
- package/src/graphify/index.ts +9 -0
- package/src/graphify/resolve.test.ts +89 -0
- package/src/graphify/resolve.ts +115 -0
- package/src/index.ts +10 -0
- package/src/stories/SubsystemComponentGraph.stories.tsx +331 -11
- package/src/subsystem/ComponentDetail.tsx +355 -156
- package/src/subsystem/SubsystemComponentGraph.tsx +53 -3
- package/src/subsystem/model.test.ts +11 -0
- package/src/subsystem/model.ts +25 -1
|
@@ -1,187 +1,386 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ComponentDetail — selection detail panel for a subsystem component
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* ComponentDetail — selection detail panel for a subsystem component, rendered
|
|
3
|
+
* as it would appear in code: a file-path comment + doc comment, then the kind's
|
|
4
|
+
* declaration (`class X extends Y { ... }`, `function f(): T`, ...) with its
|
|
5
|
+
* members as signatures. Graphify relationship drill-downs appear as trailing
|
|
6
|
+
* comments. File content lives in the bottom FileDrawer, not here.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
|
-
import type
|
|
9
|
+
import { Fragment, type ReactNode } from 'react';
|
|
8
10
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
9
11
|
import {
|
|
10
12
|
KIND_COLOR,
|
|
11
13
|
deriveNameFromSymbol,
|
|
14
|
+
formatPurl,
|
|
12
15
|
type SubsystemComponent,
|
|
13
16
|
} from './model';
|
|
14
|
-
import type { GraphifyComponentDetail } from '../graphify';
|
|
15
17
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const rows: Array<[string, string]> = [];
|
|
24
|
-
if (component.symbol) rows.push(['Symbol', component.symbol]);
|
|
25
|
-
if (component.file) rows.push(['File', component.file]);
|
|
26
|
-
if (component.purpose) rows.push(['Purpose', component.purpose]);
|
|
27
|
-
rows.push(['PURL', component.purl]);
|
|
18
|
+
/** Shared affordance for clickable text pieces. */
|
|
19
|
+
const clickableStyle = {
|
|
20
|
+
cursor: 'pointer',
|
|
21
|
+
textDecorationLine: 'underline',
|
|
22
|
+
textDecorationStyle: 'dotted',
|
|
23
|
+
textUnderlineOffset: 2,
|
|
24
|
+
} as const;
|
|
28
25
|
|
|
26
|
+
/** Clickable detail-panel piece — dotted underline signals interactivity. */
|
|
27
|
+
function DetailLink({ children, onClick }: { children: ReactNode; onClick?: () => void }) {
|
|
28
|
+
if (!onClick) return <>{children}</>;
|
|
29
29
|
return (
|
|
30
|
-
<
|
|
30
|
+
<span
|
|
31
|
+
onClick={(e) => {
|
|
32
|
+
e.stopPropagation();
|
|
33
|
+
onClick();
|
|
34
|
+
}}
|
|
31
35
|
style={{
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
cursor: 'pointer',
|
|
37
|
+
textDecorationLine: 'underline',
|
|
38
|
+
textDecorationStyle: 'dotted',
|
|
39
|
+
textUnderlineOffset: 2,
|
|
36
40
|
}}
|
|
37
41
|
>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
display: 'flex',
|
|
41
|
-
alignItems: 'center',
|
|
42
|
-
gap: 8,
|
|
43
|
-
fontSize: theme.fontSizes[1],
|
|
44
|
-
fontWeight: 600,
|
|
45
|
-
marginBottom: 6,
|
|
46
|
-
}}
|
|
47
|
-
>
|
|
48
|
-
<span style={{ color }}>{displayName}</span>
|
|
49
|
-
<span
|
|
50
|
-
style={{
|
|
51
|
-
fontSize: theme.fontSizes[0] * 0.8,
|
|
52
|
-
fontFamily: theme.fonts.monospace,
|
|
53
|
-
textTransform: 'uppercase',
|
|
54
|
-
color,
|
|
55
|
-
}}
|
|
56
|
-
>
|
|
57
|
-
{component.kind}
|
|
58
|
-
</span>
|
|
59
|
-
</div>
|
|
60
|
-
{rows.map(([k, v]) => (
|
|
61
|
-
<div
|
|
62
|
-
key={k}
|
|
63
|
-
style={{ display: 'flex', gap: 8, fontSize: theme.fontSizes[0], lineHeight: 1.5 }}
|
|
64
|
-
>
|
|
65
|
-
<span
|
|
66
|
-
style={{
|
|
67
|
-
width: 64,
|
|
68
|
-
flexShrink: 0,
|
|
69
|
-
color: muted,
|
|
70
|
-
fontFamily: theme.fonts.monospace,
|
|
71
|
-
textTransform: 'uppercase',
|
|
72
|
-
fontSize: theme.fontSizes[0] * 0.8,
|
|
73
|
-
}}
|
|
74
|
-
>
|
|
75
|
-
{k}
|
|
76
|
-
</span>
|
|
77
|
-
<span style={{ color: theme.colors.text, fontFamily: theme.fonts.monospace }}>
|
|
78
|
-
{v}
|
|
79
|
-
</span>
|
|
80
|
-
</div>
|
|
81
|
-
))}
|
|
82
|
-
{component.detail && <GraphifyDetailSections detail={component.detail} />}
|
|
83
|
-
</div>
|
|
42
|
+
{children}
|
|
43
|
+
</span>
|
|
84
44
|
);
|
|
85
45
|
}
|
|
86
46
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
47
|
+
/** Panel props. The two callbacks are wired by the graph; standalone usage
|
|
48
|
+
* without them renders the same panel with nothing clickable. */
|
|
49
|
+
export interface ComponentDetailProps {
|
|
50
|
+
component: SubsystemComponent;
|
|
51
|
+
/** File-path click → open that file in the bottom drawer. */
|
|
52
|
+
onOpenFile?: (file: string) => void;
|
|
53
|
+
/** Related-name click (types, callers/callees, implementors, …) → select
|
|
54
|
+
* that component if one matches; unmatched refs no-op. */
|
|
55
|
+
onRelatedSelect?: (ref: string) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Detail panel for the selected component — its declaration, code-style. */
|
|
59
|
+
export function ComponentDetail({ component, onOpenFile, onRelatedSelect }: ComponentDetailProps) {
|
|
89
60
|
const { theme } = useTheme();
|
|
90
61
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
62
|
+
const color = KIND_COLOR[component.kind] ?? '#888';
|
|
63
|
+
const displayName = deriveNameFromSymbol(component.symbol, component.kind, component.name, component.file);
|
|
91
64
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}}
|
|
102
|
-
>
|
|
103
|
-
{k}
|
|
104
|
-
</span>
|
|
65
|
+
// Token styles — keyword / name / member / type / string / punctuation.
|
|
66
|
+
const kw = (text: string) => <span style={{ color: theme.colors.secondary }}>{text}</span>;
|
|
67
|
+
const nm = (text: string) => <span style={{ color }}>{text}</span>;
|
|
68
|
+
const mb = (text: string) => <span style={{ color: theme.colors.info }}>{text}</span>;
|
|
69
|
+
// Type tokens are links into the graph (other components often own them).
|
|
70
|
+
const ty = (text: string) => (
|
|
71
|
+
<DetailLink onClick={onRelatedSelect ? () => onRelatedSelect(text) : undefined}>
|
|
72
|
+
<span style={{ color: theme.colors.accent }}>{text}</span>
|
|
73
|
+
</DetailLink>
|
|
105
74
|
);
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
color: theme.colors.text,
|
|
113
|
-
border: `1px solid ${theme.colors.border}`,
|
|
114
|
-
borderRadius: 4,
|
|
115
|
-
padding: '1px 6px',
|
|
116
|
-
marginRight: 6,
|
|
117
|
-
marginBottom: 4,
|
|
118
|
-
display: 'inline-block',
|
|
119
|
-
}}
|
|
120
|
-
>
|
|
121
|
-
{name}
|
|
122
|
-
{meta ? <span style={{ color: muted }}> {meta}</span> : ''}
|
|
123
|
-
</span>
|
|
124
|
-
);
|
|
125
|
-
const row = (k: string, children: ReactNode) => (
|
|
126
|
-
<div
|
|
127
|
-
style={{ display: 'flex', gap: 8, fontSize: theme.fontSizes[0], lineHeight: 1.5, marginBottom: 4 }}
|
|
128
|
-
>
|
|
129
|
-
{label(k)}
|
|
130
|
-
<div style={{ minWidth: 0 }}>{children}</div>
|
|
75
|
+
const str = (text: string) => <span style={{ color: theme.colors.success }}>{text}</span>;
|
|
76
|
+
const pn = (text: string) => <span style={{ color: muted }}>{text}</span>;
|
|
77
|
+
|
|
78
|
+
const line = (children: ReactNode, key?: string, indent?: boolean) => (
|
|
79
|
+
<div key={key} style={indent ? { paddingLeft: 16 } : undefined}>
|
|
80
|
+
{children}
|
|
131
81
|
</div>
|
|
132
82
|
);
|
|
83
|
+
const commentStyle = { color: muted, fontStyle: 'italic' } as const;
|
|
84
|
+
const commentLine = (text: string, key?: string, indent?: boolean, onClick?: () => void) =>
|
|
85
|
+
line(
|
|
86
|
+
<DetailLink onClick={onClick}>
|
|
87
|
+
<span style={{ ...commentStyle, ...(onClick ? clickableStyle : undefined) }}>{text}</span>
|
|
88
|
+
</DetailLink>,
|
|
89
|
+
key,
|
|
90
|
+
indent,
|
|
91
|
+
);
|
|
92
|
+
// Trailing relationship comments whose names are individually clickable.
|
|
93
|
+
const commentListLine = (prefix: string, names: string[], key?: string) =>
|
|
94
|
+
line(
|
|
95
|
+
<>
|
|
96
|
+
<span style={commentStyle}>{prefix}</span>
|
|
97
|
+
{list(
|
|
98
|
+
names.map((n, i) => (
|
|
99
|
+
<Fragment key={i}>
|
|
100
|
+
<DetailLink onClick={onRelatedSelect ? () => onRelatedSelect(n) : undefined}>
|
|
101
|
+
<span style={{ ...commentStyle, ...(onRelatedSelect ? clickableStyle : undefined) }}>{n}</span>
|
|
102
|
+
</DetailLink>
|
|
103
|
+
</Fragment>
|
|
104
|
+
)),
|
|
105
|
+
)}
|
|
106
|
+
</>,
|
|
107
|
+
key,
|
|
108
|
+
);
|
|
109
|
+
// Comma-join rendered tokens without raw-array key churn.
|
|
110
|
+
const list = (items: ReactNode[]) =>
|
|
111
|
+
items.map((item, i) => (
|
|
112
|
+
<Fragment key={i}>
|
|
113
|
+
{i > 0 ? ', ' : null}
|
|
114
|
+
{item}
|
|
115
|
+
</Fragment>
|
|
116
|
+
));
|
|
133
117
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
118
|
+
const lines: ReactNode[] = [];
|
|
119
|
+
if (component.purl)
|
|
120
|
+
lines.push(commentLine(`// ${formatPurl(component.purl)}`, 'purl'));
|
|
121
|
+
if (component.file)
|
|
122
|
+
lines.push(commentLine(`// ${component.file}`, 'file', false, onOpenFile ? () => onOpenFile(component.file) : undefined));
|
|
123
|
+
if (component.purpose) lines.push(commentLine(`/** ${component.purpose} */`, 'purpose'));
|
|
124
|
+
|
|
125
|
+
const detail = component.detail;
|
|
126
|
+
switch (detail?.kind ?? component.kind) {
|
|
127
|
+
case 'class': {
|
|
128
|
+
const members: ReactNode[] = [];
|
|
129
|
+
if (detail?.kind === 'class') {
|
|
130
|
+
detail.methods.forEach((m, i) =>
|
|
131
|
+
members.push(
|
|
132
|
+
line(
|
|
133
|
+
<>
|
|
134
|
+
{mb(m.name)}
|
|
135
|
+
{pn('(')}
|
|
136
|
+
{(m.parameters ?? []).length > 0 &&
|
|
137
|
+
list(
|
|
138
|
+
(m.parameters ?? []).map((p, j) => (
|
|
139
|
+
<Fragment key={j}>
|
|
140
|
+
{p.name ? (
|
|
141
|
+
<>
|
|
142
|
+
{mb(p.name)}
|
|
143
|
+
{pn(': ')}
|
|
144
|
+
</>
|
|
145
|
+
) : null}
|
|
146
|
+
{ty(p.type)}
|
|
147
|
+
</Fragment>
|
|
148
|
+
)),
|
|
149
|
+
)}
|
|
150
|
+
{pn(')')}
|
|
151
|
+
{m.returnType ? (
|
|
152
|
+
<>
|
|
153
|
+
{pn(': ')}
|
|
154
|
+
{ty(m.returnType)}
|
|
155
|
+
</>
|
|
156
|
+
) : null}
|
|
157
|
+
</>,
|
|
158
|
+
`m${i}`,
|
|
159
|
+
true,
|
|
160
|
+
),
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
detail.properties.forEach((prop, i) =>
|
|
164
|
+
members.push(
|
|
165
|
+
line(
|
|
166
|
+
<>
|
|
167
|
+
{mb(prop.name)}
|
|
168
|
+
{prop.type ? (
|
|
169
|
+
<>
|
|
170
|
+
{pn(': ')}
|
|
171
|
+
{ty(prop.type)}
|
|
172
|
+
</>
|
|
173
|
+
) : null}
|
|
174
|
+
</>,
|
|
175
|
+
`p${i}`,
|
|
176
|
+
true,
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const cls = detail?.kind === 'class' ? detail : undefined;
|
|
182
|
+
const hasBody = members.length > 0;
|
|
183
|
+
lines.push(
|
|
184
|
+
line(
|
|
185
|
+
<>
|
|
186
|
+
{kw('class')}
|
|
187
|
+
{' '}
|
|
188
|
+
{nm(displayName)}
|
|
189
|
+
{cls && cls.extends.length > 0 && (
|
|
190
|
+
<>
|
|
191
|
+
{' '}
|
|
192
|
+
{kw('extends')}
|
|
193
|
+
{' '}
|
|
194
|
+
{list(cls.extends.map((e, i) => <Fragment key={i}>{ty(e)}</Fragment>))}
|
|
195
|
+
</>
|
|
196
|
+
)}
|
|
197
|
+
{cls && cls.implements.length > 0 && (
|
|
198
|
+
<>
|
|
199
|
+
{' '}
|
|
200
|
+
{kw('implements')}
|
|
201
|
+
{' '}
|
|
202
|
+
{list(cls.implements.map((e, i) => <Fragment key={i}>{ty(e)}</Fragment>))}
|
|
203
|
+
</>
|
|
204
|
+
)}
|
|
205
|
+
{hasBody ? pn(' {') : null}
|
|
206
|
+
</>,
|
|
207
|
+
'decl',
|
|
208
|
+
),
|
|
149
209
|
);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
210
|
+
lines.push(...members);
|
|
211
|
+
if (hasBody) lines.push(line(pn('}'), 'end'));
|
|
212
|
+
if (cls && cls.instantiations.length > 0)
|
|
213
|
+
lines.push(commentListLine('// constructed by: ', cls.instantiations.map((c) => c.name), 'insts'));
|
|
214
|
+
if (cls && cls.references.length > 0)
|
|
215
|
+
lines.push(commentListLine('// referenced by: ', cls.references.map((r) => r.name), 'refs'));
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
case 'function': {
|
|
219
|
+
const fn = detail?.kind === 'function' ? detail : undefined;
|
|
220
|
+
const params = fn?.parameters ?? [];
|
|
221
|
+
lines.push(
|
|
222
|
+
line(
|
|
223
|
+
<>
|
|
224
|
+
{kw('function')}
|
|
225
|
+
{' '}
|
|
226
|
+
{nm(displayName)}
|
|
227
|
+
{pn('(')}
|
|
228
|
+
{params.length > 0 &&
|
|
229
|
+
list(
|
|
230
|
+
params.map((param, i) => (
|
|
231
|
+
<Fragment key={i}>
|
|
232
|
+
{param.name ? (
|
|
233
|
+
<>
|
|
234
|
+
{mb(param.name)}
|
|
235
|
+
{pn(': ')}
|
|
236
|
+
</>
|
|
237
|
+
) : null}
|
|
238
|
+
{ty(param.type)}
|
|
239
|
+
</Fragment>
|
|
240
|
+
)),
|
|
241
|
+
)}
|
|
242
|
+
{pn(')')}
|
|
243
|
+
{fn?.returnType ? (
|
|
244
|
+
<>
|
|
245
|
+
{pn(': ')}
|
|
246
|
+
{ty(fn.returnType)}
|
|
247
|
+
</>
|
|
248
|
+
) : null}
|
|
249
|
+
{pn(';')}
|
|
250
|
+
</>,
|
|
251
|
+
'decl',
|
|
252
|
+
),
|
|
161
253
|
);
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
254
|
+
if (fn && fn.callers.length > 0)
|
|
255
|
+
lines.push(commentListLine('// called by: ', fn.callers.map((c) => c.name), 'callers'));
|
|
256
|
+
if (fn && fn.callees.length > 0)
|
|
257
|
+
lines.push(commentListLine('// calls: ', fn.callees.map((c) => c.name), 'callees'));
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case 'type': {
|
|
261
|
+
const tpe = detail?.kind === 'type' ? detail : undefined;
|
|
262
|
+
const hasBody = (tpe?.properties.length ?? 0) > 0;
|
|
263
|
+
lines.push(
|
|
264
|
+
line(
|
|
265
|
+
<>
|
|
266
|
+
{kw('interface')}
|
|
267
|
+
{' '}
|
|
268
|
+
{nm(displayName)}
|
|
269
|
+
{hasBody ? pn(' {') : null}
|
|
270
|
+
</>,
|
|
271
|
+
'decl',
|
|
272
|
+
),
|
|
172
273
|
);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
274
|
+
if (tpe) {
|
|
275
|
+
tpe.properties.forEach((prop, i) =>
|
|
276
|
+
lines.push(
|
|
277
|
+
line(
|
|
278
|
+
<>
|
|
279
|
+
{mb(prop.name)}
|
|
280
|
+
{prop.type ? (
|
|
281
|
+
<>
|
|
282
|
+
{pn(': ')}
|
|
283
|
+
{ty(prop.type)}
|
|
284
|
+
</>
|
|
285
|
+
) : null}
|
|
286
|
+
{pn(';')}
|
|
287
|
+
</>,
|
|
288
|
+
`p${i}`,
|
|
289
|
+
true,
|
|
290
|
+
),
|
|
291
|
+
),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (hasBody) lines.push(line(pn('}'), 'end'));
|
|
295
|
+
if (tpe && tpe.implementors.length > 0)
|
|
296
|
+
lines.push(commentListLine('// implemented by: ', tpe.implementors, 'impl'));
|
|
297
|
+
if (tpe && tpe.usedBy.length > 0)
|
|
298
|
+
lines.push(commentListLine('// used by: ', tpe.usedBy.map((u) => u.name), 'usedBy'));
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
case 'module': {
|
|
302
|
+
const mod = detail?.kind === 'module' ? detail : undefined;
|
|
303
|
+
if (mod) {
|
|
304
|
+
mod.imports.forEach((imp, i) =>
|
|
305
|
+
lines.push(
|
|
306
|
+
line(
|
|
307
|
+
<>
|
|
308
|
+
{kw('import')}
|
|
309
|
+
{' '}
|
|
310
|
+
{pn("'")}
|
|
311
|
+
{str(imp.name)}
|
|
312
|
+
{pn("';")}
|
|
313
|
+
</>,
|
|
314
|
+
`imp${i}`,
|
|
315
|
+
),
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
if (mod.exports.length > 0)
|
|
319
|
+
lines.push(
|
|
320
|
+
line(
|
|
321
|
+
<>
|
|
322
|
+
{kw('export')}
|
|
323
|
+
{' '}
|
|
324
|
+
{pn('{ ')}
|
|
325
|
+
{list(mod.exports.map((e, i) => <Fragment key={i}>{mb(e)}</Fragment>))}
|
|
326
|
+
{pn(' }')}
|
|
327
|
+
{pn(';')}
|
|
328
|
+
</>,
|
|
329
|
+
'exports',
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
if (mod.symbols.length > 0)
|
|
333
|
+
lines.push(commentLine(`// defines: ${mod.symbols.join(', ')}`, 'symbols'));
|
|
334
|
+
} else {
|
|
335
|
+
lines.push(
|
|
336
|
+
line(
|
|
337
|
+
<>
|
|
338
|
+
{kw('module')}
|
|
339
|
+
{' '}
|
|
340
|
+
{nm(displayName)}
|
|
341
|
+
</>,
|
|
342
|
+
'decl',
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
case 'external': {
|
|
349
|
+
lines.push(
|
|
350
|
+
line(
|
|
351
|
+
<>
|
|
352
|
+
{kw('external')}
|
|
353
|
+
{' '}
|
|
354
|
+
{detail?.kind === 'external' ? (
|
|
355
|
+
<>
|
|
356
|
+
{pn("'")}
|
|
357
|
+
{str(detail.label)}
|
|
358
|
+
{pn("'")}
|
|
359
|
+
</>
|
|
360
|
+
) : (
|
|
361
|
+
nm(displayName)
|
|
362
|
+
)}
|
|
363
|
+
</>,
|
|
364
|
+
'decl',
|
|
365
|
+
),
|
|
183
366
|
);
|
|
184
|
-
|
|
185
|
-
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
186
369
|
}
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<div
|
|
373
|
+
style={{
|
|
374
|
+
borderTop: `1px solid ${theme.colors.border}`,
|
|
375
|
+
background: theme.colors.backgroundSecondary,
|
|
376
|
+
padding: '10px 12px',
|
|
377
|
+
fontFamily: theme.fonts.monospace,
|
|
378
|
+
fontSize: theme.fontSizes[0],
|
|
379
|
+
lineHeight: 1.7,
|
|
380
|
+
wordBreak: 'break-word',
|
|
381
|
+
}}
|
|
382
|
+
>
|
|
383
|
+
{lines}
|
|
384
|
+
</div>
|
|
385
|
+
);
|
|
187
386
|
}
|
|
@@ -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,
|
|
@@ -97,6 +97,21 @@ const edgeTypes: EdgeTypes = {
|
|
|
97
97
|
'subsystem-edge': SubsystemEdge,
|
|
98
98
|
};
|
|
99
99
|
|
|
100
|
+
// Memoized drawer body: only rebuilds children when the open file changes.
|
|
101
|
+
// Inner re-renders on every viewport pan/zoom and hover; recreating host
|
|
102
|
+
// elements then would churn their readFile closures and flash the file
|
|
103
|
+
// viewer's loading state on each render.
|
|
104
|
+
const DrawerContent = memo(function DrawerContent({
|
|
105
|
+
render,
|
|
106
|
+
file,
|
|
107
|
+
}: {
|
|
108
|
+
render: (file: string) => ReactNode;
|
|
109
|
+
file: string | null;
|
|
110
|
+
}) {
|
|
111
|
+
if (!file) return null;
|
|
112
|
+
return <>{render(file)}</>;
|
|
113
|
+
});
|
|
114
|
+
|
|
100
115
|
interface InnerProps extends SubsystemComponentGraphProps {
|
|
101
116
|
measured: { w: number; h: number } | null;
|
|
102
117
|
}
|
|
@@ -360,6 +375,26 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
360
375
|
[onFileSelect],
|
|
361
376
|
);
|
|
362
377
|
|
|
378
|
+
// Detail-panel links: related-name clicks select the matching component —
|
|
379
|
+
// resolved by id, name, or symbol (call labels may carry a trailing `()`).
|
|
380
|
+
const resolveRelatedComponent = useCallback(
|
|
381
|
+
(ref: string) => {
|
|
382
|
+
const clean = ref.replace(/\(\)$/, '');
|
|
383
|
+
const comp = components.find(
|
|
384
|
+
(c) =>
|
|
385
|
+
c.id === clean ||
|
|
386
|
+
c.name === clean ||
|
|
387
|
+
c.symbol === clean ||
|
|
388
|
+
c.symbol?.replace(/\(\)$/, '') === clean,
|
|
389
|
+
);
|
|
390
|
+
if (!comp || comp.id === selectedRef.current?.id) return;
|
|
391
|
+
setSelected(comp);
|
|
392
|
+
setSelectedEdgeId(null);
|
|
393
|
+
onSelect?.(comp.id);
|
|
394
|
+
},
|
|
395
|
+
[components, onSelect],
|
|
396
|
+
);
|
|
397
|
+
|
|
363
398
|
// Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
|
|
364
399
|
// doesn't intercept pointer events). Uses ELK-computed label midpoints from
|
|
365
400
|
// the actual edge path (not node-center approximations).
|
|
@@ -408,6 +443,15 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
408
443
|
return undefined;
|
|
409
444
|
}, [renderFileViewer, renderFileView, components]);
|
|
410
445
|
|
|
446
|
+
// Latest-viewer ref + stable callback so DrawerContent's memo holds across
|
|
447
|
+
// unrelated Inner renders (see comment there).
|
|
448
|
+
const fileViewerRef = useRef(fileViewer);
|
|
449
|
+
fileViewerRef.current = fileViewer;
|
|
450
|
+
const renderDrawerContent = useCallback(
|
|
451
|
+
(file: string) => fileViewerRef.current?.(file) ?? null,
|
|
452
|
+
[],
|
|
453
|
+
);
|
|
454
|
+
|
|
411
455
|
return (
|
|
412
456
|
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
|
|
413
457
|
{/* Sidebar: scrollable title/description on top, file tree pinned to the bottom half */}
|
|
@@ -596,9 +640,15 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
|
|
|
596
640
|
mechanisms={usedMechanisms}
|
|
597
641
|
onClose={() => setLegendOpen(false)}
|
|
598
642
|
/>
|
|
599
|
-
{selected &&
|
|
643
|
+
{selected && (
|
|
644
|
+
<ComponentDetail
|
|
645
|
+
component={selected}
|
|
646
|
+
onOpenFile={onTreeSelectFile}
|
|
647
|
+
onRelatedSelect={resolveRelatedComponent}
|
|
648
|
+
/>
|
|
649
|
+
)}
|
|
600
650
|
<FileDrawer file={openFile} onClose={() => setOpenFile(null)}>
|
|
601
|
-
{
|
|
651
|
+
<DrawerContent render={renderDrawerContent} file={openFile} />
|
|
602
652
|
</FileDrawer>
|
|
603
653
|
{/* Startup cover — hides measurement, layout swap, and camera settle. */}
|
|
604
654
|
<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
|
});
|