diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index ce2687fe2..fe4aa9e64 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -5896,6 +5896,415 @@ describe('dockviewComponent', () => { expect(dockview.panels.length).toBe(3); }); + describe('nested floating layout (multi-root)', () => { + const make = () => + new DockviewComponent(document.createElement('div'), { + createComponent(options) { + switch (options.name) { + case 'default': + return new PanelContentPartTest( + options.id, + options.name + ); + default: + throw new Error(`unsupported`); + } + }, + }); + + test('whole-group move splits a floating window rather than spawning a new one', () => { + const dockview = make(); + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + expect(dockview.floatingGroups.length).toBe(1); + + // drag the whole grid group to the right of the floating group + dockview.moveGroupOrPanel({ + from: { groupId: panel1.group.id }, + to: { group: panel2.group, position: 'right' }, + }); + + // still a single floating window, now hosting both groups + expect(dockview.floatingGroups.length).toBe(1); + expect(panel1.group.api.location.type).toBe('floating'); + expect(panel2.group.api.location.type).toBe('floating'); + expect(dockview.getGridviewForGroup(panel1.group)).toBe( + dockview.getGridviewForGroup(panel2.group) + ); + expect(dockview.getGridviewForGroup(panel1.group)).not.toBe( + (dockview as any).gridview + ); + }); + + test('panel split into a floating window creates a new group inside it', () => { + const dockview = make(); + dockview.layout(1000, 500); + + const panel1a = dockview.addPanel({ + id: 'panel_1a', + component: 'default', + }); + dockview.addPanel({ + id: 'panel_1b', + component: 'default', + position: { referencePanel: 'panel_1a' }, + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + // split one panel out of the 2-panel grid group into the float + dockview.moveGroupOrPanel({ + from: { + groupId: panel1a.group.id, + panelId: 'panel_1b', + }, + to: { group: panel2.group, position: 'bottom' }, + }); + + const movedPanel = dockview.panels.find( + (p) => p.id === 'panel_1b' + )!; + expect(movedPanel.group.api.location.type).toBe('floating'); + expect(dockview.getGridviewForGroup(movedPanel.group)).toBe( + dockview.getGridviewForGroup(panel2.group) + ); + expect(dockview.floatingGroups.length).toBe(1); + }); + + test('moving a group out of a multi-group window keeps the window alive', () => { + const dockview = make(); + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + // build a 2-group floating window: split panel3's group in + dockview.moveGroupOrPanel({ + from: { groupId: panel3.group.id }, + to: { group: panel2.group, position: 'right' }, + }); + expect(dockview.floatingGroups.length).toBe(1); + expect(dockview.getGridviewForGroup(panel3.group)).toBe( + dockview.getGridviewForGroup(panel2.group) + ); + + // now move panel3's group back to the main grid (beside panel1) + dockview.moveGroupOrPanel({ + from: { groupId: panel3.group.id }, + to: { group: panel1.group, position: 'right' }, + }); + + expect(panel3.group.api.location.type).toBe('grid'); + expect(panel2.group.api.location.type).toBe('floating'); + // the window survived because panel2's group still lives in it + expect(dockview.floatingGroups.length).toBe(1); + }); + + test('floating-to-floating move merges windows (source window closes)', () => { + const dockview = make(); + dockview.layout(1000, 500); + + dockview.addPanel({ id: 'panel_1', component: 'default' }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + floating: true, + }); + + expect(dockview.floatingGroups.length).toBe(2); + + dockview.moveGroupOrPanel({ + from: { groupId: panel3.group.id }, + to: { group: panel2.group, position: 'bottom' }, + }); + + // panel3's single-group window closed; both groups now share + // panel2's window + expect(dockview.floatingGroups.length).toBe(1); + expect(panel2.group.api.location.type).toBe('floating'); + expect(panel3.group.api.location.type).toBe('floating'); + expect(dockview.getGridviewForGroup(panel3.group)).toBe( + dockview.getGridviewForGroup(panel2.group) + ); + }); + + test('single-group floating window serializes in the legacy shape', () => { + const dockview = make(); + dockview.layout(1000, 500); + + dockview.addPanel({ id: 'panel_1', component: 'default' }); + dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + + const json = dockview.toJSON(); + expect(json.floatingGroups?.length).toBe(1); + expect(json.floatingGroups![0].data).toBeDefined(); + expect(json.floatingGroups![0].grid).toBeUndefined(); + }); + + test('a group can be split into a popout window', async () => { + window.open = () => setupMockWindow(); + const dockview = make(); + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + await dockview.addPopoutGroup(panel1.api.group); + expect(panel1.api.location.type).toBe('popout'); + + // drag panel2's group onto the right edge of the popout group + dockview.moveGroupOrPanel({ + from: { groupId: panel2.group.id }, + to: { group: panel1.group, position: 'right' }, + }); + + expect(panel2.api.location.type).toBe('popout'); + expect(dockview.getGridviewForGroup(panel1.group)).toBe( + dockview.getGridviewForGroup(panel2.group) + ); + expect(dockview.getGridviewForGroup(panel1.group)).not.toBe( + (dockview as any).gridview + ); + + dockview.dispose(); + }); + + test('a multi-group popout window serializes with a nested grid', async () => { + window.open = () => setupMockWindow(); + const dockview = make(); + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + await dockview.addPopoutGroup(panel1.api.group); + dockview.moveGroupOrPanel({ + from: { groupId: panel2.group.id }, + to: { group: panel1.group, position: 'right' }, + }); + + const json = dockview.toJSON(); + expect(json.popoutGroups![0].grid).toBeDefined(); + expect(json.popoutGroups![0].data).toBeUndefined(); + + dockview.dispose(); + }); + + test('multi-group floating window round-trips through toJSON/fromJSON', () => { + const dockview = make(); + dockview.layout(1000, 500); + + dockview.addPanel({ id: 'panel_1', component: 'default' }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + // split panel3's group into the floating window + dockview.moveGroupOrPanel({ + from: { groupId: panel3.group.id }, + to: { group: panel2.group, position: 'right' }, + }); + expect(dockview.floatingGroups.length).toBe(1); + + const json = dockview.toJSON(); + // a multi-group window serializes as a nested grid + expect(json.floatingGroups![0].grid).toBeDefined(); + expect(json.floatingGroups![0].data).toBeUndefined(); + + const restored = make(); + restored.layout(1000, 500); + restored.fromJSON(json); + + expect(restored.panels.length).toBe(3); + expect(restored.floatingGroups.length).toBe(1); + const floatingGroups = restored.groups.filter( + (g) => g.api.location.type === 'floating' + ); + expect(floatingGroups.length).toBe(2); + expect(restored.getGridviewForGroup(floatingGroups[0])).toBe( + restored.getGridviewForGroup(floatingGroups[1]) + ); + expect( + restored.getGridviewForGroup(floatingGroups[0]) + ).not.toBe((restored as any).gridview); + }); + + test('the floating title bar retargets when its anchor group leaves the window', () => { + const dockview = make(); // default dragHandle is 'titlebar' + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + floating: true, + }); + const panel3 = dockview.addPanel({ + id: 'panel_3', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + // build a 2-group floating window; panel2's group is the anchor + dockview.moveGroupOrPanel({ + from: { groupId: panel3.group.id }, + to: { group: panel2.group, position: 'right' }, + }); + + const fg = dockview.floatingGroups[0]; + expect(fg.group).toBe(panel2.group); + + const titlebar = fg.overlay.element.querySelector( + '.dv-floating-titlebar' + ) as HTMLElement; + expect(titlebar).toBeTruthy(); + + // move the original anchor back to the grid; the window survives + // and promotes panel3's group as the new anchor + dockview.moveGroupOrPanel({ + from: { groupId: panel2.group.id }, + to: { group: panel1.group, position: 'right' }, + }); + expect(dockview.floatingGroups.length).toBe(1); + expect(fg.group).toBe(panel3.group); + + // dragging the title bar now targets the promoted anchor, not + // the departed original group + const groupDragEvents: GroupDragEvent[] = []; + dockview.onWillDragGroup((event) => + groupDragEvents.push(event) + ); + + // shift+drag is the title bar's redock gesture; jsdom's + // DragEvent ctor drops shiftKey, so set it explicitly + const event = new Event('dragstart') as DragEvent; + Object.defineProperty(event, 'shiftKey', { value: true }); + fireEvent(titlebar, event); + + expect(groupDragEvents.length).toBe(1); + expect(groupDragEvents[0].group).toBe(panel3.group); + + dockview.dispose(); + }); + + test('restoring a blocked multi-group popout docks every member into the grid', async () => { + jest.useFakeTimers(); + window.open = () => setupMockWindow(); + const dockview = make(); + dockview.layout(1000, 500); + + const panel1 = dockview.addPanel({ + id: 'panel_1', + component: 'default', + }); + const panel2 = dockview.addPanel({ + id: 'panel_2', + component: 'default', + position: { referencePanel: 'panel_1', direction: 'right' }, + }); + + await dockview.addPopoutGroup(panel1.api.group); + // split panel2's group into the popout -> 2-group popout window + dockview.moveGroupOrPanel({ + from: { groupId: panel2.group.id }, + to: { group: panel1.group, position: 'right' }, + }); + + const state = dockview.toJSON(); + expect(state.popoutGroups![0].grid).toBeDefined(); + + // the browser blocks the popout when the layout is restored + (window as any).open = () => null; + + dockview.clear(); + dockview.fromJSON(state); + jest.advanceTimersByTime(500); + await dockview.popoutRestorationPromise; + + // both groups fell back into the grid; nothing is lost or + // orphaned + expect(dockview.panels.map((p) => p.id).sort()).toEqual([ + 'panel_1', + 'panel_2', + ]); + expect( + dockview.groups.filter( + (g) => g.api.location.type === 'popout' + ).length + ).toBe(0); + // both members landed in the main grid (a hidden reference + // ghost group may also linger, as with single-group restore) + expect( + dockview.groups.filter( + (g) => g.api.location.type === 'grid' + ).length + ).toBeGreaterThanOrEqual(2); + + expect(() => dockview.clear()).not.toThrow(); + expect(dockview.groups.length).toBe(0); + + jest.useRealTimers(); + }); + }); + test('move a floating group of many tabs to a new fixed group', () => { const container = document.createElement('div'); @@ -8064,6 +8473,57 @@ describe('dockviewComponent', () => { jest.useRealTimers(); }); + test('restoring popouts while the browser blocks popups falls back to the grid and clears cleanly', async () => { + jest.useFakeTimers(); + window.open = () => setupMockWindow(); + const container = document.createElement('div'); + const dockview = new DockviewComponent(container, { + createComponent(o) { + return new PanelContentPartTest(o.id, o.name); + }, + }); + dockview.layout(1000, 500); + dockview.addPanel({ id: 'p1', component: 'default' }); + const p2 = dockview.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'right' }, + }); + const p3 = dockview.addPanel({ + id: 'p3', + component: 'default', + position: { direction: 'right' }, + }); + await dockview.addPopoutGroup(p2.group); + await dockview.addPopoutGroup(p3.group); + const state = dockview.toJSON(); + + // the browser blocks the popouts when the layout is restored + (window as any).open = () => null; + + dockview.clear(); + dockview.fromJSON(state); + jest.advanceTimersByTime(500); + await dockview.popoutRestorationPromise; + + // blocked popout content is not lost — it falls back into the grid + expect(dockview.panels.map((p) => p.id).sort()).toEqual([ + 'p1', + 'p2', + 'p3', + ]); + expect( + dockview.groups.filter((g) => g.api.location.type === 'popout') + .length + ).toBe(0); + + // and clearing the restored layout doesn't throw on orphaned groups + expect(() => dockview.clear()).not.toThrow(); + expect(dockview.groups.length).toBe(0); + + jest.useRealTimers(); + }); + describe('when browsers block popups', () => { let container: HTMLDivElement; let dockview: DockviewComponent; @@ -8222,6 +8682,37 @@ describe('dockviewComponent', () => { } }); + test('rapid repeated fromJSON with popouts does not throw on orphaned groups', async () => { + jest.useFakeTimers(); + window.open = () => setupMockWindow(); + const container = document.createElement('div'); + const dockview = new DockviewComponent(container, { + createComponent: (o) => new PanelContentPartTest(o.id, o.name), + }); + dockview.layout(1000, 500); + dockview.addPanel({ id: 'p1', component: 'default' }); + const p2 = dockview.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'right' }, + }); + const p3 = dockview.addPanel({ + id: 'p3', + component: 'default', + position: { direction: 'right' }, + }); + await dockview.addPopoutGroup(p2.group); + await dockview.addPopoutGroup(p3.group); + const state = JSON.parse(JSON.stringify(dockview.toJSON())); + + for (let i = 0; i < 6; i++) { + expect(() => dockview.fromJSON(state)).not.toThrow(); + jest.advanceTimersByTime(60); // let some popout timers fire + } + dockview.dispose(); + jest.useRealTimers(); + }); + test('dispose of dockview instance when popup is open', async () => { const container = document.createElement('div'); diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts index 45dc1d00b..fa552e456 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts @@ -2076,7 +2076,11 @@ describe('dockviewGroupPanelModel', () => { test.each([ ['grid', ['top', 'bottom', 'left', 'right', 'center']], - ['popout', ['center']], + // floating + popout host their own nested gridview, so they accept + // all zones (edge drops split the window). Edge groups are + // structural and stay center-only. + ['floating', ['top', 'bottom', 'left', 'right', 'center']], + ['popout', ['top', 'bottom', 'left', 'right', 'center']], ['edge', ['center']], ] as const)( 'location=%s applies zones to BOTH dropTarget and pointerDropTarget', diff --git a/packages/dockview-core/src/dockview/components/panel/content.ts b/packages/dockview-core/src/dockview/components/panel/content.ts index b511d299a..6611b8e5b 100644 --- a/packages/dockview-core/src/dockview/components/panel/content.ts +++ b/packages/dockview-core/src/dockview/components/panel/content.ts @@ -63,7 +63,11 @@ export class ContentContainer this.addDisposables(this._onDidFocus, this._onDidBlur); - const target = group.dropTargetContainer; + // Resolve the override anchor dynamically: a group can be relocated + // between roots (grid / floating / popout) after construction, and the + // popout anchor in particular lives in another window — a value + // captured here would mount overlays in the wrong window. + const getOverrideTarget = () => group.dropTargetContainer?.model; const canDisplayOverlay = ( event: DragEvent | PointerEvent, @@ -105,7 +109,7 @@ export class ContentContainer className: 'dv-drop-target-content', acceptedTargetZones: ['top', 'bottom', 'left', 'right', 'center'], canDisplayOverlay, - getOverrideTarget: target ? () => target.model : undefined, + getOverrideTarget, }); this.pointerDropTarget = pointerBackend.createDropTarget(this.element, { @@ -117,7 +121,7 @@ export class ContentContainer : null; }, className: 'dv-drop-target-content', - getOverrideTarget: target ? () => target.model : undefined, + getOverrideTarget, }); this.addDisposables(this.dropTarget, this.pointerDropTarget); diff --git a/packages/dockview-core/src/dockview/components/titlebar/floatingTitleBar.ts b/packages/dockview-core/src/dockview/components/titlebar/floatingTitleBar.ts index 0341e2ac5..5d7d22250 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/floatingTitleBar.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/floatingTitleBar.ts @@ -20,6 +20,7 @@ import { GroupDragSource } from './groupDragSource'; export class FloatingTitleBar extends CompositeDisposable { private readonly _element: HTMLElement; private readonly dragSource: GroupDragSource; + private _group: DockviewGroupPanel; private readonly _onDragStart = new Emitter(); readonly onDragStart = this._onDragStart.event; @@ -28,19 +29,35 @@ export class FloatingTitleBar extends CompositeDisposable { return this._element; } + /** The window's current anchor group — the one this bar drags/activates. */ + get group(): DockviewGroupPanel { + return this._group; + } + + /** + * Retarget the bar at a new anchor group. Called when the original anchor + * leaves a multi-group floating window and another member is promoted, so + * the bar keeps activating/redocking a group that actually lives here. + */ + setGroup(group: DockviewGroupPanel): void { + this._group = group; + } + constructor( private readonly accessor: DockviewComponent, - private readonly group: DockviewGroupPanel + group: DockviewGroupPanel ) { super(); + this._group = group; + this._element = document.createElement('div'); this._element.className = 'dv-floating-titlebar'; this.addDisposables( this._onDragStart, addDisposableListener(this._element, 'pointerdown', () => { - this.accessor.doSetGroupActive(this.group); + this.accessor.doSetGroupActive(this._group); }), // Shift+pointerdown marks the event so the overlay's // move-the-float drag doesn't fire alongside the HTML5 redock @@ -60,7 +77,8 @@ export class FloatingTitleBar extends CompositeDisposable { this.dragSource = new GroupDragSource({ element: this._element, accessor: this.accessor, - group: this.group, + // resolve lazily so the drag source follows anchor reassignment + group: () => this._group, }); this.addDisposables( diff --git a/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts b/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts index ce05ccdd0..b8ea663a7 100644 --- a/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts +++ b/packages/dockview-core/src/dockview/components/titlebar/groupDragSource.ts @@ -29,7 +29,14 @@ const FLOATING_REDOCK_INITIATION_DELAY_MS = 500; export interface GroupDragSourceOptions { readonly element: HTMLElement; readonly accessor: DockviewComponent; - readonly group: DockviewGroupPanel; + /** + * The group this handle drags. Pass a function when the handle outlives the + * group it represents and can be retargeted — e.g. a floating window's + * dedicated title bar, whose anchor group is reassigned when the original + * anchor leaves a multi-group window. A fixed reference (the tab-bar void + * container, which lives inside its own group's DOM) is also accepted. + */ + readonly group: DockviewGroupPanel | (() => DockviewGroupPanel); /** * Whether this element is the floating window's move handle. Only the move * handle needs the floating disambiguation (shift for mouse / long-press @@ -54,7 +61,7 @@ export interface GroupDragSourceOptions { export class GroupDragSource extends CompositeDisposable { private readonly _element: HTMLElement; private readonly accessor: DockviewComponent; - private readonly group: DockviewGroupPanel; + private readonly groupAccessor: () => DockviewGroupPanel; private readonly html5DragSource: IDragSource; private readonly pointerDragSource: IDragSource; private readonly panelTransfer = @@ -65,12 +72,19 @@ export class GroupDragSource extends CompositeDisposable { private readonly isFloatingMoveHandle: () => boolean; + // Resolved lazily so a retargetable handle (the floating title bar) always + // drags the window's *current* anchor group, not the one captured here. + private get group(): DockviewGroupPanel { + return this.groupAccessor(); + } + constructor(options: GroupDragSourceOptions) { super(); this._element = options.element; this.accessor = options.accessor; - this.group = options.group; + const group = options.group; + this.groupAccessor = typeof group === 'function' ? group : () => group; this.isFloatingMoveHandle = options.isFloatingMoveHandle ?? (() => true); diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index 2d5c91ff6..59c044397 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -4,6 +4,8 @@ import { getGridLocation, ISerializedLeafNode, orthogonal, + Gridview, + SerializedGridview, } from '../gridview/gridview'; import { directionToPosition, Position } from '../dnd/droptarget'; import { tail, sequenceEquals } from '../array'; @@ -35,7 +37,7 @@ import { toTarget, } from '../gridview/baseComponentGridview'; import { DockviewApi } from '../api/component.api'; -import { Orientation } from '../splitview/splitview'; +import { Orientation, Sizing } from '../splitview/splitview'; import { GroupOptions, GroupPanelViewState, @@ -150,18 +152,44 @@ export interface DockviewPopoutGroupOptions { overridePopoutGroup?: DockviewGroupPanel; } +interface DockviewPopoutGroupOptionsInternal extends DockviewPopoutGroupOptions { + /** + * Restore into a pre-built nested gridview (multi-group popout window) + * rather than creating one around a single group. + */ + overridePopoutGridview?: Gridview; +} + export interface PanelReference { update: (event: { params: { [key: string]: any } }) => void; remove: () => void; } export interface SerializedFloatingGroup { - data: GroupPanelViewState; + /** + * Legacy single-group form. Still written when a floating window hosts a + * single group (for stable round-trips) and always accepted on read. + */ + data?: GroupPanelViewState; + /** + * Nested layout of the floating window. Written when the window hosts more + * than one group; mutually exclusive with `data`. + */ + grid?: SerializedGridview; position: AnchoredBox; } export interface SerializedPopoutGroup { - data: GroupPanelViewState; + /** + * Legacy single-group form. Still written when a popout window hosts a + * single group (for stable round-trips) and always accepted on read. + */ + data?: GroupPanelViewState; + /** + * Nested layout of the popout window. Written when the window hosts more + * than one group; mutually exclusive with `data`. + */ + grid?: SerializedGridview; url?: string; gridReferenceGroup?: string; position: Box | null; @@ -887,7 +915,7 @@ export class DockviewComponent addPopoutGroup( itemToPopout: DockviewPanel | DockviewGroupPanel, - options?: DockviewPopoutGroupOptions + options?: DockviewPopoutGroupOptionsInternal ): Promise { const service = assertModule( this._popoutWindowService, @@ -985,7 +1013,11 @@ export class DockviewComponent let group: DockviewGroupPanel; - if (!isGroupAddedToDom) { + if (options?.overridePopoutGridview) { + // Restoring a multi-group window: the anchor group is + // already built inside the supplied gridview. + group = options.overridePopoutGroup ?? referenceGroup; + } else if (!isGroupAddedToDom) { group = referenceGroup; } else if (options?.overridePopoutGroup) { group = options.overridePopoutGroup; @@ -998,26 +1030,12 @@ export class DockviewComponent } if (popoutContainer === null) { - console.error( - 'dockview: failed to create popout. perhaps you need to allow pop-ups for this website' - ); - - popoutWindowDisposable.dispose(); - this._onDidOpenPopoutWindowFail.fire(); - - // if the popout window was blocked, we need to move the group back to the reference group - // and set it to visible - this.movingLock(() => - moveGroupWithoutDestroying({ - from: group, - to: referenceGroup, - }) - ); - - if (!referenceGroup.api.isVisible) { - referenceGroup.api.setVisible(true); - } - + this.handleBlockedPopout({ + group, + referenceGroup, + options, + popoutWindowDisposable, + }); return false; } @@ -1030,14 +1048,45 @@ export class DockviewComponent ); group.model.renderContainer = overlayRenderContainer; - group.layout( + + // The popout window hosts its own gridview so it can grow into + // a nested splitview layout. The window starts with the single + // anchor group; further groups arrive via drag-and-drop. On + // restore a pre-populated gridview is supplied instead. + const popoutGridview = + options?.overridePopoutGridview ?? + this.createNestedGridview(); + if (!options?.overridePopoutGridview) { + popoutGridview.addView(group, Sizing.Distribute, [0]); + } + // Fill the popout window. Unlike the main grid (explicit px) and + // floating windows (CSS inside .dv-resize-container), the popout + // gridview has no sizing context, so without this it collapses + // to 0 height and nothing renders. + popoutGridview.element.style.width = '100%'; + popoutGridview.element.style.height = '100%'; + popoutGridview.layout( _window.window!.innerWidth, _window.window!.innerHeight ); + // Guarded so the teardown's re-entrant paths (window close + // re-enters via the anchor's doRemoveGroup) never double-dispose. + let popoutGridviewDisposed = false; + const disposePopoutGridview = () => { + if (!popoutGridviewDisposed) { + popoutGridviewDisposed = true; + popoutGridview.dispose(); + } + }; + let floatingBox: AnchoredBox | undefined; - if (!options?.overridePopoutGroup && isGroupAddedToDom) { + if ( + !options?.overridePopoutGroup && + !options?.overridePopoutGridview && + isGroupAddedToDom + ) { if (itemToPopout instanceof DockviewPanel) { this.movingLock(() => { const panel = @@ -1077,7 +1126,7 @@ export class DockviewComponent popoutContainer.style.overflow = 'hidden'; popoutContainer.appendChild(gready); - popoutContainer.appendChild(group.element); + popoutContainer.appendChild(popoutGridview.element); const anchor = document.createElement('div'); const dropTargetContainer = new DropTargetAnchorContainer( @@ -1111,6 +1160,27 @@ export class DockviewComponent popoutUrl: options?.popoutUrl, }; + if (options?.overridePopoutGridview) { + // Restored multi-group window. Wire every member (including + // the anchor) to this window's containers and popout + // location now that the gridview is attached and laid out — + // re-setting renderContainer forces a re-render at the right + // time so 'always'-rendered content positions in this + // window rather than where it was first created. + const members = this.groups.filter((candidate) => + popoutGridview.element.contains(candidate.element) + ); + for (const member of members) { + member.model.renderContainer = overlayRenderContainer; + member.model.dropTargetContainer = dropTargetContainer; + member.model.location = { + type: 'popout', + getWindow: () => _window.window!, + popoutUrl: options?.popoutUrl, + }; + } + } + if ( isGroupAddedToDom && itemToPopout.api.location.type === 'grid' @@ -1120,6 +1190,17 @@ export class DockviewComponent this.doSetGroupAndPanelActive(group); + const resizeObserverDisposable = service.observeGridviewSize( + _window, + popoutGridview, + overlayRenderContainer + ); + if (resizeObserverDisposable) { + popoutWindowDisposable.addDisposables( + resizeObserverDisposable + ); + } + popoutWindowDisposable.addDisposables( group.api.onDidActiveChange((event) => { if (event.isActive) { @@ -1131,7 +1212,10 @@ export class DockviewComponent }) ); - let returnedGroup: DockviewGroupPanel | undefined; + // Holder so the close teardown (extracted below) can publish + // the group that was returned to the main grid back to the + // entry's `dispose()` contract. + const closeResult: { returnedGroup?: DockviewGroupPanel } = {}; const isValidReferenceGroup = isGroupAddedToDom && @@ -1141,13 +1225,18 @@ export class DockviewComponent const value = { window: _window, popoutGroup: group, + gridview: popoutGridview, + overlayRenderContainer, + dropTargetContainer, + getWindow: () => _window.window!, + popoutUrl: options?.popoutUrl, referenceGroup: isValidReferenceGroup ? referenceGroup.id : undefined, disposable: { dispose: () => { popoutWindowDisposable.dispose(); - return returnedGroup; + return closeResult.returnedGroup; }, }, }; @@ -1172,85 +1261,24 @@ export class DockviewComponent group, }); }), - /** - * ResizeObserver seems slow here, I do not know why but we don't need it - * since we can reply on the window resize event as we will occupy the full - * window dimensions - */ addDisposableListener(_window.window!, 'resize', () => { - group.layout( + popoutGridview.layout( _window.window!.innerWidth, _window.window!.innerHeight ); }), overlayRenderContainer, - Disposable.from(() => { - if (this.isDisposed) { - return; // cleanup may run after instance is disposed - } - - if ( - isGroupAddedToDom && - this.getPanel(referenceGroup.id) - ) { - this.movingLock(() => - moveGroupWithoutDestroying({ - from: group, - to: referenceGroup, - }) - ); - - if (!referenceGroup.api.isVisible) { - referenceGroup.api.setVisible(true); - } - - if (this.getPanel(group.id)) { - this.doRemoveGroup(group, { - skipPopoutAssociated: true, - }); - } - } else if (this.getPanel(group.id)) { - group.model.renderContainer = - this.overlayRenderContainer; - group.model.dropTargetContainer = - this.rootDropTargetContainer; - returnedGroup = group; - - const alreadyRemoved = !service.findByGroup(group); - - if (alreadyRemoved) { - /** - * If this popout group was explicitly removed then we shouldn't run the additional - * steps. To tell if the running of this disposable is the result of this popout group - * being explicitly removed we can check if this popout group is still tracked by - * the popout window service. - */ - return; - } - - if (floatingBox) { - this.addFloatingGroup(group, { - height: floatingBox.height, - width: floatingBox.width, - position: floatingBox, - }); - } else { - this.doRemoveGroup(group, { - skipDispose: true, - skipActive: true, - skipPopoutReturn: true, - }); - - group.model.location = { type: 'grid' }; - - this.movingLock(() => { - // suppress group add events since the group already exists - this.doAddGroup(group, [0]); - }); - } - this.doSetGroupAndPanelActive(group); - } - }) + Disposable.from(() => + this.disposePopoutWindow({ + group, + referenceGroup, + popoutGridview, + isGroupAddedToDom, + floatingBox, + disposePopoutGridview, + closeResult, + }) + ) ); service.add(value); @@ -1263,6 +1291,218 @@ export class DockviewComponent }); } + /** + * The popout window was blocked (e.g. by the browser's popup blocker — + * common when restoring popouts on load). Fall back gracefully so the + * group(s) end up valid and visible in the main grid rather than as + * orphans that later crash clear()/remove(). + */ + private handleBlockedPopout(params: { + group: DockviewGroupPanel; + referenceGroup: DockviewGroupPanel; + options?: DockviewPopoutGroupOptionsInternal; + popoutWindowDisposable: CompositeDisposable; + }): void { + const { group, referenceGroup, options, popoutWindowDisposable } = + params; + + console.error( + 'dockview: failed to create popout. perhaps you need to allow pop-ups for this website' + ); + + popoutWindowDisposable.dispose(); + this._onDidOpenPopoutWindowFail.fire(); + + if (options?.overridePopoutGridview) { + // Restoring a multi-group popout window: its nested gridview was + // built up-front but never attached to a window. Dock every member + // into the main grid so no group is lost, then discard the + // detached gridview. + const blockedGridview = options.overridePopoutGridview; + const members = this.groups.filter((candidate) => + blockedGridview.element.contains(candidate.element) + ); + for (const member of members) { + this.movingLock(() => { + blockedGridview.remove(member); + this.redockGroupToMainGrid(member); + }); + } + blockedGridview.dispose(); + + if (referenceGroup && !referenceGroup.api.isVisible) { + referenceGroup.api.setVisible(true); + } + + return; + } + + if (group === referenceGroup) { + // No separate grid group to return to (e.g. restoring a popout + // straight from JSON) — dock this group into the main grid. + if (!this.gridview.element.contains(group.element)) { + this.movingLock(() => this.doAddGroup(group, [0])); + group.model.location = { type: 'grid' }; + } + } else { + // A fresh group was created for the popout — return its panels to + // the reference group and discard the now-empty popout group so it + // doesn't linger as an orphan. + this.movingLock(() => + moveGroupWithoutDestroying({ + from: group, + to: referenceGroup, + }) + ); + + if (group.model.size === 0 && this._groups.has(group.id)) { + group.dispose(); + this._groups.delete(group.id); + this._onDidRemoveGroup.fire(group); + } + } + + if (!referenceGroup.api.isVisible) { + referenceGroup.api.setVisible(true); + } + } + + /** + * Wire a group that has been displaced from a floating / popout window back + * to the main grid's render & drop-target containers and dock it at the + * root. The caller is responsible for first detaching it from its old + * gridview — the detach strategy differs between the window-teardown path + * (`doRemoveGroup`) and the blocked-window path (`gridview.remove`). + */ + private redockGroupToMainGrid(group: DockviewGroupPanel): void { + group.model.renderContainer = this.overlayRenderContainer; + group.model.dropTargetContainer = this.rootDropTargetContainer; + group.model.location = { type: 'grid' }; + this.doAddGroup(group, [0]); + } + + /** + * Teardown for a popout window's `popoutWindowDisposable`. Runs when the + * window closes (by user, by `removeGroup`, or by component disposal): + * relocates every member group back to the main grid (or to a floating + * window when the anchor came from one), then disposes the nested gridview. + * `closeResult.returnedGroup` is read by the entry's `dispose()` contract. + */ + private disposePopoutWindow(params: { + group: DockviewGroupPanel; + referenceGroup: DockviewGroupPanel; + popoutGridview: Gridview; + isGroupAddedToDom: boolean; + floatingBox: AnchoredBox | undefined; + disposePopoutGridview: () => void; + closeResult: { returnedGroup?: DockviewGroupPanel }; + }): void { + const { + group, + referenceGroup, + popoutGridview, + isGroupAddedToDom, + floatingBox, + disposePopoutGridview, + closeResult, + } = params; + + if (this.isDisposed) { + // cleanup may run after instance is disposed; just tear down the + // nested gridview. + disposePopoutGridview(); + return; + } + + // A popout window may host several groups. On a genuine window close + // (the entry is still tracked), relocate every non-anchor member back + // to the main grid before the anchor logic below handles `group`. + // Explicit removal paths remove the entry first, so this is skipped for + // them. + if (this._popoutWindowService?.findByGroup(group)) { + const extraMembers = this.groups.filter( + (candidate) => + candidate !== group && + popoutGridview.element.contains(candidate.element) + ); + + for (const member of extraMembers) { + this.movingLock(() => { + this.doRemoveGroup(member, { + skipDispose: true, + skipActive: true, + skipPopoutReturn: true, + }); + this.redockGroupToMainGrid(member); + }); + } + } + + if (isGroupAddedToDom && this.getPanel(referenceGroup.id)) { + this.movingLock(() => + moveGroupWithoutDestroying({ + from: group, + to: referenceGroup, + }) + ); + + if (!referenceGroup.api.isVisible) { + referenceGroup.api.setVisible(true); + } + + if (this.getPanel(group.id)) { + this.doRemoveGroup(group, { + skipPopoutAssociated: true, + }); + } + } else if (this.getPanel(group.id)) { + group.model.renderContainer = this.overlayRenderContainer; + group.model.dropTargetContainer = this.rootDropTargetContainer; + closeResult.returnedGroup = group; + + const alreadyRemoved = + !this._popoutWindowService?.findByGroup(group); + + if (alreadyRemoved) { + /** + * If this popout group was explicitly removed then we shouldn't run the additional + * steps. To tell if the running of this disposable is the result of this popout group + * being explicitly removed we can check if this popout group is still tracked by + * the popout window service. + */ + disposePopoutGridview(); + return; + } + + if (floatingBox) { + this.addFloatingGroup(group, { + height: floatingBox.height, + width: floatingBox.width, + position: floatingBox, + }); + } else { + this.doRemoveGroup(group, { + skipDispose: true, + skipActive: true, + skipPopoutReturn: true, + }); + + group.model.location = { type: 'grid' }; + + this.movingLock(() => { + // suppress group add events since the group already exists + this.doAddGroup(group, [0]); + }); + } + this.doSetGroupAndPanelActive(group); + } + + // All members have been relocated out; tear down the window's nested + // gridview (does not dispose the leaf views — their lifecycle stays + // with `_groups`). + disposePopoutGridview(); + } + addFloatingGroup( item: DockviewPanel | DockviewGroupPanel, options?: FloatingGroupOptionsInternal @@ -1393,22 +1633,84 @@ export class DockviewComponent const anchoredBox = getAnchoredBox(); + // The floating window hosts its own gridview so it can grow into a + // nested splitview layout. The window starts with the single anchor + // group; further groups are added via drag-and-drop. + const floatingGridview = this.createNestedGridview(); + floatingGridview.addView(group, Sizing.Distribute, [0]); + + this.mountFloatingWindow( + floatingGridview, + group, + [group], + anchoredBox, + { + dragHandle: options?.dragHandle, + inDragMode: options?.inDragMode, + skipActiveGroup: options?.skipActiveGroup, + } + ); + } + + /** + * Build an empty gridview configured to match the main grid's styling, for + * hosting a nested layout inside a floating or popout window. + */ + private createNestedGridview( + orientation: Orientation = Orientation.HORIZONTAL + ): Gridview { + return new Gridview( + true, + this.options.hideBorders + ? { separatorBorder: 'transparent' } + : undefined, + orientation, + false, + this.options.theme?.gap ?? 0 + ); + } + + /** + * Wrap a (populated) floating gridview in an overlay window: title bar / + * move handle, drag wiring, the floating-group service entry and the + * `floating` location tag for every member group. + */ + private mountFloatingWindow( + floatingGridview: Gridview, + anchorGroup: DockviewGroupPanel, + members: DockviewGroupPanel[], + anchoredBox: AnchoredBox, + options?: { + dragHandle?: 'titlebar' | 'tabbar'; + inDragMode?: boolean; + skipActiveGroup?: boolean; + } + ): void { + const service = assertModule( + this._floatingGroupService, + 'FloatingGroup', + 'api.addFloatingGroup' + ); + if (!service) { + return; + } + const dragHandleMode = options?.dragHandle ?? this.options.floatingGroupDragHandle ?? 'titlebar'; - // `'titlebar'` renders a dedicated grab bar above the group's tab bar - // and uses it as the move handle; `'tabbar'` falls back to the legacy - // behaviour of moving via the tab-bar void container. + // `'titlebar'` renders a dedicated grab bar above the tab bar and uses + // it as the move handle; `'tabbar'` falls back to moving via the + // tab-bar void container. const titleBar = dragHandleMode === 'titlebar' - ? new FloatingTitleBar(this, group) + ? new FloatingTitleBar(this, anchorGroup) : undefined; const overlay = new Overlay({ container: this._floatingOverlayHost ?? this.gridview.element, - content: group.element, + content: floatingGridview.element, header: titleBar?.element, ...anchoredBox, minimumInViewportWidth: @@ -1427,7 +1729,7 @@ export class DockviewComponent const dragHandle = titleBar?.element ?? - group.element.querySelector('.dv-void-container'); + anchorGroup.element.querySelector('.dv-void-container'); if (!dragHandle) { throw new Error('dockview: failed to find drag handle'); @@ -1440,27 +1742,39 @@ export class DockviewComponent : false, }); - const floatingGroupPanel = service.add(group, overlay); + const floatingGroupPanel = service.add( + anchorGroup, + overlay, + floatingGridview + ); if (titleBar) { - // Tie the title bar's lifetime to the floating group and surface + // Tie the title bar's lifetime to the floating window and surface // its redock drag through the same public `onWillDragGroup` event - // the tab-bar handle uses. + // the tab-bar handle uses. Register it so anchor reassignment (when + // the original anchor leaves a multi-group window) retargets the + // bar at a group that still lives here. + floatingGroupPanel.setTitleBar(titleBar); floatingGroupPanel.addDisposables( titleBar, + Disposable.from(() => + floatingGroupPanel.setTitleBar(undefined) + ), titleBar.onDragStart((event) => { this._onWillDragGroup.fire({ nativeEvent: event, - group, + group: floatingGroupPanel.group, }); }) ); } - group.model.location = { type: 'floating' }; + for (const member of members) { + member.model.location = { type: 'floating' }; + } if (!options?.skipActiveGroup) { - this.doSetGroupAndPanelActive(group); + this.doSetGroupAndPanelActive(anchorGroup); } } @@ -2012,163 +2326,20 @@ export class DockviewComponent this._layoutFromShell(width, height); - const edgeService = data.edgeGroups - ? assertModule( - this._edgeGroupService, - 'EdgeGroup', - 'fromJSON edge restoration' - ) - : this._edgeGroupService; - - if (data.edgeGroups && edgeService) { - // Auto-create edge groups for positions in the serialized state - // that don't already have a group registered (e.g. when fromJSON - // is called before the user has called addEdgeGroup). - for (const _position of [ - 'top', - 'bottom', - 'left', - 'right', - ] as EdgeGroupPosition[]) { - const fixedData = data.edgeGroups[_position]; - if (fixedData && !edgeService.has(_position)) { - const groupState = fixedData.group as - | GroupPanelViewState - | undefined; - const id = groupState?.id ?? `${_position}-group`; - this.addEdgeGroup(_position, { id }); - } - } - - // Restore panel contents of edge groups - for (const [position, edgeGroup] of edgeService.entries()) { - const edgeData = data.edgeGroups[position]; - const groupState = edgeData?.group as - | GroupPanelViewState - | undefined; - if (groupState) { - const { views, activeView } = groupState; - const createdPanels: IDockviewPanel[] = []; - - for (const panelId of views) { - if (panels[panelId]) { - const panel = this._deserializer.fromJSON( - panels[panelId], - edgeGroup - ); - createdPanels.push(panel); - } - } - - for (let i = 0; i < createdPanels.length; i++) { - const panel = createdPanels[i]; - const isActive = activeView === panel.id; - edgeGroup.model.openPanel(panel, { - skipSetActive: !isActive, - skipSetGroupActive: true, - }); - } - - // Restore tab groups before activating a fallback panel - if ( - groupState.tabGroups && - groupState.tabGroups.length > 0 - ) { - edgeGroup.model.restoreTabGroups( - groupState.tabGroups - ); - } - - if ( - !edgeGroup.activePanel && - edgeGroup.panels.length > 0 - ) { - edgeGroup.model.openPanel( - edgeGroup.panels[edgeGroup.panels.length - 1], - { skipSetGroupActive: true } - ); - } - } - } - - this._shellManager!.fromJSON(data.edgeGroups); + if (data.edgeGroups) { + this.deserializeEdgeGroups(data.edgeGroups, panels); } - const serializedFloatingGroups = data.floatingGroups ?? []; - - for (const serializedFloatingGroup of serializedFloatingGroups) { - const { data, position } = serializedFloatingGroup; - - const group = createGroupFromSerializedState(data); - - this.addFloatingGroup(group, { - position: position, - width: position.width, - height: position.height, - skipRemoveGroup: true, - inDragMode: false, - }); - } + this.deserializeFloatingWindows( + data.floatingGroups ?? [], + createGroupFromSerializedState + ); - const serializedPopoutGroups = data.popoutGroups ?? []; - - const popoutService = - serializedPopoutGroups.length > 0 - ? assertModule( - this._popoutWindowService, - 'PopoutWindow', - 'fromJSON popout restoration' - ) - : this._popoutWindowService; - - // Queue popup group creation with delays to avoid browser blocking - const popoutPromises = popoutService - ? serializedPopoutGroups.map((serializedPopoutGroup, index) => { - const { data, position, gridReferenceGroup, url } = - serializedPopoutGroup; - - const group = createGroupFromSerializedState(data); - - return popoutService.scheduleRestoration( - index * DESERIALIZATION_POPOUT_DELAY_MS, - () => { - this.addPopoutGroup(group, { - position: position ?? undefined, - overridePopoutGroup: gridReferenceGroup - ? group - : undefined, - referenceGroup: gridReferenceGroup - ? this.getPanel(gridReferenceGroup) - : undefined, - popoutUrl: url, - }); - }, - () => { - // The group was registered in _groups synchronously - // but the timer that would parent it into the popout - // window never ran. Dispose the orphan here so the - // next clear() doesn't trip over an unparented - // element. See issue #1304. - if ( - !this.isDisposed && - this._groups.has(group.id) && - group.element.parentElement === null - ) { - for (const panel of [...group.panels]) { - this.removePanel(panel, { - removeEmptyGroup: false, - }); - } - group.dispose(); - this._groups.delete(group.id); - this._onDidRemoveGroup.fire(group); - } - } - ); - }) - : []; - - popoutService?.finishRestoration(popoutPromises); + const popoutPromises = this.deserializePopoutWindows( + data.popoutGroups ?? [], + createGroupFromSerializedState + ); + this._popoutWindowService?.finishRestoration(popoutPromises); this._floatingGroupService?.constrainBounds(); @@ -2225,6 +2396,222 @@ export class DockviewComponent this._onDidLayoutFromJSON.fire(); } + /** + * Rebuild a floating / popout window's nested gridview from its serialized + * tree, collecting the member groups (in deserialization order) so the + * caller can mount or restore the window. + */ + private deserializeNestedGridview( + grid: SerializedGridview, + createGroup: (state: GroupPanelViewState) => DockviewGroupPanel + ): { gridview: Gridview; members: DockviewGroupPanel[] } { + const gridview = this.createNestedGridview(grid.orientation); + const members: DockviewGroupPanel[] = []; + gridview.deserialize(grid, { + fromJSON: (node: ISerializedLeafNode) => { + const group = createGroup(node.data); + members.push(group); + return group; + }, + }); + return { gridview, members }; + } + + private deserializeEdgeGroups( + edgeGroups: SerializedEdgeGroups, + panels: Record + ): void { + const edgeService = assertModule( + this._edgeGroupService, + 'EdgeGroup', + 'fromJSON edge restoration' + ); + if (!edgeService) { + return; + } + + // Auto-create edge groups for positions in the serialized state that + // don't already have a group registered (e.g. when fromJSON is called + // before the user has called addEdgeGroup). + for (const _position of [ + 'top', + 'bottom', + 'left', + 'right', + ] as EdgeGroupPosition[]) { + const fixedData = edgeGroups[_position]; + if (fixedData && !edgeService.has(_position)) { + const groupState = fixedData.group as + | GroupPanelViewState + | undefined; + const id = groupState?.id ?? `${_position}-group`; + this.addEdgeGroup(_position, { id }); + } + } + + // Restore panel contents of edge groups + for (const [position, edgeGroup] of edgeService.entries()) { + const edgeData = edgeGroups[position]; + const groupState = edgeData?.group as + | GroupPanelViewState + | undefined; + if (groupState) { + const { views, activeView } = groupState; + const createdPanels: IDockviewPanel[] = []; + + for (const panelId of views) { + if (panels[panelId]) { + const panel = this._deserializer.fromJSON( + panels[panelId], + edgeGroup + ); + createdPanels.push(panel); + } + } + + for (let i = 0; i < createdPanels.length; i++) { + const panel = createdPanels[i]; + const isActive = activeView === panel.id; + edgeGroup.model.openPanel(panel, { + skipSetActive: !isActive, + skipSetGroupActive: true, + }); + } + + // Restore tab groups before activating a fallback panel + if (groupState.tabGroups && groupState.tabGroups.length > 0) { + edgeGroup.model.restoreTabGroups(groupState.tabGroups); + } + + if (!edgeGroup.activePanel && edgeGroup.panels.length > 0) { + edgeGroup.model.openPanel( + edgeGroup.panels[edgeGroup.panels.length - 1], + { skipSetGroupActive: true } + ); + } + } + } + + this._shellManager!.fromJSON(edgeGroups); + } + + private deserializeFloatingWindows( + serialized: SerializedFloatingGroup[], + createGroup: (state: GroupPanelViewState) => DockviewGroupPanel + ): void { + for (const serializedFloatingGroup of serialized) { + const { data, grid, position } = serializedFloatingGroup; + + if (grid) { + // Multi-group window: rebuild the window's nested gridview from + // its serialized tree. + const { gridview: floatingGridview, members } = + this.deserializeNestedGridview(grid, createGroup); + + if (members.length === 0) { + continue; + } + + this.mountFloatingWindow( + floatingGridview, + members[0], + members, + position, + { inDragMode: false } + ); + } else if (data) { + const group = createGroup(data); + + this.addFloatingGroup(group, { + position: position, + width: position.width, + height: position.height, + skipRemoveGroup: true, + inDragMode: false, + }); + } + } + } + + private deserializePopoutWindows( + serialized: SerializedPopoutGroup[], + createGroup: (state: GroupPanelViewState) => DockviewGroupPanel + ): Promise[] { + const popoutService = + serialized.length > 0 + ? assertModule( + this._popoutWindowService, + 'PopoutWindow', + 'fromJSON popout restoration' + ) + : this._popoutWindowService; + + if (!popoutService) { + return []; + } + + // Queue popup group creation with delays to avoid browser blocking + return serialized.map((serializedPopoutGroup, index) => { + const { data, grid, position, gridReferenceGroup, url } = + serializedPopoutGroup; + + // Multi-group popout windows rebuild their nested gridview from the + // serialized tree; single-group windows use the legacy single-group + // path. + let overridePopoutGridview: Gridview | undefined; + let members: DockviewGroupPanel[] = []; + + if (grid) { + const built = this.deserializeNestedGridview(grid, createGroup); + overridePopoutGridview = built.gridview; + members = built.members; + } + + const group = grid ? members[0] : createGroup(data!); + + return popoutService.scheduleRestoration( + index * DESERIALIZATION_POPOUT_DELAY_MS, + () => { + this.addPopoutGroup(group, { + position: position ?? undefined, + overridePopoutGroup: gridReferenceGroup + ? group + : undefined, + overridePopoutGridview, + referenceGroup: gridReferenceGroup + ? this.getPanel(gridReferenceGroup) + : undefined, + popoutUrl: url, + }); + }, + () => { + // The group was registered in _groups synchronously but the + // timer that would parent it into the popout window never + // ran. Dispose the orphan here so the next clear() doesn't + // trip over an unparented element. See issue #1304. + for (const orphan of members.length > 0 + ? members + : [group]) { + if ( + !this.isDisposed && + this._groups.has(orphan.id) && + orphan.element.parentElement === null + ) { + for (const panel of [...orphan.panels]) { + this.removePanel(panel, { + removeEmptyGroup: false, + }); + } + orphan.dispose(); + this._groups.delete(orphan.id); + this._onDidRemoveGroup.fire(orphan); + } + } + } + ); + }); + } + clear(): void { const groups = Array.from(this._groups.values()).map((_) => _.value); @@ -2594,6 +2981,74 @@ export class DockviewComponent this.doRemoveGroup(group, options); } + /** + * Detach a single group from the nested gridview of its floating / popout + * window, keeping the window and its remaining members alive, and reassign + * the window's anchor if the detached group was it. + * + * @returns `true` if the group was detached from a multi-member window; + * `false` if `group` is not in a nested window, or is the window's only + * member — in which case the caller is responsible for disposing the whole + * window. + */ + private detachFromNestedWindow(group: DockviewGroupPanel): boolean { + const floating = this._floatingGroupService?.findByGroup(group); + if (floating) { + const members = this.nestedWindowMembers(group); + if (members.length <= 1) { + return false; + } + floating.gridview.remove(group); + if (floating.group === group) { + // The anchor left; promote a remaining member. + floating.setAnchorGroup(members.find((m) => m !== group)!); + } + return true; + } + + const popout = this._popoutWindowService?.findByGroup(group); + if (popout) { + const members = this.nestedWindowMembers(group); + if (members.length <= 1) { + return false; + } + popout.gridview.remove(group); + if (popout.popoutGroup === group) { + // The anchor left; promote a remaining member. + popout.popoutGroup = members.find((m) => m !== group)!; + } + return true; + } + + return false; + } + + /** + * Dispose a group and forget it: remove it from `_groups` and fire the + * removed event. + */ + private disposeGroupRecord(group: DockviewGroupPanel): void { + group.dispose(); + this._groups.delete(group.id); + this._onDidRemoveGroup.fire(group); + } + + /** + * When `removed` was the active group, fall the active selection back to + * the first remaining group (or clear it when none remain). + */ + private activateFallbackGroupIfRemoved( + removed: DockviewGroupPanel, + skipActive?: boolean + ): void { + if (!skipActive && this._activeGroup === removed) { + const groups = Array.from(this._groups.values()); + this.doSetGroupAndPanelActive( + groups.length > 0 ? groups[0].value : undefined + ); + } + } + protected override doRemoveGroup( group: DockviewGroupPanel, options?: @@ -2627,71 +3082,104 @@ export class DockviewComponent const floatingGroup = this._floatingGroupService?.findByGroup(group); - if (floatingGroup) { + if (!floatingGroup) { + throw new Error('dockview: failed to find floating group'); + } + + if (this.detachFromNestedWindow(group)) { + // The floating window hosts other groups and stays alive — + // finalize just this group. if (!options?.skipDispose) { - floatingGroup.group.dispose(); - this._groups.delete(group.id); - this._onDidRemoveGroup.fire(group); + this.disposeGroupRecord(group); + } else { + // Relocation: reset location so the destination root can + // re-tag it. + group.model.location = { type: 'grid' }; } - // floatingGroup.dispose() removes itself from the service array - floatingGroup.dispose(); - - if (!options?.skipActive && this._activeGroup === group) { - const groups = Array.from(this._groups.values()); - - this.doSetGroupAndPanelActive( - groups.length > 0 ? groups[0].value : undefined - ); - } + this.activateFallbackGroupIfRemoved(group, options?.skipActive); + return group; + } - return floatingGroup.group; + // Last group leaving — dispose the whole floating window. + if (!options?.skipDispose) { + this.disposeGroupRecord(group); } - throw new Error('dockview: failed to find floating group'); + // floatingGroup.dispose() removes itself from the service array + floatingGroup.dispose(); + + this.activateFallbackGroupIfRemoved(group, options?.skipActive); + return group; } if (group.api.location.type === 'popout') { const selectedGroup = this._popoutWindowService?.findByGroup(group); - if (selectedGroup) { + if (!selectedGroup) { + throw new Error('dockview: failed to find popout group'); + } + + if (this.detachFromNestedWindow(group)) { + // The popout window hosts other groups and stays alive — + // finalize just this group. if (!options?.skipDispose) { - if (!options?.skipPopoutAssociated) { - const refGroup = selectedGroup.referenceGroup - ? this.getPanel(selectedGroup.referenceGroup) - : undefined; - if (refGroup && refGroup.panels.length === 0) { - this.removeGroup(refGroup); - } - } + this.disposeGroupRecord(group); + } else { + // Relocation: reset location so the destination root can + // re-tag it. + group.model.location = { type: 'grid' }; + } - selectedGroup.popoutGroup.dispose(); + this.activateFallbackGroupIfRemoved(group, options?.skipActive); + return group; + } - this._groups.delete(group.id); - this._onDidRemoveGroup.fire(group); + // Last group leaving — tear the whole popout window down. + if (!options?.skipDispose) { + if (!options?.skipPopoutAssociated) { + const refGroup = selectedGroup.referenceGroup + ? this.getPanel(selectedGroup.referenceGroup) + : undefined; + if (refGroup && refGroup.panels.length === 0) { + this.removeGroup(refGroup); + } } - this._popoutWindowService?.remove(selectedGroup); + selectedGroup.popoutGroup.dispose(); - const removedGroup = selectedGroup.disposable.dispose(); + this._groups.delete(group.id); + this._onDidRemoveGroup.fire(group); + } - if (!options?.skipPopoutReturn && removedGroup) { - this.doAddGroup(removedGroup, [0]); - this.doSetGroupAndPanelActive(removedGroup); - } + this._popoutWindowService?.remove(selectedGroup); - if (!options?.skipActive && this._activeGroup === group) { - const groups = Array.from(this._groups.values()); + const removedGroup = selectedGroup.disposable.dispose(); - this.doSetGroupAndPanelActive( - groups.length > 0 ? groups[0].value : undefined - ); - } + if (!options?.skipPopoutReturn && removedGroup) { + this.doAddGroup(removedGroup, [0]); + this.doSetGroupAndPanelActive(removedGroup); + } - return selectedGroup.popoutGroup; + this.activateFallbackGroupIfRemoved(group, options?.skipActive); + return selectedGroup.popoutGroup; + } + + // A `grid`-location group whose element isn't actually in the gridview + // is an orphan — e.g. a popout-destined group created during fromJSON + // whose window hasn't opened yet, swept up by clear()/a re-entrant + // fromJSON. `gridview.remove()` would throw "Invalid grid element", so + // dispose it directly. + if (!this.gridview.element.contains(group.element)) { + if (!options?.skipDispose) { + const item = this._groups.get(group.id); + item?.disposable.dispose(); + this.disposeGroupRecord(group); } - throw new Error('dockview: failed to find popout group'); + this.activateFallbackGroupIfRemoved(group, options?.skipActive); + + return group; } const re = super.doRemoveGroup(group, options); @@ -2716,6 +3204,13 @@ export class DockviewComponent this._updatePositionsFrameId = undefined; this.overlayRenderContainer.updateAllPositions(); + + // Popout windows have their own render containers; reposition those + // too so panels moved/split within a popout are laid out (the main + // container only covers grid + floating, which share it). + for (const entry of this._popoutWindowService?.entries ?? []) { + entry.overlayRenderContainer.updateAllPositions(); + } }); } @@ -2825,9 +3320,15 @@ export class DockviewComponent * into an adjacent group */ + // The destination group may live in the main grid or in a floating + // window's nested gridview — resolve which root we are dropping + // into so locations/orientation are computed against it. + const destinationGridview = + this.getGridviewForGroup(destinationGroup); + const referenceLocation = getGridLocation(destinationGroup.element); const targetLocation = getRelativeLocation( - this.gridview.orientation, + destinationGridview.orientation, referenceLocation, destinationTarget ); @@ -2840,7 +3341,10 @@ export class DockviewComponent const [targetParentLocation, to] = tail(targetLocation); - if (sourceGroup.api.location.type === 'grid') { + if ( + sourceGroup.api.location.type === 'grid' && + destinationGridview === this.gridview + ) { const sourceLocation = getGridLocation(sourceGroup.element); const [sourceParentLocation, from] = tail(sourceLocation); @@ -2864,9 +3368,13 @@ export class DockviewComponent } } - if (sourceGroup.api.location.type === 'popout') { + if ( + sourceGroup.api.location.type === 'popout' && + this.nestedWindowMembers(sourceGroup).length <= 1 + ) { /** - * the source group is a popout group with a single panel + * the source group is the only group in a popout window and + * has a single panel * * 1. remove the panel from the group without triggering any events * 2. remove the popout group — this may cascade-remove the empty @@ -2874,6 +3382,9 @@ export class DockviewComponent * doRemoveGroup for popout groups), which can shift grid indices * 3. recompute the target location now that the grid is stable * 4. create a new group at the recomputed location and add that panel + * + * Multi-group popout windows fall through to the generic + * detach-and-re-add path so the window stays alive. */ const popoutGroup = @@ -2896,13 +3407,16 @@ export class DockviewComponent this.doRemoveGroup(sourceGroup, { skipActive: true }); const updatedTargetLocation = getRelativeLocation( - this.gridview.orientation, + destinationGridview.orientation, getGridLocation(destinationGroup.element), destinationTarget ); const newGroup = this.createGroupAtLocation( - updatedTargetLocation + updatedTargetLocation, + undefined, + undefined, + destinationGridview ); this.movingLock(() => newGroup.model.openPanel(removedPanel, { @@ -2938,7 +3452,12 @@ export class DockviewComponent ); } - const newGroup = this.createGroupAtLocation(targetLocation); + const newGroup = this.createGroupAtLocation( + targetLocation, + undefined, + undefined, + destinationGridview + ); this.movingLock(() => newGroup.model.openPanel(removedPanel, { skipSetGroupActive: true, @@ -2967,11 +3486,19 @@ export class DockviewComponent ); const location = getRelativeLocation( - this.gridview.orientation, + destinationGridview.orientation, updatedReferenceLocation, destinationTarget ); - this.movingLock(() => this.doAddGroup(targetGroup, location)); + this.movingLock(() => + this.doAddGroup( + targetGroup, + location, + undefined, + destinationGridview + ) + ); + this.setGroupLocationForRoot(targetGroup, destinationGridview); this.doSetGroupAndPanelActive(targetGroup); this._onDidMovePanel.fire({ @@ -2998,12 +3525,17 @@ export class DockviewComponent } const dropLocation = getRelativeLocation( - this.gridview.orientation, + destinationGridview.orientation, referenceLocation, destinationTarget ); - const group = this.createGroupAtLocation(dropLocation); + const group = this.createGroupAtLocation( + dropLocation, + undefined, + undefined, + destinationGridview + ); this.movingLock(() => group.model.openPanel(removedPanel, { skipSetGroupActive: true, @@ -3280,7 +3812,14 @@ export class DockviewComponent 'dockview: failed to find floating group' ); } - selectedFloatingGroup.dispose(); + + // Detach just this group from the floating window's + // nested gridview, keeping the window (and its other + // groups) alive. If it was the only member, dispose the + // whole window. + if (!this.detachFromNestedWindow(from)) { + selectedFloatingGroup.dispose(); + } break; } case 'popout': { @@ -3292,7 +3831,16 @@ export class DockviewComponent ); } - // Remove from popout groups list to prevent automatic restoration + // Detach just this group from the popout window's + // nested gridview, keeping the window + its other groups + // alive. Destination containers/location are applied by + // the placement block below. + if (this.detachFromNestedWindow(from)) { + break; + } + + // Last group leaving — tear the window down. Remove from + // the service first to prevent automatic restoration. this._popoutWindowService?.remove(selectedPopoutGroup); // Clean up the reference group (ghost) if it exists and is hidden @@ -3310,43 +3858,35 @@ export class DockviewComponent } } - // Manually dispose the window without triggering restoration + // Dispose the window without triggering restoration. The + // placement block below applies the destination + // location and containers to `from`. selectedPopoutGroup.window.dispose(); - // Update group's location and containers for target - if (to.api.location.type === 'grid') { - from.model.renderContainer = - this.overlayRenderContainer; - from.model.dropTargetContainer = - this.rootDropTargetContainer; - from.model.location = { type: 'grid' }; - } else if (to.api.location.type === 'floating') { - from.model.renderContainer = - this.overlayRenderContainer; - from.model.dropTargetContainer = - this.rootDropTargetContainer; - from.model.location = { type: 'floating' }; - } - break; } } } - // For moves to grid locations - if (to.api.location.type === 'grid') { + // Place `source` next to `to`, in whichever gridview root `to` + // lives in. When `to` is inside a floating / popout window this + // splits that window's nested layout rather than spawning a new one. + if ( + to.api.location.type === 'grid' || + to.api.location.type === 'floating' || + to.api.location.type === 'popout' + ) { + const destGridview = this.getGridviewForGroup(to); const referenceLocation = getGridLocation(to.element); const dropLocation = getRelativeLocation( - this.gridview.orientation, + destGridview.orientation, referenceLocation, target ); - // Add to grid for all moves targeting grid location - let size: number; - switch (this.gridview.orientation) { + switch (destGridview.orientation) { case Orientation.VERTICAL: size = referenceLocation.length % 2 == 0 @@ -3361,42 +3901,8 @@ export class DockviewComponent break; } - this.gridview.addView(source, size, dropLocation); - } else if (to.api.location.type === 'floating') { - // For moves to floating locations, add as floating group - // Get the position/size from the target floating group - const targetFloatingGroup = - this._floatingGroupService?.findByGroup(to); - if (targetFloatingGroup) { - const box = targetFloatingGroup.overlay.toJSON(); - - // Calculate position based on available properties - let left: number, top: number; - if ('left' in box) { - left = box.left + 50; - } else if ('right' in box) { - left = Math.max(0, box.right - box.width - 50); - } else { - left = 50; // Default fallback - } - - if ('top' in box) { - top = box.top + 50; - } else if ('bottom' in box) { - top = Math.max(0, box.bottom - box.height - 50); - } else { - top = 50; // Default fallback - } - - this.addFloatingGroup(source, { - height: box.height, - width: box.width, - position: { - left, - top, - }, - }); - } + destGridview.addView(source, size, dropLocation); + this.setGroupLocationForRoot(source, destGridview); } } @@ -3612,13 +4118,87 @@ export class DockviewComponent private createGroupAtLocation( location: number[], size?: number, - options?: GroupOptions + options?: GroupOptions, + gridview: Gridview = this.gridview ): DockviewGroupPanel { const group = this.createGroup(options); - this.doAddGroup(group, location, size); + this.doAddGroup(group, location, size, gridview); + this.setGroupLocationForRoot(group, gridview); return group; } + /** + * Tag a group with the location and render / drop-target containers + * matching the gridview root it now lives in: the main grid, a floating + * window (shares the main containers), or a popout window (uses its own + * window-local containers). + */ + private setGroupLocationForRoot( + group: DockviewGroupPanel, + gridview: Gridview + ): void { + const popout = this._popoutWindowService?.entries.find( + (entry) => entry.gridview === gridview + ); + + if (popout) { + if (group.model.renderContainer !== popout.overlayRenderContainer) { + group.model.renderContainer = popout.overlayRenderContainer; + } + group.model.dropTargetContainer = popout.dropTargetContainer; + group.model.location = { + type: 'popout', + getWindow: popout.getWindow, + popoutUrl: popout.popoutUrl, + }; + return; + } + + // grid / floating both render through the main containers + if (group.model.renderContainer !== this.overlayRenderContainer) { + group.model.renderContainer = this.overlayRenderContainer; + } + group.model.dropTargetContainer = this.rootDropTargetContainer; + group.model.location = + gridview === this.gridview + ? { type: 'grid' } + : { type: 'floating' }; + } + + /** + * Resolve which gridview root currently owns a group: the main grid, or + * the nested gridview of the floating / popout window it lives in. + */ + getGridviewForGroup(group: DockviewGroupPanel): Gridview { + const floating = this._floatingGroupService?.findByGroup(group); + if (floating) { + return floating.gridview; + } + const popout = this._popoutWindowService?.entries.find((entry) => + entry.gridview.element.contains(group.element) + ); + if (popout) { + return popout.gridview; + } + return this.gridview; + } + + /** + * The groups that live within the same floating / popout window as `group` + * (including `group` itself). Empty when `group` is in the main grid. + */ + private nestedWindowMembers( + group: DockviewGroupPanel + ): DockviewGroupPanel[] { + const gridview = this.getGridviewForGroup(group); + if (gridview === this.gridview) { + return []; + } + return this.groups.filter((candidate) => + gridview.element.contains(candidate.element) + ); + } + private findGroup(panel: IDockviewPanel): DockviewGroupPanel | undefined { return Array.from(this._groups.values()).find((group) => group.value.model.containsPanel(panel) @@ -3640,9 +4220,18 @@ export class DockviewComponent // set on the shell from reaching the dockview subtree. this._shellThemeClassnames?.setClassNames(theme.className); - this.gridview.margin = theme.gap ?? 0; + const gap = theme.gap ?? 0; + this.gridview.margin = gap; + // Floating / popout windows host their own nested gridviews; keep their + // gap in sync with the main grid when the theme changes at runtime. + for (const floating of this.floatingGroups) { + floating.gridview.margin = gap; + } + for (const entry of this._popoutWindowService?.entries ?? []) { + entry.gridview.margin = gap; + } this._shellManager?.updateTheme( - theme.gap ?? 0, + gap, theme.edgeGroupCollapsedSize ?? 35 ); diff --git a/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts b/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts index 62c51c8cc..ebb949219 100644 --- a/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts +++ b/packages/dockview-core/src/dockview/dockviewFloatingGroupPanel.ts @@ -1,5 +1,6 @@ import { Overlay } from '../overlay/overlay'; import { CompositeDisposable } from '../lifecycle'; +import { Gridview } from '../gridview/gridview'; import { AnchoredBox } from '../types'; import { DockviewGroupPanel, IDockviewGroupPanel } from './dockviewGroupPanel'; @@ -8,16 +9,61 @@ export interface IDockviewFloatingGroupPanel { position(bounds: Partial): void; } +/** + * The subset of the dedicated floating title bar this panel needs to keep in + * sync. Declared structurally to avoid a circular import on `FloatingTitleBar`. + */ +export interface IAnchorTrackingTitleBar { + setGroup(group: DockviewGroupPanel): void; +} + export class DockviewFloatingGroupPanel extends CompositeDisposable implements IDockviewFloatingGroupPanel { + private _group: DockviewGroupPanel; + private _titleBar: IAnchorTrackingTitleBar | undefined; + + /** + * The window's representative/anchor group. A floating window can host a + * nested layout of several groups; the anchor is used for back-compat + * single-group APIs and is reassigned if it leaves the window. + */ + get group(): DockviewGroupPanel { + return this._group; + } + + /** + * Register the dedicated title bar (if any) so anchor reassignment keeps + * its drag handle pointed at a group that still lives in this window. + */ + setTitleBar(titleBar: IAnchorTrackingTitleBar | undefined): void { + this._titleBar = titleBar; + } + + setAnchorGroup(group: DockviewGroupPanel): void { + this._group = group; + this._titleBar?.setGroup(group); + } + constructor( - readonly group: DockviewGroupPanel, - readonly overlay: Overlay + group: DockviewGroupPanel, + readonly overlay: Overlay, + /** + * The floating window hosts its own gridview so it can hold a nested + * splitview layout of groups, not just a single group. + */ + readonly gridview: Gridview ) { super(); - this.addDisposables(overlay); + this._group = group; + this.addDisposables(overlay, { + // The gridview owns the floating window's DOM subtree (mounted as + // the overlay's content). Disposing it tears down the splitview; + // it does NOT dispose the leaf views (groups) — their lifecycle is + // owned by the component's `_groups` map. + dispose: () => this.gridview.dispose(), + }); } position(bounds: Partial): void { diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts index 883de270b..17bfd766b 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts @@ -458,18 +458,17 @@ export class DockviewGroupPanelModel applyZones(['top', 'bottom', 'left', 'right', 'center']); break; case 'floating': - applyZones(['center']); - applyZones( - value - ? ['center'] - : ['top', 'bottom', 'left', 'right', 'center'] - ); + // Floating windows host their own nested gridview, so an edge + // drop splits the window's layout just like the main grid. + applyZones(['top', 'bottom', 'left', 'right', 'center']); toggleClass(this.container, 'dv-groupview-floating', true); break; case 'popout': - applyZones(['center']); + // Popout windows host their own nested gridview, so an edge + // drop splits the window's layout just like the main grid. + applyZones(['top', 'bottom', 'left', 'right', 'center']); toggleClass(this.container, 'dv-groupview-popout', true); diff --git a/packages/dockview-core/src/dockview/floatingGroupService.ts b/packages/dockview-core/src/dockview/floatingGroupService.ts index 322579cdc..68773da8d 100644 --- a/packages/dockview-core/src/dockview/floatingGroupService.ts +++ b/packages/dockview-core/src/dockview/floatingGroupService.ts @@ -1,6 +1,7 @@ import { CompositeDisposable, IDisposable } from '../lifecycle'; import { remove } from '../array'; import { watchElementResize } from '../dom'; +import { Gridview, ISerializedLeafNode } from '../gridview/gridview'; import { Overlay } from '../overlay/overlay'; import { DEFAULT_FLOATING_GROUP_OVERFLOW_SIZE } from '../constants'; import { DockviewFloatingGroupPanel } from './dockviewFloatingGroupPanel'; @@ -23,7 +24,8 @@ export interface IFloatingGroupService extends IDisposable { add( group: DockviewGroupPanel, - overlay: Overlay + overlay: Overlay, + gridview: Gridview ): DockviewFloatingGroupPanel; findByGroup( @@ -53,11 +55,13 @@ export class FloatingGroupService implements IFloatingGroupService { add( group: DockviewGroupPanel, - overlay: Overlay + overlay: Overlay, + gridview: Gridview ): DockviewFloatingGroupPanel { const floatingGroupPanel = new DockviewFloatingGroupPanel( group, - overlay + overlay, + gridview ); const disposable = new CompositeDisposable( @@ -67,9 +71,13 @@ export class FloatingGroupService implements IFloatingGroupService { } }), (() => { + // The floating window's nested gridview fills the overlay + // beneath the (optional) title bar; size it from its own + // measured box so it follows the overlay as the user drags + // / resizes the window. let lastWidth = -1; let lastHeight = -1; - return watchElementResize(group.element, (entry) => { + return watchElementResize(gridview.element, (entry) => { const width = Math.round(entry.contentRect.width); const height = Math.round(entry.contentRect.height); if (width === lastWidth && height === lastHeight) { @@ -77,14 +85,14 @@ export class FloatingGroupService implements IFloatingGroupService { } lastWidth = width; lastHeight = height; - group.layout(width, height); + gridview.layout(width, height); }); })() ); floatingGroupPanel.addDisposables( overlay.onDidChange(() => { - group.layout(group.width, group.height); + gridview.layout(gridview.width, gridview.height); }), overlay.onDidChangeEnd(() => { this._host.fireLayoutChange(); @@ -118,16 +126,41 @@ export class FloatingGroupService implements IFloatingGroupService { findByGroup( group: DockviewGroupPanel ): DockviewFloatingGroupPanel | undefined { + // A floating window may host several groups in a nested gridview, so + // match by membership (DOM containment) rather than only the anchor + // group. `floating.group === group` covers the brief window before the + // anchor's element is attached to the gridview. return this._floatingGroups.find( - (floating) => floating.group === group + (floating) => + floating.group === group || + floating.gridview.element.contains(group.element) ); } serialize(): SerializedFloatingGroup[] { - return this._floatingGroups.map((group) => ({ - data: group.group.toJSON() as GroupPanelViewState, - position: group.overlay.toJSON(), - })); + return this._floatingGroups.map((floating) => { + const grid = floating.gridview.serialize(); + const position = floating.overlay.toJSON(); + const root = grid.root; + + // A single-group window keeps the legacy `data` shape so layouts + // round-trip byte-stably and older readers keep working; only + // genuine multi-group windows emit the nested `grid` form. + if ( + root.type === 'branch' && + root.data.length === 1 && + root.data[0].type === 'leaf' + ) { + return { + data: ( + root.data[0] as ISerializedLeafNode + ).data, + position, + }; + } + + return { grid, position }; + }); } constrainBounds(): void { diff --git a/packages/dockview-core/src/dockview/popoutWindowService.ts b/packages/dockview-core/src/dockview/popoutWindowService.ts index ae25b3689..53fe812c1 100644 --- a/packages/dockview-core/src/dockview/popoutWindowService.ts +++ b/packages/dockview-core/src/dockview/popoutWindowService.ts @@ -5,12 +5,28 @@ import { PopoutWindow } from '../popoutWindow'; import { DockviewGroupPanel } from './dockviewGroupPanel'; import { SerializedPopoutGroup } from './dockviewComponent'; import { GroupPanelViewState } from './dockviewGroupPanelModel'; +import { Gridview, ISerializedLeafNode } from '../gridview/gridview'; +import { OverlayRenderContainer } from '../overlay/overlayRenderContainer'; +import { DropTargetAnchorContainer } from '../dnd/dropTargetAnchorContainer'; import { defineModule } from './modules'; export interface PopoutGroupEntry { window: PopoutWindow; popoutGroup: DockviewGroupPanel; referenceGroup?: string; + /** + * The popout window hosts its own gridview so it can hold a nested + * splitview layout of groups. `popoutGroup` is the window's anchor group. + */ + gridview: Gridview; + /** + * Render / drop-target containers and window accessor for this popout, so + * groups relocated into the window can be wired to its own document. + */ + overlayRenderContainer: OverlayRenderContainer; + dropTargetContainer: DropTargetAnchorContainer; + getWindow: () => Window; + popoutUrl?: string; disposable: { dispose: () => DockviewGroupPanel | undefined }; } @@ -30,6 +46,12 @@ export interface IPopoutWindowService extends IDisposable { findByGroup(group: DockviewGroupPanel): PopoutGroupEntry | undefined; findReferenceGroupId(group: DockviewGroupPanel): string | undefined; + observeGridviewSize( + popoutWindow: PopoutWindow, + gridview: Gridview, + overlayRenderContainer: OverlayRenderContainer + ): IDisposable | undefined; + getPopupService(groupId: string): PopupService | undefined; setPopupService(groupId: string, service: PopupService): void; deletePopupService(groupId: string): void; @@ -75,7 +97,13 @@ export class PopoutWindowService implements IPopoutWindowService { } findByGroup(group: DockviewGroupPanel): PopoutGroupEntry | undefined { - return this._entries.find((entry) => entry.popoutGroup === group); + // A popout window may host several groups in a nested gridview, so + // match by membership (DOM containment) rather than only the anchor. + return this._entries.find( + (entry) => + entry.popoutGroup === group || + entry.gridview.element.contains(group.element) + ); } findReferenceGroupId(group: DockviewGroupPanel): string | undefined { @@ -83,6 +111,65 @@ export class PopoutWindowService implements IPopoutWindowService { ?.referenceGroup; } + /** + * The popout window's innerWidth/innerHeight are often 0/stale until it has + * painted, and the nested gridview lays its children out to the size passed + * to layout() (a plain group fills via CSS instead). To stop content + * rendering into a zero box until a manual resize — and to avoid the race a + * fixed number of animation frames had — observe the gridview element with + * a ResizeObserver created in the POPOUT window's OWN realm. A parent-realm + * observer fires unreliably across the window boundary; a same-realm one + * fires reliably, including the initial observation once the window is + * sized. + * + * @returns a disposable that disconnects the observer, or `undefined` when + * the popout realm has no ResizeObserver (e.g. jsdom). + */ + observeGridviewSize( + popoutWindow: PopoutWindow, + gridview: Gridview, + overlayRenderContainer: OverlayRenderContainer + ): IDisposable | undefined { + const PopoutResizeObserver = ( + popoutWindow.window as (Window & typeof globalThis) | undefined + )?.ResizeObserver; + if (!PopoutResizeObserver) { + return undefined; + } + + let lastWidth = -1; + let lastHeight = -1; + const relayout = () => { + const win = popoutWindow.window; + if (this._host.isDisposed || !win || win.closed) { + return; + } + const width = Math.round(gridview.element.clientWidth); + const height = Math.round(gridview.element.clientHeight); + if (width === lastWidth && height === lastHeight) { + return; + } + lastWidth = width; + lastHeight = height; + if (width > 0 && height > 0) { + gridview.layout(width, height); + } + overlayRenderContainer.updateAllPositions(); + }; + const observer = new PopoutResizeObserver(() => { + // Defer out of the observer callback into the popout's own frame to + // size against the settled layout and to avoid resize-loop warnings. + const raf = popoutWindow.window?.requestAnimationFrame; + if (raf) { + raf.call(popoutWindow.window, relayout); + } else { + relayout(); + } + }); + observer.observe(gridview.element); + return { dispose: () => observer.disconnect() }; + } + getPopupService(groupId: string): PopupService | undefined { return this._popupServices.get(groupId); } @@ -137,15 +224,37 @@ export class PopoutWindowService implements IPopoutWindowService { } serialize(): SerializedPopoutGroup[] { - return this._entries.map((entry) => ({ - data: entry.popoutGroup.toJSON() as GroupPanelViewState, - gridReferenceGroup: entry.referenceGroup, - position: entry.window.dimensions(), - url: + return this._entries.map((entry) => { + const grid = entry.gridview.serialize(); + const root = grid.root; + const url = entry.popoutGroup.api.location.type === 'popout' ? entry.popoutGroup.api.location.popoutUrl - : undefined, - })); + : undefined; + + const base = { + gridReferenceGroup: entry.referenceGroup, + position: entry.window.dimensions(), + url, + }; + + // Single-group window keeps the legacy `data` shape so layouts + // round-trip byte-stably and older readers keep working. + if ( + root.type === 'branch' && + root.data.length === 1 && + root.data[0].type === 'leaf' + ) { + return { + ...base, + data: ( + root.data[0] as ISerializedLeafNode + ).data, + }; + } + + return { ...base, grid }; + }); } disposeAll(): void { diff --git a/packages/dockview-core/src/gridview/baseComponentGridview.ts b/packages/dockview-core/src/gridview/baseComponentGridview.ts index 9cd0bd05b..f409497e8 100644 --- a/packages/dockview-core/src/gridview/baseComponentGridview.ts +++ b/packages/dockview-core/src/gridview/baseComponentGridview.ts @@ -274,9 +274,10 @@ export abstract class BaseGrid protected doAddGroup( group: T, location: number[] = [0], - size?: number + size?: number, + gridview: Gridview = this.gridview ): void { - this.gridview.addView(group, size ?? Sizing.Distribute, location); + gridview.addView(group, size ?? Sizing.Distribute, location); this._onDidAdd.fire(group); } diff --git a/packages/dockview-core/src/overlay/overlay.scss b/packages/dockview-core/src/overlay/overlay.scss index 8793df09e..edb94f17d 100644 --- a/packages/dockview-core/src/overlay/overlay.scss +++ b/packages/dockview-core/src/overlay/overlay.scss @@ -34,9 +34,17 @@ } } +// A floating window hosts its own gridview as the overlay content; it must +// fill the resize container so its size tracks the overlay box. +.dv-resize-container > .dv-grid-view { + width: 100%; + height: 100%; +} + // When a dedicated drag-handle title bar is present the overlay stacks the -// handle above the group as a flex column so the group shrinks to fit beneath -// it. Resize handles are position:absolute and stay out of the flex flow. +// handle above the nested gridview as a flex column so the layout shrinks to +// fit beneath it. Resize handles are position:absolute and stay out of the +// flex flow. .dv-resize-container-with-titlebar { display: flex; flex-direction: column; @@ -45,7 +53,7 @@ flex: 0 0 auto; } - > .dv-groupview { + > .dv-grid-view { height: auto; flex: 1 1 0; min-height: 0;