@principal-ai/principal-view-react 0.16.32 → 0.16.34

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.
@@ -0,0 +1,158 @@
1
+ /**
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.
6
+ */
7
+
8
+ import { useEffect, useMemo, useRef } from 'react';
9
+ import type { CSSProperties } from 'react';
10
+ import { FileTree, useFileTree } from '@pierre/trees/react';
11
+ import { useTheme } from '@principal-ade/industry-theme';
12
+
13
+ export interface SubsystemFileTreeProps {
14
+ /** Repo-root-relative file paths; duplicates are deduped. */
15
+ files: string[];
16
+ /** File to select/highlight (the drawer-open file). */
17
+ selectedFile?: string | null;
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. */
22
+ onSelectFile?: (file: string) => void;
23
+ }
24
+
25
+ /** Fills its parent height by design — pin it with a sized flex container. */
26
+ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFile }: SubsystemFileTreeProps) {
27
+ const { theme } = useTheme();
28
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
29
+ const paths = useMemo(() => Array.from(new Set(files)).sort(), [files]);
30
+
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
+ });
59
+
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);
75
+ }
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
+ };
101
+
102
+ return (
103
+ <div
104
+ onClickCapture={handleContainerClick}
105
+ style={{
106
+ height: '50%',
107
+ minHeight: 120,
108
+ flexShrink: 0,
109
+ borderTop: `1px solid ${theme.colors.border}`,
110
+ display: 'flex',
111
+ flexDirection: 'column',
112
+ }}
113
+ >
114
+ <div
115
+ style={{
116
+ padding: '10px 16px 4px',
117
+ display: 'flex',
118
+ alignItems: 'baseline',
119
+ gap: 6,
120
+ flexShrink: 0,
121
+ }}
122
+ >
123
+ <span
124
+ style={{
125
+ fontSize: theme.fontSizes[0] * 0.8,
126
+ fontFamily: theme.fonts.monospace,
127
+ textTransform: 'uppercase',
128
+ color: muted,
129
+ fontWeight: 600,
130
+ }}
131
+ >
132
+ Files
133
+ </span>
134
+ <span style={{ fontSize: theme.fontSizes[0] * 0.8, fontFamily: theme.fonts.monospace, color: muted }}>
135
+ {paths.length}
136
+ </span>
137
+ </div>
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
+ />
156
+ </div>
157
+ );
158
+ }
@@ -132,6 +132,13 @@ export type SubsystemGraphNodeType = 'subsystem-component' | 'subsystem-group';
132
132
 
133
133
  export interface SubsystemGraphNodeData extends Record<string, unknown> {
134
134
  component: SubsystemComponent;
135
+ /** Set while a file is open in the drawer: true if this node's component
136
+ * lives in that file (spotlighted), false otherwise (dimmed). Absent when
137
+ * no file is open — render neutrally. */
138
+ fileMatch?: boolean;
139
+ /** False hides the click-to-open file chip under the component name.
140
+ * Absent/true renders it (default). */
141
+ showFileBadges?: boolean;
135
142
  }
136
143
 
137
144
  export type SubsystemGraphNode = Node<SubsystemGraphNodeData, SubsystemGraphNodeType>;
@@ -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
  }
@@ -67,11 +69,21 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
67
69
  const maxWidth = configuredMax ?? 300;
68
70
  // `symbol` is the source of truth; `name` is derived from it consistently.
69
71
  const displayName = deriveNameFromSymbol(c.symbol, c.kind, c.name, c.file);
72
+ // Set while a file is open in the drawer: true → spotlight, false → dim,
73
+ // absent (no file open) → neutral.
74
+ const fileMatch = data.fileMatch as boolean | undefined;
75
+ const showFileBadges = data.showFileBadges !== false;
70
76
 
71
77
  return (
72
78
  <div
73
- onMouseEnter={() => setHover(true)}
74
- onMouseLeave={() => setHover(false)}
79
+ onMouseEnter={() => {
80
+ setHover(true);
81
+ SUBSYSTEM_CALLBACKS.onHover?.(c.id);
82
+ }}
83
+ onMouseLeave={() => {
84
+ setHover(false);
85
+ SUBSYSTEM_CALLBACKS.onHover?.(null);
86
+ }}
75
87
  onClick={(e) => {
76
88
  e.stopPropagation();
77
89
  SUBSYSTEM_CALLBACKS.onSelect?.(c.id);
@@ -90,8 +102,12 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
90
102
  padding: '6px 10px',
91
103
  borderRadius: 8,
92
104
  background: theme.colors.backgroundSecondary ?? theme.colors.background,
93
- border: `2px solid ${selected ? theme.colors.primary : color}`,
94
- boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
105
+ border: `2px solid ${selected || fileMatch ? theme.colors.primary : color}`,
106
+ boxShadow: fileMatch
107
+ ? `0 1px 4px rgba(0,0,0,0.25), 0 0 12px ${theme.colors.primary}55`
108
+ : '0 1px 4px rgba(0,0,0,0.25)',
109
+ opacity: fileMatch === false ? 0.18 : 1,
110
+ transition: 'opacity 150ms ease',
95
111
  cursor: 'pointer',
96
112
  fontFamily: theme.fonts.body,
97
113
  }}
@@ -110,7 +126,6 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
110
126
  whiteSpace: 'nowrap',
111
127
  fontSize: theme.fontSizes[0] * 0.8,
112
128
  fontFamily: theme.fonts.monospace,
113
- textTransform: 'uppercase',
114
129
  letterSpacing: 0.5,
115
130
  color,
116
131
  background: theme.colors.background,
@@ -119,8 +134,10 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
119
134
  padding: '1px 6px',
120
135
  }}
121
136
  >
122
- {KIND_LABEL[c.kind] ?? c.kind}
123
- {c.purl ? ` · ${c.purl}` : ''}
137
+ <span style={{ textTransform: 'uppercase' }}>
138
+ {KIND_LABEL[c.kind] ?? c.kind}
139
+ </span>
140
+ {c.file ? ` · ${c.file.split('/').pop()}` : ''}
124
141
  {c.capture && c.capture !== 'edited' ? ` · ${c.capture}` : ''}
125
142
  </div>
126
143
  )}
@@ -162,9 +179,7 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
162
179
  whiteSpace: 'normal',
163
180
  overflowWrap: 'anywhere',
164
181
  maxWidth: '100%',
165
- // Types get a serif name to distinguish them from runtime units.
166
- fontFamily:
167
- c.kind === 'type' ? 'Georgia, "Times New Roman", serif' : theme.fonts.body,
182
+ fontFamily: theme.fonts.body,
168
183
  }}
169
184
  >
170
185
  {breakWords(displayName)}
@@ -189,7 +204,7 @@ export function SubsystemComponentNode(props: NodeProps<SubsystemGraphNode>) {
189
204
  </div>
190
205
  )}
191
206
 
192
- {c.file && (
207
+ {c.file && showFileBadges && (
193
208
  <div
194
209
  onClick={(e) => {
195
210
  e.stopPropagation();