Skip to content

feat(dockview-modules): layout history (undo/redo) — Phases B–E#1375

Merged
mathuo merged 4 commits into
v8-branchfrom
feat/layout-history-undo-redo
Jun 24, 2026
Merged

feat(dockview-modules): layout history (undo/redo) — Phases B–E#1375
mathuo merged 4 commits into
v8-branchfrom
feat/layout-history-undo-redo

Conversation

@mathuo

@mathuo mathuo commented Jun 23, 2026

Copy link
Copy Markdown
Owner

First user-facing slice of layout history: snapshot-based undo/redo of discrete layout mutations. Builds on the will/did mutation seam (#1317) + its origin field (#1328) — both already merged.

What's in it

  • LayoutHistoryService (+ LayoutHistoryModule, auto-registered): captures a toJSON() pre-image on onWillMutateLayout, a post-image on onDidMutateLayout, pushes a bounded undo stack, and applies snapshots 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 user-origin mutations recorded by default; undoableProgrammaticMutations opts api-origin in.
  • Public API: api.undo()/redo(), api.canUndo/canRedo, api.clearHistory(), api.onDidChangeHistory. Options: layoutHistory: { enabled, depth=25, undoableProgrammaticMutations=false, clearOnFromJSON=true }default off, zero overhead.
  • Dockview binds no keys — host app wires Ctrl+Z etc. (avoids clashing with the keyboard-navigation keymap).

Design notes

  • Snapshot strategy (not command-inverse): each entry keeps full before/after toJSON()s — immune to intermediate drift, and undo-close restores panel content + params + title.
  • reuseExistingPanels: true on 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). See enterprise-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, onDidChangeHistory payload. 1092 core + 108 modules green; lint 0 errors.

Targets v8-branch.

🤖 Generated with Claude Code

mathuo and others added 2 commits June 23, 2026 23:00
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>
@mathuo mathuo changed the title feat(dockview-core): layout history (undo/redo) — Phase B feat(dockview-core): layout history (undo/redo) — Phases B + C Jun 23, 2026
@mathuo

mathuo commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

Added Phase C — resize coalescing on top of Phase B (same branch/PR).

  • Sash resize is captured off the coalesced onDidLayoutChange ping using a lazy pre-image (the last settled snapshot), since resize has no mutation boundary.
  • A continuous drag is debounced into one undo entry (coalesceMs, default 400); a discrete mutation finalizes the open resize run first (no resize+close fold).
  • The layout ping trailing a mutation / undo-apply / initial layout is treated as a settle (refreshes baseline, never opens a resize run); a no-op drag isn't recorded.
  • New options: recordResize (default true), coalesceMs (default 400).
  • Fully inert when disabled — never calls toJSON() (fixed a perturbation of a popout test whose jsdom mock has an incrementing innerWidth getter).

+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>
@mathuo mathuo changed the title feat(dockview-core): layout history (undo/redo) — Phases B + C feat(dockview-core): layout history (undo/redo) — Phases B + C + D Jun 23, 2026
@mathuo

mathuo commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

Added Phase D — cross-window (same branch/PR).

  • Undo/redo restore popout windows, which re-open asynchronously. The re-entrancy guard is now a counter held across the async re-open: when the restored snapshot contains popouts, _apply holds the guard until popoutRestorationPromise settles (so the re-open doesn't record a spurious entry); otherwise it releases synchronously so a user mutation right after undo/redo is still recorded.
  • Exposed api.popoutRestorationPromise to await the async re-open.
  • A mutation inside a popout already records once (the boundary fires on the root).

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.

This PR now covers Phases B + C + D. Only Phase E remains: relocate the implementation to the dockview-modules package (mechanical, post-merge).

…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>
@mathuo mathuo changed the title feat(dockview-core): layout history (undo/redo) — Phases B + C + D feat(dockview-modules): layout history (undo/redo) — Phases B–E Jun 23, 2026
@mathuo

mathuo commented Jun 23, 2026

Copy link
Copy Markdown
Owner Author

Added Phase E — move to the modules package (same branch/PR). LayoutHistory now ships from dockview-modules like the other extracted modules; no user-facing change.

  • LayoutHistoryService + LayoutHistoryModuledockview-modules (in Modules, auto-registered + exported); removed from core allModules.
  • Contracts (ILayoutHistoryHost, ILayoutHistoryService, LayoutHistoryChangeEvent, LayoutHistoryKind) → core moduleContracts.ts (exported from the core index); option types stay in options.ts; the component keeps its ?.-delegating undo/redo surface + a local never-event fallback.
  • Unit tests moved to dockview-modules/src/__tests__/; regenerated __generated__/dockview-core-exports.txt.

Removability holds (module absent → undo/redo no-op, canUndo/canRedo false, onDidChangeHistory never-fires).

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.

@sonarqubecloud

Copy link
Copy Markdown

@mathuo mathuo merged commit 1214418 into v8-branch Jun 24, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant