@principal-ai/subsystems-react 0.56.0 → 0.56.2

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.
Files changed (49) hide show
  1. package/dist/index.d.ts +0 -6
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +0 -3
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreTrailCodeView.d.ts +3 -3
  6. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  7. package/dist/pierre/PierreTrailCodeView.js +16 -16
  8. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  9. package/dist/pierre/trailSnippet.d.ts +10 -0
  10. package/dist/pierre/trailSnippet.d.ts.map +1 -0
  11. package/dist/pierre/trailSnippet.js +22 -0
  12. package/dist/pierre/trailSnippet.js.map +1 -0
  13. package/dist/subsystem/FileDrawer.d.ts +9 -11
  14. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  15. package/dist/subsystem/FileDrawer.js +23 -23
  16. package/dist/subsystem/FileDrawer.js.map +1 -1
  17. package/dist/subsystem/GraphSidebar.d.ts.map +1 -1
  18. package/dist/subsystem/GraphSidebar.js +3 -1
  19. package/dist/subsystem/GraphSidebar.js.map +1 -1
  20. package/dist/subsystem/SubsystemComponentGraph.d.ts +4 -3
  21. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  22. package/dist/subsystem/SubsystemComponentGraph.js +32 -30
  23. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/index.ts +0 -37
  26. package/src/pierre/PierreTrailCodeView.tsx +14 -23
  27. package/src/pierre/trailSnippet.test.ts +74 -0
  28. package/src/pierre/trailSnippet.ts +35 -0
  29. package/src/stories/Canvases/Subsystem/LayoutDirection.stories.tsx +55 -3
  30. package/src/subsystem/FileDrawer.test.tsx +130 -0
  31. package/src/subsystem/FileDrawer.tsx +27 -28
  32. package/src/subsystem/GraphSidebar.test.tsx +56 -1
  33. package/src/subsystem/GraphSidebar.tsx +3 -1
  34. package/src/subsystem/SubsystemComponentGraph.tsx +65 -57
  35. package/dist/subsystem/DecorationPanel.d.ts +0 -37
  36. package/dist/subsystem/DecorationPanel.d.ts.map +0 -1
  37. package/dist/subsystem/DecorationPanel.js +0 -147
  38. package/dist/subsystem/DecorationPanel.js.map +0 -1
  39. package/dist/subsystem/decoration.d.ts +0 -112
  40. package/dist/subsystem/decoration.d.ts.map +0 -1
  41. package/dist/subsystem/decoration.js +0 -54
  42. package/dist/subsystem/decoration.js.map +0 -1
  43. package/src/stories/Canvases/Subsystem/StepEmphasis.stories.tsx +0 -528
  44. package/src/stories/Components/DecorationPanel.stories.tsx +0 -160
  45. package/src/subsystem/DecorationPanel.test.tsx +0 -128
  46. package/src/subsystem/DecorationPanel.tsx +0 -334
  47. package/src/subsystem/decoration.test.ts +0 -72
  48. package/src/subsystem/decoration.ts +0 -151
  49. package/src/subsystem/stepEmphasis.test.ts +0 -110
package/src/index.ts CHANGED
@@ -227,43 +227,6 @@ export type {
227
227
  TrailStackView,
228
228
  } from './subsystem/trailStack';
229
229
  export { buildStepBrief } from './subsystem/trailBrief';
230
- export {
231
- buildStepEmphasisMap,
232
- emphasisForTrail,
233
- emphasisKey,
234
- findStepEmphasis,
235
- orphanedEmphasis,
236
- stepClaimKey,
237
- } from './subsystem/stepEmphasis';
238
- export type {
239
- StepEmphasisDocument,
240
- StepEmphasisMap,
241
- StepEmphasisProvenance,
242
- StepEmphasisRecord,
243
- } from './subsystem/stepEmphasis';
244
- export {
245
- decorationModelIds,
246
- decorationTrailId,
247
- decorationTrailKey,
248
- findDecorationTrail,
249
- groupDecorationTrailsByModel,
250
- } from './subsystem/decoration';
251
- export type {
252
- DecorationBaseTrailRef,
253
- DecorationDraft,
254
- DecorationDraftComponent,
255
- DecorationDraftEdge,
256
- DecorationDraftTrailRef,
257
- DecorationProvenance,
258
- DecorationStepAnchor,
259
- DecorationTrailRef,
260
- SubsystemDecoration,
261
- } from './subsystem/decoration';
262
- export { DecorationPanel } from './subsystem/DecorationPanel';
263
- export type {
264
- DecorationPanelProps,
265
- DecorationPanelTrailMeta,
266
- } from './subsystem/DecorationPanel';
267
230
  export { GraphLayoutCover } from './subsystem/GraphLayoutCover';
