Skip to content

Commit 1214418

Browse files
authored
Merge pull request #1375 from mathuo/feat/layout-history-undo-redo
feat(dockview-modules): layout history (undo/redo) — Phases B–E
2 parents 91ecadd + c3f0033 commit 1214418

12 files changed

Lines changed: 1074 additions & 1 deletion

File tree

__generated__/dockview-core-exports.txt

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

e2e/fixtures/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@
4444
const el = document.getElementById('app');
4545
const dockview = new core.DockviewComponent(el, {
4646
keyboardNavigation: true,
47+
// Record layout history so the harness can exercise undo/redo
48+
// (incl. async popout re-open).
49+
layoutHistory: { enabled: true },
4750
// A served (not about:blank) target avoids a 404 when the
4851
// popout window navigates before dockview injects content.
4952
popoutUrl: '/e2e/fixtures/popout.html',
@@ -93,6 +96,13 @@
9396
popoutActiveGroup: () =>
9497
dockview.addPopoutGroup(dockview.activeGroup),
9598
groupCount: () => dockview.groups.length,
99+
popoutCount: () => dockview.getPopouts().length,
100+
closeActivePopout: () =>
101+
dockview.getPopouts()[0].group.api.close(),
102+
undo: () => dockview.undo(),
103+
redo: () => dockview.redo(),
104+
canUndo: () => dockview.canUndo,
105+
awaitPopoutRestore: () => dockview.popoutRestorationPromise,
96106
};
97107
window.__ready = true;
98108
})();

e2e/tests/layout-history.spec.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { test, expect, Page } from '@playwright/test';
2+
3+
/**
4+
* Cross-window layout history (Phase D). Undo/redo restore the whole layout —
5+
* including popout windows, which re-open **asynchronously**. The recorder holds
6+
* its re-entrancy guard across that async re-open (so the re-open doesn't record
7+
* a spurious entry) and exposes `popoutRestorationPromise` to await it. None of
8+
* this is reachable in jsdom (no real second window).
9+
*/
10+
test.describe('cross-window layout history', () => {
11+
const ready = async (page: Page) => {
12+
await page.goto('/e2e/fixtures/index.html');
13+
await page.waitForFunction(() => (window as any).__ready === true);
14+
await page.evaluate(() => {
15+
(window as any).__dv.addPanel('alpha');
16+
(window as any).__dv.addPanel('beta');
17+
});
18+
};
19+
20+
test('undo reverts a popout (group returns to the grid)', async ({
21+
page,
22+
context,
23+
}) => {
24+
await ready(page);
25+
26+
const [win] = await Promise.all([
27+
context.waitForEvent('page'),
28+
page.evaluate(() => (window as any).__dv.popoutActiveGroup()),
29+
]);
30+
await (win as Page).waitForLoadState();
31+
expect(
32+
await page.evaluate(() => (window as any).__dv.popoutCount())
33+
).toBe(1);
34+
35+
// Undo the popout → the window closes and the group goes back.
36+
await Promise.all([
37+
(win as Page).waitForEvent('close'),
38+
page.evaluate(() => (window as any).__dv.undo()),
39+
]);
40+
await expect
41+
.poll(() => page.evaluate(() => (window as any).__dv.popoutCount()))
42+
.toBe(0);
43+
});
44+
45+
test('undo re-opens a closed popout window', async ({ page, context }) => {
46+
await ready(page);
47+
48+
// pop the active group out
49+
const [win1] = await Promise.all([
50+
context.waitForEvent('page'),
51+
page.evaluate(() => (window as any).__dv.popoutActiveGroup()),
52+
]);
53+
await (win1 as Page).waitForLoadState();
54+
55+
// close the popout (records a removal whose pre-image still has the popout)
56+
await Promise.all([
57+
(win1 as Page).waitForEvent('close'),
58+
page.evaluate(() => (window as any).__dv.closeActivePopout()),
59+
]);
60+
expect(
61+
await page.evaluate(() => (window as any).__dv.popoutCount())
62+
).toBe(0);
63+
expect(await page.evaluate(() => (window as any).__dv.canUndo())).toBe(
64+
true
65+
);
66+
67+
// undo → re-opens the popout window asynchronously
68+
const [win2] = await Promise.all([
69+
context.waitForEvent('page'),
70+
page.evaluate(async () => {
71+
(window as any).__dv.undo();
72+
await (window as any).__dv.awaitPopoutRestore();
73+
}),
74+
]);
75+
await (win2 as Page).waitForLoadState();
76+
await expect
77+
.poll(() => page.evaluate(() => (window as any).__dv.popoutCount()))
78+
.toBe(1);
79+
80+
// The re-open must not have left a spurious history entry that a single
81+
// redo can't account for — redo should cleanly close it again.
82+
await Promise.all([
83+
(win2 as Page).waitForEvent('close'),
84+
page.evaluate(() => (window as any).__dv.redo()),
85+
]);
86+
await expect
87+
.poll(() => page.evaluate(() => (window as any).__dv.popoutCount()))
88+
.toBe(0);
89+
});
90+
});

