Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions __generated__/dockview-core-exports.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions e2e/fixtures/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
const el = document.getElementById('app');
const dockview = new core.DockviewComponent(el, {
keyboardNavigation: true,
// Record layout history so the harness can exercise undo/redo
// (incl. async popout re-open).
layoutHistory: { enabled: true },
// A served (not about:blank) target avoids a 404 when the
// popout window navigates before dockview injects content.
popoutUrl: '/e2e/fixtures/popout.html',
Expand Down Expand Up @@ -93,6 +96,13 @@
popoutActiveGroup: () =>
dockview.addPopoutGroup(dockview.activeGroup),
groupCount: () => dockview.groups.length,
popoutCount: () => dockview.getPopouts().length,
closeActivePopout: () =>
dockview.getPopouts()[0].group.api.close(),
undo: () => dockview.undo(),
redo: () => dockview.redo(),
canUndo: () => dockview.canUndo,
awaitPopoutRestore: () => dockview.popoutRestorationPromise,
};
window.__ready = true;
})();
Expand Down
90 changes: 90 additions & 0 deletions e2e/tests/layout-history.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { test, expect, Page } from '@playwright/test';

/**
* Cross-window layout history (Phase D). Undo/redo restore the whole layout —
* including popout windows, which re-open **asynchronously**. The recorder holds
* its re-entrancy guard across that async re-open (so the re-open doesn't record
* a spurious entry) and exposes `popoutRestorationPromise` to await it. None of
* this is reachable in jsdom (no real second window).
*/
test.describe('cross-window layout history', () => {
const ready = async (page: Page) => {
await page.goto('/e2e/fixtures/index.html');
await page.waitForFunction(() => (window as any).__ready === true);
await page.evaluate(() => {
(window as any).__dv.addPanel('alpha');
(window as any).__dv.addPanel('beta');
});
};

test('undo reverts a popout (group returns to the grid)', async ({
page,
context,
}) => {
await ready(page);

const [win] = await Promise.all([
context.waitForEvent('page'),
page.evaluate(() => (window as any).__dv.popoutActiveGroup()),
]);
await (win as Page).waitForLoadState();
expect(
await page.evaluate(() => (window as any).__dv.popoutCount())
).toBe(1);

// Undo the popout → the window closes and the group goes back.
await Promise.all([
(win as Page).waitForEvent('close'),
page.evaluate(() => (window as any).__dv.undo()),
]);
await expect
.poll(() => page.evaluate(() => (window as any).__dv.popoutCount()))
.toBe(0);
});

test('undo re-opens a closed popout window', async ({ page, context }) => {
await ready(page);

// pop the active group out
const [win1] = await Promise.all([
context.waitForEvent('page'),
page.evaluate(() => (window as any).__dv.popoutActiveGroup()),
]);
await (win1 as Page).waitForLoadState();

// close the popout (records a removal whose pre-image still has the popout)
await Promise.all([
(win1 as Page).waitForEvent('close'),
page.evaluate(() => (window as any).__dv.closeActivePopout()),
]);
expect(
await page.evaluate(() => (window as any).__dv.popoutCount())
).toBe(0);
expect(await page.evaluate(() => (window as any).__dv.canUndo())).toBe(
true
);

// undo → re-opens the popout window asynchronously
const [win2] = await Promise.all([
context.waitForEvent('page'),
page.evaluate(async () => {
(window as any).__dv.undo();
await (window as any).__dv.awaitPopoutRestore();
}),
]);
await (win2 as Page).waitForLoadState();
await expect
.poll(() => page.evaluate(() => (window as any).__dv.popoutCount()))
.toBe(1);

// The re-open must not have left a spurious history entry that a single
// redo can't account for — redo should cleanly close it again.
await Promise.all([
(win2 as Page).waitForEvent('close'),
page.evaluate(() => (window as any).__dv.redo()),
]);
await expect
.poll(() => page.evaluate(() => (window as any).__dv.popoutCount()))
.toBe(0);
});
});
45 changes: 45 additions & 0 deletions packages/dockview-core/src/api/component.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
IDockviewGroupPanel,
} from '../dockview/dockviewGroupPanel';
import { Event } from '../events';
import { LayoutHistoryChangeEvent } from '../dockview/moduleContracts';
import { IDockviewPanel } from '../dockview/dockviewPanel';
import { PaneviewDidDropEvent } from '../paneview/draggablePaneviewPanel';
import {
Expand Down Expand Up @@ -1020,6 +1021,50 @@ export class DockviewApi implements CommonApi<SerializedDockview> {
this.component.withOrigin('api', () => this.component.clear());
}

/**
* Undo the previous recorded layout mutation. No-op when there is nothing
* to undo, when `layoutHistory.enabled` is not set, or when the
* LayoutHistory module is absent.
*/
undo(): void {
this.component.undo();
}

/** Re-apply the next layout mutation undone via {@link undo}. */
redo(): void {
this.component.redo();
}

/** Whether {@link undo} would do something. Reactive via {@link onDidChangeHistory}. */
get canUndo(): boolean {
return this.component.canUndo;
}

/** Whether {@link redo} would do something. */
get canRedo(): boolean {
return this.component.canRedo;
}

/** Drop both undo and redo stacks (e.g. on document switch). */
clearHistory(): void {
this.component.clearHistory();
}

/** Fires whenever the undo/redo stacks change. */
get onDidChangeHistory(): Event<LayoutHistoryChangeEvent> {
return this.component.onDidChangeHistory;
}

/**
* Resolves once any in-flight popout-window restoration completes. Popout
* windows re-open asynchronously, so after an {@link undo} / {@link redo} (or
* {@link fromJSON}) that re-opens a popout, await this to know the window is
* ready. Already-resolved when nothing is restoring.
*/
get popoutRestorationPromise(): Promise<void> {
return this.component.popoutRestorationPromise;
}

/**
* Move the focus progmatically to the next panel or group.
*/
Expand Down
59 changes: 58 additions & 1 deletion packages/dockview-core/src/dockview/dockviewComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ import {
IAdvancedDnDHost,
IContextMenuHost,
IContextMenuService,
ILayoutHistoryHost,
LayoutHistoryChangeEvent,
ITabGroupChipsHost,
} from './moduleContracts';
import { IHeaderActionsHost } from './headerActionsService';
Expand Down Expand Up @@ -423,8 +425,25 @@ export interface IDockviewComponent extends IBaseGrid<DockviewGroupPanel> {
setEdgeGroupVisible(position: EdgeGroupPosition, visible: boolean): void;
isEdgeGroupVisible(position: EdgeGroupPosition): boolean;
removeEdgeGroup(position: EdgeGroupPosition): void;
// layout history (undo / redo)
undo(): void;
redo(): void;
readonly canUndo: boolean;
readonly canRedo: boolean;
clearHistory(): void;
readonly onDidChangeHistory: Event<LayoutHistoryChangeEvent>;
readonly popoutRestorationPromise: Promise<void>;
}

/** A never-firing history-change event — the fallback `onDidChangeHistory`
* returns when the LayoutHistory module is absent, so the api event is always
* valid and subscribable. */
const NO_LAYOUT_HISTORY_CHANGES: Event<LayoutHistoryChangeEvent> = () => ({
dispose: () => {
// noop — nothing ever fires
},
});

let _hasWarnedUsingCoreDirectly = false;

/**
Expand Down Expand Up @@ -471,7 +490,8 @@ export class DockviewComponent
IHeaderActionsHost,
IAdvancedDnDHost,
ILiveRegionHost,
IAccessibilityHost
IAccessibilityHost,
ILayoutHistoryHost
{
private readonly nextGroupId = sequentialNumberGenerator();
private readonly _deserializer = new DefaultDockviewDeserialzier(this);
Expand Down Expand Up @@ -765,6 +785,43 @@ export class DockviewComponent
return this._moduleRegistry.services.headerActionsService;
}

private get _layoutHistoryService() {
// Optional like every other module service; `?.`-guarded so the module
// can be removed from AllModules without crashing the component.
return this._moduleRegistry.services.layoutHistoryService;
}

/** Undo the previous recorded layout mutation (no-op if nothing to undo or
* the LayoutHistory module is absent). Requires `layoutHistory.enabled`. */
undo(): void {
this._layoutHistoryService?.undo();
}

/** Re-apply the next layout mutation (no-op if nothing to redo). */
redo(): void {
this._layoutHistoryService?.redo();
}

get canUndo(): boolean {
return this._layoutHistoryService?.canUndo ?? false;
}

get canRedo(): boolean {
return this._layoutHistoryService?.canRedo ?? false;
}

/** Drop both undo and redo stacks. */
clearHistory(): void {
this._layoutHistoryService?.clear();
}

get onDidChangeHistory(): Event<LayoutHistoryChangeEvent> {
return (
this._layoutHistoryService?.onDidChangeHistory ??
NO_LAYOUT_HISTORY_CHANGES
);
}

isGridEmpty(): boolean {
return this.gridview.length === 0;
}
Expand Down
56 changes: 56 additions & 0 deletions packages/dockview-core/src/dockview/moduleContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import { PopupService } from './components/popupService';
import { DockviewComponentOptions } from './options';
import {
DockviewLayoutMutationEvent,
DockviewLayoutMutationKind,
DockviewOrigin,
GroupNavigationDirection,
SerializedDockview,
} from './dockviewComponent';
import { DockviewWillDropEvent } from './dockviewGroupPanelModel';
import {
Expand Down Expand Up @@ -189,3 +192,56 @@ export interface IAdvancedDnDService extends IDisposable {
position: Position
): IDisposable;
}

// --- LayoutHistory ---

/**
* The narrow surface the layout-history service needs from the host
* (`DockviewComponent`). It reads/writes whole-layout snapshots and listens to
* the mutation-transaction boundary — the only place a *pre-image* can be taken
* before a mutation runs.
*/
export interface ILayoutHistoryHost {
readonly options: DockviewComponentOptions;
toJSON(): SerializedDockview;
fromJSON(
data: SerializedDockview,
options?: { reuseExistingPanels: boolean }
): void;
/** Fires before a structural mutation — used to capture the pre-image. */
readonly onWillMutateLayout: Event<DockviewLayoutMutationEvent>;
/** Fires after a structural mutation — used to capture the post-image. */
readonly onDidMutateLayout: Event<DockviewLayoutMutationEvent>;
/** Coalesced (microtask-buffered) ping after any layout change — the only
* signal for sash resize, which does not go through the mutation boundary. */
readonly onDidLayoutChange: Event<void>;
/** Settles once any in-flight popout-window restoration (from `fromJSON`)
* completes. Popouts re-open asynchronously, so undo/redo holds its guard
* until this resolves. Already-resolved when nothing is restoring. */
readonly popoutRestorationPromise: Promise<void>;
}

/** Entry labels — the mutation kinds plus the synthetic `'resize'` (sash drag,
* which has no mutation-boundary kind of its own). */
export type LayoutHistoryKind = DockviewLayoutMutationKind | 'resize';

export interface LayoutHistoryChangeEvent {
readonly canUndo: boolean;
readonly canRedo: boolean;
readonly undoCount: number;
readonly redoCount: number;
readonly lastEntry?: {
kind: LayoutHistoryKind;
origin: DockviewOrigin;
};
}

export interface ILayoutHistoryService extends IDisposable {
readonly canUndo: boolean;
readonly canRedo: boolean;
readonly onDidChangeHistory: Event<LayoutHistoryChangeEvent>;
undo(): void;
redo(): void;
/** Drop both stacks (e.g. on document switch). */
clear(): void;
}
2 changes: 2 additions & 0 deletions packages/dockview-core/src/dockview/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
IAdvancedDnDService,
IContextMenuService,
IKeyboardDockingService,
ILayoutHistoryService,
ITabGroupChipsService,
} from './moduleContracts';

Expand All @@ -38,6 +39,7 @@ export interface ServiceCollection {
liveRegionService?: ILiveRegionService;
accessibilityService?: IAccessibilityService;
keyboardDockingService?: IKeyboardDockingService;
layoutHistoryService?: ILayoutHistoryService;
}

export interface DockviewModule<THost = unknown> {
Expand Down
Loading
Loading