@principal-ai/principal-view-react 0.16.45 → 0.16.46

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.
@@ -6,15 +6,16 @@
6
6
  * comments. File content lives in the bottom FileDrawer, not here.
7
7
  */
8
8
 
9
- import { Fragment, useState, type ReactNode } from 'react';
9
+ import { useEffect, 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 {
13
13
  KIND_COLOR,
14
- deriveNameFromSymbol,
15
14
  formatPurl,
16
15
  type SubsystemComponent,
16
+ type SubsystemDeclTokenKind,
17
17
  } from './model';
18
+ import { tokenizeComponent } from './tokenizeComponent';
18
19
 
19
20
  /** Shared affordance for clickable text pieces. */
20
21
  const clickableStyle = {
@@ -54,10 +55,12 @@ export interface ComponentDeclarationProps {
54
55
  /** Related-name click (types, callers/callees, implementors, …) → select
55
56
  * that component if one matches; unmatched refs no-op. */
56
57
  onRelatedSelect?: (ref: string) => void;
58
+ /** Max width of the declaration panel (CSS value). Defaults to none. */
59
+ maxWidth?: string | number;
57
60
  }
58
61
 
59
62
  /** Declaration panel for the selected component — its definition, code-style. */
60
- export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect }: ComponentDeclarationProps) {
63
+ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect: _onRelatedSelect, maxWidth }: ComponentDeclarationProps) {
61
64
  const { theme } = useTheme();
62
65
  const [fileHovered, setFileHovered] = useState(false);
63
66
  // Header visibility toggles — off by default so the panel opens as pure
@@ -66,26 +69,26 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect }:
66
69
  const [showPurpose, setShowPurpose] = useState(false);
67
70
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
68
71
  const color = KIND_COLOR[component.kind] ?? '#888';
69
- const displayName = deriveNameFromSymbol(component.symbol, component.kind, component.name, component.file);
70
72
 
71
- // Token styles keyword / name / member / type / string / punctuation.
72
- const kw = (text: string) => <span style={{ color: theme.colors.secondary }}>{text}</span>;
73
- const nm = (text: string) => <span style={{ color }}>{text}</span>;
74
- const mb = (text: string) => <span style={{ color: theme.colors.info }}>{text}</span>;
75
- // Type tokens are links into the graph (other components often own them).
76
- const ty = (text: string) => (
77
- <DetailLink onClick={onRelatedSelect ? () => onRelatedSelect(text) : undefined}>
78
- <span style={{ color: theme.colors.accent }}>{text}</span>
79
- </DetailLink>
80
- );
81
- const str = (text: string) => <span style={{ color: theme.colors.success }}>{text}</span>;
82
- const pn = (text: string) => <span style={{ color: muted }}>{text}</span>;
73
+ // Token-kind theme-color map (used by the token iterator below).
74
+ const tokenColor: Record<SubsystemDeclTokenKind, string> = {
75
+ keyword: theme.colors.secondary,
76
+ name: color,
77
+ member: theme.colors.info,
78
+ type: theme.colors.accent,
79
+ punctuation: muted,
80
+ string: theme.colors.success,
81
+ newline: '', // not rendered — drives line breaks
82
+ };
83
83
 
84
- const line = (children: ReactNode, key?: string, indent?: boolean) => (
85
- <div key={key} style={indent ? { paddingLeft: 16 } : undefined}>
86
- {children}
87
- </div>
88
- );
84
+ const line = (children: ReactNode, key?: string, indent?: boolean | number) => {
85
+ const paddingLeft = typeof indent === 'number' ? indent : indent ? 16 : 0;
86
+ return (
87
+ <div key={key} style={{ display: 'flex', alignItems: 'center', whiteSpace: 'pre', minHeight: 18, ...(paddingLeft ? { paddingLeft } : {}) }}>
88
+ {children}
89
+ </div>
90
+ );
91
+ };
89
92
  const commentStyle = { color: muted, fontStyle: 'italic' } as const;
90
93
  const commentLine = (text: string, key?: string, indent?: boolean, onClick?: () => void) =>
91
94
  line(
@@ -109,15 +112,6 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect }:
109
112
  // `// implemented by:`, …) are deliberately not rendered — the graph's
110
113
  // edges carry interactions, and duplicated name lists read as clutter. The
111
114
  // data stays on the Graphify detail types for other surfaces.
112
- // Comma-join rendered tokens without raw-array key churn.
113
- const list = (items: ReactNode[]) =>
114
- items.map((item, i) => (
115
- <Fragment key={i}>
116
- {i > 0 ? ', ' : null}
117
- {item}
118
- </Fragment>
119
- ));
120
-
121
115
  const lines: ReactNode[] = [];
