@weasel-js/labkit 1.0.3 → 1.0.4

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 (45) hide show
  1. package/dist/_dts/{DrawCommand-uKHt4Vul.d.ts → DrawCommand-BkZztJsW.d.ts} +3 -1
  2. package/dist/_dts/{useExperimentState-vrttakTt.d.ts → useExperimentState-D7EQnnwJ.d.ts} +4 -0
  3. package/dist/{chunk-N5KTQKQA.js → chunk-BAPZPDDA.js} +628 -588
  4. package/dist/chunk-BAPZPDDA.js.map +1 -0
  5. package/dist/{chunk-73PXCRCR.js → chunk-DEWXYFEU.js} +6 -6
  6. package/dist/chunk-DEWXYFEU.js.map +1 -0
  7. package/dist/{chunk-3WPOGKUP.js → chunk-DWV7SFKR.js} +518 -217
  8. package/dist/chunk-DWV7SFKR.js.map +1 -0
  9. package/dist/{chunk-T7OKNJTY.js → chunk-KSTEW2AF.js} +18 -2
  10. package/dist/chunk-KSTEW2AF.js.map +1 -0
  11. package/dist/index.d.ts +64 -15
  12. package/dist/index.js +133 -24
  13. package/dist/index.js.map +1 -1
  14. package/dist/passthrough/weasel-canvas.d.ts +1 -1
  15. package/dist/passthrough/weasel-canvas.js +1 -1
  16. package/dist/passthrough/weasel-ui.d.ts +1 -1
  17. package/dist/passthrough/weasel-ui.js +2 -2
  18. package/dist/state/index.d.ts +4 -4
  19. package/dist/state/index.js +2 -2
  20. package/dist/styles.css +163 -6
  21. package/dist/ui/layers/index.js +3 -3
  22. package/package.json +2 -1
  23. package/src/lab/Lab.tsx +14 -2
  24. package/src/lab/LabContext.ts +1 -0
  25. package/src/lab/WorkspaceGrid.less +37 -5
  26. package/src/lab/WorkspaceGrid.stories.tsx +16 -0
  27. package/src/lab/WorkspaceGrid.test.tsx +84 -10
  28. package/src/lab/WorkspaceGrid.tsx +188 -14
  29. package/src/lab/index.ts +0 -2
  30. package/src/state/helpers.ts +2 -2
  31. package/src/state/store.test.ts +2 -1
  32. package/src/state/store.ts +19 -0
  33. package/src/state/types.ts +3 -0
  34. package/src/test-setup.ts +19 -0
  35. package/src/workspace/Workspace.stories.tsx +1 -0
  36. package/src/workspace/Workspace.test.tsx +1 -0
  37. package/src/workspace/index.ts +1 -0
  38. package/src/workspace/workspaceOps.test.ts +34 -1
  39. package/src/workspace/workspaceOps.ts +16 -0
  40. package/dist/chunk-3WPOGKUP.js.map +0 -1
  41. package/dist/chunk-73PXCRCR.js.map +0 -1
  42. package/dist/chunk-N5KTQKQA.js.map +0 -1
  43. package/dist/chunk-T7OKNJTY.js.map +0 -1
  44. package/src/lab/gridDims.test.ts +0 -35
  45. package/src/lab/gridDims.ts +0 -13
@@ -59,3 +59,19 @@ export const SevenTiles: Story = {
59
59
  </div>
60
60
  ),
61
61
  };
62
+
63
+ /** Draggable seams. Drag a tile edge, or Tab to a seam and press an arrow —
64
+ * a grid moves extents a whole cell at a time. */
65
+ export const Resizable: Story = {
66
+ render: () => (
67
+ <div style={{ height: 500 }}>
68
+ <WorkspaceGrid ids={['a', 'b', 'c', 'd', 'e']} resizable>
69
+ <Tile>1</Tile>
70
+ <Tile>2</Tile>
71
+ <Tile>3</Tile>
72
+ <Tile>4</Tile>
73
+ <Tile>5</Tile>
74
+ </WorkspaceGrid>
75
+ </div>
76
+ ),
77
+ };
@@ -1,11 +1,13 @@
1
1
  import { render, screen } from '@testing-library/react';