268
231
  export type { GraphLayoutCoverProps } from './subsystem/GraphLayoutCover';
269
232
  export {
@@ -2,8 +2,8 @@
2
2
  * PierreTrailCodeView — multi-file step snippets via `@pierre/diffs` CodeView.
3
3
  *
4
4
  * Renders one sliced window per trail step in a single virtualized
5
- * scroll; when `stepIndex` changes, scrolls that step's site line into view
6
- * and highlights it via CodeView `selectedLines` (same mechanism as
5
+ * scroll; when `stepIndex` changes, scrolls that step's call into view
6
+ * and highlights its full range via CodeView `selectedLines` (same mechanism as
7
7
  * `PierreSnippetView`).
8
8
  */
9
9
 
@@ -46,9 +46,9 @@ import { resolveVisibleStepIndex, type VisibleStepRect } from './visibleStep';
46
46
  import { fileUnavailableNotice, isFileUnavailableError } from './fileAvailability';
47
47
  import {
48
48
  remapSnippetLineNumbers,
49
- sliceSnippetWindow,
50
49
  type SnippetSlice,
51
50
  } from './sliceSnippet';
51
+ import { sliceTrailStep, trailCallSelection } from './trailSnippet';
52
52
 
53
53
  /**
54
54
  * A token the user clicked or hovered inside a step's snippet. `stepIndex` is
@@ -88,7 +88,7 @@ export interface PierreTrailCodeViewProps {
88
88
  stepIndex: number | null;
89
89
  /** Read a step site; `purl` names the seam site's repo for checkout resolution. */
90
90
  readFile: (path: string, purl?: string) => Promise<string>;
91
- /** Context lines above/below each step site; defaults to 8. */
91
+ /** Fallback context when a step has no caller construct span; defaults to 8. */
92
92
  contextLines?: number;
93
93
  /** Override Pierre's container background. */
94
94
  background?: string;
@@ -338,20 +338,7 @@ export function PierreTrailCodeView({
338
338
  );
339
339
  }
340
340
  const contents = load.byPath.get(key) ?? '';
341
- // A resolved site construct is the frame the reader wants: show its
342
- // whole declaration and focus the seam line inside it. Older models
343
- // fall back to a context window around the seam.
344
- const site = step.site?.span;
345
- const start = site?.startLine ?? step.span?.startLine ?? 1;
346
- const end = site?.endLine ?? step.span?.endLine ?? start;
347
- const focus = step.span?.startLine ?? start;
348
- return sliceSnippetWindow(
349
- contents,
350
- start,
351
- end,
352
- site ? 0 : contextLines,
353
- focus,
354
- );
341
+ return sliceTrailStep(contents, step, contextLines);
355
342
  });
356
343
  }, [load, trail.steps, contextLines]);
357
344
 
@@ -397,13 +384,17 @@ export function PierreTrailCodeView({
397
384
  if (stepIndex == null || stepIndex < 0 || stepIndex >= slices.length) {
398
385
  return null;
399
386
  }
400
- const focus = slices[stepIndex]?.focusOffset;
401
- if (focus == null) return null;
387
+ const step = trail.steps[stepIndex];
388
+ const slice = slices[stepIndex];
389
+ if (step == null || slice == null || load.status !== 'ready') return null;
390
+ if (load.unavailable.has(siteKey(step))) return null;
391
+ const range = trailCallSelection(step, slice);
392
+ if (range == null) return null;
402
393
  return {
403
394
  id: stepItemId(trail.id, stepIndex),
404
- range: { start: focus, end: focus },
395
+ range,
405
396
  };
406
- }, [stepIndex, slices, trail.id]);
397
+ }, [stepIndex, slices, trail.id, trail.steps, load]);
407
398
 