packages/dockview-core/src/api/component.api.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
IDockviewGroupPanel,
5151
} from '../dockview/dockviewGroupPanel';
5252
import { Event } from '../events';
53+
import { LayoutHistoryChangeEvent } from '../dockview/moduleContracts';
5354
import { IDockviewPanel } from '../dockview/dockviewPanel';
5455
import { PaneviewDidDropEvent } from '../paneview/draggablePaneviewPanel';
5556
import {
@@ -1033,6 +1034,50 @@ export class DockviewApi implements CommonApi<SerializedDockview> {
10331034
this.component.withOrigin('api', () => this.component.clear());
10341035
}
10351036

1037+
/**
1038+
* Undo the previous recorded layout mutation. No-op when there is nothing
1039+
* to undo, when `layoutHistory.enabled` is not set, or when the
1040+
* LayoutHistory module is absent.
1041+
*/
1042+
undo(): void {
1043+
this.component.undo();
1044+
}
1045+
1046+
/** Re-apply the next layout mutation undone via {@link undo}. */
1047+
redo(): void {
1048+
this.component.redo();
1049+
}
1050+
1051+
/** Whether {@link undo} would do something. Reactive via {@link onDidChangeHistory}. */
1052+
get canUndo(): boolean {
1053+
return this.component.canUndo;
1054+
}
1055+
1056+
/** Whether {@link redo} would do something. */
1057+
get canRedo(): boolean {
1058+
return this.component.canRedo;
1059+
}
1060+
1061+
/** Drop both undo and redo stacks (e.g. on document switch). */
1062+
clearHistory(): void {
1063+
this.component.clearHistory();
1064+
}
1065+
1066+
/** Fires whenever the undo/redo stacks change. */
1067+
get onDidChangeHistory(): Event<LayoutHistoryChangeEvent> {
1068+
return this.component.onDidChangeHistory;
1069+
}
1070+
1071+
/**
1072+
* Resolves once any in-flight popout-window restoration completes. Popout
1073+
* windows re-open asynchronously, so after an {@link undo} / {@link redo} (or
1074+
* {@link fromJSON}) that re-opens a popout, await this to know the window is
1075+
* ready. Already-resolved when nothing is restoring.
1076+
*/
1077+
get popoutRestorationPromise(): Promise<void> {
1078+
return this.component.popoutRestorationPromise;
1079+
}
1080+
10361081
/**
10371082
* Move the focus progmatically to the next panel or group.
10381083
*/

packages/dockview-core/src/dockview/dockviewComponent.ts

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ import {
9090
IAdvancedDnDHost,
9191
IContextMenuHost,
9292
IContextMenuService,
93+
ILayoutHistoryHost,
94+
LayoutHistoryChangeEvent,
9395
ITabGroupChipsHost,
9496
} from './moduleContracts';
9597
import { IHeaderActionsHost } from './headerActionsService';
@@ -423,8 +425,25 @@ export interface IDockviewComponent extends IBaseGrid<DockviewGroupPanel> {
423425
setEdgeGroupVisible(position: EdgeGroupPosition, visible: boolean): void;
424426
isEdgeGroupVisible(position: EdgeGroupPosition): boolean;
425427
removeEdgeGroup(position: EdgeGroupPosition): void;
428+
// layout history (undo / redo)
429+
undo(): void;
430+
redo(): void;
431+
readonly canUndo: boolean;
432+
readonly canRedo: boolean;
433+
clearHistory(): void;
434+
readonly onDidChangeHistory: Event<LayoutHistoryChangeEvent>;
435+
readonly popoutRestorationPromise: Promise<void>;
426436
}
427437

438+
/** A never-firing history-change event — the fallback `onDidChangeHistory`
439+
* returns when the LayoutHistory module is absent, so the api event is always
440+
* valid and subscribable. */
441+
const NO_LAYOUT_HISTORY_CHANGES: Event<LayoutHistoryChangeEvent> = () => ({
442+
dispose: () => {
443+
// noop — nothing ever fires
444+
},
445+
});
446+
428447
let _hasWarnedUsingCoreDirectly = false;
429448

430449
/**
@@ -471,7 +490,8 @@ export class DockviewComponent
471490
IHeaderActionsHost,
472491
IAdvancedDnDHost,
473492
ILiveRegionHost,
474-
IAccessibilityHost
493+
IAccessibilityHost,
494+
ILayoutHistoryHost
475495
{
476496
private readonly nextGroupId = sequentialNumberGenerator();
477497
private readonly _deserializer = new DefaultDockviewDeserialzier(this);
@@ -765,6 +785,43 @@ export class DockviewComponent
765785
return this._moduleRegistry.services.headerActionsService;
766786
}
767787

788+
private get _layoutHistoryService() {
789+
// Optional like every other module service; `?.`-guarded so the module
790+
// can be removed from AllModules without crashing the component.
791+
return this._moduleRegistry.services.layoutHistoryService;
792+
}
793+
794+
/** Undo the previous recorded layout mutation (no-op if nothing to undo or
795+
* the LayoutHistory module is absent). Requires `layoutHistory.enabled`. */
796+
undo(): void {
797+
this._layoutHistoryService?.undo();
798+
}
799+
800+
/** Re-apply the next layout mutation (no-op if nothing to redo). */
801+
redo(): void {
802+
this._layoutHistoryService?.redo();
803+
}
804+
805+
get canUndo(): boolean {
806+
return this._layoutHistoryService?.canUndo ?? false;
807+
}
808+
809+
get canRedo(): boolean {
810+
return this._layoutHistoryService?.canRedo ?? false;
811+
}
812+
813+
/** Drop both undo and redo stacks. */
814+
clearHistory(): void {
815+
this._layoutHistoryService?.clear();
816+
}
817+
818+
get onDidChangeHistory(): Event<LayoutHistoryChangeEvent> {
819+
return (
820+
this._layoutHistoryService?.onDidChangeHistory ??
821+
NO_LAYOUT_HISTORY_CHANGES
822+
);
823+
}
824+
768825
isGridEmpty(): boolean {
769826
return this.gridview.length === 0;
770827
}

packages/dockview-core/src/dockview/moduleContracts.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import { PopupService } from './components/popupService';
1717
import { DockviewComponentOptions } from './options';
1818
import {
1919
DockviewLayoutMutationEvent,
20+
DockviewLayoutMutationKind,
21+
DockviewOrigin,
2022
GroupNavigationDirection,
23+
SerializedDockview,
2124
} from './dockviewComponent';
2225
import { DockviewWillDropEvent } from './dockviewGroupPanelModel';
2326
import {
@@ -189,3 +192,56 @@ export interface IAdvancedDnDService extends IDisposable {
189192
position: Position
190193
): IDisposable;
191194
}
195+
196+
// --- LayoutHistory ---
197+
198+
/**
199+
* The narrow surface the layout-history service needs from the host
200+
* (`DockviewComponent`). It reads/writes whole-layout snapshots and listens to
201+
* the mutation-transaction boundary — the only place a *pre-image* can be taken
202+
* before a mutation runs.
203+
*/
204+
export interface ILayoutHistoryHost {
205+
readonly options: DockviewComponentOptions;
206+
toJSON(): SerializedDockview;
207+
fromJSON(
208+
data: SerializedDockview,
209+
options?: { reuseExistingPanels: boolean }
210+
): void;
211+
/** Fires before a structural mutation — used to capture the pre-image. */
212+
readonly onWillMutateLayout: Event<DockviewLayoutMutationEvent>;
213+
/** Fires after a structural mutation — used to capture the post-image. */
214+
readonly onDidMutateLayout: Event<DockviewLayoutMutationEvent>;
215+
/** Coalesced (microtask-buffered) ping after any layout change — the only
216+
* signal for sash resize, which does not go through the mutation boundary. */
217+
readonly onDidLayoutChange: Event<void>;
218+
/** Settles once any in-flight popout-window restoration (from `fromJSON`)
219+
* completes. Popouts re-open asynchronously, so undo/redo holds its guard
220+
* until this resolves. Already-resolved when nothing is restoring. */
221+
readonly popoutRestorationPromise: Promise<void>;
222+
}
223+
224+
/** Entry labels — the mutation kinds plus the synthetic `'resize'` (sash drag,
225+
* which has no mutation-boundary kind of its own). */
226+
export type LayoutHistoryKind = DockviewLayoutMutationKind | 'resize';
227+
228+
export interface LayoutHistoryChangeEvent {
229+
readonly canUndo: boolean;
230+
readonly canRedo: boolean;
231+
readonly undoCount: number;
232+
readonly redoCount: number;
233+
readonly lastEntry?: {
234+
kind: LayoutHistoryKind;
235+
origin: DockviewOrigin;
236+
};
237+
}
238+
239+
export interface ILayoutHistoryService extends IDisposable {
240+
readonly canUndo: boolean;
241+
readonly canRedo: boolean;
242+
readonly onDidChangeHistory: Event<LayoutHistoryChangeEvent>;
243+
undo(): void;
244+
redo(): void;
245+
/** Drop both stacks (e.g. on document switch). */
246+
clear(): void;
247+
}

packages/dockview-core/src/dockview/modules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
IAdvancedDnDService,
2323
IContextMenuService,
2424
IKeyboardDockingService,
25+
ILayoutHistoryService,
2526
ITabGroupChipsService,
2627
} from './moduleContracts';
2728

@@ -38,6 +39,7 @@ export interface ServiceCollection {
3839
liveRegionService?: ILiveRegionService;
3940
accessibilityService?: IAccessibilityService;
4041
keyboardDockingService?: IKeyboardDockingService;
42+
layoutHistoryService?: ILayoutHistoryService;
4143
}
4244

4345
export interface DockviewModule<THost = unknown> {

0 commit comments

Comments
 (0)