122
116
  // Repo identity — owner avatar + name for GitHub-hosted purls; anything
123
117
  // else falls back to the formatted purl as a quiet comment. The avatar
@@ -207,246 +201,42 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect }:
207
201
  ),
208
202
  );
209
203
 
210
- const detail = component.detail;
211
- // Detail facets may be hand-authored (see `detailProvenance`) and the wire
212
- // carries no guarantee that every typed array is present — treat all of
213
- // them as optional so a partial detail degrades to fewer lines, never a
214
- // render crash.
215
- switch (detail?.kind ?? component.kind) {
216
- case 'class': {
217
- const members: ReactNode[] = [];
218
- if (detail?.kind === 'class') {
219
- (detail.methods ?? []).forEach((m, i) =>
220
- members.push(
221
- line(
222
- <>
223
- {mb(m.name)}
224
- {pn('(')}
225
- {(m.parameters ?? []).length > 0 &&
226
- list(
227
- (m.parameters ?? []).map((p, j) => (
228
- <Fragment key={j}>
229
- {p.name ? (
230
- <>
231
- {mb(p.name)}
232
- {pn(': ')}
233
- </>
234
- ) : null}
235
- {ty(p.type)}
236
- </Fragment>
237
- )),
238
- )}
239
- {pn(')')}
240
- {m.returnType ? (
241
- <>
242
- {pn(': ')}
243
- {ty(m.returnType)}
244
- </>
245
- ) : null}
246
- </>,
247
- `m${i}`,
248
- true,
249
- ),
250
- ),
251
- );
252
- (detail.properties ?? []).forEach((prop, i) =>
253
- members.push(
254
- line(
255
- <>
256
- {mb(prop.name)}
257
- {prop.type ? (
258
- <>
259
- {pn(': ')}
260
- {ty(prop.type)}
261
- </>
262
- ) : null}
263
- </>,
264
- `p${i}`,
265
- true,
266
- ),
267
- ),
268
- );
269
- }
270
- const cls = detail?.kind === 'class' ? detail : undefined;
271
- const hasBody = members.length > 0;
272
- lines.push(
273
- line(
274
- <>
275
- {kw('class')}
276
- {' '}
277
- {nm(displayName)}
278
- {cls && (cls.extends ?? []).length > 0 && (
279
- <>
280
- {' '}
281
- {kw('extends')}
282
- {' '}
283
- {list((cls.extends ?? []).map((e, i) => <Fragment key={i}>{ty(e)}</Fragment>))}
284
- </>
285
- )}
286
- {cls && (cls.implements ?? []).length > 0 && (
287
- <>
288
- {' '}
289
- {kw('implements')}
290
- {' '}
291
- {list((cls.implements ?? []).map((e, i) => <Fragment key={i}>{ty(e)}</Fragment>))}
292
- </>
293
- )}
294
- {hasBody ? pn(' {') : null}
295
- </>,
296
- 'decl',
297
- ),
298
- );
299
- lines.push(...members);
300
- if (hasBody) lines.push(line(pn('}'), 'end'));
301
- break;
204
+ // --- Declaration tokens ------------------------------------------------
205
+ // Tokenize asynchronously via Prettier + highlight.js. Pre-tokenized
206
+ // tokens from the wire skip the pipeline entirely.
207
+ const [tokens, setTokens] = useState(component.tokens ?? []);
208
+ useEffect(() => {
209
+ if (component.tokens) {
210
+ setTokens(component.tokens);
211
+ return;
302
212
  }
303
- case 'function': {
304
- const fn = detail?.kind === 'function' ? detail : undefined;
305
- const params = fn?.parameters ?? [];
306
- lines.push(
307
- line(
308
- <>
309
- {kw('function')}
310
- {' '}
311
- {nm(displayName)}
312
- {pn('(')}
313
- {params.length > 0 &&
314
- list(
315
- params.map((param, i) => (
316
- <Fragment key={i}>
317
- {param.name ? (
318
- <>
319
- {mb(param.name)}
320
- {pn(': ')}
321
- </>
322
- ) : null}
323
- {ty(param.type)}
324
- </Fragment>
325
- )),
326
- )}
327
- {pn(')')}
328
- {fn?.returnType ? (
329
- <>
330
- {pn(': ')}
331
- {ty(fn.returnType)}
332
- </>
333
- ) : null}
334
- {pn(';')}
335
- </>,
336
- 'decl',
337
- ),
338
- );
339
- // Caller/callee relationship comments (`// called by:` / `// calls:`)
340
- // are deliberately not rendered here — the graph's edges carry
341
- // interactions, and duplicated call lists read as clutter. The data
342
- // stays on `GraphifyFunctionDetail` for other surfaces.
343
- break;
344
- }
345
- case 'type': {
346
- const tpe = detail?.kind === 'type' ? detail : undefined;
347
- const hasBody = ((tpe?.properties ?? []).length) > 0;
348
- lines.push(
349
- line(
350
- <>
351
- {kw('interface')}
352
- {' '}
353
- {nm(displayName)}
354
- {hasBody ? pn(' {') : null}
355
- </>,
356
- 'decl',
357
- ),
358
- );
359
- if (tpe) {
360
- (tpe.properties ?? []).forEach((prop, i) =>
361
- lines.push(
362
- line(
363
- <>
364
- {mb(prop.name)}
365
- {prop.type ? (
366
- <>
367
- {pn(': ')}
368
- {ty(prop.type)}
369
- </>
370
- ) : null}
371
- {pn(';')}
372
- </>,
373
- `p${i}`,
374
- true,
375
- ),
376
- ),
377
- );
378
- }
379
- if (hasBody) lines.push(line(pn('}'), 'end'));
380
- break;
381
- }
382
- case 'module': {
383
- const mod = detail?.kind === 'module' ? detail : undefined;
384
- if (mod) {
385
- (mod.imports ?? []).forEach((imp, i) =>
386
- lines.push(
387
- line(
388
- <>
389
- {kw('import')}
390
- {' '}
391
- {pn("'")}
392
- {str(imp.name)}
393
- {pn("';")}
394
- </>,
395
- `imp${i}`,
396
- ),
397
- ),
398
- );
399
- if ((mod.exports ?? []).length > 0)
400
- lines.push(
401
- line(
402
- <>
403
- {kw('export')}
404
- {' '}
405
- {pn('{ ')}
406
- {list((mod.exports ?? []).map((e, i) => <Fragment key={i}>{mb(e)}</Fragment>))}
407
- {pn(' }')}
408
- {pn(';')}
409
- </>,
410
- 'exports',
411
- ),
412
- );
413
- if ((mod.symbols ?? []).length > 0)
414
- lines.push(commentLine(`// defines: ${(mod.symbols ?? []).join(', ')}`, 'symbols'));
415
- } else {
416
- lines.push(
417
- line(
418
- <>
419
- {kw('module')}
420
- {' '}
421
- {nm(displayName)}
422
- </>,
423
- 'decl',
424
- ),
425
- );
426
- }
427
- break;
428
- }
429
- case 'external': {
430
- lines.push(
431
- line(
432
- <>
433
- {kw('external')}
434
- {' '}
435
- {detail?.kind === 'external' ? (
436
- <>
437
- {pn("'")}
438
- {str(detail.label)}
439
- {pn("'")}
440
- </>
441
- ) : (
442
- nm(displayName)
443
- )}
444
- </>,
445
- 'decl',
446
- ),
447
- );
448
- break;
213
+ let cancelled = false;
214
+ tokenizeComponent(component).then((t) => {
215
+ if (!cancelled) setTokens(t);
216
+ });
217
+ return () => { cancelled = true; };
218
+ }, [component]);
219
+ const declLines: ReactNode[][] = [[]];
220
+ let di = 0;
221
+ for (const tok of tokens) {
222
+ if (tok.kind === 'newline') {
223
+ declLines.push([]);
224
+ di++;
225
+ continue;
449
226
  }
227
+ declLines[di].push(
228
+ <span key={`${di}-${declLines[di].length}`} style={{ color: tokenColor[tok.kind] }}>
229
+ {tok.text}
230
+ </span>,
231
+ );
232
+ }
233
+ for (let li = 0; li < declLines.length; li++) {
234
+ if (declLines[li].length === 0) continue;
235
+ lines.push(
236
+ <div key={`decl-${li}`} style={{ display: 'flex', alignItems: 'center', whiteSpace: 'pre', minHeight: 18 }}>
237
+ {declLines[li]}
238
+ </div>,
239
+ );
450
240
  }
451
241
 
452
242
  // Purpose prose follows the declaration — identity and syntax first, the
@@ -474,7 +264,9 @@ export function ComponentDeclaration({ component, onOpenFile, onRelatedSelect }:
474
264
  fontFamily: theme.fonts.monospace,
475
265
  fontSize: theme.fontSizes[1],
476
266
  lineHeight: 1.7,
477
- wordBreak: 'break-word',
267
+ ...(maxWidth != null
268
+ ? { maxWidth, wordBreak: 'break-word' }
269
+ : { overflowX: 'auto' }),
478
270
  }}
479
271
  >
480
272
  {lines}
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Generate a TypeScript declaration string from a SubsystemComponent's detail.
3
+ *
4
+ * This is the "source code" that Prettier will format. The output is valid
5
+ * TypeScript (except for `external`, which is handled separately). The string
6
+ * is intentionally simple — no indentation, no line breaks — because Prettier
7
+ * handles all formatting.
8
+ */
9
+
10
+ import type { SubsystemComponent } from './model';
11
+ import type { GraphifyComponentDetail } from '../graphify';
12
+
13
+ export function generateDeclarationString(component: SubsystemComponent): string {
14
+ const detail = component.detail;
15
+ const kind = detail?.kind ?? component.kind;
16
+ const name = component.symbol || component.name || 'untitled';
17
+
18
+ switch (kind) {
19
+ case 'class':
20
+ return generateClass(name, detail);
21
+ case 'function':
22
+ return generateFunction(name, detail);
23
+ case 'type':
24
+ return generateType(name, detail);
25
+ case 'module':
26
+ return generateModule(detail);
27
+ case 'external':
28
+ // Not valid TypeScript — caller should handle formatting.
29
+ return `external '${detail?.kind === 'external' ? detail.label : name}'`;
30
+ default:
31
+ return `${kind} ${name}`;
32
+ }
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Format parameters, synthesising names for unnamed positionals. */
40
+ function formatParams(params: { name?: string; type: string }[]): string {
41
+ return params
42
+ .map((p, i) => (p.name ? `${p.name}: ${p.type}` : `arg${i}: ${p.type}`))
43
+ .join(', ');
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Per-kind generators
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function generateClass(name: string, detail?: GraphifyComponentDetail): string {
51
+ const cls = detail?.kind === 'class' ? detail : undefined;
52
+ const parts: string[] = [`class ${name}`];
53
+
54
+ if (cls?.extends && cls.extends.length > 0) {
55
+ parts.push(`extends ${cls.extends.join(', ')}`);
56
+ }
57
+ if (cls?.implements && cls.implements.length > 0) {
58
+ parts.push(`implements ${cls.implements.join(', ')}`);
59
+ }
60
+
61
+ const members: string[] = [];
62
+
63
+ for (const m of cls?.methods ?? []) {
64
+ const ret = m.returnType ? `: ${m.returnType}` : '';
65
+ members.push(` ${m.name}(${formatParams(m.parameters ?? [])})${ret};`);
66
+ }
67
+
68
+ for (const prop of cls?.properties ?? []) {
69
+ const t = prop.type ? `: ${prop.type}` : '';
70
+ members.push(` ${prop.name}${t};`);
71
+ }
72
+
73
+ if (members.length > 0) {
74
+ parts.push(`{\n${members.join('\n')}\n}`);
75
+ } else {
76
+ parts.push('{}');
77
+ }
78
+
79
+ return parts.join(' ');
80
+ }
81
+
82
+ function generateFunction(name: string, detail?: GraphifyComponentDetail): string {
83
+ const fn = detail?.kind === 'function' ? detail : undefined;
84
+ const params = formatParams(fn?.parameters ?? []);
85
+ const ret = fn?.returnType ? `: ${fn.returnType}` : '';
86
+ return `function ${name}(${params})${ret};`;
87
+ }
88
+
89
+ function generateType(name: string, detail?: GraphifyComponentDetail): string {
90
+ const tpe = detail?.kind === 'type' ? detail : undefined;
91
+ const props = (tpe?.properties ?? [])
92
+ .map((p) => ` ${p.name}${p.type ? `: ${p.type}` : ''};`)
93
+ .join('\n');
94
+
95
+ if (props) {
96
+ return `interface ${name} {\n${props}\n}`;
97
+ }
98
+ return `interface ${name} {}`;
99
+ }
100
+
101
+ function generateModule(detail?: GraphifyComponentDetail): string {
102
+ const mod = detail?.kind === 'module' ? detail : undefined;
103
+ if (!mod) return 'module {}';
104
+
105
+ const parts: string[] = [];
106
+
107
+ for (const imp of mod.imports ?? []) {
108
+ parts.push(`import '${imp.name}';`);
109
+ }
110
+
111
+ if ((mod.exports ?? []).length > 0) {
112
+ parts.push(`export { ${mod.exports!.join(', ')} };`);
113
+ }
114
+
115
+ return parts.join('\n') || `module {}`;
116
+ }
@@ -27,6 +27,24 @@ export type SubsystemComponentKind =
27
27
  | 'module'
28
28
  | 'external';
29
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // Declaration tokens — structured source representation
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export type SubsystemDeclTokenKind =
35
+ | 'keyword'
36
+ | 'name'
37
+ | 'member'
38
+ | 'type'
39
+ | 'punctuation'
40
+ | 'string'
41
+ | 'newline';
42
+
43
+ export interface SubsystemDeclToken {
44
+ text: string;
45
+ kind: SubsystemDeclTokenKind;
46
+ }
47
+
30
48
  export type SubsystemEdgeMechanism =
31
49
  | 'imports'
32
50
  | 'imports_from'
@@ -87,6 +105,12 @@ export interface SubsystemComponent {
87
105
  * may claim `verified`.
88
106
  */
89
107
  detailProvenance?: 'verified' | 'authored';
108
+ /**
109
+ * Pre-tokenized declaration for the detail panel. When present, the
110
+ * renderer skips client-side tokenization. Tokens are language-agnostic;
111
+ * a different language just needs a different tokenizer and text joiner.
112
+ */
113
+ tokens?: SubsystemDeclToken[];
90
114
  }
91
115
 
92
116
  /** A cross-component edge in the subsystem graph. */
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Tokenize a SubsystemComponent into a flat token stream for the detail panel.
3
+ *
4
+ * Pipeline: generate declaration string → format with Prettier → tokenize
5
+ * with highlight.js. The `component.tokens` field, when present, overrides
6
+ * the entire pipeline (for pre-tokenized data from graphify).
7
+ *
8
+ * This function is async because Prettier's format() is async.
9
+ */
10
+
11
+ import type { SubsystemComponent, SubsystemDeclToken } from './model';
12
+ import { generateDeclarationString } from './formatDeclaration';
13
+ import { tokenizeFormatted } from './tokenizeFormatted';
14
+
15
+ // Lazy-loaded Prettier to avoid startup cost.
16
+ let prettierPromise: Promise<typeof import('prettier/standalone')> | null = null;
17
+ let prettierPluginsPromise: Promise<{
18
+ typescript: typeof import('prettier/plugins/typescript');
19
+ estree: typeof import('prettier/plugins/estree');
20
+ }> | null = null;
21
+
22
+ async function getPrettier() {
23
+ if (!prettierPromise) {
24
+ prettierPromise = import('prettier/standalone');
25
+ }
26
+ if (!prettierPluginsPromise) {
27
+ prettierPluginsPromise = Promise.all([
28
+ import('prettier/plugins/typescript'),
29
+ import('prettier/plugins/estree'),
30
+ ]).then(([typescript, estree]) => ({ typescript, estree }));
31
+ }
32
+ const [prettier, plugins] = await Promise.all([prettierPromise, prettierPluginsPromise]);
33
+ return { prettier, plugins };
34
+ }
35
+
36
+ /**
37
+ * Tokenize a SubsystemComponent into SubsystemDeclToken[].
38
+ *
39
+ * When `component.tokens` is present (pre-tokenized data from the wire),
40
+ * it's returned as-is. Otherwise the pipeline generates a TypeScript
41
+ * declaration string, formats it with Prettier, and tokenizes the output.
42
+ */
43
+ export async function tokenizeComponent(component: SubsystemComponent): Promise<SubsystemDeclToken[]> {
44
+ // Pre-tokenized tokens from the wire take precedence.
45
+ if (component.tokens) return component.tokens;
46
+
47
+ // External kind — not valid TypeScript, bypass Prettier.
48
+ const kind = component.detail?.kind ?? component.kind;
49
+ if (kind === 'external') {
50
+ const label = component.detail?.kind === 'external' ? component.detail.label : component.name;
51
+ return [
52
+ { text: 'external', kind: 'keyword' },
53
+ { text: ' ', kind: 'punctuation' },
54
+ { text: "'", kind: 'punctuation' },
55
+ { text: label, kind: 'string' },
56
+ { text: "'", kind: 'punctuation' },
57
+ ];
58
+ }
59
+
60
+ // Generate → format → tokenize
61
+ const raw = generateDeclarationString(component);
62
+ const { prettier, plugins } = await getPrettier();
63
+ const formatted = await prettier.format(raw, {
64
+ parser: 'typescript',
65
+ plugins: [plugins.typescript, plugins.estree],
66
+ printWidth: 80,
67
+ });
68
+
69
+ return tokenizeFormatted(formatted);
70
+ }