408
399
  const sliceStartByItemId = useMemo(() => {
409
400
  const map = new Map<string, number>();
@@ -464,7 +455,7 @@ export function PierreTrailCodeView({
464
455
  ) : (
465
456
  <>
466
457
  {step.site?.span
467
- ? `L${step.site.span.startLine}-${step.site.span.endLine}`
458
+ ? `${step.site.symbol} · L${step.site.span.startLine}-${step.site.span.endLine}`
468
459
  : `L${step.span?.startLine ?? '?'}`}
469
460
  {onOpenFile && (
470
461
  <OpenFileHeaderButton
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { SubsystemTrailStep } from '../subsystem/model';
3
+ import { sliceTrailStep, trailCallSelection } from './trailSnippet';
4
+
5
+ const contents = Array.from({ length: 100 }, (_, i) => `line ${i + 1}`).join('\n');
6
+ const step: SubsystemTrailStep = {
7
+ from: 'caller',
8
+ to: 'callee',
9
+ mechanism: 'calls',
10
+ file: 'src/caller.ts',
11
+ purl: 'pkg:github/example/repo#src/caller.ts',
12
+ symbol: 'loadModel',
13
+ site: {
14
+ symbol: 'loadModel',
15
+ span: { startLine: 10, endLine: 70, contentHash: 'caller' },
16
+ },
17
+ span: { startLine: 35, endLine: 38, contentHash: 'call' },
18
+ target: {
19
+ file: 'src/callee.ts',
20
+ purl: 'pkg:github/example/repo#src/callee.ts',
21
+ symbol: 'readFile',
22
+ span: { startLine: 2, endLine: 8, contentHash: 'callee' },
23
+ },
24
+ };
25
+
26
+ describe('trail construct snippets', () => {
27
+ test('shows the full caller span without extra context, not the callee span', () => {
28
+ const slice = sliceTrailStep(contents, step, 8);
29
+ expect(slice.sliceStart).toBe(10);
30
+ expect(slice.sliceEnd).toBe(70);
31
+ expect(slice.contents).toBe(contents.split('\n').slice(9, 70).join('\n'));
32
+ expect(slice.focusOffset).toBe(26);
33
+ });
34
+
35
+ test('highlights every line of a multiline call inside the construct', () => {
36
+ const slice = sliceTrailStep(contents, step, 8);
37
+ expect(trailCallSelection(step, slice)).toEqual({ start: 26, end: 29 });
38
+ });
39
+
40
+ test('preserves single-line call highlighting', () => {
41
+ const single = { ...step, span: { ...step.span!, endLine: 35 } };
42
+ expect(trailCallSelection(single, sliceTrailStep(contents, single, 8)))
43
+ .toEqual({ start: 26, end: 26 });
44
+ });
45
+
46
+ test('keeps a context window for older steps without a site construct', () => {
47
+ const legacy = { ...step, site: undefined };
48
+ const slice = sliceTrailStep(contents, legacy, 8);
49
+ expect(slice.sliceStart).toBe(27);
50
+ expect(slice.sliceEnd).toBe(46);
51
+ expect(slice.focusOffset).toBe(9);
52
+ expect(trailCallSelection(legacy, slice)).toEqual({ start: 9, end: 12 });
53
+ });
54
+
55
+ test('does not highlight the declaration as a call when call evidence is missing', () => {
56
+ const unverified = { ...step, span: undefined };
57
+ const slice = sliceTrailStep(contents, unverified, 8);
58
+ expect(slice.sliceStart).toBe(10);
59
+ expect(slice.sliceEnd).toBe(70);
60
+ expect(slice.focusOffset).toBe(1);
61
+ expect(trailCallSelection(unverified, slice)).toBeNull();
62
+ });
63
+
64
+ test('clips stale call ranges to the displayed construct', () => {
65
+ const stale = { ...step, span: { ...step.span!, startLine: 68, endLine: 75 } };
66
+ expect(trailCallSelection(stale, sliceTrailStep(contents, stale, 8)))
67
+ .toEqual({ start: 59, end: 61 });
68
+ });
69
+
70
+ test('does not highlight unrelated lines when the call falls outside the construct', () => {
71
+ const stale = { ...step, span: { ...step.span!, startLine: 80, endLine: 82 } };
72
+ expect(trailCallSelection(stale, sliceTrailStep(contents, stale, 8))).toBeNull();
73
+ });
74
+ });
@@ -0,0 +1,35 @@
1
+ import type { SubsystemTrailStep } from '../subsystem/model';
2
+ import { sliceSnippetWindow, type SnippetSlice } from './sliceSnippet';
3
+
4
+ /** Show the caller's whole declaration, keeping focus on its call site. */
5
+ export function sliceTrailStep(
6
+ contents: string,
7
+ step: SubsystemTrailStep,
8
+ contextLines: number,
9
+ ): SnippetSlice {
10
+ const site = step.site?.span;
11
+ const start = site?.startLine ?? step.span?.startLine ?? 1;
12
+ const end = site?.endLine ?? step.span?.endLine ?? start;
13
+ return sliceSnippetWindow(
14
+ contents,
15
+ start,
16
+ end,
17
+ site ? 0 : contextLines,
18
+ step.span?.startLine ?? start,
19
+ );
20
+ }
21
+
22
+ /** Convert the complete call range to snippet-local lines, never inventing evidence. */
23
+ export function trailCallSelection(
24
+ step: SubsystemTrailStep,
25
+ slice: SnippetSlice,
26
+ ): { start: number; end: number } | null {
27
+ if (step.span == null) return null;
28
+ const start = Math.max(step.span.startLine, slice.sliceStart);
29
+ const end = Math.min(step.span.endLine, slice.sliceEnd);
30
+ if (start > end) return null;
31
+ return {
32
+ start: start - slice.sliceStart + 1,
33
+ end: end - slice.sliceStart + 1,
34
+ };
35
+ }
@@ -12,8 +12,11 @@ import React, { useState } from 'react';
12
12
  import '@xyflow/react/dist/style.css';
13
13
  import type { Meta, StoryObj } from '@storybook/react';
14
14
  import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
15
- import { SubsystemComponentGraph } from '../../../subsystem/SubsystemComponentGraph';
15
+ import { SubsystemComponentGraph, type TrailViewerContext } from '../../../subsystem/SubsystemComponentGraph';
16
+ import { PierreTrailCodeView } from '../../../pierre/PierreTrailCodeView';
17
+ import { PierreFileView } from '../../../pierre/PierreFileView';
16
18
  import type { SubsystemLayoutDirection } from '../../../subsystem/model';
19
+ import type { SubsystemOpenFileOptions } from '../../../subsystem/sourceSpan';
17
20
  import { components, graphSpecFromSteps } from './fixtures';
18
21
 
19
22
  const meta = {
@@ -50,13 +53,57 @@ const layoutSpec = graphSpecFromSteps([
50
53
  ['svc', 'event', 'produces', 'src/session/SessionService.ts', 95],
51
54
  ]);
52
55
 
53
- function DirectionDemo({ initial }: { initial: SubsystemLayoutDirection }) {
56
+ const codeTrails = layoutSpec.trails.map((trail) => ({
57
+ ...trail,
58
+ steps: trail.steps.map((step) => ({
59
+ ...step,
60
+ site: {
61
+ symbol: step.symbol,
62
+ span: { startLine: 1, endLine: 110, contentHash: 'fixture' },
63
+ },
64
+ })),
65
+ }));
66
+
67
+ const codeSource = Array.from({ length: 110 }, (_, i) => {
68
+ if (i === 0) return 'export async function handleSession(request: Request) {';
69
+ if (i === 109) return '}';
70
+ if ([18, 42, 88, 95].includes(i + 1)) return ' await sessionService.save(request);';
71
+ return ` // Session processing, line ${i + 1}`;
72
+ }).join('\n');
73
+ const readCodeSource = async () => codeSource;
74
+
75
+ function renderTrailCode({ trail, stepIndex, onOpenFile, onVisibleStepChange }: TrailViewerContext) {
76
+ return (
77
+ <PierreTrailCodeView
78
+ trail={trail}
79
+ stepIndex={stepIndex}
80
+ readFile={readCodeSource}
81
+ onOpenFile={onOpenFile}
82
+ onVisibleStepChange={onVisibleStepChange}
83
+ />
84
+ );
85
+ }
86
+
87
+ function renderCodeFile(file: string, opts?: SubsystemOpenFileOptions) {
88
+ return (
89
+ <PierreFileView
90
+ filePath={file}
91
+ fileName={file.split('/').pop() ?? file}
92
+ readFile={readCodeSource}
93
+ focusLine={opts?.startLine}
94
+ />
95
+ );
96
+ }
97
+
98
+ function DirectionDemo({ initial, withCode = false }: { initial: SubsystemLayoutDirection; withCode?: boolean }) {
54
99
  return (
55
100
  <div style={{ width: '100%', height: '100vh', display: 'flex', flexDirection: 'column' }}>
56
101
  <SubsystemComponentGraph
57
102
  components={layoutComponents}
58
- trails={layoutSpec.trails}
103
+ trails={withCode ? codeTrails : layoutSpec.trails}
59
104
  defaultLayoutDirection={initial}
105
+ renderTrailViewer={withCode ? renderTrailCode : undefined}
106
+ renderFileViewer={withCode ? renderCodeFile : undefined}
60
107
  showEdgeLabels
61
108
  />
62
109
  </div>
@@ -73,6 +120,11 @@ export const Vertical: Story = {
73
120
  render: () => <DirectionDemo initial="vertical" />,
74
121
  };
75
122
 
123
+ /** Select a trail step, then flip the layout: code docks left vertically and bottom horizontally. */
124
+ export const WithCodeView: Story = {
125
+ render: () => <DirectionDemo initial="vertical" withCode />,
126
+ };
127
+
76
128
  /**
77
129
  * Host-controlled: the graph renders no built-in toggle; the host owns the
78
130
  * value and echoes it back through `onLayoutDirectionChange`.
@@ -0,0 +1,130 @@
1
+ import { afterEach, describe, expect, test } from 'bun:test';
2
+ import type { ComponentProps } from 'react';
3
+ import { Window } from 'happy-dom';
4
+ import { cleanup, fireEvent, render } from '@testing-library/react/pure';
5
+ import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
6
+ import { FileDrawer } from './FileDrawer';
7
+
8
+ const dom = new Window();
9
+ const globals = globalThis as unknown as Record<string, unknown>;
10
+ globals.window = dom;
11
+ globals.document = dom.document;
12
+ globals.navigator = dom.navigator;
13
+ globals.HTMLElement = dom.HTMLElement;
14
+ globals.Node = dom.Node;
15
+ globals.Element = dom.Element;
16
+ globals.Event = dom.Event;
17
+ globals.MouseEvent = dom.MouseEvent;
18
+ globals.KeyboardEvent = dom.KeyboardEvent;
19
+ globals.localStorage = dom.localStorage;
20
+ globals.sessionStorage = dom.sessionStorage;
21
+ globals.matchMedia = dom.matchMedia.bind(dom);
22
+ globals.requestAnimationFrame = dom.requestAnimationFrame.bind(dom);
23
+ globals.cancelAnimationFrame = dom.cancelAnimationFrame.bind(dom);
24
+
25
+ afterEach(cleanup);
26
+
27
+ function drawer(props: Partial<ComponentProps<typeof FileDrawer>> = {}) {
28
+ return (
29
+ <ThemeProvider theme={defaultEditorTheme}>
30
+ <FileDrawer title="Trail code" onClose={() => {}} {...props}>
31
+ <input aria-label="Code state" defaultValue="selected step" />
32
+ </FileDrawer>
33
+ </ThemeProvider>
34
+ );
35
+ }
36
+
37
+ function parts(container: HTMLElement) {
38
+ return {
39
+ spacer: container.children[0] as HTMLElement,
40
+ panel: container.children[1] as HTMLElement,
41
+ };
42
+ }
43
+
44
+ describe('FileDrawer docking', () => {
45
+ test('keeps the default bottom drawer and reserves height', () => {
46
+ const { container } = render(drawer());
47
+ const { spacer, panel } = parts(container);
48
+ expect(spacer.style.height).toBe('45%');
49
+ expect(spacer.style.width).toBe('');
50
+ expect(panel.style.height).toBe('45%');
51
+ expect(panel.style.left).toBe('0px');
52
+ expect(panel.style.right).toBe('0px');
53
+ expect(panel.style.top).toBe('');
54
+ expect(panel.style.borderTopStyle).toBe('solid');
55
+ });
56
+
57
+ test('docks on the left and reserves width before the canvas', () => {
58
+ const { container } = render(drawer({ placement: 'left' }));
59
+ const { spacer, panel } = parts(container);
60
+ expect(spacer.style.width).toBe('45%');
61
+ expect(spacer.style.height).toBe('');
62
+ expect(spacer.style.order).toBe('-1');
63
+ expect(panel.style.width).toBe('45%');
64
+ expect(panel.style.height).toBe('100%');
65
+ expect(panel.style.left).toBe('0px');
66
+ expect(panel.style.right).toBe('');
67
+ expect(panel.style.top).toBe('0px');
68
+ expect(panel.style.borderRightStyle).toBe('solid');
69
+ expect(panel.style.borderTopStyle).toBe('none');
70
+ });
71
+
72
+ test('preserves mounted content when switching dock axes', () => {
73
+ const { container, getByLabelText, rerender } = render(drawer());
74
+ const input = getByLabelText('Code state') as HTMLInputElement;
75
+ fireEvent.change(input, { target: { value: 'step 3' } });
76
+ rerender(drawer({ placement: 'left' }));
77
+ expect(getByLabelText('Code state')).toBe(input);
78
+ expect(input.value).toBe('step 3');
79
+ expect(parts(container).spacer.style.height).toBe('');
80
+ rerender(drawer());
81
+ expect(getByLabelText('Code state')).toBe(input);
82
+ expect(parts(container).spacer.style.width).toBe('');
83
+ expect(parts(container).panel.style.width).toBe('');
84
+ });
85
+
86
+ test('maximizes and restores along the left dock axis', () => {
87
+ const { container, getByLabelText } = render(drawer({ placement: 'left' }));
88
+ fireEvent.click(getByLabelText('Maximize code view'));
89
+ expect(parts(container).panel.style.width).toBe('100%');
90
+ expect(parts(container).spacer.style.width).toBe('0px');
91
+ fireEvent.click(getByLabelText('Minimize code view'));
92
+ expect(parts(container).panel.style.width).toBe('45%');
93
+ expect(parts(container).spacer.style.width).toBe('45%');
94
+ });
95
+
96
+ test('fills the graph area for full-file reading', () => {
97
+ const { container } = render(drawer({ placement: 'left', fillHeight: true }));
98
+ expect(parts(container).panel.style.width).toBe('100%');
99
+ expect(parts(container).spacer.style.width).toBe('0px');
100
+ });
101
+
102
+ test('closed and hidden left drawers reserve no width', () => {
103
+ const { container, rerender } = render(drawer({ placement: 'left', title: null }));
104
+ expect(parts(container).panel.style.width).toBe('0px');
105
+ expect(parts(container).spacer.style.width).toBe('0px');
106
+ rerender(drawer({ placement: 'left', hidden: true }));
107
+ expect(parts(container).panel.style.width).toBe('0px');
108
+ expect(parts(container).spacer.style.width).toBe('0px');
109
+ });
110
+
111
+ test('closing resets maximize state before reopening', () => {
112
+ const { container, getByLabelText, rerender } = render(drawer({ placement: 'left' }));
113
+ fireEvent.click(getByLabelText('Maximize code view'));
114
+ rerender(drawer({ placement: 'left', title: null }));
115
+ rerender(drawer({ placement: 'left' }));
116
+ expect(parts(container).panel.style.width).toBe('45%');
117
+ expect(getByLabelText('Maximize code view')).not.toBeNull();
118
+ });
119
+
120
+ test('Escape closes a left drawer unless a full-file overlay handles it', () => {
121
+ let closes = 0;
122
+ const onClose = () => { closes++; };
123
+ const { rerender } = render(drawer({ placement: 'left', onClose }));
124
+ fireEvent.keyDown(window, { key: 'Escape' });
125
+ expect(closes).toBe(1);
126
+ rerender(drawer({ placement: 'left', onClose, suppressEscape: true }));
127
+ fireEvent.keyDown(window, { key: 'Escape' });
128
+ expect(closes).toBe(1);
129
+ });
130
+ });
@@ -1,6 +1,6 @@
1
1
  /**
2
- * FileDrawer — bottom panel that slides up from the bottom of the graph area
3
- * to show file / trail code. Opened by sidebar file-tree clicks,
2
+ * FileDrawer — bottom or left panel in the graph area
3
+ * showing file / trail code. Opened by sidebar file-tree clicks,
4
4
  * declaration links, and trail step focus; content is injected as
5
5
  * children by the graph component.
6
6
  */
@@ -10,18 +10,15 @@ import type { ReactNode } from "react";
10
10
  import { useTheme } from "@principal-ade/industry-theme";
11
11
  import { Minimize2, Maximize2, X } from "lucide-react";
12
12
 
13
- /** Matches `transition: height …` below — callers that fitView after open
14
- * should wait at least this long so the canvas has its reduced height. */
13
+ /** Matches the drawer's size transition; wait this long before fitting the canvas. */
15
14
  export const FILE_DRAWER_HEIGHT_MS = 200;
16
15
 
17
- /** Bottom panel that slides up from the bottom of the graph area.
18
- * Bottom-anchored with an animated height; an in-flow spacer reserves its
19
- * footprint so the canvas shrinks while open (nothing covered). Stays mounted
20
- * so open/close/maximize animate. The header's maximize button (or a
21
- * double-click) grows it over the whole graph column for whole-file reading. */
16
+ /** An in-flow spacer reserves the panel's footprint so it never covers the
17
+ * canvas. Stays mounted across open/close, maximize, and docking changes. */
22
18
  export function FileDrawer({
23
19
  title,
24
20
  onClose,
21
+ placement = "bottom",
25
22
  fillHeight = false,
26
23
  suppressEscape = false,
27
24
  hidden = false,
@@ -32,7 +29,9 @@ export function FileDrawer({
32
29
  /** Drawer chrome title; `null` closes the drawer. */
33
30
  title: string | null;
34
31
  onClose: () => void;
35
- /** When true and open, fill the graph column instead of ~45% height. */
32
+ /** Left docking requires a row container; bottom docking requires a column. */
33
+ placement?: "bottom" | "left";
34
+ /** When true and open, fill the graph area instead of ~45% of its dock axis. */
36
35
  fillHeight?: boolean;
37
36
  /** Skip Escape handling (e.g. while a full-file overlay is on top). */
38
37
  suppressEscape?: boolean;
@@ -54,18 +53,17 @@ export function FileDrawer({
54
53
  const showProgress = progress != null && progress.total > 1;
55
54
  const [closeHover, setCloseHover] = useState(false);
56
55
  const [maxHover, setMaxHover] = useState(false);
57
- // Full-height mode: the maximize button or double-clicking the header.
58
- // The panel is always bottom-anchored with an animated height, and a spacer
59
- // in normal flow reserves its footprint, so maximizing grows it upward over
60
- // the still-mounted graph instead of snapping to full.
56
+ // Maximize grows over the still-mounted graph along the current dock axis.
61
57
  const [maximized, setMaximized] = useState(false);
62
- // A closed drawer reopens at the default height, not maximized.
58
+ // A closed drawer reopens at the default size, not maximized.
63
59
  useEffect(() => {
64
60
  if (!open) setMaximized(false);
65
61
  }, [open]);
66
- const fullHeight = open && (maximized || fillHeight);
62
+ const fullSize = open && (maximized || fillHeight);
63
+ const sideDocked = placement === "left";
64
+ const sizeAxis = sideDocked ? "width" : "height";
67
65
  // Space the panel takes out of the canvas's flow (0 when it overlays fully).
68
- const reservedHeight = fullHeight ? 0 : open ? "45%" : 0;
66
+ const reservedSize = fullSize ? 0 : open ? "45%" : 0;
69
67
 
70
68
  useEffect(() => {
71
69
  if (!open || suppressEscape) return;
@@ -78,36 +76,37 @@ export function FileDrawer({
78
76
 
79
77
  return (
80
78
  <>
81
- {/* Reserves the drawer's footprint in the column so the canvas shrinks
82
- while the drawer is open. Animates with the panel, so the boundary
83
- between graph and code slides smoothly. */}
79
+ {/* Reserve the same size along the parent's matching flex axis. */}
84
80
  <div
85
81
  aria-hidden="true"
86
82
  style={{
87
83
  flexShrink: 0,
88
- height: reservedHeight,
89
- transition: `height ${FILE_DRAWER_HEIGHT_MS}ms ease`,
84
+ order: sideDocked ? -1 : undefined,
85
+ [sizeAxis]: reservedSize,
86
+ transition: `${sizeAxis} ${FILE_DRAWER_HEIGHT_MS}ms ease`,
90
87
  }}
91
88
  />
92
- {/* The panel — bottom-anchored and grown via `height`, so maximize/fill
93
- reads as dragging up over the diagram. */}
94
89
  <div
95
90
  style={{
96
91
  position: "absolute",
97
92
  left: 0,
98
- right: 0,
93
+ top: sideDocked ? 0 : undefined,
94
+ right: sideDocked ? undefined : 0,
99
95
  bottom: 0,
100
96
  // Above the edge-label overlay (zIndex 5), React Flow, and overlays
101
97
  // (zIndex 6–8); 30 lifts it over the whole graph area when maximized.
102
98
  zIndex: maximized ? 30 : 6,
103
- height: open ? (fullHeight ? "100%" : "45%") : 0,
99
+ width: sideDocked ? (open ? (fullSize ? "100%" : "45%") : 0) : undefined,
100
+ height: sideDocked ? "100%" : open ? (fullSize ? "100%" : "45%") : 0,
101
+ minWidth: 0,
104
102
  minHeight: 0,
105
103
  overflow: "hidden",
106
104
  display: "flex",
107
105
  flexDirection: "column",
108
106
  background: theme.colors.background,
109
- borderTop: open ? `1px solid ${theme.colors.border}` : "none",
110
- transition: `height ${FILE_DRAWER_HEIGHT_MS}ms ease`,
107
+ borderTop: open && !sideDocked ? `1px solid ${theme.colors.border}` : "none",
108
+ borderRight: open && sideDocked ? `1px solid ${theme.colors.border}` : "none",
109
+ transition: `${sizeAxis} ${FILE_DRAWER_HEIGHT_MS}ms ease`,
111
110
  }}
112
111
  >
113
112
  <div
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
- import { createElement } from 'react';
2
+ import { createElement, useState } from 'react';
3
3
  import { Window } from 'happy-dom';
4
4
  import { cleanup, fireEvent, render } from '@testing-library/react/pure';
5
5
  import { ThemeProvider, defaultEditorTheme } from '@principal-ade/industry-theme';
@@ -125,6 +125,59 @@ describe('GraphSidebar chrome', () => {
125
125
  expect(sidebarColumn(container).style.width).toBe('400px');
126
126
  });
127
127
 
128
+ test('multiple moves track displacement from drag start without accumulating', () => {
129
+ const { container, getByLabelText } = renderSidebar({});
130
+ const handle = getByLabelText('Resize sidebar');
131
+ fireEvent.mouseDown(handle, { clientX: 100 });
132
+ fireEvent.mouseMove(document, { clientX: 120 });
133
+ expect(sidebarColumn(container).style.width).toBe('340px');
134
+ fireEvent.mouseMove(document, { clientX: 140 });
135
+ expect(sidebarColumn(container).style.width).toBe('360px');
136
+ fireEvent.mouseMove(document, { clientX: 110 });
137
+ expect(sidebarColumn(container).style.width).toBe('330px');
138
+ fireEvent.mouseUp(document);
139
+
140
+ // Each new gesture starts from the width the previous gesture left.
141
+ fireEvent.mouseDown(handle, { clientX: 110 });
142
+ fireEvent.mouseMove(document, { clientX: 130 });
143
+ fireEvent.mouseUp(document);
144
+ expect(sidebarColumn(container).style.width).toBe('350px');
145
+ });
146
+
147
+ test('clamps at both limits and follows the pointer back from them', () => {
148
+ const { container, getByLabelText } = renderSidebar({});
149
+ fireEvent.mouseDown(getByLabelText('Resize sidebar'), { clientX: 100 });
150
+ fireEvent.mouseMove(document, { clientX: 500 });
151
+ expect(sidebarColumn(container).style.width).toBe('600px');
152
+ fireEvent.mouseMove(document, { clientX: 150 });
153
+ expect(sidebarColumn(container).style.width).toBe('370px');
154
+ fireEvent.mouseMove(document, { clientX: -100 });
155
+ expect(sidebarColumn(container).style.width).toBe('240px');
156
+ fireEvent.mouseMove(document, { clientX: 100 });
157
+ expect(sidebarColumn(container).style.width).toBe('320px');
158
+ fireEvent.mouseUp(document);
159
+ });
160
+
161
+ test('controlled host updates do not change the drag baseline', () => {
162
+ function ControlledSidebar() {
163
+ const [width, setWidth] = useState(400);
164
+ return createElement(
165
+ ThemeProvider,
166
+ { theme: defaultEditorTheme },
167
+ createElement(GraphSidebar, { width, onWidthChange: setWidth }),
168
+ );
169
+ }
170
+ const { container, getByLabelText } = render(createElement(ControlledSidebar));
171
+ fireEvent.mouseDown(getByLabelText('Resize sidebar'), { clientX: 100 });
172
+ fireEvent.mouseMove(document, { clientX: 140 });
173
+ expect(sidebarColumn(container).style.width).toBe('440px');
174
+ fireEvent.mouseMove(document, { clientX: 160 });
175
+ expect(sidebarColumn(container).style.width).toBe('460px');
176
+ fireEvent.mouseMove(document, { clientX: 100 });
177
+ expect(sidebarColumn(container).style.width).toBe('400px');
178
+ fireEvent.mouseUp(document);
179
+ });
180
+
128
181
  test('a drag ends by persisting the width under persistKey', () => {
129
182
  const { getByLabelText } = renderSidebar({
130
183
  persistKey: 'gs-test',
@@ -132,6 +185,8 @@ describe('GraphSidebar chrome', () => {
132
185
  });
133
186
  const handle = getByLabelText('Resize sidebar');
134
187
  fireEvent.mouseDown(handle, { clientX: 100 });
188
+ fireEvent.mouseMove(document, { clientX: 140 });
189
+ fireEvent.mouseMove(document, { clientX: 180 });
135
190
  fireEvent.mouseMove(document, { clientX: 200 });
136
191
  fireEvent.mouseUp(document);
137
192
  expect(window.localStorage.getItem('principal.subsystems.sidebarWidth.gs-test')).toBe('420');
@@ -130,6 +130,7 @@ export function GraphSidebar({
130
130
  // persisting only then — not on every mousemove.
131
131
  const [dragging, setDragging] = useState(false);
132
132
  const dragStartX = useRef(0);
133
+ const dragStartWidth = useRef(0);
133
134
  // Mirror of the current width for the mouseup handler (a closure over the
134
135
  // drag-start render would persist the stale start width).
135
136
  const currentWidthRef = useRef(effectiveWidth);
@@ -139,6 +140,7 @@ export function GraphSidebar({
139
140
  (e: ReactMouseEvent) => {
140
141
  e.preventDefault();
141
142
  dragStartX.current = e.clientX;
143
+ dragStartWidth.current = effectiveWidth;
142
144
  currentWidthRef.current = effectiveWidth;
143
145
  setDragging(true);
144
146
  onDraggingChange?.(true);
@@ -150,7 +152,7 @@ export function GraphSidebar({
150
152
  if (!dragging) return;
151
153
  const onMove = (e: MouseEvent) => {
152
154
  const next = Math.min(
153
- Math.max(currentWidthRef.current + (e.clientX - dragStartX.current), minWidth),
155
+ Math.max(dragStartWidth.current + (e.clientX - dragStartX.current), minWidth),
154
156
  maxWidth,
155
157
  );
156
158
  if (controlled) {