@principal-ai/principal-view-react 0.16.61 → 0.16.62

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.
@@ -1,4 +1,4 @@
1
- import React from 'react';
1
+ import React, { useCallback } from 'react';
2
2
  import '@xyflow/react/dist/style.css';
3
3
  import type { Meta, StoryObj } from '@storybook/react';
4
4
  import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
@@ -8,6 +8,8 @@ import type {
8
8
  SubsystemComponentEdge,
9
9
  SubsystemThroughline,
10
10
  } from '../../../subsystem/model';
11
+ import { PierreThroughlineCodeView } from '../../../pierre';
12
+ import type { ThroughlineViewerContext } from '../../../subsystem/SubsystemComponentGraph';
11
13
 
12
14
  const meta = {
13
15
  title: 'Subsystem/ComponentGraph/Flows',
@@ -32,10 +34,53 @@ type Story = StoryObj<typeof meta>;
32
34
  // Mirror of the retrofitted electron-app drawing graph: the sidebar's Files
33
35
  // panel swaps to a Flows panel listing three throughlines (open / save /
34
36
  // delete). Clicking a flow row toggles its steps; clicking a step focuses
35
- // that step's edge and frames it on the canvas. Other opened flows stay
36
- // dimmed; the rest of the graph is hidden.
37
+ // that step's edge, frames it on the canvas, and scrolls the bottom CodeView
38
+ // to that step's snippet. Other opened flows stay dimmed; the rest of the
39
+ // graph is hidden.
37
40
  // ---------------------------------------------------------------------------
38
41
 
42
+ /** Build enough placeholder lines so step `line` values land inside the file. */
43
+ function fakeSource(path: string, markedLines: number[]): string {
44
+ const maxLine = Math.max(80, ...markedLines);
45
+ const marks = new Set(markedLines);
46
+ const lines: string[] = [`// ${path}`, ''];
47
+ for (let i = 3; i <= maxLine; i++) {
48
+ if (marks.has(i)) {
49
+ lines.push(`export function stepAtLine${i}() {`);
50
+ lines.push(` return ${i};`);
51
+ lines.push(`}`);
52
+ lines.push('');
53
+ } else {
54
+ lines.push(`// context line ${i}`);
55
+ }
56
+ }
57
+ return lines.join('\n');
58
+ }
59
+
60
+ const storyFiles: Record<string, string> = {
61
+ 'src/panels/DrawingsLeftPanel.tsx': fakeSource('src/panels/DrawingsLeftPanel.tsx', [
62
+ 56, 85,
63
+ ]),
64
+ 'src/hooks/useDrawingsHost.ts': fakeSource('src/hooks/useDrawingsHost.ts', [
65
+ 45, 64, 66,
66
+ ]),
67
+ 'src/workspace/WorkspaceShell.tsx': fakeSource('src/workspace/WorkspaceShell.tsx', [
68
+ 369, 372,
69
+ ]),
70
+ 'src/storage/drawingsStorage.ts': fakeSource('src/storage/drawingsStorage.ts', [90]),
71
+ 'src/components/DrawingTabContent.tsx': fakeSource('src/components/DrawingTabContent.tsx', [
72
+ 83, 112, 121,
73
+ ]),
74
+ };
75
+
76
+ function readStoryFile(path: string): Promise<string> {
77
+ const content = storyFiles[path];
78
+ if (content == null) {
79
+ return Promise.reject(new Error(`No story fixture for ${path}`));
80
+ }
81
+ return Promise.resolve(content);
82
+ }
83
+
39
84
  const drawingComponents: SubsystemComponent[] = [
40
85
  {
41
86
  id: 'panel',
@@ -148,6 +193,18 @@ const drawingThroughlines: SubsystemThroughline[] = [
148
193
  ];
149
194
 
150
195
  function FlowsDemo() {
196
+ const renderThroughlineViewer = useCallback(
197
+ ({ throughline, stepIndex }: ThroughlineViewerContext) => (
198
+ <PierreThroughlineCodeView
199
+ throughline={throughline}
200
+ stepIndex={stepIndex}
201
+ readFile={readStoryFile}
202
+ contextLines={4}
203
+ />
204
+ ),
205
+ [],
206
+ );
207
+
151
208
  return (
152
209
  <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
153
210
  <SubsystemComponentGraph
@@ -155,7 +212,8 @@ function FlowsDemo() {
155
212
  edges={drawingEdges}
156
213
  throughlines={drawingThroughlines}
157
214
  title="drawing-files flow"
158
- description="Three throughlines over one graph — opening, saving, and deleting a drawing. The sidebar's **Flows** panel lists each step by **symbol** (the frame at that hop); clicking a step focuses that edge on the canvas."
215
+ description="Three throughlines over one graph — opening, saving, and deleting a drawing. The sidebar's **Flows** panel lists each step by **symbol**; clicking a step focuses that edge and scrolls the bottom CodeView to that snippet."
216
+ renderThroughlineViewer={renderThroughlineViewer}
159
217
  renderFileViewer={(file, opts) => (
160
218
  <div
161
219
  style={{
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * FileDrawer — bottom panel that slides up from the bottom of the graph area
3
- * to show a file's contents. Opened by sidebar file-tree clicks (and any host
4
- * wiring); content is injected as children by the graph component.
3
+ * to show file / throughline code. Opened by sidebar file-tree clicks,
4
+ * declaration links, and throughline step focus; content is injected as
5
+ * children by the graph component.
5
6
  */
6
7
 
7
8
  import { useEffect } from 'react';
@@ -9,22 +10,22 @@ import type { ReactNode } from 'react';
9
10
  import { useTheme } from '@principal-ade/industry-theme';
10
11
  import { X } from 'lucide-react';
11
12
 
12
- /** Bottom panel that slides up from the bottom of the graph area to show a
13
- * file's contents opened by node clicks and sidebar file-tree clicks
14
- * alike. Sits in normal flow (canvas shrinks while open, nothing covered)
13
+ /** Bottom panel that slides up from the bottom of the graph area.
14
+ * Sits in normal flow (canvas shrinks while open, nothing covered)
15
15
  * and animates via height; stays mounted so open/close animates. */
16
16
  export function FileDrawer({
17
- file,
17
+ title,
18
18
  onClose,
19
19
  children,
20
20
  }: {
21
- file: string | null;
21
+ /** Drawer chrome title; `null` closes the drawer. */
22
+ title: string | null;
22
23
  onClose: () => void;
23
24
  children?: ReactNode;
24
25
  }) {
25
26
  const { theme } = useTheme();
26
27
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
27
- const open = file !== null;
28
+ const open = title !== null;
28
29
 
29
30
  useEffect(() => {
30
31
  if (!open) return;
@@ -64,7 +65,7 @@ export function FileDrawer({
64
65
  }}
65
66
  >
66
67
  <span
67
- title={file ?? undefined}
68
+ title={title ?? undefined}
68
69
  style={{
69
70
  flex: 1,
70
71
  minWidth: 0,
@@ -76,7 +77,7 @@ export function FileDrawer({
76
77
  color: muted,
77
78
  }}
78
79
  >
79
- {file}
80
+ {title}
80
81
  </span>
81
82
  <button
82
83
  type="button"
@@ -59,6 +59,17 @@ const EDGE_LABEL_MAX_EDGE_FRACTION = 0.55;
59
59
  const EDGE_LABEL_CHAR_PX = 6.2;
60
60
  const EDGE_LABEL_PAD_PX = 18;
61
61
 
62
+ /** Context passed to `renderThroughlineViewer` when a flow/step is focused. */
63
+ export interface ThroughlineViewerContext {
64
+ throughline: SubsystemThroughline;
65
+ /** Focused step index; `null` means the whole flow (no specific step). */
66
+ stepIndex: number | null;
67
+ }
68
+
69
+ type DrawerTarget =
70
+ | { kind: 'file'; file: string; startLine?: number }
71
+ | { kind: 'throughline'; throughlineId: string; stepIndex: number | null };
72
+
62
73
  export interface SubsystemComponentGraphProps {
63
74
  components: SubsystemComponent[];
64
75
  edges: SubsystemComponentEdge[];
@@ -67,8 +78,9 @@ export interface SubsystemComponentGraphProps {
67
78
  * flow. When present the sidebar's bottom half offers a Files/Flows toggle:
68
79
  * the flows panel lists each throughline's steps (`symbol` or `file:line`);
69
80
  * clicking a flow row toggles its steps; clicking a step focuses that
70
- * step's edge. Opened flows stay on the canvas (unselected ones dimmed);
71
- * everything else is hidden.
81
+ * step's edge and (when `renderThroughlineViewer` is set) opens the bottom
82
+ * drawer on that flow's snippets. Opened flows stay on the canvas
83
+ * (unselected ones dimmed); everything else is hidden.
72
84
  */
73
85
  throughlines?: SubsystemThroughline[];
74
86
  onSelect?: (componentId: string) => void;
@@ -105,11 +117,16 @@ export interface SubsystemComponentGraphProps {
105
117
  sidebarAfterDescription?: ReactNode;
106
118
  /**
107
119
  * Host-injected reader/renderer for the bottom file drawer, keyed by
108
- * repo-root-relative path. Opening happens on node click (component with a
109
- * `file`) and sidebar file-tree click. Keeps this package free of fs and
110
- * code-view dependencies.
120
+ * repo-root-relative path. Opening happens on declaration/file-tree clicks.
121
+ * Keeps this package free of fs and code-view dependencies.
111
122
  */
112
123
  renderFileViewer?: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
124
+ /**
125
+ * Host-injected multi-snippet viewer for a focused throughline. When set,
126
+ * clicking a flow step (or the whole flow) opens the bottom drawer with
127
+ * this content and updates it as the focused step changes.
128
+ */
129
+ renderThroughlineViewer?: (ctx: ThroughlineViewerContext) => ReactNode;
113
130
  /**
114
131
  * Legacy component-keyed variant, kept for backward compatibility. When
115
132
  * `renderFileViewer` is absent, drawer content resolves via the first
@@ -136,28 +153,39 @@ const edgeTypes: EdgeTypes = {
136
153
  'subsystem-edge': SubsystemEdge,
137
154
  };
138
155
 
139
- // Memoized drawer body: only rebuilds children when the open file changes.
156
+ // Memoized drawer body: only rebuilds children when the open target changes.
140
157
  // Inner re-renders on every viewport pan/zoom and hover; recreating host
141
158
  // elements then would churn their readFile closures and flash the file
142
159
  // viewer's loading state on each render.
143
- const DrawerContent = memo(function DrawerContent({
160
+ const FileDrawerContent = memo(function FileDrawerContent({
144
161
  render,
145
162
  file,
146
163
  startLine,
147
164
  }: {
148
165
  render: (file: string, opts?: SubsystemOpenFileOptions) => ReactNode;
149
- file: string | null;
166
+ file: string;
150
167
  startLine?: number;
151
168
  }) {
152
- if (!file) return null;
153
169
  return <>{render(file, startLine != null ? { startLine } : undefined)}</>;
154
170
  });
155
171
 
172
+ const ThroughlineDrawerContent = memo(function ThroughlineDrawerContent({
173
+ render,
174
+ throughline,
175
+ stepIndex,
176
+ }: {
177
+ render: (ctx: ThroughlineViewerContext) => ReactNode;
178
+ throughline: SubsystemThroughline;
179
+ stepIndex: number | null;
180
+ }) {
181
+ return <>{render({ throughline, stepIndex })}</>;
182
+ });
183
+
156
184
  interface InnerProps extends SubsystemComponentGraphProps {
157
185
  measured: { w: number; h: number } | null;
158
186
  }
159
187
 
160
- function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showLegend, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
188
+ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showLegend, title, hideSidebar, graphTitle, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, renderThroughlineViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
161
189
  const { theme } = useTheme();
162
190
  const { fitView } = useReactFlow();
163
191
  const viewport = useViewport();
@@ -167,13 +195,8 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
167
195
  });
168
196
  const [layoutReady, setLayoutReady] = useState(false);
169
197
  const [selected, setSelected] = useState<SubsystemComponent | null>(null);
170
- /** File shown in the bottom drawer + optional declaration scroll target. */
171
- const [openFileTarget, setOpenFileTarget] = useState<{
172
- file: string;
173
- startLine?: number;
174
- } | null>(null);
175
- const openFile = openFileTarget?.file ?? null;
176
- const openFileStartLine = openFileTarget?.startLine;
198
+ /** Bottom drawer: single file or throughline multi-snippet mode. */
199
+ const [drawerTarget, setDrawerTarget] = useState<DrawerTarget | null>(null);
177
200
  // Edge-legend modal visibility (opened from the canvas's top-left button).
178
201
  const [legendOpen, setLegendOpen] = useState(false);
179
202
  // Component the pointer is over (null on leave) → transient tree highlight.
@@ -194,9 +217,31 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
194
217
  // closure over the effect deps) can toggle without a stale value.
195
218
  const selectedRef = useRef<SubsystemComponent | null>(null);
196
219
  selectedRef.current = selected;
197
- // Ref mirror of `openFile` for the tree-click toggle.
220
+ // Ref mirror of the open file drawer target for tree-click toggle.
198
221
  const openFileRef = useRef<{ file: string; startLine?: number } | null>(null);
199
- openFileRef.current = openFileTarget;
222
+ const openFile =
223
+ drawerTarget?.kind === 'file' ? drawerTarget.file : null;
224
+ openFileRef.current =
225
+ drawerTarget?.kind === 'file'
226
+ ? { file: drawerTarget.file, startLine: drawerTarget.startLine }
227
+ : null;
228
+
229
+ const focusedThroughline = useMemo(() => {
230
+ if (drawerTarget?.kind !== 'throughline' || !throughlines) return null;
231
+ return throughlines.find((t) => t.id === drawerTarget.throughlineId) ?? null;
232
+ }, [drawerTarget, throughlines]);
233
+
234
+ const drawerTitle = useMemo(() => {
235
+ if (!drawerTarget) return null;
236
+ if (drawerTarget.kind === 'file') return drawerTarget.file;
237
+ const tl = focusedThroughline;
238
+ if (!tl) return null;
239
+ if (drawerTarget.stepIndex == null) return tl.title;
240
+ const step = tl.steps[drawerTarget.stepIndex];
241
+ if (!step) return tl.title;
242
+ const site = `${step.file.split('/').pop() ?? step.file}:${step.line}`;
243
+ return `${tl.title} · ${site}`;
244
+ }, [drawerTarget, focusedThroughline]);
200
245
 
201
246
  // Refresh selected component when the components list updates (e.g. verify
202
247
  // writes back declarationRef).
@@ -594,10 +639,10 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
594
639
  const onTreeSelectFile = useCallback(
595
640
  (file: string) => {
596
641
  if (openFileRef.current?.file === file && openFileRef.current.startLine == null) {
597
- setOpenFileTarget(null);
642
+ setDrawerTarget(null);
598
643
  return;
599
644
  }
600
- setOpenFileTarget({ file });
645
+ setDrawerTarget({ kind: 'file', file });
601
646
  onFileSelect?.(file);
602
647
  },
603
648
  [onFileSelect],
@@ -610,10 +655,10 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
610
655
  openFileRef.current?.file === file &&
611
656
  openFileRef.current.startLine === startLine
612
657
  ) {
613
- setOpenFileTarget(null);
658
+ setDrawerTarget(null);
614
659
  return;
615
660
  }
616
- setOpenFileTarget({ file, startLine });
661
+ setDrawerTarget({ kind: 'file', file, startLine });
617
662
  onFileSelect?.(file);
618
663
  },
619
664
  [onFileSelect],
@@ -643,7 +688,7 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
643
688
 
644
689
  // Focus an entire flow: hide everything but the flow's nodes and edges, and
645
690
  // frame the flow on the canvas. Selection state is cleared — the graph now
646
- // reads as the narrative.
691
+ // reads as the narrative. Opens the throughline drawer when a viewer is set.
647
692
  const focusThroughlineEdges = useCallback(
648
693
  (tl: SubsystemThroughline) => {
649
694
  setSelected(null);
@@ -651,17 +696,28 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
651
696
  setFocusedStepIndex(null);
652
697
  setFocusedThroughlineId(tl.id);
653
698
  fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
699
+ if (renderThroughlineViewer) {
700
+ setDrawerTarget({ kind: 'throughline', throughlineId: tl.id, stepIndex: null });
701
+ } else if (tl.steps[0]) {
702
+ // Fallback: single-file snippet of the first step.
703
+ setDrawerTarget({
704
+ kind: 'file',
705
+ file: tl.steps[0].file,
706
+ startLine: tl.steps[0].line,
707
+ });
708
+ }
654
709
  },
655
- [fitFocusBounds],
710
+ [fitFocusBounds, renderThroughlineViewer],
656
711
  );
657
712
 
658
713
  const clearThroughlineFocus = useCallback(() => {
659
714
  setFocusedThroughlineId(null);
660
715
  setFocusedStepIndex(null);
716
+ setDrawerTarget((prev) => (prev?.kind === 'throughline' ? null : prev));
661
717
  }, []);
662
718
 
663
- // Focus a single step's edge on the canvas. The step's file:line is listed
664
- // in the row; we don't open the drawer from here.
719
+ // Focus a single step's edge on the canvas and open/scroll the throughline
720
+ // drawer to that step's snippet.
665
721
  const focusThroughlineStep = useCallback(
666
722
  (tl: SubsystemThroughline, stepIndex: number) => {
667
723
  const step = tl.steps[stepIndex];
@@ -671,8 +727,21 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
671
727
  setFocusedStepIndex(stepIndex);
672
728
  setFocusedThroughlineId(tl.id);
673
729
  fitFocusBounds(new Set([step.edgeId]));
730
+ if (renderThroughlineViewer) {
731
+ setDrawerTarget({
732
+ kind: 'throughline',
733
+ throughlineId: tl.id,
734
+ stepIndex,
735
+ });
736
+ } else {
737
+ setDrawerTarget({
738
+ kind: 'file',
739
+ file: step.file,
740
+ startLine: step.line,
741
+ });
742
+ }
674
743
  },
675
- [fitFocusBounds],
744
+ [fitFocusBounds, renderThroughlineViewer],
676
745
  );
677
746
 
678
747
  const toggleThroughlineCollapsed = useCallback((tlId: string) => {
@@ -781,6 +850,14 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
781
850
  [],
782
851
  );
783
852
 
853
+ const throughlineViewerRef = useRef(renderThroughlineViewer);
854
+ throughlineViewerRef.current = renderThroughlineViewer;
855
+ const renderThroughlineDrawerContent = useCallback(
856
+ (ctx: ThroughlineViewerContext) =>
857
+ throughlineViewerRef.current?.(ctx) ?? null,
858
+ [],
859
+ );
860
+
784
861
  return (
785
862
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
786
863
  {/* Sidebar: scrollable title/description on top, files or flows pinned to the bottom half */}
@@ -1157,12 +1234,22 @@ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measur
1157
1234
  mechanisms={usedMechanisms}
1158
1235
  onClose={() => setLegendOpen(false)}
1159
1236
  />
1160
- <FileDrawer file={openFile} onClose={() => setOpenFileTarget(null)}>
1161
- <DrawerContent
1162
- render={renderDrawerContent}
1163
- file={openFile}
1164
- startLine={openFileStartLine}
1165
- />
1237
+ <FileDrawer title={drawerTitle} onClose={() => setDrawerTarget(null)}>
1238
+ {drawerTarget?.kind === 'throughline' &&
1239
+ focusedThroughline &&
1240
+ renderThroughlineViewer ? (
1241
+ <ThroughlineDrawerContent
1242
+ render={renderThroughlineDrawerContent}
1243
+ throughline={focusedThroughline}
1244
+ stepIndex={drawerTarget.stepIndex}
1245
+ />
1246
+ ) : drawerTarget?.kind === 'file' ? (
1247
+ <FileDrawerContent
1248
+ render={renderDrawerContent}
1249
+ file={drawerTarget.file}
1250
+ startLine={drawerTarget.startLine}
1251
+ />
1252
+ ) : null}
1166
1253
  </FileDrawer>
1167
1254
  {/* Startup cover — hides measurement, layout swap, and camera settle. */}
1168
1255
  <GraphLayoutCover revealed={layoutReady} />