diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx index f93916b0a..aeadce8cc 100644 --- a/src/routes/v2/pages/Editor/EditorV2.tsx +++ b/src/routes/v2/pages/Editor/EditorV2.tsx @@ -42,6 +42,7 @@ import { usePipelineTreeWindow } from "./hooks/usePipelineTreeWindow"; import { usePropertiesWindowPositioning } from "./hooks/usePropertiesWindowPositioning"; import { useRecentRunsWindow } from "./hooks/useRecentRunsWindow"; import { useRunsAndSubmissionWindow } from "./hooks/useRunsAndSubmissionWindow"; +import { useSeedInitialDockLayoutFromPreset } from "./hooks/useSeedInitialDockLayoutFromPreset"; import { useSelectionWindowSync } from "./hooks/useSelectionWindowSync"; import { useSpecLifecycle } from "./hooks/useSpecLifecycle"; import { useUndoRedoKeyboard } from "./hooks/useUndoRedoKeyboard"; @@ -87,6 +88,7 @@ const PipelineEditor = withSuspenseWrapper( useShortcutListener(); useEditorEscapeShortcut(); useDebugPanelWindow(); + useSeedInitialDockLayoutFromPreset(); const activeSpec = navigation.activeSpec; diff --git a/src/routes/v2/pages/Editor/hooks/useSeedInitialDockLayoutFromPreset.ts b/src/routes/v2/pages/Editor/hooks/useSeedInitialDockLayoutFromPreset.ts new file mode 100644 index 000000000..eae5aeced --- /dev/null +++ b/src/routes/v2/pages/Editor/hooks/useSeedInitialDockLayoutFromPreset.ts @@ -0,0 +1,23 @@ +import { useEffect } from "react"; + +import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; +import { DEFAULT_VIEW_PRESET } from "@/routes/v2/shared/windows/viewPresets"; +import { hasPersistedLayout } from "@/routes/v2/shared/windows/windowPersistence"; + +/** + * On first editor visit (no window layout in localStorage), reorder dock stacks + * to match `DEFAULT_VIEW_PRESET`. + * + * Must run in an effect declared after the editor `use*Window` hooks in + * `PipelineEditor` so those hooks’ effects have already opened windows in the + * store. Only runs when `hasPersistedLayout()` is false. + */ +export function useSeedInitialDockLayoutFromPreset(): void { + const { windows } = useSharedStores(); + + useEffect(() => { + if (!hasPersistedLayout()) { + windows.seedInitialDockLayoutFromPreset(DEFAULT_VIEW_PRESET); + } + }, [windows]); +} diff --git a/src/routes/v2/shared/windows/viewPresets.ts b/src/routes/v2/shared/windows/viewPresets.ts index b2bae6eec..7036d52c4 100644 --- a/src/routes/v2/shared/windows/viewPresets.ts +++ b/src/routes/v2/shared/windows/viewPresets.ts @@ -1,22 +1,43 @@ +/** Ordered window ids per dock column; array order is the default stack order (first visit only). */ +interface PresetDockAreas { + left: string[]; + right: string[]; +} + export interface ViewPreset { label: string; description: string; visible: Set; - /** Default dock positions to restore when applying this preset. */ - dockPositions?: Record; + /** Default dock columns: ids per side, order matters for first-visit layout seeding. */ + dockAreas?: PresetDockAreas; } -const DEFAULT_DOCK_POSITIONS: Record = { - "runs-and-submission": "left", - "component-library": "left", - "pipeline-tree": "left", - history: "left", - "debug-panel": "left", - "pipeline-details": "right", - "recent-runs": "left", - "context-panel": "right", +export const DEFAULT_DOCK_AREAS: PresetDockAreas = { + left: [ + "runs-and-submission", + "component-library", + "pipeline-tree", + "history", + "debug-panel", + "recent-runs", + ], + right: ["pipeline-details", "context-panel"], }; +/** Target dock side for each window id listed in a preset's `dockAreas`. Right wins if listed on both. */ +export function dockSideByWindowId( + areas: PresetDockAreas, +): Map { + const map = new Map(); + for (const id of areas.left) { + map.set(id, "left"); + } + for (const id of areas.right) { + map.set(id, "right"); + } + return map; +} + export const DEFAULT_VIEW_PRESET: ViewPreset = { label: "Default", description: "Components, Runs & Submissions, Recent Runs, Pipeline Details", @@ -26,7 +47,7 @@ export const DEFAULT_VIEW_PRESET: ViewPreset = { "component-library", "pipeline-details", ]), - dockPositions: DEFAULT_DOCK_POSITIONS, + dockAreas: DEFAULT_DOCK_AREAS, }; export const VIEW_PRESETS: ViewPreset[] = [ @@ -44,7 +65,7 @@ export const VIEW_PRESETS: ViewPreset[] = [ "debug-panel", "recent-runs", ]), - dockPositions: DEFAULT_DOCK_POSITIONS, + dockAreas: DEFAULT_DOCK_AREAS, }, { label: "Minimal", diff --git a/src/routes/v2/shared/windows/windowStore.ts b/src/routes/v2/shared/windows/windowStore.ts index 54e6fc422..5c01b207e 100644 --- a/src/routes/v2/shared/windows/windowStore.ts +++ b/src/routes/v2/shared/windows/windowStore.ts @@ -12,7 +12,7 @@ import { type WindowOptions, type WindowRef, } from "./types"; -import type { ViewPreset } from "./viewPresets"; +import { dockSideByWindowId, type ViewPreset } from "./viewPresets"; import { WindowModel, type WindowStoreRef } from "./windowModel"; import { buildWindowModelInit } from "./windowStore.utils"; @@ -263,7 +263,64 @@ export class WindowStoreImpl implements WindowStoreRef { // -- View presets -- - /** Apply a view preset: toggle visibility and reset dock positions. */ + /** + * First-visit only: align dock sides and stack order with `preset.dockAreas`. + * Call only when there is no persisted layout (e.g. gated on `hasPersistedLayout()`). + */ + @action seedInitialDockLayoutFromPreset(preset: ViewPreset): void { + const areas = preset.dockAreas; + if (!areas) return; + + const presetIdSet = new Set([...areas.left, ...areas.right]); + this.alignDockSidesWithPresetAreas(areas); + this.alignDockStackOrderWithPresetAreas(areas, presetIdSet); + } + + private alignDockSidesWithPresetAreas( + areas: NonNullable, + ): void { + for (const id of areas.left) { + this.moveWindowToDockSideIfNeeded(id, "left"); + } + for (const id of areas.right) { + this.moveWindowToDockSideIfNeeded(id, "right"); + } + } + + private moveWindowToDockSideIfNeeded( + id: string, + side: "left" | "right", + ): void { + const win = this.windows[id]; + if (!win) return; + if (win.dockState === side) return; + this.undockWindow(id); + this.dockWindow(id, side); + } + + private alignDockStackOrderWithPresetAreas( + areas: NonNullable, + presetIdSet: Set, + ): void { + this.alignDockSideStackOrder("left", areas.left, presetIdSet); + this.alignDockSideStackOrder("right", areas.right, presetIdSet); + } + + private alignDockSideStackOrder( + side: "left" | "right", + presetOrderOnSide: string[], + presetIdSet: Set, + ): void { + const desired = presetOrderOnSide.filter( + (id) => this.windows[id]?.dockState === side, + ); + const remaining = this.dockAreas[side].windowOrder.filter( + (id) => !presetIdSet.has(id), + ); + this.dockAreas[side].windowOrder = [...desired, ...remaining]; + } + + /** Apply a view preset: toggle visibility and correct dock sides (does not reshuffle stack order). */ @action applyViewPreset(preset: ViewPreset): void { const allWindows = this.getAllWindows(); for (const win of allWindows) { @@ -273,8 +330,9 @@ export class WindowStoreImpl implements WindowStoreRef { if (win.state !== "hidden") win.hide(); } } - if (preset.dockPositions) { - for (const [id, side] of Object.entries(preset.dockPositions)) { + if (preset.dockAreas) { + const sides = dockSideByWindowId(preset.dockAreas); + for (const [id, side] of sides) { const win = this.windows[id]; if (win && win.dockState !== side) { this.undockWindow(id); diff --git a/src/routes/v2/shared/windows/windowStore.viewPreset.test.ts b/src/routes/v2/shared/windows/windowStore.viewPreset.test.ts new file mode 100644 index 000000000..075f6dafe --- /dev/null +++ b/src/routes/v2/shared/windows/windowStore.viewPreset.test.ts @@ -0,0 +1,69 @@ +import { runInAction } from "mobx"; +import { createElement } from "react"; +import { describe, expect, it } from "vitest"; + +import { DEFAULT_DOCK_AREAS, DEFAULT_VIEW_PRESET } from "./viewPresets"; +import { WindowStoreImpl } from "./windowStore"; + +const stubContent = createElement("span"); + +describe("WindowStoreImpl view preset dock layout", () => { + it("seedInitialDockLayoutFromPreset applies DEFAULT_DOCK_AREAS stack order on the left", () => { + const store = new WindowStoreImpl(); + store.enableDockSide("left"); + store.enableDockSide("right"); + + store.openWindow(stubContent, { + id: "component-library", + title: "Components", + defaultDockState: "left", + }); + store.openWindow(stubContent, { + id: "runs-and-submission", + title: "Runs", + defaultDockState: "left", + }); + + expect(store.getDockAreaWindowIds("left")).toEqual([ + "component-library", + "runs-and-submission", + ]); + + store.seedInitialDockLayoutFromPreset(DEFAULT_VIEW_PRESET); + + expect(store.getDockAreaWindowIds("left")).toEqual( + DEFAULT_DOCK_AREAS.left.filter((id) => store.getWindowById(id)), + ); + }); + + it("applyViewPreset does not replace dock windowOrder when sides already match preset", () => { + const store = new WindowStoreImpl(); + store.enableDockSide("left"); + store.enableDockSide("right"); + + store.openWindow(stubContent, { + id: "component-library", + title: "Components", + defaultDockState: "left", + }); + store.openWindow(stubContent, { + id: "runs-and-submission", + title: "Runs", + defaultDockState: "left", + }); + + runInAction(() => { + store.dockAreas.left.windowOrder = [ + "component-library", + "runs-and-submission", + ]; + }); + + store.applyViewPreset(DEFAULT_VIEW_PRESET); + + expect(store.getDockAreaWindowIds("left")).toEqual([ + "component-library", + "runs-and-submission", + ]); + }); +});