diff --git a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts index 6c4a5aef8e..a4dd2ce978 100644 --- a/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/dockview/dockviewComponent.spec.ts @@ -5,7 +5,7 @@ import { ITabRenderer, } from '../../dockview/types'; import { PanelUpdateEvent } from '../../panel/types'; -import { Orientation } from '../../splitview/splitview'; +import { LayoutPriority, Orientation } from '../../splitview/splitview'; import { CompositeDisposable } from '../../lifecycle'; import { Emitter } from '../../events'; import { DockviewPanel, IDockviewPanel } from '../../dockview/dockviewPanel'; @@ -128,6 +128,109 @@ describe('dockviewComponent', () => { }); }); + test('group LayoutPriority is settable via addGroup and round-trips through JSON', () => { + const create = (el: HTMLElement) => + new DockviewComponent(el, { + createComponent(options) { + switch (options.name) { + case 'default': + return new PanelContentPartTest( + options.id, + options.name + ); + default: + throw new Error(`unsupported`); + } + }, + }); + + const containerA = document.createElement('div'); + const dv = create(containerA); + dv.layout(1000, 500); + + // priority set at group-creation time reaches the group (grid leaf) + dv.addPanel({ id: 'p1', component: 'default' }); + const fillGroup = dv.addGroup({ + direction: 'right', + priority: LayoutPriority.Fill, + }); + dv.addPanel({ + id: 'p2', + component: 'default', + position: { referenceGroup: fillGroup }, + }); + + expect(fillGroup.priority).toBe(LayoutPriority.Fill); + expect(fillGroup.api.priority).toBe(LayoutPriority.Fill); + + // it is present in the serialized group state + const state = dv.toJSON(); + expect(JSON.stringify(state)).toContain('"priority":"fill"'); + + // and restored when loaded into a fresh component + const containerB = document.createElement('div'); + const dv2 = create(containerB); + dv2.layout(1000, 500); + dv2.fromJSON(state); + + const restored = dv2.groups.find( + (g) => g.priority === LayoutPriority.Fill + ); + expect(restored).toBeDefined(); + expect(restored!.panels.map((p) => p.id)).toContain('p2'); + + dv.dispose(); + dv2.dispose(); + }); + + test('group LayoutPriority is settable at runtime via the group api', () => { + dockview.layout(1000, 500); + + const panel = dockview.addPanel({ id: 'p1', component: 'default' }); + const group = panel.group; + + expect(group.api.priority).toBeUndefined(); + + group.api.priority = LayoutPriority.Fill; + expect(group.api.priority).toBe(LayoutPriority.Fill); + expect(group.priority).toBe(LayoutPriority.Fill); + }); + + test('a group with LayoutPriority.Fill absorbs resize while siblings stay fixed', () => { + dockview.layout(600, 1000); + + const p1 = 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' }, + }); + + expect(p1.api.width).toBe(200); + expect(p2.api.width).toBe(200); + expect(p3.api.width).toBe(200); + + // make the middle group the fill group (runtime — forces a relayout) + p2.api.group.api.priority = LayoutPriority.Fill; + + // grow the container: only the fill group grows; siblings stay fixed + dockview.layout(900, 1000); + expect(p1.api.width).toBe(200); + expect(p3.api.width).toBe(200); + expect(p2.api.width).toBe(500); + + // shrink the container: only the fill group shrinks + dockview.layout(700, 1000); + expect(p1.api.width).toBe(200); + expect(p3.api.width).toBe(200); + expect(p2.api.width).toBe(300); + }); + test('update className', () => { dockview = new DockviewComponent(container, { createComponent(options) { diff --git a/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts b/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts index 9a8b85aa40..d7cd33d173 100644 --- a/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts +++ b/packages/dockview-core/src/__tests__/gridview/gridviewComponent.spec.ts @@ -2,7 +2,7 @@ import { GridviewComponent } from '../../gridview/gridviewComponent'; import { GridviewPanel } from '../../gridview/gridviewPanel'; import { CompositeDisposable } from '../../lifecycle'; import { IFrameworkPart } from '../../panel/types'; -import { Orientation } from '../../splitview/splitview'; +import { LayoutPriority, Orientation } from '../../splitview/splitview'; class TestGridview extends GridviewPanel { constructor(id: string, componentName: string) { @@ -80,6 +80,52 @@ describe('gridview', () => { expect(panel?.api.isVisible).toBeTruthy(); }); + test('LayoutPriority.Fill round-trips through toJSON/fromJSON', () => { + const create = () => + new GridviewComponent(container, { + proportionalLayout: false, + orientation: Orientation.VERTICAL, + createComponent: (options) => { + switch (options.name) { + case 'default': + return new TestGridview(options.id, options.name); + default: + throw new Error('unsupported'); + } + }, + }); + + const gridview = create(); + gridview.layout(800, 400); + + gridview.addPanel({ + id: 'content', + component: 'default', + priority: LayoutPriority.Fill, + }); + + expect(gridview.getPanel('content')!.priority).toBe( + LayoutPriority.Fill + ); + + const state = JSON.parse(JSON.stringify(gridview.toJSON())); + + // the fill priority is present in the serialized output + expect(JSON.stringify(state)).toContain('"priority":"fill"'); + + // and it is restored when loaded into a fresh component + const restored = create(); + restored.layout(800, 400); + restored.fromJSON(state); + + expect(restored.getPanel('content')!.priority).toBe( + LayoutPriority.Fill + ); + + gridview.dispose(); + restored.dispose(); + }); + test('remove panel', () => { const gridview = new GridviewComponent(container, { proportionalLayout: false, diff --git a/packages/dockview-core/src/__tests__/splitview/splitview.spec.ts b/packages/dockview-core/src/__tests__/splitview/splitview.spec.ts index f020ce1278..02c61f2fa6 100644 --- a/packages/dockview-core/src/__tests__/splitview/splitview.spec.ts +++ b/packages/dockview-core/src/__tests__/splitview/splitview.spec.ts @@ -88,6 +88,191 @@ describe('splitview', () => { jest.clearAllMocks(); }); + test('LayoutPriority.Fill absorbs surplus/deficit while siblings stay fixed', () => { + const splitview = new Splitview(container, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + }); + + splitview.layout(1000, 500); + + const side1 = new Testview(50, Number.POSITIVE_INFINITY); + const fill = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + const side2 = new Testview(50, Number.POSITIVE_INFINITY); + + splitview.addView(side1); // index 0 + splitview.addView(fill); // index 1 + splitview.addView(side2); // index 2 + + // pin the two side panels to fixed sizes; the fill view compensates + splitview.resizeView(0, 200); + splitview.resizeView(2, 150); + + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(2)).toBe(150); + expect(splitview.getViewSize(1)).toBe(650); // 1000 - 200 - 150 + + // grow the container: only the fill view grows + splitview.layout(1200, 500); + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(2)).toBe(150); + expect(splitview.getViewSize(1)).toBe(850); + + // shrink the container: only the fill view shrinks + splitview.layout(700, 500); + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(2)).toBe(150); + expect(splitview.getViewSize(1)).toBe(350); + + splitview.dispose(); + }); + + test('LayoutPriority.Fill absorbs add/remove of siblings', () => { + const splitview = new Splitview(container, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + }); + + splitview.layout(1000, 500); + + const side1 = new Testview(50, Number.POSITIVE_INFINITY); + const fill = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + + splitview.addView(side1); // index 0 + splitview.addView(fill); // index 1 + splitview.resizeView(0, 200); + + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(1)).toBe(800); + + // add another fixed sibling — the fill view yields the space, not the + // existing side panel + const side2 = new Testview(50, Number.POSITIVE_INFINITY); + splitview.addView(side2, 250, 2); + + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(2)).toBe(250); + expect(splitview.getViewSize(1)).toBe(550); // 1000 - 200 - 250 + + // remove the new sibling — the fill view reclaims the space + splitview.removeView(2); + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(1)).toBe(800); + + splitview.dispose(); + }); + + test('multiple LayoutPriority.Fill views share the surplus equally', () => { + const splitview = new Splitview(container, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + }); + + splitview.layout(1000, 500); + + const fill1 = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + const fill2 = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + + splitview.addView(fill1); // index 0 + splitview.addView(fill2); // index 1 + + // both fill views split the container equally + expect(splitview.getViewSize(0)).toBe(500); + expect(splitview.getViewSize(1)).toBe(500); + + // the surplus from growing the container is shared equally + splitview.layout(1200, 500); + expect(splitview.getViewSize(0)).toBe(600); + expect(splitview.getViewSize(1)).toBe(600); + + // and the deficit from shrinking it + splitview.layout(800, 500); + expect(splitview.getViewSize(0)).toBe(400); + expect(splitview.getViewSize(1)).toBe(400); + + splitview.dispose(); + }); + + test('LayoutPriority.Fill takes precedence over High (High stays fixed)', () => { + const splitview = new Splitview(container, { + orientation: Orientation.HORIZONTAL, + proportionalLayout: true, + }); + + splitview.layout(1000, 500); + + const high = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.High + ); + const fill = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + + splitview.addView(high); // index 0 + splitview.addView(fill); // index 1 + + splitview.resizeView(0, 300); + expect(splitview.getViewSize(0)).toBe(300); + expect(splitview.getViewSize(1)).toBe(700); + + // grow: the fill view absorbs everything even though `High` would + // normally be offered space first + splitview.layout(1200, 500); + expect(splitview.getViewSize(0)).toBe(300); + expect(splitview.getViewSize(1)).toBe(900); + + splitview.dispose(); + }); + + test('LayoutPriority.Fill works in a vertical splitview', () => { + const splitview = new Splitview(container, { + orientation: Orientation.VERTICAL, + proportionalLayout: true, + }); + + splitview.layout(1000, 500); + + const side = new Testview(50, Number.POSITIVE_INFINITY); + const fill = new Testview( + 50, + Number.POSITIVE_INFINITY, + LayoutPriority.Fill + ); + + splitview.addView(side); // index 0 + splitview.addView(fill); // index 1 + + splitview.resizeView(0, 200); + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(1)).toBe(800); + + splitview.layout(600, 500); + expect(splitview.getViewSize(0)).toBe(200); + expect(splitview.getViewSize(1)).toBe(400); + + splitview.dispose(); + }); + test('vertical splitview', () => { const splitview = new Splitview(container, { orientation: Orientation.HORIZONTAL, diff --git a/packages/dockview-core/src/api/component.api.ts b/packages/dockview-core/src/api/component.api.ts index d0bd51e941..f100e1330d 100644 --- a/packages/dockview-core/src/api/component.api.ts +++ b/packages/dockview-core/src/api/component.api.ts @@ -16,6 +16,7 @@ import { import { AddGroupOptions, AddPanelOptions, + DockviewBreakpointChangeEvent, DockviewComponentOptions, DockviewDndOverlayEvent, MovementOptions, @@ -739,6 +740,45 @@ export class DockviewApi implements CommonApi { return this.component.onDidLayoutChange; } + /** + * The active responsive breakpoint name, or `undefined` when `responsive` + * is not configured (or the `ResponsiveLayoutModule` is absent). + */ + get activeBreakpoint(): string | undefined { + return this.component.activeBreakpoint; + } + + /** Fires after the responsive layout switches breakpoint. */ + get onDidBreakpointChange(): Event { + return this.component.onDidBreakpointChange; + } + + /** Fires when an edit made while collapsed could not be cleanly rebased. */ + get onDidRebaseConflict(): Event<{ reason: string }> { + return this.component.onDidRebaseConflict; + } + + /** Force a responsive re-resolution against the current container width. */ + reflow(): void { + this.component.reflow(); + } + + /** + * The canonical (wide) responsive layout — the frozen baseline while + * collapsed, or the live layout when not collapsed / not configured. + */ + getCanonicalLayout(): SerializedDockview { + return this.component.getCanonicalLayout(); + } + + /** + * Replace the canonical (wide) responsive layout, then re-derive for the + * current container width. + */ + setCanonicalLayout(data: SerializedDockview): void { + this.component.setCanonicalLayout(data); + } + /** * Invoked when a Drag'n'Drop event occurs that the component was unable to handle. Exposed for custom Drag'n'Drop functionality. */ diff --git a/packages/dockview-core/src/api/dockviewGroupPanelApi.ts b/packages/dockview-core/src/api/dockviewGroupPanelApi.ts index 61885cd591..b534524766 100644 --- a/packages/dockview-core/src/api/dockviewGroupPanelApi.ts +++ b/packages/dockview-core/src/api/dockviewGroupPanelApi.ts @@ -8,6 +8,7 @@ import { DockviewGroupPanelLocked, } from '../dockview/dockviewGroupPanelModel'; import { DockviewHeaderPosition } from '../dockview/options'; +import { LayoutPriority } from '../splitview/splitview'; import { Emitter, Event } from '../events'; import { GridviewPanelApi, @@ -164,6 +165,25 @@ export class DockviewGroupPanelApiImpl extends GridviewPanelApiImpl { this._group.locked = value; } + /** + * The group's layout priority within the grid. Setting `LayoutPriority.Fill` + * makes the group absorb surplus/deficit space while its siblings keep a + * fixed size; the change relayouts the containing branch. + */ + get priority(): LayoutPriority | undefined { + if (!this._group) { + throw new Error(NOT_INITIALIZED_MESSAGE); + } + return this._group.priority; + } + + set priority(value: LayoutPriority | undefined) { + if (!this._group) { + throw new Error(NOT_INITIALIZED_MESSAGE); + } + this._group.priority = value; + } + constructor( id: string, private readonly accessor: DockviewComponent diff --git a/packages/dockview-core/src/dockview/dockviewComponent.ts b/packages/dockview-core/src/dockview/dockviewComponent.ts index 9aa22ef97e..e3e4e9058d 100644 --- a/packages/dockview-core/src/dockview/dockviewComponent.ts +++ b/packages/dockview-core/src/dockview/dockviewComponent.ts @@ -24,6 +24,7 @@ import { DefaultDockviewDeserialzier } from './deserializer'; import { AddGroupOptions, AddPanelOptions, + DockviewBreakpointChangeEvent, DockviewComponentOptions, DockviewDndOverlayEvent, DockviewOptions, @@ -368,6 +369,12 @@ export interface PopoutGroup { export interface IDockviewComponent extends IBaseGrid { readonly activePanel: IDockviewPanel | undefined; + readonly activeBreakpoint: string | undefined; + readonly onDidBreakpointChange: Event; + readonly onDidRebaseConflict: Event<{ reason: string }>; + reflow(): void; + getCanonicalLayout(): SerializedDockview; + setCanonicalLayout(data: SerializedDockview): void; readonly totalPanels: number; readonly panels: IDockviewPanel[]; readonly orientation: Orientation; @@ -885,6 +892,61 @@ export class DockviewComponent return this._moduleRegistry.services.smartGuidesService; } + private get _responsiveLayoutService() { + return this._moduleRegistry.services.responsiveLayoutService; + } + + /** + * The active responsive breakpoint name, or `undefined` when `responsive` + * is not configured / the `ResponsiveLayoutModule` is absent. + */ + get activeBreakpoint(): string | undefined { + return this._responsiveLayoutService?.activeBreakpoint; + } + + /** Fires after the responsive layout switches breakpoint. */ + get onDidBreakpointChange(): Event { + return ( + this._responsiveLayoutService?.onDidBreakpointChange ?? + Event.any() + ); + } + + /** Fires when an edit made while collapsed could not be cleanly rebased. */ + get onDidRebaseConflict(): Event<{ reason: string }> { + return ( + this._responsiveLayoutService?.onDidRebaseConflict ?? + Event.any<{ reason: string }>() + ); + } + + /** Force a responsive re-resolution against the current container width. */ + reflow(): void { + this._responsiveLayoutService?.reflow(); + } + + /** + * The canonical (wide) responsive layout — the frozen baseline while + * collapsed. Falls back to the live layout when the module is absent. + */ + getCanonicalLayout(): SerializedDockview { + return ( + this._responsiveLayoutService?.getCanonicalLayout() ?? this.toJSON() + ); + } + + /** + * Replace the canonical (wide) responsive layout, then re-derive for the + * current width. Falls back to `fromJSON` when the module is absent. + */ + setCanonicalLayout(data: SerializedDockview): void { + if (this._responsiveLayoutService) { + this._responsiveLayoutService.setCanonicalLayout(data); + } else { + this.fromJSON(data); + } + } + /** Whether Smart Guides snapping is currently active. */ get smartGuidesEnabled(): boolean { return this._smartGuidesService?.enabled ?? false; @@ -3058,6 +3120,24 @@ export class DockviewComponent * @returns A JSON respresentation of the layout */ toJSON(): SerializedDockview { + const live = this.serializeLiveLayout(); + + // When the responsive layout is in a derived (collapsed) state, persist + // the *canonical* (wide) grid + panels instead of the live tree, so a + // narrow-width save never bakes the collapse in. Floating/popout/edge + // groups are outside the reflow's scope and always come from live. + const canonical = this._responsiveLayoutService?.serializeCanonical(); + if (canonical) { + return { ...live, grid: canonical.grid, panels: canonical.panels }; + } + return live; + } + + /** + * The raw live layout serialization, bypassing the responsive canonical + * hook. `toJSON` builds on this; rebase uses it to read the derived layout. + */ + serializeLiveLayout(): SerializedDockview { const data = this.gridview.serialize(); const panels = this.panels.reduce( @@ -3196,6 +3276,7 @@ export class DockviewComponent headerPosition, views, activeView, + priority, } = data; if (typeof id !== 'string') { @@ -3209,6 +3290,7 @@ export class DockviewComponent locked: !!locked, hideHeader: !!hideHeader, headerPosition, + priority, }); this._onDidAddGroup.fire(group); @@ -5085,7 +5167,9 @@ export class DockviewComponent } const view = new DockviewGroupPanel(this, id, options); - view.init({ params: {}, accessor: this }); + // the group is the grid leaf, so its layout priority lives on the view + // (inherited from GridviewPanel); apply it via init like a gridview panel + view.init({ params: {}, accessor: this, priority: options.priority }); if (!this._groups.has(view.id)) { const disposable = new CompositeDisposable( diff --git a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts index 101a927ac5..267fe84102 100644 --- a/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts +++ b/packages/dockview-core/src/dockview/dockviewGroupPanelModel.ts @@ -16,6 +16,7 @@ import { DockviewWillShowOverlayLocationEventOptions, } from './events'; import { IViewSize } from '../gridview/gridview'; +import { LayoutPriority } from '../splitview/splitview'; import { CompositeDisposable, IDisposable } from '../lifecycle'; import { IPanel, @@ -70,6 +71,12 @@ interface CoreGroupOptions { constraints?: Partial; initialWidth?: number; initialHeight?: number; + /** + * The group's layout priority within the grid. `LayoutPriority.Fill` makes + * the group absorb surplus/deficit space while its siblings keep fixed + * sizes. Applies to the group (the grid leaf), not individual panels. + */ + priority?: LayoutPriority; } export interface GroupOptions extends CoreGroupOptions { @@ -1322,6 +1329,10 @@ export class DockviewGroupPanelModel result.headerPosition = this.headerPosition; } + if (this.groupPanel?.priority !== undefined) { + result.priority = this.groupPanel.priority; + } + if (this._tabGroups.length > 0) { result.tabGroups = this._tabGroups.map((tg) => tg.toJSON()); } diff --git a/packages/dockview-core/src/dockview/moduleContracts.ts b/packages/dockview-core/src/dockview/moduleContracts.ts index 9a4451060f..461efe7a43 100644 --- a/packages/dockview-core/src/dockview/moduleContracts.ts +++ b/packages/dockview-core/src/dockview/moduleContracts.ts @@ -20,6 +20,7 @@ import { ITabGroup } from './tabGroup'; import { TabGroupColorPalette } from './tabGroupAccent'; import { PopupService } from './components/popupService'; import { + DockviewBreakpointChangeEvent, DockviewComponentOptions, FloatingGroupDragContext, SmartGuidesOptions, @@ -548,3 +549,69 @@ export interface IPinnedTabsService extends IDisposable { index: number ): number; } + +/** + * Narrow surface the {@link IResponsiveLayoutService} reads from the component: + * the observed container box + the coalesced size ping. The host is the + * `DockviewComponent`; this interface keeps the service decoupled from it. + */ +export interface IResponsiveLayoutHost { + readonly options: DockviewComponentOptions; + /** The observed container width (grid box). */ + readonly width: number; + /** The observed container height (grid box). */ + readonly height: number; + /** Coalesced (microtask-buffered) ping after any layout/size change. */ + readonly onDidLayoutChange: Event; + /** Fires after a `fromJSON` load — the signal to re-baseline canonical. */ + readonly onDidLayoutFromJSON: Event; + /** Snapshot the live layout (used to capture / refresh canonical). */ + toJSON(): SerializedDockview; + /** + * Reconcile the live layout to a target. Reuses existing panel instances + * (`reuseExistingPanels`) so a reflow re-parents panels rather than + * re-creating them. + */ + fromJSON( + data: SerializedDockview, + options: { reuseExistingPanels: boolean } + ): void; + /** Whether a group is currently maximized — reflow defers until restore. */ + hasMaximizedGroup(): boolean; + /** + * The live grid serialization, bypassing the responsive `toJSON` canonical + * hook. Used by rebase to read the *derived* (collapsed) layout the user is + * editing, since `toJSON` reports canonical while collapsed. + */ + serializeLiveLayout(): SerializedDockview; + /** Fires after a structural layout mutation (add / remove / move / …). */ + readonly onDidMutateLayout: Event; +} + +/** + * Container-size-driven reflow. Phase 1 resolves the active breakpoint (with + * hysteresis) and fires {@link onDidBreakpointChange}; the reflow transforms + * land in later phases. + */ +export interface IResponsiveLayoutService extends IDisposable { + /** The active breakpoint name, or `undefined` when `responsive` is unset. */ + readonly activeBreakpoint: string | undefined; + /** Fires after the derived layout switches breakpoint. */ + readonly onDidBreakpointChange: Event; + /** Force a re-resolution against the current width. */ + reflow(): void; + /** + * The canonical (wide) `grid` + `panels` for `DockviewComponent.toJSON` to + * persist, or `undefined` to serialize the live tree. Only diverges from + * live once the layout is a derived/collapsed projection. + */ + serializeCanonical(): + | Pick + | undefined; + /** Fires when an edit made while collapsed could not be cleanly rebased. */ + readonly onDidRebaseConflict: Event<{ reason: string }>; + /** The canonical (wide) layout — the frozen baseline while collapsed. */ + getCanonicalLayout(): SerializedDockview; + /** Replace the canonical (wide) layout, then re-derive for the current width. */ + setCanonicalLayout(data: SerializedDockview): void; +} diff --git a/packages/dockview-core/src/dockview/modules.ts b/packages/dockview-core/src/dockview/modules.ts index 9128ef4b77..cf9580fbfc 100644 --- a/packages/dockview-core/src/dockview/modules.ts +++ b/packages/dockview-core/src/dockview/modules.ts @@ -27,6 +27,7 @@ import { ILayoutHistoryService, IMultiRowTabsService, IPinnedTabsService, + IResponsiveLayoutService, ISmartGuidesService, ITabGroupChipsService, } from './moduleContracts'; @@ -50,6 +51,7 @@ export interface ServiceCollection { autoHideEdgeGroupService?: IAutoHideEdgeGroupService; multiRowTabsService?: IMultiRowTabsService; pinnedTabsService?: IPinnedTabsService; + responsiveLayoutService?: IResponsiveLayoutService; } export interface DockviewModule { diff --git a/packages/dockview-core/src/dockview/options.ts b/packages/dockview-core/src/dockview/options.ts index 178f94c2aa..2a80b175b0 100644 --- a/packages/dockview-core/src/dockview/options.ts +++ b/packages/dockview-core/src/dockview/options.ts @@ -562,6 +562,63 @@ export interface DockviewOptions { * module; dormant unless `enabled` is set. */ pinnedTabs?: PinnedTabsOptions; + /** + * Container-size-driven reflow: the layout adapts as its container (not the + * viewport) crosses width breakpoints. Owned by the `ResponsiveLayoutModule`; + * ignored (no reflow, no observer taps) when that module is absent, and + * inert until `breakpoints` are configured. + */ + responsive?: DockviewResponsiveOptions; +} + +/** + * A container-width band for {@link DockviewOptions.responsive}. The active + * breakpoint is the narrowest one whose band the container width sits inside; + * `enterAt`/`exitAt` add hysteresis so a reflow can't bounce the measurement + * back across the threshold. + */ +export interface ResponsiveBreakpoint { + /** Identifier, e.g. `'sm' | 'md' | 'lg'`. Unique within a set. */ + name: string; + /** Active when `containerWidth <= maxWidth`. Use `Infinity` for the widest. */ + maxWidth: number; + /** Collapse into this breakpoint at `<= enterAt`. Defaults to `maxWidth`. */ + enterAt?: number; + /** Expand out of this breakpoint at `>= exitAt`. Defaults to `maxWidth`. */ + exitAt?: number; + /** The reflow transform for this band (reserved for a later phase). */ + rules?: ReflowRule[]; +} + +/** A single reflow transform. The transform set expands in later phases. */ +export type ReflowRule = + | { kind: 'collapseToTabs' } + | { kind: 'restack' } + | { kind: 'hide' }; + +export interface DockviewResponsiveOptions { + /** Which dimension drives reflow. Default `'width'`. */ + observe?: 'width' | 'height' | 'both'; + /** Resize-settle window (ms) before a breakpoint is resolved. Default 120. */ + debounceMs?: number; + /** + * How edits made *while collapsed* are folded back onto the canonical (wide) + * layout. `'auto'` (default) rebases them so widening keeps them; `'discard'` + * treats them as ephemeral (lost on widen); `'manual'` fires + * `onDidRebaseConflict` and leaves canonical untouched. + */ + rebase?: 'auto' | 'discard' | 'manual'; + /** Breakpoints, widest → narrowest. The widest is the canonical band. */ + breakpoints: ResponsiveBreakpoint[]; + /** Default rule chain applied below the widest band unless a breakpoint overrides it. */ + rules?: ReflowRule[]; +} + +/** Fired after the active breakpoint changes. */ +export interface DockviewBreakpointChangeEvent { + from: string | undefined; + to: string; + width: number; } export interface PinnedTabsOptions { @@ -693,6 +750,7 @@ export const PROPERTY_KEYS_DOCKVIEW: (keyof DockviewOptions)[] = (() => { tabGroupColors: undefined, tabGroupAccent: undefined, pinnedTabs: undefined, + responsive: undefined, }; return Object.keys(properties) as (keyof DockviewOptions)[]; diff --git a/packages/dockview-core/src/gridview/branchNode.ts b/packages/dockview-core/src/gridview/branchNode.ts index 084c698307..e1485d8e69 100644 --- a/packages/dockview-core/src/gridview/branchNode.ts +++ b/packages/dockview-core/src/gridview/branchNode.ts @@ -125,7 +125,9 @@ export class BranchNode extends CompositeDisposable implements IView { : c.priority ); - if (priorities.some((p) => p === LayoutPriority.High)) { + if (priorities.some((p) => p === LayoutPriority.Fill)) { + return LayoutPriority.Fill; + } else if (priorities.some((p) => p === LayoutPriority.High)) { return LayoutPriority.High; } else if (priorities.some((p) => p === LayoutPriority.Low)) { return LayoutPriority.Low; diff --git a/packages/dockview-core/src/gridview/gridviewPanel.ts b/packages/dockview-core/src/gridview/gridviewPanel.ts index a82b23a70f..132b35c155 100644 --- a/packages/dockview-core/src/gridview/gridviewPanel.ts +++ b/packages/dockview-core/src/gridview/gridviewPanel.ts @@ -70,6 +70,24 @@ export abstract class GridviewPanel< return this._priority; } + set priority(value: LayoutPriority | undefined) { + if (this._priority === value) { + return; + } + this._priority = value; + // Re-run the whole grid layout so the new priority propagates up the + // branch tree — a nested branch reports its highest child priority to + // its parent, and a purely local splitview relayout would miss that. + // (`_params` is only populated once `init` has run, i.e. for runtime + // changes; the construction-time value is applied through `init`.) + const params = this._params as GridviewInitParameters | undefined; + params?.accessor.layout( + params.accessor.width, + params.accessor.height, + true + ); + } + get snap(): boolean { return this._snap; } diff --git a/packages/dockview-core/src/index.ts b/packages/dockview-core/src/index.ts index 398ab23106..edc82616c0 100644 --- a/packages/dockview-core/src/index.ts +++ b/packages/dockview-core/src/index.ts @@ -212,6 +212,8 @@ export { LayoutHistoryKind, IMultiRowTabsHost, IMultiRowTabsService, + IResponsiveLayoutHost, + IResponsiveLayoutService, ISmartGuidesHost, ISmartGuidesService, SmartGuidesSnapPosition, diff --git a/packages/dockview-core/src/splitview/splitview.ts b/packages/dockview-core/src/splitview/splitview.ts index 7192ac86ba..352dfccf69 100644 --- a/packages/dockview-core/src/splitview/splitview.ts +++ b/packages/dockview-core/src/splitview/splitview.ts @@ -43,6 +43,7 @@ export enum LayoutPriority { Low = 'low', // view is offered space last High = 'high', // view is offered space first Normal = 'normal', // view is offered space in view order + Fill = 'fill', // view absorbs all remaining space; siblings keep fixed sizes } export interface IBaseView extends IDisposable { @@ -674,12 +675,32 @@ export class Splitview { this.addView(view, sizing, to); } + /** + * A view with `LayoutPriority.Fill` absorbs all surplus/deficit space while + * its siblings keep their current sizes. When one is present the splitview + * switches out of proportional redistribution: siblings are held fixed and + * `distributeEmptySpace` funnels the difference into the fill view(s) first. + */ + private hasFillView(): boolean { + // a hidden fill view can't absorb anything, so it doesn't trigger fill + // mode — the layout falls back to normal (proportional) distribution. + return this.viewItems.some( + (item) => item.priority === LayoutPriority.Fill && item.visible + ); + } + public layout(size: number, orthogonalSize: number): void { const previousSize = Math.max(this.size, this._contentSize); this.size = size; this.orthogonalSize = orthogonalSize; - if (!this.proportions) { + if (this.hasFillView()) { + /** + * Fill mode: the siblings keep their sizes; `distributeEmptySpace` + * below hands the entire `size - contentSize` delta to the fill + * view(s), so there is nothing to rescale here. + */ + } else if (!this.proportions) { const indexes = range(this.viewItems.length); const lowPriorityIndexes = indexes.filter( (i) => this.viewItems[i].priority === LayoutPriority.Low @@ -733,13 +754,20 @@ export class Splitview { ): void { const contentSize = this.viewItems.reduce((r, i) => r + i.size, 0); - this.resize( - this.viewItems.length - 1, - this._size - contentSize, - undefined, - lowPriorityIndexes, - highPriorityIndexes - ); + if (!this.hasFillView()) { + this.resize( + this.viewItems.length - 1, + this._size - contentSize, + undefined, + lowPriorityIndexes, + highPriorityIndexes + ); + } + /** + * In fill mode the sibling sizes are already settled (they are held + * fixed); `distributeEmptySpace` funnels the remaining delta into the + * fill view(s), so the `resize` pass above is skipped. + */ this.distributeEmptySpace(); this.layoutViews(); this.saveProportions(); @@ -756,11 +784,48 @@ export class Splitview { const highPriorityIndexes = indexes.filter( (i) => this.viewItems[i].priority === LayoutPriority.High ); + const fillPriorityIndexes = indexes.filter( + (i) => + this.viewItems[i].priority === LayoutPriority.Fill && + this.viewItems[i].visible + ); + + /** + * Fill views take precedence over every other priority: they share the + * surplus/deficit equally between them and absorb it entirely, leaving + * their siblings at a fixed size. Only once every fill view has hit a + * min/max constraint does any leftover spill to the ordered siblings + * below. + */ + if (fillPriorityIndexes.length > 0) { + let remaining = fillPriorityIndexes.length; + for (const index of fillPriorityIndexes) { + if (emptyDelta === 0) { + break; + } + const item = this.viewItems[index]; + const share = Math.round(emptyDelta / remaining); + const size = clamp( + item.size + share, + item.minimumSize, + item.maximumSize + ); + emptyDelta -= size - item.size; + item.size = size; + remaining--; + } + } for (const index of highPriorityIndexes) { pushToStart(indexes, index); } + // fills stay ahead of high priority in the fallback ordering too, so any + // leftover from a clamped fill lands on another fill before a sibling. + for (const index of fillPriorityIndexes) { + pushToStart(indexes, index); + } + for (const index of lowPriorityIndexes) { pushToEnd(indexes, index); } diff --git a/packages/dockview-modules/src/__tests__/responsiveBreakpointResolver.spec.ts b/packages/dockview-modules/src/__tests__/responsiveBreakpointResolver.spec.ts new file mode 100644 index 0000000000..621a7ce7d8 --- /dev/null +++ b/packages/dockview-modules/src/__tests__/responsiveBreakpointResolver.spec.ts @@ -0,0 +1,157 @@ +import { BreakpointResolver } from '../responsiveBreakpointResolver'; +import { ResponsiveBreakpoint } from 'dockview-core'; + +describe('BreakpointResolver', () => { + // widest -> narrowest, as a user would author them + const BREAKPOINTS: ResponsiveBreakpoint[] = [ + { name: 'lg', maxWidth: Infinity }, + { name: 'md', maxWidth: 1000, exitAt: 1080 }, + { name: 'sm', maxWidth: 640, exitAt: 720 }, + ]; + + describe('natural resolution (first run, no current breakpoint)', () => { + const resolver = new BreakpointResolver(BREAKPOINTS); + + test.each([ + [1500, 'lg'], + [1001, 'lg'], + [1000, 'md'], + [800, 'md'], + [641, 'md'], + [640, 'sm'], + [300, 'sm'], + ])('width %ipx -> %s', (width, expected) => { + expect(resolver.resolve(width)).toBe(expected); + }); + + test('exposes names narrowest-first', () => { + expect(resolver.names).toEqual(['sm', 'md', 'lg']); + }); + }); + + describe('hysteresis', () => { + const resolver = new BreakpointResolver(BREAKPOINTS); + + test('collapses at enterAt (= maxWidth by default)', () => { + // coming from md, drop to 640 -> collapses to sm + expect(resolver.resolve(640, 'md')).toBe('sm'); + // 641 stays md + expect(resolver.resolve(641, 'md')).toBe('md'); + }); + + test('does not expand until exitAt (hysteresis dead band)', () => { + // in sm, growing back through the dead band [640, 720] + expect(resolver.resolve(650, 'sm')).toBe('sm'); + expect(resolver.resolve(700, 'sm')).toBe('sm'); + expect(resolver.resolve(719, 'sm')).toBe('sm'); + // only expands at exitAt + expect(resolver.resolve(720, 'sm')).toBe('md'); + }); + + test('the dead band is direction-dependent (sticky to current)', () => { + // width 700 is inside sm's dead band [640,720] + expect(resolver.resolve(700, 'sm')).toBe('sm'); // stays collapsed + expect(resolver.resolve(700, 'md')).toBe('md'); // stays expanded + }); + + test('md has its own dead band [1000, 1080]', () => { + expect(resolver.resolve(1000, 'lg')).toBe('md'); // collapse lg->md at 1000 + expect(resolver.resolve(1050, 'md')).toBe('md'); // stick in the band + expect(resolver.resolve(1080, 'md')).toBe('lg'); // expand at exitAt + }); + }); + + describe('multi-level jumps', () => { + const resolver = new BreakpointResolver(BREAKPOINTS); + + test('collapses across two breakpoints in one step (lg -> sm)', () => { + expect(resolver.resolve(400, 'lg')).toBe('sm'); + }); + + test('expands across two breakpoints in one step (sm -> lg)', () => { + expect(resolver.resolve(2000, 'sm')).toBe('lg'); + }); + }); + + describe('anti-thrash: a reflow-induced width jitter never oscillates', () => { + const resolver = new BreakpointResolver(BREAKPOINTS); + + test('a small width bounce inside the dead band cannot flip the breakpoint', () => { + // The container shrinks to 640 and collapses to sm. Collapsing tabs a + // group, which can nudge the *measured* width back up by a few px. + // Hysteresis must absorb that so it does not immediately re-expand. + let bp = resolver.resolve(640, 'md'); + expect(bp).toBe('sm'); + + // reflow nudges the measurement up by 40px (still < the 80px band) + bp = resolver.resolve(680, bp); + expect(bp).toBe('sm'); // did NOT bounce back to md + + // and settling anywhere in the band stays put + for (const w of [641, 700, 715, 660]) { + bp = resolver.resolve(w, bp); + expect(bp).toBe('sm'); + } + }); + + test('a continuous resize sweep crosses each boundary exactly once per direction', () => { + const visited: string[] = []; + let bp: string | undefined; + + // sweep down 1200 -> 300 then back up 300 -> 1200 in 10px steps + const widths: number[] = []; + for (let w = 1200; w >= 300; w -= 10) widths.push(w); + for (let w = 300; w <= 1200; w += 10) widths.push(w); + + for (const w of widths) { + const next = resolver.resolve(w, bp); + if (next !== bp) { + visited.push(next as string); + } + bp = next; + } + + // Down (lg->md->sm) then up (sm->md->lg): each boundary crossed once + // per direction, no repeats — i.e. no strobing at any threshold. + expect(visited).toEqual(['lg', 'md', 'sm', 'md', 'lg']); + }); + }); + + describe('edge cases', () => { + test('empty breakpoint set resolves to undefined', () => { + const resolver = new BreakpointResolver([]); + expect(resolver.resolve(800)).toBeUndefined(); + }); + + test('single breakpoint always resolves to itself', () => { + const resolver = new BreakpointResolver([ + { name: 'only', maxWidth: Infinity }, + ]); + expect(resolver.resolve(50, 'only')).toBe('only'); + expect(resolver.resolve(5000)).toBe('only'); + }); + + test('unordered input is normalized (author order does not matter)', () => { + const resolver = new BreakpointResolver([ + { name: 'sm', maxWidth: 640 }, + { name: 'lg', maxWidth: Infinity }, + { name: 'md', maxWidth: 1000 }, + ]); + expect(resolver.resolve(800)).toBe('md'); + expect(resolver.names).toEqual(['sm', 'md', 'lg']); + }); + + test('a stale current name falls back to natural resolution', () => { + const resolver = new BreakpointResolver(BREAKPOINTS); + expect(resolver.resolve(800, 'does-not-exist')).toBe('md'); + }); + + test('width beyond the widest maxWidth maps to the widest', () => { + const resolver = new BreakpointResolver([ + { name: 'sm', maxWidth: 640 }, + { name: 'wide', maxWidth: 1200 }, + ]); + expect(resolver.resolve(99999, 'sm')).toBe('wide'); + }); + }); +}); diff --git a/packages/dockview-modules/src/__tests__/responsiveCanonicalStore.spec.ts b/packages/dockview-modules/src/__tests__/responsiveCanonicalStore.spec.ts new file mode 100644 index 0000000000..1e220c00b5 --- /dev/null +++ b/packages/dockview-modules/src/__tests__/responsiveCanonicalStore.spec.ts @@ -0,0 +1,48 @@ +import { SerializedDockview } from 'dockview-core'; +import { CanonicalStore } from '../responsiveCanonicalStore'; + +const layout = (width: number): SerializedDockview => + ({ + grid: { + root: { type: 'leaf', data: { views: [], id: '1' } }, + width, + height: 500, + orientation: 'HORIZONTAL', + }, + panels: {}, + }) as unknown as SerializedDockview; + +describe('CanonicalStore', () => { + test('starts empty', () => { + const store = new CanonicalStore(); + expect(store.has()).toBe(false); + }); + + test('get throws before a value is set', () => { + const store = new CanonicalStore(); + expect(() => store.get()).toThrow(/canonical layout has not been set/); + }); + + test('set / get / has round-trip', () => { + const store = new CanonicalStore(); + const l = layout(1000); + store.set(l); + expect(store.has()).toBe(true); + expect(store.get()).toBe(l); + }); + + test('set replaces the previous value', () => { + const store = new CanonicalStore(); + store.set(layout(1000)); + store.set(layout(600)); + expect(store.get().grid.width).toBe(600); + }); + + test('clear empties the store', () => { + const store = new CanonicalStore(); + store.set(layout(1000)); + store.clear(); + expect(store.has()).toBe(false); + expect(() => store.get()).toThrow(); + }); +}); diff --git a/packages/dockview-modules/src/__tests__/responsiveLayout.spec.ts b/packages/dockview-modules/src/__tests__/responsiveLayout.spec.ts new file mode 100644 index 0000000000..b4ce988ba3 --- /dev/null +++ b/packages/dockview-modules/src/__tests__/responsiveLayout.spec.ts @@ -0,0 +1,499 @@ +import { + DockviewComponent, + DockviewBreakpointChangeEvent, + IContentRenderer, + LayoutPriority, +} from 'dockview-core'; + +class TestPanel implements IContentRenderer { + element = document.createElement('div'); + init(): void { + // noop + } + layout(): void { + // noop + } + dispose(): void { + // noop + } +} + +/** + * ResponsiveLayout — Phase 1 (seam + skeleton). The module resolves the active + * breakpoint from the container width (hysteresis + debounce) and fires + * `onDidBreakpointChange`. No reflow transforms yet. + * + * Uses `debounceMs: 0` + `reflow()` for deterministic, synchronous resolution + * (the debounce itself is unit-tested in `responsiveSizeObserver.spec.ts`). + */ +describe('ResponsiveLayout module', () => { + let container: HTMLElement; + + const BREAKPOINTS = [ + { name: 'lg', maxWidth: Infinity }, + { name: 'md', maxWidth: 1000, exitAt: 1080 }, + { name: 'sm', maxWidth: 640, exitAt: 720 }, + ]; + + const make = (responsive?: DockviewComponent['options']['responsive']) => { + container = document.createElement('div'); + document.body.appendChild(container); + return new DockviewComponent(container, { + createComponent: () => new TestPanel(), + responsive, + }); + }; + + afterEach(() => { + container.remove(); + }); + + test('resolves + fires onDidBreakpointChange as the container crosses breakpoints', () => { + const dockview = make({ debounceMs: 0, breakpoints: BREAKPOINTS }); + + const events: DockviewBreakpointChangeEvent[] = []; + dockview.onDidBreakpointChange((e) => events.push(e)); + + const at = (width: number): string | undefined => { + dockview.layout(width, 500); + dockview.reflow(); + return dockview.activeBreakpoint; + }; + + expect(at(1200)).toBe('lg'); + expect(at(800)).toBe('md'); + expect(at(500)).toBe('sm'); + + // hysteresis: growing back into the dead band [640, 720] stays 'sm' + expect(at(700)).toBe('sm'); + // and only expands at exitAt + expect(at(720)).toBe('md'); + + expect(events.map((e) => e.to)).toEqual(['lg', 'md', 'sm', 'md']); + expect(events[0]).toMatchObject({ from: 'sm', to: 'lg', width: 1200 }); + }); + + test('is inert when `responsive` is not configured', () => { + const dockview = make(); + + const events: DockviewBreakpointChangeEvent[] = []; + dockview.onDidBreakpointChange((e) => events.push(e)); + + dockview.layout(1200, 500); + dockview.reflow(); + dockview.layout(400, 500); + dockview.reflow(); + + expect(dockview.activeBreakpoint).toBeUndefined(); + expect(events).toEqual([]); + }); + + test('is inert with an empty breakpoint set', () => { + const dockview = make({ breakpoints: [] }); + dockview.layout(400, 500); + dockview.reflow(); + expect(dockview.activeBreakpoint).toBeUndefined(); + }); + + test('exposes the same surface through the public api', () => { + const dockview = make({ debounceMs: 0, breakpoints: BREAKPOINTS }); + const api = dockview.api; + + const events: DockviewBreakpointChangeEvent[] = []; + api.onDidBreakpointChange((e) => events.push(e)); + + // baseline at construction (width 0) is already the narrowest ('sm'), + // so widen to force a real change through the public surface + dockview.layout(1200, 500); + api.reflow(); + + expect(api.activeBreakpoint).toBe('lg'); + expect(events.map((e) => e.to)).toEqual(['lg']); + }); + + // --- Phase 2: canonical / derived split + serialization --- + + test('toJSON round-trips unchanged with the module active (identity)', () => { + const dockview = make({ debounceMs: 0, breakpoints: BREAKPOINTS }); + dockview.layout(1000, 500); + dockview.api.addPanel({ id: 'p1', component: 'default' }); + dockview.api.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'right' }, + }); + + // drive a couple of breakpoint changes — the derived layout is identity, + // so nothing collapses and the serialized layout is unaffected + dockview.layout(500, 500); + dockview.reflow(); + dockview.layout(1200, 500); + dockview.reflow(); + + const saved = dockview.toJSON(); + + const restored = make({ debounceMs: 0, breakpoints: BREAKPOINTS }); + restored.layout(1200, 500); + restored.fromJSON(saved); + + expect(restored.panels.map((p) => p.id).sort()).toEqual(['p1', 'p2']); + // and re-serializing the restored layout matches the saved one + expect(JSON.stringify(restored.toJSON())).toBe(JSON.stringify(saved)); + }); + + test('serialization is idempotent across a breakpoint sweep', () => { + const dockview = make({ debounceMs: 0, breakpoints: BREAKPOINTS }); + dockview.layout(1000, 500); + dockview.api.addPanel({ id: 'p1', component: 'default' }); + dockview.api.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'below' }, + }); + + dockview.layout(1200, 500); + dockview.reflow(); + const before = JSON.stringify(dockview.toJSON()); + + // sweep down and back up — identity reflow must not mutate the layout + for (const w of [800, 500, 700, 720, 1200]) { + dockview.layout(w, 500); + dockview.reflow(); + } + + const after = JSON.stringify(dockview.toJSON()); + expect(after).toBe(before); + }); + + test('serializeCanonical returns undefined while not collapsed (Phase 2)', () => { + const dockview = make({ debounceMs: 0, breakpoints: BREAKPOINTS }); + dockview.layout(500, 500); + dockview.reflow(); + // the toJSON hook falls back to the live tree — identical to a component + // without the responsive option configured + const withResponsive = JSON.stringify(dockview.toJSON()); + + const plain = make(); + plain.layout(500, 500); + expect(JSON.stringify(plain.toJSON())).toBe(withResponsive); + }); + + // --- Phase 4: applying the derived layout to the live component --- + + describe('apply (collapse reaches the live layout)', () => { + // 'sm' collapses side-by-side groups to tabs; 'lg' is canonical (identity) + const COLLAPSE_BP = [ + { name: 'lg', maxWidth: Infinity }, + { + name: 'sm', + maxWidth: 640, + exitAt: 720, + rules: [{ kind: 'collapseToTabs' as const }], + }, + ]; + + const withThreeGroups = () => { + const dv = make({ debounceMs: 0, breakpoints: COLLAPSE_BP }); + dv.layout(1000, 500); + const p1 = dv.api.addPanel({ id: 'p1', component: 'default' }); + dv.api.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'right' }, + }); + dv.api.addPanel({ + id: 'p3', + component: 'default', + position: { direction: 'right' }, + }); + dv.reflow(); + return { dv, p1 }; + }; + + test('collapses side-by-side groups into one on narrow, restores on wide', () => { + const { dv } = withThreeGroups(); + expect(dv.activeBreakpoint).toBe('lg'); + expect(dv.groups.length).toBe(3); + + dv.layout(500, 500); + dv.reflow(); + expect(dv.activeBreakpoint).toBe('sm'); + expect(dv.groups.length).toBe(1); + expect(dv.groups[0].panels.map((p) => p.id).sort()).toEqual([ + 'p1', + 'p2', + 'p3', + ]); + + dv.layout(1000, 500); + dv.reflow(); + expect(dv.activeBreakpoint).toBe('lg'); + expect(dv.groups.length).toBe(3); + }); + + test('reuses panel instances across a collapse (no teardown)', () => { + const { dv, p1 } = withThreeGroups(); + dv.layout(500, 500); + dv.reflow(); + expect(dv.api.getPanel('p1')).toBe(p1); + }); + + test('serializes the canonical (wide) layout while collapsed', () => { + const { dv } = withThreeGroups(); + dv.layout(500, 500); + dv.reflow(); + expect(dv.groups.length).toBe(1); // collapsed live + + const saved = dv.toJSON(); + + // loading the save into a fresh wide component restores 3 groups — + // proving the *wide* canonical was serialized, not the collapse + const restored = make({ debounceMs: 0, breakpoints: COLLAPSE_BP }); + restored.layout(1000, 500); + restored.fromJSON(saved); + restored.reflow(); + expect(restored.groups.length).toBe(3); + }); + + test('fires one breakpoint-change event per transition (no re-entrant reflow)', () => { + const { dv } = withThreeGroups(); + const events: string[] = []; + dv.onDidBreakpointChange((e) => events.push(e.to)); + + dv.layout(500, 500); + dv.reflow(); + dv.layout(1000, 500); + dv.reflow(); + + expect(events).toEqual(['sm', 'lg']); + }); + + test('defers reflow while a group is maximized, then applies on restore', () => { + const { dv, p1 } = withThreeGroups(); + + p1.api.group.api.maximize(); + dv.layout(500, 500); + dv.reflow(); + expect(dv.groups.length).toBe(3); // deferred — not collapsed + + dv.exitMaximizedGroup(); + dv.reflow(); + expect(dv.groups.length).toBe(1); // now applied + }); + + test('restack applies to the live layout without losing panels', () => { + const dv = make({ + debounceMs: 0, + breakpoints: [ + { name: 'lg', maxWidth: Infinity }, + { + name: 'sm', + maxWidth: 640, + exitAt: 720, + rules: [{ kind: 'restack' as const }], + }, + ], + }); + dv.layout(1000, 500); + dv.api.addPanel({ id: 'p1', component: 'default' }); + dv.api.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'below' }, + }); + dv.reflow(); + + dv.layout(500, 500); + dv.reflow(); // restack + expect(dv.api.panels.map((p) => p.id).sort()).toEqual(['p1', 'p2']); + + dv.layout(1000, 500); + dv.reflow(); // widen — restore + expect(dv.api.panels.map((p) => p.id).sort()).toEqual(['p1', 'p2']); + }); + + // --- Phase 6: rebase edits made while collapsed --- + + test('auto-rebase: closing a tab while collapsed keeps it gone after widening', () => { + const { dv } = withThreeGroups(); + dv.layout(500, 500); + dv.reflow(); // collapse to 1 group (p1,p2,p3) + expect(dv.groups.length).toBe(1); + + // close p2 while collapsed + dv.api.removePanel(dv.api.getPanel('p2')!); + + dv.layout(1000, 500); + dv.reflow(); // widen — canonical was rebased, so p2 stays gone + expect(dv.api.panels.map((p) => p.id).sort()).toEqual(['p1', 'p3']); + }); + + test('auto-rebase: a panel added while collapsed survives widening', () => { + const { dv } = withThreeGroups(); + dv.layout(500, 500); + dv.reflow(); + + dv.api.addPanel({ id: 'p4', component: 'default' }); // into the collapsed group + + dv.layout(1000, 500); + dv.reflow(); + expect(dv.api.panels.map((p) => p.id).sort()).toEqual([ + 'p1', + 'p2', + 'p3', + 'p4', + ]); + }); + + test('rebase:discard drops edits made while collapsed', () => { + const dv = make({ + debounceMs: 0, + rebase: 'discard', + breakpoints: COLLAPSE_BP, + }); + dv.layout(1000, 500); + dv.api.addPanel({ id: 'p1', component: 'default' }); + dv.api.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'right' }, + }); + dv.reflow(); + + dv.layout(500, 500); + dv.reflow(); // collapse + dv.api.removePanel(dv.api.getPanel('p2')!); // close while collapsed + + dv.layout(1000, 500); + dv.reflow(); // widen — discard restores the frozen (pre-edit) canonical + expect(dv.api.panels.map((p) => p.id).sort()).toEqual(['p1', 'p2']); + }); + + test('rebase:manual fires onDidRebaseConflict instead of rebasing', () => { + const dv = make({ + debounceMs: 0, + rebase: 'manual', + breakpoints: COLLAPSE_BP, + }); + dv.layout(1000, 500); + dv.api.addPanel({ id: 'p1', component: 'default' }); + dv.api.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'right' }, + }); + dv.reflow(); + + const conflicts: { reason: string }[] = []; + dv.onDidRebaseConflict((e) => conflicts.push(e)); + + dv.layout(500, 500); + dv.reflow(); // collapse + dv.api.removePanel(dv.api.getPanel('p2')!); // edit while collapsed + + expect(conflicts.length).toBeGreaterThan(0); + }); + + test('hide parks a low-priority group but keeps its panel alive', () => { + const dv = make({ + debounceMs: 0, + breakpoints: [ + { name: 'lg', maxWidth: Infinity }, + { + name: 'sm', + maxWidth: 640, + exitAt: 720, + rules: [{ kind: 'hide' as const }], + }, + ], + }); + dv.layout(1000, 500); + dv.api.addPanel({ id: 'p1', component: 'default' }); + const p2 = dv.api.addPanel({ + id: 'p2', + component: 'default', + position: { direction: 'right' }, + }); + // mark p2's group low-priority so `hide` parks it + p2.api.group.api.priority = LayoutPriority.Low; + dv.reflow(); + + dv.layout(500, 500); + dv.reflow(); // hide + + // the panel survives (parked, not destroyed) + expect(dv.api.getPanel('p2')).toBeDefined(); + expect(dv.api.panels.map((p) => p.id).sort()).toEqual(['p1', 'p2']); + + dv.layout(1000, 500); + dv.reflow(); // widen — restore + expect(dv.api.panels.map((p) => p.id).sort()).toEqual(['p1', 'p2']); + }); + + // --- Phase 7: public canonical API + float/popout exclusion --- + + const countLeaves = (root: unknown): number => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const n = root as any; + return n.type === 'leaf' + ? 1 + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + n.data.reduce( + (sum: number, c: any) => sum + countLeaves(c), + 0 + ); + }; + + test('getCanonicalLayout reports the wide layout even while collapsed', () => { + const { dv } = withThreeGroups(); + dv.layout(500, 500); + dv.reflow(); // collapse to 1 group + expect(dv.groups.length).toBe(1); + + // canonical still describes the wide (3-group) layout + const canonical = dv.api.getCanonicalLayout(); + expect(countLeaves(canonical.grid.root)).toBe(3); + }); + + test('setCanonicalLayout replaces the wide layout and re-derives', () => { + // build a 2-group layout to install as the new canonical + const source = make({ debounceMs: 0, breakpoints: COLLAPSE_BP }); + source.layout(1000, 500); + source.api.addPanel({ id: 'x1', component: 'default' }); + source.api.addPanel({ + id: 'x2', + component: 'default', + position: { direction: 'right' }, + }); + const newCanonical = source.toJSON(); + + const { dv } = withThreeGroups(); + dv.layout(500, 500); + dv.reflow(); // collapse + dv.api.setCanonicalLayout(newCanonical); + + dv.layout(1000, 500); + dv.reflow(); // widen — the new canonical is what expands + expect(dv.api.panels.map((p) => p.id).sort()).toEqual(['x1', 'x2']); + }); + + test('floating groups are untouched across a collapse/widen', () => { + const { dv, p1 } = withThreeGroups(); + dv.addFloatingGroup(p1.api.group); // float p1 out of the grid + dv.reflow(); + + dv.layout(500, 500); + dv.reflow(); // collapse the grid + dv.layout(1000, 500); + dv.reflow(); // widen + + // the floated panel survives the reflow intact + expect(dv.api.getPanel('p1')).toBeDefined(); + expect(dv.api.panels.map((p) => p.id).sort()).toEqual([ + 'p1', + 'p2', + 'p3', + ]); + }); + }); +}); diff --git a/packages/dockview-modules/src/__tests__/responsiveRebase.spec.ts b/packages/dockview-modules/src/__tests__/responsiveRebase.spec.ts new file mode 100644 index 0000000000..3a621e7911 --- /dev/null +++ b/packages/dockview-modules/src/__tests__/responsiveRebase.spec.ts @@ -0,0 +1,148 @@ +import { SerializedDockview } from 'dockview-core'; +import { rebaseCanonical } from '../responsiveRebase'; + +/** Build a horizontal layout from a list of groups. */ +const layout = ( + groups: { id: string; views: string[]; active?: string }[], + activeGroup?: string +): SerializedDockview => + ({ + grid: { + root: { + type: 'branch', + data: groups.map((g) => ({ + type: 'leaf', + size: 100, + data: { + id: g.id, + views: g.views, + activeView: g.active ?? g.views[0], + }, + })), + }, + width: 1000, + height: 500, + orientation: 'HORIZONTAL', + }, + panels: Object.fromEntries( + groups.flatMap((g) => g.views.map((v) => [v, { id: v }])) + ), + activeGroup: activeGroup ?? groups[0]?.id, + }) as unknown as SerializedDockview; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const leaves = (l: SerializedDockview): any[] => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const walk = (n: any): any[] => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + n.type === 'leaf' ? [n.data] : n.data.flatMap((c: any) => walk(c)); + return walk(l.grid.root); +}; +const ids = (l: SerializedDockview): string[] => + leaves(l) + .flatMap((g) => g.views) + .sort(); + +describe('rebaseCanonical', () => { + test('closing a panel while collapsed removes it from canonical', () => { + const canonical = layout([ + { id: 'g1', views: ['a', 'b'] }, + { id: 'g2', views: ['c'] }, + ]); + // derived (collapsed) with 'b' closed + const live = layout([{ id: 'g1', views: ['a', 'c'] }]); + + const { canonical: next } = rebaseCanonical(canonical, live); + expect(ids(next)).toEqual(['a', 'c']); + // the surviving groups keep their structure + expect(leaves(next).find((g) => g.id === 'g1').views).toEqual(['a']); + expect(leaves(next).find((g) => g.id === 'g2').views).toEqual(['c']); + expect(next.panels.b).toBeUndefined(); + }); + + test('closing the last panel of a group prunes the empty group', () => { + const canonical = layout([ + { id: 'g1', views: ['a'] }, + { id: 'g2', views: ['b', 'c'] }, + ]); + const live = layout([{ id: 'g2', views: ['b', 'c'] }]); // 'a' closed + + const { canonical: next } = rebaseCanonical(canonical, live); + expect(ids(next)).toEqual(['b', 'c']); + expect(leaves(next)).toHaveLength(1); // g1 pruned, branch collapsed + expect(leaves(next)[0].id).toBe('g2'); + }); + + test('adding a panel while collapsed inserts it into the active canonical group', () => { + const canonical = layout( + [ + { id: 'g1', views: ['a'] }, + { id: 'g2', views: ['b'] }, + ], + 'g2' + ); + const live = layout([{ id: 'g1', views: ['a', 'b', 'x'] }]); // 'x' added + + const { canonical: next } = rebaseCanonical(canonical, live); + expect(ids(next)).toEqual(['a', 'b', 'x']); + // inserted into the active group (g2) + expect(leaves(next).find((g) => g.id === 'g2').views).toContain('x'); + expect(next.panels.x).toBeDefined(); + }); + + test('activating a different tab updates the canonical active group + view', () => { + const canonical = layout( + [ + { id: 'g1', views: ['a', 'b'], active: 'a' }, + { id: 'g2', views: ['c'] }, + ], + 'g1' + ); + // collapsed, user activated 'c' + const live = layout([ + { id: 'g1', views: ['a', 'b', 'c'], active: 'c' }, + ]); + + const { canonical: next } = rebaseCanonical(canonical, live); + expect(next.activeGroup).toBe('g2'); + expect(leaves(next).find((g) => g.id === 'g2').activeView).toBe('c'); + }); + + test('an unchanged panel set is a no-op', () => { + const canonical = layout([ + { id: 'g1', views: ['a'] }, + { id: 'g2', views: ['b'] }, + ]); + const live = layout([{ id: 'g1', views: ['a', 'b'] }]); // same panels + const { canonical: next, conflict } = rebaseCanonical(canonical, live); + expect(ids(next)).toEqual(['a', 'b']); + expect(conflict).toBeUndefined(); + }); + + test('does not mutate the input canonical', () => { + const canonical = layout([ + { id: 'g1', views: ['a', 'b'] }, + { id: 'g2', views: ['c'] }, + ]); + const snapshot = JSON.stringify(canonical); + rebaseCanonical(canonical, layout([{ id: 'g1', views: ['a'] }])); + expect(JSON.stringify(canonical)).toBe(snapshot); + }); + + test('reports a conflict when an added panel has no canonical group to land in', () => { + const empty = { + grid: { + root: { type: 'branch', data: [] }, + width: 1000, + height: 500, + orientation: 'HORIZONTAL', + }, + panels: {}, + activeGroup: undefined, + } as unknown as SerializedDockview; + const live = layout([{ id: 'g1', views: ['x'] }]); + + const { conflict } = rebaseCanonical(empty, live); + expect(conflict).toMatch(/no group/); + }); +}); diff --git a/packages/dockview-modules/src/__tests__/responsiveReflowEngine.spec.ts b/packages/dockview-modules/src/__tests__/responsiveReflowEngine.spec.ts new file mode 100644 index 0000000000..5a22086802 --- /dev/null +++ b/packages/dockview-modules/src/__tests__/responsiveReflowEngine.spec.ts @@ -0,0 +1,363 @@ +import { LayoutPriority, SerializedDockview } from 'dockview-core'; +import { + computeGroupPriority, + deriveLayout, + diffLayouts, +} from '../responsiveReflowEngine'; + +/** + * `[g1(a,b) | g2(c) | g3(d)]` side by side, active group = g1. + * Priorities are passed in so tests can vary them. + */ +const makeMulti = ( + priorities: { + g1?: LayoutPriority; + g2?: LayoutPriority; + g3?: LayoutPriority; + } = {}, + activeGroup = 'g1' +): SerializedDockview => + ({ + grid: { + root: { + type: 'branch', + data: [ + { + type: 'leaf', + size: 300, + data: { + id: 'g1', + views: ['a', 'b'], + activeView: 'a', + priority: priorities.g1, + }, + }, + { + type: 'leaf', + size: 400, + data: { + id: 'g2', + views: ['c'], + activeView: 'c', + priority: priorities.g2, + }, + }, + { + type: 'leaf', + size: 300, + data: { + id: 'g3', + views: ['d'], + activeView: 'd', + priority: priorities.g3, + }, + }, + ], + }, + width: 1000, + height: 500, + orientation: 'HORIZONTAL', + }, + panels: { + a: { id: 'a' }, + b: { id: 'b' }, + c: { id: 'c' }, + d: { id: 'd' }, + }, + activeGroup, + }) as unknown as SerializedDockview; + +const COLLAPSE = [{ kind: 'collapseToTabs' as const }]; + +// the single merged group inside the collapsed root (a branch with one leaf) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const collapsedGroup = (l: SerializedDockview): any => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (l.grid.root as any).data[0].data; + +// every leaf node (with `visible` + `data`) in the grid tree +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const leafNodes = (l: SerializedDockview): any[] => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const walk = (n: any): any[] => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + n.type === 'leaf' ? [n] : n.data.flatMap((c: any) => walk(c)); + return walk(l.grid.root); +}; + +const make = (): SerializedDockview => + ({ + grid: { + root: { + type: 'branch', + data: [ + { + type: 'leaf', + data: { views: ['a'], id: '1' }, + size: 500, + }, + { + type: 'leaf', + data: { views: ['b'], id: '2' }, + size: 500, + }, + ], + }, + width: 1000, + height: 500, + orientation: 'HORIZONTAL', + }, + panels: { + a: { id: 'a', contentComponent: 'x' }, + b: { id: 'b', contentComponent: 'y' }, + }, + activeGroup: '1', + }) as unknown as SerializedDockview; + +describe('reflow engine (Phase 2 — identity)', () => { + describe('deriveLayout', () => { + test('identity: the derived layout is byte-identical to canonical', () => { + const canonical = make(); + const derived = deriveLayout(canonical, []); + expect(derived).toEqual(canonical); + }); + + test('returns an independent clone (mutating derived leaves canonical intact)', () => { + const canonical = make(); + const derived = deriveLayout(canonical, []); + + expect(derived).not.toBe(canonical); + (derived.grid as { width: number }).width = 1; + delete derived.panels.a; + + expect(canonical.grid.width).toBe(1000); + expect(canonical.panels.a).toBeDefined(); + }); + + test('an empty rule chain is the identity', () => { + const canonical = make(); + expect(deriveLayout(canonical, [])).toEqual(canonical); + }); + }); + + describe('diffLayouts', () => { + test('identical layouts produce zero ops (the idempotence guard)', () => { + const live = make(); + const target = deriveLayout(live, []); + expect(diffLayouts(live, target)).toEqual([]); + }); + + test('a grid difference is detected', () => { + const live = make(); + const target = make(); + (target.grid as { width: number }).width = 640; + expect(diffLayouts(live, target)).toEqual([{ kind: 'replace' }]); + }); + + test('a panels difference is detected', () => { + const live = make(); + const target = make(); + target.panels.a = { id: 'a', contentComponent: 'changed' } as never; + expect(diffLayouts(live, target)).toEqual([{ kind: 'replace' }]); + }); + + test('floating/popout differences are ignored (out of reflow scope)', () => { + const live = make(); + const target = make(); + (target as { floatingGroups: unknown[] }).floatingGroups = [ + { id: 'f' }, + ]; + expect(diffLayouts(live, target)).toEqual([]); + }); + }); +}); + +describe('reflow engine — Phase 3 (CollapsePass)', () => { + describe('computeGroupPriority', () => { + test('Fill outranks High outranks Normal outranks Low', () => { + const p = (v?: LayoutPriority) => + computeGroupPriority({ priority: v }, false); + expect(p(LayoutPriority.Fill)).toBeGreaterThan( + p(LayoutPriority.High) + ); + expect(p(LayoutPriority.High)).toBeGreaterThan( + p(LayoutPriority.Normal) + ); + expect(p(LayoutPriority.Normal)).toBeGreaterThan( + p(LayoutPriority.Low) + ); + expect(p(undefined)).toBe(p(LayoutPriority.Normal)); + }); + + test('the active group gets a tie-break bonus over an equal-priority peer', () => { + expect(computeGroupPriority({}, true)).toBeGreaterThan( + computeGroupPriority({}, false) + ); + // ...but the bonus never lifts Normal above High + expect(computeGroupPriority({}, true)).toBeLessThan( + computeGroupPriority({ priority: LayoutPriority.High }, false) + ); + }); + }); + + describe('collapseToTabs', () => { + test('folds side-by-side groups into a single tabbed group', () => { + const derived = deriveLayout(makeMulti(), COLLAPSE); + // the root stays a branch (dockview requires it) with one leaf + expect(derived.grid.root.type).toBe('branch'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((derived.grid.root as any).data).toHaveLength(1); + expect(collapsedGroup(derived).views).toHaveLength(4); + }); + + test('orders tabs by priority (Fill first), document order breaking ties', () => { + // g2 = Fill, g1/g3 = Normal; active = g1 (bonus lifts it over g3) + const derived = deriveLayout( + makeMulti({ g2: LayoutPriority.Fill }), + COLLAPSE + ); + // g2(c) first (Fill), then g1(a,b) (Normal+active), then g3(d) + expect(collapsedGroup(derived).views).toEqual(['c', 'a', 'b', 'd']); + }); + + test('the highest-priority group is the host (its id survives)', () => { + const derived = deriveLayout( + makeMulti({ g2: LayoutPriority.Fill }), + COLLAPSE + ); + expect(collapsedGroup(derived).id).toBe('g2'); + expect(derived.activeGroup).toBe('g2'); + }); + + test('keeps the user on the globally-active panel', () => { + // active group g1, active panel 'a' — even though g2(Fill) is host + const derived = deriveLayout( + makeMulti({ g2: LayoutPriority.Fill }, 'g1'), + COLLAPSE + ); + expect(collapsedGroup(derived).activeView).toBe('a'); + }); + + test('a single-group layout is unchanged (nothing to fold)', () => { + const single = { + grid: { + root: { + type: 'leaf', + data: { id: 'only', views: ['a'], activeView: 'a' }, + }, + width: 800, + height: 500, + orientation: 'HORIZONTAL', + }, + panels: { a: { id: 'a' } }, + activeGroup: 'only', + } as unknown as SerializedDockview; + expect(deriveLayout(single, COLLAPSE)).toEqual(single); + }); + + test('does not mutate canonical', () => { + const canonical = makeMulti({ g2: LayoutPriority.Fill }); + const snapshot = JSON.stringify(canonical); + deriveLayout(canonical, COLLAPSE); + expect(JSON.stringify(canonical)).toBe(snapshot); + }); + + test('reversible: widening (identity) after a collapse reproduces canonical byte-for-byte', () => { + const canonical = makeMulti({ g2: LayoutPriority.Fill }); + // collapse (narrow)… + deriveLayout(canonical, COLLAPSE); + // …then widen (no rule = identity) re-derives canonical exactly + const widened = deriveLayout(canonical, []); + expect(widened).toEqual(canonical); + }); + + test('panels record is carried through untouched', () => { + const canonical = makeMulti(); + const derived = deriveLayout(canonical, COLLAPSE); + expect(derived.panels).toEqual(canonical.panels); + }); + + test('floating groups pass through the transform (out of reflow scope)', () => { + const canonical = makeMulti(); + (canonical as { floatingGroups: unknown[] }).floatingGroups = [ + { id: 'f1' }, + ]; + const derived = deriveLayout(canonical, COLLAPSE); + expect( + (derived as { floatingGroups: unknown[] }).floatingGroups + ).toEqual([{ id: 'f1' }]); + }); + }); +}); + +describe('reflow engine — Phase 5 (RestackPass + HidePass)', () => { + describe('restack', () => { + test('flips the primary axis', () => { + const derived = deriveLayout(makeMulti(), [{ kind: 'restack' }]); + expect(derived.grid.orientation).toBe('VERTICAL'); + }); + + test('is reversible and non-mutating', () => { + const canonical = makeMulti(); + const snapshot = JSON.stringify(canonical); + deriveLayout(canonical, [{ kind: 'restack' }]); + expect(JSON.stringify(canonical)).toBe(snapshot); // untouched + expect(deriveLayout(canonical, [])).toEqual(canonical); // widen = identity + }); + }); + + describe('hide', () => { + test('parks Low-priority groups (visible:false), leaves the rest', () => { + const derived = deriveLayout( + makeMulti({ g3: LayoutPriority.Low }), + [{ kind: 'hide' }] + ); + const nodes = leafNodes(derived); + expect(nodes.find((n) => n.data.id === 'g3').visible).toBe(false); + // untouched groups keep their (undefined => visible) state + expect( + nodes.find((n) => n.data.id === 'g1').visible + ).toBeUndefined(); + expect( + nodes.find((n) => n.data.id === 'g2').visible + ).toBeUndefined(); + }); + + test('never hides the highest-priority group (layout never left empty)', () => { + const derived = deriveLayout( + makeMulti( + { + g1: LayoutPriority.Low, + g2: LayoutPriority.Low, + g3: LayoutPriority.Low, + }, + 'g1' + ), + [{ kind: 'hide' }] + ); + const visible = leafNodes(derived).filter( + (n) => n.visible !== false + ); + expect(visible).toHaveLength(1); // the active/top group survives + expect(visible[0].data.id).toBe('g1'); + }); + + test('does not park anything when no group is Low', () => { + const derived = deriveLayout(makeMulti(), [{ kind: 'hide' }]); + expect(leafNodes(derived).every((n) => n.visible !== false)).toBe( + true + ); + }); + }); + + describe('rule chains', () => { + test('apply in array order (restack then collapseToTabs)', () => { + const derived = deriveLayout(makeMulti(), [ + { kind: 'restack' }, + { kind: 'collapseToTabs' }, + ]); + expect(derived.grid.orientation).toBe('VERTICAL'); // restacked + expect(collapsedGroup(derived).views).toHaveLength(4); // collapsed + }); + }); +}); diff --git a/packages/dockview-modules/src/__tests__/responsiveSizeObserver.spec.ts b/packages/dockview-modules/src/__tests__/responsiveSizeObserver.spec.ts new file mode 100644 index 0000000000..ff6043f715 --- /dev/null +++ b/packages/dockview-modules/src/__tests__/responsiveSizeObserver.spec.ts @@ -0,0 +1,165 @@ +import { SizeObserver, SizeObserverClock } from '../responsiveSizeObserver'; + +/** Deterministic fake clock with manual advance. */ +class FakeClock implements SizeObserverClock { + private seq = 0; + private readonly timers = new Map void }>(); + private now = 0; + + setTimeout(handler: () => void, ms: number): unknown { + const id = ++this.seq; + this.timers.set(id, { at: this.now + ms, fn: handler }); + return id; + } + + clearTimeout(handle: unknown): void { + if (typeof handle === 'number') { + this.timers.delete(handle); + } + } + + /** Advance time, firing any timers whose deadline has passed. */ + advance(ms: number): void { + this.now += ms; + for (const [id, t] of [...this.timers.entries()]) { + if (t.at <= this.now) { + this.timers.delete(id); + t.fn(); + } + } + } + + get activeTimers(): number { + return this.timers.size; + } +} + +describe('SizeObserver', () => { + test('debounces a burst of signals into a single settle', () => { + const clock = new FakeClock(); + let width = 1000; + const settled: number[] = []; + const obs = new SizeObserver( + () => width, + (w) => settled.push(w), + 120, + clock + ); + + // a burst of resize signals (e.g. a drag) arriving faster than the window + obs.signal(); + clock.advance(50); + width = 900; + obs.signal(); + clock.advance(50); + width = 800; + obs.signal(); + + // nothing fired yet — still inside the settle window + expect(settled).toEqual([]); + + // let it settle + clock.advance(120); + expect(settled).toEqual([800]); // fires once, with the latest width + }); + + test('reads the width lazily at settle time', () => { + const clock = new FakeClock(); + let width = 1000; + const settled: number[] = []; + const obs = new SizeObserver( + () => width, + (w) => settled.push(w), + 100, + clock + ); + + obs.signal(); + width = 640; // changes after the signal but before settle + clock.advance(100); + + expect(settled).toEqual([640]); + }); + + test('flush resolves a pending signal immediately', () => { + const clock = new FakeClock(); + const settled: number[] = []; + const obs = new SizeObserver( + () => 500, + (w) => settled.push(w), + 120, + clock + ); + + obs.signal(); + expect(obs.pending).toBe(true); + obs.flush(); + + expect(settled).toEqual([500]); + expect(obs.pending).toBe(false); + }); + + test('flush is a no-op when nothing is pending', () => { + const clock = new FakeClock(); + const settled: number[] = []; + const obs = new SizeObserver( + () => 500, + (w) => settled.push(w), + 120, + clock + ); + + obs.flush(); + expect(settled).toEqual([]); + }); + + test('dispose cancels a pending settle', () => { + const clock = new FakeClock(); + const settled: number[] = []; + const obs = new SizeObserver( + () => 500, + (w) => settled.push(w), + 120, + clock + ); + + obs.signal(); + obs.dispose(); + clock.advance(1000); + + expect(settled).toEqual([]); + expect(clock.activeTimers).toBe(0); + }); + + test('debounceMs <= 0 resolves synchronously', () => { + const clock = new FakeClock(); + const settled: number[] = []; + const obs = new SizeObserver( + () => 777, + (w) => settled.push(w), + 0, + clock + ); + + obs.signal(); + expect(settled).toEqual([777]); // no clock advance needed + expect(clock.activeTimers).toBe(0); + }); + + test('only the final signal in a rapid burst survives (single timer)', () => { + const clock = new FakeClock(); + const obs = new SizeObserver( + () => 100, + () => {}, + 120, + clock + ); + + obs.signal(); + obs.signal(); + obs.signal(); + + // each signal cancels the previous timer — never a backlog + expect(clock.activeTimers).toBe(1); + }); +}); diff --git a/packages/dockview-modules/src/index.ts b/packages/dockview-modules/src/index.ts index f8f58f3bf0..0bed784f0a 100644 --- a/packages/dockview-modules/src/index.ts +++ b/packages/dockview-modules/src/index.ts @@ -9,6 +9,7 @@ import { AutoHideEdgeGroupModule } from './autoHideEdgeGroupService'; import { MultiRowTabsModule } from './multiRowTabsService'; import { PinnedTabsModule } from './pinnedTabsService'; import { KeyboardDockingModule } from './keyboardDockingService'; +import { ResponsiveLayoutModule } from './responsiveLayoutService'; export { TabGroupChipsService, @@ -39,6 +40,12 @@ export { KeyboardDockingService, KeyboardDockingModule, } from './keyboardDockingService'; +export { + ResponsiveLayoutService, + ResponsiveLayoutModule, +} from './responsiveLayoutService'; +export { BreakpointResolver } from './responsiveBreakpointResolver'; +export { SizeObserver } from './responsiveSizeObserver'; /** * The set of modules provided by this package. `dockview` registers these via @@ -55,4 +62,5 @@ export const Modules: DockviewModule[] = [ MultiRowTabsModule, PinnedTabsModule, KeyboardDockingModule, + ResponsiveLayoutModule, ]; diff --git a/packages/dockview-modules/src/responsiveBreakpointResolver.ts b/packages/dockview-modules/src/responsiveBreakpointResolver.ts new file mode 100644 index 0000000000..48608435d4 --- /dev/null +++ b/packages/dockview-modules/src/responsiveBreakpointResolver.ts @@ -0,0 +1,85 @@ +import { ResponsiveBreakpoint } from 'dockview-core'; + +interface NormalizedBreakpoint { + name: string; + maxWidth: number; + /** Collapse into this breakpoint once width drops to/below here (<= maxWidth). */ + enterAt: number; + /** Expand out of this breakpoint once width climbs to/above here (>= enterAt). */ + exitAt: number; +} + +/** + * Pure, stateful-input resolver mapping a container width to the active + * breakpoint, with hysteresis to prevent oscillation. + * + * The active breakpoint is the narrowest one whose band the width sits inside. + * Each breakpoint owns a dead band `[enterAt, exitAt]` around its `maxWidth` + * boundary: you *collapse* into a narrower breakpoint at `<= enterAt` but only + * *expand* back out once the width climbs to `>= exitAt`. The gap between them + * absorbs the width delta a reflow itself introduces, so a collapse can never + * bounce straight back — the layout-shift feedback loop that plagues single + * threshold reflow (see `responsive-layout.md` §4.6). + * + * It holds no mutable state: the caller passes the previously-active breakpoint + * name each call, which is what makes the hysteresis direction-aware. + */ +export class BreakpointResolver { + /** Normalized breakpoints, ordered narrowest -> widest. */ + private readonly breakpoints: NormalizedBreakpoint[]; + + constructor(breakpoints: ResponsiveBreakpoint[]) { + this.breakpoints = breakpoints + .map((bp) => { + const enterAt = bp.enterAt ?? bp.maxWidth; + const exitAt = bp.exitAt ?? bp.maxWidth; + return { + name: bp.name, + maxWidth: bp.maxWidth, + // enterAt can't exceed the boundary; exitAt can't precede enterAt + enterAt: Math.min(enterAt, bp.maxWidth), + exitAt: Math.max(exitAt, Math.min(enterAt, bp.maxWidth)), + }; + }) + .sort((a, b) => a.maxWidth - b.maxWidth); + } + + /** Configured breakpoint names, narrowest first. */ + get names(): string[] { + return this.breakpoints.map((b) => b.name); + } + + /** + * Resolve the active breakpoint for `width`, honouring hysteresis relative + * to `currentName` (the previously-active breakpoint, if any). Returns + * `undefined` only when no breakpoints are configured. + */ + resolve(width: number, currentName?: string): string | undefined { + const bps = this.breakpoints; + if (bps.length === 0) { + return undefined; + } + + // Start from the current breakpoint; fall back to the widest for an + // unknown/undefined name (first run or a stale name). + let idx = bps.findIndex((b) => b.name === currentName); + if (idx === -1) { + idx = bps.length - 1; + } + + // Expand: leave the current (narrower) breakpoint once the width climbs + // to/above its exit threshold. The widest breakpoint's exitAt is its + // (typically Infinite) maxWidth, so this never runs past the end. + while (idx < bps.length - 1 && width >= bps[idx].exitAt) { + idx++; + } + + // Collapse: enter a narrower breakpoint once the width drops to/below + // its enter threshold. + while (idx > 0 && width <= bps[idx - 1].enterAt) { + idx--; + } + + return bps[idx].name; + } +} diff --git a/packages/dockview-modules/src/responsiveCanonicalStore.ts b/packages/dockview-modules/src/responsiveCanonicalStore.ts new file mode 100644 index 0000000000..68352839d5 --- /dev/null +++ b/packages/dockview-modules/src/responsiveCanonicalStore.ts @@ -0,0 +1,33 @@ +import { SerializedDockview } from 'dockview-core'; + +/** + * Holds the **canonical** ("wide") layout — the source of truth from which the + * derived (possibly collapsed) layout is projected, and the only thing that is + * persisted. Widening re-derives from canonical rather than reversing + * operations, so the wide arrangement is reproduced exactly. + * + * Phase 2 introduces the store and the identity projection; later phases keep it + * in sync with user edits (rebase) and derive genuinely collapsed layouts. + */ +export class CanonicalStore { + private _canonical: SerializedDockview | undefined; + + has(): boolean { + return this._canonical !== undefined; + } + + set(layout: SerializedDockview): void { + this._canonical = layout; + } + + get(): SerializedDockview { + if (this._canonical === undefined) { + throw new Error('dockview: canonical layout has not been set'); + } + return this._canonical; + } + + clear(): void { + this._canonical = undefined; + } +} diff --git a/packages/dockview-modules/src/responsiveLayoutService.ts b/packages/dockview-modules/src/responsiveLayoutService.ts new file mode 100644 index 0000000000..f949a45dfc --- /dev/null +++ b/packages/dockview-modules/src/responsiveLayoutService.ts @@ -0,0 +1,326 @@ +import { + DockviewBreakpointChangeEvent, + DockviewCompositeDisposable as CompositeDisposable, + DockviewEmitter as Emitter, + DockviewEvent as Event, + DockviewResponsiveOptions, + IResponsiveLayoutHost, + IResponsiveLayoutService, + ReflowRule, + SerializedDockview, + defineModule, +} from 'dockview-core'; +import { BreakpointResolver } from './responsiveBreakpointResolver'; +import { SizeObserver } from './responsiveSizeObserver'; +import { CanonicalStore } from './responsiveCanonicalStore'; +import { deriveLayout } from './responsiveReflowEngine'; +import { rebaseCanonical } from './responsiveRebase'; + +type RebaseMode = 'auto' | 'discard' | 'manual'; + +const DEFAULT_DEBOUNCE_MS = 120; + +/** + * The responsive-layout service. + * + * - Phase 1: resolves the active breakpoint from the container width (with + * hysteresis + debounce) and fires `onDidBreakpointChange`. + * - Phase 2: the canonical/derived split — holds the canonical ("wide") layout + * and serializes *it* (not the collapsed view) through `toJSON`. + * - Phase 3: `deriveLayout` projects a collapsed layout via `collapseToTabs`. + * - Phase 4: **applies** the derived layout to the live component. On the first + * collapse it freezes the current layout as canonical, then reconciles the + * live tree to each breakpoint's derived target (reusing panel instances). + * Widening back to the canonical band restores the frozen layout exactly. A + * reentrancy guard stops the apply's own layout events from re-triggering, + * and reflow defers while a group is maximized. + * - Phase 6: **rebase** — edits made while collapsed (close / add / activate) + * are folded back onto canonical (by panel id) so widening keeps them. + * `rebase: 'discard'` treats them as ephemeral; `'manual'` fires + * `onDidRebaseConflict` and leaves canonical untouched. + */ +export class ResponsiveLayoutService + extends CompositeDisposable + implements IResponsiveLayoutService +{ + private resolver: BreakpointResolver | undefined; + private observer: SizeObserver | undefined; + private options: DockviewResponsiveOptions | undefined; + private _activeBreakpoint: string | undefined; + + private readonly _canonical = new CanonicalStore(); + /** True once the live layout is a derived (collapsed) projection. */ + private _derived = false; + /** The breakpoint whose reflow has actually been applied to the live tree — + * distinct from `_activeBreakpoint`, so a layout that *starts* inside a + * band (never crossing a boundary) still applies on the first settle. */ + private _appliedBreakpoint: string | undefined; + /** Guards against the apply's own layout events re-triggering a reflow. */ + private _applying = false; + /** How edits made while collapsed are folded back onto canonical. */ + private _rebaseMode: RebaseMode = 'auto'; + + private readonly _onDidBreakpointChange = + new Emitter(); + readonly onDidBreakpointChange: Event = + this._onDidBreakpointChange.event; + + private readonly _onDidRebaseConflict = new Emitter<{ reason: string }>(); + readonly onDidRebaseConflict: Event<{ reason: string }> = + this._onDidRebaseConflict.event; + + get activeBreakpoint(): string | undefined { + return this._activeBreakpoint; + } + + constructor(private readonly host: IResponsiveLayoutHost) { + super(); + + this.addDisposables( + this._onDidBreakpointChange, + this._onDidRebaseConflict + ); + + this.configure(host.options.responsive); + + this.addDisposables( + // re-settle on every layout/size ping; the observer debounces so a + // continuous drag-resize only resolves once it settles + this.host.onDidLayoutChange(() => { + if (!this._applying) { + this.observer?.signal(); + } + }), + // a fresh (external) load replaces the canonical baseline + this.host.onDidLayoutFromJSON(() => { + if (!this._applying) { + this._derived = false; + this._appliedBreakpoint = undefined; + this._canonical.clear(); + this.reflow(); + } + }), + // fold user edits made while collapsed back onto canonical + this.host.onDidMutateLayout(() => { + if (!this._applying && this._derived) { + this.rebase(); + } + }) + ); + } + + /** (Re)build the resolver + observer from the `responsive` option. */ + private configure(options: DockviewResponsiveOptions | undefined): void { + this.observer?.dispose(); + this.observer = undefined; + this.resolver = undefined; + this.options = undefined; + this._activeBreakpoint = undefined; + this._appliedBreakpoint = undefined; + this._canonical.clear(); + this._derived = false; + this._rebaseMode = 'auto'; + + if (!options || options.breakpoints.length === 0) { + return; // inert until configured + } + + this.options = options; + this._rebaseMode = options.rebase ?? 'auto'; + this.resolver = new BreakpointResolver(options.breakpoints); + this.observer = new SizeObserver( + () => this.host.width, + (width) => this.resolveAt(width), + options.debounceMs ?? DEFAULT_DEBOUNCE_MS + ); + + // resolve an initial breakpoint against the current width (no event on + // the very first resolution — it establishes the baseline) + this._activeBreakpoint = this.resolver.resolve(this.host.width); + } + + reflow(): void { + // flush any pending debounce, or resolve synchronously if idle + if (this.observer?.pending) { + this.observer.flush(); + } else { + this.resolveAt(this.host.width); + } + } + + serializeCanonical(): + | Pick + | undefined { + if (!this._derived || !this._canonical.has()) { + return undefined; + } + const canonical = this._canonical.get(); + return { grid: canonical.grid, panels: canonical.panels }; + } + + /** The canonical (wide) layout — the frozen baseline while collapsed, or the + * live layout when not. */ + getCanonicalLayout(): SerializedDockview { + return this._derived && this._canonical.has() + ? this._canonical.get() + : this.host.serializeLiveLayout(); + } + + /** Replace the canonical (wide) layout, then re-derive for the current width. */ + setCanonicalLayout(data: SerializedDockview): void { + this._applying = true; + try { + this.host.fromJSON(data, { reuseExistingPanels: true }); + } finally { + this._applying = false; + } + this._canonical.clear(); + this._derived = false; + this._appliedBreakpoint = undefined; + this.reflow(); + } + + private resolveAt(width: number): void { + if (!this.resolver) { + return; + } + const next = this.resolver.resolve(width, this._activeBreakpoint); + if (next === undefined) { + return; + } + + const changed = next !== this._activeBreakpoint; + const needsApply = next !== this._appliedBreakpoint; + if (!changed && !needsApply) { + return; // already resolved *and* applied for this band + } + + // Defer while maximized — a restore fires `onDidLayoutChange`, which + // re-runs this. Neither pointer advances, so it is re-detected then. + if (this.host.hasMaximizedGroup()) { + return; + } + + const from = this._activeBreakpoint; + this._activeBreakpoint = next; + this._appliedBreakpoint = next; + this.applyReflow(next); + + // only a genuine breakpoint change is an `onDidBreakpointChange` — an + // apply that merely catches up a same-band initial layout is silent + if (changed) { + this._onDidBreakpointChange.fire({ from, to: next, width }); + } + } + + /** + * Reconcile the live layout to the breakpoint's derived target. + * + * - Collapsing band: freeze the current layout as canonical on the *first* + * collapse, then apply `deriveLayout(canonical, rules)`. + * - Canonical band (no rules): restore the frozen wide layout exactly. + */ + private applyReflow(breakpoint: string): void { + const rules = this.effectiveRules(breakpoint); + + this._applying = true; + try { + if (rules.length === 0) { + // widest / canonical band — restore the wide layout + if (this._derived) { + this.applyGrid(this._canonical.get()); + this._derived = false; + } + return; + } + + // collapsing band — freeze the wide layout the first time + if (!this._derived) { + this._canonical.set(this.host.toJSON()); + } + this.applyGrid(deriveLayout(this._canonical.get(), rules)); + this._derived = true; + } finally { + this._applying = false; + } + } + + /** + * Reconcile the live tree to `source`'s grid + panels, but keep the *live* + * floating groups, popouts and edge groups — they are outside the reflow's + * scope, so a collapse/restore never tears them down or reverts a float the + * user moved while collapsed. + */ + private applyGrid(source: SerializedDockview): void { + const live = this.host.serializeLiveLayout(); + this.host.fromJSON( + { + ...live, + grid: source.grid, + panels: source.panels, + activeGroup: source.activeGroup, + }, + { reuseExistingPanels: true } + ); + } + + /** + * Fold an edit made while collapsed back onto canonical, per the configured + * mode. `'discard'` leaves canonical frozen (the edit is lost on widen); + * `'manual'` leaves it untouched and fires `onDidRebaseConflict`; `'auto'` + * reconciles canonical with the live derived layout. + */ + private rebase(): void { + if (this._rebaseMode === 'discard' || !this._canonical.has()) { + return; + } + if (this._rebaseMode === 'manual') { + this._onDidRebaseConflict.fire({ + reason: 'layout edited while collapsed (rebase: manual)', + }); + return; + } + const { canonical, conflict } = rebaseCanonical( + this._canonical.get(), + this.host.serializeLiveLayout() + ); + this._canonical.set(canonical); + if (conflict) { + this._onDidRebaseConflict.fire({ reason: conflict }); + } + } + + /** + * The complete rule chain for a breakpoint. A breakpoint's own `rules` win; + * otherwise the widest band is the identity (canonical) and every narrower + * band falls back to the default `rules`. + */ + private effectiveRules(breakpoint: string): readonly ReflowRule[] { + const options = this.options; + if (!options) { + return []; + } + const bp = options.breakpoints.find((b) => b.name === breakpoint); + if (bp?.rules) { + return bp.rules; + } + const widest = options.breakpoints.reduce((a, b) => + b.maxWidth > a.maxWidth ? b : a + ); + return bp === widest ? [] : (options.rules ?? []); + } + + override dispose(): void { + this.observer?.dispose(); + super.dispose(); + } +} + +export const ResponsiveLayoutModule = defineModule< + 'responsiveLayoutService', + IResponsiveLayoutHost +>({ + name: 'ResponsiveLayout', + serviceKey: 'responsiveLayoutService', + create: (host) => new ResponsiveLayoutService(host), +}); diff --git a/packages/dockview-modules/src/responsiveRebase.ts b/packages/dockview-modules/src/responsiveRebase.ts new file mode 100644 index 0000000000..ab4476bdfd --- /dev/null +++ b/packages/dockview-modules/src/responsiveRebase.ts @@ -0,0 +1,155 @@ +import { SerializedDockview, SerializedGridObject } from 'dockview-core'; + +/** Minimal view of a serialized group for rebasing. */ +interface GroupState { + id: string; + views: string[]; + activeView?: string; + [key: string]: unknown; +} + +type GridNode = SerializedGridObject; + +export interface RebaseResult { + /** The updated canonical layout (a fresh object; the input is untouched). */ + canonical: SerializedDockview; + /** Set when an edit could not be cleanly folded in. */ + conflict?: string; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function collectLeafNodes(node: GridNode): GridNode[] { + if (node.type === 'leaf') { + return [node]; + } + return (node.data as GridNode[]).flatMap((child) => + collectLeafNodes(child) + ); +} + +function panelIds(layout: SerializedDockview): Set { + const ids = new Set(); + for (const node of collectLeafNodes(layout.grid.root as GridNode)) { + for (const view of (node.data as GroupState).views) { + ids.add(view); + } + } + return ids; +} + +/** The active panel id (the active group's active view). */ +function activePanelId(layout: SerializedDockview): string | undefined { + const leaves = collectLeafNodes(layout.grid.root as GridNode); + const active = leaves.find( + (node) => (node.data as GroupState).id === layout.activeGroup + ); + return (active?.data as GroupState | undefined)?.activeView; +} + +/** Remove empty leaves and collapse single-child branches. */ +function prune(node: GridNode): GridNode | undefined { + if (node.type === 'leaf') { + return (node.data as GroupState).views.length > 0 ? node : undefined; + } + const children = (node.data as GridNode[]) + .map(prune) + .filter((child): child is GridNode => child !== undefined); + if (children.length === 0) { + return undefined; + } + if (children.length === 1) { + return children[0]; // collapse a branch down to its only child + } + return { ...node, data: children }; +} + +function removePanel(canonical: SerializedDockview, id: string): void { + delete canonical.panels[id]; + for (const node of collectLeafNodes(canonical.grid.root as GridNode)) { + const group = node.data as GroupState; + group.views = group.views.filter((view) => view !== id); + if (group.activeView === id) { + group.activeView = group.views[0]; + } + } + const pruned = prune(canonical.grid.root as GridNode); + // never leave a null root — an emptied layout keeps an empty branch + canonical.grid.root = (pruned ?? { + type: 'branch', + data: [], + }) as unknown as SerializedDockview['grid']['root']; +} + +function addPanel( + canonical: SerializedDockview, + id: string, + state: unknown, + conflicts: string[] +): void { + canonical.panels[id] = state as never; + const leaves = collectLeafNodes(canonical.grid.root as GridNode); + const target = + leaves.find( + (node) => (node.data as GroupState).id === canonical.activeGroup + ) ?? leaves[0]; + if (!target) { + conflicts.push(`panel "${id}" was added but canonical has no group`); + return; + } + (target.data as GroupState).views.push(id); +} + +function reactivate( + canonical: SerializedDockview, + activePanel: string | undefined +): void { + if (!activePanel) { + return; + } + const leaf = collectLeafNodes(canonical.grid.root as GridNode).find( + (node) => (node.data as GroupState).views.includes(activePanel) + ); + if (leaf) { + (leaf.data as GroupState).activeView = activePanel; + canonical.activeGroup = (leaf.data as GroupState).id; + } +} + +/** + * Fold the edits a user made against the derived (`live`) layout back onto the + * canonical (wide) one, matching panels by id. Handles closes (remove + prune), + * adds (insert into the canonical active group), and active-tab changes. Moves + * / resizes *within* the collapsed layout don't change the panel set and so are + * naturally ignored. Pure: the input `canonical` is never mutated. + */ +export function rebaseCanonical( + canonicalIn: SerializedDockview, + live: SerializedDockview +): RebaseResult { + const canonical = clone(canonicalIn); + const conflicts: string[] = []; + + const canonicalIds = panelIds(canonical); + const liveIds = panelIds(live); + + for (const id of canonicalIds) { + if (!liveIds.has(id)) { + removePanel(canonical, id); // closed while collapsed + } + } + for (const id of liveIds) { + if (!canonicalIds.has(id)) { + addPanel(canonical, id, live.panels[id], conflicts); // added + } + } + + reactivate(canonical, activePanelId(live)); + + return { + canonical, + conflict: conflicts.length > 0 ? conflicts.join('; ') : undefined, + }; +} diff --git a/packages/dockview-modules/src/responsiveReflowEngine.ts b/packages/dockview-modules/src/responsiveReflowEngine.ts new file mode 100644 index 0000000000..08e5573bb9 --- /dev/null +++ b/packages/dockview-modules/src/responsiveReflowEngine.ts @@ -0,0 +1,233 @@ +import { + LayoutPriority, + Orientation, + ReflowRule, + SerializedDockview, + SerializedGridObject, +} from 'dockview-core'; + +/** + * A pending change the Applier would make to reconcile the live layout with a + * derived target. Phase 2 is coarse (a single `replace`); the minimal + * move-based op set lands in Phase 4. + */ +export type ReflowOp = { kind: 'replace' }; + +/** The serialized state of a single group (a grid leaf). */ +interface GroupState { + id: string; + views: string[]; + activeView?: string; + priority?: LayoutPriority; + tabGroups?: unknown; + [key: string]: unknown; +} + +type GridNode = SerializedGridObject; + +/** Structural deep clone — the identity-transform baseline. */ +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * The reflow priority of a group — higher survives collapse longer. Folds the + * group's `LayoutPriority` (`Fill` outranks everything, then `High` > `Normal` > + * `Low`) and gives the active group a tie-break bonus so the thing the user is + * looking at survives. See `responsive-layout.md` §4.3. + */ +export function computeGroupPriority( + group: { priority?: LayoutPriority }, + isActive: boolean +): number { + let score: number; + switch (group.priority) { + case LayoutPriority.Fill: + score = 1000; + break; + case LayoutPriority.High: + score = 1; + break; + case LayoutPriority.Low: + score = -1; + break; + case LayoutPriority.Normal: + default: + score = 0; + break; + } + return score + (isActive ? 0.5 : 0); +} + +/** Depth-first, left-to-right list of every leaf node in the grid tree. */ +function collectLeafNodes(node: GridNode): GridNode[] { + if (node.type === 'leaf') { + return [node]; + } + return (node.data as GridNode[]).flatMap((child) => + collectLeafNodes(child) + ); +} + +/** + * Fold every side-by-side group into a single tabbed group. The highest-priority + * group is the host (its id + settings survive); the remaining groups' panels + * become tabs after it in descending priority order (document order breaks + * ties). The globally-active panel stays active. Mutates + returns `layout` + * (already a private clone). + */ +function collapseToTabs(layout: SerializedDockview): SerializedDockview { + const leaves = collectLeafNodes(layout.grid.root as GridNode).map( + (node) => node.data as GroupState + ); + + if (leaves.length <= 1) { + return layout; // already a single group — nothing to fold + } + + const activeGroupId = layout.activeGroup; + + const ordered = leaves + .map((group, index) => ({ + group, + index, + isActive: group.id === activeGroupId, + })) + .sort((a, b) => { + const pa = computeGroupPriority(a.group, a.isActive); + const pb = computeGroupPriority(b.group, b.isActive); + // higher priority first; document order breaks ties (deterministic) + return pb !== pa ? pb - pa : a.index - b.index; + }); + + const host = ordered[0].group; + const mergedViews = ordered.flatMap((entry) => entry.group.views); + const activeGroup = leaves.find((group) => group.id === activeGroupId); + const activeView = activeGroup?.activeView ?? host.activeView; + + const mergedGroup: GroupState = { + ...host, + views: mergedViews, + activeView, + }; + delete mergedGroup.tabGroups; + + // Keep the root a branch (dockview's `fromJSON` requires it): a single + // collapsed group is a branch with one leaf child filling the primary axis. + const primary = + layout.grid.orientation === Orientation.HORIZONTAL + ? layout.grid.width + : layout.grid.height; + + layout.grid = { + ...layout.grid, + root: { + type: 'branch', + data: [{ type: 'leaf', data: mergedGroup, size: primary }], + } as unknown as SerializedDockview['grid']['root'], + }; + layout.activeGroup = host.id; + + return layout; +} + +/** + * Flip the layout's primary axis (columns <-> rows). Nested branches alternate + * orientation from the root, so flipping the root orientation restacks the whole + * tree. Sizes are carried through and rescaled to fit on layout. Mutates + + * returns `layout`. + */ +function restack(layout: SerializedDockview): SerializedDockview { + layout.grid = { + ...layout.grid, + orientation: + layout.grid.orientation === Orientation.HORIZONTAL + ? Orientation.VERTICAL + : Orientation.HORIZONTAL, + }; + return layout; +} + +/** + * Park the low-priority groups by marking them `visible: false` — the group and + * its panels survive (re-shown on widen), they are just not laid out. The + * single highest-priority group is always kept visible so the layout is never + * left empty. Mutates + returns `layout`. + * + * (An overflow affordance to reach parked groups while collapsed is a later + * refinement; the core hide/restore is complete here.) + */ +function hide(layout: SerializedDockview): SerializedDockview { + const leaves = collectLeafNodes(layout.grid.root as GridNode); + if (leaves.length <= 1) { + return layout; + } + + const activeGroupId = layout.activeGroup; + let top = leaves[0]; + let topScore = -Infinity; + for (const node of leaves) { + const group = node.data as GroupState; + const score = computeGroupPriority(group, group.id === activeGroupId); + if (score > topScore) { + topScore = score; + top = node; + } + } + + for (const node of leaves) { + const group = node.data as GroupState; + if (node !== top && group.priority === LayoutPriority.Low) { + node.visible = false; + } + } + return layout; +} + +/** + * Project the **derived** layout from the **canonical** one by applying the + * active rule chain in array order. Pure: `(canonical, rules) -> + * SerializedDockview`, no DOM, no side effects, never mutates `canonical`. + * + * With no rules the transform is the identity (a clone of canonical). + */ +export function deriveLayout( + canonical: SerializedDockview, + rules: readonly ReflowRule[] +): SerializedDockview { + let layout = clone(canonical); + for (const rule of rules) { + switch (rule.kind) { + case 'collapseToTabs': + layout = collapseToTabs(layout); + break; + case 'restack': + layout = restack(layout); + break; + case 'hide': + layout = hide(layout); + break; + } + } + return layout; +} + +/** + * Diff the live layout against a derived target, returning the ops needed to + * reconcile them. Reports a single `replace` when the transformed part (grid + + * panels) differs, and — critically — **zero ops when identical** (the + * idempotence guard). The minimal, move-based diff lands in a later phase. + * + * Only `grid` + `panels` are compared: floating groups, popouts and edge groups + * are outside the reflow's scope and pass through untouched. + */ +export function diffLayouts( + live: SerializedDockview, + target: SerializedDockview +): ReflowOp[] { + const identical = + JSON.stringify(live.grid) === JSON.stringify(target.grid) && + JSON.stringify(live.panels) === JSON.stringify(target.panels); + + return identical ? [] : [{ kind: 'replace' }]; +} diff --git a/packages/dockview-modules/src/responsiveSizeObserver.ts b/packages/dockview-modules/src/responsiveSizeObserver.ts new file mode 100644 index 0000000000..19821fc37f --- /dev/null +++ b/packages/dockview-modules/src/responsiveSizeObserver.ts @@ -0,0 +1,70 @@ +import { DockviewIDisposable as IDisposable } from 'dockview-core'; + +/** + * Minimal clock seam so the debounce is deterministic under test. Defaults to + * the host `globalThis` timers in production. + */ +export interface SizeObserverClock { + setTimeout(handler: () => void, ms: number): unknown; + clearTimeout(handle: unknown): void; +} + +/** + * Debounces the component's raw size signal (`onDidLayoutChange`, already + * rAF-batched and integer-rounded upstream) so a breakpoint is only resolved + * once a drag-resize *settles*. Continuous dragging therefore never reflows + * mid-drag — only on settle — which is the first line of defence against the + * layout-shift feedback loop (`responsive-layout.md` §4.6). + * + * Holds no size state of its own: it reads the current width lazily at settle + * time via `getWidth`, so it always acts on the latest measurement. + */ +export class SizeObserver implements IDisposable { + private handle: unknown = undefined; + + constructor( + private readonly getWidth: () => number, + private readonly onSettled: (width: number) => void, + private readonly debounceMs: number, + private readonly clock: SizeObserverClock = globalThis as SizeObserverClock + ) {} + + /** Feed a raw size signal; restarts the settle timer. */ + signal(): void { + if (this.debounceMs <= 0) { + // no debounce configured — resolve synchronously + this.cancel(); + this.onSettled(this.getWidth()); + return; + } + this.clock.clearTimeout(this.handle); + this.handle = this.clock.setTimeout(() => { + this.handle = undefined; + this.onSettled(this.getWidth()); + }, this.debounceMs); + } + + /** Resolve any pending signal immediately (e.g. on an explicit reflow). */ + flush(): void { + if (this.handle !== undefined) { + this.cancel(); + this.onSettled(this.getWidth()); + } + } + + /** Whether a debounced signal is waiting to settle. */ + get pending(): boolean { + return this.handle !== undefined; + } + + private cancel(): void { + if (this.handle !== undefined) { + this.clock.clearTimeout(this.handle); + this.handle = undefined; + } + } + + dispose(): void { + this.cancel(); + } +}