feat(dockview-modules): layout history (undo/redo) — Phases B–E#1375
Conversation
Snapshot-based undo/redo of discrete layout mutations, built on the will/did mutation-transaction seam (PR #1317) and its `origin` field (PR #1328). - New `LayoutHistoryService` (+ `LayoutHistoryModule`, auto-registered in `allModules`): captures a `toJSON()` pre-image on `onWillMutateLayout` and a post-image on `onDidMutateLayout`, pushes a bounded undo stack, and applies via `fromJSON(.., { reuseExistingPanels: true })` under a re-entrancy guard so the apply never re-records. - Records discrete kinds (add/remove/move/float/popout/maximize/tab-group); bulk `load`/`clear` clear the stacks (`clearOnFromJSON`, default true). - Only `origin:'user'` mutations are recorded by default; opt api-origin in via `undoableProgrammaticMutations`. - Public surface: `api.undo/redo`, `api.canUndo/canRedo`, `api.clearHistory()`, `api.onDidChangeHistory`; options `layoutHistory: { enabled, depth, undoableProgrammaticMutations, clearOnFromJSON }` (default off — zero overhead). Dockview binds no keys (host app's call; avoids the keyboard-nav keymap clash). Scope: single-window, discrete mutations. Resize coalescing and cross-window async popout re-open are follow-up phases. Test: `layoutHistory.spec.ts` (12) — add/close round-trips, undo-close restores title+params, redo invalidation, depth eviction, re-entrancy, origin filter, disabled no-op, clear-on-fromJSON, onDidChangeHistory. 1092 core + 108 modules green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sash resize has no mutation boundary, so it can't be captured the way discrete mutations are. Catch it off the coalesced `onDidLayoutChange` ping: - **Lazy pre-image:** keep the last settled snapshot as `_baseline`; a resize run's `before` is that baseline (captured before the drag), `after` is the live snapshot. - **Coalescing:** a continuous drag is debounced (`coalesceMs`, default 400) into a single undo entry; a discrete mutation finalizes the open resize run first so a resize and an unrelated close never fold together. - **Settle vs resize:** the layout ping trailing a boundary mutation (or an undo/redo apply, or the initial layout) is treated as a settle that just refreshes the baseline — it never opens a resize run. - A drag that nets no change is not recorded. - New options `recordResize` (default true) and `coalesceMs` (default 400). Fully inert when `enabled` is false — never calls `toJSON()`, so hosts whose serialization reads live geometry are untouched. Test: 5 fake-host + fake-timer cases (coalesce-to-one, discrete-finalizes-run, no-op drag, settle-not-resize, recordResize:false). 1097 core + 108 modules green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Added Phase C — resize coalescing on top of Phase B (same branch/PR).
+5 deterministic fake-host/fake-timer tests. 1097 core + 108 modules green. Still deferred: Phase D (cross-window async popout re-open), Phase E (move to the modules package). |
Undo/redo restore the whole layout including popout windows. Popouts re-open ASYNCHRONOUSLY (window.open + load), re-firing the mutation boundary on the root once the window loads — which would record a spurious entry. - The re-entrancy guard is now a counter held across the async re-open: when a restored snapshot contains popouts, `_apply` keeps the guard up until `popoutRestorationPromise` settles; otherwise it releases synchronously so a user mutation made right after undo/redo is still recorded. - Expose `api.popoutRestorationPromise` so callers can await the async re-open after an undo that re-opens a popout. - A mutation made inside a popout already records once (the boundary fires on the root regardless of window) — no extra work, covered by e2e. Test: e2e `layout-history.spec.ts` — undo reverts a popout; undo re-opens a closed popout window (awaiting the promise) and redo cleanly closes it again (proving the async re-open left no spurious entry). 8 e2e + 1097 core + 108 modules green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Added Phase D — cross-window (same branch/PR).
e2e This PR now covers Phases B + C + D. Only Phase E remains: relocate the implementation to the |
…ckage (Phase E) Relocate `LayoutHistoryService` + `LayoutHistoryModule` from `dockview-core` into `dockview-modules`, the same path the other extracted modules took. Core keeps the contracts and wiring; nothing user-facing changes. - Contracts to core `moduleContracts.ts`: `ILayoutHistoryHost`, `ILayoutHistoryService`, `LayoutHistoryChangeEvent`, `LayoutHistoryKind` (exported from the core index). Option types stay in `options.ts`. The component keeps its `?.`-delegating undo/redo/canUndo/canRedo/clearHistory/ onDidChangeHistory surface + a local never-event fallback. - Implementation moves to `dockview-modules/src/layoutHistoryService.ts`, added to `Modules` (auto-registered on import) and exported. Removed from core `allModules`. - Unit tests move to `dockview-modules/src/__tests__/layoutHistory.spec.ts` (the module is registered there via the test setup). - Regenerated `__generated__/dockview-core-exports.txt` (gen:check). Removability holds: with the module absent, `api.undo/redo` no-op, `canUndo/ canRedo` are false, `onDidChangeHistory` is the never-event. Test: 1080 core + 125 modules + 8 e2e green; lint 0 errors; gen clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Added Phase E — move to the modules package (same branch/PR). LayoutHistory now ships from
Removability holds (module absent → This PR now covers Phases B–E — undo/redo is feature-complete and lives in its final package. 1080 core + 125 modules + 8 e2e green; lint 0 errors; gen clean. |
|



First user-facing slice of layout history: snapshot-based undo/redo of discrete layout mutations. Builds on the will/did mutation seam (#1317) + its
originfield (#1328) — both already merged.What's in it
LayoutHistoryService(+LayoutHistoryModule, auto-registered): captures atoJSON()pre-image ononWillMutateLayout, a post-image ononDidMutateLayout, pushes a bounded undo stack, and applies snapshots viafromJSON(.., { reuseExistingPanels: true })under a re-entrancy guard so the apply never re-records.add/remove/move/float/popout/maximize/tab-group); bulkload/clearclear the stacks (clearOnFromJSON, defaulttrue).undoableProgrammaticMutationsopts api-origin in.api.undo()/redo(),api.canUndo/canRedo,api.clearHistory(),api.onDidChangeHistory. Options:layoutHistory: { enabled, depth=25, undoableProgrammaticMutations=false, clearOnFromJSON=true }— default off, zero overhead.Ctrl+Zetc. (avoids clashing with the keyboard-navigation keymap).Design notes
before/aftertoJSON()s — immune to intermediate drift, and undo-close restores panel content +params+title.reuseExistingPanels: trueon apply is mandatory (without it every undo flashes content + loses focus).Scope
Single-window, discrete mutations — the headline
Ctrl+Z. Follow-ups: resize coalescing (Phase C), cross-window async popout re-open (Phase D), move to the modules package (Phase E). Seeenterprise-modules/layout-history.md§8.Test
layoutHistory.spec.ts(12): add/close round-trips, undo-close restores title+params, redo invalidation, depth eviction, re-entrancy (no double-record), origin filter (+undoableProgrammaticMutations), disabled no-op, clear-on-fromJSON,onDidChangeHistorypayload. 1092 core + 108 modules green; lint 0 errors.Targets
v8-branch.🤖 Generated with Claude Code