diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index c3b9fafa6..9e8001100 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -1138,6 +1138,237 @@ describe('dockviewComponent', () => { }); describe('serialization', () => { + test('reuseExistingPanels true', () => { + const parts: PanelContentPartTest[] = []; + + dockview = new DockviewComponent(container, { + createComponent(options) { + switch (options.name) { + case 'default': + const part = new PanelContentPartTest( + options.id, + options.name + ); + parts.push(part); + return part; + default: + throw new Error(`unsupported`); + } + }, + }); + + dockview.layout(1000, 1000); + + dockview.addPanel({ id: 'panel1', component: 'default' }); + dockview.addPanel({ id: 'panel2', component: 'default' }); + dockview.addPanel({ id: 'panel7', component: 'default' }); + + expect(parts.length).toBe(3); + + expect(parts.map((part) => part.isDisposed)).toEqual([ + false, + false, + false, + ]); + + dockview.fromJSON( + { + activeGroup: 'group-1', + grid: { + root: { + type: 'branch', + data: [ + { + type: 'leaf', + data: { + views: ['panel1'], + id: 'group-1', + activeView: 'panel1', + }, + size: 500, + }, + { + type: 'branch', + data: [ + { + type: 'leaf', + data: { + views: ['panel2', 'panel3'], + id: 'group-2', + }, + size: 500, + }, + { + type: 'leaf', + data: { + views: ['panel4'], + id: 'group-3', + }, + size: 500, + }, + ], + size: 500, + }, + ], + size: 1000, + }, + height: 1000, + width: 1000, + orientation: Orientation.VERTICAL, + }, + panels: { + panel1: { + id: 'panel1', + contentComponent: 'default', + tabComponent: 'tab-default', + title: 'panel1', + }, + panel2: { + id: 'panel2', + contentComponent: 'default', + title: 'panel2', + }, + panel3: { + id: 'panel3', + contentComponent: 'default', + title: 'panel3', + renderer: 'onlyWhenVisible', + }, + panel4: { + id: 'panel4', + contentComponent: 'default', + title: 'panel4', + renderer: 'always', + }, + }, + }, + { reuseExistingPanels: true } + ); + + expect(parts.map((part) => part.isDisposed)).toEqual([ + false, + false, + true, + false, + false, + ]); + }); + + test('reuseExistingPanels false', () => { + const parts: PanelContentPartTest[] = []; + + dockview = new DockviewComponent(container, { + createComponent(options) { + switch (options.name) { + case 'default': + const part = new PanelContentPartTest( + options.id, + options.name + ); + parts.push(part); + return part; + default: + throw new Error(`unsupported`); + } + }, + }); + + dockview.layout(1000, 1000); + + dockview.addPanel({ id: 'panel1', component: 'default' }); + dockview.addPanel({ id: 'panel2', component: 'default' }); + dockview.addPanel({ id: 'panel7', component: 'default' }); + + expect(parts.length).toBe(3); + + expect(parts.map((part) => part.isDisposed)).toEqual([ + false, + false, + false, + ]); + + dockview.fromJSON({ + activeGroup: 'group-1', + grid: { + root: { + type: 'branch', + data: [ + { + type: 'leaf', + data: { + views: ['panel1'], + id: 'group-1', + activeView: 'panel1', + }, + size: 500, + }, + { + type: 'branch', + data: [ + { + type: 'leaf', + data: { + views: ['panel2', 'panel3'], + id: 'group-2', + }, + size: 500, + }, + { + type: 'leaf', + data: { + views: ['panel4'], + id: 'group-3', + }, + size: 500, + }, + ], + size: 500, + }, + ], + size: 1000, + }, + height: 1000, + width: 1000, + orientation: Orientation.VERTICAL, + }, + panels: { + panel1: { + id: 'panel1', + contentComponent: 'default', + tabComponent: 'tab-default', + title: 'panel1', + }, + panel2: { + id: 'panel2', + contentComponent: 'default', + title: 'panel2', + }, + panel3: { + id: 'panel3', + contentComponent: 'default', + title: 'panel3', + renderer: 'onlyWhenVisible', + }, + panel4: { + id: 'panel4', + contentComponent: 'default', + title: 'panel4', + renderer: 'always', + }, + }, + }); + + expect(parts.map((part) => part.isDisposed)).toEqual([ + true, + true, + true, + false, + false, + false, + false, + ]); + }); + test('basic', () => { dockview.layout(1000, 1000); @@ -1429,14 +1660,18 @@ describe('dockviewComponent', () => { // Verify that always visible panels have been positioned const overlayContainer = dockview.overlayRenderContainer; - + // Check that panels with renderer: 'always' are attached to overlay container expect(panel2.api.renderer).toBe('always'); expect(panel3.api.renderer).toBe('always'); // Get the overlay elements for always visible panels - const panel2Overlay = overlayContainer.element.querySelector('[data-panel-id]') as HTMLElement; - const panel3Overlay = overlayContainer.element.querySelector('[data-panel-id]:not(:first-child)') as HTMLElement; + const panel2Overlay = overlayContainer.element.querySelector( + '[data-panel-id]' + ) as HTMLElement; + const panel3Overlay = overlayContainer.element.querySelector( + '[data-panel-id]:not(:first-child)' + ) as HTMLElement; // Verify positioning has been applied (should not be 0 after layout) if (panel2Overlay) { @@ -1449,16 +1684,19 @@ describe('dockviewComponent', () => { } // Test that updateAllPositions method works correctly - const updateSpy = jest.spyOn(overlayContainer, 'updateAllPositions'); - + const updateSpy = jest.spyOn( + overlayContainer, + 'updateAllPositions' + ); + // Call fromJSON again to trigger position updates dockview.fromJSON(dockview.toJSON()); - + // Wait for the position update to be called await new Promise((resolve) => requestAnimationFrame(resolve)); - + expect(updateSpy).toHaveBeenCalled(); - + updateSpy.mockRestore(); }); }); @@ -5443,7 +5681,7 @@ describe('dockviewComponent', () => { container.style.width = '800px'; container.style.height = '600px'; document.body.appendChild(container); - + const dockview = new DockviewComponent(container, { createComponent(options) { const element = document.createElement('div'); @@ -5451,21 +5689,21 @@ describe('dockviewComponent', () => { element.style.background = 'lightblue'; element.style.padding = '10px'; return new PanelContentPartTest(options.id, options.name); - } + }, }); - + dockview.layout(800, 600); - + try { // 1. Create a panel const panel = dockview.addPanel({ id: 'test-panel', - component: 'default' + component: 'default', }); - + // Verify initial state expect(panel.api.location.type).toBe('grid'); - + // 2. Move to floating group dockview.addFloatingGroup(panel, { position: { @@ -5475,27 +5713,27 @@ describe('dockviewComponent', () => { width: 400, height: 300, }); - + // Verify floating state expect(panel.api.location.type).toBe('floating'); - + // 3. Move back to grid using addGroup + moveTo pattern (reproducing user's exact issue) const addGroup = dockview.addGroup(); panel.api.moveTo({ group: addGroup }); - + // THIS IS THE FIX: Component should still be visible expect(panel.api.location.type).toBe('grid'); - + // Test multiple scenarios const panel2 = dockview.addPanel({ id: 'panel-2', component: 'default', - floating: true + floating: true, }); - + const group2 = dockview.addGroup(); panel2.api.moveTo({ group: group2 }); - + expect(panel2.api.location.type).toBe('grid'); } finally { dockview.dispose(); @@ -6378,10 +6616,10 @@ describe('dockviewComponent', () => { expect(dockview.groups.length).toBe(0); dockview.fromJSON(state); - + // Advance timers to trigger delayed popout creation (0ms, 100ms delays) jest.advanceTimersByTime(200); - + // Wait for the popout restoration to complete await dockview.popoutRestorationPromise; @@ -6417,7 +6655,7 @@ describe('dockviewComponent', () => { url: '/custom.html', }, ]); - + jest.useRealTimers(); }); diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts index 6e24cd53f..092607cf6 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewGroupPanelModel.spec.ts @@ -195,6 +195,10 @@ export class TestPanel implements IDockviewPanel { }); } + updateFromStateModel(state: GroupviewPanelState): void { + // + } + init(params: IGroupPanelInitParameters) { this._params = params; } diff --git a/packages/dockview-core/src/api/component.api.ts b/packages/dockview-core/src/api/component.api.ts index 76d9cd128..62714532d 100644 --- a/packages/dockview-core/src/api/component.api.ts +++ b/packages/dockview-core/src/api/component.api.ts @@ -861,8 +861,11 @@ export class DockviewApi implements CommonApi { /** * Create a component from a serialized object. */ - fromJSON(data: SerializedDockview): void { - this.component.fromJSON(data); + fromJSON( + data: SerializedDockview, + options?: { reuseExistingPanels: boolean } + ): void { + this.component.fromJSON(data, options); } /** diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index c528b1484..f050dfc86 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -175,6 +175,7 @@ type MoveGroupOrPanelOptions = { index?: number; }; skipSetActive?: boolean; + keepEmptyGroups?: boolean; }; export interface FloatingGroupOptions { @@ -267,6 +268,7 @@ export interface IDockviewComponent extends IBaseGrid { onWillClose?: (event: { id: string; window: Window }) => void; } ): Promise; + fromJSON(data: any, options?: { reuseExistingPanels: boolean }): void; } export class DockviewComponent @@ -1148,7 +1150,7 @@ export class DockviewComponent const el = group.element.querySelector('.dv-void-container'); if (!el) { - throw new Error('failed to find drag handle'); + throw new Error('dockview: failed to find drag handle'); } overlay.setupDrag(el, { @@ -1253,7 +1255,7 @@ export class DockviewComponent options ); // insert into last position default: - throw new Error(`unsupported position ${position}`); + throw new Error(`dockview: unsupported position ${position}`); } } @@ -1434,17 +1436,62 @@ export class DockviewComponent return result; } - fromJSON(data: SerializedDockview): void { + fromJSON( + data: SerializedDockview, + options?: { reuseExistingPanels: boolean } + ): void { + const existingPanels = new Map(); + + let tempGroup: DockviewGroupPanel | undefined; + + if (options?.reuseExistingPanels) { + /** + * What are we doing here? + * + * 1. Create a temporary group to hold any panels that currently exist and that also exist in the new layout + * 2. Remove that temporary group from the group mapping so that it doesn't get cleared when we clear the layout + */ + + tempGroup = this.createGroup(); + this._groups.delete(tempGroup.api.id); + + const newPanels = Object.keys(data.panels); + + for (const panel of this.panels) { + if (newPanels.includes(panel.api.id)) { + existingPanels.set(panel.api.id, panel); + } + } + + this.movingLock(() => { + Array.from(existingPanels.values()).forEach((panel) => { + this.moveGroupOrPanel({ + from: { + groupId: panel.api.group.api.id, + panelId: panel.api.id, + }, + to: { + group: tempGroup!, + position: 'center', + }, + keepEmptyGroups: true, + }); + }); + }); + } + this.clear(); if (typeof data !== 'object' || data === null) { - throw new Error('serialized layout must be a non-null object'); + throw new Error( + 'dockview: serialized layout must be a non-null object' + ); } const { grid, panels, activeGroup } = data; if (grid.root.type !== 'branch' || !Array.isArray(grid.root.data)) { - throw new Error('root must be of type branch'); + throw new Error('dockview: root must be of type branch'); } try { @@ -1458,7 +1505,9 @@ export class DockviewComponent const { id, locked, hideHeader, views, activeView } = data; if (typeof id !== 'string') { - throw new Error('group id must be of type string'); + throw new Error( + 'dockview: group id must be of type string' + ); } const group = this.createGroup({ @@ -1476,11 +1525,23 @@ export class DockviewComponent * In running this section first we avoid firing lots of 'add' events in the event of a failure * due to a corruption of input data. */ - const panel = this._deserializer.fromJSON( - panels[child], - group - ); - createdPanels.push(panel); + + const existingPanel = existingPanels.get(child); + + if (tempGroup && existingPanel) { + this.movingLock(() => { + tempGroup!.model.removePanel(existingPanel); + }); + + createdPanels.push(existingPanel); + existingPanel.updateFromStateModel(panels[child]); + } else { + const panel = this._deserializer.fromJSON( + panels[child], + group + ); + createdPanels.push(panel); + } } for (let i = 0; i < views.length; i++) { @@ -1490,10 +1551,21 @@ export class DockviewComponent typeof activeView === 'string' && activeView === panel.id; - group.model.openPanel(panel, { - skipSetActive: !isActive, - skipSetGroupActive: true, - }); + const hasExisting = existingPanels.has(panel.api.id); + + if (hasExisting) { + this.movingLock(() => { + group.model.openPanel(panel, { + skipSetActive: !isActive, + skipSetGroupActive: true, + }); + }); + } else { + group.model.openPanel(panel, { + skipSetActive: !isActive, + skipSetGroupActive: true, + }); + } } if (!group.activePanel && group.panels.length > 0) { @@ -1549,7 +1621,9 @@ export class DockviewComponent setTimeout(() => { this.addPopoutGroup(group, { position: position ?? undefined, - overridePopoutGroup: gridReferenceGroup ? group : undefined, + overridePopoutGroup: gridReferenceGroup + ? group + : undefined, referenceGroup: gridReferenceGroup ? this.getPanel(gridReferenceGroup) : undefined, @@ -1563,7 +1637,9 @@ export class DockviewComponent }); // Store the promise for tests to wait on - this._popoutRestorationPromise = Promise.all(popoutPromises).then(() => void 0); + this._popoutRestorationPromise = Promise.all(popoutPromises).then( + () => void 0 + ); for (const floatingGroup of this._floatingGroups) { floatingGroup.overlay.setBounds(); @@ -1658,14 +1734,16 @@ export class DockviewComponent options: AddPanelOptions ): DockviewPanel { if (this.panels.find((_) => _.id === options.id)) { - throw new Error(`panel with id ${options.id} already exists`); + throw new Error( + `dockview: panel with id ${options.id} already exists` + ); } let referenceGroup: DockviewGroupPanel | undefined; if (options.position && options.floating) { throw new Error( - 'you can only provide one of: position, floating as arguments to .addPanel(...)' + 'dockview: you can only provide one of: position, floating as arguments to .addPanel(...)' ); } @@ -1686,7 +1764,7 @@ export class DockviewComponent if (!referencePanel) { throw new Error( - `referencePanel '${options.position.referencePanel}' does not exist` + `dockview: referencePanel '${options.position.referencePanel}' does not exist` ); } @@ -1701,7 +1779,7 @@ export class DockviewComponent if (!referenceGroup) { throw new Error( - `referenceGroup '${options.position.referenceGroup}' does not exist` + `dockview: referenceGroup '${options.position.referenceGroup}' does not exist` ); } } else { @@ -1865,7 +1943,7 @@ export class DockviewComponent if (!group) { throw new Error( - `cannot remove panel ${panel.id}. it's missing a group.` + `dockview: cannot remove panel ${panel.id}. it's missing a group.` ); } @@ -1931,7 +2009,7 @@ export class DockviewComponent if (!referencePanel) { throw new Error( - `reference panel ${options.referencePanel} does not exist` + `dockview: reference panel ${options.referencePanel} does not exist` ); } @@ -1939,7 +2017,7 @@ export class DockviewComponent if (!referenceGroup) { throw new Error( - `reference group for reference panel ${options.referencePanel} does not exist` + `dockview: reference group for reference panel ${options.referencePanel} does not exist` ); } } else if (isGroupOptionsWithGroup(options)) { @@ -1950,7 +2028,7 @@ export class DockviewComponent if (!referenceGroup) { throw new Error( - `reference group ${options.referenceGroup} does not exist` + `dockview: reference group ${options.referenceGroup} does not exist` ); } } else { @@ -2064,7 +2142,7 @@ export class DockviewComponent return floatingGroup.group; } - throw new Error('failed to find floating group'); + throw new Error('dockview: failed to find floating group'); } if (group.api.location.type === 'popout') { @@ -2110,7 +2188,7 @@ export class DockviewComponent return selectedGroup.popoutGroup; } - throw new Error('failed to find popout group'); + throw new Error('dockview: failed to find popout group'); } const re = super.doRemoveGroup(group, options); @@ -2149,7 +2227,9 @@ export class DockviewComponent : undefined; if (!sourceGroup) { - throw new Error(`Failed to find group id ${sourceGroupId}`); + throw new Error( + `dockview: Failed to find group id ${sourceGroupId}` + ); } if (sourceItemId === undefined) { @@ -2182,21 +2262,23 @@ export class DockviewComponent ); if (!removedPanel) { - throw new Error(`No panel with id ${sourceItemId}`); + throw new Error(`dockview: No panel with id ${sourceItemId}`); } - if (sourceGroup.model.size === 0) { + if (!options.keepEmptyGroups && sourceGroup.model.size === 0) { // remove the group and do not set a new group as active this.doRemoveGroup(sourceGroup, { skipActive: true }); } // Check if destination group is empty - if so, force render the component const isDestinationGroupEmpty = destinationGroup.model.size === 0; - + this.movingLock(() => destinationGroup.model.openPanel(removedPanel, { index: destinationIndex, - skipSetActive: (options.skipSetActive ?? false) && !isDestinationGroupEmpty, + skipSetActive: + (options.skipSetActive ?? false) && + !isDestinationGroupEmpty, skipSetGroupActive: true, }) ); @@ -2281,7 +2363,9 @@ export class DockviewComponent const newGroup = this.createGroupAtLocation(targetLocation); this.movingLock(() => - newGroup.model.openPanel(removedPanel) + newGroup.model.openPanel(removedPanel, { + skipSetActive: true, + }) ); this.doSetGroupAndPanelActive(newGroup); @@ -2331,7 +2415,9 @@ export class DockviewComponent ); if (!removedPanel) { - throw new Error(`No panel with id ${sourceItemId}`); + throw new Error( + `dockview: No panel with id ${sourceItemId}` + ); } const dropLocation = getRelativeLocation( @@ -2405,7 +2491,9 @@ export class DockviewComponent (x) => x.group === from ); if (!selectedFloatingGroup) { - throw new Error('failed to find floating group'); + throw new Error( + 'dockview: failed to find floating group' + ); } selectedFloatingGroup.dispose(); break; @@ -2415,7 +2503,9 @@ export class DockviewComponent (x) => x.popoutGroup === from ); if (!selectedPopoutGroup) { - throw new Error('failed to find popout group'); + throw new Error( + 'dockview: failed to find popout group' + ); } // Remove from popout groups list to prevent automatic restoration diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts index d8e52150f..0afb82b21 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts @@ -906,6 +906,8 @@ export class DockviewGroupPanelModel if (panel) { this.tabsContainer.setActivePanel(panel); + this.contentContainer.openPanel(panel); + panel.layout(this._width, this._height); this.updateMru(panel); diff --git a/packages/dockview-core/src/dockview/dockviewPanel.ts b/packages/dockview-core/src/dockview/dockviewPanel.ts index bf71ca6c1..c070a9c71 100644 --- a/packages/dockview-core/src/dockview/dockviewPanel.ts +++ b/packages/dockview-core/src/dockview/dockviewPanel.ts @@ -27,6 +27,7 @@ export interface IDockviewPanel extends IDisposable, IPanel { group: DockviewGroupPanel, options?: { skipSetActive?: boolean } ): void; + updateFromStateModel(state: GroupviewPanelState): void; init(params: IGroupPanelInitParameters): void; toJSON(): GroupviewPanelState; setTitle(title: string): void; @@ -45,10 +46,10 @@ export class DockviewPanel private _title: string | undefined; private _renderer: DockviewPanelRenderer | undefined; - private readonly _minimumWidth: number | undefined; - private readonly _minimumHeight: number | undefined; - private readonly _maximumWidth: number | undefined; - private readonly _maximumHeight: number | undefined; + private _minimumWidth: number | undefined; + private _minimumHeight: number | undefined; + private _maximumWidth: number | undefined; + private _maximumHeight: number | undefined; get params(): Parameters | undefined { return this._params; @@ -209,6 +210,20 @@ export class DockviewPanel }); } + updateFromStateModel(state: GroupviewPanelState): void { + this._maximumHeight = state.maximumHeight; + this._minimumHeight = state.minimumHeight; + this._maximumWidth = state.maximumWidth; + this._minimumWidth = state.minimumWidth; + + this.update({ params: state.params ?? {} }); + this.setTitle(state.title ?? this.id); + this.setRenderer(state.renderer ?? this.accessor.renderer); + + // state.contentComponent; + // state.tabComponent; + } + public updateParentGroup( group: DockviewGroupPanel, options?: { skipSetActive?: boolean } diff --git a/packages/docs/sandboxes/react/dockview/demo-dockview/src/app.tsx b/packages/docs/sandboxes/react/dockview/demo-dockview/src/app.tsx index 7c9fe42f7..58a1e7e7a 100644 --- a/packages/docs/sandboxes/react/dockview/demo-dockview/src/app.tsx +++ b/packages/docs/sandboxes/react/dockview/demo-dockview/src/app.tsx @@ -51,6 +51,12 @@ const components = { const isDebug = React.useContext(DebugContext); const metadata = usePanelApiMetadata(props.api); + const [firstRender, setFirstRender] = React.useState(''); + + React.useEffect(() => { + setFirstRender(new Date().toISOString()); + }, []); + return (
+
{firstRender}
+ {isDebug && (