@principal-ai/subsystems-react 0.33.0 → 0.34.0
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 +4 -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/ComponentDeclaration.d.ts +7 -1
- package/dist/subsystem/ComponentDeclaration.d.ts.map +1 -1
- package/dist/subsystem/ComponentDeclaration.js +169 -5
- package/dist/subsystem/ComponentDeclaration.js.map +1 -1
- package/dist/subsystem/IssueList.d.ts +4 -2
- package/dist/subsystem/IssueList.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +6 -6
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SymbolInspectionCard.d.ts +31 -0
- package/dist/subsystem/SymbolInspectionCard.d.ts.map +1 -0
- package/dist/subsystem/SymbolInspectionCard.js +247 -0
- package/dist/subsystem/SymbolInspectionCard.js.map +1 -0
- package/dist/subsystem/symbolRefs.d.ts +95 -0
- package/dist/subsystem/symbolRefs.d.ts.map +1 -0
- package/dist/subsystem/symbolRefs.js +167 -0
- package/dist/subsystem/symbolRefs.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +14 -0
- package/src/stories/Subsystem/ComponentDeclarationSymbols.stories.tsx +269 -0
- package/src/subsystem/ComponentDeclaration.tsx +239 -9
- package/src/subsystem/IssueList.tsx +10 -8
- package/src/subsystem/SymbolInspectionCard.tsx +433 -0
- package/src/subsystem/symbolRefs.test.ts +127 -0
- package/src/subsystem/symbolRefs.ts +256 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import '@xyflow/react/dist/style.css';
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
4
|
+
import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
|
|
5
|
+
import { ComponentDeclaration } from '../../subsystem/ComponentDeclaration';
|
|
6
|
+
import type { SubsystemComponent } from '../../subsystem/model';
|
|
7
|
+
import type { GraphifyComponentDetail } from '../../graphify';
|
|
8
|
+
import type { SymbolInspection } from '../../subsystem/symbolRefs';
|
|
9
|
+
|
|
10
|
+
const meta = {
|
|
11
|
+
title: 'Subsystem/ComponentDeclarationSymbols',
|
|
12
|
+
component: ComponentDeclaration,
|
|
13
|
+
parameters: { layout: 'padded' },
|
|
14
|
+
tags: ['autodocs'],
|
|
15
|
+
decorators: [
|
|
16
|
+
(Story) => (
|
|
17
|
+
<ThemeProvider theme={defaultEditorTheme}>
|
|
18
|
+
<div style={{ maxWidth: '80ch', margin: '0 auto' }}>
|
|
19
|
+
<Story />
|
|
20
|
+
</div>
|
|
21
|
+
</ThemeProvider>
|
|
22
|
+
),
|
|
23
|
+
],
|
|
24
|
+
} satisfies Meta<typeof ComponentDeclaration>;
|
|
25
|
+
export default meta;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Fixture graphify lookups — one per information level. Names absent from the
|
|
29
|
+
// map resolve as `missing`, mirroring "nothing in the cached graph".
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
const INSPECTIONS: Record<string, SymbolInspection> = {
|
|
33
|
+
RawEvent: {
|
|
34
|
+
symbol: 'RawEvent',
|
|
35
|
+
purl: 'pkg:github/principal-ai/agent-monitoring',
|
|
36
|
+
resolution: 'resolved',
|
|
37
|
+
node: {
|
|
38
|
+
nodeId: 'eventprocessing_types_rawevent',
|
|
39
|
+
label: 'RawEvent',
|
|
40
|
+
sourceFile: 'src/event-processing/types.ts',
|
|
41
|
+
sourceLocation: 'L8',
|
|
42
|
+
},
|
|
43
|
+
declaration: {
|
|
44
|
+
kind: 'type',
|
|
45
|
+
properties: [
|
|
46
|
+
{ name: 'id', type: 'string' },
|
|
47
|
+
{ name: 'at', type: 'number' },
|
|
48
|
+
{ name: 'payload', type: 'unknown' },
|
|
49
|
+
],
|
|
50
|
+
usedBy: [],
|
|
51
|
+
implementors: [],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
BaseProcessor: {
|
|
55
|
+
symbol: 'BaseProcessor',
|
|
56
|
+
purl: 'pkg:github/principal-ai/agent-monitoring',
|
|
57
|
+
resolution: 'resolved',
|
|
58
|
+
node: {
|
|
59
|
+
nodeId: 'eventprocessing_baseprocessor_baseprocessor',
|
|
60
|
+
label: 'BaseProcessor',
|
|
61
|
+
sourceFile: 'src/event-processing/BaseProcessor.ts',
|
|
62
|
+
sourceLocation: 'L3',
|
|
63
|
+
},
|
|
64
|
+
declaration: {
|
|
65
|
+
kind: 'class',
|
|
66
|
+
methods: [{ nodeId: 'm-run', name: 'run', parameters: [], returnType: 'void' }],
|
|
67
|
+
properties: [],
|
|
68
|
+
extends: [],
|
|
69
|
+
implements: [],
|
|
70
|
+
instantiations: [],
|
|
71
|
+
references: [],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
// Limited: graphify resolved the location but couldn't reconstruct the
|
|
75
|
+
// shape — the card falls back to the source declaration, still offering
|
|
76
|
+
// "Add to references" because the model has no structured declaration.
|
|
77
|
+
ProcessingOptions: {
|
|
78
|
+
symbol: 'ProcessingOptions',
|
|
79
|
+
purl: 'pkg:github/principal-ai/agent-monitoring',
|
|
80
|
+
resolution: 'resolved',
|
|
81
|
+
node: {
|
|
82
|
+
nodeId: 'eventprocessing_types_processingoptions',
|
|
83
|
+
label: 'ProcessingOptions',
|
|
84
|
+
sourceFile: 'src/event-processing/types.ts',
|
|
85
|
+
sourceLocation: 'L21',
|
|
86
|
+
},
|
|
87
|
+
source: {
|
|
88
|
+
file: 'src/event-processing/types.ts',
|
|
89
|
+
startLine: 21,
|
|
90
|
+
text: 'interface ProcessingOptions {\n retries: number;\n timeoutMs: number;\n}',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
// Limited: multiple same-label definitions.
|
|
94
|
+
ProcessedEvent: {
|
|
95
|
+
symbol: 'ProcessedEvent',
|
|
96
|
+
resolution: 'ambiguous',
|
|
97
|
+
candidates: [
|
|
98
|
+
{ nodeId: 'a1', label: 'ProcessedEvent', sourceFile: 'src/event-processing/types.ts' },
|
|
99
|
+
{ nodeId: 'a2', label: 'ProcessedEvent', sourceFile: 'src/legacy/types.ts' },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
// Limited: referenced but not defined in this corpus (external/ambient type).
|
|
103
|
+
Disposable: {
|
|
104
|
+
symbol: 'Disposable',
|
|
105
|
+
resolution: 'unresolved',
|
|
106
|
+
reason: 'referenced but not defined in this corpus',
|
|
107
|
+
},
|
|
108
|
+
// Limited: nothing in the corpus.
|
|
109
|
+
EventEmitterLike: {
|
|
110
|
+
symbol: 'EventEmitterLike',
|
|
111
|
+
resolution: 'missing',
|
|
112
|
+
reason: 'no matching definition in the cached graph',
|
|
113
|
+
},
|
|
114
|
+
SessionRecord: {
|
|
115
|
+
symbol: 'SessionRecord',
|
|
116
|
+
resolution: 'resolved',
|
|
117
|
+
node: {
|
|
118
|
+
nodeId: 'session_transcript_sessionrecord',
|
|
119
|
+
label: 'SessionRecord',
|
|
120
|
+
sourceFile: 'src/session/transcript.ts',
|
|
121
|
+
sourceLocation: 'L12',
|
|
122
|
+
},
|
|
123
|
+
declaration: {
|
|
124
|
+
kind: 'type',
|
|
125
|
+
properties: [
|
|
126
|
+
{ name: 'id', type: 'string' },
|
|
127
|
+
{ name: 'admittedSeq', type: 'number' },
|
|
128
|
+
{ name: 'events', type: 'SessionEvent[]' },
|
|
129
|
+
],
|
|
130
|
+
usedBy: [],
|
|
131
|
+
implementors: [],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
'capture-session': {
|
|
135
|
+
symbol: 'capture-session',
|
|
136
|
+
resolution: 'resolved',
|
|
137
|
+
node: {
|
|
138
|
+
nodeId: 'session_capture_capture_session',
|
|
139
|
+
label: 'capture-session()',
|
|
140
|
+
sourceFile: 'src/session/capture.ts',
|
|
141
|
+
sourceLocation: 'L40',
|
|
142
|
+
},
|
|
143
|
+
declaration: {
|
|
144
|
+
kind: 'function',
|
|
145
|
+
parameters: [{ name: 'session', type: 'SessionRecord' }],
|
|
146
|
+
returnType: 'Promise<void>',
|
|
147
|
+
callers: [],
|
|
148
|
+
callees: [],
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const inspect = (symbol: string): Promise<SymbolInspection> =>
|
|
154
|
+
new Promise((resolve) =>
|
|
155
|
+
setTimeout(
|
|
156
|
+
() =>
|
|
157
|
+
resolve(
|
|
158
|
+
INSPECTIONS[symbol] ?? {
|
|
159
|
+
symbol,
|
|
160
|
+
resolution: 'missing',
|
|
161
|
+
reason: 'no matching definition in the cached graph',
|
|
162
|
+
},
|
|
163
|
+
),
|
|
164
|
+
220,
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
function Harness({ component }: { component: SubsystemComponent }) {
|
|
169
|
+
const [log, setLog] = useState<string[]>([]);
|
|
170
|
+
return (
|
|
171
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
172
|
+
<div style={{ fontFamily: 'monospace', fontSize: 11, color: '#888' }}>
|
|
173
|
+
Click a dotted-underlined symbol to inspect what graphify has on it.
|
|
174
|
+
</div>
|
|
175
|
+
<ComponentDeclaration
|
|
176
|
+
component={component}
|
|
177
|
+
onInspectSymbol={inspect}
|
|
178
|
+
onAddToModel={(ref, info) =>
|
|
179
|
+
setLog((l) => [`add ${ref.name} (${info?.resolution ?? 'no info'})`, ...l])
|
|
180
|
+
}
|
|
181
|
+
onOpenFile={(file, opts) =>
|
|
182
|
+
setLog((l) => [
|
|
183
|
+
`open ${file}${opts?.startLine ? `:${opts.startLine}` : ''}`,
|
|
184
|
+
...l,
|
|
185
|
+
])
|
|
186
|
+
}
|
|
187
|
+
/>
|
|
188
|
+
<div
|
|
189
|
+
data-symbol-action-log
|
|
190
|
+
style={{ fontFamily: 'monospace', fontSize: 11, color: '#6a9955', minHeight: 16 }}
|
|
191
|
+
>
|
|
192
|
+
{log.length ? `actions: ${log.join(' · ')}` : 'actions: —'}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// A class whose declaration references a range of symbols — resolved
|
|
200
|
+
// (type/class), thin (unknown construct → "Add to references"), ambiguous,
|
|
201
|
+
// unresolved, and missing.
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
const classComponent: SubsystemComponent = {
|
|
205
|
+
alias: 'class-event-processor',
|
|
206
|
+
name: 'EventProcessor',
|
|
207
|
+
construct: 'class',
|
|
208
|
+
file: 'src/event-processing/EventProcessor.ts',
|
|
209
|
+
purl: 'pkg:github/principal-ai/agent-monitoring',
|
|
210
|
+
symbol: 'EventProcessor',
|
|
211
|
+
purpose: 'Consumes raw events and emits processed ones.',
|
|
212
|
+
declaration: {
|
|
213
|
+
kind: 'class',
|
|
214
|
+
methods: [
|
|
215
|
+
{
|
|
216
|
+
nodeId: 'm-process',
|
|
217
|
+
name: 'process',
|
|
218
|
+
parameters: [
|
|
219
|
+
{ name: 'event', type: 'RawEvent' },
|
|
220
|
+
{ name: 'options', type: 'ProcessingOptions' },
|
|
221
|
+
],
|
|
222
|
+
returnType: 'ProcessedEvent',
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
nodeId: 'm-batch',
|
|
226
|
+
name: 'batch',
|
|
227
|
+
parameters: [{ name: 'events', type: 'RawEvent[]' }],
|
|
228
|
+
returnType: 'Promise<ProcessedEvent[]>',
|
|
229
|
+
},
|
|
230
|
+
{ nodeId: 'm-dispose', name: 'dispose' },
|
|
231
|
+
],
|
|
232
|
+
properties: [
|
|
233
|
+
{ name: 'queue', type: 'RawEvent[]' },
|
|
234
|
+
{ name: 'options', type: 'Required<ProcessingOptions>' },
|
|
235
|
+
],
|
|
236
|
+
extends: ['BaseProcessor'],
|
|
237
|
+
implements: ['Disposable', 'EventEmitterLike'],
|
|
238
|
+
instantiations: [],
|
|
239
|
+
references: [{ nodeId: 'n-emitter', name: 'EventEmitterLike', context: 'type' }],
|
|
240
|
+
} satisfies GraphifyComponentDetail,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
export const InspectableSymbols: StoryObj<Record<string, never>> = {
|
|
244
|
+
render: () => <Harness component={classComponent} />,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// A function — callers/callees are clickable too, alongside param/return types.
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
const functionComponent: SubsystemComponent = {
|
|
252
|
+
alias: 'fn-normalize',
|
|
253
|
+
name: 'normalize',
|
|
254
|
+
construct: 'function',
|
|
255
|
+
file: 'src/session/SessionReader.ts',
|
|
256
|
+
purl: 'pkg:github/principal-ai/agent-monitoring',
|
|
257
|
+
symbol: 'SessionReader.normalize',
|
|
258
|
+
declaration: {
|
|
259
|
+
kind: 'function',
|
|
260
|
+
parameters: [{ name: 'session', type: 'SessionRecord' }],
|
|
261
|
+
returnType: 'SessionEvent[]',
|
|
262
|
+
callers: [{ nodeId: 'c1', name: 'capture-session', source_location: 'L120' }],
|
|
263
|
+
callees: [{ nodeId: 'c2', name: 'toUniversalEvents', source_location: 'L64' }],
|
|
264
|
+
} satisfies GraphifyComponentDetail,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
export const CallableRelated: StoryObj<Record<string, never>> = {
|
|
268
|
+
render: () => <Harness component={functionComponent} />,
|
|
269
|
+
};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* comments. File content lives in the bottom FileDrawer, not here.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
10
10
|
import { AlignLeft, FileText } from 'lucide-react';
|
|
11
11
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
12
12
|
import { resolvePierreSyntaxThemeName } from '../pierre/pierreSyntaxTheme';
|
|
@@ -19,6 +19,38 @@ import type { SubsystemOpenFileOptions } from './declarationRef';
|
|
|
19
19
|
import { parseSourceLocation } from './declarationRef';
|
|
20
20
|
import { tokenizeComponent } from './tokenizeComponent';
|
|
21
21
|
import { componentColor } from '../pierre/constructColors';
|
|
22
|
+
import type { DeclarationSymbolRef, SymbolInspection } from './symbolRefs';
|
|
23
|
+
import { extractDeclarationSymbolRefs } from './symbolRefs';
|
|
24
|
+
import { SymbolInspectionCard } from './SymbolInspectionCard';
|
|
25
|
+
|
|
26
|
+
/** One clickable span in the token stream, mapped to a referenced symbol. */
|
|
27
|
+
interface SymbolClickRange {
|
|
28
|
+
start: number;
|
|
29
|
+
end: number;
|
|
30
|
+
ref: DeclarationSymbolRef;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whole-word occurrences of `name` in `text` (identifier boundaries only). */
|
|
34
|
+
function findSymbolOccurrences(
|
|
35
|
+
text: string,
|
|
36
|
+
name: string,
|
|
37
|
+
): { start: number; end: number }[] {
|
|
38
|
+
const out: { start: number; end: number }[] = [];
|
|
39
|
+
if (!name) return out;
|
|
40
|
+
const isWordChar = (ch: string) => /[\w$]/.test(ch);
|
|
41
|
+
let from = 0;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const idx = text.indexOf(name, from);
|
|
44
|
+
if (idx < 0) break;
|
|
45
|
+
const before = idx > 0 ? text[idx - 1]! : '';
|
|
46
|
+
const after = text[idx + name.length] ?? '';
|
|
47
|
+
if ((!before || !isWordChar(before)) && (!after || !isWordChar(after))) {
|
|
48
|
+
out.push({ start: idx, end: idx + name.length });
|
|
49
|
+
}
|
|
50
|
+
from = idx + name.length;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
22
54
|
|
|
23
55
|
/** Live / result state for the declaration-panel Verify control. */
|
|
24
56
|
export type ComponentVerificationPhase =
|
|
@@ -113,6 +145,14 @@ export interface ComponentDeclarationProps {
|
|
|
113
145
|
/** Related-name click (types, callers/callees, implementors, …) → select
|
|
114
146
|
* that component if one matches; unmatched refs no-op. */
|
|
115
147
|
onRelatedSelect?: (ref: string) => void;
|
|
148
|
+
/** Referenced-symbol click → resolve it against graphify. When absent,
|
|
149
|
+
* symbols are not clickable. Host-injected; stories pass a fixture. */
|
|
150
|
+
onInspectSymbol?: (
|
|
151
|
+
symbol: string,
|
|
152
|
+
ref: DeclarationSymbolRef,
|
|
153
|
+
) => Promise<SymbolInspection | null> | SymbolInspection | null;
|
|
154
|
+
/** "Add to references" from the inspection card. */
|
|
155
|
+
onAddToModel?: (ref: DeclarationSymbolRef, info?: SymbolInspection) => void;
|
|
116
156
|
/** Max width of the declaration panel (CSS value). Defaults to none. */
|
|
117
157
|
maxWidth?: string | number;
|
|
118
158
|
/** Live verification status for the selected component. */
|
|
@@ -267,6 +307,8 @@ export function ComponentDeclaration({
|
|
|
267
307
|
component,
|
|
268
308
|
onOpenFile,
|
|
269
309
|
onRelatedSelect: _onRelatedSelect,
|
|
310
|
+
onInspectSymbol,
|
|
311
|
+
onAddToModel,
|
|
270
312
|
maxWidth,
|
|
271
313
|
verification,
|
|
272
314
|
defaultShowFile = false,
|
|
@@ -300,6 +342,53 @@ export function ComponentDeclaration({
|
|
|
300
342
|
/** Fallback when wire tokens omit Shiki colors. */
|
|
301
343
|
const declarationTextColor = muted;
|
|
302
344
|
|
|
345
|
+
// Symbols the declaration references — made clickable for a graphify lookup.
|
|
346
|
+
const symbolRefs = useMemo(
|
|
347
|
+
() => extractDeclarationSymbolRefs(component.declaration),
|
|
348
|
+
[component.declaration],
|
|
349
|
+
);
|
|
350
|
+
const [inspection, setInspection] = useState<
|
|
351
|
+
| {
|
|
352
|
+
ref: DeclarationSymbolRef;
|
|
353
|
+
loading: boolean;
|
|
354
|
+
info?: SymbolInspection;
|
|
355
|
+
error?: string;
|
|
356
|
+
x: number;
|
|
357
|
+
y: number;
|
|
358
|
+
}
|
|
359
|
+
| null
|
|
360
|
+
>(null);
|
|
361
|
+
// Symbol name currently hovered, so every occurrence of the same symbol can
|
|
362
|
+
// highlight together (and stay lit while its popover is open).
|
|
363
|
+
const [hoveredSymbol, setHoveredSymbol] = useState<string | null>(null);
|
|
364
|
+
const activeSymbol = inspection?.ref.name ?? null;
|
|
365
|
+
|
|
366
|
+
// Dismiss the inspection card on outside click / Escape.
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
if (!inspection) return;
|
|
369
|
+
const onDown = (e: MouseEvent) => {
|
|
370
|
+
const t = e.target as HTMLElement | null;
|
|
371
|
+
// The popover, and the symbol spans themselves (they toggle on click),
|
|
372
|
+
// must not be treated as outside.
|
|
373
|
+
if (t?.closest('[data-symbol-inspection]')) return;
|
|
374
|
+
if (t?.closest('[data-symbol-ref]')) return;
|
|
375
|
+
setInspection(null);
|
|
376
|
+
};
|
|
377
|
+
const onKey = (e: KeyboardEvent) => {
|
|
378
|
+
if (e.key === 'Escape') {
|
|
379
|
+
// Drop focus from the symbol span so no focus ring lingers after close.
|
|
380
|
+
(document.activeElement as HTMLElement | null)?.blur?.();
|
|
381
|
+
setInspection(null);
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
document.addEventListener('mousedown', onDown);
|
|
385
|
+
document.addEventListener('keydown', onKey);
|
|
386
|
+
return () => {
|
|
387
|
+
document.removeEventListener('mousedown', onDown);
|
|
388
|
+
document.removeEventListener('keydown', onKey);
|
|
389
|
+
};
|
|
390
|
+
}, [inspection]);
|
|
391
|
+
|
|
303
392
|
const declarationStartLine =
|
|
304
393
|
component.declarationRef?.startLine ??
|
|
305
394
|
verification?.declaration?.ref?.startLine ??
|
|
@@ -729,22 +818,140 @@ export function ComponentDeclaration({
|
|
|
729
818
|
cancelled = true;
|
|
730
819
|
};
|
|
731
820
|
}, [component, printWidth, pierreSyntaxTheme]);
|
|
821
|
+
|
|
822
|
+
// Map character offsets in the concatenated token text to referenced symbols,
|
|
823
|
+
// so inline occurrences render as clickable spans. `-1` = not a symbol.
|
|
824
|
+
const clickIndex = useMemo(() => {
|
|
825
|
+
const full = tokens.map((t) => t.text).join('');
|
|
826
|
+
const rangeAt = new Int32Array(full.length).fill(-1);
|
|
827
|
+
if (!onInspectSymbol || symbolRefs.length === 0) {
|
|
828
|
+
return { ranges: [] as SymbolClickRange[], rangeAt };
|
|
829
|
+
}
|
|
830
|
+
const ranges: SymbolClickRange[] = [];
|
|
831
|
+
for (const ref of symbolRefs) {
|
|
832
|
+
for (const occ of findSymbolOccurrences(full, ref.name)) {
|
|
833
|
+
ranges.push({ start: occ.start, end: occ.end, ref });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
ranges.sort((a, b) => a.start - b.start);
|
|
837
|
+
ranges.forEach((r, i) => {
|
|
838
|
+
for (let k = r.start; k < r.end && k < rangeAt.length; k++) {
|
|
839
|
+
if (rangeAt[k] === -1) rangeAt[k] = i;
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
return { ranges, rangeAt };
|
|
843
|
+
}, [onInspectSymbol, symbolRefs, tokens]);
|
|
844
|
+
|
|
845
|
+
const handleInspect = useCallback(
|
|
846
|
+
async (ref: DeclarationSymbolRef, el: HTMLElement) => {
|
|
847
|
+
if (!onInspectSymbol) return;
|
|
848
|
+
// Toggle: clicking the symbol whose popover is already open closes it.
|
|
849
|
+
if (inspection?.ref.name === ref.name) {
|
|
850
|
+
setInspection(null);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
let x = 0;
|
|
854
|
+
let y = 0;
|
|
855
|
+
const cont = containerRef.current;
|
|
856
|
+
if (cont) {
|
|
857
|
+
const crect = cont.getBoundingClientRect();
|
|
858
|
+
const r = el.getBoundingClientRect();
|
|
859
|
+
x = r.left - crect.left;
|
|
860
|
+
y = r.bottom - crect.top + 4;
|
|
861
|
+
}
|
|
862
|
+
setInspection({ ref, loading: true, x, y });
|
|
863
|
+
try {
|
|
864
|
+
const info = await onInspectSymbol(ref.name, ref);
|
|
865
|
+
setInspection((cur) =>
|
|
866
|
+
cur?.ref.name === ref.name
|
|
867
|
+
? { ref, loading: false, info: info ?? undefined, x, y }
|
|
868
|
+
: cur,
|
|
869
|
+
);
|
|
870
|
+
} catch (err) {
|
|
871
|
+
setInspection((cur) =>
|
|
872
|
+
cur?.ref.name === ref.name
|
|
873
|
+
? {
|
|
874
|
+
ref,
|
|
875
|
+
loading: false,
|
|
876
|
+
error: err instanceof Error ? err.message : String(err),
|
|
877
|
+
x,
|
|
878
|
+
y,
|
|
879
|
+
}
|
|
880
|
+
: cur,
|
|
881
|
+
);
|
|
882
|
+
}
|
|
883
|
+
},
|
|
884
|
+
[onInspectSymbol, inspection],
|
|
885
|
+
);
|
|
886
|
+
|
|
732
887
|
const declLines: ReactNode[][] = [[]];
|
|
733
888
|
let di = 0;
|
|
889
|
+
let offset = 0;
|
|
734
890
|
for (const tok of tokens) {
|
|
735
891
|
if (tok.kind === 'newline') {
|
|
736
892
|
declLines.push([]);
|
|
737
893
|
di++;
|
|
738
894
|
continue;
|
|
739
895
|
}
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
896
|
+
const color = tok.color ?? tokenColor[tok.kind] ?? declarationTextColor;
|
|
897
|
+
let i = 0;
|
|
898
|
+
while (i < tok.text.length) {
|
|
899
|
+
const rIdx = clickIndex.rangeAt[offset + i] ?? -1;
|
|
900
|
+
let j = i + 1;
|
|
901
|
+
while (j < tok.text.length && (clickIndex.rangeAt[offset + j] ?? -1) === rIdx) {
|
|
902
|
+
j++;
|
|
903
|
+
}
|
|
904
|
+
const piece = tok.text.slice(i, j);
|
|
905
|
+
const key = `${di}-${declLines[di].length}`;
|
|
906
|
+
const range = rIdx >= 0 ? clickIndex.ranges[rIdx] : undefined;
|
|
907
|
+
if (range) {
|
|
908
|
+
const ref = range.ref;
|
|
909
|
+
const hovered = hoveredSymbol === ref.name;
|
|
910
|
+
// Active (popover open for this symbol) is the only state that gets a
|
|
911
|
+
// solid underline; hover just shifts the color.
|
|
912
|
+
const active = activeSymbol === ref.name;
|
|
913
|
+
const lit = hovered || active;
|
|
914
|
+
declLines[di].push(
|
|
915
|
+
<span
|
|
916
|
+
key={key}
|
|
917
|
+
role="button"
|
|
918
|
+
tabIndex={0}
|
|
919
|
+
data-symbol-ref={ref.name}
|
|
920
|
+
// Don't take focus on mouse click — otherwise closing the popover
|
|
921
|
+
// (Escape) leaves a focus ring around the clicked symbol.
|
|
922
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
923
|
+
onMouseEnter={() => setHoveredSymbol(ref.name)}
|
|
924
|
+
onMouseLeave={(e) => {
|
|
925
|
+
// Keep it lit when moving straight to another occurrence of the
|
|
926
|
+
// same symbol; otherwise clear on unhover.
|
|
927
|
+
const to = e.relatedTarget as HTMLElement | null;
|
|
928
|
+
const next = to?.closest('[data-symbol-ref]')?.getAttribute('data-symbol-ref');
|
|
929
|
+
if (next === ref.name) return;
|
|
930
|
+
setHoveredSymbol((s) => (s === ref.name ? null : s));
|
|
931
|
+
}}
|
|
932
|
+
onClick={(e) => handleInspect(ref, e.currentTarget)}
|
|
933
|
+
style={{
|
|
934
|
+
// Lit occurrences shift to the accent color (no background fill).
|
|
935
|
+
color: lit ? theme.colors.accent ?? theme.colors.secondary : color,
|
|
936
|
+
cursor: 'pointer',
|
|
937
|
+
textDecorationLine: 'underline',
|
|
938
|
+
textDecorationStyle: active ? 'solid' : 'dotted',
|
|
939
|
+
textUnderlineOffset: 2,
|
|
940
|
+
}}
|
|
941
|
+
>
|
|
942
|
+
{piece}
|
|
943
|
+
</span>,
|
|
944
|
+
);
|
|
945
|
+
} else {
|
|
946
|
+
declLines[di].push(
|
|
947
|
+
<span key={key} style={{ color }}>
|
|
948
|
+
{piece}
|
|
949
|
+
</span>,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
i = j;
|
|
953
|
+
}
|
|
954
|
+
offset += tok.text.length;
|
|
748
955
|
}
|
|
749
956
|
for (let li = 0; li < declLines.length; li++) {
|
|
750
957
|
if (declLines[li].length === 0) continue;
|
|
@@ -783,10 +990,22 @@ export function ComponentDeclaration({
|
|
|
783
990
|
);
|
|
784
991
|
}
|
|
785
992
|
|
|
993
|
+
const inspectionLeft = inspection
|
|
994
|
+
? Math.max(
|
|
995
|
+
0,
|
|
996
|
+
Math.min(
|
|
997
|
+
inspection.x,
|
|
998
|
+
Math.max(0, (containerRef.current?.clientWidth ?? 320) - 320),
|
|
999
|
+
),
|
|
1000
|
+
)
|
|
1001
|
+
: 0;
|
|
1002
|
+
|
|
786
1003
|
return (
|
|
787
1004
|
<div
|
|
788
1005
|
ref={containerRef}
|
|
1006
|
+
onMouseLeave={() => setHoveredSymbol(null)}
|
|
789
1007
|
style={{
|
|
1008
|
+
position: 'relative',
|
|
790
1009
|
width: '100%',
|
|
791
1010
|
minWidth: 0,
|
|
792
1011
|
boxSizing: 'border-box',
|
|
@@ -800,6 +1019,17 @@ export function ComponentDeclaration({
|
|
|
800
1019
|
}}
|
|
801
1020
|
>
|
|
802
1021
|
{lines}
|
|
1022
|
+
{inspection && !inspection.loading && (
|
|
1023
|
+
<SymbolInspectionCard
|
|
1024
|
+
symbolRef={inspection.ref}
|
|
1025
|
+
inspection={inspection.info}
|
|
1026
|
+
error={inspection.error}
|
|
1027
|
+
onClose={() => setInspection(null)}
|
|
1028
|
+
onAddToModel={onAddToModel}
|
|
1029
|
+
onOpenFile={onOpenFile}
|
|
1030
|
+
style={{ left: inspectionLeft, top: inspection.y }}
|
|
1031
|
+
/>
|
|
1032
|
+
)}
|
|
803
1033
|
</div>
|
|
804
1034
|
);
|
|
805
1035
|
}
|
|
@@ -38,13 +38,15 @@ export type SubsystemIssueTargetKind =
|
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* The four verification layers, in order. Mirrors the docs' progression:
|
|
41
|
-
* constructs → static topology →
|
|
41
|
+
* constructs → static topology → dynamic topology → walkthrough.
|
|
42
|
+
* Static topology = relations; dynamic topology = process (runtime) +
|
|
43
|
+
* package/module (containment).
|
|
42
44
|
*/
|
|
43
45
|
export type SubsystemIssueCategory =
|
|
44
46
|
| 'repo'
|
|
45
47
|
| 'construct'
|
|
46
48
|
| 'static-topology'
|
|
47
|
-
| '
|
|
49
|
+
| 'dynamic-topology'
|
|
48
50
|
| 'walkthrough';
|
|
49
51
|
|
|
50
52
|
/** Layer order + display names, shared by the list and its headers. */
|
|
@@ -52,7 +54,7 @@ export const SUBSYSTEM_ISSUE_CATEGORIES: SubsystemIssueCategory[] = [
|
|
|
52
54
|
'repo',
|
|
53
55
|
'construct',
|
|
54
56
|
'static-topology',
|
|
55
|
-
'
|
|
57
|
+
'dynamic-topology',
|
|
56
58
|
'walkthrough',
|
|
57
59
|
];
|
|
58
60
|
|
|
@@ -64,7 +66,7 @@ export const SUBSYSTEM_ISSUE_CATEGORY_ICON: Record<
|
|
|
64
66
|
repo: FolderGit2,
|
|
65
67
|
construct: Component,
|
|
66
68
|
'static-topology': Network,
|
|
67
|
-
'
|
|
69
|
+
'dynamic-topology': Server,
|
|
68
70
|
walkthrough: Route,
|
|
69
71
|
};
|
|
70
72
|
|
|
@@ -75,11 +77,11 @@ export const SUBSYSTEM_ISSUE_CATEGORY_LABEL: Record<
|
|
|
75
77
|
repo: 'Repositories',
|
|
76
78
|
construct: 'Constructs',
|
|
77
79
|
'static-topology': 'Static topology',
|
|
78
|
-
'
|
|
80
|
+
'dynamic-topology': 'Dynamic topology',
|
|
79
81
|
walkthrough: 'Walkthrough',
|
|
80
82
|
};
|
|
81
83
|
|
|
82
|
-
/** Default `kind` → layer. `boundary_*`
|
|
84
|
+
/** Default `kind` → layer. `boundary_*` (module containment + process) is dynamic. */
|
|
83
85
|
const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
|
|
84
86
|
missing_file: 'construct',
|
|
85
87
|
symbol_ambiguous: 'construct',
|
|
@@ -94,8 +96,8 @@ const KIND_CATEGORY: Record<string, SubsystemIssueCategory> = {
|
|
|
94
96
|
topology_broken_endpoint: 'static-topology',
|
|
95
97
|
topology_import_unconfirmed: 'static-topology',
|
|
96
98
|
topology_relation_unconfirmed: 'static-topology',
|
|
97
|
-
boundary_module_file_mismatch: '
|
|
98
|
-
boundary_process_nest_disagree: '
|
|
99
|
+
boundary_module_file_mismatch: 'dynamic-topology',
|
|
100
|
+
boundary_process_nest_disagree: 'dynamic-topology',
|
|
99
101
|
walkthrough: 'walkthrough',
|
|
100
102
|
};
|
|
101
103
|
|