diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md index 904ffffd6ca..b1c564345c0 100644 --- a/apps/desktop/CLAUDE.md +++ b/apps/desktop/CLAUDE.md @@ -3,8 +3,6 @@ For Electron interprocess communnication, ALWAYS use trpc as defined in `src/lib Please use alias as defined in `tsconfig.json` when possible Prefer zustand for state management if it makes sense. Do not use effect unless absolutely necessary. - - # Code quality ``` Code is clean if it can be understood easily – by everyone on the team. Clean code can be read and enhanced by a developer other than its original author. With understandability comes readability, changeability, extensibility and maintainability. diff --git a/apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts b/apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts new file mode 100644 index 00000000000..78704a592fa --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useGlobalShortcuts.ts @@ -0,0 +1,158 @@ +import { useEffect, useMemo } from "react"; +import { createShortcutHandler } from "../lib/keyboard-shortcuts"; +import { + createSplitPaneShortcuts, + createTabShortcuts, + createWorkspaceShortcuts, +} from "../lib/shortcuts"; +import { useSidebarStore } from "../stores/sidebar-state"; +import { + useActiveTabIds, + useAddTab, + useRemoveTab, + useSetActiveTab, + useSplitTabHorizontal, + useSplitTabVertical, + useTabs, +} from "../stores/tabs"; +import { useWorkspacesStore } from "../stores/workspaces"; + +function findWorkspaceIndex( + workspaces: Array<{ id: string }>, + id: string | null, +) { + if (!id) return -1; + return workspaces.findIndex((w) => w.id === id); +} + +function findTabIndex(tabs: Array<{ id: string }>, id: string | null) { + if (!id) return -1; + return tabs.findIndex((t) => t.id === id); +} + +export function useGlobalShortcuts() { + const { workspaces, activeWorkspaceId, setActiveWorkspace } = + useWorkspacesStore(); + const { toggleSidebar } = useSidebarStore(); + const tabs = useTabs(); + const activeTabIds = useActiveTabIds(); + const setActiveTab = useSetActiveTab(); + const addTab = useAddTab(); + const removeTab = useRemoveTab(); + const splitTabVertical = useSplitTabVertical(); + const splitTabHorizontal = useSplitTabHorizontal(); + + const workspaceTabs = useMemo(() => { + if (!activeWorkspaceId) return []; + return tabs.filter( + (t) => t.workspaceId === activeWorkspaceId && !t.parentId, + ); + }, [tabs, activeWorkspaceId]); + + const activeTabId = activeWorkspaceId + ? activeTabIds[activeWorkspaceId] + : null; + + useEffect(() => { + const workspaceHandlers = { + switchToPrevWorkspace: () => { + if (!activeWorkspaceId) return; + const index = findWorkspaceIndex(workspaces, activeWorkspaceId); + if (index > 0) { + setActiveWorkspace(workspaces[index - 1].id); + } + }, + switchToNextWorkspace: () => { + if (!activeWorkspaceId) return; + const index = findWorkspaceIndex(workspaces, activeWorkspaceId); + if (index < workspaces.length - 1) { + setActiveWorkspace(workspaces[index + 1].id); + } + }, + toggleSidebar, + splitVertical: () => { + if (activeWorkspaceId) { + splitTabVertical(activeWorkspaceId); + } + }, + splitHorizontal: () => { + if (activeWorkspaceId) { + splitTabHorizontal(activeWorkspaceId); + } + }, + }; + + const tabHandlers = { + switchToPrevTab: () => { + if (!activeWorkspaceId || !activeTabId) return; + const index = findTabIndex(workspaceTabs, activeTabId); + if (index > 0) { + setActiveTab(activeWorkspaceId, workspaceTabs[index - 1].id); + } + }, + switchToNextTab: () => { + if (!activeWorkspaceId || !activeTabId) return; + const index = findTabIndex(workspaceTabs, activeTabId); + if (index < workspaceTabs.length - 1) { + setActiveTab(activeWorkspaceId, workspaceTabs[index + 1].id); + } + }, + newTab: () => { + if (activeWorkspaceId) { + addTab(activeWorkspaceId); + } + }, + closeTab: () => { + if (activeTabId) { + removeTab(activeTabId); + } + }, + reopenClosedTab: () => { + console.log("Reopen closed tab"); + }, + jumpToTab: (index: number) => { + if (!activeWorkspaceId) return; + const targetTab = workspaceTabs[index - 1]; + if (targetTab) { + setActiveTab(activeWorkspaceId, targetTab.id); + } + }, + }; + + const splitPaneHandlers = { + focusPaneLeft: () => console.log("Focus pane left"), + focusPaneRight: () => console.log("Focus pane right"), + focusPaneUp: () => console.log("Focus pane up"), + focusPaneDown: () => console.log("Focus pane down"), + }; + + const workspaceShortcuts = createWorkspaceShortcuts(workspaceHandlers); + const tabShortcuts = createTabShortcuts(tabHandlers); + const splitPaneShortcuts = createSplitPaneShortcuts(splitPaneHandlers); + + const allShortcuts = [ + ...workspaceShortcuts.shortcuts, + ...tabShortcuts.shortcuts, + ...splitPaneShortcuts.shortcuts, + ]; + + const handleKeyDown = createShortcutHandler(allShortcuts); + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ + workspaces, + activeWorkspaceId, + workspaceTabs, + activeTabId, + setActiveWorkspace, + toggleSidebar, + setActiveTab, + addTab, + removeTab, + splitTabVertical, + splitTabHorizontal, + ]); +} diff --git a/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts b/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts index d0a3de242dc..3bad6ab1f8c 100644 --- a/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts +++ b/apps/desktop/src/renderer/lib/keyboard-shortcuts.ts @@ -9,7 +9,7 @@ export interface KeyboardShortcut { key: string; modifiers?: ModifierKey[]; description: string; - handler: (event: KeyboardEvent) => boolean | void; + handler: (event: KeyboardEvent) => boolean; } export interface KeyboardShortcutGroup { diff --git a/apps/desktop/src/renderer/lib/shortcuts.ts b/apps/desktop/src/renderer/lib/shortcuts.ts index 5e481c35afe..52073affbb7 100644 --- a/apps/desktop/src/renderer/lib/shortcuts.ts +++ b/apps/desktop/src/renderer/lib/shortcuts.ts @@ -12,8 +12,8 @@ export interface ShortcutHandlers { switchToPrevWorkspace: () => void; switchToNextWorkspace: () => void; toggleSidebar: () => void; - createSplitView: () => void; - createVerticalSplit: () => void; + splitHorizontal: () => void; + splitVertical: () => void; // Tab management switchToPrevTab: () => void; @@ -23,6 +23,12 @@ export interface ShortcutHandlers { reopenClosedTab: () => void; jumpToTab: (index: number) => void; + // Split pane navigation + focusPaneLeft: () => void; + focusPaneRight: () => void; + focusPaneUp: () => void; + focusPaneDown: () => void; + // Terminal specific clearTerminal: () => void; closeTerminal: () => void; @@ -34,8 +40,8 @@ export function createWorkspaceShortcuts( | "switchToPrevWorkspace" | "switchToNextWorkspace" | "toggleSidebar" - | "createSplitView" - | "createVerticalSplit" + | "splitVertical" + | "splitHorizontal" >, ): KeyboardShortcutGroup { return { @@ -74,20 +80,20 @@ export function createWorkspaceShortcuts( { key: "d", modifiers: ["meta"], - description: "Create split view (horizontal)", + description: "Split window vertically", handler: (event) => { event.preventDefault(); - handlers.createSplitView(); + handlers.splitVertical(); return false; }, }, { key: "d", modifiers: ["meta", "shift"], - description: "Create split view (vertical)", + description: "Split window horizontally", handler: (event) => { event.preventDefault(); - handlers.createVerticalSplit(); + handlers.splitHorizontal(); return false; }, }, @@ -159,7 +165,6 @@ export function createTabShortcuts( }, ]; - // Add Cmd+1-9 shortcuts for jumping to tabs for (let i = 1; i <= 9; i++) { shortcuts.push({ key: i.toString(), @@ -179,6 +184,59 @@ export function createTabShortcuts( }; } +export function createSplitPaneShortcuts( + handlers: Pick< + ShortcutHandlers, + "focusPaneLeft" | "focusPaneRight" | "focusPaneUp" | "focusPaneDown" + >, +): KeyboardShortcutGroup { + return { + name: "Split Pane Navigation", + shortcuts: [ + { + key: "ArrowLeft", + modifiers: ["meta", "alt"], + description: "Focus left pane", + handler: (event) => { + event.preventDefault(); + handlers.focusPaneLeft(); + return false; + }, + }, + { + key: "ArrowRight", + modifiers: ["meta", "alt"], + description: "Focus right pane", + handler: (event) => { + event.preventDefault(); + handlers.focusPaneRight(); + return false; + }, + }, + { + key: "ArrowUp", + modifiers: ["meta", "alt"], + description: "Focus upper pane", + handler: (event) => { + event.preventDefault(); + handlers.focusPaneUp(); + return false; + }, + }, + { + key: "ArrowDown", + modifiers: ["meta", "alt"], + description: "Focus lower pane", + handler: (event) => { + event.preventDefault(); + handlers.focusPaneDown(); + return false; + }, + }, + ], + }; +} + export function createTerminalShortcuts( handlers: Pick, ): KeyboardShortcutGroup { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/index.tsx index 8cb713241e3..d9776d031b7 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/index.tsx @@ -14,6 +14,8 @@ import { getChildTabIds, type TabGroup, useActiveTabIds, + useSplitTabHorizontal, + useSplitTabVertical, useTabs, useTabsStore, } from "renderer/stores"; @@ -52,6 +54,8 @@ export function GroupTabView({ tab }: GroupTabViewProps) { const removeChildTabFromGroup = useTabsStore( (state) => state.removeChildTabFromGroup, ); + const splitTabHorizontal = useSplitTabHorizontal(); + const splitTabVertical = useSplitTabVertical(); const activeTabIds = useActiveTabIds(); const activeTabId = activeTabIds[tab.workspaceId]; @@ -78,49 +82,64 @@ export function GroupTabView({ tab }: GroupTabViewProps) { [tab.id, tab.layout, updateTabGroupLayout, removeChildTabFromGroup], ); - const handleSplitHorizontal = (tabId: string) => { - // TODO: Implement split horizontally functionality - console.log("Split horizontally:", tabId); - }; - - const handleSplitVertical = (tabId: string) => { - // TODO: Implement split vertically functionality - console.log("Split vertically:", tabId); - }; - - const handleClosePane = (tabId: string) => { - // TODO: Implement close pane functionality - console.log("Close pane:", tabId); - }; - - const renderPane = (tabId: string, path: MosaicBranch[]) => { - const isActive = tabId === activeTabId; - const childTab = childTabs.find((t) => t.id === tabId); - if (!childTab) { - return ( -
- Tab not found: {tabId} -
- ); - } + const handleSplitHorizontal = useCallback( + (tabId: string, path: MosaicBranch[]) => { + splitTabHorizontal(tab.workspaceId, tabId, path); + }, + [tab.workspaceId, splitTabHorizontal], + ); - return ( - - path={path} - title={childTab.title} - toolbarControls={
} - className={isActive ? "mosaic-window-focused" : ""} - > - handleSplitHorizontal(tabId)} - onSplitVertical={() => handleSplitVertical(tabId)} - onClosePane={() => handleClosePane(tabId)} + const handleSplitVertical = useCallback( + (tabId: string, path: MosaicBranch[]) => { + splitTabVertical(tab.workspaceId, tabId, path); + }, + [tab.workspaceId, splitTabVertical], + ); + + const handleClosePane = useCallback( + (tabId: string) => { + removeChildTabFromGroup(tab.id, tabId); + }, + [tab.id, removeChildTabFromGroup], + ); + + const renderPane = useCallback( + (tabId: string, path: MosaicBranch[]) => { + const isActive = tabId === activeTabId; + const childTab = childTabs.find((t) => t.id === tabId); + if (!childTab) { + return ( +
+ Tab not found: {tabId} +
+ ); + } + + return ( + + path={path} + title={childTab.title} + toolbarControls={
} + className={isActive ? "mosaic-window-focused" : ""} > -
{childTab.title}
- - - ); - }; + handleSplitHorizontal(tabId, path)} + onSplitVertical={() => handleSplitVertical(tabId, path)} + onClosePane={() => handleClosePane(tabId)} + > +
{childTab.title}
+
+ + ); + }, + [ + activeTabId, + childTabs, + handleSplitHorizontal, + handleSplitVertical, + handleClosePane, + ], + ); if (childTabs.length === 0 || !cleanedLayout) { return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx index 31d3002f7d0..93041c91f69 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx @@ -1,4 +1,9 @@ -import type { SingleTab } from "renderer/stores"; +import { + type SingleTab, + useRemoveTab, + useSplitTabHorizontal, + useSplitTabVertical, +} from "renderer/stores"; import { TabContentContextMenu } from "./TabContentContextMenu"; interface SingleTabViewProps { @@ -7,19 +12,20 @@ interface SingleTabViewProps { } export function SingleTabView({ tab }: SingleTabViewProps) { + const splitTabHorizontal = useSplitTabHorizontal(); + const splitTabVertical = useSplitTabVertical(); + const removeTab = useRemoveTab(); + const handleSplitHorizontal = () => { - // TODO: Implement split horizontally functionality - console.log("Split horizontally:", tab.id); + splitTabHorizontal(tab.workspaceId, tab.id); }; const handleSplitVertical = () => { - // TODO: Implement split vertically functionality - console.log("Split vertically:", tab.id); + splitTabVertical(tab.workspaceId, tab.id); }; const handleClosePane = () => { - // TODO: Implement close pane functionality - console.log("Close pane:", tab.id); + removeTab(tab.id); }; return ( diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 0c14dd80a27..a01440d04bc 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -1,4 +1,5 @@ import { DndProvider } from "react-dnd"; +import { useGlobalShortcuts } from "renderer/hooks/useGlobalShortcuts"; import { dragDropManager } from "../../lib/dnd"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; @@ -6,6 +7,8 @@ import { TopBar } from "./components/TopBar"; import { WorkspaceView } from "./components/WorkspaceView"; export function MainScreen() { + useGlobalShortcuts(); + return ( diff --git a/apps/desktop/src/renderer/stores/tabs/store.test.ts b/apps/desktop/src/renderer/stores/tabs/store.test.ts index b43fb6e713a..7f0d2a04cc3 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.test.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.test.ts @@ -176,3 +176,189 @@ describe("removeTab", () => { expect(state.activeTabIds["workspace-1"]).toBe("other-1"); }); }); + +describe("splitTabVertical", () => { + test("splits active tab vertically creating a group with two children", () => { + const store = useTabsStore.getState(); + + const singleTab = { + id: "tab-1", + title: "Original Tab", + workspaceId: "workspace-1", + type: TabType.Single, + } as const; + + useTabsStore.setState({ + tabs: [singleTab], + activeTabIds: { "workspace-1": "tab-1" }, + tabHistoryStacks: { "workspace-1": [] }, + }); + + // Split the tab vertically + store.splitTabVertical("workspace-1"); + + const state = useTabsStore.getState(); + + // Should have 3 tabs: original (now child), new child, and group + expect(state.tabs.length).toBe(3); + + // Find the group tab + const groupTab = state.tabs.find((t) => t.type === TabType.Group); + expect(groupTab).toBeDefined(); + if (groupTab?.type !== TabType.Group) return; + + expect(groupTab.layout).toEqual({ + direction: "row", + first: "tab-1", + second: expect.any(String), + splitPercentage: 50, + }); + + // Original tab should now be a child + const originalTab = state.tabs.find((t) => t.id === "tab-1"); + expect(originalTab?.parentId).toBe(groupTab.id); + + // New child should exist + if (typeof groupTab.layout === "string" || !groupTab.layout) return; + const newChild = state.tabs.find( + (t) => t.id === groupTab.layout.second && t.type === TabType.Single, + ); + expect(newChild).toBeDefined(); + expect(newChild?.parentId).toBe(groupTab.id); + expect(newChild?.title).toBe("New Tab"); + + // Active tab should be the group + expect(state.activeTabIds["workspace-1"]).toBe(groupTab.id); + }); + + test("does not split a group tab", () => { + const store = useTabsStore.getState(); + + const groupTab = { + id: "group-1", + title: "Group", + workspaceId: "workspace-1", + type: TabType.Group, + layout: { + direction: "row" as const, + first: "child-1", + second: "child-2", + splitPercentage: 50, + }, + }; + + useTabsStore.setState({ + tabs: [groupTab], + activeTabIds: { "workspace-1": "group-1" }, + tabHistoryStacks: { "workspace-1": [] }, + }); + + // Try to split the group + store.splitTabVertical("workspace-1"); + + const state = useTabsStore.getState(); + + // Should remain unchanged + expect(state.tabs.length).toBe(1); + expect(state.tabs[0].id).toBe("group-1"); + }); +}); + +describe("splitTabHorizontal", () => { + test("splits active tab horizontally creating a group with two children", () => { + const store = useTabsStore.getState(); + + const singleTab = { + id: "tab-1", + title: "Original Tab", + workspaceId: "workspace-1", + type: TabType.Single, + } as const; + + useTabsStore.setState({ + tabs: [singleTab], + activeTabIds: { "workspace-1": "tab-1" }, + tabHistoryStacks: { "workspace-1": [] }, + }); + + // Split the tab horizontally + store.splitTabHorizontal("workspace-1"); + + const state = useTabsStore.getState(); + + // Should have 3 tabs: original (now child), new child, and group + expect(state.tabs.length).toBe(3); + + // Find the group tab + const groupTab = state.tabs.find((t) => t.type === TabType.Group); + expect(groupTab).toBeDefined(); + if (groupTab?.type !== TabType.Group) return; + + expect(groupTab.layout).toEqual({ + direction: "column", + first: "tab-1", + second: expect.any(String), + splitPercentage: 50, + }); + + // Original tab should now be a child + const originalTab = state.tabs.find((t) => t.id === "tab-1"); + expect(originalTab?.parentId).toBe(groupTab.id); + + // New child should exist + if (typeof groupTab.layout === "string" || !groupTab.layout) return; + const newChild = state.tabs.find( + (t) => t.id === groupTab.layout.second && t.type === TabType.Single, + ); + expect(newChild).toBeDefined(); + expect(newChild?.parentId).toBe(groupTab.id); + expect(newChild?.title).toBe("New Tab"); + + // Active tab should be the group + expect(state.activeTabIds["workspace-1"]).toBe(groupTab.id); + }); + + test("splits specific tab by id", () => { + const store = useTabsStore.getState(); + + const tab1 = { + id: "tab-1", + title: "Tab 1", + workspaceId: "workspace-1", + type: TabType.Single, + } as const; + + const tab2 = { + id: "tab-2", + title: "Tab 2", + workspaceId: "workspace-1", + type: TabType.Single, + } as const; + + useTabsStore.setState({ + tabs: [tab1, tab2], + activeTabIds: { "workspace-1": "tab-1" }, + tabHistoryStacks: { "workspace-1": [] }, + }); + + // Split tab-2 specifically (not the active tab) + store.splitTabHorizontal("workspace-1", "tab-2"); + + const state = useTabsStore.getState(); + + // Should have 4 tabs: tab-1, tab-2 (now child), new child, and group + expect(state.tabs.length).toBe(4); + + // Find the group tab + const groupTab = state.tabs.find((t) => t.type === TabType.Group); + expect(groupTab).toBeDefined(); + + // Tab-2 should be in the group + const tab2After = state.tabs.find((t) => t.id === "tab-2"); + expect(tab2After?.parentId).toBe(groupTab?.id); + + // Tab-1 should remain unchanged + const tab1After = state.tabs.find((t) => t.id === "tab-1"); + expect(tab1After?.parentId).toBeUndefined(); + }); +}); diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index f9a546f3c69..051d5cf6f52 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -1,4 +1,5 @@ -import type { MosaicNode } from "react-mosaic-component"; +import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; +import { updateTree } from "react-mosaic-component"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { @@ -35,6 +36,17 @@ interface TabsState { ungroupTab: (tabId: string, targetIndex?: number) => void; ungroupTabs: (groupId: string) => void; + splitTabVertical: ( + workspaceId: string, + sourceTabId?: string, + path?: MosaicBranch[], + ) => void; + splitTabHorizontal: ( + workspaceId: string, + sourceTabId?: string, + path?: MosaicBranch[], + ) => void; + getTabsByWorkspace: (workspaceId: string) => Tab[]; getActiveTab: (workspaceId: string) => Tab | null; getLastActiveTabId: (workspaceId: string) => string | null; @@ -117,6 +129,145 @@ const validateGroupLayouts = (tabs: Tab[]): Tab[] => { }); }; +/** + * Splits a pane within an existing group + */ +const splitPaneInGroup = ( + state: { + tabs: Tab[]; + activeTabIds: Record; + tabHistoryStacks: Record; + }, + tabToSplit: Tab, + workspaceId: string, + path: MosaicBranch[], + direction: "row" | "column", +) => { + const group = state.tabs.find( + (t) => t.id === tabToSplit.parentId && t.type === TabType.Group, + ); + if (!group || group.type !== TabType.Group || !group.layout) return state; + + // Create a new child tab + const newTab = createNewTab(workspaceId, TabType.Single); + const newTabWithParent: Tab = { + ...newTab, + parentId: tabToSplit.parentId, + }; + + // Update the mosaic layout + const newLayout = updateTree(group.layout, [ + { + path, + spec: { + $set: { + direction, + first: tabToSplit.id, + second: newTab.id, + splitPercentage: 50, + }, + }, + }, + ]); + + // Update the group's layout and add the new tab + const updatedTabs = state.tabs.map((tab) => + tab.id === group.id && tab.type === TabType.Group + ? { ...tab, layout: newLayout } + : tab, + ); + + return { + tabs: [...updatedTabs, newTabWithParent], + }; +}; + +/** + * Converts a top-level tab into a group with a split + */ +const convertTabToGroup = ( + state: { + tabs: Tab[]; + activeTabIds: Record; + tabHistoryStacks: Record; + }, + tabToSplit: Tab, + workspaceId: string, + direction: "row" | "column", +) => { + // Create a new group tab + const groupTab: Tab = { + id: `tab-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + title: `${tabToSplit.title} - Split`, + workspaceId, + type: TabType.Group, + layout: null, + isNew: false, + }; + + // Create a new child tab for the new pane + const newChildTab: Tab = { + id: `tab-${Date.now() + 1}-${Math.random().toString(36).substring(2, 11)}`, + title: "New Tab", + workspaceId, + type: TabType.Single, + parentId: groupTab.id, + isNew: true, + }; + + // Update the original tab to be a child of the group + const updatedSourceTab: Tab = { + ...tabToSplit, + parentId: groupTab.id, + }; + + // Create the split layout + const layout: MosaicNode = { + direction, + first: tabToSplit.id, + second: newChildTab.id, + splitPercentage: 50, + }; + + const updatedGroupTab: Tab = { + ...groupTab, + layout, + }; + + // Find the position of the original tab + const workspaceTabs = state.tabs.filter( + (t) => t.workspaceId === workspaceId && !t.parentId, + ); + const sourceTabIndex = workspaceTabs.findIndex((t) => t.id === tabToSplit.id); + + // Replace the source tab with the group and add the new child + const otherTabs = state.tabs.filter((t) => t.id !== tabToSplit.id); + const otherWorkspaceTabs = otherTabs.filter( + (t) => t.workspaceId === workspaceId && !t.parentId, + ); + const nonWorkspaceTabs = otherTabs.filter( + (t) => t.workspaceId !== workspaceId || t.parentId, + ); + + // Insert the group at the original position + otherWorkspaceTabs.splice(sourceTabIndex, 0, updatedGroupTab); + + const newTabs = [ + ...nonWorkspaceTabs, + ...otherWorkspaceTabs, + updatedSourceTab, + newChildTab, + ]; + + return { + tabs: newTabs, + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: updatedGroupTab.id, + }, + }; +}; + /** * Handles the logic for when an empty group needs to be removed * Returns updated state with the group removed and active tab/history updated @@ -181,293 +332,332 @@ export const useTabsStore = create()( activeTabIds: {}, tabHistoryStacks: {}, - addTab: (workspaceId, type = TabType.Single) => { - const newTab = createNewTab(workspaceId, type); - set((state) => { - const currentActiveId = state.activeTabIds[workspaceId]; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - const newHistoryStack = currentActiveId - ? [ - currentActiveId, - ...historyStack.filter((id) => id !== currentActiveId), - ] - : historyStack; - - return { - tabs: [...state.tabs, newTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: newTab.id, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; - }); - }, + addTab: (workspaceId, type = TabType.Single) => { + const newTab = createNewTab(workspaceId, type); + set((state) => { + const currentActiveId = state.activeTabIds[workspaceId]; + const historyStack = state.tabHistoryStacks[workspaceId] || []; + const newHistoryStack = currentActiveId + ? [ + currentActiveId, + ...historyStack.filter((id) => id !== currentActiveId), + ] + : historyStack; + + return { + tabs: [...state.tabs, newTab], + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: newTab.id, + }, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }; + }); + }, - removeTab: (id) => { - const state = get(); - const tabToRemove = state.tabs.find((tab) => tab.id === id); - if (!tabToRemove) return; - - // If this tab is a child of a group, delegate to removeChildTabFromGroup - // which handles empty group cleanup - if (tabToRemove.parentId) { - get().removeChildTabFromGroup(tabToRemove.parentId, id); - return; - } - - // Otherwise, handle as a top-level tab - set((state) => { - const workspaceId = tabToRemove.workspaceId; - const workspaceTabs = state.tabs.filter( - (tab) => tab.workspaceId === workspaceId && tab.id !== id, - ); - const tabs = state.tabs.filter((tab) => tab.id !== id); - - const historyStack = state.tabHistoryStacks[workspaceId] || []; - const newHistoryStack = historyStack.filter((tabId) => tabId !== id); - - const newActiveTabIds = { ...state.activeTabIds }; - if (state.activeTabIds[workspaceId] === id) { - if (workspaceTabs.length > 0) { - const nextTabFromHistory = newHistoryStack.find((tabId) => - workspaceTabs.some((tab) => tab.id === tabId), - ); - if (nextTabFromHistory) { - newActiveTabIds[workspaceId] = nextTabFromHistory; + removeTab: (id) => { + const state = get(); + const tabToRemove = state.tabs.find((tab) => tab.id === id); + if (!tabToRemove) return; + + // Don't allow closing group tabs directly + if (tabToRemove.type === TabType.Group) { + console.error( + "Cannot close group tabs directly. Ungroup the tabs first.", + ); + return; + } + + // If this tab is a child of a group, delegate to removeChildTabFromGroup + // which handles empty group cleanup + if (tabToRemove.parentId) { + get().removeChildTabFromGroup(tabToRemove.parentId, id); + return; + } + + // Otherwise, handle as a top-level tab + set((state) => { + const workspaceId = tabToRemove.workspaceId; + const workspaceTabs = state.tabs.filter( + (tab) => tab.workspaceId === workspaceId && tab.id !== id, + ); + const tabs = state.tabs.filter((tab) => tab.id !== id); + + const historyStack = state.tabHistoryStacks[workspaceId] || []; + const newHistoryStack = historyStack.filter( + (tabId) => tabId !== id, + ); + + const newActiveTabIds = { ...state.activeTabIds }; + if (state.activeTabIds[workspaceId] === id) { + if (workspaceTabs.length > 0) { + const nextTabFromHistory = newHistoryStack.find((tabId) => + workspaceTabs.some((tab) => tab.id === tabId), + ); + if (nextTabFromHistory) { + newActiveTabIds[workspaceId] = nextTabFromHistory; + } else { + const closedIndex = state.tabs + .filter((tab) => tab.workspaceId === workspaceId) + .findIndex((tab) => tab.id === id); + const nextTab = + workspaceTabs[closedIndex] || + workspaceTabs[closedIndex - 1]; + newActiveTabIds[workspaceId] = nextTab.id; + } } else { - const closedIndex = state.tabs - .filter((tab) => tab.workspaceId === workspaceId) - .findIndex((tab) => tab.id === id); - const nextTab = - workspaceTabs[closedIndex] || workspaceTabs[closedIndex - 1]; - newActiveTabIds[workspaceId] = nextTab.id; + newActiveTabIds[workspaceId] = null; } - } else { - newActiveTabIds[workspaceId] = null; } - } - return { - tabs, - activeTabIds: newActiveTabIds, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; - }); - }, - - renameTab: (id, newTitle) => { - set((state) => ({ - tabs: state.tabs.map((tab) => - tab.id === id ? { ...tab, title: newTitle } : tab, - ), - })); - }, + return { + tabs, + activeTabIds: newActiveTabIds, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }; + }); + }, + + renameTab: (id, newTitle) => { + set((state) => ({ + tabs: state.tabs.map((tab) => + tab.id === id ? { ...tab, title: newTitle } : tab, + ), + })); + }, + + setActiveTab: (workspaceId, tabId) => { + set((state) => { + const currentActiveId = state.activeTabIds[workspaceId]; + const historyStack = state.tabHistoryStacks[workspaceId] || []; + + let newHistoryStack = historyStack.filter((id) => id !== tabId); + if (currentActiveId && currentActiveId !== tabId) { + newHistoryStack = [ + currentActiveId, + ...newHistoryStack.filter((id) => id !== currentActiveId), + ]; + } - setActiveTab: (workspaceId, tabId) => { - set((state) => { - const currentActiveId = state.activeTabIds[workspaceId]; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - - let newHistoryStack = historyStack.filter((id) => id !== tabId); - if (currentActiveId && currentActiveId !== tabId) { - newHistoryStack = [ - currentActiveId, - ...newHistoryStack.filter((id) => id !== currentActiveId), - ]; - } + return { + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: tabId, + }, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }; + }); + }, - return { - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: tabId, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; - }); - }, + reorderTabs: (workspaceId, startIndex, endIndex) => { + set((state) => { + const workspaceTabs = state.tabs.filter( + (tab) => tab.workspaceId === workspaceId, + ); + const otherTabs = state.tabs.filter( + (tab) => tab.workspaceId !== workspaceId, + ); - reorderTabs: (workspaceId, startIndex, endIndex) => { - set((state) => { - const workspaceTabs = state.tabs.filter( - (tab) => tab.workspaceId === workspaceId, - ); - const otherTabs = state.tabs.filter( - (tab) => tab.workspaceId !== workspaceId, - ); + const [removed] = workspaceTabs.splice(startIndex, 1); + workspaceTabs.splice(endIndex, 0, removed); - const [removed] = workspaceTabs.splice(startIndex, 1); - workspaceTabs.splice(endIndex, 0, removed); + return { tabs: [...otherTabs, ...workspaceTabs] }; + }); + }, - return { tabs: [...otherTabs, ...workspaceTabs] }; - }); - }, + reorderTabById: (tabId, targetIndex) => { + set((state) => { + const tab = state.tabs.find((t) => t.id === tabId); + if (!tab || tab.parentId) return state; // Only reorder top-level tabs - reorderTabById: (tabId, targetIndex) => { - set((state) => { - const tab = state.tabs.find((t) => t.id === tabId); - if (!tab || tab.parentId) return state; // Only reorder top-level tabs + const workspaceId = tab.workspaceId; + const workspaceTabs = state.tabs.filter( + (t) => t.workspaceId === workspaceId && !t.parentId, + ); + const otherTabs = state.tabs.filter( + (t) => t.workspaceId !== workspaceId || t.parentId, + ); - const workspaceId = tab.workspaceId; - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const otherTabs = state.tabs.filter( - (t) => t.workspaceId !== workspaceId || t.parentId, - ); + const tabToMove = workspaceTabs.find((t) => t.id === tabId); + if (!tabToMove) return state; - const tabToMove = workspaceTabs.find((t) => t.id === tabId); - if (!tabToMove) return state; + const filteredTabs = workspaceTabs.filter((t) => t.id !== tabId); + filteredTabs.splice(targetIndex, 0, tabToMove); - const filteredTabs = workspaceTabs.filter((t) => t.id !== tabId); - filteredTabs.splice(targetIndex, 0, tabToMove); + return { tabs: [...otherTabs, ...filteredTabs] }; + }); + }, + + markTabAsUsed: (id) => { + set((state) => ({ + tabs: state.tabs.map((tab) => + tab.id === id ? { ...tab, isNew: false } : tab, + ), + })); + }, + + updateTabGroupLayout: (id, layout) => { + set((state) => ({ + tabs: state.tabs.map((tab) => + tab.id === id && tab.type === TabType.Group + ? { ...tab, layout } + : tab, + ), + })); + }, + + addChildTabToGroup: (groupId, childTabId) => { + set((state) => { + const updatedTabs = state.tabs.map((tab) => { + if (tab.id === childTabId) { + return { + ...tab, + parentId: groupId, + }; + } + return tab; + }); - return { tabs: [...otherTabs, ...filteredTabs] }; - }); - }, + // Note: This doesn't update layout - caller is responsible for layout updates + // This is typically used in conjunction with updateTabGroupLayout - markTabAsUsed: (id) => { - set((state) => ({ - tabs: state.tabs.map((tab) => - tab.id === id ? { ...tab, isNew: false } : tab, - ), - })); - }, + return { + tabs: updatedTabs, + }; + }); + }, - updateTabGroupLayout: (id, layout) => { - set((state) => ({ - tabs: state.tabs.map((tab) => - tab.id === id && tab.type === TabType.Group - ? { ...tab, layout } - : tab, - ), - })); - }, + removeChildTabFromGroup: (groupId, childTabId) => { + set((state) => { + const group = state.tabs.find( + (tab) => tab.id === groupId && tab.type === TabType.Group, + ); + if (!group || group.type !== TabType.Group) return state; - addChildTabToGroup: (groupId, childTabId) => { - set((state) => { - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === childTabId) { - return { - ...tab, - parentId: groupId, - }; + // Derive children from parentId + const updatedChildTabIds = getChildTabIds( + state.tabs, + groupId, + ).filter((id: string) => id !== childTabId); + + // If no children left, remove both the child and the group + if (updatedChildTabIds.length === 0) { + return handleEmptyGroupRemoval( + state.tabs, + state.activeTabIds, + state.tabHistoryStacks, + group.workspaceId, + [groupId, childTabId], + ); } - return tab; + + // Validate layouts after removing child tab + const validatedTabs = validateGroupLayouts( + state.tabs.filter((tab) => tab.id !== childTabId), + ); + + return { + tabs: validatedTabs, + }; }); + }, - // Note: This doesn't update layout - caller is responsible for layout updates - // This is typically used in conjunction with updateTabGroupLayout + dragTabToTab: (draggedTabId, targetTabId) => { + set((state) => handleDragTabToTab(draggedTabId, targetTabId, state)); + }, - return { - tabs: updatedTabs, - }; - }); - }, + ungroupTab: (tabId, targetIndex) => { + set((state) => { + const tab = state.tabs.find((t) => t.id === tabId); + if (!tab || !tab.parentId) return state; - removeChildTabFromGroup: (groupId, childTabId) => { - set((state) => { - const group = state.tabs.find( - (tab) => tab.id === groupId && tab.type === TabType.Group, - ); - if (!group || group.type !== TabType.Group) return state; - - // Derive children from parentId - const updatedChildTabIds = getChildTabIds(state.tabs, groupId).filter( - (id: string) => id !== childTabId, - ); - - // If no children left, remove both the child and the group - if (updatedChildTabIds.length === 0) { - return handleEmptyGroupRemoval( - state.tabs, - state.activeTabIds, - state.tabHistoryStacks, - group.workspaceId, - [groupId, childTabId], + const parentGroup = state.tabs.find( + (t) => t.id === tab.parentId && t.type === TabType.Group, + ); + if (!parentGroup || parentGroup.type !== TabType.Group) + return state; + + // Remove parentId from the tab + const updatedTab: Tab = { + ...tab, + parentId: undefined, + }; + + // Remove tab from parent's layout + const updatedLayout = removeTabFromLayout( + parentGroup.layout, + tabId, + ) as MosaicNode | null; + + // Get remaining children + const remainingChildren = state.tabs.filter( + (t) => t.parentId === parentGroup.id && t.id !== tabId, ); - } - // Validate layouts after removing child tab - const validatedTabs = validateGroupLayouts( - state.tabs.filter((tab) => tab.id !== childTabId), - ); + const updatedTabs = state.tabs.map((t) => { + if (t.id === tabId) return updatedTab; + if (t.id === parentGroup.id && t.type === TabType.Group) { + return { + ...t, + layout: updatedLayout, + }; + } + return t; + }); + + // If no children left, remove the group + if (remainingChildren.length === 0) { + const result = handleEmptyGroupRemoval( + updatedTabs, + state.activeTabIds, + state.tabHistoryStacks, + tab.workspaceId, + [parentGroup.id], + tabId, // Prefer the ungrouped tab as the new active tab + ); - return { - tabs: validatedTabs, - }; - }); - }, + // Apply reordering if needed + if (targetIndex !== undefined) { + const workspaceTabs = result.tabs.filter( + (t) => t.workspaceId === tab.workspaceId && !t.parentId, + ); + const otherTabs = result.tabs.filter( + (t) => t.workspaceId !== tab.workspaceId || t.parentId, + ); - dragTabToTab: (draggedTabId, targetTabId) => { - set((state) => handleDragTabToTab(draggedTabId, targetTabId, state)); - }, + const tabToMove = workspaceTabs.find((t) => t.id === tabId); + if (tabToMove) { + const filteredTabs = workspaceTabs.filter( + (t) => t.id !== tabId, + ); + filteredTabs.splice(targetIndex, 0, tabToMove); + result.tabs = [...otherTabs, ...filteredTabs]; + } + } - ungroupTab: (tabId, targetIndex) => { - set((state) => { - const tab = state.tabs.find((t) => t.id === tabId); - if (!tab || !tab.parentId) return state; - - const parentGroup = state.tabs.find( - (t) => t.id === tab.parentId && t.type === TabType.Group, - ); - if (!parentGroup || parentGroup.type !== TabType.Group) return state; - - // Remove parentId from the tab - const updatedTab: Tab = { - ...tab, - parentId: undefined, - }; - - // Remove tab from parent's layout - const updatedLayout = removeTabFromLayout( - parentGroup.layout, - tabId, - ) as MosaicNode | null; - - // Get remaining children - const remainingChildren = state.tabs.filter( - (t) => t.parentId === parentGroup.id && t.id !== tabId, - ); - - const updatedTabs = state.tabs.map((t) => { - if (t.id === tabId) return updatedTab; - if (t.id === parentGroup.id && t.type === TabType.Group) { - return { - ...t, - layout: updatedLayout, - }; + return result; } - return t; - }); - // If no children left, remove the group - if (remainingChildren.length === 0) { - const result = handleEmptyGroupRemoval( - updatedTabs, - state.activeTabIds, - state.tabHistoryStacks, - tab.workspaceId, - [parentGroup.id], - tabId, // Prefer the ungrouped tab as the new active tab - ); + // Validate layouts after removing tab + let validatedTabs = validateGroupLayouts(updatedTabs); - // Apply reordering if needed + // Reorder if targetIndex is provided if (targetIndex !== undefined) { - const workspaceTabs = result.tabs.filter( - (t) => t.workspaceId === tab.workspaceId && !t.parentId, + const workspaceId = tab.workspaceId; + const workspaceTabs = validatedTabs.filter( + (t) => t.workspaceId === workspaceId && !t.parentId, ); - const otherTabs = result.tabs.filter( - (t) => t.workspaceId !== tab.workspaceId || t.parentId, + const otherTabs = validatedTabs.filter( + (t) => t.workspaceId !== workspaceId || t.parentId, ); const tabToMove = workspaceTabs.find((t) => t.id === tabId); @@ -476,137 +666,168 @@ export const useTabsStore = create()( (t) => t.id !== tabId, ); filteredTabs.splice(targetIndex, 0, tabToMove); - result.tabs = [...otherTabs, ...filteredTabs]; + validatedTabs = [...otherTabs, ...filteredTabs]; } } - return result; - } + return { + ...state, + tabs: validatedTabs, + }; + }); + }, + + ungroupTabs: (groupId) => { + set((state) => { + const group = state.tabs.find( + (t) => t.id === groupId && t.type === TabType.Group, + ); + if (!group || group.type !== TabType.Group) return state; - // Validate layouts after removing tab - let validatedTabs = validateGroupLayouts(updatedTabs); + // Get all child tabs + const childTabIds = getChildTabIds(state.tabs, groupId); + if (childTabIds.length === 0) return state; - // Reorder if targetIndex is provided - if (targetIndex !== undefined) { - const workspaceId = tab.workspaceId; - const workspaceTabs = validatedTabs.filter( + // Find the group's position in the workspace + const workspaceId = group.workspaceId; + const workspaceTabs = state.tabs.filter( (t) => t.workspaceId === workspaceId && !t.parentId, ); - const otherTabs = validatedTabs.filter( + const groupIndex = workspaceTabs.findIndex((t) => t.id === groupId); + + // Remove parentId from all child tabs + const updatedTabs = state.tabs + .map((tab) => { + if (childTabIds.includes(tab.id)) { + return { + ...tab, + parentId: undefined, + }; + } + return tab; + }) + // Remove the group tab itself + .filter((tab) => tab.id !== groupId); + + // Reorder tabs to place ungrouped tabs where the group was + const newWorkspaceTabs = updatedTabs.filter( + (t) => t.workspaceId === workspaceId && !t.parentId, + ); + const otherTabs = updatedTabs.filter( (t) => t.workspaceId !== workspaceId || t.parentId, ); - const tabToMove = workspaceTabs.find((t) => t.id === tabId); - if (tabToMove) { - const filteredTabs = workspaceTabs.filter((t) => t.id !== tabId); - filteredTabs.splice(targetIndex, 0, tabToMove); - validatedTabs = [...otherTabs, ...filteredTabs]; - } - } + // Get the ungrouped child tabs + const ungroupedTabs = newWorkspaceTabs.filter((t) => + childTabIds.includes(t.id), + ); + // Get tabs that are not the ungrouped children + const nonUngroupedTabs = newWorkspaceTabs.filter( + (t) => !childTabIds.includes(t.id), + ); - return { - ...state, - tabs: validatedTabs, - }; - }); - }, + // Insert ungrouped tabs at the group's original position + nonUngroupedTabs.splice(groupIndex, 0, ...ungroupedTabs); - ungroupTabs: (groupId) => { - set((state) => { - const group = state.tabs.find( - (t) => t.id === groupId && t.type === TabType.Group, - ); - if (!group || group.type !== TabType.Group) return state; - - // Get all child tabs - const childTabIds = getChildTabIds(state.tabs, groupId); - if (childTabIds.length === 0) return state; - - // Find the group's position in the workspace - const workspaceId = group.workspaceId; - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const groupIndex = workspaceTabs.findIndex((t) => t.id === groupId); - - // Remove parentId from all child tabs - const updatedTabs = state.tabs - .map((tab) => { - if (childTabIds.includes(tab.id)) { - return { - ...tab, - parentId: undefined, - }; + const finalTabs = [...otherTabs, ...nonUngroupedTabs]; + + // Clean up active tab and history if the group was active + const currentActiveId = state.activeTabIds[workspaceId]; + const historyStack = state.tabHistoryStacks[workspaceId] || []; + const newHistoryStack = historyStack.filter((id) => id !== groupId); + + const newActiveTabIds = { ...state.activeTabIds }; + if (currentActiveId === groupId) { + // Set the first ungrouped tab as active + if (ungroupedTabs.length > 0) { + newActiveTabIds[workspaceId] = ungroupedTabs[0].id; + } else if (nonUngroupedTabs.length > 0) { + newActiveTabIds[workspaceId] = nonUngroupedTabs[0].id; + } else { + newActiveTabIds[workspaceId] = null; } - return tab; - }) - // Remove the group tab itself - .filter((tab) => tab.id !== groupId); - - // Reorder tabs to place ungrouped tabs where the group was - const newWorkspaceTabs = updatedTabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const otherTabs = updatedTabs.filter( - (t) => t.workspaceId !== workspaceId || t.parentId, - ); - - // Get the ungrouped child tabs - const ungroupedTabs = newWorkspaceTabs.filter((t) => - childTabIds.includes(t.id), - ); - // Get tabs that are not the ungrouped children - const nonUngroupedTabs = newWorkspaceTabs.filter( - (t) => !childTabIds.includes(t.id), - ); - - // Insert ungrouped tabs at the group's original position - nonUngroupedTabs.splice(groupIndex, 0, ...ungroupedTabs); - - const finalTabs = [...otherTabs, ...nonUngroupedTabs]; - - // Clean up active tab and history if the group was active - const currentActiveId = state.activeTabIds[workspaceId]; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - const newHistoryStack = historyStack.filter((id) => id !== groupId); - - const newActiveTabIds = { ...state.activeTabIds }; - if (currentActiveId === groupId) { - // Set the first ungrouped tab as active - if (ungroupedTabs.length > 0) { - newActiveTabIds[workspaceId] = ungroupedTabs[0].id; - } else if (nonUngroupedTabs.length > 0) { - newActiveTabIds[workspaceId] = nonUngroupedTabs[0].id; - } else { - newActiveTabIds[workspaceId] = null; } - } - return { - tabs: finalTabs, - activeTabIds: newActiveTabIds, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; - }); - }, + return { + tabs: finalTabs, + activeTabIds: newActiveTabIds, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }; + }); + }, + + getTabsByWorkspace: (workspaceId) => { + return get().tabs.filter((tab) => tab.workspaceId === workspaceId); + }, + + getActiveTab: (workspaceId) => { + const activeTabId = get().activeTabIds[workspaceId]; + if (!activeTabId) return null; + return get().tabs.find((tab) => tab.id === activeTabId) || null; + }, + + getLastActiveTabId: (workspaceId) => { + const historyStack = get().tabHistoryStacks[workspaceId] || []; + return historyStack[0] || null; + }, + splitTabVertical: (workspaceId, sourceTabId, path) => { + set((state) => { + // Use provided sourceTabId or get the active tab + const tabToSplit = sourceTabId + ? state.tabs.find((t) => t.id === sourceTabId) + : state.tabs.find( + (t) => + t.id === state.activeTabIds[workspaceId] && !t.parentId, + ); - getTabsByWorkspace: (workspaceId) => { - return get().tabs.filter((tab) => tab.workspaceId === workspaceId); - }, + if (!tabToSplit || tabToSplit.type === TabType.Group) return state; - getActiveTab: (workspaceId) => { - const activeTabId = get().activeTabIds[workspaceId]; - if (!activeTabId) return null; - return get().tabs.find((tab) => tab.id === activeTabId) || null; - }, + // Check if this tab is within a group (has a parentId) and path is provided + if (tabToSplit.parentId && path) { + return splitPaneInGroup( + state, + tabToSplit, + workspaceId, + path, + "row", + ); + } - getLastActiveTabId: (workspaceId) => { - const historyStack = get().tabHistoryStacks[workspaceId] || []; - return historyStack[0] || null; - }, + // Convert top-level tab into a group + return convertTabToGroup(state, tabToSplit, workspaceId, "row"); + }); + }, + + splitTabHorizontal: (workspaceId, sourceTabId, path) => { + set((state) => { + // Use provided sourceTabId or get the active tab + const tabToSplit = sourceTabId + ? state.tabs.find((t) => t.id === sourceTabId) + : state.tabs.find( + (t) => + t.id === state.activeTabIds[workspaceId] && !t.parentId, + ); + + if (!tabToSplit || tabToSplit.type === TabType.Group) return state; + + // Check if this tab is within a group (has a parentId) and path is provided + if (tabToSplit.parentId && path) { + return splitPaneInGroup( + state, + tabToSplit, + workspaceId, + path, + "column", + ); + } + + // Convert top-level tab into a group + return convertTabToGroup(state, tabToSplit, workspaceId, "column"); + }); + }, }), { name: "tabs-storage", @@ -633,3 +854,7 @@ export const useMarkTabAsUsed = () => useTabsStore((state) => state.markTabAsUsed); export const useUngroupTab = () => useTabsStore((state) => state.ungroupTab); export const useUngroupTabs = () => useTabsStore((state) => state.ungroupTabs); +export const useSplitTabVertical = () => + useTabsStore((state) => state.splitTabVertical); +export const useSplitTabHorizontal = () => + useTabsStore((state) => state.splitTabHorizontal); diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 987d053c4e2..69b2915636b 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -14,6 +14,7 @@ "allowJs": true, "allowSyntheticDefaultImports": true, "skipLibCheck": true, + "types": ["bun-types"], "forceConsistentCasingInFileNames": true, "noEmit": true, "baseUrl": ".",