2
- import { describe, expect, test } from 'vitest';
2
+ import { describe, expect, test, vi } from 'vitest';
3
3
  import { WorkspaceGrid } from './WorkspaceGrid';
4
4
 
5
+ const VIEWPORT = { w: 800, h: 600 };
6
+
5
7
  describe('WorkspaceGrid', () => {
6
8
  test('renders all children', () => {
7
9
  render(
8
- <WorkspaceGrid>
10
+ <WorkspaceGrid viewport={VIEWPORT}>
9
11
  <div>one</div>
10
12
  <div>two</div>
11
13
  <div>three</div>
@@ -16,25 +18,97 @@ describe('WorkspaceGrid', () => {
16
18
  expect(screen.getByText('three')).toBeInTheDocument();
17
19
  });
18
20
 
19
- test('sets CSS custom properties for grid dimensions based on child count', () => {
20
- const { container } = render(
21
- <WorkspaceGrid>
21
+ test('keeps a child with the tile its id names when an earlier one closes', () => {
22
+ const ids = ['a', 'b', 'c'];
23
+ const { rerender, container } = render(
24
+ <WorkspaceGrid ids={ids} viewport={VIEWPORT}>
22
25
  <div>a</div>
23
26
  <div>b</div>
24
27
  <div>c</div>
25
28
  </WorkspaceGrid>,
26
29
  );
27
- const grid = container.firstChild as HTMLElement;
28
- expect(grid.style.getPropertyValue('--lk-grid-cols')).toBe('2');
29
- expect(grid.style.getPropertyValue('--lk-grid-rows')).toBe('2');
30
+ expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
31
+
32
+ rerender(
33
+ <WorkspaceGrid ids={['b', 'c']} viewport={VIEWPORT}>
34
+ <div>b</div>
35
+ <div>c</div>
36
+ </WorkspaceGrid>,
37
+ );
38
+ expect(container.querySelector('[data-node="c"]')).toHaveTextContent('c');
39
+ expect(container.querySelector('[data-node="a"]')).toBeNull();
30
40
  });
31
41
 
32
42
  test('uses lk-workspace-grid class', () => {
33
43
  const { container } = render(
34
- <WorkspaceGrid>
44
+ <WorkspaceGrid viewport={VIEWPORT}>
35
45
  <div />
36
46
  </WorkspaceGrid>,
37
47
  );
38
- expect((container.firstChild as HTMLElement).className).toBe('lk-workspace-grid');
48
+ expect((container.firstChild as HTMLElement).className).toContain('lk-workspace-grid');
49
+ });
50
+
51
+ test('renders resize affordances only when resizable', () => {
52
+ const { container, rerender } = render(
53
+ <WorkspaceGrid ids={['a', 'b']} viewport={VIEWPORT}>
54
+ <div>a</div>
55
+ <div>b</div>
56
+ </WorkspaceGrid>,
57
+ );
58
+ expect(container.querySelectorAll('[role="separator"]')).toHaveLength(0);
59
+
60
+ rerender(
61
+ <WorkspaceGrid ids={['a', 'b']} resizable viewport={VIEWPORT}>
62
+ <div>a</div>
63
+ <div>b</div>
64
+ </WorkspaceGrid>,
65
+ );
66
+ expect(container.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
67
+ });
68
+
69
+ test('applies a saved extent to a tile as it registers', () => {
70
+ const { container } = render(
71
+ <WorkspaceGrid
72
+ ids={['a', 'b']}
73
+ resizable
74
+ viewport={VIEWPORT}
75
+ layout={{ a: { span: { cols: 2 } } }}
76
+ >
77
+ <div>a</div>
78
+ <div>b</div>
79
+ </WorkspaceGrid>,
80
+ );
81
+ const a = container.querySelector('[data-node="a"]') as HTMLElement;
82
+ const b = container.querySelector('[data-node="b"]') as HTMLElement;
83
+ // `a` holds two columns, so it is wider than the single-column `b`.
84
+ expect(Number.parseFloat(a.style.width)).toBeGreaterThan(Number.parseFloat(b.style.width));
85
+ });
86
+
87
+ test('reports the order a drop would produce instead of applying it', () => {
88
+ const onReorder = vi.fn();
89
+ const { container } = render(
90
+ <WorkspaceGrid ids={['a', 'b']} reorderable onReorder={onReorder} viewport={VIEWPORT}>
91
+ <div>a</div>
92
+ <div>b</div>
93
+ </WorkspaceGrid>,
94
+ );
95
+ // The grid is controlled: it renders a handle per tile and commits nothing
96
+ // itself. Order still comes from `ids`.
97
+ expect(container.querySelectorAll('.lk-workspace-tile__grip')).toHaveLength(2);
98
+ expect(onReorder).not.toHaveBeenCalled();
99
+ const nodes = [...container.querySelectorAll('[data-node]')].map((el) =>
100
+ el.getAttribute('data-node'),
101
+ );
102
+ expect(nodes).toEqual(['a', 'b']);
103
+ });
104
+
105
+ test('renders no drag handles unless reorderable', () => {
106
+ const { container } = render(
107
+ <WorkspaceGrid ids={['a', 'b']} viewport={VIEWPORT}>
108
+ <div>a</div>
109
+ <div>b</div>
110
+ </WorkspaceGrid>,
111
+ );
112
+ expect(container.querySelectorAll('.lk-workspace-tile__grip')).toHaveLength(0);
39
113
  });
40
114
  });
@@ -1,22 +1,196 @@
1
- import { Children, type CSSProperties, type ReactNode } from 'react';
2
- import { gridDims } from './gridDims';
1
+ import {
2
+ Children,
3
+ type ReactNode,
4
+ useCallback,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ } from 'react';
10
+ import { asNodeId, createNode, gridStrategy, type NodeId, Store } from 'windease';
11
+ import {
12
+ type ChromeMap,
13
+ Container,
14
+ DragHandle,
15
+ DragProvider,
16
+ Provider,
17
+ StrategyRegistryProvider,
18
+ } from 'windease/react';
19
+
20
+ const ZONE_ID = asNodeId('lk-workspaces');
21
+ const STRATEGIES = { grid: gridStrategy as never };
22
+ const KIND = 'workspace';
23
+
24
+ /** A tile's persisted extent, keyed by the id its caller gave it. Grid resizes
25
+ * write `span`; `size` is here because a strategy swap would write that. */
26
+ export type WorkspaceLayout = Record<
27
+ string,
28
+ { size?: { w?: number; h?: number }; span?: { cols?: number; rows?: number } }
29
+ >;
3
30
 
4
- /** Props for `<WorkspaceGrid>`. */
5
31
  export interface WorkspaceGridProps {
6
32
  children: ReactNode;
33
+ /**
34
+ * Stable id per child, positionally matched. Supply these whenever a tile
35
+ * can be closed from the middle: without them a tile is identified by its
36
+ * position, so closing one shifts every id after it and the panes inherit
37
+ * each other's dragged extents. `layout` and `reorderable` both key off
38
+ * these, so neither means much without them.
39
+ */
40
+ ids?: readonly string[];
41
+ /** Draggable seams between tiles. Off by default — an even tiling is the
42
+ * behavior every existing caller has. */
43
+ resizable?: boolean;
44
+ /** Let a tile be dragged to a new position. Off by default. The grid never
45
+ * reorders `children` itself: it reports the order a drop would produce and
46
+ * the caller commits it. */
47
+ reorderable?: boolean;
48
+ /** The full id list a drop would produce, in its new order. */
49
+ onReorder?: (ids: string[]) => void;
50
+ /** Extents from a previous session, applied to tiles as they register. */
51
+ layout?: WorkspaceLayout;
52
+ /** Fires when a tile's extent changes. Persist it and hand it back as
53
+ * `layout` to make a resize survive a reload. */
54
+ onLayoutChange?: (layout: WorkspaceLayout) => void;
55
+ gap?: number;
56
+ padding?: number;
57
+ /**
58
+ * Fixed tiling extent. Omit in an app — the grid measures its own box. Supply
59
+ * it where nothing measures, notably jsdom: at a zero measurement the grid
60
+ * renders no tiles at all.
61
+ */
62
+ viewport?: { w: number; h: number };
63
+ }
64
+
65
+ function extentOf(store: Store, id: NodeId): WorkspaceLayout[string] | null {
66
+ const p = store.getNode(id)?.membership?.placement as WorkspaceLayout[string] | undefined;
67
+ if (!p) return null;
68
+ const out: WorkspaceLayout[string] = {};
69
+ if (p.size) out.size = p.size;
70
+ if (p.span) out.span = p.span;
71
+ return Object.keys(out).length > 0 ? out : null;
7
72
  }
8
73
 
9
- /** Lays its children out in the most nearly square grid that fits them. */
10
- export function WorkspaceGrid({ children }: WorkspaceGridProps) {
11
- const count = Children.count(children);
12
- const { cols, rows } = gridDims(count);
13
- const style = {
14
- '--lk-grid-cols': String(cols),
15
- '--lk-grid-rows': String(rows),
16
- } as CSSProperties;
74
+ /**
75
+ * Auto-balanced tiling of workspaces, `ceil(sqrt(n))` columns wide.
76
+ *
77
+ * Tiles are absolutely positioned at the rects `gridStrategy` computes, not
78
+ * laid out by CSS — `windease/styles.css` (folded into
79
+ * `@weasel-js/labkit/styles.css`) carries the rules that positioning depends on.
80
+ */
81
+ export function WorkspaceGrid({
82
+ children,
83
+ ids,
84
+ resizable = false,
85
+ reorderable = false,
86
+ onReorder,
87
+ layout,
88
+ onLayoutChange,
89
+ gap = 12,
90
+ padding = 0,
91
+ viewport,
92
+ }: WorkspaceGridProps) {
93
+ const items = Children.toArray(children);
94
+ const idKey = ids ? ids.join(',') : `#${items.length}`;
95
+ // biome-ignore lint/correctness/useExhaustiveDependencies: idKey is the stable projection of items/ids; depending on those directly rebuilds every render and re-runs the sync effect forever
96
+ const nodeIds = useMemo(() => items.map((_, i) => asNodeId(ids?.[i] ?? `lk-ws-${i}`)), [idKey]);
97
+
98
+ // Held in refs rather than depended on: a fresh object each render would
99
+ // re-run the sync effect, and only a newly registered tile reads `layout`.
100
+ const layoutRef = useRef(layout);
101
+ layoutRef.current = layout;
102
+ const onLayoutChangeRef = useRef(onLayoutChange);
103
+ onLayoutChangeRef.current = onLayoutChange;
104
+
105
+ // One store for the component's lifetime: a tile's dragged extent lives in
106
+ // its node, so rebuilding the store on every add or close would silently
107
+ // reset every pane.
108
+ const storeRef = useRef<Store | null>(null);
109
+ if (storeRef.current === null) {
110
+ const store = new Store();
111
+ store.registerNode(
112
+ createNode({
113
+ kind: 'zone',
114
+ id: ZONE_ID,
115
+ container: { strategyId: 'grid', config: { resizable, gap, padding } },
116
+ }),
117
+ );
118
+ storeRef.current = store;
119
+ }
120
+ const store = storeRef.current;
121
+
122
+ useLayoutEffect(() => {
123
+ store.updateContainerConfig(ZONE_ID, { resizable, gap, padding });
124
+ }, [store, resizable, gap, padding]);
125
+
126
+ useLayoutEffect(() => {
127
+ const present = new Set(store.getContainerView(ZONE_ID)?.childOrder ?? []);
128
+ const wanted = new Set(nodeIds);
129
+ for (const id of present) {
130
+ if (!wanted.has(id)) store.unregisterNode(id);
131
+ }
132
+ for (const id of nodeIds) {
133
+ if (present.has(id)) continue;
134
+ store.registerNode(createNode({ kind: KIND, id, parentId: ZONE_ID, focus: true }));
135
+ store.showNode(id);
136
+ const saved = layoutRef.current?.[id];
137
+ if (saved) store.patchPlacement(id, saved);
138
+ }
139
+ store.setChildOrder(ZONE_ID, [...nodeIds]);
140
+ }, [store, nodeIds]);
141
+
142
+ useEffect(() => {
143
+ if (!onLayoutChange) return;
144
+ return store.events.on('node.placementChanged', () => {
145
+ const next: WorkspaceLayout = {};
146
+ for (const child of store.getChildren(ZONE_ID)) {
147
+ const extent = extentOf(store, child.id);
148
+ if (extent) next[child.id] = extent;
149
+ }
150
+ onLayoutChangeRef.current?.(next);
151
+ });
152
+ }, [store, onLayoutChange]);
153
+
154
+ const commitOrder = useCallback(
155
+ (nextIds: NodeId[]) => onReorder?.(nextIds.map(String)),
156
+ [onReorder],
157
+ );
158
+
159
+ const chrome = useMemo<ChromeMap>(() => {
160
+ const byId = new Map<string, ReactNode>(nodeIds.map((id, i) => [id, items[i]]));
161
+ return {
162
+ [KIND]: ({ node }) => {
163
+ const content = byId.get(node.id) ?? null;
164
+ if (!reorderable) return content;
165
+ // A tile is full of controls, so the whole thing can't be the handle.
166
+ return (
167
+ <div className="lk-workspace-tile">
168
+ <DragHandle nodeId={node.id} className="lk-workspace-tile__grip">
169
+ <span className="lk-workspace-tile__grip-dots" aria-hidden="true" />
170
+ </DragHandle>
171
+ <div className="lk-workspace-tile__body">{content}</div>
172
+ </div>
173
+ );
174
+ },
175
+ };
176
+ }, [nodeIds, items, reorderable]);
177
+
178
+ const grid = (
179
+ <Container
180
+ parentId={ZONE_ID}
181
+ chrome={chrome}
182
+ className="lk-workspace-grid windease-zone"
183
+ affordances={resizable}
184
+ viewport={viewport}
185
+ onChildOrderChange={reorderable ? commitOrder : undefined}
186
+ />
187
+ );
188
+
17
189
  return (
18
- <div className="lk-workspace-grid" style={style}>
19
- {children}
20
- </div>
190
+ <Provider store={store}>
191
+ <StrategyRegistryProvider strategies={STRATEGIES}>
192
+ {reorderable ? <DragProvider>{grid}</DragProvider> : grid}
193
+ </StrategyRegistryProvider>
194
+ </Provider>
21
195
  );
22
196
  }
package/src/lab/index.ts CHANGED
@@ -1,5 +1,3 @@
1
- export type { GridDims } from './gridDims';
2
- export { gridDims } from './gridDims';
3
1
  export type { LabProps } from './Lab';
4
2
  export { Lab } from './Lab';
5
3
  export type { LabContextValue } from './LabContext';
@@ -1,10 +1,10 @@
1
1
  import type { InstrumentSerializers, UndoStack, WorkspaceRecord } from './types';
2
2
 
3
- /** The storage key a lab writes one of its three buckets under. Namespaced by
3
+ /** The storage key a lab writes one of its buckets under. Namespaced by
4
4
  * `storageKey` so two labs sharing an origin do not collide. */
5
5
  export function labStorageKey(
6
6
  storageKey: string,
7
- bucket: 'workspaces' | 'saves' | 'theme',
7
+ bucket: 'workspaces' | 'saves' | 'theme' | 'layout',
8
8
  ): string {
9
9
  return `lk:${storageKey}:${bucket}`;
10
10
  }
@@ -245,7 +245,8 @@ describe('persistence — debounced writes', () => {
245
245
  vi.advanceTimersByTime(400);
246
246
  const writesAfter = writeSpy.mock.calls.length;
247
247
 
248
- expect(writesAfter - writesBefore).toBe(3);
248
+ // One flush, one write per storage key: workspaces, saves, theme, layout.
249
+ expect(writesAfter - writesBefore).toBe(4);
249
250
  vi.useRealTimers();
250
251
  });
251
252
  });
@@ -34,6 +34,7 @@ export interface LabStoreActions {
34
34
  deleteSnapshot: (snapshotId: string) => void;
35
35
  listSnapshots: (workspaceId?: string) => SavedSnapshot[];
36
36
  setMode: (mode: LabMode) => void;
37
+ setLayout: (layout: Record<string, unknown>) => void;
37
38
  }
38
39
 
39
40
  /** A lab's store: its state and actions, plus the hook instruments use to
@@ -65,6 +66,17 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
65
66
  }
66
67
  }
67
68
 
69
+ const layoutRaw = options.storage.read(labStorageKey(options.storageKey, 'layout'));
70
+ let hydratedLayout: Record<string, unknown> = {};
71
+ if (layoutRaw) {
72
+ try {
73
+ hydratedLayout = JSON.parse(layoutRaw) as Record<string, unknown>;
74
+ } catch {
75
+ console.warn('[labkit] failed to parse saved layout, starting empty');
76
+ hydratedLayout = {};
77
+ }
78
+ }
79
+
68
80
  const modeRaw = options.storage.read(labStorageKey(options.storageKey, 'theme'));
69
81
  // `interstellar` was the dark mode's name back when it was a theme.
70
82
  const stored = modeRaw === 'interstellar' ? 'dark' : modeRaw;
@@ -79,6 +91,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
79
91
  workspaces: hydratedWorkspaces,
80
92
  savedSnapshots: hydratedSnapshots,
81
93
  mode: hydratedMode,
94
+ layout: hydratedLayout,
82
95
 
83
96
  addWorkspace: (record) => {
84
97
  set((s) => ({
@@ -207,6 +220,11 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
207
220
  set({ mode });
208
221
  scheduleFlush();
209
222
  },
223
+
224
+ setLayout: (layout) => {
225
+ set({ layout });
226
+ scheduleFlush();
227
+ },
210
228
  }));
211
229
 
212
230
  function scheduleFlush(): void {
@@ -222,6 +240,7 @@ export function createLabStore(options: CreateLabStoreOptions): LabStore {
222
240
  JSON.stringify(s.savedSnapshots),
223
241
  );
224
242
  options.storage.write(labStorageKey(options.storageKey, 'theme'), s.mode);
243
+ options.storage.write(labStorageKey(options.storageKey, 'layout'), JSON.stringify(s.layout));
225
244
  flushTimer = null;
226
245
  }, 300);
227
246
  }
@@ -37,6 +37,9 @@ export interface LabStoreState {
37
37
  workspaces: WorkspaceRecord[];
38
38
  savedSnapshots: SavedSnapshot[];
39
39
  mode: LabMode;
40
+ /** Per-workspace tile extents, keyed by workspace id. Opaque here — the
41
+ * shape belongs to whatever lays the workspaces out. */
42
+ layout: Record<string, unknown>;
40
43
  }
41
44
 
42
45
  /** Where a lab persists itself. Implementations are keyed string storage and
package/src/test-setup.ts CHANGED
@@ -12,6 +12,25 @@ if (jsdomWin && typeof globalThis.localStorage === 'undefined') {
12
12
  Object.defineProperty(globalThis, 'sessionStorage', { value: jsdomWin.sessionStorage });
13
13
  }
14
14
 
15
+ // windease's ContainerHost calls `new ResizeObserver` unguarded, and jsdom
16
+ // ships none. The stub must report a non-zero box: a windease container
17
+ // renders no children at all until something measures it, and jsdom's own
18
+ // geometry is always 0, so every tiled tile would vanish from the DOM.
19
+ if (typeof globalThis.ResizeObserver === 'undefined') {
20
+ globalThis.ResizeObserver = class {
21
+ #cb: ResizeObserverCallback;
22
+ constructor(cb: ResizeObserverCallback) {
23
+ this.#cb = cb;
24
+ }
25
+ observe(target: Element) {
26
+ const contentRect = { width: 1024, height: 768, x: 0, y: 0, top: 0, left: 0 };
27
+ this.#cb([{ target, contentRect } as ResizeObserverEntry], this);
28
+ }
29
+ unobserve() {}
30
+ disconnect() {}
31
+ } as unknown as typeof ResizeObserver;
32
+ }
33
+
15
34
  afterEach(() => {
16
35
  cleanup();
17
36
  });
@@ -34,6 +34,7 @@ function Harness() {
34
34
  cloneWorkspace: noop,
35
35
  closeWorkspace: noop,
36
36
  resetWorkspace: noop,
37
+ reorderWorkspaces: noop,
37
38
  savedSnapshots: [],
38
39
  saveSnapshot: noop,
39
40
  loadSnapshot: noop,
@@ -126,6 +126,7 @@ function ChromeHarness({
126
126
  cloneWorkspace: vi.fn(),
127
127
  closeWorkspace: vi.fn(),
128
128
  resetWorkspace: vi.fn(),
129
+ reorderWorkspaces: vi.fn(),
129
130
  savedSnapshots: [],
130
131
  saveSnapshot: vi.fn(),
131
132
  loadSnapshot: vi.fn(),
@@ -20,5 +20,6 @@ export {
20
20
  addWorkspace,
21
21
  cloneWorkspace,
22
22
  closeWorkspace,
23
+ reorderWorkspaces,
23
24
  resetWorkspace,
24
25
  } from './workspaceOps';
@@ -1,7 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import type { Instrument } from '../instrument/types';
3
3
  import type { WorkspaceRecord } from '../state/types';
4
- import { addWorkspace, cloneWorkspace, closeWorkspace, resetWorkspace } from './workspaceOps';
4
+ import {
5
+ addWorkspace,
6
+ cloneWorkspace,
7
+ closeWorkspace,
8
+ reorderWorkspaces,
9
+ resetWorkspace,
10
+ } from './workspaceOps';
5
11
 
6
12
  interface CounterState {
7
13
  count: number;
@@ -140,3 +146,30 @@ describe('resetWorkspace', () => {
140
146
  expect((arr[0]?.config as CounterConfig).step).toBe(99);
141
147
  });
142
148
  });
149
+
150
+ describe('reorderWorkspaces', () => {
151
+ const ws = (id: string) =>
152
+ ({
153
+ id,
154
+ instrumentName: 'T',
155
+ config: {},
156
+ state: {},
157
+ view: { zoom: 1, pan: { x: 0, y: 0 } },
158
+ undoStack: { past: [], future: [] },
159
+ }) as never;
160
+
161
+ it('reorders to match the given ids', () => {
162
+ const next = reorderWorkspaces([ws('a'), ws('b'), ws('c')], ['c', 'a', 'b']);
163
+ expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
164
+ });
165
+
166
+ it('drops ids that no longer exist rather than resurrecting them', () => {
167
+ const next = reorderWorkspaces([ws('a'), ws('b')], ['gone', 'b', 'a']);
168
+ expect(next.map((w) => w.id)).toEqual(['b', 'a']);
169
+ });
170
+
171
+ it('keeps unmentioned workspaces, after the named ones', () => {
172
+ const next = reorderWorkspaces([ws('a'), ws('b'), ws('c')], ['c']);
173
+ expect(next.map((w) => w.id)).toEqual(['c', 'a', 'b']);
174
+ });
175
+ });
@@ -84,3 +84,19 @@ export function resetWorkspace(
84
84
  };
85
85
  return [...workspaces.slice(0, idx), reset, ...workspaces.slice(idx + 1)];
86
86
  }
87
+
88
+ /**
89
+ * Reorder to match `ids`. Ids the list doesn't mention keep their relative
90
+ * order at the end, and ids it names that no longer exist are dropped — a
91
+ * reorder that raced a close should not resurrect the closed workspace.
92
+ */
93
+ export function reorderWorkspaces(
94
+ workspaces: WorkspaceRecord[],
95
+ ids: readonly string[],
96
+ ): WorkspaceRecord[] {
97
+ const byId = new Map(workspaces.map((w) => [w.id, w]));
98
+ const named = ids.map((id) => byId.get(id)).filter((w): w is WorkspaceRecord => w !== undefined);
99
+ const seen = new Set(named.map((w) => w.id));
100
+ const rest = workspaces.filter((w) => !seen.has(w.id));
101
+ return [...named, ...rest];
102
+ }