From 913056b33f3210722024774ff1d7ec80a675bd05 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 29 Mar 2026 17:41:49 -0700 Subject: [PATCH 01/10] Fix cors for host service --- apps/desktop/src/main/host-service/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/host-service/index.ts b/apps/desktop/src/main/host-service/index.ts index 56da823a9d2..484f661fb54 100644 --- a/apps/desktop/src/main/host-service/index.ts +++ b/apps/desktop/src/main/host-service/index.ts @@ -21,6 +21,7 @@ const dbPath = process.env.HOST_DB_PATH; const deviceClientId = process.env.DEVICE_CLIENT_ID; const deviceName = process.env.DEVICE_NAME; const hostServiceSecret = process.env.HOST_SERVICE_SECRET; +const desktopVitePort = process.env.DESKTOP_VITE_PORT ?? "5173"; const auth = authToken && cloudApiUrl ? new JwtApiAuthProvider(authToken) : undefined; @@ -36,7 +37,10 @@ const { app, injectWebSocket } = createApp({ dbPath, deviceClientId, deviceName, - allowedOrigins: ["http://127.0.0.1"], + allowedOrigins: [ + `http://localhost:${desktopVitePort}`, + `http://127.0.0.1:${desktopVitePort}`, + ], }); const server = serve( From be5a2efca1e1b355588890ccee6446ac11688881 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 29 Mar 2026 18:41:27 -0700 Subject: [PATCH 02/10] Checkpoint - store regenerated --- apps/desktop/package.json | 2 +- bun.lock | 8 +- packages/pane-layout/src/core/store/index.ts | 11 - .../pane-layout/src/core/store/store.test.ts | 759 -------------- packages/pane-layout/src/core/store/store.ts | 888 ---------------- .../pane-layout/src/core/store/utils/index.ts | 11 - .../pane-layout/src/core/store/utils/utils.ts | 159 --- packages/pane-layout/src/index.ts | 29 - .../PaneWorkspace/PaneWorkspace.tsx | 52 - .../components/PaneContent/PaneContent.tsx | 49 - .../components/PaneContent/index.ts | 1 - .../components/PaneGroup/PaneGroup.tsx | 276 ----- .../components/PaneGroup/index.ts | 1 - .../components/PaneNodeView/PaneNodeView.tsx | 94 -- .../components/PaneNodeView/index.ts | 1 - .../components/PaneRootTabs/PaneRootTabs.tsx | 196 ---- .../PaneRootTabItem/PaneRootTabItem.tsx | 140 --- .../RootRenameInput/RootRenameInput.tsx | 52 - .../components/RootRenameInput/index.ts | 1 - .../components/PaneRootTabItem/index.ts | 1 - .../components/PaneRootTabs/index.ts | 1 - .../components/PaneRootView/PaneRootView.tsx | 50 - .../components/PaneRootView/index.ts | 1 - .../PaneSplitHandle/PaneSplitHandle.tsx | 34 - .../components/PaneSplitHandle/index.ts | 1 - .../react/components/PaneWorkspace/index.ts | 1 - .../pane-layout/src/react/components/index.ts | 1 - packages/pane-layout/src/react/hooks/index.ts | 1 - .../hooks/usePaneWorkspaceStore/index.ts | 1 - .../usePaneWorkspaceStore.ts | 10 - packages/pane-layout/src/react/index.ts | 8 - packages/pane-layout/src/react/types.ts | 41 - packages/pane-layout/src/types.ts | 55 - packages/panes/README.md | 343 +++++++ packages/{pane-layout => panes}/package.json | 2 +- packages/panes/src/core/store/index.ts | 7 + packages/panes/src/core/store/store.test.ts | 574 +++++++++++ packages/panes/src/core/store/store.ts | 587 +++++++++++ packages/panes/src/core/store/utils/index.ts | 9 + .../panes/src/core/store/utils/utils.test.ts | 268 +++++ packages/panes/src/core/store/utils/utils.ts | 177 ++++ packages/panes/src/index.ts | 15 + packages/panes/src/types.ts | 36 + packages/{pane-layout => panes}/tsconfig.json | 0 plans/panes-v2-data-model-redesign.md | 961 ++++++++++++++++++ 45 files changed, 2983 insertions(+), 2932 deletions(-) delete mode 100644 packages/pane-layout/src/core/store/index.ts delete mode 100644 packages/pane-layout/src/core/store/store.test.ts delete mode 100644 packages/pane-layout/src/core/store/store.ts delete mode 100644 packages/pane-layout/src/core/store/utils/index.ts delete mode 100644 packages/pane-layout/src/core/store/utils/utils.ts delete mode 100644 packages/pane-layout/src/index.ts delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/PaneWorkspace.tsx delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneContent/PaneContent.tsx delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneContent/index.ts delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneGroup/PaneGroup.tsx delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneGroup/index.ts delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneNodeView/PaneNodeView.tsx delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneNodeView/index.ts delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/PaneRootTabs.tsx delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/PaneRootTabItem.tsx delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/components/RootRenameInput/RootRenameInput.tsx delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/components/RootRenameInput/index.ts delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/index.ts delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/index.ts delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootView/PaneRootView.tsx delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootView/index.ts delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneSplitHandle/PaneSplitHandle.tsx delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/components/PaneSplitHandle/index.ts delete mode 100644 packages/pane-layout/src/react/components/PaneWorkspace/index.ts delete mode 100644 packages/pane-layout/src/react/components/index.ts delete mode 100644 packages/pane-layout/src/react/hooks/index.ts delete mode 100644 packages/pane-layout/src/react/hooks/usePaneWorkspaceStore/index.ts delete mode 100644 packages/pane-layout/src/react/hooks/usePaneWorkspaceStore/usePaneWorkspaceStore.ts delete mode 100644 packages/pane-layout/src/react/index.ts delete mode 100644 packages/pane-layout/src/react/types.ts delete mode 100644 packages/pane-layout/src/types.ts create mode 100644 packages/panes/README.md rename packages/{pane-layout => panes}/package.json (94%) create mode 100644 packages/panes/src/core/store/index.ts create mode 100644 packages/panes/src/core/store/store.test.ts create mode 100644 packages/panes/src/core/store/store.ts create mode 100644 packages/panes/src/core/store/utils/index.ts create mode 100644 packages/panes/src/core/store/utils/utils.test.ts create mode 100644 packages/panes/src/core/store/utils/utils.ts create mode 100644 packages/panes/src/index.ts create mode 100644 packages/panes/src/types.ts rename packages/{pane-layout => panes}/tsconfig.json (100%) create mode 100644 plans/panes-v2-data-model-redesign.md diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 295ca29d3f7..79772c0e5ce 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -84,7 +84,7 @@ "@superset/host-service": "workspace:*", "@superset/local-db": "workspace:*", "@superset/macos-process-metrics": "workspace:*", - "@superset/pane-layout": "workspace:*", + "@superset/panes": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", diff --git a/bun.lock b/bun.lock index d20994ee6c8..0de5d9d12f2 100644 --- a/bun.lock +++ b/bun.lock @@ -161,7 +161,7 @@ "@superset/host-service": "workspace:*", "@superset/local-db": "workspace:*", "@superset/macos-process-metrics": "workspace:*", - "@superset/pane-layout": "workspace:*", + "@superset/panes": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", @@ -760,8 +760,8 @@ "typescript": "^5.9.3", }, }, - "packages/pane-layout": { - "name": "@superset/pane-layout", + "packages/panes": { + "name": "@superset/panes", "version": "0.1.0", "dependencies": { "@superset/ui": "workspace:*", @@ -2325,7 +2325,7 @@ "@superset/mobile": ["@superset/mobile@workspace:apps/mobile"], - "@superset/pane-layout": ["@superset/pane-layout@workspace:packages/pane-layout"], + "@superset/panes": ["@superset/panes@workspace:packages/panes"], "@superset/shared": ["@superset/shared@workspace:packages/shared"], diff --git a/packages/pane-layout/src/core/store/index.ts b/packages/pane-layout/src/core/store/index.ts deleted file mode 100644 index cb91242ca9b..00000000000 --- a/packages/pane-layout/src/core/store/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type { - CreatePaneWorkspaceStoreOptions, - PaneWorkspaceStore, - PaneWorkspaceStoreState, -} from "./store"; -export { - createPane, - createPaneRoot, - createPaneWorkspaceState, - createPaneWorkspaceStore, -} from "./store"; diff --git a/packages/pane-layout/src/core/store/store.test.ts b/packages/pane-layout/src/core/store/store.test.ts deleted file mode 100644 index edff558b852..00000000000 --- a/packages/pane-layout/src/core/store/store.test.ts +++ /dev/null @@ -1,759 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import type { PaneState, PaneWorkspaceState } from "../../types"; -import { - createPaneRoot, - createPaneWorkspaceState, - createPaneWorkspaceStore, -} from "./store"; - -interface TestPaneData { - label: string; -} - -function createTestPane(id: string, label = id): PaneState { - return { - id, - kind: "test", - data: { label }, - }; -} - -function getFirstRoot(state: PaneWorkspaceState) { - const root = state.roots[0]; - if (!root) { - throw new Error("Expected first root"); - } - return root; -} - -describe("pane workspace state operations", () => { - it("builds default active ids from the first available root and pane", () => { - const root = createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [createTestPane("pane-a"), createTestPane("pane-b")], - }); - - const state = createPaneWorkspaceState({ - roots: [root], - }); - - expect(root.activeGroupId).toBe("group-root"); - expect(root.root.type).toBe("group"); - if (root.root.type !== "group") { - throw new Error("Expected group root"); - } - expect(root.root.activePaneId).toBe("pane-a"); - expect(state.activeRootId).toBe("root-main"); - }); - - it("adds roots, removes the active root, and falls back to the next root", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-a", - groupId: "group-a", - panes: [createTestPane("pane-a")], - }), - ], - }), - }); - - store.getState().addRoot( - createPaneRoot({ - id: "root-b", - groupId: "group-b", - panes: [createTestPane("pane-b")], - }), - ); - store.getState().removeRoot("root-a"); - - expect(store.getState().state.roots.map((root) => root.id)).toEqual([ - "root-b", - ]); - expect(store.getState().state.activeRootId).toBe("root-b"); - }); - - it("sets the active group without disturbing the active root", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - { - id: "root-main", - activeGroupId: "group-left", - root: { - type: "split", - id: "split-root", - direction: "horizontal", - sizes: [50, 50], - children: [ - { - type: "group", - id: "group-left", - activePaneId: "pane-a", - panes: [createTestPane("pane-a")], - }, - { - type: "group", - id: "group-right", - activePaneId: "pane-b", - panes: [createTestPane("pane-b")], - }, - ], - }, - }, - ], - }), - }); - - store.getState().setActiveGroup({ - rootId: "root-main", - groupId: "group-right", - }); - - expect(store.getState().state.activeRootId).toBe("root-main"); - expect(store.getState().state.roots[0]?.activeGroupId).toBe("group-right"); - }); - - it("updates a root title override without affecting the active root", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - titleOverride: "Main", - groupId: "group-root", - panes: [createTestPane("pane-a")], - }), - ], - }), - }); - - store.getState().setRootTitleOverride({ - rootId: "root-main", - titleOverride: "Renamed", - }); - - expect(store.getState().state.activeRootId).toBe("root-main"); - expect(store.getState().state.roots[0]?.titleOverride).toBe("Renamed"); - }); - - it("updates pane data in place by pane id", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [createTestPane("pane-a", "A")], - }), - ], - }), - }); - - store.getState().setPaneData({ - paneId: "pane-a", - data: { label: "Updated" }, - }); - - expect(store.getState().getPane("pane-a")?.pane.data).toEqual({ - label: "Updated", - }); - }); - - it("splits a group and adds a sibling group", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [createTestPane("pane-a", "A")], - }), - ], - }), - }); - - store.getState().splitGroup({ - rootId: "root-main", - groupId: "group-root", - position: "right", - newPane: createTestPane("pane-b", "B"), - }); - - const nextState = store.getState().state; - - const root = getFirstRoot(nextState); - expect(root.root.type).toBe("split"); - expect(root.activeGroupId).toMatch(/^group-/); - - const splitNode = root.root.type === "split" ? root.root : null; - expect(splitNode?.children[1]).toMatchObject({ - type: "group", - activePaneId: "pane-b", - }); - expect(splitNode?.children[1]?.id).toMatch(/^group-/); - }); - - it("splits with custom metadata and preserves the current group when requested", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [createTestPane("pane-a")], - }), - ], - }), - }); - - store.getState().splitGroup({ - rootId: "root-main", - groupId: "group-root", - position: "top", - newPane: createTestPane("pane-b"), - selectNewPane: false, - sizes: [30, 70], - }); - - const root = getFirstRoot(store.getState().state); - expect(root.activeGroupId).toBe("group-root"); - expect(root.root).toMatchObject({ - type: "split", - direction: "vertical", - sizes: [30, 70], - }); - - if (root.root.type !== "split") { - throw new Error("Expected split root"); - } - - expect(root.root.children[0]).toMatchObject({ - type: "group", - }); - expect(root.root.id).toMatch(/^split-/); - expect(root.root.children[0]?.id).toMatch(/^group-/); - expect(root.root.children[1]).toMatchObject({ - type: "group", - id: "group-root", - }); - }); - - it("moves a pane across roots", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-source", - groupId: "group-source", - panes: [createTestPane("pane-a", "A")], - }), - createPaneRoot({ - id: "root-target", - groupId: "group-target", - panes: [createTestPane("pane-b", "B")], - }), - ], - activeRootId: "root-source", - }), - }); - - store.getState().movePane({ - paneId: "pane-a", - targetRootId: "root-target", - targetGroupId: "group-target", - select: true, - }); - - const nextState = store.getState().state; - - const sourceGroup = - nextState.roots[0]?.root.type === "group" - ? nextState.roots[0]?.root - : null; - const targetGroup = - nextState.roots[1]?.root.type === "group" - ? nextState.roots[1]?.root - : null; - - expect(sourceGroup?.panes).toEqual([]); - expect(targetGroup?.panes.map((pane) => pane.id)).toEqual([ - "pane-b", - "pane-a", - ]); - expect(targetGroup?.activePaneId).toBe("pane-a"); - expect(nextState.activeRootId).toBe("root-target"); - }); - - it("reorders a pane inside the same group with index adjustment", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [ - createTestPane("pane-a"), - createTestPane("pane-b"), - createTestPane("pane-c"), - ], - }), - ], - }), - }); - - store.getState().movePane({ - paneId: "pane-a", - targetRootId: "root-main", - targetGroupId: "group-root", - index: 2, - select: true, - }); - - const root = getFirstRoot(store.getState().state); - if (root.root.type !== "group") { - throw new Error("Expected group root"); - } - - expect(root.root.panes.map((pane) => pane.id)).toEqual([ - "pane-b", - "pane-a", - "pane-c", - ]); - expect(root.root.activePaneId).toBe("pane-a"); - }); - - it("adds a pane to a group at a specific index", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [createTestPane("pane-a"), createTestPane("pane-c")], - }), - ], - }), - }); - - store.getState().addPaneToGroup({ - rootId: "root-main", - groupId: "group-root", - pane: createTestPane("pane-b"), - index: 1, - }); - - const nextState = store.getState().state; - - const group = - nextState.roots[0]?.root.type === "group" - ? nextState.roots[0]?.root - : null; - expect(group?.panes.map((pane) => pane.id)).toEqual([ - "pane-a", - "pane-b", - "pane-c", - ]); - }); - - it("clamps add-pane indexes and can select the inserted pane", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [createTestPane("pane-b"), createTestPane("pane-c")], - }), - ], - }), - }); - - store.getState().addPaneToGroup({ - rootId: "root-main", - groupId: "group-root", - pane: createTestPane("pane-a"), - index: -10, - select: true, - }); - - const root = getFirstRoot(store.getState().state); - if (root.root.type !== "group") { - throw new Error("Expected group root"); - } - - expect(root.root.panes.map((pane) => pane.id)).toEqual([ - "pane-a", - "pane-b", - "pane-c", - ]); - expect(root.root.activePaneId).toBe("pane-a"); - expect(root.activeGroupId).toBe("group-root"); - }); - - it("replaces the existing unpinned pane when opening in preview mode", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [ - { ...createTestPane("pane-pinned"), pinned: true }, - { ...createTestPane("pane-preview"), pinned: false }, - ], - activePaneId: "pane-preview", - }), - ], - }), - }); - - store.getState().addPaneToGroup({ - rootId: "root-main", - groupId: "group-root", - pane: { ...createTestPane("pane-next-preview"), pinned: false }, - replaceUnpinned: true, - }); - - const root = getFirstRoot(store.getState().state); - if (root.root.type !== "group") { - throw new Error("Expected group root"); - } - - expect(root.root.panes.map((pane) => pane.id)).toEqual([ - "pane-pinned", - "pane-next-preview", - ]); - expect(root.root.activePaneId).toBe("pane-next-preview"); - }); - - it("pins a pane so later preview opens append instead of replacing it", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [{ ...createTestPane("pane-preview"), pinned: false }], - }), - ], - }), - }); - - store.getState().setPanePinned({ - rootId: "root-main", - groupId: "group-root", - paneId: "pane-preview", - pinned: true, - }); - store.getState().addPaneToGroup({ - rootId: "root-main", - groupId: "group-root", - pane: { ...createTestPane("pane-second-preview"), pinned: false }, - replaceUnpinned: true, - }); - - const root = getFirstRoot(store.getState().state); - if (root.root.type !== "group") { - throw new Error("Expected group root"); - } - - expect(root.root.panes.map((pane) => pane.id)).toEqual([ - "pane-preview", - "pane-second-preview", - ]); - expect(root.root.panes[0]?.pinned).toBe(true); - }); - - it("closes the active pane and selects the next available pane", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [createTestPane("pane-a"), createTestPane("pane-b")], - activePaneId: "pane-a", - }), - ], - }), - }); - - store.getState().closePane({ - rootId: "root-main", - groupId: "group-root", - paneId: "pane-a", - }); - - const nextState = store.getState().state; - - const group = - nextState.roots[0]?.root.type === "group" - ? nextState.roots[0]?.root - : null; - expect(group?.panes.map((pane) => pane.id)).toEqual(["pane-b"]); - expect(group?.activePaneId).toBe("pane-b"); - }); - - it("resizes an existing split node", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - { - id: "root-main", - activeGroupId: "group-left", - root: { - type: "split", - id: "split-root", - direction: "horizontal", - sizes: [50, 50], - children: [ - { - type: "group", - id: "group-left", - activePaneId: "pane-a", - panes: [createTestPane("pane-a")], - }, - { - type: "group", - id: "group-right", - activePaneId: "pane-b", - panes: [createTestPane("pane-b")], - }, - ], - }, - }, - ], - }), - }); - - store.getState().resizeSplit({ - rootId: "root-main", - splitId: "split-root", - sizes: [35, 65], - }); - - const root = getFirstRoot(store.getState().state); - expect(root.root).toMatchObject({ - type: "split", - id: "split-root", - sizes: [35, 65], - }); - }); - - it("equalizes split sizes across all children", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - { - id: "root-main", - activeGroupId: "group-a", - root: { - type: "split", - id: "split-root", - direction: "horizontal", - sizes: [10, 30, 60], - children: [ - { - type: "group", - id: "group-a", - activePaneId: "pane-a", - panes: [createTestPane("pane-a")], - }, - { - type: "group", - id: "group-b", - activePaneId: "pane-b", - panes: [createTestPane("pane-b")], - }, - { - type: "group", - id: "group-c", - activePaneId: "pane-c", - panes: [createTestPane("pane-c")], - }, - ], - }, - }, - ], - }), - }); - - store.getState().equalizeSplit({ - rootId: "root-main", - splitId: "split-root", - }); - - const root = getFirstRoot(store.getState().state); - expect(root.root).toMatchObject({ - type: "split", - id: "split-root", - sizes: [100 / 3, 100 / 3, 100 / 3], - }); - }); - - it("treats invalid ids as no-ops", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [createTestPane("pane-a"), createTestPane("pane-b")], - }), - ], - }), - }); - - const before = structuredClone(store.getState().state); - - store.getState().setActiveRoot("missing-root"); - store.getState().setActiveGroup({ - rootId: "root-main", - groupId: "missing-group", - }); - store.getState().setActivePane({ - rootId: "root-main", - groupId: "group-root", - paneId: "missing-pane", - }); - store.getState().movePane({ - paneId: "missing-pane", - targetRootId: "root-main", - targetGroupId: "group-root", - }); - store.getState().resizeSplit({ - rootId: "root-main", - splitId: "missing-split", - sizes: [10, 90], - }); - - expect(store.getState().state).toEqual(before); - }); - - it("updates active pane without going through a reducer action union", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [createTestPane("pane-a"), createTestPane("pane-b")], - }), - ], - }), - }); - - store.getState().setActivePane({ - rootId: "root-main", - groupId: "group-root", - paneId: "pane-b", - }); - - const nextState = store.getState().state; - - const group = - nextState.roots[0]?.root.type === "group" - ? nextState.roots[0]?.root - : null; - expect(group?.activePaneId).toBe("pane-b"); - }); -}); - -describe("createPaneWorkspaceStore", () => { - it("wraps the pure operations in ergonomic Zustand methods", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - createPaneRoot({ - id: "root-main", - groupId: "group-root", - panes: [createTestPane("pane-a"), createTestPane("pane-b")], - }), - ], - }), - }); - - store.getState().setActivePane({ - rootId: "root-main", - groupId: "group-root", - paneId: "pane-b", - }); - - const root = getFirstRoot(store.getState().state); - const group = root.root.type === "group" ? root.root : null; - expect(store.getState().state.activeRootId).toBe("root-main"); - expect(root.activeGroupId).toBe("group-root"); - expect(group?.activePaneId).toBe("pane-b"); - }); - - it("exposes active-root, group, and pane selectors for renderer code", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [ - { - id: "root-main", - activeGroupId: "group-right", - root: { - type: "split", - id: "split-root", - direction: "horizontal", - sizes: [50, 50], - children: [ - { - type: "group", - id: "group-left", - activePaneId: "pane-a", - panes: [createTestPane("pane-a")], - }, - { - type: "group", - id: "group-right", - activePaneId: "pane-b", - panes: [createTestPane("pane-b", "File B")], - }, - ], - }, - }, - ], - }), - }); - - expect(store.getState().getActiveRoot()?.id).toBe("root-main"); - expect(store.getState().getActiveGroup()?.id).toBe("group-right"); - expect(store.getState().getPane("pane-b")).toMatchObject({ - rootId: "root-main", - groupId: "group-right", - paneIndex: 0, - pane: { - id: "pane-b", - data: { label: "File B" }, - }, - }); - expect(store.getState().getActivePane()).toMatchObject({ - rootId: "root-main", - groupId: "group-right", - paneIndex: 0, - pane: { - id: "pane-b", - }, - }); - }); - - it("supports direct state replacement", () => { - const store = createPaneWorkspaceStore({ - initialState: createPaneWorkspaceState({ - roots: [], - }), - }); - - store.getState().replaceState((prev: PaneWorkspaceState) => ({ - ...prev, - activeRootId: "root-created", - })); - - expect(store.getState().state.activeRootId).toBe("root-created"); - }); -}); diff --git a/packages/pane-layout/src/core/store/store.ts b/packages/pane-layout/src/core/store/store.ts deleted file mode 100644 index 0a58971d515..00000000000 --- a/packages/pane-layout/src/core/store/store.ts +++ /dev/null @@ -1,888 +0,0 @@ -import { createStore, type StoreApi } from "zustand/vanilla"; -import type { - PaneGroupNode, - PaneRootState, - PaneSplitPosition, - PaneState, - PaneWorkspaceState, -} from "../../types"; -import { - findNodePathByGroupId, - findNodePathBySplitId, - findPaneLocation, - getGroupNode, - getNodeAtPath, - replaceNodeAtPath, - updateGroupNode, - updateNodeAtPath, -} from "./utils"; - -export interface PaneWorkspaceStoreState { - state: PaneWorkspaceState; -} - -export interface CreatePaneWorkspaceStoreOptions { - initialState: PaneWorkspaceState; -} - -function generatePaneLayoutId(prefix: string): string { - return `${prefix}-${crypto.randomUUID()}`; -} - -function getFirstGroupId( - node: PaneRootState["root"], -): string | null { - if (node.type === "group") { - return node.id; - } - - for (const child of node.children) { - const groupId = getFirstGroupId(child); - if (groupId) { - return groupId; - } - } - - return null; -} - -function collapseEmptyNodes( - node: PaneRootState["root"], -): PaneRootState["root"] | null { - if (node.type === "group") { - return node.panes.length > 0 ? node : null; - } - - const nextChildren = node.children - .map((child) => collapseEmptyNodes(child)) - .filter((child): child is NonNullable => child !== null); - - if (nextChildren.length === 0) { - return null; - } - - if (nextChildren.length === 1) { - const [onlyChild] = nextChildren; - return onlyChild ?? null; - } - - return { - ...node, - children: nextChildren, - sizes: Array.from( - { length: nextChildren.length }, - () => 100 / nextChildren.length, - ), - }; -} - -function normalizeRootState( - root: PaneRootState, -): PaneRootState | null { - const nextRootNode = collapseEmptyNodes(root.root); - if (!nextRootNode) { - return null; - } - - const nextRoot = { - ...root, - root: nextRootNode, - }; - const nextActiveGroupId = - root.activeGroupId && getGroupNode(nextRoot, root.activeGroupId) - ? root.activeGroupId - : getFirstGroupId(nextRootNode); - - return { - ...nextRoot, - activeGroupId: nextActiveGroupId, - }; -} - -function getRootAtIndex( - roots: PaneRootState[], - index: number, -): PaneRootState | null { - return roots[index] ?? null; -} - -export interface PaneWorkspaceStore - extends PaneWorkspaceStoreState { - getRoot: (rootId: string) => PaneRootState | null; - getActiveRoot: () => PaneRootState | null; - getGroup: (args: { - rootId: string; - groupId: string; - }) => PaneGroupNode | null; - getActiveGroup: (rootId?: string) => PaneGroupNode | null; - getPane: (paneId: string) => { - rootId: string; - groupId: string; - paneIndex: number; - pane: PaneState; - } | null; - getActivePane: (rootId?: string) => { - rootId: string; - groupId: string; - paneIndex: number; - pane: PaneState; - } | null; - replaceState: ( - next: - | PaneWorkspaceState - | (( - prev: PaneWorkspaceState, - ) => PaneWorkspaceState), - ) => void; - addRoot: (root: PaneRootState) => void; - removeRoot: (rootId: string) => void; - setActiveRoot: (rootId: string) => void; - setRootTitleOverride: (args: { - rootId: string; - titleOverride?: string; - }) => void; - setActiveGroup: (args: { rootId: string; groupId: string }) => void; - setActivePane: (args: { - rootId: string; - groupId: string; - paneId: string; - }) => void; - setPaneTitleOverride: (args: { - rootId: string; - groupId: string; - paneId: string; - titleOverride?: string; - }) => void; - setPanePinned: (args: { - rootId: string; - groupId: string; - paneId: string; - pinned: boolean; - }) => void; - setPaneData: (args: { paneId: string; data: TPaneData }) => void; - addPaneToGroup: (args: { - rootId: string; - groupId: string; - pane: PaneState; - index?: number; - replaceUnpinned?: boolean; - select?: boolean; - }) => void; - closePane: (args: { - rootId: string; - groupId: string; - paneId: string; - }) => void; - movePane: (args: { - paneId: string; - targetRootId: string; - targetGroupId: string; - index?: number; - select?: boolean; - }) => void; - splitGroup: (args: { - rootId: string; - groupId: string; - position: PaneSplitPosition; - newPane: PaneState; - selectNewPane?: boolean; - sizes?: number[]; - }) => void; - resizeSplit: (args: { - rootId: string; - splitId: string; - sizes: number[]; - }) => void; - equalizeSplit: (args: { rootId: string; splitId: string }) => void; -} - -export function createPane({ - id, - kind, - titleOverride, - pinned, - data, -}: { - id?: string; - kind: string; - titleOverride?: string; - pinned?: boolean; - data: TPaneData; -}): PaneState { - return { - id: id ?? generatePaneLayoutId("pane"), - kind, - titleOverride, - pinned, - data, - }; -} - -export function createPaneRoot({ - id, - titleOverride, - groupId, - panes, - activePaneId, -}: { - id?: string; - titleOverride?: string; - groupId?: string; - panes: Array>; - activePaneId?: string | null; -}): PaneRootState { - const resolvedRootId = id ?? generatePaneLayoutId("root"); - const resolvedGroupId = groupId ?? generatePaneLayoutId("group"); - - return { - id: resolvedRootId, - titleOverride, - root: { - type: "group", - id: resolvedGroupId, - activePaneId: activePaneId ?? panes[0]?.id ?? null, - panes, - }, - activeGroupId: resolvedGroupId, - }; -} - -export function createPaneWorkspaceState({ - roots, - activeRootId, -}: { - roots?: Array>; - activeRootId?: string | null; -}): PaneWorkspaceState { - const resolvedRoots = roots ?? []; - - return { - version: 1, - roots: resolvedRoots, - activeRootId: activeRootId ?? resolvedRoots[0]?.id ?? null, - }; -} - -export function createPaneWorkspaceStore( - options: CreatePaneWorkspaceStoreOptions, -): StoreApi> { - return createStore>((set, get) => ({ - state: options.initialState, - getRoot: (rootId) => - get().state.roots.find((root) => root.id === rootId) ?? null, - getActiveRoot: () => { - const state = get().state; - return state.roots.find((root) => root.id === state.activeRootId) ?? null; - }, - getGroup: (args) => { - const root = get().state.roots.find((entry) => entry.id === args.rootId); - return root ? getGroupNode(root, args.groupId) : null; - }, - getActiveGroup: (rootId) => { - const state = get().state; - const root = - (rootId == null - ? state.roots.find((entry) => entry.id === state.activeRootId) - : state.roots.find((entry) => entry.id === rootId)) ?? null; - return root?.activeGroupId - ? getGroupNode(root, root.activeGroupId) - : null; - }, - getPane: (paneId) => { - const location = findPaneLocation(get().state, paneId); - if (!location) return null; - - const root = get().state.roots.find( - (entry) => entry.id === location.rootId, - ); - const group = root ? getGroupNode(root, location.groupId) : null; - const pane = group?.panes[location.paneIndex] ?? null; - - return pane - ? { - ...location, - pane, - } - : null; - }, - getActivePane: (rootId) => { - const root = - rootId == null ? get().getActiveRoot() : get().getRoot(rootId); - if (!root || !root.activeGroupId) return null; - - const group = getGroupNode(root, root.activeGroupId); - if (!group || !group.activePaneId) return null; - - const paneIndex = group.panes.findIndex( - (pane) => pane.id === group.activePaneId, - ); - if (paneIndex === -1) return null; - const pane = group.panes[paneIndex]; - if (!pane) return null; - - return { - rootId: root.id, - groupId: group.id, - paneIndex, - pane, - }; - }, - replaceState: (next) => { - set((state) => ({ - state: typeof next === "function" ? next(state.state) : next, - })); - }, - addRoot: (root) => { - set((state) => ({ - state: { - ...state.state, - roots: [...state.state.roots, root], - activeRootId: state.state.activeRootId ?? root.id, - }, - })); - }, - removeRoot: (rootId) => { - set((state) => ({ - state: { - ...state.state, - roots: state.state.roots.filter((root) => root.id !== rootId), - activeRootId: - state.state.activeRootId === rootId - ? (state.state.roots.filter((root) => root.id !== rootId)[0] - ?.id ?? null) - : state.state.activeRootId, - }, - })); - }, - setActiveRoot: (rootId) => { - set((state) => ({ - state: state.state.roots.some((root) => root.id === rootId) - ? { ...state.state, activeRootId: rootId } - : state.state, - })); - }, - setRootTitleOverride: (args) => { - set((state) => ({ - state: { - ...state.state, - roots: state.state.roots.map((root) => - root.id === args.rootId - ? { - ...root, - titleOverride: args.titleOverride, - } - : root, - ), - }, - })); - }, - setActiveGroup: (args) => { - set((state) => { - const rootIndex = state.state.roots.findIndex( - (root) => root.id === args.rootId, - ); - if (rootIndex === -1) return state; - const root = getRootAtIndex(state.state.roots, rootIndex); - if (!root || !getGroupNode(root, args.groupId)) { - return state; - } - - return { - state: { - ...state.state, - roots: state.state.roots.map((root, index) => - index === rootIndex - ? { - ...root, - activeGroupId: args.groupId, - } - : root, - ), - }, - }; - }); - }, - setActivePane: (args) => { - set((state) => { - const rootIndex = state.state.roots.findIndex( - (root) => root.id === args.rootId, - ); - if (rootIndex === -1) return state; - const root = getRootAtIndex(state.state.roots, rootIndex); - if (!root) return state; - - const group = getGroupNode(root, args.groupId); - if (!group || !group.panes.some((pane) => pane.id === args.paneId)) { - return state; - } - - return { - state: { - ...state.state, - roots: state.state.roots.map((root, index) => - index === rootIndex - ? { - ...updateGroupNode(root, args.groupId, (currentGroup) => ({ - ...currentGroup, - activePaneId: args.paneId, - })), - activeGroupId: args.groupId, - } - : root, - ), - activeRootId: args.rootId, - }, - }; - }); - }, - setPaneTitleOverride: (args) => { - set((state) => { - const rootIndex = state.state.roots.findIndex( - (root) => root.id === args.rootId, - ); - if (rootIndex === -1) return state; - const root = getRootAtIndex(state.state.roots, rootIndex); - if (!root) return state; - - const group = getGroupNode(root, args.groupId); - if (!group || !group.panes.some((pane) => pane.id === args.paneId)) { - return state; - } - - return { - state: { - ...state.state, - roots: state.state.roots.map((root, index) => - index === rootIndex - ? updateGroupNode(root, args.groupId, (currentGroup) => ({ - ...currentGroup, - panes: currentGroup.panes.map((pane) => - pane.id === args.paneId - ? { - ...pane, - titleOverride: args.titleOverride, - } - : pane, - ), - })) - : root, - ), - }, - }; - }); - }, - setPanePinned: (args) => { - set((state) => { - const rootIndex = state.state.roots.findIndex( - (root) => root.id === args.rootId, - ); - if (rootIndex === -1) return state; - const root = getRootAtIndex(state.state.roots, rootIndex); - if (!root) return state; - - const group = getGroupNode(root, args.groupId); - if (!group || !group.panes.some((pane) => pane.id === args.paneId)) { - return state; - } - - return { - state: { - ...state.state, - roots: state.state.roots.map((root, index) => - index === rootIndex - ? updateGroupNode(root, args.groupId, (currentGroup) => ({ - ...currentGroup, - panes: currentGroup.panes.map((pane) => - pane.id === args.paneId - ? { ...pane, pinned: args.pinned } - : pane, - ), - })) - : root, - ), - }, - }; - }); - }, - setPaneData: (args) => { - set((state) => { - const paneLocation = findPaneLocation(state.state, args.paneId); - if (!paneLocation) return state; - - return { - state: { - ...state.state, - roots: state.state.roots.map((root) => - root.id === paneLocation.rootId - ? updateGroupNode( - root, - paneLocation.groupId, - (currentGroup) => ({ - ...currentGroup, - panes: currentGroup.panes.map((pane) => - pane.id === args.paneId - ? { ...pane, data: args.data } - : pane, - ), - }), - ) - : root, - ), - }, - }; - }); - }, - addPaneToGroup: (args) => { - set((state) => { - const rootIndex = state.state.roots.findIndex( - (root) => root.id === args.rootId, - ); - if (rootIndex === -1) return state; - const root = getRootAtIndex(state.state.roots, rootIndex); - if (!root) return state; - - const group = getGroupNode(root, args.groupId); - if (!group || group.panes.some((pane) => pane.id === args.pane.id)) { - return state; - } - - return { - state: { - ...state.state, - roots: state.state.roots.map((root, index) => - index === rootIndex - ? { - ...updateGroupNode(root, args.groupId, (currentGroup) => { - const previewIndex = args.replaceUnpinned - ? currentGroup.panes.findIndex( - (pane) => pane.pinned !== true, - ) - : -1; - const nextPanes = [...currentGroup.panes]; - - if (previewIndex !== -1) { - nextPanes.splice(previewIndex, 1, args.pane); - } else { - const insertAt = - args.index == null - ? currentGroup.panes.length - : Math.max( - 0, - Math.min(args.index, currentGroup.panes.length), - ); - nextPanes.splice(insertAt, 0, args.pane); - } - - return { - ...currentGroup, - panes: nextPanes, - activePaneId: - args.select === true || - currentGroup.activePaneId === - currentGroup.panes[previewIndex]?.id || - currentGroup.activePaneId == null - ? args.pane.id - : currentGroup.activePaneId, - }; - }), - activeGroupId: - args.select === true ? args.groupId : root.activeGroupId, - } - : root, - ), - activeRootId: args.rootId, - }, - }; - }); - }, - closePane: (args) => { - set((state) => { - const rootIndex = state.state.roots.findIndex( - (root) => root.id === args.rootId, - ); - if (rootIndex === -1) return state; - const root = getRootAtIndex(state.state.roots, rootIndex); - if (!root) return state; - - const group = getGroupNode(root, args.groupId); - if (!group || !group.panes.some((pane) => pane.id === args.paneId)) { - return state; - } - - const nextRoots = state.state.roots - .map((root, index) => - index === rootIndex - ? normalizeRootState( - updateGroupNode(root, args.groupId, (currentGroup) => { - const nextPanes = currentGroup.panes.filter( - (pane) => pane.id !== args.paneId, - ); - return { - ...currentGroup, - panes: nextPanes, - activePaneId: - currentGroup.activePaneId === args.paneId - ? (nextPanes[0]?.id ?? null) - : currentGroup.activePaneId, - }; - }), - ) - : root, - ) - .filter((root): root is PaneRootState => root !== null); - - return { - state: { - ...state.state, - roots: nextRoots, - activeRootId: nextRoots.some( - (root) => root.id === state.state.activeRootId, - ) - ? state.state.activeRootId - : (nextRoots[0]?.id ?? null), - }, - }; - }); - }, - movePane: (args) => { - set((state) => { - const source = findPaneLocation(state.state, args.paneId); - if (!source) return state; - - const sourceRootIndex = state.state.roots.findIndex( - (root) => root.id === source.rootId, - ); - const targetRootIndex = state.state.roots.findIndex( - (root) => root.id === args.targetRootId, - ); - if (sourceRootIndex === -1 || targetRootIndex === -1) return state; - const sourceRoot = getRootAtIndex(state.state.roots, sourceRootIndex); - const targetRoot = getRootAtIndex(state.state.roots, targetRootIndex); - if (!sourceRoot || !targetRoot) return state; - - const sourceGroup = getGroupNode(sourceRoot, source.groupId); - const targetGroup = getGroupNode(targetRoot, args.targetGroupId); - if (!sourceGroup || !targetGroup) return state; - - const sourcePaneIndex = sourceGroup.panes.findIndex( - (pane) => pane.id === args.paneId, - ); - if (sourcePaneIndex === -1) return state; - const pane = sourceGroup.panes[sourcePaneIndex]; - if (!pane) return state; - const nextSourceGroup = { - ...sourceGroup, - panes: sourceGroup.panes.filter( - (existingPane) => existingPane.id !== args.paneId, - ), - activePaneId: - sourceGroup.activePaneId === args.paneId - ? (sourceGroup.panes.find( - (existingPane) => existingPane.id !== args.paneId, - )?.id ?? null) - : sourceGroup.activePaneId, - }; - - let nextState: PaneWorkspaceState = { - ...state.state, - roots: state.state.roots.map((root, index) => - index === sourceRootIndex - ? updateGroupNode(root, source.groupId, () => nextSourceGroup) - : root, - ), - }; - - const adjustedTargetIndex = - source.rootId === args.targetRootId && - source.groupId === args.targetGroupId && - args.index != null && - args.index > sourcePaneIndex - ? args.index - 1 - : args.index; - - nextState = { - ...nextState, - roots: nextState.roots.map((root, index) => { - if (index !== targetRootIndex) return root; - - const nextRoot = updateGroupNode( - root, - args.targetGroupId, - (currentGroup) => { - if ( - currentGroup.panes.some( - (existingPane) => existingPane.id === pane.id, - ) - ) { - return currentGroup; - } - - const insertAt = - adjustedTargetIndex == null - ? currentGroup.panes.length - : Math.max( - 0, - Math.min( - adjustedTargetIndex, - currentGroup.panes.length, - ), - ); - const nextPanes = [...currentGroup.panes]; - nextPanes.splice(insertAt, 0, pane); - - return { - ...currentGroup, - panes: nextPanes, - activePaneId: - args.select === true ? pane.id : currentGroup.activePaneId, - }; - }, - ); - - return { - ...nextRoot, - activeGroupId: - args.select === true - ? args.targetGroupId - : nextRoot.activeGroupId, - }; - }), - }; - - return { - state: { - ...nextState, - activeRootId: args.targetRootId, - }, - }; - }); - }, - splitGroup: (args) => { - set((state) => { - const rootIndex = state.state.roots.findIndex( - (root) => root.id === args.rootId, - ); - if (rootIndex === -1) return state; - const root = getRootAtIndex(state.state.roots, rootIndex); - if (!root) return state; - const path = findNodePathByGroupId(root.root, args.groupId); - if (!path) return state; - - const node = getNodeAtPath(root.root, path); - if (node.type !== "group") return state; - - const newGroup: PaneGroupNode = { - type: "group", - id: generatePaneLayoutId("group"), - activePaneId: args.newPane.id, - panes: [args.newPane], - }; - - const children = - args.position === "left" || args.position === "top" - ? [newGroup, node] - : [node, newGroup]; - - return { - state: { - ...state.state, - roots: state.state.roots.map((currentRoot, index) => - index === rootIndex - ? { - ...currentRoot, - root: replaceNodeAtPath(currentRoot.root, path, { - type: "split", - id: generatePaneLayoutId("split"), - direction: - args.position === "left" || args.position === "right" - ? "horizontal" - : "vertical", - sizes: args.sizes ?? [50, 50], - children, - }), - activeGroupId: - args.selectNewPane === false - ? currentRoot.activeGroupId - : newGroup.id, - } - : currentRoot, - ), - activeRootId: args.rootId, - }, - }; - }); - }, - resizeSplit: (args) => { - set((state) => { - const rootIndex = state.state.roots.findIndex( - (root) => root.id === args.rootId, - ); - if (rootIndex === -1) return state; - const root = getRootAtIndex(state.state.roots, rootIndex); - if (!root) return state; - const path = findNodePathBySplitId(root.root, args.splitId); - if (!path) return state; - - return { - state: { - ...state.state, - roots: state.state.roots.map((currentRoot, index) => - index === rootIndex - ? { - ...currentRoot, - root: updateNodeAtPath(currentRoot.root, path, (node) => { - if (node.type !== "split") { - throw new Error("Expected split node"); - } - return { - ...node, - sizes: args.sizes, - }; - }), - } - : currentRoot, - ), - }, - }; - }); - }, - equalizeSplit: (args) => { - set((state) => { - const rootIndex = state.state.roots.findIndex( - (root) => root.id === args.rootId, - ); - if (rootIndex === -1) return state; - const root = getRootAtIndex(state.state.roots, rootIndex); - if (!root) return state; - const path = findNodePathBySplitId(root.root, args.splitId); - if (!path) return state; - - return { - state: { - ...state.state, - roots: state.state.roots.map((currentRoot, index) => - index === rootIndex - ? { - ...currentRoot, - root: updateNodeAtPath(currentRoot.root, path, (node) => { - if (node.type !== "split") { - throw new Error("Expected split node"); - } - - return { - ...node, - sizes: Array.from( - { length: node.children.length }, - () => 100 / node.children.length, - ), - }; - }), - } - : currentRoot, - ), - }, - }; - }); - }, - })); -} diff --git a/packages/pane-layout/src/core/store/utils/index.ts b/packages/pane-layout/src/core/store/utils/index.ts deleted file mode 100644 index 9cb56a9b9c6..00000000000 --- a/packages/pane-layout/src/core/store/utils/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type { PaneLocation } from "./utils"; -export { - findNodePathByGroupId, - findNodePathBySplitId, - findPaneLocation, - getGroupNode, - getNodeAtPath, - replaceNodeAtPath, - updateGroupNode, - updateNodeAtPath, -} from "./utils"; diff --git a/packages/pane-layout/src/core/store/utils/utils.ts b/packages/pane-layout/src/core/store/utils/utils.ts deleted file mode 100644 index f181fbb9757..00000000000 --- a/packages/pane-layout/src/core/store/utils/utils.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { - PaneGroupNode, - PaneLayoutNode, - PaneRootState, - PaneWorkspaceState, -} from "../../../types"; - -export interface PaneLocation { - rootId: string; - groupId: string; - paneIndex: number; -} - -export function findNodePathByGroupId( - node: PaneLayoutNode, - groupId: string, - path: number[] = [], -): number[] | null { - if (node.type === "group") { - return node.id === groupId ? path : null; - } - - for (const [index, child] of node.children.entries()) { - const childPath = findNodePathByGroupId(child, groupId, [...path, index]); - if (childPath) return childPath; - } - - return null; -} - -export function findNodePathBySplitId( - node: PaneLayoutNode, - splitId: string, - path: number[] = [], -): number[] | null { - if (node.type === "group") return null; - if (node.id === splitId) return path; - - for (const [index, child] of node.children.entries()) { - const childPath = findNodePathBySplitId(child, splitId, [...path, index]); - if (childPath) return childPath; - } - - return null; -} - -export function getNodeAtPath( - node: PaneLayoutNode, - path: number[], -): PaneLayoutNode { - let current = node; - for (const index of path) { - if (current.type !== "split") { - throw new Error("Invalid path into non-split node"); - } - const child = current.children[index]; - if (!child) { - throw new Error("Invalid path index"); - } - current = child; - } - return current; -} - -export function replaceNodeAtPath( - node: PaneLayoutNode, - path: number[], - replacement: PaneLayoutNode, -): PaneLayoutNode { - if (path.length === 0) return replacement; - if (node.type !== "split") { - throw new Error("Cannot replace child of non-split node"); - } - - const [index, ...rest] = path; - return { - ...node, - children: node.children.map((child, childIndex) => - childIndex === index - ? replaceNodeAtPath(child, rest, replacement) - : child, - ), - }; -} - -export function updateNodeAtPath( - node: PaneLayoutNode, - path: number[], - updater: (current: PaneLayoutNode) => PaneLayoutNode, -): PaneLayoutNode { - return replaceNodeAtPath(node, path, updater(getNodeAtPath(node, path))); -} - -export function getGroupNode( - root: PaneRootState, - groupId: string, -): PaneGroupNode | null { - const path = findNodePathByGroupId(root.root, groupId); - if (!path) return null; - - const node = getNodeAtPath(root.root, path); - return node.type === "group" ? node : null; -} - -export function updateGroupNode( - root: PaneRootState, - groupId: string, - updater: (group: PaneGroupNode) => PaneGroupNode, -): PaneRootState { - const path = findNodePathByGroupId(root.root, groupId); - if (!path) return root; - - return { - ...root, - root: updateNodeAtPath(root.root, path, (node) => { - if (node.type !== "group") { - throw new Error("Expected group node"); - } - return updater(node); - }), - }; -} - -export function findPaneLocation( - state: PaneWorkspaceState, - paneId: string, -): PaneLocation | null { - const visit = ( - rootId: string, - node: PaneLayoutNode, - ): PaneLocation | null => { - if (node.type === "group") { - for (const [paneIndex, pane] of node.panes.entries()) { - if (pane.id === paneId) { - return { - rootId, - groupId: node.id, - paneIndex, - }; - } - } - return null; - } - - for (const child of node.children) { - const location = visit(rootId, child); - if (location) return location; - } - - return null; - }; - - for (const root of state.roots) { - const location = visit(root.id, root.root); - if (location) return location; - } - - return null; -} diff --git a/packages/pane-layout/src/index.ts b/packages/pane-layout/src/index.ts deleted file mode 100644 index 21c3fa4ebb1..00000000000 --- a/packages/pane-layout/src/index.ts +++ /dev/null @@ -1,29 +0,0 @@ -export type { - CreatePaneWorkspaceStoreOptions, - PaneWorkspaceStore, - PaneWorkspaceStoreState, -} from "./core/store"; -export { - createPane, - createPaneRoot, - createPaneWorkspaceState, - createPaneWorkspaceStore, -} from "./core/store"; -export type { - PaneDefinition, - PaneRegistry, - PaneRendererContext, - PaneWorkspaceProps, -} from "./react"; -export { PaneWorkspace, usePaneWorkspaceStore } from "./react"; -export type { - DropTarget, - PaneGroupNode, - PaneLayoutNode, - PaneRootState, - PaneSplitDirection, - PaneSplitNode, - PaneSplitPosition, - PaneState, - PaneWorkspaceState, -} from "./types"; diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/PaneWorkspace.tsx b/packages/pane-layout/src/react/components/PaneWorkspace/PaneWorkspace.tsx deleted file mode 100644 index 251b85ea4c5..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/PaneWorkspace.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { cn } from "@superset/ui/lib/utils"; -import { usePaneWorkspaceStore } from "../../hooks"; -import type { PaneWorkspaceProps } from "../../types"; -import { PaneRootTabs } from "./components/PaneRootTabs"; -import { PaneRootView } from "./components/PaneRootView"; - -export function PaneWorkspace({ - store, - registry, - className, - getRootTitle, - onAddRoot, - renderAddRootMenu, - onAddPane, - renderEmptyState, - renderUnknownPane, -}: PaneWorkspaceProps) { - const roots = usePaneWorkspaceStore(store, (state) => state.state.roots); - const activeRootId = usePaneWorkspaceStore( - store, - (state) => state.state.activeRootId, - ); - const activeRoot = - roots.find((root) => root.id === activeRootId) ?? roots[0] ?? null; - - return ( -
- store.getState().setActiveRoot(rootId)} - roots={roots} - store={store} - /> - -
- ); -} diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneContent/PaneContent.tsx b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneContent/PaneContent.tsx deleted file mode 100644 index c20c2f61879..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneContent/PaneContent.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { StoreApi } from "zustand/vanilla"; -import type { PaneWorkspaceStore } from "../../../../../core/store"; -import type { - PaneGroupNode, - PaneRootState, - PaneState, -} from "../../../../../types"; -import type { PaneRegistry, PaneRendererContext } from "../../../../types"; - -interface PaneContentProps { - store: StoreApi>; - root: PaneRootState; - group: PaneGroupNode; - pane: PaneState; - registry: PaneRegistry; - renderUnknownPane?: ( - context: PaneRendererContext, - ) => React.ReactNode; -} - -export function PaneContent({ - store, - root, - group, - pane, - registry, - renderUnknownPane, -}: PaneContentProps) { - const definition = registry[pane.kind]; - const context: PaneRendererContext = { - store, - root, - group, - pane, - isActive: group.activePaneId === pane.id, - }; - - return ( -
- {definition - ? definition.renderPane(context) - : (renderUnknownPane?.(context) ?? ( -
- Unknown pane kind: {pane.kind} -
- ))} -
- ); -} diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneContent/index.ts b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneContent/index.ts deleted file mode 100644 index c9a57af1792..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaneContent } from "./PaneContent"; diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneGroup/PaneGroup.tsx b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneGroup/PaneGroup.tsx deleted file mode 100644 index 08018225b93..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneGroup/PaneGroup.tsx +++ /dev/null @@ -1,276 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import { Input } from "@superset/ui/input"; -import { cn } from "@superset/ui/lib/utils"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { PlusIcon, XIcon } from "lucide-react"; -import { useState } from "react"; -import type { StoreApi } from "zustand/vanilla"; -import type { PaneWorkspaceStore } from "../../../../../core/store"; -import type { - PaneGroupNode, - PaneRootState, - PaneState, -} from "../../../../../types"; -import type { PaneRegistry, PaneRendererContext } from "../../../../types"; -import { PaneContent } from "../PaneContent"; - -interface PaneGroupProps { - store: StoreApi>; - root: PaneRootState; - group: PaneGroupNode; - registry: PaneRegistry; - onAddPane?: (args: { - store: StoreApi>; - root: PaneRootState; - group: PaneGroupNode; - }) => void; - renderUnknownPane?: ( - context: PaneRendererContext, - ) => React.ReactNode; -} - -interface PaneGroupItemProps { - store: StoreApi>; - root: PaneRootState; - group: PaneGroupNode; - pane: PaneState; - registry: PaneRegistry; - isActive: boolean; -} - -function PaneGroupItem({ - store, - root, - group, - pane, - registry, - isActive, -}: PaneGroupItemProps) { - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(""); - - const context: PaneRendererContext = { - store, - root, - group, - pane, - isActive, - }; - const definition = registry[pane.kind]; - const title = - pane.titleOverride ?? definition?.getTitle?.(context) ?? pane.id; - - const startEditing = () => { - setEditValue(typeof title === "string" ? title : pane.id); - setIsEditing(true); - }; - - const saveEdit = () => { - const nextTitle = editValue.trim(); - store.getState().setPaneTitleOverride({ - rootId: root.id, - groupId: group.id, - paneId: pane.id, - titleOverride: nextTitle.length > 0 ? nextTitle : undefined, - }); - setIsEditing(false); - }; - - return ( - - -
- {isEditing ? ( -
- setEditValue(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - saveEdit(); - } - if (event.key === "Escape") { - event.preventDefault(); - setIsEditing(false); - } - }} - value={editValue} - /> -
- ) : ( - <> - -
- - - - - - Close - - -
- - )} -
-
- - Rename - - - store.getState().setPanePinned({ - rootId: root.id, - groupId: group.id, - paneId: pane.id, - pinned: !pane.pinned, - }) - } - > - {pane.pinned ? "Unpin" : "Pin"} - - - - store.getState().closePane({ - rootId: root.id, - groupId: group.id, - paneId: pane.id, - }) - } - > - Close - - -
- ); -} - -export function PaneGroup({ - store, - root, - group, - registry, - onAddPane, - renderUnknownPane, -}: PaneGroupProps) { - const activePane = - group.panes.find((pane) => pane.id === group.activePaneId) ?? - group.panes[0] ?? - null; - - return ( -
-
-
-
- {group.panes.map((pane) => ( - - ))} -
-
- {onAddPane ? ( -
- - - - - - Add pane - - -
- ) : null} -
-
- {activePane ? ( - - ) : ( -
- No panes in this group -
- )} -
-
- ); -} diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneGroup/index.ts b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneGroup/index.ts deleted file mode 100644 index 45be6166523..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneGroup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaneGroup } from "./PaneGroup"; diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneNodeView/PaneNodeView.tsx b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneNodeView/PaneNodeView.tsx deleted file mode 100644 index c4843fb21a8..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneNodeView/PaneNodeView.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import type { StoreApi } from "zustand/vanilla"; -import type { PaneWorkspaceStore } from "../../../../../core/store"; -import type { PaneLayoutNode, PaneRootState } from "../../../../../types"; -import type { PaneRegistry, PaneRendererContext } from "../../../../types"; -import { PaneGroup } from "../PaneGroup"; -import { PaneSplitHandle } from "../PaneSplitHandle"; - -interface PaneNodeViewProps { - store: StoreApi>; - root: PaneRootState; - node: PaneLayoutNode; - registry: PaneRegistry; - onAddPane?: (args: { - store: StoreApi>; - root: PaneRootState; - group: import("../../../../../types").PaneGroupNode; - }) => void; - renderUnknownPane?: ( - context: PaneRendererContext, - ) => React.ReactNode; -} - -export function PaneNodeView({ - store, - root, - node, - registry, - onAddPane, - renderUnknownPane, -}: PaneNodeViewProps) { - if (node.type === "group") { - return ( - - ); - } - - const isHorizontal = node.direction === "horizontal"; - - return ( -
- {node.children.flatMap((child, index) => { - const items = [ -
- -
, - ]; - - if (index < node.children.length - 1) { - items.push( - - store.getState().equalizeSplit({ - rootId: root.id, - splitId: node.id, - }) - } - orientation={node.direction} - />, - ); - } - - return items; - })} -
- ); -} diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneNodeView/index.ts b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneNodeView/index.ts deleted file mode 100644 index 3eed51dcfa7..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneNodeView/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaneNodeView } from "./PaneNodeView"; diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/PaneRootTabs.tsx b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/PaneRootTabs.tsx deleted file mode 100644 index d0c5eb92a88..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/PaneRootTabs.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, -} from "@superset/ui/dropdown-menu"; -import { cn } from "@superset/ui/lib/utils"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { PlusIcon } from "lucide-react"; -import { - type ReactNode, - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from "react"; -import type { StoreApi } from "zustand/vanilla"; -import type { PaneWorkspaceStore } from "../../../../../core/store"; -import type { PaneRootState } from "../../../../../types"; -import { PaneRootTabItem } from "./components/PaneRootTabItem"; - -interface PaneRootTabsProps { - store: StoreApi>; - roots: Array>; - activeRootId: string | null; - onSelectRoot: (rootId: string) => void; - onAddRoot?: (args: { - store: StoreApi>; - }) => void; - renderAddRootMenu?: (args: { - store: StoreApi>; - }) => ReactNode; - getRootTitle?: (root: PaneRootState) => ReactNode; -} - -function AddRootButtonCell({ - store, - onAddRoot, - renderAddRootMenu, -}: { - store: StoreApi>; - onAddRoot?: (args: { - store: StoreApi>; - }) => void; - renderAddRootMenu?: (args: { - store: StoreApi>; - }) => ReactNode; -}) { - const button = ( - - ); - - if (renderAddRootMenu) { - return ( - - {button} - - {renderAddRootMenu({ store })} - - - ); - } - - return ( - - {button} - - Add root - - - ); -} - -export function PaneRootTabs({ - store, - roots, - activeRootId, - onSelectRoot, - onAddRoot, - renderAddRootMenu, - getRootTitle, -}: PaneRootTabsProps) { - const scrollContainerRef = useRef(null); - const rootsTrackRef = useRef(null); - const [hasHorizontalOverflow, setHasHorizontalOverflow] = useState(false); - - const updateOverflow = useCallback(() => { - const container = scrollContainerRef.current; - const track = rootsTrackRef.current; - if (!container || !track) return; - setHasHorizontalOverflow(track.scrollWidth > container.clientWidth + 1); - }, []); - - useLayoutEffect(() => { - const container = scrollContainerRef.current; - const track = rootsTrackRef.current; - if (!container || !track) return; - - updateOverflow(); - const resizeObserver = new ResizeObserver(updateOverflow); - resizeObserver.observe(container); - resizeObserver.observe(track); - window.addEventListener("resize", updateOverflow); - - return () => { - resizeObserver.disconnect(); - window.removeEventListener("resize", updateOverflow); - }; - }, [updateOverflow]); - - useEffect(() => { - requestAnimationFrame(updateOverflow); - }, [updateOverflow]); - - if (roots.length === 0) { - return ( -
- {(onAddRoot || renderAddRootMenu) && ( -
- -
- )} -
-
- ); - } - - return ( -
-
-
- {roots.map((root) => ( -
- onSelectRoot(root.id)} - root={root} - store={store} - /> -
- ))} - {(onAddRoot || renderAddRootMenu) && !hasHorizontalOverflow ? ( -
- -
- ) : null} -
-
- {(onAddRoot || renderAddRootMenu) && hasHorizontalOverflow ? ( -
- -
- ) : null} -
- ); -} diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/PaneRootTabItem.tsx b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/PaneRootTabItem.tsx deleted file mode 100644 index 53a537da397..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/PaneRootTabItem.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import { cn } from "@superset/ui/lib/utils"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import { PencilIcon, XIcon } from "lucide-react"; -import { type ReactNode, useState } from "react"; -import type { StoreApi } from "zustand/vanilla"; -import type { PaneWorkspaceStore } from "../../../../../../../core/store"; -import type { PaneRootState } from "../../../../../../../types"; -import { RootRenameInput } from "./components/RootRenameInput"; - -interface PaneRootTabItemProps { - store: StoreApi>; - root: PaneRootState; - isActive: boolean; - onSelect: () => void; - getRootTitle?: (root: PaneRootState) => ReactNode; -} - -export function PaneRootTabItem({ - store, - root, - isActive, - onSelect, - getRootTitle, -}: PaneRootTabItemProps) { - const [isEditing, setIsEditing] = useState(false); - const [editValue, setEditValue] = useState(""); - const resolvedTitle = root.titleOverride ?? getRootTitle?.(root) ?? root.id; - - const startEditing = () => { - setEditValue(typeof resolvedTitle === "string" ? resolvedTitle : root.id); - setIsEditing(true); - }; - - const stopEditing = () => { - setIsEditing(false); - }; - - const saveEdit = () => { - const nextTitle = editValue.trim(); - store.getState().setRootTitleOverride({ - rootId: root.id, - titleOverride: nextTitle.length > 0 ? nextTitle : undefined, - }); - stopEditing(); - }; - - const handleClose = () => { - store.getState().removeRoot(root.id); - }; - - return ( - - -
- {isEditing ? ( -
- -
- ) : ( - <> - - - - - - {resolvedTitle} - - -
- - - - - - Close - - -
- - )} -
-
- - - - Rename - - - - - Close - - -
- ); -} diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/components/RootRenameInput/RootRenameInput.tsx b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/components/RootRenameInput/RootRenameInput.tsx deleted file mode 100644 index 6d388636e76..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/components/RootRenameInput/RootRenameInput.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useRef } from "react"; - -interface RootRenameInputProps { - value: string; - onChange: (value: string) => void; - onSubmit: () => void; - onCancel: () => void; - className?: string; - maxLength?: number; -} - -export function RootRenameInput({ - value, - onChange, - onSubmit, - onCancel, - className, - maxLength, -}: RootRenameInputProps) { - const inputRef = useRef(null); - - useEffect(() => { - if (inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, []); - - return ( - onChange(event.target.value)} - onClick={(event) => event.stopPropagation()} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - onSubmit(); - } else if (event.key === "Escape") { - event.preventDefault(); - onCancel(); - } - }} - onMouseDown={(event) => event.stopPropagation()} - type="text" - value={value} - /> - ); -} diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/components/RootRenameInput/index.ts b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/components/RootRenameInput/index.ts deleted file mode 100644 index 34c4f38a0b8..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/components/RootRenameInput/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { RootRenameInput } from "./RootRenameInput"; diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/index.ts b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/index.ts deleted file mode 100644 index bc91d9a299e..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/components/PaneRootTabItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaneRootTabItem } from "./PaneRootTabItem"; diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/index.ts b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/index.ts deleted file mode 100644 index ef90a6f990f..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootTabs/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaneRootTabs } from "./PaneRootTabs"; diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootView/PaneRootView.tsx b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootView/PaneRootView.tsx deleted file mode 100644 index 5e8fbddef2a..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootView/PaneRootView.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { StoreApi } from "zustand/vanilla"; -import type { PaneWorkspaceStore } from "../../../../../core/store"; -import type { PaneRootState } from "../../../../../types"; -import type { PaneRegistry, PaneRendererContext } from "../../../../types"; -import { PaneNodeView } from "../PaneNodeView"; - -interface PaneRootViewProps { - store: StoreApi>; - root: PaneRootState | null; - registry: PaneRegistry; - onAddPane?: (args: { - store: StoreApi>; - root: PaneRootState; - group: import("../../../../../types").PaneGroupNode; - }) => void; - renderEmptyState?: () => React.ReactNode; - renderUnknownPane?: ( - context: PaneRendererContext, - ) => React.ReactNode; -} - -export function PaneRootView({ - store, - root, - registry, - onAddPane, - renderEmptyState, - renderUnknownPane, -}: PaneRootViewProps) { - if (!root) { - return ( -
- {renderEmptyState?.() ?? "No panes open"} -
- ); - } - - return ( -
- -
- ); -} diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootView/index.ts b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootView/index.ts deleted file mode 100644 index 9ebe1e4365f..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneRootView/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaneRootView } from "./PaneRootView"; diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneSplitHandle/PaneSplitHandle.tsx b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneSplitHandle/PaneSplitHandle.tsx deleted file mode 100644 index c413ed0687c..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneSplitHandle/PaneSplitHandle.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { cn } from "@superset/ui/lib/utils"; - -interface PaneSplitHandleProps { - orientation: "horizontal" | "vertical"; - onDoubleClick: () => void; -} - -export function PaneSplitHandle({ - orientation, - onDoubleClick, -}: PaneSplitHandleProps) { - const isHorizontal = orientation === "horizontal"; - - return ( - - ); -} diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneSplitHandle/index.ts b/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneSplitHandle/index.ts deleted file mode 100644 index 79c76698e5a..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/components/PaneSplitHandle/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaneSplitHandle } from "./PaneSplitHandle"; diff --git a/packages/pane-layout/src/react/components/PaneWorkspace/index.ts b/packages/pane-layout/src/react/components/PaneWorkspace/index.ts deleted file mode 100644 index 4d331628f27..00000000000 --- a/packages/pane-layout/src/react/components/PaneWorkspace/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaneWorkspace } from "./PaneWorkspace"; diff --git a/packages/pane-layout/src/react/components/index.ts b/packages/pane-layout/src/react/components/index.ts deleted file mode 100644 index 4d331628f27..00000000000 --- a/packages/pane-layout/src/react/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PaneWorkspace } from "./PaneWorkspace"; diff --git a/packages/pane-layout/src/react/hooks/index.ts b/packages/pane-layout/src/react/hooks/index.ts deleted file mode 100644 index 18ea78b5973..00000000000 --- a/packages/pane-layout/src/react/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { usePaneWorkspaceStore } from "./usePaneWorkspaceStore"; diff --git a/packages/pane-layout/src/react/hooks/usePaneWorkspaceStore/index.ts b/packages/pane-layout/src/react/hooks/usePaneWorkspaceStore/index.ts deleted file mode 100644 index 18ea78b5973..00000000000 --- a/packages/pane-layout/src/react/hooks/usePaneWorkspaceStore/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { usePaneWorkspaceStore } from "./usePaneWorkspaceStore"; diff --git a/packages/pane-layout/src/react/hooks/usePaneWorkspaceStore/usePaneWorkspaceStore.ts b/packages/pane-layout/src/react/hooks/usePaneWorkspaceStore/usePaneWorkspaceStore.ts deleted file mode 100644 index c7e7bbd7687..00000000000 --- a/packages/pane-layout/src/react/hooks/usePaneWorkspaceStore/usePaneWorkspaceStore.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { useStore } from "zustand"; -import type { StoreApi } from "zustand/vanilla"; -import type { PaneWorkspaceStore } from "../../../core/store"; - -export function usePaneWorkspaceStore( - store: StoreApi>, - selector: (state: PaneWorkspaceStore) => TSelected, -): TSelected { - return useStore(store, selector); -} diff --git a/packages/pane-layout/src/react/index.ts b/packages/pane-layout/src/react/index.ts deleted file mode 100644 index 9505312520d..00000000000 --- a/packages/pane-layout/src/react/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { PaneWorkspace } from "./components"; -export { usePaneWorkspaceStore } from "./hooks"; -export type { - PaneDefinition, - PaneRegistry, - PaneRendererContext, - PaneWorkspaceProps, -} from "./types"; diff --git a/packages/pane-layout/src/react/types.ts b/packages/pane-layout/src/react/types.ts deleted file mode 100644 index 60bb2605763..00000000000 --- a/packages/pane-layout/src/react/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { ReactNode } from "react"; -import type { StoreApi } from "zustand/vanilla"; -import type { PaneWorkspaceStore } from "../core/store"; -import type { PaneGroupNode, PaneRootState, PaneState } from "../types"; - -export interface PaneRendererContext { - store: StoreApi>; - root: PaneRootState; - group: PaneGroupNode; - pane: PaneState; - isActive: boolean; -} - -export interface PaneDefinition { - renderPane: (context: PaneRendererContext) => ReactNode; - getTitle?: (context: PaneRendererContext) => ReactNode; - getIcon?: (context: PaneRendererContext) => ReactNode; - renderTabAccessory?: (context: PaneRendererContext) => ReactNode; -} - -export type PaneRegistry = Record>; - -export interface PaneWorkspaceProps { - store: StoreApi>; - registry: PaneRegistry; - className?: string; - getRootTitle?: (root: PaneRootState) => ReactNode; - onAddRoot?: (args: { - store: StoreApi>; - }) => void; - renderAddRootMenu?: (args: { - store: StoreApi>; - }) => ReactNode; - onAddPane?: (args: { - store: StoreApi>; - root: PaneRootState; - group: PaneGroupNode; - }) => void; - renderEmptyState?: () => ReactNode; - renderUnknownPane?: (context: PaneRendererContext) => ReactNode; -} diff --git a/packages/pane-layout/src/types.ts b/packages/pane-layout/src/types.ts deleted file mode 100644 index 58f3a2fd8d0..00000000000 --- a/packages/pane-layout/src/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -export type PaneSplitDirection = "horizontal" | "vertical"; -export type PaneSplitPosition = "top" | "right" | "bottom" | "left"; - -export interface PaneState { - id: string; - kind: string; - titleOverride?: string; - pinned?: boolean; - data: TPaneData; -} - -export interface PaneGroupNode { - type: "group"; - id: string; - activePaneId: string | null; - panes: Array>; -} - -export interface PaneSplitNode { - type: "split"; - id: string; - direction: PaneSplitDirection; - sizes: number[]; - children: Array>; -} - -export type PaneLayoutNode = - | PaneGroupNode - | PaneSplitNode; - -export interface PaneRootState { - id: string; - titleOverride?: string; - root: PaneLayoutNode; - activeGroupId: string | null; -} - -export interface PaneWorkspaceState { - version: 1; - roots: Array>; - activeRootId: string | null; -} - -export type DropTarget = - | { - type: "group-center"; - rootId: string; - groupId: string; - } - | { - type: "split"; - rootId: string; - groupId: string; - position: PaneSplitPosition; - }; diff --git a/packages/panes/README.md b/packages/panes/README.md new file mode 100644 index 00000000000..dce39c6aba6 --- /dev/null +++ b/packages/panes/README.md @@ -0,0 +1,343 @@ +# @superset/panes + +A generic, headless workspace layout engine. Tabs hold panes arranged in split layouts. The package provides the data model, store, and React components — you provide the pane content. + +## Concepts + +``` +Workspace +├── Tab (chat, terminal, etc.) +│ ├── Pane A ──┐ +│ ├── Pane B ├── split layout (horizontal/vertical, n-ary, weighted) +│ └── Pane C ──┘ +├── Tab +│ └── Pane D (single pane, no splits) +└── ... +``` + +- **Workspace** — top-level container. Holds tabs, tracks the active tab. +- **Tab** — a named workspace context. Each tab has a split layout of panes and a flat pane data map. +- **Pane** — a leaf in the layout tree. Typed with your own data (`TData`). Rendered by a registry of pane definitions. +- **Layout tree** — purely structural. Describes how panes are arranged (splits + weights) but holds no pane data — just `paneId` references into the tab's flat `panes` map. + +## Quick Start + +### 1. Define your pane data type + +```tsx +type MyPaneData = + | { kind: "editor"; filePath: string } + | { kind: "terminal"; sessionId: string } + | { kind: "browser"; url: string }; +``` + +### 2. Create a pane registry + +The registry tells the layout engine how to render each pane kind: + +```tsx +import type { PaneRegistry } from "@superset/panes"; + +const registry: PaneRegistry = { + // Simple pane — just title + icon, default header + terminal: { + renderPane: (ctx) => , + getTitle: () => "Terminal", + getIcon: () => , + }, + + // Full toolbar eject (browser needs nav buttons + URL bar) + browser: { + renderPane: (ctx) => , + renderToolbar: (ctx) => , + getTitle: (ctx) => ctx.pane.data.url, + getIcon: () => , + }, +}; +``` + +### 3. Create the store + +```tsx +import { createWorkspaceStore, createTab, createPane } from "@superset/panes"; + +const store = createWorkspaceStore({ + initialState: { + version: 1, + tabs: [ + createTab({ + titleOverride: "My Tab", + panes: [ + createPane({ kind: "terminal", data: { kind: "terminal", sessionId: "abc" } }), + ], + }), + ], + activeTabId: null, // auto-set to first tab + }, +}); +``` + +### 4. Render the workspace + +```tsx +import { Workspace } from "@superset/panes"; + +function App() { + return ( + ( + + addTerminalTab()}> + Terminal + + addChatTab()}> + Chat + + addBrowserTab()}> + Browser + + + )} + renderTabAccessory={(tab) => } + /> + ); +} +``` + +That's it. You get a tab bar, split panes with resizable handles, pane headers with close buttons, and context menus — all wired up. + +## Data Model + +### Layout Tree + +The layout is a tree of split nodes and pane leaves: + +```ts +type LayoutNode = + | { type: "pane"; paneId: string } + | { type: "split"; id: string; direction: "horizontal" | "vertical"; children: LayoutNode[]; weights: number[] }; +``` + +Splits are **n-ary** (not binary) — a 3-way split is `children: [A, B, C], weights: [1, 1, 1]`, not nested binary nodes. + +**Weights** are relative, not percentages. `[1, 1, 1]` = equal thirds. `[3, 2]` = 60/40. They don't need to sum to any specific value — CSS `flex-grow` handles the proportional rendering. + +### Pane + +```ts +interface Pane { + id: string; + kind: string; // maps to a key in your PaneRegistry + titleOverride?: string; // overrides getTitle() from registry + pinned?: boolean; // unpinned panes can be replaced in-place (preview mode) + data: TData; // your pane-specific state +} +``` + +### Tab + +```ts +interface Tab { + id: string; + titleOverride?: string; + createdAt: number; + activePaneId: string | null; + layout: LayoutNode | null; + panes: Record>; // flat map — layout tree references these by paneId +} +``` + +The **flat `panes` map** is separate from the layout tree. The tree is purely structural (`paneId` references), pane data lives in the map. This gives you O(1) pane lookup and clean separation of layout vs data. + +## Store + +The store is a vanilla zustand `StoreApi` (not a React hook store). This is intentional: +- Stable reference — created once, passed as a prop +- Subscribable from both React (`useStore`) and non-React code (`store.subscribe`) +- Works with any persistence layer (localStorage, IndexedDB, TanStack DB, etc.) via `replaceState` for hydration and `store.subscribe` for writes + +Create it with `createWorkspaceStore()` and pass it to ``. + +### Tab actions + +```ts +store.getState().addTab(tab) +store.getState().removeTab(tabId) +store.getState().setActiveTab(tabId) +store.getState().setTabTitleOverride(tabId, title) +store.getState().getTab(tabId) +store.getState().getActiveTab() +``` + +### Pane actions + +```ts +store.getState().setActivePane(tabId, paneId) +store.getState().getPane(paneId) // searches across all tabs +store.getState().getActivePane(tabId?) +store.getState().closePane(tabId, paneId) // removes from layout + panes, collapses empty splits +store.getState().setPaneData(paneId, data) +store.getState().setPaneTitleOverride(tabId, paneId, title) +store.getState().setPanePinned(tabId, paneId, pinned) +store.getState().replacePane(tabId, paneId, newPane) // swap unpinned pane in-place, no-op if pinned +``` + +### Split actions + +```ts +store.getState().splitPane(tabId, paneId, position, newPane, weights?) +// position: "top" | "right" | "bottom" | "left" +// splits the target pane, steals space from it (other panes untouched) + +store.getState().addPane(tabId, pane, position?, relativeToPaneId?) +// ergonomic wrapper — splits relative to a target, or appends to edge + +store.getState().resizeSplit(tabId, splitId, weights) +store.getState().equalizeSplit(tabId, splitId) // sets all weights to 1 +``` + +### Bulk + +```ts +store.getState().replaceState(newState) +store.getState().replaceState((prev) => ({ ...prev, ... })) +``` + +## Pane Registry + +Each pane kind registers how it renders: + +```ts +interface PaneDefinition { + renderPane(context: RendererContext): ReactNode; // required — the pane content + getTitle?(context: RendererContext): ReactNode; // derived title (titleOverride wins) + getIcon?(context: RendererContext): ReactNode; // icon in the pane header + renderToolbar?(context: RendererContext): ReactNode; // full eject — replaces entire header content +} +``` + +## RendererContext + +Every registry method receives a `RendererContext` with the pane's data and pre-wired actions: + +```ts +interface RendererContext { + pane: Pane; + tab: Tab; + isActive: boolean; + store: StoreApi>; // escape hatch + + actions: { + close: () => void; + focus: () => void; + setTitle: (title: string) => void; + pin: () => void; + updateData: (data: TData) => void; + splitRight: (newPane: Pane) => void; + splitDown: (newPane: Pane) => void; + }; +} +``` + +Use `context.actions.*` for normal operations. The `store` is an escape hatch for advanced cases (e.g. setting a tab title from within a pane). + +## Hooks + +Use these inside your pane components to register behavior with the layout engine: + +### useOnBeforeClose + +Register a close guard. Return `false` to cancel the close (e.g. show a "Save changes?" dialog): + +```tsx +function EditorPane({ context }: { context: RendererContext }) { + const isDirty = useDirtyState(); + + useOnBeforeClose(context, async () => { + if (!isDirty) return true; + return await showSaveConfirmation(); // returns true/false + }, [isDirty]); + + return ; +} +``` + +### useContextMenuActions + +Register pane-specific context menu items. These appear after the default items (Close, Split Right, Split Down): + +```tsx +function BrowserPane({ context }: { context: RendererContext }) { + const webviewRef = useRef(null); + + useContextMenuActions(context, [ + { label: "Refresh", icon: , shortcut: "⌘R", onSelect: () => webviewRef.current?.reload() }, + { type: "separator" }, + { label: "Open in External Browser", icon: , onSelect: () => shell.openExternal(context.pane.data.url) }, + ], [context.pane.data.url]); + + return ; +} +``` + +Context menu items support: +- `variant: "destructive"` — red text styling +- `shortcut` — display-only keyboard hint (e.g. `"⌘K"`) +- `disabled` — grayed out +- `type: "separator"` — visual divider +- `type: "submenu"` — nested menu with `items` + +## Splitting + +When you split a pane, the new pane steals space from the target. Other panes are untouched. + +```ts +// Single pane → 50/50 split +store.getState().splitPane(tabId, "pane-a", "right", newPane); +// Result: horizontal split, weights [1, 1] + +// Already in a same-direction split → target's weight is halved +// Before: horizontal [3, 2, 1], split pane[1] right +// After: horizontal [3, 1, 1, 1] +``` + +Position determines direction and order: +- `"left"` / `"right"` → horizontal split +- `"top"` / `"bottom"` → vertical split +- `"left"` / `"top"` → new pane goes first +- `"right"` / `"bottom"` → new pane goes second + +## Preview Panes (Pin/Unpin) + +Unpinned panes can be replaced in-place without creating a new split — useful for file preview (click a file → replaces the preview pane, double-click or edit → pins it): + +```ts +// Find any unpinned file pane in the tab +const preview = Object.values(tab.panes).find(p => p.kind === "file" && !p.pinned); + +if (preview) { + store.getState().replacePane(tabId, preview.id, newFilePane); +} else { + store.getState().splitPane(tabId, activePaneId, "right", newFilePane); +} +``` + +Pin from inside a pane component (e.g. on first edit): + +```tsx +context.actions.pin(); +``` + +## Workspace Props + +```ts + ReactNode} // custom UI in each tab (status dot, badge, etc.) + renderEmptyState={() => ReactNode} // shown when no tabs exist + renderAddTabMenu={() => ReactNode} // dropdown content for "+" button in tab bar +/> +``` diff --git a/packages/pane-layout/package.json b/packages/panes/package.json similarity index 94% rename from packages/pane-layout/package.json rename to packages/panes/package.json index de598c9aaf5..7af0a4daf99 100644 --- a/packages/pane-layout/package.json +++ b/packages/panes/package.json @@ -1,5 +1,5 @@ { - "name": "@superset/pane-layout", + "name": "@superset/panes", "version": "0.1.0", "private": true, "type": "module", diff --git a/packages/panes/src/core/store/index.ts b/packages/panes/src/core/store/index.ts new file mode 100644 index 00000000000..f1ee283d4c1 --- /dev/null +++ b/packages/panes/src/core/store/index.ts @@ -0,0 +1,7 @@ +export { createWorkspaceStore } from "./store"; +export type { + CreatePaneInput, + CreateTabInput, + CreateWorkspaceStoreOptions, + WorkspaceStore, +} from "./store"; diff --git a/packages/panes/src/core/store/store.test.ts b/packages/panes/src/core/store/store.test.ts new file mode 100644 index 00000000000..40d8fa45ee3 --- /dev/null +++ b/packages/panes/src/core/store/store.test.ts @@ -0,0 +1,574 @@ +import { describe, expect, it } from "bun:test"; +import type { WorkspaceState } from "../../types"; +import type { CreatePaneInput } from "./store"; +import { createWorkspaceStore } from "./store"; + +interface TestData { + label: string; +} + +function tp(id: string, label = id): CreatePaneInput { + return { id, kind: "test", data: { label } }; +} + +function makeStore(initialState?: WorkspaceState) { + return createWorkspaceStore( + initialState ? { initialState } : undefined, + ); +} + +describe("tab operations", () => { + it("adds a tab and auto-sets activeTabId", () => { + const store = makeStore(); + + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + expect(store.getState().tabs).toHaveLength(1); + expect(store.getState().activeTabId).toBe("t1"); + }); + + it("removes the active tab and falls back to neighbor", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().addTab({ id: "t2", panes: [tp("p2")] }); + store.getState().setActiveTab("t1"); + + store.getState().removeTab("t1"); + + expect(store.getState().tabs).toHaveLength(1); + expect(store.getState().activeTabId).toBe("t2"); + }); + + it("removes the only tab and sets activeTabId to null", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + store.getState().removeTab("t1"); + + expect(store.getState().tabs).toHaveLength(0); + expect(store.getState().activeTabId).toBeNull(); + }); + + it("sets active tab", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().addTab({ id: "t2", panes: [tp("p2")] }); + + store.getState().setActiveTab("t2"); + expect(store.getState().activeTabId).toBe("t2"); + }); + + it("sets tab title override", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + store.getState().setTabTitleOverride({ + tabId: "t1", + titleOverride: "Renamed", + }); + + expect(store.getState().tabs[0]?.titleOverride).toBe("Renamed"); + }); +}); + +describe("pane operations", () => { + it("sets active pane within a tab", () => { + const store = makeStore(); + store.getState().addTab({ + id: "t1", + panes: [tp("p1"), tp("p2")], + activePaneId: "p1", + }); + + store.getState().setActivePane({ tabId: "t1", paneId: "p2" }); + expect(store.getState().tabs[0]?.activePaneId).toBe("p2"); + }); + + it("gets pane by ID across tabs", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + store.getState().addTab({ id: "t2", panes: [tp("p2", "second")] }); + + const result = store.getState().getPane("p2"); + expect(result?.tabId).toBe("t2"); + expect(result?.pane.data.label).toBe("second"); + }); + + it("gets active pane with and without explicit tabId", () => { + const store = makeStore(); + store.getState().addTab({ + id: "t1", + panes: [tp("p1", "A")], + activePaneId: "p1", + }); + store.getState().addTab({ + id: "t2", + panes: [tp("p2", "B")], + activePaneId: "p2", + }); + store.getState().setActiveTab("t2"); + + expect(store.getState().getActivePane()?.pane.data.label).toBe("B"); + expect(store.getState().getActivePane("t1")?.pane.data.label).toBe("A"); + }); + + it("sets pane data in-place", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1", "old")] }); + + store.getState().setPaneData({ paneId: "p1", data: { label: "new" } }); + expect(store.getState().getPane("p1")?.pane.data.label).toBe("new"); + }); + + it("sets pane title override", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + store.getState().setPaneTitleOverride({ + tabId: "t1", + paneId: "p1", + titleOverride: "Custom", + }); + + expect(store.getState().getPane("p1")?.pane.titleOverride).toBe( + "Custom", + ); + }); + + it("pins a pane", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + store.getState().setPanePinned({ + tabId: "t1", + paneId: "p1", + pinned: true, + }); + + expect(store.getState().getPane("p1")?.pane.pinned).toBe(true); + }); + + it("replaces an unpinned pane with a new pane", () => { + const store = makeStore(); + store.getState().addTab({ + id: "t1", + panes: [{ ...tp("p1", "old"), pinned: false }], + activePaneId: "p1", + }); + + store.getState().replacePane({ + tabId: "t1", + paneId: "p1", + newPane: tp("p2", "new"), + }); + + const tab = store.getState().tabs[0]!; + expect(tab.panes["p1"]).toBeUndefined(); + expect(tab.panes["p2"]?.data.label).toBe("new"); + expect(tab.activePaneId).toBe("p2"); + expect( + tab.layout?.type === "pane" ? tab.layout.paneId : null, + ).toBe("p2"); + }); + + it("replacePane is no-op if target pane is pinned", () => { + const store = makeStore(); + store.getState().addTab({ + id: "t1", + panes: [{ ...tp("p1"), pinned: true }], + }); + + const before = structuredClone({ + version: store.getState().version, + tabs: store.getState().tabs, + activeTabId: store.getState().activeTabId, + }); + store.getState().replacePane({ + tabId: "t1", + paneId: "p1", + newPane: tp("p2"), + }); + + expect({ + version: store.getState().version, + tabs: store.getState().tabs, + activeTabId: store.getState().activeTabId, + }).toEqual(before); + }); +}); + +describe("split operations", () => { + it("splits a single pane into a split with weights [1, 1]", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "right", + newPane: tp("p2"), + }); + + const layout = store.getState().tabs[0]?.layout; + expect(layout?.type).toBe("split"); + if (layout?.type === "split") { + expect(layout.direction).toBe("horizontal"); + expect(layout.weights).toEqual([1, 1]); + expect(layout.children).toHaveLength(2); + expect(layout.children[0]).toEqual({ type: "pane", paneId: "p1" }); + expect(layout.children[1]).toEqual({ type: "pane", paneId: "p2" }); + } + }); + + it("split left puts new pane first", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "left", + newPane: tp("p2"), + }); + + const layout = store.getState().tabs[0]?.layout; + if (layout?.type === "split") { + expect(layout.direction).toBe("horizontal"); + expect(layout.children[0]).toEqual({ type: "pane", paneId: "p2" }); + expect(layout.children[1]).toEqual({ type: "pane", paneId: "p1" }); + } + }); + + it("split top/bottom uses vertical direction", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "bottom", + newPane: tp("p2"), + }); + + const layout = store.getState().tabs[0]?.layout; + if (layout?.type === "split") { + expect(layout.direction).toBe("vertical"); + expect(layout.children[0]).toEqual({ type: "pane", paneId: "p1" }); + expect(layout.children[1]).toEqual({ type: "pane", paneId: "p2" }); + } + }); + + it("split within same-direction split halves target weight and inserts adjacent", () => { + const store = makeStore({ + version: 1, + tabs: [ + { + id: "t1", + createdAt: Date.now(), + activePaneId: "p1", + layout: { + type: "split", + id: "s1", + direction: "horizontal", + children: [ + { type: "pane", paneId: "p1" }, + { type: "pane", paneId: "p2" }, + { type: "pane", paneId: "p3" }, + ], + weights: [3, 2, 1], + }, + panes: { + p1: { id: "p1", kind: "test", data: { label: "p1" } }, + p2: { id: "p2", kind: "test", data: { label: "p2" } }, + p3: { id: "p3", kind: "test", data: { label: "p3" } }, + }, + }, + ], + activeTabId: "t1", + }); + + store.getState().splitPane({ + tabId: "t1", + paneId: "p2", + position: "right", + newPane: tp("p4"), + }); + + const layout = store.getState().tabs[0]?.layout; + if (layout?.type === "split") { + expect(layout.weights).toEqual([3, 1, 1, 1]); + expect(layout.children).toHaveLength(4); + expect(layout.children[2]).toEqual({ type: "pane", paneId: "p4" }); + } + }); + + it("split with custom weights", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "right", + newPane: tp("p2"), + weights: [3, 1], + }); + + const layout = store.getState().tabs[0]?.layout; + if (layout?.type === "split") { + expect(layout.weights).toEqual([3, 1]); + } + }); + + it("split with selectNewPane: false preserves focus", () => { + const store = makeStore(); + store.getState().addTab({ + id: "t1", + panes: [tp("p1")], + activePaneId: "p1", + }); + + store.getState().splitPane({ + tabId: "t1", + paneId: "p1", + position: "right", + newPane: tp("p2"), + selectNewPane: false, + }); + + expect(store.getState().tabs[0]?.activePaneId).toBe("p1"); + }); + + it("resizes a split", () => { + const store = makeStore({ + version: 1, + tabs: [ + { + id: "t1", + createdAt: Date.now(), + activePaneId: "p1", + layout: { + type: "split", + id: "s1", + direction: "horizontal", + children: [ + { type: "pane", paneId: "p1" }, + { type: "pane", paneId: "p2" }, + ], + weights: [1, 1], + }, + panes: { + p1: { id: "p1", kind: "test", data: { label: "p1" } }, + p2: { id: "p2", kind: "test", data: { label: "p2" } }, + }, + }, + ], + activeTabId: "t1", + }); + + store.getState().resizeSplit({ + tabId: "t1", + splitId: "s1", + weights: [3, 7], + }); + + const layout = store.getState().tabs[0]?.layout; + if (layout?.type === "split") { + expect(layout.weights).toEqual([3, 7]); + } + }); + + it("equalizes a split — all weights become 1", () => { + const store = makeStore({ + version: 1, + tabs: [ + { + id: "t1", + createdAt: Date.now(), + activePaneId: "p1", + layout: { + type: "split", + id: "s1", + direction: "horizontal", + children: [ + { type: "pane", paneId: "p1" }, + { type: "pane", paneId: "p2" }, + { type: "pane", paneId: "p3" }, + ], + weights: [10, 30, 60], + }, + panes: { + p1: { id: "p1", kind: "test", data: { label: "p1" } }, + p2: { id: "p2", kind: "test", data: { label: "p2" } }, + p3: { id: "p3", kind: "test", data: { label: "p3" } }, + }, + }, + ], + activeTabId: "t1", + }); + + store.getState().equalizeSplit({ tabId: "t1", splitId: "s1" }); + + const layout = store.getState().tabs[0]?.layout; + if (layout?.type === "split") { + expect(layout.weights).toEqual([1, 1, 1]); + } + }); +}); + +describe("collapsing", () => { + it("close pane in 2-pane split collapses to remaining leaf", () => { + const store = makeStore({ + version: 1, + tabs: [ + { + id: "t1", + createdAt: Date.now(), + activePaneId: "p1", + layout: { + type: "split", + id: "s1", + direction: "horizontal", + children: [ + { type: "pane", paneId: "p1" }, + { type: "pane", paneId: "p2" }, + ], + weights: [1, 1], + }, + panes: { + p1: { id: "p1", kind: "test", data: { label: "p1" } }, + p2: { id: "p2", kind: "test", data: { label: "p2" } }, + }, + }, + ], + activeTabId: "t1", + }); + + store.getState().closePane({ tabId: "t1", paneId: "p1" }); + + const tab = store.getState().tabs[0]!; + expect(tab.layout).toEqual({ type: "pane", paneId: "p2" }); + expect(tab.activePaneId).toBe("p2"); + expect(tab.panes["p1"]).toBeUndefined(); + }); + + it("close pane in 3-pane split removes child + weight", () => { + const store = makeStore({ + version: 1, + tabs: [ + { + id: "t1", + createdAt: Date.now(), + activePaneId: "p2", + layout: { + type: "split", + id: "s1", + direction: "horizontal", + children: [ + { type: "pane", paneId: "p1" }, + { type: "pane", paneId: "p2" }, + { type: "pane", paneId: "p3" }, + ], + weights: [3, 2, 1], + }, + panes: { + p1: { id: "p1", kind: "test", data: { label: "p1" } }, + p2: { id: "p2", kind: "test", data: { label: "p2" } }, + p3: { id: "p3", kind: "test", data: { label: "p3" } }, + }, + }, + ], + activeTabId: "t1", + }); + + store.getState().closePane({ tabId: "t1", paneId: "p2" }); + + const layout = store.getState().tabs[0]?.layout; + if (layout?.type === "split") { + expect(layout.children).toHaveLength(2); + expect(layout.weights).toEqual([3, 1]); + } + }); + + it("close last pane in tab removes the tab entirely", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + store.getState().closePane({ tabId: "t1", paneId: "p1" }); + + expect(store.getState().tabs).toHaveLength(0); + expect(store.getState().activeTabId).toBeNull(); + }); + + it("activePaneId falls back to sibling after close", () => { + const store = makeStore({ + version: 1, + tabs: [ + { + id: "t1", + createdAt: Date.now(), + activePaneId: "p1", + layout: { + type: "split", + id: "s1", + direction: "horizontal", + children: [ + { type: "pane", paneId: "p1" }, + { type: "pane", paneId: "p2" }, + ], + weights: [1, 1], + }, + panes: { + p1: { id: "p1", kind: "test", data: { label: "p1" } }, + p2: { id: "p2", kind: "test", data: { label: "p2" } }, + }, + }, + ], + activeTabId: "t1", + }); + + store.getState().closePane({ tabId: "t1", paneId: "p1" }); + expect(store.getState().tabs[0]?.activePaneId).toBe("p2"); + }); +}); + +describe("edge cases", () => { + it("invalid IDs are no-ops", () => { + const store = makeStore(); + store.getState().addTab({ id: "t1", panes: [tp("p1")] }); + + const before = structuredClone({ + version: store.getState().version, + tabs: store.getState().tabs, + activeTabId: store.getState().activeTabId, + }); + + store.getState().setActiveTab("missing"); + store.getState().setActivePane({ tabId: "t1", paneId: "missing" }); + store.getState().closePane({ tabId: "t1", paneId: "missing" }); + store.getState().resizeSplit({ + tabId: "t1", + splitId: "missing", + weights: [1], + }); + + expect({ + version: store.getState().version, + tabs: store.getState().tabs, + activeTabId: store.getState().activeTabId, + }).toEqual(before); + }); + + it("replaces state wholesale", () => { + const store = makeStore(); + + store + .getState() + .replaceState((prev: WorkspaceState) => ({ + ...prev, + activeTabId: "injected", + })); + + expect(store.getState().activeTabId).toBe("injected"); + }); +}); diff --git a/packages/panes/src/core/store/store.ts b/packages/panes/src/core/store/store.ts new file mode 100644 index 00000000000..7d0402a4c2b --- /dev/null +++ b/packages/panes/src/core/store/store.ts @@ -0,0 +1,587 @@ +import { createStore, type StoreApi } from "zustand/vanilla"; +import type { + LayoutNode, + Pane, + SplitPosition, + Tab, + WorkspaceState, +} from "../../types"; +import { + findFirstPaneId, + findPaneInLayout, + generateId, + removePaneFromLayout, + replacePaneIdInLayout, + splitPaneInLayout, + updateSplitInLayout, +} from "./utils"; + +function buildPane(args: CreatePaneInput): Pane { + return { + id: args.id ?? generateId("pane"), + kind: args.kind, + titleOverride: args.titleOverride, + pinned: args.pinned, + data: args.data, + }; +} + +function buildTab(args: { + id?: string; + titleOverride?: string; + panes: Pane[]; + activePaneId?: string; +}): Tab { + const panesMap: Record> = {}; + let layout: LayoutNode | null = null; + + if (args.panes.length === 1 && args.panes[0]) { + panesMap[args.panes[0].id] = args.panes[0]; + layout = { type: "pane", paneId: args.panes[0].id }; + } else if (args.panes.length > 1) { + const children: LayoutNode[] = []; + const weights: number[] = []; + for (const pane of args.panes) { + panesMap[pane.id] = pane; + children.push({ type: "pane", paneId: pane.id }); + weights.push(1); + } + layout = { + type: "split", + id: generateId("split"), + direction: "horizontal", + children, + weights, + }; + } + + return { + id: args.id ?? generateId("tab"), + titleOverride: args.titleOverride, + createdAt: Date.now(), + activePaneId: args.activePaneId ?? args.panes[0]?.id ?? null, + layout, + panes: panesMap, + }; +} + +// --- Public types --- + +export type CreatePaneInput = { + id?: string; + kind: string; + titleOverride?: string; + pinned?: boolean; + data: TData; +}; + +export type CreateTabInput = { + id?: string; + titleOverride?: string; + panes: CreatePaneInput[]; + activePaneId?: string; +}; + +export interface WorkspaceStore extends WorkspaceState { + addTab: (args: CreateTabInput) => void; + removeTab: (tabId: string) => void; + setActiveTab: (tabId: string) => void; + setTabTitleOverride: (args: { + tabId: string; + titleOverride?: string; + }) => void; + getTab: (tabId: string) => Tab | null; + getActiveTab: () => Tab | null; + + setActivePane: (args: { tabId: string; paneId: string }) => void; + getPane: ( + paneId: string, + ) => { tabId: string; pane: Pane } | null; + getActivePane: ( + tabId?: string, + ) => { tabId: string; pane: Pane } | null; + closePane: (args: { tabId: string; paneId: string }) => void; + setPaneData: (args: { paneId: string; data: TData }) => void; + setPaneTitleOverride: (args: { + tabId: string; + paneId: string; + titleOverride?: string; + }) => void; + setPanePinned: (args: { + tabId: string; + paneId: string; + pinned: boolean; + }) => void; + replacePane: (args: { + tabId: string; + paneId: string; + newPane: CreatePaneInput; + }) => void; + + splitPane: (args: { + tabId: string; + paneId: string; + position: SplitPosition; + newPane: CreatePaneInput; + weights?: number[]; + selectNewPane?: boolean; + }) => void; + addPane: (args: { + tabId: string; + pane: CreatePaneInput; + position?: SplitPosition; + relativeToPaneId?: string; + }) => void; + resizeSplit: (args: { + tabId: string; + splitId: string; + weights: number[]; + }) => void; + equalizeSplit: (args: { tabId: string; splitId: string }) => void; + + replaceState: ( + next: + | WorkspaceState + | ((prev: WorkspaceState) => WorkspaceState), + ) => void; +} + +export interface CreateWorkspaceStoreOptions { + initialState?: WorkspaceState; +} + +export function createWorkspaceStore( + options?: CreateWorkspaceStoreOptions, +): StoreApi> { + return createStore>((set, get) => ({ + version: 1, + tabs: options?.initialState?.tabs ?? [], + activeTabId: options?.initialState?.activeTabId ?? null, + + addTab: (args) => { + const tab = buildTab({ ...args, panes: args.panes.map(buildPane) }); + set((s) => ({ + tabs: [...s.tabs, tab], + activeTabId: s.activeTabId ?? tab.id, + })); + }, + + removeTab: (tabId) => { + set((s) => { + const nextTabs = s.tabs.filter((t) => t.id !== tabId); + return { + tabs: nextTabs, + activeTabId: + s.activeTabId === tabId + ? (nextTabs[0]?.id ?? null) + : s.activeTabId, + }; + }); + }, + + setActiveTab: (tabId) => { + set((s) => { + if (!s.tabs.some((t) => t.id === tabId)) return s; + return { activeTabId: tabId }; + }); + }, + + setTabTitleOverride: (args) => { + set((s) => ({ + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { ...t, titleOverride: args.titleOverride } + : t, + ), + })); + }, + + getTab: (tabId) => get().tabs.find((t) => t.id === tabId) ?? null, + + getActiveTab: () => { + const s = get(); + return s.tabs.find((t) => t.id === s.activeTabId) ?? null; + }, + + setActivePane: (args) => { + set((s) => { + const tab = s.tabs.find((t) => t.id === args.tabId); + if (!tab || !tab.panes[args.paneId]) return s; + + return { + activeTabId: args.tabId, + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { ...t, activePaneId: args.paneId } + : t, + ), + }; + }); + }, + + getPane: (paneId) => { + for (const tab of get().tabs) { + const pane = tab.panes[paneId]; + if (pane) return { tabId: tab.id, pane }; + } + return null; + }, + + getActivePane: (tabId) => { + const s = get(); + const tab = tabId + ? s.tabs.find((t) => t.id === tabId) + : s.tabs.find((t) => t.id === s.activeTabId); + if (!tab || !tab.activePaneId) return null; + + const pane = tab.panes[tab.activePaneId]; + if (!pane) return null; + + return { tabId: tab.id, pane }; + }, + + closePane: (args) => { + set((s) => { + const tab = s.tabs.find((t) => t.id === args.tabId); + if (!tab || !tab.panes[args.paneId] || !tab.layout) return s; + + const nextLayout = removePaneFromLayout(tab.layout, args.paneId); + const { [args.paneId]: _, ...nextPanes } = tab.panes; + + if (!nextLayout) { + const nextTabs = s.tabs.filter( + (t) => t.id !== args.tabId, + ); + return { + tabs: nextTabs, + activeTabId: + s.activeTabId === args.tabId + ? (nextTabs[0]?.id ?? null) + : s.activeTabId, + }; + } + + return { + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { + ...tab, + layout: nextLayout, + panes: nextPanes, + activePaneId: + tab.activePaneId === args.paneId + ? findFirstPaneId(nextLayout) + : tab.activePaneId, + } + : t, + ), + }; + }); + }, + + setPaneData: (args) => { + set((s) => { + const location = get().getPane(args.paneId); + if (!location) return s; + + return { + tabs: s.tabs.map((t) => + t.id === location.tabId + ? { + ...t, + panes: { + ...t.panes, + [args.paneId]: { + ...location.pane, + data: args.data, + }, + }, + } + : t, + ), + }; + }); + }, + + setPaneTitleOverride: (args) => { + set((s) => { + const tab = s.tabs.find((t) => t.id === args.tabId); + const pane = tab?.panes[args.paneId]; + if (!tab || !pane) return s; + + return { + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { + ...t, + panes: { + ...t.panes, + [args.paneId]: { + ...pane, + titleOverride: args.titleOverride, + }, + }, + } + : t, + ), + }; + }); + }, + + setPanePinned: (args) => { + set((s) => { + const tab = s.tabs.find((t) => t.id === args.tabId); + const pane = tab?.panes[args.paneId]; + if (!tab || !pane) return s; + + return { + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { + ...t, + panes: { + ...t.panes, + [args.paneId]: { + ...pane, + pinned: args.pinned, + }, + }, + } + : t, + ), + }; + }); + }, + + replacePane: (args) => { + set((s) => { + const tab = s.tabs.find((t) => t.id === args.tabId); + const pane = tab?.panes[args.paneId]; + if (!tab || !pane || !tab.layout) return s; + if (pane.pinned) return s; + + const newPane = buildPane(args.newPane); + const { [args.paneId]: _, ...restPanes } = tab.panes; + + return { + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { + ...tab, + layout: replacePaneIdInLayout( + tab.layout!, + args.paneId, + newPane.id, + ), + panes: { ...restPanes, [newPane.id]: newPane }, + activePaneId: + tab.activePaneId === args.paneId + ? newPane.id + : tab.activePaneId, + } + : t, + ), + }; + }); + }, + + splitPane: (args) => { + set((s) => { + const tab = s.tabs.find((t) => t.id === args.tabId); + if (!tab || !tab.layout) return s; + if ( + !tab.panes[args.paneId] || + !findPaneInLayout(tab.layout, args.paneId) + ) + return s; + + const newPane = buildPane(args.newPane); + + return { + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { + ...tab, + layout: splitPaneInLayout( + tab.layout!, + args.paneId, + newPane.id, + args.position, + args.weights, + ), + panes: { + ...tab.panes, + [newPane.id]: newPane, + }, + activePaneId: + args.selectNewPane === false + ? tab.activePaneId + : newPane.id, + } + : t, + ), + }; + }); + }, + + addPane: (args) => { + set((s) => { + const tab = s.tabs.find((t) => t.id === args.tabId); + if (!tab) return s; + + const newPane = buildPane(args.pane); + + if (!tab.layout) { + return { + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { + ...tab, + layout: { + type: "pane", + paneId: newPane.id, + } satisfies LayoutNode, + panes: { + ...tab.panes, + [newPane.id]: newPane, + }, + activePaneId: newPane.id, + } + : t, + ), + }; + } + + const position = args.position ?? "right"; + const targetPaneId = + args.relativeToPaneId ?? tab.activePaneId; + + if ( + targetPaneId && + findPaneInLayout(tab.layout, targetPaneId) + ) { + return { + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { + ...tab, + layout: splitPaneInLayout( + tab.layout!, + targetPaneId, + newPane.id, + position, + ), + panes: { + ...tab.panes, + [newPane.id]: newPane, + }, + activePaneId: newPane.id, + } + : t, + ), + }; + } + + const newPaneLeaf: LayoutNode = { + type: "pane", + paneId: newPane.id, + }; + const edgeLayout: LayoutNode = { + type: "split", + id: generateId("split"), + direction: + position === "left" || position === "right" + ? "horizontal" + : "vertical", + children: + position === "left" || position === "top" + ? [newPaneLeaf, tab.layout!] + : [tab.layout!, newPaneLeaf], + weights: [1, 1], + }; + + return { + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { + ...tab, + layout: edgeLayout, + panes: { + ...tab.panes, + [newPane.id]: newPane, + }, + activePaneId: newPane.id, + } + : t, + ), + }; + }); + }, + + resizeSplit: (args) => { + set((s) => { + const tab = s.tabs.find((t) => t.id === args.tabId); + if (!tab || !tab.layout) return s; + + return { + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { + ...t, + layout: updateSplitInLayout( + tab.layout!, + args.splitId, + (split) => ({ + ...split, + weights: args.weights, + }), + ), + } + : t, + ), + }; + }); + }, + + equalizeSplit: (args) => { + set((s) => { + const tab = s.tabs.find((t) => t.id === args.tabId); + if (!tab || !tab.layout) return s; + + return { + tabs: s.tabs.map((t) => + t.id === args.tabId + ? { + ...t, + layout: updateSplitInLayout( + tab.layout!, + args.splitId, + (split) => ({ + ...split, + weights: split.children.map( + () => 1, + ), + }), + ), + } + : t, + ), + }; + }); + }, + + replaceState: (next) => { + set((s) => { + const resolved = + typeof next === "function" + ? next({ version: s.version, tabs: s.tabs, activeTabId: s.activeTabId }) + : next; + return { + version: resolved.version, + tabs: resolved.tabs, + activeTabId: resolved.activeTabId, + }; + }); + }, + })); +} diff --git a/packages/panes/src/core/store/utils/index.ts b/packages/panes/src/core/store/utils/index.ts new file mode 100644 index 00000000000..e4f85fd557f --- /dev/null +++ b/packages/panes/src/core/store/utils/index.ts @@ -0,0 +1,9 @@ +export { + findFirstPaneId, + findPaneInLayout, + generateId, + removePaneFromLayout, + replacePaneIdInLayout, + splitPaneInLayout, + updateSplitInLayout, +} from "./utils"; diff --git a/packages/panes/src/core/store/utils/utils.test.ts b/packages/panes/src/core/store/utils/utils.test.ts new file mode 100644 index 00000000000..7803559dde2 --- /dev/null +++ b/packages/panes/src/core/store/utils/utils.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "bun:test"; +import type { LayoutNode } from "../../../types"; +import { + findFirstPaneId, + findPaneInLayout, + positionToDirection, + removePaneFromLayout, + replacePaneIdInLayout, + splitPaneInLayout, + updateSplitInLayout, +} from "./utils"; + +const SINGLE: LayoutNode = { type: "pane", paneId: "a" }; + +const TWO_SPLIT: LayoutNode = { + type: "split", + id: "s1", + direction: "horizontal", + children: [ + { type: "pane", paneId: "a" }, + { type: "pane", paneId: "b" }, + ], + weights: [1, 1], +}; + +const THREE_SPLIT: LayoutNode = { + type: "split", + id: "s1", + direction: "horizontal", + children: [ + { type: "pane", paneId: "a" }, + { type: "pane", paneId: "b" }, + { type: "pane", paneId: "c" }, + ], + weights: [3, 2, 1], +}; + +const NESTED: LayoutNode = { + type: "split", + id: "s1", + direction: "horizontal", + children: [ + { type: "pane", paneId: "a" }, + { + type: "split", + id: "s2", + direction: "vertical", + children: [ + { type: "pane", paneId: "b" }, + { type: "pane", paneId: "c" }, + ], + weights: [1, 1], + }, + ], + weights: [1, 1], +}; + +describe("findPaneInLayout", () => { + it("finds a pane in a single leaf", () => { + expect(findPaneInLayout(SINGLE, "a")).toBe(true); + expect(findPaneInLayout(SINGLE, "z")).toBe(false); + }); + + it("finds panes in a split", () => { + expect(findPaneInLayout(TWO_SPLIT, "a")).toBe(true); + expect(findPaneInLayout(TWO_SPLIT, "b")).toBe(true); + expect(findPaneInLayout(TWO_SPLIT, "z")).toBe(false); + }); + + it("finds panes in nested splits", () => { + expect(findPaneInLayout(NESTED, "c")).toBe(true); + expect(findPaneInLayout(NESTED, "z")).toBe(false); + }); +}); + +describe("findFirstPaneId", () => { + it("returns the pane id for a leaf", () => { + expect(findFirstPaneId(SINGLE)).toBe("a"); + }); + + it("returns the first (depth-first) pane in a split", () => { + expect(findFirstPaneId(TWO_SPLIT)).toBe("a"); + }); + + it("returns the first pane in nested splits", () => { + expect(findFirstPaneId(NESTED)).toBe("a"); + }); +}); + +describe("removePaneFromLayout", () => { + it("returns null when removing the only pane", () => { + expect(removePaneFromLayout(SINGLE, "a")).toBeNull(); + }); + + it("returns the remaining pane when removing from a 2-pane split", () => { + const result = removePaneFromLayout(TWO_SPLIT, "a"); + expect(result).toEqual({ type: "pane", paneId: "b" }); + }); + + it("preserves weights when removing from a 3-pane split", () => { + const result = removePaneFromLayout(THREE_SPLIT, "b"); + expect(result).toMatchObject({ + type: "split", + weights: [3, 1], + children: [ + { type: "pane", paneId: "a" }, + { type: "pane", paneId: "c" }, + ], + }); + }); + + it("collapses nested split when child is removed", () => { + const result = removePaneFromLayout(NESTED, "b"); + // s2 had [b, c], removing b leaves just c — s2 collapses + // s1 now has [a, c] + expect(result).toMatchObject({ + type: "split", + id: "s1", + children: [ + { type: "pane", paneId: "a" }, + { type: "pane", paneId: "c" }, + ], + }); + }); + + it("returns unchanged layout when pane not found", () => { + expect(removePaneFromLayout(TWO_SPLIT, "z")).toEqual(TWO_SPLIT); + }); +}); + +describe("replacePaneIdInLayout", () => { + it("replaces a pane id in a leaf", () => { + expect(replacePaneIdInLayout(SINGLE, "a", "x")).toEqual({ + type: "pane", + paneId: "x", + }); + }); + + it("replaces a pane id inside a split", () => { + const result = replacePaneIdInLayout(TWO_SPLIT, "b", "x"); + if (result.type === "split") { + expect(result.children[1]).toEqual({ type: "pane", paneId: "x" }); + } + }); + + it("replaces in nested splits", () => { + const result = replacePaneIdInLayout(NESTED, "c", "x"); + if (result.type === "split" && result.children[1]?.type === "split") { + expect(result.children[1].children[1]).toEqual({ + type: "pane", + paneId: "x", + }); + } + }); + + it("returns unchanged layout when pane not found", () => { + expect(replacePaneIdInLayout(SINGLE, "z", "x")).toEqual(SINGLE); + }); +}); + +describe("splitPaneInLayout", () => { + it("wraps a leaf in a new split", () => { + const result = splitPaneInLayout(SINGLE, "a", "b", "right"); + expect(result.type).toBe("split"); + if (result.type === "split") { + expect(result.direction).toBe("horizontal"); + expect(result.weights).toEqual([1, 1]); + expect(result.children[0]).toEqual({ type: "pane", paneId: "a" }); + expect(result.children[1]).toEqual({ type: "pane", paneId: "b" }); + } + }); + + it("left/top puts new pane first", () => { + const result = splitPaneInLayout(SINGLE, "a", "b", "left"); + if (result.type === "split") { + expect(result.children[0]).toEqual({ type: "pane", paneId: "b" }); + expect(result.children[1]).toEqual({ type: "pane", paneId: "a" }); + } + }); + + it("top/bottom uses vertical direction", () => { + const result = splitPaneInLayout(SINGLE, "a", "b", "top"); + if (result.type === "split") { + expect(result.direction).toBe("vertical"); + } + }); + + it("inserts into existing same-direction split and halves weight", () => { + const result = splitPaneInLayout(THREE_SPLIT, "b", "d", "right"); + if (result.type === "split") { + expect(result.children).toHaveLength(4); + expect(result.weights).toEqual([3, 1, 1, 1]); + expect(result.children[1]).toEqual({ type: "pane", paneId: "b" }); + expect(result.children[2]).toEqual({ type: "pane", paneId: "d" }); + } + }); + + it("inserts left into existing same-direction split", () => { + const result = splitPaneInLayout(THREE_SPLIT, "b", "d", "left"); + if (result.type === "split") { + expect(result.children).toHaveLength(4); + expect(result.children[1]).toEqual({ type: "pane", paneId: "d" }); + expect(result.children[2]).toEqual({ type: "pane", paneId: "b" }); + } + }); + + it("creates nested split for cross-direction split", () => { + const result = splitPaneInLayout(TWO_SPLIT, "b", "c", "bottom"); + if (result.type === "split") { + expect(result.children).toHaveLength(2); + expect(result.children[0]).toEqual({ type: "pane", paneId: "a" }); + const nested = result.children[1]; + expect(nested?.type).toBe("split"); + if (nested?.type === "split") { + expect(nested.direction).toBe("vertical"); + expect(nested.children[0]).toEqual({ type: "pane", paneId: "b" }); + expect(nested.children[1]).toEqual({ type: "pane", paneId: "c" }); + } + } + }); + + it("uses custom weights", () => { + const result = splitPaneInLayout(SINGLE, "a", "b", "right", [3, 1]); + if (result.type === "split") { + expect(result.weights).toEqual([3, 1]); + } + }); +}); + +describe("updateSplitInLayout", () => { + it("updates a split by id", () => { + const result = updateSplitInLayout(TWO_SPLIT, "s1", (split) => ({ + ...split, + weights: [3, 7], + })); + if (result.type === "split") { + expect(result.weights).toEqual([3, 7]); + } + }); + + it("updates a nested split", () => { + const result = updateSplitInLayout(NESTED, "s2", (split) => ({ + ...split, + weights: [3, 1], + })); + if (result.type === "split" && result.children[1]?.type === "split") { + expect(result.children[1].weights).toEqual([3, 1]); + } + }); + + it("returns unchanged layout for missing id", () => { + expect(updateSplitInLayout(TWO_SPLIT, "missing", (s) => s)).toEqual( + TWO_SPLIT, + ); + }); +}); + +describe("positionToDirection", () => { + it("maps left/right to horizontal", () => { + expect(positionToDirection("left")).toBe("horizontal"); + expect(positionToDirection("right")).toBe("horizontal"); + }); + + it("maps top/bottom to vertical", () => { + expect(positionToDirection("top")).toBe("vertical"); + expect(positionToDirection("bottom")).toBe("vertical"); + }); +}); diff --git a/packages/panes/src/core/store/utils/utils.ts b/packages/panes/src/core/store/utils/utils.ts new file mode 100644 index 00000000000..ab7a19449ff --- /dev/null +++ b/packages/panes/src/core/store/utils/utils.ts @@ -0,0 +1,177 @@ +import type { LayoutNode, SplitDirection, SplitPosition } from "../../../types"; + +export function findPaneInLayout( + node: LayoutNode, + paneId: string, +): boolean { + if (node.type === "pane") { + return node.paneId === paneId; + } + return node.children.some((child) => findPaneInLayout(child, paneId)); +} + +export function findFirstPaneId(node: LayoutNode): string | null { + if (node.type === "pane") { + return node.paneId; + } + for (const child of node.children) { + const id = findFirstPaneId(child); + if (id) return id; + } + return null; +} + +export function removePaneFromLayout( + node: LayoutNode, + paneId: string, +): LayoutNode | null { + if (node.type === "pane") { + return node.paneId === paneId ? null : node; + } + + const nextChildren: LayoutNode[] = []; + const nextWeights: number[] = []; + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + if (!child) continue; + + const result = removePaneFromLayout(child, paneId); + if (result) { + nextChildren.push(result); + nextWeights.push(node.weights[i] ?? 1); + } + } + + if (nextChildren.length === 0) { + return null; + } + + if (nextChildren.length === 1) { + return nextChildren[0] ?? null; + } + + return { + ...node, + children: nextChildren, + weights: nextWeights, + }; +} + +export function replacePaneIdInLayout( + node: LayoutNode, + oldPaneId: string, + newPaneId: string, +): LayoutNode { + if (node.type === "pane") { + return node.paneId === oldPaneId + ? { type: "pane", paneId: newPaneId } + : node; + } + + return { + ...node, + children: node.children.map((child) => + replacePaneIdInLayout(child, oldPaneId, newPaneId), + ), + }; +} + +export function splitPaneInLayout( + node: LayoutNode, + targetPaneId: string, + newPaneId: string, + position: SplitPosition, + weights?: number[], +): LayoutNode { + if (node.type === "pane") { + if (node.paneId !== targetPaneId) return node; + + const direction = positionToDirection(position); + const newPaneNode: LayoutNode = { type: "pane", paneId: newPaneId }; + const isFirst = position === "left" || position === "top"; + + return { + type: "split", + id: generateId("split"), + direction, + children: isFirst ? [newPaneNode, node] : [node, newPaneNode], + weights: weights ?? [1, 1], + }; + } + + const parentInfo = findDirectChild(node, targetPaneId); + + if (parentInfo && node.direction === positionToDirection(position)) { + const { childIndex } = parentInfo; + const currentWeight = node.weights[childIndex] ?? 1; + const halfWeight = currentWeight / 2; + const newPaneNode: LayoutNode = { type: "pane", paneId: newPaneId }; + const isFirst = position === "left" || position === "top"; + + const nextChildren = [...node.children]; + const nextWeights = [...node.weights]; + + nextWeights[childIndex] = halfWeight; + + if (isFirst) { + nextChildren.splice(childIndex, 0, newPaneNode); + nextWeights.splice(childIndex, 0, halfWeight); + } else { + nextChildren.splice(childIndex + 1, 0, newPaneNode); + nextWeights.splice(childIndex + 1, 0, halfWeight); + } + + return { + ...node, + children: nextChildren, + weights: nextWeights, + }; + } + + return { + ...node, + children: node.children.map((child) => + splitPaneInLayout(child, targetPaneId, newPaneId, position, weights), + ), + }; +} + +function findDirectChild( + split: LayoutNode & { type: "split" }, + paneId: string, +): { childIndex: number } | null { + for (let i = 0; i < split.children.length; i++) { + const child = split.children[i]; + if (child?.type === "pane" && child.paneId === paneId) { + return { childIndex: i }; + } + } + return null; +} + +export function updateSplitInLayout( + node: LayoutNode, + splitId: string, + updater: (split: LayoutNode & { type: "split" }) => LayoutNode, +): LayoutNode { + if (node.type === "pane") return node; + if (node.id === splitId) return updater(node); + + return { + ...node, + children: node.children.map((child) => + updateSplitInLayout(child, splitId, updater), + ), + }; +} + +export function positionToDirection(position: SplitPosition): SplitDirection { + return position === "left" || position === "right" + ? "horizontal" + : "vertical"; +} + +export function generateId(prefix: string): string { + return `${prefix}-${crypto.randomUUID()}`; +} diff --git a/packages/panes/src/index.ts b/packages/panes/src/index.ts new file mode 100644 index 00000000000..6de1f583390 --- /dev/null +++ b/packages/panes/src/index.ts @@ -0,0 +1,15 @@ +export { createWorkspaceStore } from "./core/store"; +export type { + CreatePaneInput, + CreateTabInput, + CreateWorkspaceStoreOptions, + WorkspaceStore, +} from "./core/store"; +export type { + LayoutNode, + Pane, + SplitDirection, + SplitPosition, + Tab, + WorkspaceState, +} from "./types"; diff --git a/packages/panes/src/types.ts b/packages/panes/src/types.ts new file mode 100644 index 00000000000..3f2736b2565 --- /dev/null +++ b/packages/panes/src/types.ts @@ -0,0 +1,36 @@ +export type SplitDirection = "horizontal" | "vertical"; + +export type SplitPosition = "top" | "right" | "bottom" | "left"; + +export type LayoutNode = + | { type: "pane"; paneId: string } + | { + type: "split"; + id: string; + direction: SplitDirection; + children: LayoutNode[]; + weights: number[]; + }; + +export interface Pane { + id: string; + kind: string; + titleOverride?: string; + pinned?: boolean; + data: TData; +} + +export interface Tab { + id: string; + titleOverride?: string; + createdAt: number; + activePaneId: string | null; + layout: LayoutNode | null; + panes: Record>; +} + +export interface WorkspaceState { + version: 1; + tabs: Tab[]; + activeTabId: string | null; +} diff --git a/packages/pane-layout/tsconfig.json b/packages/panes/tsconfig.json similarity index 100% rename from packages/pane-layout/tsconfig.json rename to packages/panes/tsconfig.json diff --git a/plans/panes-v2-data-model-redesign.md b/plans/panes-v2-data-model-redesign.md new file mode 100644 index 00000000000..e9f92e1034f --- /dev/null +++ b/plans/panes-v2-data-model-redesign.md @@ -0,0 +1,961 @@ +# Pane Layout: New Tab + Split + Pane Model + +## Context + +The current v2 pane model has a 3-tier layout tree: **Root → Split/Group → Pane**. `PaneGroupNode` is a leaf in the split tree that contains a *tab strip* of multiple panes — essentially tabs-within-tabs. The goal is to flatten this to **Tab → Split → Pane**. Each leaf in the layout tree becomes a single pane, no inner tab strip. + +No backwards compatibility or migration needed — this isn't live yet. + +--- + +## Schema + +### Layout Tree (purely structural — no pane data) + +```ts +type SplitDirection = "horizontal" | "vertical"; + +type LayoutNode = + | { type: "pane"; paneId: string } + | { + type: "split"; + id: string; + direction: SplitDirection; + children: LayoutNode[]; // n-ary (not binary) + weights: number[]; // relative weights, one per child (e.g. [1, 2, 1]) + }; +``` + +**N-ary splits** — matches FlexLayout and VS Code's approach. + +**Relative weights** instead of percentages (FlexLayout's approach). Weights don't need to sum to any specific value — they're proportional. `[1, 1, 1]` = equal thirds, `[3, 2]` = 60/40. Sidesteps the "33.33 + 33.33 + 33.34" rounding problem entirely. + +- **Rendering**: CSS `flex-grow` takes weights directly — `flexGrow: weight` on each child. +- **Resize drag**: UI snapshots pixel sizes from the DOM on mousedown, does pixel math during drag, then converts back to weights via `newPixelSize / totalPixels * sumOfWeights`. Only the two panes adjacent to the dragged splitter are affected. +- **Equalize**: just set all weights to `1`. Done. + +The layout tree only holds `paneId` strings — pane data lives separately in a flat map. + +### Pane Data (generic) + +```ts +interface Pane { + id: string; + kind: string; + titleOverride?: string; // optional override; titles derived via registry's getTitle() + pinned?: boolean; // unpinned panes can be replaced in-place (e.g. file preview) + data: TData; // pane-specific state lives here (including status indicators, URLs, etc.) +} +``` + +- **`@superset/panes` stays generic** — `TData` parameterized by consumers +- **Titles are derived** by the registry's `getTitle(context)`, with optional `titleOverride` for user renames +- **`pinned`** — controls preview/replace behavior. Unpinned panes (e.g. file preview on single-click) can be replaced in-place without splitting. Double-click or edit pins the pane so it persists. + +### Open-a-pane behavior (e.g. quick-open, open file from sidebar) + +This is the most common user flow — currently `addPaneToGroup({ replaceUnpinned: true })`. In the new model: + +``` +1. Check if the file is already open in the tab → focus it (setActivePane) +2. Find ANY unpinned file pane in the tab (scan tab.panes for kind === "file" && !pinned): + → replacePane(tabId, paneId, newPane) — swap entire Pane, update layout tree paneId ref + (VS Code behavior: preview pane is a tab-wide singleton, not tied to focus) +3. No unpinned file pane, but active pane exists: + → splitPane(tabId, activePaneId, "right", newPane) — split the active pane to the right +4. No active pane / no tab: + → addTab with the new pane +``` + +Default split direction is **right** (horizontal). This matches VS Code's behavior and the v1 `splitPaneAuto` which picked vertical/horizontal based on dimensions — we can refine later, but right is the sane default. + +The consumer (apps/desktop `PaneViewer.tsx`) owns this logic, not the pane-layout package. The package provides the primitives (`replacePane`, `splitPane`, `addTab`), the app composes them. + +### Tab + +```ts +interface Tab { + id: string; + titleOverride?: string; + createdAt: number; + activePaneId: string | null; + layout: LayoutNode | null; // null = empty tab + panes: Record>; // flat map, O(1) lookup +} +``` + +- **`panes` as flat map** — layout tree is purely structural (`paneId` refs), pane data lives here. Clean separation. +- **`activePaneId`** — single level of focus tracking (replaces the old two-hop `activeGroupId` → group's `activePaneId` chain) +- **`layout: null`** — not a normal state. Tabs always have at least one pane: creation makes one, closing the last pane closes the tab. Null is only for transient/initial states. + +### Workspace (top-level) + +```ts +interface WorkspaceState { + version: 1; + tabs: Tab[]; + activeTabId: string | null; +} +``` + +### Drop Targets + +```ts +type SplitPosition = "top" | "right" | "bottom" | "left"; + +type DropTarget = { + type: "split"; + tabId: string; + paneId: string; + position: SplitPosition; +}; +``` + +Dragging always creates a split. No "add as tab within group" drop zone (groups don't exist). + +--- + +## Concrete Example + +```json +{ + "version": 1, + "tabs": [ + { + "id": "tab_1", + "titleOverride": "Chat", + "createdAt": 1743300000000, + "activePaneId": "pane_chat", + "layout": { + "type": "split", + "id": "split_1", + "direction": "horizontal", + "children": [ + { "type": "pane", "paneId": "pane_chat" }, + { "type": "pane", "paneId": "pane_term" } + ], + "weights": [3, 2] + }, + "panes": { + "pane_chat": { + "id": "pane_chat", + "kind": "chat", + "data": { "sessionId": null } + }, + "pane_term": { + "id": "pane_term", + "kind": "terminal", + "data": { + "sessionKey": "workspace-123:abc", + "cwd": "/workspace/my-repo", + "launchMode": "workspace-shell" + } + } + } + } + ], + "activeTabId": "tab_1" +} +``` + +--- + +## Store Interface + +### Tab actions + +| Action | Signature | Notes | +|---|---|---| +| `addTab` | `(tab: Tab)` | Adds a new tab | +| `removeTab` | `(tabId: string)` | Removes tab, activates neighbor | +| `setActiveTab` | `(tabId: string)` | Switches active tab | +| `setTabTitleOverride` | `(tabId, titleOverride?)` | | +| `getTab` | `(tabId) → Tab \| null` | | +| `getActiveTab` | `() → Tab \| null` | | + +### Pane actions + +| Action | Signature | Notes | +|---|---|---| +| `setActivePane` | `(tabId, paneId)` | Sets focused pane in tab | +| `getPane` | `(paneId) → { tabId, pane } \| null` | Searches across all tabs | +| `getActivePane` | `(tabId?) → { tabId, pane } \| null` | | +| `closePane` | `(tabId, paneId)` | Removes from layout + panes map, collapses empty splits | +| `setPaneData` | `(paneId, data)` | Updates pane data in flat map | +| `setPaneTitleOverride` | `(tabId, paneId, titleOverride?)` | | + +### Split actions + +| Action | Signature | Notes | +|---|---|---| +| `splitPane` | `(tabId, paneId, position, newPane, weights?)` | Wraps target pane in a split with the new pane | +| `addPane` | `(tabId, pane, position?, relativeToPaneId?)` | Adds pane by splitting; appends to edge if no target | +| `resizeSplit` | `(tabId, splitId, weights)` | Updates weights array (UI converts pixels → weights) | +| `equalizeSplit` | `(tabId, splitId)` | Sets all weights to `1` | + +### Pane pin actions + +| Action | Signature | Notes | +|---|---|---| +| `setPanePinned` | `(tabId, paneId, pinned)` | Pin/unpin a pane | +| `replacePane` | `(tabId, paneId, newPane: Pane)` | Replace an unpinned pane with a full new Pane. Removes old entry from `panes` map, adds new entry, updates the layout tree leaf's `paneId` to `newPane.id`. No structural layout change (no splits created/removed). No-op if target pane is pinned. | + +### Bulk + +| Action | Signature | +|---|---| +| `replaceState` | `(next \| (prev) => next)` | + +--- + +## Splitting Behavior + +When splitting a pane, the new pane steals space from the target — everything else stays put. + +**Split into a new direction** (target pane is a leaf or in a split with a different direction): +``` +Before: { type: "pane", paneId: "A" } +Split A right with new pane B: +After: { type: "split", direction: "horizontal", children: [A, B], weights: [1, 1] } +``` + +**Split within an existing same-direction split** (e.g., drop right on a pane already in a horizontal split): +``` +Before: weights [3, 2, 1], split pane[1] (weight 2) +After: weights [3, 1, 1, 1] — pane[1]'s weight halved, new pane inserted adjacent +``` + +The rule: `targetWeight / 2` for each of the two panes. Other siblings are untouched. + +**Position → Direction mapping:** left/right → `"horizontal"`, top/bottom → `"vertical"`. Position also determines child order: left/top → new pane first, right/bottom → new pane second. + +--- + +## Collapsing / Normalization + +When a pane is closed: +1. `context.actions.close()` calls the registered `onBeforeClose` handler (if any) — if it returns false, stop (e.g. "Save changes?" modal) +2. Remove the `{ type: "pane", paneId }` leaf from the parent split's `children` and `weights` arrays +3. Remove corresponding entry from `tab.panes` +4. If the parent split has 1 child left → replace the split with that child (collapse). Recurse up. +5. If `activePaneId` was the closed pane → fall back to first pane in tree (depth-first) +6. If that was the last pane in the tab → remove the tab entirely (tabs always have at least one pane) + +--- + +## React Components + +### Component Tree + +``` +Workspace manages tabs, renders TabBar + active Tab +├── TabBar horizontal tab strip with overflow +│ └── TabItem × N single tab: click, middle-click close, drag reorder +│ ├── TabRenameInput inline input on double-click +│ └── TabContextMenu Rename / Close / Close Others / Close All +└── Tab resolves tab's layout, provides tab context, owns recursive renderer + └── (recursive layout renderer, inline in Tab.tsx) + ├── [if pane] Pane data boundary: resolves pane, wires handlers + │ ├── PaneHeader toolbar content + close + active state + context menu + │ │ ├── PaneRenameInput inline input on double-click title + │ │ └── PaneContextMenu Close / Split Right / Split Down + registered items + │ └── PaneContent calls registry renderPane + └── [if split] flex container + SplitHandle → recurse +``` + +**Responsibility boundaries:** +- `Workspace` — tab-level state (active tab, add/remove tabs). Renders empty state when no tabs exist. Doesn't know about panes. +- `Tab` — resolves one tab's layout tree, provides tab context to children. Owns the recursive layout renderer. Tabs always have at least one pane (closing last pane closes the tab). +- `Pane` — data/handler boundary. Resolves pane from flat map, builds `RendererContext`, wires handlers (close, focus, rename, pin, split). Children are presentational. +- `PaneHeader` / `PaneContent` — presentational. Receive resolved data + callbacks as props, don't touch the store. + +### File Structure (co-located per AGENTS.md) + +``` +packages/pane-layout/src/react/components/ +└── Workspace/ + ├── Workspace.tsx + ├── index.ts + └── components/ + ├── TabBar/ + │ ├── TabBar.tsx + │ ├── index.ts + │ └── components/ + │ └── TabItem/ + │ ├── TabItem.tsx + │ ├── index.ts + │ └── components/ + │ ├── TabRenameInput/ + │ │ ├── TabRenameInput.tsx + │ │ └── index.ts + │ └── TabContextMenu/ + │ ├── TabContextMenu.tsx + │ └── index.ts + ├── Tab/ + │ ├── Tab.tsx (resolves layout, recursive renderer) + │ ├── index.ts + │ └── components/ + │ ├── Pane/ + │ │ ├── Pane.tsx (data boundary: resolves pane, wires handlers) + │ │ ├── index.ts + │ │ └── components/ + │ │ ├── PaneHeader/ + │ │ │ ├── PaneHeader.tsx + │ │ │ ├── index.ts + │ │ │ └── components/ + │ │ │ ├── PaneRenameInput/ + │ │ │ │ ├── PaneRenameInput.tsx + │ │ │ │ └── index.ts + │ │ │ └── PaneContextMenu/ + │ │ │ ├── PaneContextMenu.tsx + │ │ │ └── index.ts + │ │ └── PaneContent/ + │ │ ├── PaneContent.tsx + │ │ └── index.ts + │ └── SplitHandle/ + │ ├── SplitHandle.tsx + │ └── index.ts +``` + +### Types + +```ts +type ContextMenuItem = + | { + type?: "item"; + label: string; + icon?: ReactNode; + variant?: "default" | "destructive"; + onSelect: () => void; + shortcut?: string; // display-only hint (e.g. "⌘K") — actual keybinding is owned by the pane + disabled?: boolean; + } + | { type: "separator" } + | { + type: "submenu"; + label: string; + icon?: ReactNode; + items: ContextMenuItem[]; // nested items (e.g. "Move to Tab ›") + }; + +interface RendererContext { + pane: Pane; + tab: Tab; + isActive: boolean; + store: StoreApi>; // escape hatch for advanced cases + + actions: { + close: () => void; // checks onBeforeClose guard first, then calls store.closePane + focus: () => void; + setTitle: (title: string) => void; + pin: () => void; + updateData: (data: TData) => void; + splitRight: (newPane: Pane) => void; + splitDown: (newPane: Pane) => void; + }; +} + +interface PaneDefinition { + renderPane(context: RendererContext): ReactNode; + renderToolbar?(context: RendererContext): ReactNode; + getTitle?(context: RendererContext): ReactNode; + getIcon?(context: RendererContext): ReactNode; +} + +type PaneRegistry = Record>; + +// Workspace-level props (passed to ) +interface WorkspaceProps { + store: StoreApi>; + registry: PaneRegistry; + renderTabAccessory?: (tab: Tab) => ReactNode; // custom tab UI (status dot, badge, etc.) + renderEmptyState?: () => ReactNode; // shown when no tabs exist + renderAddTabMenu?: () => ReactNode; // dropdown content for "+" button in tab bar + // ...other callbacks as needed +} +``` + +**Notes:** +- `renderToolbar` — full eject. If provided, replaces the entire PaneHeader content (icon, title, actions — everything). For panes that need a completely custom header (e.g. browser with nav buttons + URL bar). Most panes don't need this — the default header uses `getIcon()` + `getTitle()` + split/close buttons. +- `context.actions` — pre-wired imperative actions. `close()` checks the close guard (if registered via `useOnBeforeClose`) first, then calls the store's raw `closePane`. +- `store` on context is an escape hatch — pane implementations should use `context.actions.*` for normal operations. +- The `Pane` component (data boundary) builds the full `RendererContext` so pane implementations never need to know about `tabId` or call store methods directly. + +**Pane hooks** (react-dnd style — spec + deps, framework handles registration/cleanup): + +```ts +// Close guard — return false to cancel close (e.g. show "Save changes?" modal) +useOnBeforeClose(context, async () => { + if (!isDirty) return true; + return await showSaveDialog(); +}, [isDirty]); + +// Context menu items — registered from inside the render tree (access to refs) +useContextMenuActions(context, [ + { label: "Refresh", onSelect: () => webviewRef.current?.reload() }, +], []); +``` + +Both hooks store the handler/items via a ref on the `Pane` component (through context). Cleanup on unmount is automatic. `PaneContextMenu` renders default items (Close, Split Right, Split Down) + items from `useContextMenuActions`, after a separator. + +### Visual Reference (v1 components to match) + +These v1 files are the styling targets — the new components should match their look 1:1: + +| New Component | Reference File | What to match | +|---|---|---| +| `PaneHeader` | `apps/desktop/.../TabView/mosaic-theme.css` | `.mosaic-window-toolbar` (28px height, `var(--color-tertiary)` bg, focused = `var(--color-secondary)`) | +| `PaneHeader` (layout) | `apps/desktop/.../TabView/components/BasePaneWindow/BasePaneWindow.tsx` | Toolbar wrapper pattern, focus/split/close handler wiring | +| `PaneHeader` (title) | `apps/desktop/.../TabView/components/PaneTitle/PaneTitle.tsx` | Editable title, `text-sm text-muted-foreground`, double-click to rename | +| `PaneHeader` (actions) | `apps/desktop/.../TabView/components/PaneToolbarActions/PaneToolbarActions.tsx` | Split + close buttons, `rounded p-0.5 text-muted-foreground/60` | +| `PaneRenameInput` | `apps/desktop/.../WorkspaceSidebar/RenameInput/RenameInput.tsx` | Shared inline rename input (Enter/Escape/blur, auto-focus + select) | +| `TabBar` | `apps/desktop/.../TabsContent/GroupStrip/GroupStrip.tsx` | `h-10` tab strip, scroll overflow, fixed `160px` tab width | +| `TabItem` | `apps/desktop/.../TabsContent/GroupStrip/GroupItem.tsx` | Tab item styles, context menu (inline), middle-click close | +| `SplitHandle` | `apps/desktop/.../TabView/components/MosaicSplitOverlay/MosaicSplitOverlay.tsx` | 20px hit area, 1px `after:bg-border` line on hover, double-click equalize | +| `PaneContextMenu` | `apps/desktop/.../TabsContent/TabContentContextMenu.tsx` | Pane right-click menu structure | + +### PaneHeader behavior + +- **Default**: `getIcon()` + `getTitle()` on left, split + close buttons on right +- **Full eject**: if `renderToolbar()` is provided, replaces entire header content +- Focus state driven by `pane.id === tab.activePaneId` +- Click anywhere: `context.actions.focus()` +- Right-click: `PaneContextMenu` +- Future DnD: entire header becomes drag handle + +--- + +## Implementation Plan + +### Phase 1: Rename + gut the package + +1. Rename `packages/pane-layout/` → `packages/panes/` and `@superset/panes` → `@superset/panes` in `package.json` +2. Delete all existing source files in `src/` (types, store, react components, tests) +3. Update the import in `apps/desktop/package.json` from `@superset/panes` to `@superset/panes` +4. Stub `src/index.ts` so the build doesn't break + +### Phase 2: Types + Store (no React) + +1. `src/types.ts` — `Pane`, `Tab`, `WorkspaceState`, `LayoutNode`, `SplitDirection`, `SplitPosition`, `ContextMenuItem` +2. `src/core/store/utils.ts` — tree traversal helpers: find pane in layout tree, find parent split, collapse empty splits, find first pane (depth-first) +3. `src/core/store/store.ts` — `createWorkspaceStore()` with all actions (tab CRUD, pane CRUD, split/resize/equalize, replacePane, replaceState) +4. `src/core/store/store.test.ts` — full test suite (see Tests section) +5. `src/index.ts` — export types + store + +Run `bun test` — all store tests pass before touching React. + +### Phase 3: React components + +1. `src/react/types.ts` — `RendererContext`, `PaneDefinition`, `PaneRegistry`, `WorkspaceProps` +2. `src/react/hooks/` — `useOnBeforeClose`, `useContextMenuActions`, zustand `useStore` wrapper +3. Build component tree top-down: + - `Workspace/Workspace.tsx` — reads store, renders `TabBar` + `Tab` + - `Workspace/components/TabBar/` — tab strip with overflow, `TabItem`, `TabRenameInput`, `TabContextMenu` + - `Workspace/components/Tab/` — resolves layout, recursive renderer + - `Workspace/components/Tab/components/Pane/` — data boundary, builds `RendererContext` + - `Workspace/components/Tab/components/Pane/components/PaneHeader/` — default header with icon/title/actions, full eject via `renderToolbar` + - `Workspace/components/Tab/components/Pane/components/PaneContent/` — calls `definition.renderPane()` + - `Workspace/components/Tab/components/SplitHandle/` — resize divider +4. `src/index.ts` — export React components + hooks + +Run `bun run typecheck` — package compiles. + +### Phase 4: Hook up desktop app + +1. Update `apps/desktop/package.json` import +2. Update collection schema (`dashboardSidebarLocal/schema.ts`) — change `PaneWorkspaceState` → `WorkspaceState` import +3. Update `pane-viewer.model.ts` — pane factory functions (`createFilePane`, `createTerminalPane`, etc.) to return new `Pane` shape +4. Update `PaneViewer.tsx` — new pane registry with `renderPane`, `getTitle`, `getIcon`, `renderToolbar` (for browser) +5. Update `useV2WorkspacePaneLayout.ts` — swap `createPaneWorkspaceStore` → `createWorkspaceStore`, update type references. Persistence sync pattern stays the same. +6. Update all callsites using old store actions — search for `addPaneToGroup`, `splitGroup`, `groupId`, `addRoot`, `removeRoot`, `setActiveRoot` +7. Write the README.md into `packages/panes/README.md` + +Run `bun run typecheck` from root, `bun run lint:fix`, manual test in desktop app. + +### What stays untouched +- `apps/desktop/.../CollectionsProvider/` collection structure (workspaceId, sidebarState) — just update the type import +- The bidirectional persistence sync pattern in `useV2WorkspacePaneLayout` — same `replaceState` + `store.subscribe` approach +- All pane content components (terminal, chat, browser, file viewer) — they just get the new `RendererContext` interface + +--- + +## Drag-and-Drop (future — not part of first push) + +DnD is out of scope for the initial implementation but the model is designed to support it. Here's the plan for when we add it. + +### Store action + +```ts +movePaneBySplit: (args: { + sourcePaneId: string; + targetTabId: string; + targetPaneId: string; + position: SplitPosition; +}) => void; +``` + +Atomically: remove source pane from its current location (layout + panes map, collapse empty splits), then split the target pane and insert the moved pane at the given position. Works cross-tab (move entry between tabs' `panes` maps). + +No path adjustment needed (unlike react-mosaic) because we use IDs, not paths. + +### UI behavior + +- **Dragging over the tab bar** → `setActiveTab(hoveredTabId)` to switch tabs during drag (with small delay to avoid flicker), so you can see the target tab's panes before dropping +- **Dragging over a pane's content area** → show split preview overlay (4 edge zones: top/right/bottom/left highlighted based on mouse position within the pane rect). This is local React state on the drop target, not store state. +- **Drop** → calls `movePaneBySplit` with the resolved target +- **Cancel / invalid drop** → no-op, source pane stays where it was + +### Component changes for DnD + +**`PaneHeader`** +- The entire header bar is the drag handle (same pattern as v1's MosaicWindow toolbar) +- On drag start: store `{ tabId, paneId }` in drag item + +**`Pane`** +- Wraps each pane in a drop target +- On drag hover: tracks mouse position within the pane rect, determines which edge zone (top/right/bottom/left) is closest, shows a split preview overlay highlighting that zone +- On drop: calls `movePaneBySplit({ sourcePaneId, targetTabId, targetPaneId, position })` + +**`TabBar` / `TabItem`** +- Each tab item is a drop target +- On drag hover (with ~300ms delay): calls `setActiveTab(hoveredTabId)` to switch the visible tab +- Visual indicator that the tab will activate (e.g. subtle highlight) + +**`Workspace`** +- Wraps the whole workspace in the DnD provider (e.g. `DndProvider` from react-dnd or equivalent) + +### Library choice (TBD) + +- `react-dnd` — used by react-mosaic, proven for panel layouts +- `@dnd-kit` — modern, better touch/keyboard support +- Native HTML drag — simplest, fewer features + +--- + +## Tests + +### Tab operations +- Add tab, verify it appears in state +- Remove active tab → falls back to neighbor +- Remove only tab → `activeTabId` becomes null +- Set active tab +- Set tab title override + +### Pane operations +- Set active pane within a tab +- Get pane by ID (searches across all tabs) +- Get active pane (with and without explicit tabId) +- Set pane data in-place (flat map update, no layout change) +- Set pane title override +- Pin a pane via `setPanePinned` +- Replace unpinned pane data via `replacePane` (preview behavior) +- Replace is no-op if target pane is pinned + +### Split operations +- Split a single pane → creates split node with `weights: [1, 1]` +- Split right/left → horizontal direction, correct child order +- Split top/bottom → vertical direction, correct child order +- Split within existing same-direction split → halves target weight, inserts adjacent +- Split with custom weights +- Split with `selectNewPane: false` → focus stays on original +- Resize split (update weights array) +- Equalize split → all weights become `1` + +### Collapsing +- Close pane in 2-pane split → split collapses to remaining leaf +- Close pane in 3-pane split → child + weight removed, split stays +- Close last pane in tab → tab is removed entirely +- `activePaneId` falls back to sibling after close + +### Edge cases +- Invalid IDs (tab, pane, split) are all no-ops +- Replace state wholesale via `replaceState` +- Operations on empty tab (null layout) +- Duplicate pane ID insertion is no-op + +--- + +## Verification + +1. `bun test` in `packages/pane-layout` — all tests pass +2. `bun run typecheck` — all packages type-check +3. `bun run lint:fix` — clean lint +4. Manual: open desktop app → tabs render → panes render without group tab strips → splitting works → closing panes collapses splits → persistence round-trips + +--- + + +## Pane Lifecycle Notes + +**Cleanup on close (terminal kill, editor cleanup):** +Pane components handle their own cleanup via React unmount (`useEffect` return). When `closePane` removes a pane from state, React unmounts the component, which triggers cleanup (e.g. terminal calls `kill` on unmount, editor cleans up document state). The store doesn't need type-specific cleanup logic. + +**Devtools auto-close when browser closes:** +The devtools pane component reactively watches `store.getPane(targetPaneId)`. When it returns null (browser pane was closed), the devtools pane calls `context.actions.close()` to self-close. No coupling in the store — devtools owns this behavior. + +--- + +## README.md (for `packages/panes/`) + +````md +# @superset/panes + +A generic, headless workspace layout engine. Tabs hold panes arranged in split layouts. The package provides the data model, store, and React components — you provide the pane content. + +## Concepts + +``` +Workspace +├── Tab (chat, terminal, etc.) +│ ├── Pane A ──┐ +│ ├── Pane B ├── split layout (horizontal/vertical, n-ary, weighted) +│ └── Pane C ──┘ +├── Tab +│ └── Pane D (single pane, no splits) +└── ... +``` + +- **Workspace** — top-level container. Holds tabs, tracks the active tab. +- **Tab** — a named workspace context. Each tab has a split layout of panes and a flat pane data map. +- **Pane** — a leaf in the layout tree. Typed with your own data (`TData`). Rendered by a registry of pane definitions. +- **Layout tree** — purely structural. Describes how panes are arranged (splits + weights) but holds no pane data — just `paneId` references into the tab's flat `panes` map. + +## Quick Start + +### 1. Define your pane data type + +```tsx +type MyPaneData = + | { kind: "editor"; filePath: string } + | { kind: "terminal"; sessionId: string } + | { kind: "browser"; url: string }; +``` + +### 2. Create a pane registry + +The registry tells the layout engine how to render each pane kind: + +```tsx +import type { PaneRegistry } from "@superset/panes"; + +const registry: PaneRegistry = { + // Simple pane — just title + icon, default header + terminal: { + renderPane: (ctx) => , + getTitle: () => "Terminal", + getIcon: () => , + }, + + // Extra toolbar actions (pin button before split/close) + editor: { + renderPane: (ctx) => , + getTitle: (ctx) => ctx.pane.data.filePath.split("/").pop(), + getIcon: (ctx) => , + renderToolbarActions: (ctx) => ( + !ctx.pane.pinned && ctx.actions.pin()} /> + ), + }, + + // Full toolbar eject (browser needs nav buttons + URL bar) + browser: { + renderPane: (ctx) => , + renderToolbar: (ctx) => , + getTitle: (ctx) => ctx.pane.data.url, + getIcon: () => , + }, +}; +``` + +### 3. Create the store + +```tsx +import { createWorkspaceStore, createTab, createPane } from "@superset/panes"; + +const store = createWorkspaceStore({ + initialState: { + version: 1, + tabs: [ + createTab({ + titleOverride: "My Tab", + panes: [ + createPane({ kind: "terminal", data: { kind: "terminal", sessionId: "abc" } }), + ], + }), + ], + activeTabId: null, // auto-set to first tab + }, +}); +``` + +### 4. Render the workspace + +```tsx +import { Workspace } from "@superset/panes"; + +function App() { + return ( + ( + + addTerminalTab()}> + Terminal + + addChatTab()}> + Chat + + addBrowserTab()}> + Browser + + + )} + renderTabAccessory={(tab) => } + /> + ); +} +``` + +That's it. You get a tab bar, split panes with resizable handles, pane headers with close buttons, and context menus — all wired up. + +## Data Model + +### Layout Tree + +The layout is a tree of split nodes and pane leaves: + +```ts +type LayoutNode = + | { type: "pane"; paneId: string } + | { type: "split"; id: string; direction: "horizontal" | "vertical"; children: LayoutNode[]; weights: number[] }; +``` + +Splits are **n-ary** (not binary) — a 3-way split is `children: [A, B, C], weights: [1, 1, 1]`, not nested binary nodes. + +**Weights** are relative, not percentages. `[1, 1, 1]` = equal thirds. `[3, 2]` = 60/40. They don't need to sum to any specific value — CSS `flex-grow` handles the proportional rendering. + +### Pane + +```ts +interface Pane { + id: string; + kind: string; // maps to a key in your PaneRegistry + titleOverride?: string; // overrides getTitle() from registry + pinned?: boolean; // unpinned panes can be replaced in-place (preview mode) + data: TData; // your pane-specific state +} +``` + +### Tab + +```ts +interface Tab { + id: string; + titleOverride?: string; + createdAt: number; + activePaneId: string | null; + layout: LayoutNode | null; + panes: Record>; // flat map — layout tree references these by paneId +} +``` + +The **flat `panes` map** is separate from the layout tree. The tree is purely structural (`paneId` references), pane data lives in the map. This gives you O(1) pane lookup and clean separation of layout vs data. + +## Store + +The store is a vanilla zustand `StoreApi` (not a React hook store). This is intentional: +- Stable reference — created once, passed as a prop +- Subscribable from both React (`useStore`) and non-React code (`store.subscribe`) +- Works with any persistence layer (localStorage, IndexedDB, TanStack DB, etc.) via `replaceState` for hydration and `store.subscribe` for writes + +Create it with `createWorkspaceStore()` and pass it to ``. + +### Tab actions + +```ts +store.getState().addTab(tab) +store.getState().removeTab(tabId) +store.getState().setActiveTab(tabId) +store.getState().setTabTitleOverride(tabId, title) +store.getState().getTab(tabId) +store.getState().getActiveTab() +``` + +### Pane actions + +```ts +store.getState().setActivePane(tabId, paneId) +store.getState().getPane(paneId) // searches across all tabs +store.getState().getActivePane(tabId?) +store.getState().closePane(tabId, paneId) // removes from layout + panes, collapses empty splits +store.getState().setPaneData(paneId, data) +store.getState().setPaneTitleOverride(tabId, paneId, title) +store.getState().setPanePinned(tabId, paneId, pinned) +store.getState().replacePane(tabId, paneId, newPane) // swap unpinned pane in-place, no-op if pinned +``` + +### Split actions + +```ts +store.getState().splitPane(tabId, paneId, position, newPane, weights?) +// position: "top" | "right" | "bottom" | "left" +// splits the target pane, steals space from it (other panes untouched) + +store.getState().addPane(tabId, pane, position?, relativeToPaneId?) +// ergonomic wrapper — splits relative to a target, or appends to edge + +store.getState().resizeSplit(tabId, splitId, weights) +store.getState().equalizeSplit(tabId, splitId) // sets all weights to 1 +``` + +### Bulk + +```ts +store.getState().replaceState(newState) +store.getState().replaceState((prev) => ({ ...prev, ... })) +``` + +## Pane Registry + +Each pane kind registers how it renders: + +```ts +interface PaneDefinition { + renderPane(context: RendererContext): ReactNode; // required — the pane content + getTitle?(context: RendererContext): ReactNode; // derived title (titleOverride wins) + getIcon?(context: RendererContext): ReactNode; // icon in the pane header + renderToolbar?(context: RendererContext): ReactNode; // full eject — replaces entire header content +} +``` + +## RendererContext + +Every registry method receives a `RendererContext` with the pane's data and pre-wired actions: + +```ts +interface RendererContext { + pane: Pane; + tab: Tab; + isActive: boolean; + store: StoreApi>; // escape hatch + + actions: { + close: () => void; + focus: () => void; + setTitle: (title: string) => void; + pin: () => void; + updateData: (data: TData) => void; + splitRight: (newPane: Pane) => void; + splitDown: (newPane: Pane) => void; + }; +} +``` + +Use `context.actions.*` for normal operations. The `store` is an escape hatch for advanced cases (e.g. setting a tab title from within a pane). + +## Hooks + +Use these inside your pane components to register behavior with the layout engine: + +### useOnBeforeClose + +Register a close guard. Return `false` to cancel the close (e.g. show a "Save changes?" dialog): + +```tsx +function EditorPane({ context }: { context: RendererContext }) { + const isDirty = useDirtyState(); + + useOnBeforeClose(context, async () => { + if (!isDirty) return true; + return await showSaveConfirmation(); // returns true/false + }, [isDirty]); + + return ; +} +``` + +### useContextMenuActions + +Register pane-specific context menu items. These appear after the default items (Close, Split Right, Split Down): + +```tsx +function BrowserPane({ context }: { context: RendererContext }) { + const webviewRef = useRef(null); + + useContextMenuActions(context, [ + { label: "Refresh", icon: , shortcut: "⌘R", onSelect: () => webviewRef.current?.reload() }, + { type: "separator" }, + { label: "Open in External Browser", icon: , onSelect: () => shell.openExternal(context.pane.data.url) }, + ], [context.pane.data.url]); + + return ; +} +``` + +Context menu items support: +- `variant: "destructive"` — red text styling +- `shortcut` — display-only keyboard hint (e.g. `"⌘K"`) +- `disabled` — grayed out +- `type: "separator"` — visual divider +- `type: "submenu"` — nested menu with `items` + +## Splitting + +When you split a pane, the new pane steals space from the target. Other panes are untouched. + +```ts +// Single pane → 50/50 split +store.getState().splitPane(tabId, "pane-a", "right", newPane); +// Result: horizontal split, weights [1, 1] + +// Already in a same-direction split → target's weight is halved +// Before: horizontal [3, 2, 1], split pane[1] right +// After: horizontal [3, 1, 1, 1] +``` + +Position determines direction and order: +- `"left"` / `"right"` → horizontal split +- `"top"` / `"bottom"` → vertical split +- `"left"` / `"top"` → new pane goes first +- `"right"` / `"bottom"` → new pane goes second + +## Preview Panes (Pin/Unpin) + +Unpinned panes can be replaced in-place without creating a new split — useful for file preview (click a file → replaces the preview pane, double-click or edit → pins it): + +```ts +// Find any unpinned file pane in the tab +const preview = Object.values(tab.panes).find(p => p.kind === "file" && !p.pinned); + +if (preview) { + store.getState().replacePane(tabId, preview.id, newFilePane); +} else { + store.getState().splitPane(tabId, activePaneId, "right", newFilePane); +} +``` + +Pin from inside a pane component (e.g. on first edit): + +```tsx +context.actions.pin(); +``` + +## Workspace Props + +```ts + ReactNode} // custom UI in each tab (status dot, badge, etc.) + renderEmptyState={() => ReactNode} // shown when no tabs exist + renderAddTabMenu={() => ReactNode} // dropdown content for "+" button in tab bar +/> +``` +```` + +--- + +## Follow-up Tasks (not part of first push) + +1. **Drag-and-drop** — see DnD section above +2. **Reopen closed tab** — add `closedTabsStack: ClosedTab[]` to `WorkspaceState` and `reopenClosedTab()` action. Snapshot tab + panes on close, restore with fresh IDs. Persist stack so it survives app restarts. From 0bc29adb18bc6bf2015d1337b60c4475274d4d55 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 29 Mar 2026 19:22:27 -0700 Subject: [PATCH 03/10] Checkpoint - React types more locked in --- packages/panes/src/core/store/index.ts | 2 +- packages/panes/src/core/store/store.test.ts | 36 ++++++------ packages/panes/src/core/store/store.ts | 60 +++++++++----------- packages/panes/src/core/store/utils/utils.ts | 5 +- packages/panes/src/index.ts | 8 ++- packages/panes/src/react/index.ts | 6 ++ packages/panes/src/react/types.ts | 47 +++++++++++++++ 7 files changed, 106 insertions(+), 58 deletions(-) create mode 100644 packages/panes/src/react/index.ts create mode 100644 packages/panes/src/react/types.ts diff --git a/packages/panes/src/core/store/index.ts b/packages/panes/src/core/store/index.ts index f1ee283d4c1..73deab21a67 100644 --- a/packages/panes/src/core/store/index.ts +++ b/packages/panes/src/core/store/index.ts @@ -1,7 +1,7 @@ -export { createWorkspaceStore } from "./store"; export type { CreatePaneInput, CreateTabInput, CreateWorkspaceStoreOptions, WorkspaceStore, } from "./store"; +export { createWorkspaceStore } from "./store"; diff --git a/packages/panes/src/core/store/store.test.ts b/packages/panes/src/core/store/store.test.ts index 40d8fa45ee3..0fd25ef1310 100644 --- a/packages/panes/src/core/store/store.test.ts +++ b/packages/panes/src/core/store/store.test.ts @@ -130,9 +130,7 @@ describe("pane operations", () => { titleOverride: "Custom", }); - expect(store.getState().getPane("p1")?.pane.titleOverride).toBe( - "Custom", - ); + expect(store.getState().getPane("p1")?.pane.titleOverride).toBe("Custom"); }); it("pins a pane", () => { @@ -162,13 +160,12 @@ describe("pane operations", () => { newPane: tp("p2", "new"), }); - const tab = store.getState().tabs[0]!; - expect(tab.panes["p1"]).toBeUndefined(); - expect(tab.panes["p2"]?.data.label).toBe("new"); - expect(tab.activePaneId).toBe("p2"); - expect( - tab.layout?.type === "pane" ? tab.layout.paneId : null, - ).toBe("p2"); + const tab = store.getState().tabs[0]; + expect(tab).toBeDefined(); + expect(tab?.panes.p1).toBeUndefined(); + expect(tab?.panes.p2?.data.label).toBe("new"); + expect(tab?.activePaneId).toBe("p2"); + expect(tab?.layout?.type === "pane" ? tab.layout.paneId : null).toBe("p2"); }); it("replacePane is no-op if target pane is pinned", () => { @@ -446,10 +443,11 @@ describe("collapsing", () => { store.getState().closePane({ tabId: "t1", paneId: "p1" }); - const tab = store.getState().tabs[0]!; - expect(tab.layout).toEqual({ type: "pane", paneId: "p2" }); - expect(tab.activePaneId).toBe("p2"); - expect(tab.panes["p1"]).toBeUndefined(); + const tab = store.getState().tabs[0]; + expect(tab).toBeDefined(); + expect(tab?.layout).toEqual({ type: "pane", paneId: "p2" }); + expect(tab?.activePaneId).toBe("p2"); + expect(tab?.panes.p1).toBeUndefined(); }); it("close pane in 3-pane split removes child + weight", () => { @@ -562,12 +560,10 @@ describe("edge cases", () => { it("replaces state wholesale", () => { const store = makeStore(); - store - .getState() - .replaceState((prev: WorkspaceState) => ({ - ...prev, - activeTabId: "injected", - })); + store.getState().replaceState((prev: WorkspaceState) => ({ + ...prev, + activeTabId: "injected", + })); expect(store.getState().activeTabId).toBe("injected"); }); diff --git a/packages/panes/src/core/store/store.ts b/packages/panes/src/core/store/store.ts index 7d0402a4c2b..82bf9dd0cf3 100644 --- a/packages/panes/src/core/store/store.ts +++ b/packages/panes/src/core/store/store.ts @@ -94,9 +94,7 @@ export interface WorkspaceStore extends WorkspaceState { getActiveTab: () => Tab | null; setActivePane: (args: { tabId: string; paneId: string }) => void; - getPane: ( - paneId: string, - ) => { tabId: string; pane: Pane } | null; + getPane: (paneId: string) => { tabId: string; pane: Pane } | null; getActivePane: ( tabId?: string, ) => { tabId: string; pane: Pane } | null; @@ -172,9 +170,7 @@ export function createWorkspaceStore( return { tabs: nextTabs, activeTabId: - s.activeTabId === tabId - ? (nextTabs[0]?.id ?? null) - : s.activeTabId, + s.activeTabId === tabId ? (nextTabs[0]?.id ?? null) : s.activeTabId, }; }); }, @@ -189,9 +185,7 @@ export function createWorkspaceStore( setTabTitleOverride: (args) => { set((s) => ({ tabs: s.tabs.map((t) => - t.id === args.tabId - ? { ...t, titleOverride: args.titleOverride } - : t, + t.id === args.tabId ? { ...t, titleOverride: args.titleOverride } : t, ), })); }, @@ -211,9 +205,7 @@ export function createWorkspaceStore( return { activeTabId: args.tabId, tabs: s.tabs.map((t) => - t.id === args.tabId - ? { ...t, activePaneId: args.paneId } - : t, + t.id === args.tabId ? { ...t, activePaneId: args.paneId } : t, ), }; }); @@ -249,9 +241,7 @@ export function createWorkspaceStore( const { [args.paneId]: _, ...nextPanes } = tab.panes; if (!nextLayout) { - const nextTabs = s.tabs.filter( - (t) => t.id !== args.tabId, - ); + const nextTabs = s.tabs.filter((t) => t.id !== args.tabId); return { tabs: nextTabs, activeTabId: @@ -360,6 +350,7 @@ export function createWorkspaceStore( if (!tab || !pane || !tab.layout) return s; if (pane.pinned) return s; + const { layout } = tab; const newPane = buildPane(args.newPane); const { [args.paneId]: _, ...restPanes } = tab.panes; @@ -369,7 +360,7 @@ export function createWorkspaceStore( ? { ...tab, layout: replacePaneIdInLayout( - tab.layout!, + layout, args.paneId, newPane.id, ), @@ -395,6 +386,7 @@ export function createWorkspaceStore( ) return s; + const { layout } = tab; const newPane = buildPane(args.newPane); return { @@ -403,7 +395,7 @@ export function createWorkspaceStore( ? { ...tab, layout: splitPaneInLayout( - tab.layout!, + layout, args.paneId, newPane.id, args.position, @@ -453,20 +445,18 @@ export function createWorkspaceStore( } const position = args.position ?? "right"; - const targetPaneId = - args.relativeToPaneId ?? tab.activePaneId; + const targetPaneId = args.relativeToPaneId ?? tab.activePaneId; - if ( - targetPaneId && - findPaneInLayout(tab.layout, targetPaneId) - ) { + const { layout } = tab; + + if (targetPaneId && findPaneInLayout(layout, targetPaneId)) { return { tabs: s.tabs.map((t) => t.id === args.tabId ? { ...tab, layout: splitPaneInLayout( - tab.layout!, + layout, targetPaneId, newPane.id, position, @@ -495,8 +485,8 @@ export function createWorkspaceStore( : "vertical", children: position === "left" || position === "top" - ? [newPaneLeaf, tab.layout!] - : [tab.layout!, newPaneLeaf], + ? [newPaneLeaf, layout] + : [layout, newPaneLeaf], weights: [1, 1], }; @@ -523,13 +513,15 @@ export function createWorkspaceStore( const tab = s.tabs.find((t) => t.id === args.tabId); if (!tab || !tab.layout) return s; + const { layout } = tab; + return { tabs: s.tabs.map((t) => t.id === args.tabId ? { ...t, layout: updateSplitInLayout( - tab.layout!, + layout, args.splitId, (split) => ({ ...split, @@ -548,19 +540,19 @@ export function createWorkspaceStore( const tab = s.tabs.find((t) => t.id === args.tabId); if (!tab || !tab.layout) return s; + const { layout } = tab; + return { tabs: s.tabs.map((t) => t.id === args.tabId ? { ...t, layout: updateSplitInLayout( - tab.layout!, + layout, args.splitId, (split) => ({ ...split, - weights: split.children.map( - () => 1, - ), + weights: split.children.map(() => 1), }), ), } @@ -574,7 +566,11 @@ export function createWorkspaceStore( set((s) => { const resolved = typeof next === "function" - ? next({ version: s.version, tabs: s.tabs, activeTabId: s.activeTabId }) + ? next({ + version: s.version, + tabs: s.tabs, + activeTabId: s.activeTabId, + }) : next; return { version: resolved.version, diff --git a/packages/panes/src/core/store/utils/utils.ts b/packages/panes/src/core/store/utils/utils.ts index ab7a19449ff..ce39f3620e9 100644 --- a/packages/panes/src/core/store/utils/utils.ts +++ b/packages/panes/src/core/store/utils/utils.ts @@ -1,9 +1,6 @@ import type { LayoutNode, SplitDirection, SplitPosition } from "../../../types"; -export function findPaneInLayout( - node: LayoutNode, - paneId: string, -): boolean { +export function findPaneInLayout(node: LayoutNode, paneId: string): boolean { if (node.type === "pane") { return node.paneId === paneId; } diff --git a/packages/panes/src/index.ts b/packages/panes/src/index.ts index 6de1f583390..30a355f2d48 100644 --- a/packages/panes/src/index.ts +++ b/packages/panes/src/index.ts @@ -1,10 +1,16 @@ -export { createWorkspaceStore } from "./core/store"; export type { CreatePaneInput, CreateTabInput, CreateWorkspaceStoreOptions, WorkspaceStore, } from "./core/store"; +export { createWorkspaceStore } from "./core/store"; +export type { + PaneDefinition, + PaneRegistry, + RendererContext, + WorkspaceProps, +} from "./react"; export type { LayoutNode, Pane, diff --git a/packages/panes/src/react/index.ts b/packages/panes/src/react/index.ts new file mode 100644 index 00000000000..23b0b13509e --- /dev/null +++ b/packages/panes/src/react/index.ts @@ -0,0 +1,6 @@ +export type { + PaneDefinition, + PaneRegistry, + RendererContext, + WorkspaceProps, +} from "./types"; diff --git a/packages/panes/src/react/types.ts b/packages/panes/src/react/types.ts new file mode 100644 index 00000000000..5d658479866 --- /dev/null +++ b/packages/panes/src/react/types.ts @@ -0,0 +1,47 @@ +import type { ComponentType, ReactNode } from "react"; +import type { StoreApi } from "zustand/vanilla"; +import type { WorkspaceStore } from "../core/store"; +import type { Pane, Tab } from "../types"; + +export interface RendererContext { + pane: Pane; + tab: Tab; + isActive: boolean; + store: StoreApi>; + + actions: { + close: () => void; + focus: () => void; + setTitle: (title: string) => void; + pin: () => void; + updateData: (data: TData) => void; + splitRight: (newPane: Pane) => void; + splitDown: (newPane: Pane) => void; + }; + + components: { + DefaultContextMenuItems: ComponentType; + }; +} + +export interface PaneDefinition { + renderPane(context: RendererContext): ReactNode; + getTitle?(context: RendererContext): ReactNode; + getIcon?(context: RendererContext): ReactNode; + renderToolbar?(context: RendererContext): ReactNode; +} + +export type PaneRegistry = Record>; + +export interface WorkspaceProps { + store: StoreApi>; + registry: PaneRegistry; + className?: string; + renderTabAccessory?: (tab: Tab) => ReactNode; + renderEmptyState?: () => ReactNode; + renderAddTabMenu?: () => ReactNode; + onBeforeClose?: ( + pane: Pane, + tab: Tab, + ) => boolean | Promise; +} From 83bb9b71f2d70a905f946cb1c46f55b6fd19b4d7 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Sun, 29 Mar 2026 22:04:04 -0700 Subject: [PATCH 04/10] Better organized --- .../components/PaneViewer/PaneViewer.tsx | 313 +++++------------- .../components/AddTabMenu/AddTabMenu.tsx | 52 +++ .../PaneViewer/components/AddTabMenu/index.ts | 1 + .../PaneViewer/hooks/usePaneRegistry/index.ts | 1 + .../hooks/usePaneRegistry/usePaneRegistry.tsx | 96 ++++++ .../useV2WorkspacePaneLayout.ts | 45 +-- .../PaneViewer/pane-viewer.model.ts | 121 ------- .../useDashboardSidebarState.ts | 4 +- .../dashboardSidebarLocal/schema.ts | 4 +- bun.lock | 1 + packages/panes/src/core/store/store.test.ts | 47 +++ packages/panes/src/core/store/store.ts | 52 +++ packages/panes/src/index.ts | 1 + .../react/components/Workspace/Workspace.tsx | 68 ++++ .../Workspace/components/Tab/Tab.tsx | 102 ++++++ .../components/Tab/components/Pane/Pane.tsx | 102 ++++++ .../components/PaneContent/PaneContent.tsx | 13 + .../Pane/components/PaneContent/index.ts | 1 + .../Pane/components/PaneHeader/PaneHeader.tsx | 39 +++ .../Pane/components/PaneHeader/index.ts | 1 + .../components/Tab/components/Pane/index.ts | 1 + .../Workspace/components/Tab/index.ts | 1 + .../Workspace/components/TabBar/TabBar.tsx | 169 ++++++++++ .../TabBar/components/TabItem/TabItem.tsx | 144 ++++++++ .../TabRenameInput/TabRenameInput.tsx | 52 +++ .../components/TabRenameInput/index.ts | 1 + .../TabBar/components/TabItem/index.ts | 1 + .../Workspace/components/TabBar/index.ts | 1 + .../src/react/components/Workspace/index.ts | 1 + packages/panes/src/react/index.ts | 1 + packages/panes/src/react/types.ts | 3 +- 31 files changed, 1069 insertions(+), 370 deletions(-) create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/components/AddTabMenu/AddTabMenu.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/components/AddTabMenu/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/hooks/usePaneRegistry/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/hooks/usePaneRegistry/usePaneRegistry.tsx create mode 100644 packages/panes/src/react/components/Workspace/Workspace.tsx create mode 100644 packages/panes/src/react/components/Workspace/components/Tab/Tab.tsx create mode 100644 packages/panes/src/react/components/Workspace/components/Tab/components/Pane/Pane.tsx create mode 100644 packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneContent/PaneContent.tsx create mode 100644 packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneContent/index.ts create mode 100644 packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/PaneHeader.tsx create mode 100644 packages/panes/src/react/components/Workspace/components/Tab/components/Pane/components/PaneHeader/index.ts create mode 100644 packages/panes/src/react/components/Workspace/components/Tab/components/Pane/index.ts create mode 100644 packages/panes/src/react/components/Workspace/components/Tab/index.ts create mode 100644 packages/panes/src/react/components/Workspace/components/TabBar/TabBar.tsx create mode 100644 packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/TabItem.tsx create mode 100644 packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/components/TabRenameInput/TabRenameInput.tsx create mode 100644 packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/components/TabRenameInput/index.ts create mode 100644 packages/panes/src/react/components/Workspace/components/TabBar/components/TabItem/index.ts create mode 100644 packages/panes/src/react/components/Workspace/components/TabBar/index.ts create mode 100644 packages/panes/src/react/components/Workspace/index.ts diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx index ba1d4d82cca..12c2b49314a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/PaneViewer/PaneViewer.tsx @@ -1,43 +1,23 @@ -import { - createPaneRoot, - type PaneRegistry, - PaneWorkspace, -} from "@superset/pane-layout"; -import { - DropdownMenuCheckboxItem, - DropdownMenuItem, - DropdownMenuSeparator, -} from "@superset/ui/dropdown-menu"; +import { Workspace } from "@superset/panes"; import { useNavigate } from "@tanstack/react-router"; -import { FileCode2, Globe, MessageSquare, TerminalSquare } from "lucide-react"; -import { useCallback, useMemo } from "react"; -import { BsTerminalPlus } from "react-icons/bs"; -import { TbMessageCirclePlus, TbWorld } from "react-icons/tb"; -import { HotkeyMenuShortcut } from "renderer/components/HotkeyMenuShortcut"; +import { useCallback } from "react"; import { electronTrpc } from "renderer/lib/electron-trpc"; -import { WorkspaceChat } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceChat"; -import { WorkspaceFilePreview } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceFiles/components/WorkspaceFilePreview/WorkspaceFilePreview"; -import { WorkspaceTerminal } from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/components/WorkspaceTerminal"; import { CommandPalette, useCommandPalette, } from "renderer/screens/main/components/CommandPalette"; import { PresetsBar } from "renderer/screens/main/components/WorkspaceView/ContentView/components/PresetsBar"; import { useAppHotkey } from "renderer/stores/hotkeys"; -import { DEFAULT_SHOW_PRESETS_BAR } from "shared/constants"; +import { AddTabMenu } from "./components/AddTabMenu"; import { PaneViewerEmptyState } from "./components/PaneViewerEmptyState"; +import { usePaneRegistry } from "./hooks/usePaneRegistry"; import { useV2WorkspacePaneLayout } from "./hooks/useV2WorkspacePaneLayout"; -import { - type BrowserPaneData, - type ChatPaneData, - createBrowserPane, - createChatPane, - createFilePane, - createTerminalPane, - type DevtoolsPaneData, - type FilePaneData, - type PaneViewerData, - type TerminalPaneData, +import type { + BrowserPaneData, + ChatPaneData, + FilePaneData, + PaneViewerData, + TerminalPaneData, } from "./pane-viewer.model"; interface PaneViewerProps { @@ -46,116 +26,93 @@ interface PaneViewerProps { workspaceName: string; } -function getFileTitle(filePath: string): string { - return filePath.split("/").pop() ?? filePath; -} - export function PaneViewer({ projectId, workspaceId, workspaceName, }: PaneViewerProps) { const navigate = useNavigate(); - const { store } = useV2WorkspacePaneLayout({ - projectId, - workspaceId, - }); + const { store } = useV2WorkspacePaneLayout({ projectId, workspaceId }); + const paneRegistry = usePaneRegistry(workspaceId); + const utils = electronTrpc.useUtils(); - const { data: showPresetsBar } = + const { data: showPresetsBar, isLoading: isLoadingPresetsBar } = electronTrpc.settings.getShowPresetsBar.useQuery(); - const setShowPresetsBar = electronTrpc.settings.setShowPresetsBar.useMutation( - { - onMutate: async ({ enabled }) => { - await utils.settings.getShowPresetsBar.cancel(); - const previous = utils.settings.getShowPresetsBar.getData(); - utils.settings.getShowPresetsBar.setData(undefined, enabled); - return { previous }; - }, - onError: (_error, _variables, context) => { - if (context?.previous !== undefined) { - utils.settings.getShowPresetsBar.setData(undefined, context.previous); - } - }, - onSettled: () => { - utils.settings.getShowPresetsBar.invalidate(); - }, + const setShowPresetsBar = electronTrpc.settings.setShowPresetsBar.useMutation({ + onMutate: async ({ enabled }) => { + await utils.settings.getShowPresetsBar.cancel(); + const previous = utils.settings.getShowPresetsBar.getData(); + utils.settings.getShowPresetsBar.setData(undefined, enabled); + return { previous }; }, - ); + onError: (_error, _variables, context) => { + if (context?.previous !== undefined) { + utils.settings.getShowPresetsBar.setData(undefined, context.previous); + } + }, + onSettled: () => { + utils.settings.getShowPresetsBar.invalidate(); + }, + }); const openFilePane = useCallback( (filePath: string) => { - const pane = createFilePane({ - title: getFileTitle(filePath), - filePath, - mode: "editor", - hasChanges: false, + store.getState().openPane({ + pane: { + kind: "file", + data: { + filePath, + mode: "editor", + hasChanges: false, + } as FilePaneData, + }, + tabTitle: "Files", }); - const activePane = store.getState().getActivePane(); - - if (activePane) { - store.getState().addPaneToGroup({ - rootId: activePane.rootId, - groupId: activePane.groupId, - pane, - replaceUnpinned: true, - select: true, - }); - return; - } - - store.getState().addRoot( - createPaneRoot({ - titleOverride: "Files", - panes: [pane], - }), - ); }, [store], ); - const addTerminalRoot = useCallback(() => { - store.getState().addRoot( - createPaneRoot({ - titleOverride: "Terminal", - panes: [ - createTerminalPane({ - title: "Terminal", + const addTerminalTab = useCallback(() => { + store.getState().addTab({ + titleOverride: "Terminal", + panes: [ + { + kind: "terminal", + data: { sessionKey: `${workspaceId}:${crypto.randomUUID()}`, cwd: `/workspace/${workspaceName}`, launchMode: "workspace-shell", - }), - ], - }), - ); + } as TerminalPaneData, + }, + ], + }); }, [store, workspaceId, workspaceName]); - const addChatRoot = useCallback(() => { - store.getState().addRoot( - createPaneRoot({ - titleOverride: "Chat", - panes: [ - createChatPane({ - title: "Chat", - sessionId: null, - }), - ], - }), - ); + const addChatTab = useCallback(() => { + store.getState().addTab({ + titleOverride: "Chat", + panes: [ + { + kind: "chat", + data: { sessionId: null } as ChatPaneData, + }, + ], + }); }, [store]); - const addBrowserRoot = useCallback(() => { - store.getState().addRoot( - createPaneRoot({ - titleOverride: "Browser", - panes: [ - createBrowserPane({ - title: "Browser", + const addBrowserTab = useCallback(() => { + store.getState().addTab({ + titleOverride: "Browser", + panes: [ + { + kind: "browser", + data: { url: "http://localhost:3000", mode: "preview", - }), - ], - }), - ); + } as BrowserPaneData, + }, + ], + }); }, [store]); const commandPalette = useCommandPalette({ @@ -163,7 +120,6 @@ export function PaneViewer({ navigate, onSelectFile: ({ close, filePath, targetWorkspaceId }) => { close(); - if (targetWorkspaceId !== workspaceId) { void navigate({ to: "/v2-workspace/$workspaceId", @@ -171,7 +127,6 @@ export function PaneViewer({ }); return; } - openFilePane(filePath); }, }); @@ -179,80 +134,10 @@ export function PaneViewer({ const handleQuickOpen = useCallback(() => { commandPalette.toggle(); }, [commandPalette]); - const setPaneData = store.getState().setPaneData; - - const paneRegistry = useMemo>( - () => ({ - file: { - getIcon: () => , - renderPane: ({ pane }) => { - const data = pane.data as FilePaneData; - - return ( - - ); - }, - }, - terminal: { - getIcon: () => , - renderPane: ({ pane }) => { - const _data = pane.data as TerminalPaneData; - return ; - }, - }, - browser: { - getIcon: () => , - renderPane: ({ pane }) => { - const data = pane.data as BrowserPaneData; - - return ( -