@principal-ai/subsystems-react 0.24.23 → 0.24.25

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.
@@ -11,7 +11,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
11
11
  import type { ReactNode } from 'react';
12
12
  import { useTheme } from '@principal-ade/industry-theme';
13
13
  import { IndustryMarkdownSlide } from 'themed-markdown';
14
- import { ChevronDown, ChevronUp } from 'lucide-react';
14
+ import { ChevronDown, ChevronRight, ChevronUp, FileText, Folder } from 'lucide-react';
15
15
  import {
16
16
  constructBadgeColor,
17
17
  constructBadgeLabel,
@@ -55,6 +55,55 @@ const FileDrawerContent = memo(function FileDrawerContent({
55
55
  return <>{render(file, startLine != null ? { startLine } : undefined)}</>;
56
56
  });
57
57
 
58
+ interface FileTreeNode {
59
+ name: string;
60
+ /** Full path for files; path prefix for folders. */
61
+ path: string;
62
+ isFile: boolean;
63
+ children: FileTreeNode[];
64
+ /** Components declared in this file (file nodes only). */
65
+ components: SubsystemComponent[];
66
+ }
67
+
68
+ /** Build a folder/file tree from component `file` paths. */
69
+ function buildFileTree(components: SubsystemComponent[]): FileTreeNode {
70
+ const root: FileTreeNode = {
71
+ name: '',
72
+ path: '',
73
+ isFile: false,
74
+ children: [],
75
+ components: [],
76
+ };
77
+ for (const c of components) {
78
+ if (!c.file) continue;
79
+ const parts = c.file.split('/').filter(Boolean);
80
+ let node = root;
81
+ let prefix = '';
82
+ parts.forEach((name, i) => {
83
+ const isFile = i === parts.length - 1;
84
+ prefix = prefix ? `${prefix}/${name}` : name;
85
+ let child = node.children.find(
86
+ (x) => x.name === name && x.isFile === isFile,
87
+ );
88
+ if (!child) {
89
+ child = { name, path: prefix, isFile, children: [], components: [] };
90
+ node.children.push(child);
91
+ }
92
+ node = child;
93
+ });
94
+ node.components.push(c);
95
+ }
96
+ const sortNode = (n: FileTreeNode) => {
97
+ n.children.sort((a, b) => {
98
+ if (a.isFile !== b.isFile) return a.isFile ? 1 : -1;
99
+ return a.name.localeCompare(b.name);
100
+ });
101
+ n.children.forEach(sortNode);
102
+ };
103
+ sortNode(root);
104
+ return root;
105
+ }
106
+
58
107
  function resolveRelated(
59
108
  components: SubsystemComponent[],
60
109
  ref: string,
@@ -69,9 +118,7 @@ function resolveRelated(
69
118
  );
70
119
  }
71
120
 
72
- const GH_PURL = /^pkg:github\/([^/]+)\/([^/#?]+)/;
73
-
74
- /** Repo/app label for a purl group header. */
121
+ const GH_PURL = /^pkg:github\/([^/]+)\/([^/#?]+)/;/** Repo/app label for a purl group header. */
75
122
  function repoLabel(c: SubsystemComponent): string {
76
123
  const m = GH_PURL.exec(c.purl ?? '');
77
124
  if (m) return m[2]!;
@@ -131,6 +178,11 @@ export function ConstructsCatalog({
131
178
  );
132
179
  const [descriptionVisible, setDescriptionVisible] = useState(false);
133
180
  const [descToggleHover, setDescToggleHover] = useState(false);
181
+ const [hoveredRow, setHoveredRow] = useState<string | null>(null);
182
+ const [sidebarTab, setSidebarTab] = useState<'constructs' | 'files'>('constructs');
183
+ const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
184
+ new Set(),
185
+ );
134
186
  const [drawer, setDrawer] = useState<{ file: string; startLine?: number } | null>(
135
187
  null,
136
188
  );
@@ -143,6 +195,12 @@ export function ConstructsCatalog({
143
195
  [orderedComponents, visibleSet],
144
196
  );
145
197
 
198
+ const fileTree = useMemo(() => buildFileTree(orderedComponents), [orderedComponents]);
199
+ const hasFiles = useMemo(
200
+ () => orderedComponents.some((c) => !!c.file),
201
+ [orderedComponents],
202
+ );
203
+
146
204
  // Group visible declarations by purl so the repo/app identity (logo + name)
147
205
  // renders once per group instead of on every card.
148
206
  const repoGroups = useMemo(() => {
@@ -235,6 +293,169 @@ export function ConstructsCatalog({
235
293
  [],
236
294
  );
237
295
 
296
+ const toggleFolder = useCallback((path: string) => {
297
+ setCollapsedFolders((prev) => {
298
+ const next = new Set(prev);
299
+ if (next.has(path)) next.delete(path);
300
+ else next.add(path);
301
+ return next;
302
+ });
303
+ }, []);
304
+
305
+ /** File-tree click: show every construct declared in the file, or hide them
306
+ * all when they're already shown — mirroring a construct row toggle. */
307
+ const toggleFile = useCallback(
308
+ (comps: SubsystemComponent[]) => {
309
+ const ids = comps.map((c) => c.id);
310
+ setVisibleIds((prev) => {
311
+ const set = new Set(prev);
312
+ const allVisible = ids.every((id) => set.has(id));
313
+ ids.forEach((id) => (allVisible ? set.delete(id) : set.add(id)));
314
+ return orderedComponents.filter((c) => set.has(c.id)).map((c) => c.id);
315
+ });
316
+ const first = comps[0];
317
+ if (first) {
318
+ setFocusedId(first.id);
319
+ onSelect?.(first.id);
320
+ }
321
+ },
322
+ [orderedComponents, onSelect],
323
+ );
324
+
325
+ const renderTree = useCallback(
326
+ (node: FileTreeNode, depth: number): ReactNode => {
327
+ return node.children.map((child) => {
328
+ if (!child.isFile) {
329
+ const collapsed = collapsedFolders.has(child.path);
330
+ const hovered = hoveredRow === child.path;
331
+ return (
332
+ <li key={child.path} role="presentation">
333
+ <button
334
+ type="button"
335
+ onClick={() => toggleFolder(child.path)}
336
+ onMouseEnter={() => setHoveredRow(child.path)}
337
+ onMouseLeave={() =>
338
+ setHoveredRow((h) => (h === child.path ? null : h))
339
+ }
340
+ style={{
341
+ display: 'flex',
342
+ alignItems: 'center',
343
+ gap: 4,
344
+ width: '100%',
345
+ padding: '4px 8px',
346
+ paddingLeft: 8 + depth * 12,
347
+ border: 'none',
348
+ borderRadius: 6,
349
+ background: hovered ? theme.colors.border : 'transparent',
350
+ color:
351
+ hovered
352
+ ? theme.colors.text
353
+ : theme.colors.textSecondary ?? muted,
354
+ fontFamily: theme.fonts.monospace,
355
+ fontSize: theme.fontSizes[0],
356
+ textAlign: 'left',
357
+ cursor: 'pointer',
358
+ transition: 'background 100ms ease',
359
+ }}
360
+ >
361
+ {collapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
362
+ <Folder size={12} />
363
+ <span
364
+ style={{
365
+ minWidth: 0,
366
+ overflow: 'hidden',
367
+ textOverflow: 'ellipsis',
368
+ whiteSpace: 'nowrap',
369
+ }}
370
+ >
371
+ {child.name}
372
+ </span>
373
+ </button>
374
+ {!collapsed && (
375
+ <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
376
+ {renderTree(child, depth + 1)}
377
+ </ul>
378
+ )}
379
+ </li>
380
+ );
381
+ }
382
+ const ids = child.components.map((c) => c.id);
383
+ const allVisible = ids.length > 0 && ids.every((id) => visibleSet.has(id));
384
+ const hovered = hoveredRow === child.path;
385
+ const color = componentColor(child.components[0]!, pierreTheme);
386
+ return (
387
+ <li key={child.path} role="presentation">
388
+ <button
389
+ type="button"
390
+ data-testid={`file-row-${child.path}`}
391
+ aria-pressed={allVisible}
392
+ onClick={() => toggleFile(child.components)}
393
+ onMouseEnter={() => setHoveredRow(child.path)}
394
+ onMouseLeave={() =>
395
+ setHoveredRow((h) => (h === child.path ? null : h))
396
+ }
397
+ style={{
398
+ display: 'flex',
399
+ alignItems: 'center',
400
+ gap: 6,
401
+ width: '100%',
402
+ padding: '4px 8px',
403
+ paddingLeft: 8 + depth * 12 + 16,
404
+ border: 'none',
405
+ borderRadius: 6,
406
+ background: hovered ? theme.colors.border : 'transparent',
407
+ color:
408
+ allVisible
409
+ ? color
410
+ : hovered
411
+ ? theme.colors.text
412
+ : theme.colors.textSecondary ?? muted,
413
+ fontFamily: theme.fonts.monospace,
414
+ fontSize: theme.fontSizes[0],
415
+ textAlign: 'left',
416
+ cursor: 'pointer',
417
+ transition: 'background 100ms ease',
418
+ }}
419
+ >
420
+ <FileText size={12} style={{ flexShrink: 0 }} />
421
+ <span
422
+ style={{
423
+ flex: 1,
424
+ minWidth: 0,
425
+ overflow: 'hidden',
426
+ textOverflow: 'ellipsis',
427
+ whiteSpace: 'nowrap',
428
+ fontWeight: allVisible ? 600 : 400,
429
+ }}
430
+ >
431
+ {child.name}
432
+ </span>
433
+ <span
434
+ style={{
435
+ flexShrink: 0,
436
+ fontSize: theme.fontSizes[0] * 0.85,
437
+ color: allVisible ? color : muted,
438
+ }}
439
+ >
440
+ {ids.length}
441
+ </span>
442
+ </button>
443
+ </li>
444
+ );
445
+ });
446
+ },
447
+ [
448
+ collapsedFolders,
449
+ hoveredRow,
450
+ visibleSet,
451
+ toggleFolder,
452
+ toggleFile,
453
+ theme,
454
+ muted,
455
+ pierreTheme,
456
+ ],
457
+ );
458
+
238
459
  const onOpenFile = useCallback(
239
460
  (file: string, opts?: SubsystemOpenFileOptions) => {
240
461
  const startLine = opts?.startLine;
@@ -458,18 +679,45 @@ export function ConstructsCatalog({
458
679
  </div>
459
680
  )}
460
681
  <div
682
+ role="tablist"
683
+ aria-label="Sidebar view"
461
684
  style={{
462
- padding: '10px 16px 6px',
463
- fontSize: theme.fontSizes[0],
464
- fontFamily: theme.fonts.monospace,
465
- letterSpacing: 0.4,
466
- textTransform: 'uppercase',
467
- color: muted,
685
+ display: 'flex',
686
+ gap: 4,
687
+ padding: '8px 12px 6px',
468
688
  flexShrink: 0,
689
+ borderBottom: `1px solid ${theme.colors.border}`,
469
690
  }}
470
691
  >
471
- Constructs
692
+ {(['constructs', 'files'] as const).map((tab) => {
693
+ if (tab === 'files' && !hasFiles) return null;
694
+ const active = sidebarTab === tab;
695
+ return (
696
+ <button
697
+ key={tab}
698
+ type="button"
699
+ role="tab"
700
+ aria-selected={active}
701
+ onClick={() => setSidebarTab(tab)}
702
+ style={{
703
+ padding: '3px 10px',
704
+ border: 'none',
705
+ borderRadius: 6,
706
+ background: active ? theme.colors.border : 'transparent',
707
+ color: active ? theme.colors.text : muted,
708
+ fontSize: theme.fontSizes[0],
709
+ fontFamily: theme.fonts.monospace,
710
+ letterSpacing: 0.4,
711
+ textTransform: 'uppercase',
712
+ cursor: 'pointer',
713
+ }}
714
+ >
715
+ {tab === 'constructs' ? 'Constructs' : 'Files'}
716
+ </button>
717
+ );
718
+ })}
472
719
  </div>
720
+ {sidebarTab === 'constructs' ? (
473
721
  <ul
474
722
  role="listbox"
475
723
  aria-label="Constructs"
@@ -503,6 +751,7 @@ export function ConstructsCatalog({
503
751
  !!c.file &&
504
752
  c.file === drawer.file &&
505
753
  c.declarationRef?.startLine === drawer.startLine;
754
+ const hovered = hoveredRow === c.id;
506
755
  return (
507
756
  <li key={c.id} role="presentation">
508
757
  <button
@@ -512,6 +761,10 @@ export function ConstructsCatalog({
512
761
  aria-selected={visible}
513
762
  data-testid={`construct-row-${c.id}`}
514
763
  onClick={() => toggle(c.id)}
764
+ onMouseEnter={() => setHoveredRow(c.id)}
765
+ onMouseLeave={() =>
766
+ setHoveredRow((h) => (h === c.id ? null : h))
767
+ }
515
768
  style={{
516
769
  display: 'flex',
517
770
  flexDirection: 'column',
@@ -523,10 +776,13 @@ export function ConstructsCatalog({
523
776
  textAlign: 'left',
524
777
  border: 'none',
525
778
  borderRadius: 6,
526
- background: visible
527
- ? theme.colors.background
528
- : 'transparent',
779
+ background: hovered
780
+ ? theme.colors.border
781
+ : visible
782
+ ? theme.colors.background
783
+ : 'transparent',
529
784
  cursor: 'pointer',
785
+ transition: 'background 100ms ease, opacity 100ms ease',
530
786
  boxShadow: [
531
787
  visible ? `inset 0 0 0 1px ${theme.colors.border}` : null,
532
788
  rowOpen ? `inset 2px 0 0 ${accentColor}` : null,
@@ -535,7 +791,7 @@ export function ConstructsCatalog({
535
791
  .join(', ') || undefined,
536
792
  outline: focused && !visible ? `1px dotted ${muted}` : undefined,
537
793
  outlineOffset: -1,
538
- opacity: visible ? 1 : 0.72,
794
+ opacity: visible ? 1 : hovered ? 1 : 0.72,
539
795
  }}
540
796
  >
541
797
  <span
@@ -593,6 +849,22 @@ export function ConstructsCatalog({
593
849
  );
594
850
  })}
595
851
  </ul>
852
+ ) : (
853
+ <ul
854
+ role="tree"
855
+ aria-label="Files"
856
+ style={{
857
+ listStyle: 'none',
858
+ margin: 0,
859
+ padding: '6px 8px 12px',
860
+ overflowY: 'auto',
861
+ flex: 1,
862
+ minHeight: 0,
863
+ }}
864
+ >
865
+ {renderTree(fileTree, 0)}
866
+ </ul>
867
+ )}
596
868
  </aside>
597
869
  <div
598
870
  style={{
@@ -8,6 +8,8 @@ import {
8
8
  getSubsystemPackageRegions,
9
9
  processGroupNodeId,
10
10
  moduleGroupNodeId,
11
+ moduleBadgeLabel,
12
+ moduleMinWidthForBadge,
11
13
  packageGroupNodeId,
12
14
  buildBoundaryLayoutGroups,
13
15
  buildSubsystemGraph,
@@ -502,6 +502,27 @@ export function moduleGroupNodeId(moduleKey: string): string {
502
502
  return `module:${moduleKey}`;
503
503
  }
504
504
 
505
+ export const MODULE_BADGE_INSET = 12;
506
+ const MODULE_BADGE_CHAR_WIDTH = 12.5;
507
+ const MODULE_BADGE_CHROME = 16;
508
+ const MODULE_FRAME_BORDER = 4;
509
+
510
+ export function moduleBadgeLabel(path: string, availableWidth = 0): string {
511
+ if (path.length * MODULE_BADGE_CHAR_WIDTH + MODULE_BADGE_CHROME <= availableWidth) {
512
+ return path;
513
+ }
514
+ const parts = path.split(/[\\/]/).filter((part) => part !== '' && part !== '.');
515
+ if (parts.length <= 2) return path;
516
+ return `${parts[0]}/…/${parts[parts.length - 1]}`;
517
+ }
518
+
519
+ export function moduleMinWidthForBadge(path: string): number {
520
+ return Math.ceil(
521
+ moduleBadgeLabel(path).length * MODULE_BADGE_CHAR_WIDTH +
522
+ MODULE_BADGE_CHROME + MODULE_BADGE_INSET * 2 + MODULE_FRAME_BORDER,
523
+ );
524
+ }
525
+
505
526
  /** React Flow id for a package (repo) boundary group node. */
506
527
  export function packageGroupNodeId(packageKey: string): string {
507
528
  return `package:${packageKey}`;
@@ -1363,6 +1384,7 @@ export async function buildSubsystemGraph(
1363
1384
  id: g.id,
1364
1385
  memberIds: g.memberIds,
1365
1386
  parentId: g.parentId,
1387
+ minWidth: g.region.kind === 'module' ? moduleMinWidthForBadge(g.region.label) : undefined,
1366
1388
  })),
1367
1389
  });
1368
1390
  const builtGroupIds = new Set(result.groupBounds.keys());
@@ -1,5 +1,9 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility, hexWithAlpha } from './nodes';
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
2
+ import { createElement, type ComponentProps } from 'react';
3
+ import { cleanup, fireEvent, render } from '@testing-library/react/pure';
4
+ import { ThemeProvider } from '@principal-ade/industry-theme';
5
+ import { Window } from 'happy-dom';
6
+ import { EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility, hexWithAlpha, SubsystemGroupNode } from './nodes';
3
7
 
4
8
  describe('hexWithAlpha', () => {
5
9
  test('appends a two-digit alpha to #rrggbb', () => {
@@ -29,6 +29,8 @@ import {
29
29
  deriveNameFromSymbol,
30
30
  BADGE_EDGE_INSET,
31
31
  nodeMinWidthForBadges,
32
+ MODULE_BADGE_INSET,
33
+ moduleBadgeLabel,
32
34
  packageColor,
33
35
  type SubsystemGraphNodeData,
34
36
  type SubsystemGroupNodeData,
@@ -352,6 +354,12 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
352
354
  // Kind is carried by frame chrome (square = process; rounded = module/package)
353
355
  // — don't prefix `module ·` / `package ·` or the path reads twice.
354
356
  const label = region?.label ?? '';
357
+ const [expandedLabel, setExpandedLabel] = useState<string | null>(null);
358
+ const isModule = region?.kind === 'module';
359
+ const expanded = isModule && expandedLabel === label;
360
+ const availableBadgeWidth = Math.max(0, (width ?? 400) - MODULE_BADGE_INSET * 2 - 4);
361
+ const collapsedLabel = isModule ? moduleBadgeLabel(label, availableBadgeWidth) : label;
362
+ const canExpand = isModule && collapsedLabel !== label;
355
363
  // Process frames match external nodes — square corners (outside the soft
356
364
  // component language). Module / package frames keep the rounded look.
357
365
  const isProcess = region?.kind === 'process';
@@ -382,6 +390,26 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
382
390
  }}
383
391
  >
384
392
  <div
393
+ className={isModule ? 'nodrag nopan' : undefined}
394
+ role={canExpand ? 'button' : undefined}
395
+ tabIndex={canExpand && !hidden ? 0 : undefined}
396
+ aria-expanded={canExpand ? expanded : undefined}
397
+ aria-label={canExpand ? `${expanded ? 'Collapse' : 'Expand'} module path: ${label}` : undefined}
398
+ title={isModule ? label : undefined}
399
+ onClick={canExpand ? (event) => {
400
+ event.stopPropagation();
401
+ setExpandedLabel(expanded ? null : label);
402
+ } : undefined}
403
+ onKeyDown={canExpand ? (event) => {
404
+ if (event.key === 'Enter' || event.key === ' ') {
405
+ event.preventDefault();
406
+ event.stopPropagation();
407
+ setExpandedLabel(expanded ? null : label);
408
+ } else if (event.key === 'Escape' && expanded) {
409
+ event.stopPropagation();
410
+ setExpandedLabel(null);
411
+ }
412
+ } : undefined}
385
413
  style={{
386
414
  position: 'absolute',
387
415
  // Process: badge fill starts with the process fill (inside the
@@ -79,7 +79,7 @@ export interface ElkLayoutOptions {
79
79
  * nests this group under another group; omit for a root-level frame.
80
80
  * Members reference their immediate parent via React Flow `parentId`.
81
81
  */
82
- groups?: Array<{ id: string; memberIds: string[]; parentId?: string }>;
82
+ groups?: Array<{ id: string; memberIds: string[]; parentId?: string; minWidth?: number }>;
83
83
  }
84
84
 
85
85
  /** Result of ELK layout computation */
@@ -587,7 +587,11 @@ export async function computeElkLayout(
587
587
  builtGroups.set(g.id, {
588
588
  id: g.id,
589
589
  children: childNodes,
590
- layoutOptions: compoundLayoutOptions,
590
+ layoutOptions: g.minWidth == null ? compoundLayoutOptions : {
591
+ ...compoundLayoutOptions,
592
+ 'elk.nodeSize.constraints': 'MINIMUM_SIZE',
593
+ 'elk.nodeSize.minimum': `(${g.minWidth},0)`,
594
+ },
591
595
  });
592
596
  }
593
597
  if (!progress) {