From d311ab0c0304c472b48b08c63129ee4be0c64edf Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 1 Dec 2025 15:55:22 -0800 Subject: [PATCH 1/5] Refactor tabs to windows/panes model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor from "tabs can be singles OR groups" to "windows always contain panes" model - Windows now always have ≥1 pane (invariant enforced at creation time) - Remove ungroup concepts - panes can only be split/closed - Simplify sidebar to show only windows (no expandable children) - Drag-and-drop now only reorders windows in sidebar - Window names are now static at creation time (e.g., "Window 5" stays "Window 5") - Add validation in setActiveWindow and split operations - Add efficient useWindowNeedsAttention selector hook - Delete ~3,200 lines of unused code (old tab grouping logic, tests, helpers) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../workspaces/useCreateWorkspace.ts | 12 +- .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 35 +- .../ContentView/TabsContent/DropOverlay.tsx | 13 - .../TabsContent/GroupTabView/GroupTabPane.tsx | 77 -- .../TabsContent/GroupTabView/index.tsx | 149 --- .../ContentView/TabsContent/SingleTabView.tsx | 49 - .../TabsContent/Terminal/Terminal.tsx | 59 +- .../TabsContent/Terminal/helpers.ts | 12 +- .../TabsContent/WindowView/WindowPane.tsx | 80 ++ .../TabsContent/WindowView/index.tsx | 137 +++ .../mosaic-theme.css | 0 .../ContentView/TabsContent/index.tsx | 44 +- .../TabsContent/useTabContentDrop.ts | 36 - .../TabsView/TabItem/TabContextMenu.tsx | 62 - .../Sidebar/TabsView/TabItem/index.tsx | 197 --- .../Sidebar/TabsView/TabItem/types.ts | 13 - .../Sidebar/TabsView/TabItem/useDragTab.ts | 42 - .../Sidebar/TabsView/TabItem/useGroupDrop.ts | 30 - .../Sidebar/TabsView/TabItem/useTabRename.ts | 61 - .../TabsView/UngroupDropZone/index.tsx | 101 -- .../TabsView/WindowItem/WindowContextMenu.tsx | 33 + .../Sidebar/TabsView/WindowItem/index.tsx | 174 +++ .../WorkspaceView/Sidebar/TabsView/index.tsx | 183 ++- .../main/components/WorkspaceView/index.tsx | 70 +- .../src/renderer/screens/main/index.tsx | 28 +- .../renderer/stores/tabs/drag-logic.test.ts | 1081 ----------------- .../src/renderer/stores/tabs/drag-logic.ts | 467 ------- .../stores/tabs/helpers/active-tab.ts | 61 - .../tabs/helpers/group-operations.test.ts | 247 ---- .../stores/tabs/helpers/group-operations.ts | 311 ----- .../tabs/helpers/next-tab-finder.test.ts | 547 --------- .../stores/tabs/helpers/next-tab-finder.ts | 130 -- .../stores/tabs/helpers/split-operations.ts | 297 ----- .../renderer/stores/tabs/helpers/tab-crud.ts | 99 -- .../stores/tabs/helpers/tab-ordering.ts | 44 - .../stores/tabs/helpers/validation.ts | 23 - .../desktop/src/renderer/stores/tabs/hooks.ts | 75 +- .../desktop/src/renderer/stores/tabs/index.ts | 1 - .../src/renderer/stores/tabs/store.test.ts | 776 ------------ .../desktop/src/renderer/stores/tabs/store.ts | 597 +++++++-- .../desktop/src/renderer/stores/tabs/types.ts | 114 +- .../stores/tabs/useAgentHookListener.ts | 38 +- .../desktop/src/renderer/stores/tabs/utils.ts | 228 +++- 43 files changed, 1538 insertions(+), 5295 deletions(-) delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/DropOverlay.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/index.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/WindowPane.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/index.tsx rename apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/{GroupTabView => WindowView}/mosaic-theme.css (100%) delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/useTabContentDrop.ts delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/TabContextMenu.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/types.ts delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useDragTab.ts delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useGroupDrop.ts delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useTabRename.ts delete mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/UngroupDropZone/index.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/WindowContextMenu.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/index.tsx delete mode 100644 apps/desktop/src/renderer/stores/tabs/drag-logic.test.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/drag-logic.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/helpers/active-tab.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/helpers/group-operations.test.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/helpers/group-operations.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/helpers/next-tab-finder.test.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/helpers/next-tab-finder.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/helpers/split-operations.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/helpers/tab-ordering.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/helpers/validation.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/store.test.ts diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts index 171f98c04ed..d1ac7f73d52 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -1,19 +1,19 @@ import { toast } from "@superset/ui/sonner"; import { trpc } from "renderer/lib/trpc"; import { useOpenConfigModal } from "renderer/stores/config-modal"; -import { useTabsStore } from "renderer/stores/tabs/store"; +import { useWindowsStore } from "renderer/stores/tabs/store"; /** * Mutation hook for creating a new workspace * Automatically invalidates all workspace queries on success - * Creates a terminal tab with setup commands if present + * Creates a terminal window with setup commands if present * Shows config toast if no setup commands are configured */ export function useCreateWorkspace( options?: Parameters[0], ) { const utils = trpc.useUtils(); - const addTab = useTabsStore((state) => state.addTab); + const addWindow = useWindowsStore((state) => state.addWindow); const createOrAttach = trpc.terminal.createOrAttach.useMutation(); const openConfigModal = useOpenConfigModal(); const dismissConfigToast = trpc.config.dismissConfigToast.useMutation(); @@ -24,16 +24,16 @@ export function useCreateWorkspace( // Auto-invalidate all workspace queries await utils.workspaces.invalidate(); - // Create terminal tab with setup commands if present + // Create terminal window with setup commands if present if ( Array.isArray(data.initialCommands) && data.initialCommands.length > 0 ) { - const tabId = addTab(data.workspace.id); + const { paneId } = addWindow(data.workspace.id); // Pre-create terminal session with initial commands // Terminal component will attach to this session when it mounts createOrAttach.mutate({ - tabId, + tabId: paneId, workspaceId: data.workspace.id, tabTitle: "Terminal", initialCommands: data.initialCommands, diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx index e8825d100f6..e94aab90b5e 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -8,7 +8,7 @@ import { useReorderWorkspaces, useSetActiveWorkspace, } from "renderer/react-query/workspaces"; -import { useTabs } from "renderer/stores"; +import { usePanes, useWindows } from "renderer/stores"; import { useCloseSettings } from "renderer/stores/app-state"; import { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; import { useWorkspaceRename } from "./useWorkspaceRename"; @@ -43,12 +43,37 @@ export function WorkspaceItem({ const reorderWorkspaces = useReorderWorkspaces(); const closeSettings = useCloseSettings(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const tabs = useTabs(); + const windows = useWindows(); + const panes = usePanes(); const rename = useWorkspaceRename(id, title); - const needsAttention = tabs - .filter((t) => t.workspaceId === id) - .some((t) => t.needsAttention); + // Check if any pane in windows belonging to this workspace needs attention + const workspaceWindows = windows.filter((w) => w.workspaceId === id); + const workspacePaneIds = new Set( + workspaceWindows.flatMap((w) => { + // Extract pane IDs from the layout (which is a MosaicNode) + const collectPaneIds = (node: unknown): string[] => { + if (typeof node === "string") return [node]; + if ( + node && + typeof node === "object" && + "first" in node && + "second" in node + ) { + const branch = node as { first: unknown; second: unknown }; + return [ + ...collectPaneIds(branch.first), + ...collectPaneIds(branch.second), + ]; + } + return []; + }; + return collectPaneIds(w.layout); + }), + ); + const needsAttention = Object.values(panes) + .filter((p) => workspacePaneIds.has(p.id)) + .some((p) => p.needsAttention); const [{ isDragging }, drag] = useDrag( () => ({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/DropOverlay.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/DropOverlay.tsx deleted file mode 100644 index eec0a8f4993..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/DropOverlay.tsx +++ /dev/null @@ -1,13 +0,0 @@ -interface DropOverlayProps { - message: string; -} - -export function DropOverlay({ message }: DropOverlayProps) { - return ( -
-
-

{message}

-
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx deleted file mode 100644 index 73d2531ab97..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { HiMiniXMark } from "react-icons/hi2"; -import type { MosaicBranch } from "react-mosaic-component"; -import { MosaicWindow } from "react-mosaic-component"; -import type { Tab } from "renderer/stores"; -import { TabContentContextMenu } from "../TabContentContextMenu"; -import { Terminal } from "../Terminal"; - -interface GroupTabPaneProps { - tabId: string; - path: MosaicBranch[]; - childTab: Tab; - isActive: boolean; - workspaceId: string; - groupId: string; - splitTabHorizontal: ( - workspaceId: string, - sourceTabId?: string, - path?: MosaicBranch[], - ) => void; - splitTabVertical: ( - workspaceId: string, - sourceTabId?: string, - path?: MosaicBranch[], - ) => void; - removeChildTabFromGroup: (groupId: string, tabId: string) => void; - setActiveTab: (workspaceId: string, tabId: string) => void; -} - -export function GroupTabPane({ - tabId, - path, - childTab, - isActive, - workspaceId, - groupId, - splitTabHorizontal, - splitTabVertical, - removeChildTabFromGroup, - setActiveTab, -}: GroupTabPaneProps) { - const _handleFocus = () => { - setActiveTab(workspaceId, tabId); - }; - - const handleCloseTab = (e: React.MouseEvent) => { - e.stopPropagation(); - removeChildTabFromGroup(groupId, tabId); - }; - - return ( - - path={path} - title={childTab.title} - toolbarControls={ - - } - className={isActive ? "mosaic-window-focused" : ""} - > - splitTabHorizontal(workspaceId, tabId, path)} - onSplitVertical={() => splitTabVertical(workspaceId, tabId, path)} - onClosePane={() => removeChildTabFromGroup(groupId, tabId)} - > -
- -
-
- - ); -} 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 deleted file mode 100644 index 7cb555e5b81..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/index.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import "react-mosaic-component/react-mosaic-component.css"; -import "./mosaic-theme.css"; - -import { useCallback } from "react"; -import { - Mosaic, - type MosaicBranch, - type MosaicNode, -} from "react-mosaic-component"; -import { dragDropManager } from "renderer/lib/dnd"; -import { - cleanLayout, - getChildTabIds, - type TabGroup, - useActiveTabIds, - useSetActiveTab, - useSplitTabHorizontal, - useSplitTabVertical, - useTabs, - useTabsStore, -} from "renderer/stores"; -import { GroupTabPane } from "./GroupTabPane"; - -interface GroupTabViewProps { - tab: TabGroup; -} - -function extractTabIdsFromLayout( - layout: MosaicNode | null, -): Set { - const ids = new Set(); - - if (!layout) return ids; - - if (typeof layout === "string") { - ids.add(layout); - } else { - const firstIds = extractTabIdsFromLayout(layout.first); - const secondIds = extractTabIdsFromLayout(layout.second); - for (const id of firstIds) ids.add(id); - for (const id of secondIds) ids.add(id); - } - - return ids; -} - -export function GroupTabView({ tab }: GroupTabViewProps) { - const allTabs = useTabs(); - const childTabIds = getChildTabIds(allTabs, tab.id); - const childTabs = allTabs.filter((t) => childTabIds.includes(t.id)); - const updateTabGroupLayout = useTabsStore( - (state) => state.updateTabGroupLayout, - ); - const removeChildTabFromGroup = useTabsStore( - (state) => state.removeChildTabFromGroup, - ); - const splitTabHorizontal = useSplitTabHorizontal(); - const splitTabVertical = useSplitTabVertical(); - const setActiveTab = useSetActiveTab(); - const activeTabIds = useActiveTabIds(); - const activeTabId = activeTabIds[tab.workspaceId]; - - const validTabIds = new Set(childTabIds); - const cleanedLayout = cleanLayout(tab.layout, validTabIds); - - const handleLayoutChange = useCallback( - (newLayout: MosaicNode | null) => { - const oldTabIds = extractTabIdsFromLayout(tab.layout); - const newTabIds = extractTabIdsFromLayout(newLayout); - - const removedTabIds = Array.from(oldTabIds).filter( - (id) => !newTabIds.has(id), - ); - - for (const removedId of removedTabIds) { - removeChildTabFromGroup(tab.id, removedId); - } - - if (newLayout) { - updateTabGroupLayout(tab.id, newLayout); - } - }, - [tab.id, tab.layout, updateTabGroupLayout, 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 ( - - ); - }, - [ - activeTabId, - childTabs, - splitTabHorizontal, - splitTabVertical, - removeChildTabFromGroup, - setActiveTab, - tab.workspaceId, - tab.id, - ], - ); - - if (childTabs.length === 0 || !cleanedLayout) { - return ( -
-
-

No panes in this group

-

- Create a new pane to get started -

-
-
- ); - } - - return ( -
- - renderTile={renderPane} - value={cleanedLayout} - onChange={handleLayoutChange} - className="mosaic-theme-dark" - dragAndDropManager={dragDropManager} - /> -
- ); -} 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 deleted file mode 100644 index 61c8d67e52b..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { - type SingleTab, - useRemoveTab, - useSetActiveTab, - useSplitTabHorizontal, - useSplitTabVertical, -} from "renderer/stores"; -import { TabContentContextMenu } from "./TabContentContextMenu"; -import { Terminal } from "./Terminal"; - -interface SingleTabViewProps { - tab: SingleTab; - isDropZone: boolean; -} - -export function SingleTabView({ tab }: SingleTabViewProps) { - const splitTabHorizontal = useSplitTabHorizontal(); - const splitTabVertical = useSplitTabVertical(); - const removeTab = useRemoveTab(); - const setActiveTab = useSetActiveTab(); - - const handleSplitHorizontal = () => { - splitTabHorizontal(tab.workspaceId, tab.id); - }; - - const handleSplitVertical = () => { - splitTabVertical(tab.workspaceId, tab.id); - }; - - const handleClosePane = () => { - removeTab(tab.id); - }; - - const _handleFocus = () => { - setActiveTab(tab.workspaceId, tab.id); - }; - - return ( - -
- -
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 7568a42dff5..662a61b2e53 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -1,9 +1,9 @@ import "@xterm/xterm/css/xterm.css"; import type { FitAddon } from "@xterm/addon-fit"; import type { Terminal as XTerm } from "@xterm/xterm"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { trpc } from "renderer/lib/trpc"; -import { useSetActiveTab, useTabs, useTerminalTheme } from "renderer/stores"; +import { usePanes, useSetFocusedPane, useTerminalTheme } from "renderer/stores"; import { createTerminalInstance, getDefaultTerminalBg, @@ -15,16 +15,18 @@ import type { TerminalProps, TerminalStreamEvent } from "./types"; import { shellEscapePaths } from "./utils"; export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { - const tabs = useTabs(); - const tab = tabs.find((t) => t.id === tabId); - const tabTitle = tab?.title || "Terminal"; + // tabId is actually paneId in the new model + const paneId = tabId; + const panes = usePanes(); + const pane = panes[paneId]; + const paneName = pane?.name || "Terminal"; const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const isExitedRef = useRef(false); const pendingEventsRef = useRef([]); const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); - const setActiveTab = useSetActiveTab(); + const setFocusedPane = useSetFocusedPane(); const terminalTheme = useTerminalTheme(); // Required for resolving relative file paths in terminal commands @@ -72,12 +74,20 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { } }; - trpc.terminal.stream.useSubscription(tabId, { + // Use paneId (tabId) for stream subscription + trpc.terminal.stream.useSubscription(paneId, { onData: handleStreamData, // Always listen to prevent missing events during initialization enabled: true, }); + // Handler to set focused pane when terminal gains focus + const handleTerminalFocus = useCallback(() => { + if (pane?.windowId) { + setFocusedPane(pane.windowId, paneId); + } + }, [pane?.windowId, paneId, setFocusedPane]); + useEffect(() => { const container = terminalRef.current; if (!container) return; @@ -124,9 +134,9 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { xterm.clear(); createOrAttachRef.current( { - tabId, + tabId: paneId, workspaceId, - tabTitle, + tabTitle: paneName, cols: xterm.cols, rows: xterm.rows, }, @@ -147,15 +157,15 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { if (isExitedRef.current) { restartTerminal(); } else { - writeRef.current({ tabId, data }); + writeRef.current({ tabId: paneId, data }); } }; createOrAttachRef.current( { - tabId, + tabId: paneId, workspaceId, - tabTitle, + tabTitle: paneName, cols: xterm.cols, rows: xterm.rows, }, @@ -182,23 +192,19 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { onShiftEnter: () => { if (!isExitedRef.current) { // Use shell's native continuation syntax to avoid shell-specific parsing - writeRef.current({ tabId, data: "\\\n" }); + writeRef.current({ tabId: paneId, data: "\\\n" }); } }, }); - const cleanupFocus = setupFocusListener( - xterm, - workspaceId, - tabId, - setActiveTab, - ); + // Setup focus listener to track focused pane + const cleanupFocus = setupFocusListener(xterm, handleTerminalFocus); const cleanupResize = setupResizeHandlers( container, xterm, fitAddon, (cols, rows) => { - resizeRef.current({ tabId, cols, rows }); + resizeRef.current({ tabId: paneId, cols, rows }); }, ); @@ -208,12 +214,19 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { cleanupResize(); cleanupQuerySuppression(); // Keep PTY running for reattachment - detachRef.current({ tabId }); + detachRef.current({ tabId: paneId }); setSubscriptionEnabled(false); xterm.dispose(); xtermRef.current = null; }; - }, [tabId, workspaceId, setActiveTab, workspaceCwd, tabTitle, terminalTheme]); + }, [ + paneId, + workspaceId, + workspaceCwd, + paneName, + terminalTheme, + handleTerminalFocus, + ]); // Sync theme changes to xterm instance for live theme switching useEffect(() => { @@ -242,7 +255,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const text = shellEscapePaths(paths); if (!isExitedRef.current) { - writeRef.current({ tabId, data: text }); + writeRef.current({ tabId: paneId, data: text }); } }; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 945e591eef8..1a1a3bec35c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -187,21 +187,15 @@ export function setupKeyboardHandler( export function setupFocusListener( xterm: XTerm, - workspaceId: string, - tabId: string, - setActiveTab: (workspaceId: string, tabId: string) => void, + onFocus: () => void, ): (() => void) | null { const textarea = xterm.textarea; if (!textarea) return null; - const handleFocus = () => { - setActiveTab(workspaceId, tabId); - }; - - textarea.addEventListener("focus", handleFocus); + textarea.addEventListener("focus", onFocus); return () => { - textarea.removeEventListener("focus", handleFocus); + textarea.removeEventListener("focus", onFocus); }; } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/WindowPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/WindowPane.tsx new file mode 100644 index 00000000000..a6aed788947 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/WindowPane.tsx @@ -0,0 +1,80 @@ +import { Button } from "@superset/ui/button"; +import { HiMiniXMark } from "react-icons/hi2"; +import type { MosaicBranch } from "react-mosaic-component"; +import { MosaicWindow } from "react-mosaic-component"; +import type { Pane } from "renderer/stores/tabs/types"; +import { TabContentContextMenu } from "../TabContentContextMenu"; +import { Terminal } from "../Terminal"; + +interface WindowPaneProps { + paneId: string; + path: MosaicBranch[]; + pane: Pane; + isActive: boolean; + windowId: string; + workspaceId: string; + splitPaneHorizontal: ( + windowId: string, + sourcePaneId: string, + path?: MosaicBranch[], + ) => void; + splitPaneVertical: ( + windowId: string, + sourcePaneId: string, + path?: MosaicBranch[], + ) => void; + removePane: (paneId: string) => void; + setFocusedPane: (windowId: string, paneId: string) => void; +} + +export function WindowPane({ + paneId, + path, + pane, + isActive, + windowId, + workspaceId, + splitPaneHorizontal, + splitPaneVertical, + removePane, + setFocusedPane, +}: WindowPaneProps) { + const handleFocus = () => { + setFocusedPane(windowId, paneId); + }; + + const handleClosePane = (e: React.MouseEvent) => { + e.stopPropagation(); + removePane(paneId); + }; + + return ( + + path={path} + title={pane.name} + toolbarControls={ + + } + className={isActive ? "mosaic-window-focused" : ""} + > + splitPaneHorizontal(windowId, paneId, path)} + onSplitVertical={() => splitPaneVertical(windowId, paneId, path)} + onClosePane={() => removePane(paneId)} + > + {/* biome-ignore lint/a11y/useKeyWithClickEvents lint/a11y/noStaticElementInteractions: Terminal handles its own keyboard events and focus */} +
+ +
+
+ + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/index.tsx new file mode 100644 index 00000000000..f0a30a2d6ee --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/index.tsx @@ -0,0 +1,137 @@ +import "react-mosaic-component/react-mosaic-component.css"; +import "./mosaic-theme.css"; + +import { useCallback, useEffect } from "react"; +import { + Mosaic, + type MosaicBranch, + type MosaicNode, +} from "react-mosaic-component"; +import { dragDropManager } from "renderer/lib/dnd"; +import { + useFocusedPaneIds, + useRemovePane, + useRemoveWindow, + useSetFocusedPane, + useSplitPaneHorizontal, + useSplitPaneVertical, + useUpdateWindowLayout, +} from "renderer/stores"; +import type { Pane, Window } from "renderer/stores/tabs/types"; +import { + cleanLayout, + extractPaneIdsFromLayout, + getPaneIdsForWindow, +} from "renderer/stores/tabs/utils"; +import { WindowPane } from "./WindowPane"; + +interface WindowViewProps { + window: Window; + panes: Record; +} + +export function WindowView({ window, panes }: WindowViewProps) { + const updateWindowLayout = useUpdateWindowLayout(); + const removePane = useRemovePane(); + const removeWindow = useRemoveWindow(); + const splitPaneHorizontal = useSplitPaneHorizontal(); + const splitPaneVertical = useSplitPaneVertical(); + const setFocusedPane = useSetFocusedPane(); + const focusedPaneIds = useFocusedPaneIds(); + + const focusedPaneId = focusedPaneIds[window.id]; + + // Get valid pane IDs for this window + const validPaneIds = new Set(getPaneIdsForWindow(panes, window.id)); + const cleanedLayout = cleanLayout(window.layout, validPaneIds); + + // Auto-remove window when all panes are gone + useEffect(() => { + if (!cleanedLayout) { + removeWindow(window.id); + } + }, [cleanedLayout, removeWindow, window.id]); + + const handleLayoutChange = useCallback( + (newLayout: MosaicNode | null) => { + if (!newLayout) { + // This shouldn't happen as we handle last pane removal in removePane + return; + } + + const oldPaneIds = extractPaneIdsFromLayout(window.layout); + const newPaneIds = extractPaneIdsFromLayout(newLayout); + + // Find removed panes (e.g., from Mosaic close button) + const removedPaneIds = oldPaneIds.filter( + (id) => !newPaneIds.includes(id), + ); + + // Remove panes that were removed via Mosaic UI + for (const removedId of removedPaneIds) { + removePane(removedId); + } + + // Update the layout + updateWindowLayout(window.id, newLayout); + }, + [window.id, window.layout, updateWindowLayout, removePane], + ); + + const renderPane = useCallback( + (paneId: string, path: MosaicBranch[]) => { + const pane = panes[paneId]; + const isActive = paneId === focusedPaneId; + + if (!pane) { + return ( +
+ Pane not found: {paneId} +
+ ); + } + + return ( + + ); + }, + [ + panes, + focusedPaneId, + window.id, + window.workspaceId, + splitPaneHorizontal, + splitPaneVertical, + removePane, + setFocusedPane, + ], + ); + + // Window will be removed by useEffect above + if (!cleanedLayout) { + return null; + } + + return ( +
+ + renderTile={renderPane} + value={cleanedLayout} + onChange={handleLayoutChange} + className="mosaic-theme-dark" + dragAndDropManager={dragDropManager} + /> +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/mosaic-theme.css b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/mosaic-theme.css similarity index 100% rename from apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/mosaic-theme.css rename to apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/mosaic-theme.css diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index d25fc6b90dc..8f9118b65d4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -1,51 +1,35 @@ import { useMemo } from "react"; import { trpc } from "renderer/lib/trpc"; -import { TabType, useActiveTabIds, useTabs } from "renderer/stores"; -import { DropOverlay } from "./DropOverlay"; +import { useActiveWindowIds, usePanes, useWindows } from "renderer/stores"; import { EmptyTabView } from "./EmptyTabView"; -import { GroupTabView } from "./GroupTabView"; -import { SingleTabView } from "./SingleTabView"; -import { useTabContentDrop } from "./useTabContentDrop"; +import { WindowView } from "./WindowView"; export function TabsContent() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id; - const allTabs = useTabs(); - const activeTabIds = useActiveTabIds(); + const allWindows = useWindows(); + const panes = usePanes(); + const activeWindowIds = useActiveWindowIds(); - const tabToRender = useMemo(() => { + const windowToRender = useMemo(() => { if (!activeWorkspaceId) return null; - const activeTabId = activeTabIds[activeWorkspaceId]; - if (!activeTabId) return null; + const activeWindowId = activeWindowIds[activeWorkspaceId]; + if (!activeWindowId) return null; - const activeTab = allTabs.find((tab) => tab.id === activeTabId); - if (!activeTab) return null; + return allWindows.find((win) => win.id === activeWindowId) || null; + }, [activeWorkspaceId, activeWindowIds, allWindows]); - if (activeTab.parentId) { - const parentGroup = allTabs.find((tab) => tab.id === activeTab.parentId); - return parentGroup || null; - } - - return activeTab; - }, [activeWorkspaceId, activeTabIds, allTabs]); - - const { isDropZone, attachDrop } = useTabContentDrop(tabToRender); - - if (!tabToRender) { + if (!windowToRender) { return ( -
+
); } return ( -
- {tabToRender.type === TabType.Single && ( - - )} - {tabToRender.type === TabType.Group && } - {isDropZone && } +
+
); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/useTabContentDrop.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/useTabContentDrop.ts deleted file mode 100644 index c86fb150e23..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/useTabContentDrop.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useDrop } from "react-dnd"; -import type { Tab } from "renderer/stores"; -import { useTabsStore } from "renderer/stores"; -import { type DragItem, TAB_DND_TYPE } from "./types"; - -export function useTabContentDrop(tabToRender: Tab | null) { - const dragTabToTab = useTabsStore((state) => state.dragTabToTab); - - const [{ isOver, canDrop }, drop] = useDrop< - DragItem, - void, - { isOver: boolean; canDrop: boolean } - >({ - accept: TAB_DND_TYPE, - drop: (item) => { - if (tabToRender) { - dragTabToTab(item.tabId, tabToRender.id); - } - }, - canDrop: () => { - return tabToRender !== null; - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }); - - const isDropZone = isOver && canDrop; - - const attachDrop = (node: HTMLDivElement | null) => { - if (node) drop(node); - }; - - return { isDropZone, attachDrop }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/TabContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/TabContextMenu.tsx deleted file mode 100644 index 48f760e658a..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/TabContextMenu.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { - ContextMenu, - ContextMenuContent, - ContextMenuItem, - ContextMenuSeparator, - ContextMenuTrigger, -} from "@superset/ui/context-menu"; -import type { ReactNode } from "react"; -import { TabType } from "renderer/stores/tabs/types"; - -interface TabContextMenuProps { - children: ReactNode; - tabId: string; - tabType: TabType; - hasParent?: boolean; - onClose: () => void; - onRename: () => void; - onUngroup?: () => void; - onMoveOutOfGroup?: () => void; -} - -export function TabContextMenu({ - children, - tabType, - hasParent = false, - onClose, - onRename, - onUngroup, - onMoveOutOfGroup, -}: TabContextMenuProps) { - const isGroupTab = tabType === TabType.Group; - - return ( - - {children} - - {isGroupTab ? ( - <> - Rename Group - Ungroup Tabs - - ) : ( - <> - Rename Tab - {hasParent && onMoveOutOfGroup && ( - <> - - - Move Out of Group - - - )} - - - Close Tab - - - )} - - - ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx deleted file mode 100644 index 20eba3dadb7..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { Button } from "@superset/ui/button"; -import { Input } from "@superset/ui/input"; -import { useState } from "react"; -import { HiChevronRight, HiMiniXMark } from "react-icons/hi2"; -import { trpc } from "renderer/lib/trpc"; -import { - useActiveTabIds, - useRemoveTab, - useSetActiveTab, - useTabs, - useUngroupTab, - useUngroupTabs, -} from "renderer/stores"; -import { TabType } from "renderer/stores/tabs/types"; -import { TabContextMenu } from "./TabContextMenu"; -import type { TabItemProps } from "./types"; -import { useDragTab } from "./useDragTab"; -import { useGroupDrop } from "./useGroupDrop"; -import { useTabRename } from "./useTabRename"; - -export function TabItem({ tab, childTabs = [] }: TabItemProps) { - const [isExpanded, setIsExpanded] = useState(true); - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const activeWorkspaceId = activeWorkspace?.id; - const activeTabIds = useActiveTabIds(); - const removeTab = useRemoveTab(); - const setActiveTab = useSetActiveTab(); - const ungroupTabs = useUngroupTabs(); - const ungroupTab = useUngroupTab(); - const tabs = useTabs(); - - const activeTabId = activeWorkspaceId - ? activeTabIds[activeWorkspaceId] - : null; - const isActive = tab.id === activeTabId; - - const { drag, drop, isDragging, isDragOver } = useDragTab(tab.id); - const groupDrop = useGroupDrop(tab.id); - - const rename = useTabRename(tab.id, tab.title); - - const handleRemoveTab = (e?: React.MouseEvent) => { - e?.stopPropagation(); - removeTab(tab.id); - }; - - const handleTabClick = () => { - if (rename.isRenaming) return; - if (activeWorkspaceId) { - setActiveTab(activeWorkspaceId, tab.id); - } - }; - - const handleToggleExpand = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - if (rename.isRenaming) return; - setIsExpanded(!isExpanded); - }; - - const handleUngroup = () => { - ungroupTabs(tab.id); - }; - - const handleMoveOutOfGroup = () => { - if (!tab.parentId) return; - - // Find the parent group's index in the workspace tabs - const workspaceTabs = tabs.filter( - (t) => t.workspaceId === tab.workspaceId && !t.parentId, - ); - const parentIndex = workspaceTabs.findIndex((t) => t.id === tab.parentId); - - // Place after the parent (parentIndex + 1) - if (parentIndex !== -1) { - ungroupTab(tab.id, parentIndex + 1); - } - }; - - const attachRef = (el: HTMLButtonElement | null) => { - drag(el); - drop(el); - }; - - const isGroupTab = tab.type === TabType.Group; - const hasChildren = childTabs.length > 0; - - return ( -
- - - )} - {rename.isRenaming ? ( - rename.setRenameValue(e.target.value)} - onBlur={rename.submitRename} - onKeyDown={rename.handleKeyDown} - onClick={(e) => e.stopPropagation()} - className="flex-1 px-1 py-0.5" - /> - ) : ( - <> - {tab.title} - {tab.needsAttention && ( - - - - - )} - - )} -
- {!isGroupTab && ( - - )} - - - - {isGroupTab && hasChildren && isExpanded && ( -
{ - groupDrop.drop(node); - }} - className="ml-4 mt-1 space-y-1 relative" - > - {groupDrop.isDragOver && ( -
- )} - {childTabs.map((childTab) => { - return ( -
-
- -
-
- ); - })} -
- )} -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/types.ts deleted file mode 100644 index 11ba489f438..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { Tab } from "renderer/stores/tabs/types"; - -export interface DragItem { - type: "TAB"; - tabId: string; -} - -export const TAB_DND_TYPE = "TAB"; - -export interface TabItemProps { - tab: Tab; - childTabs?: Tab[]; -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useDragTab.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useDragTab.ts deleted file mode 100644 index df3cc6b779e..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useDragTab.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useDrag, useDrop } from "react-dnd"; -import { useTabsStore } from "renderer/stores"; -import { type DragItem, TAB_DND_TYPE } from "./types"; - -export function useDragTab(tabId: string) { - const dragTabToTab = useTabsStore((state) => state.dragTabToTab); - - // Set up drag source - const [{ isDragging }, drag] = useDrag< - DragItem, - void, - { isDragging: boolean } - >({ - type: TAB_DND_TYPE, - item: { type: TAB_DND_TYPE, tabId }, - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - }); - - // Set up drop target - const [{ isOver, canDrop }, drop] = useDrop< - DragItem, - void, - { isOver: boolean; canDrop: boolean } - >({ - accept: TAB_DND_TYPE, - drop: (item) => { - if (item.tabId !== tabId) { - dragTabToTab(item.tabId, tabId); - } - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }); - - const isDragOver = isOver && canDrop; - - return { drag, drop, isDragging, isDragOver }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useGroupDrop.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useGroupDrop.ts deleted file mode 100644 index 12cf792cbf0..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useGroupDrop.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useDrop } from "react-dnd"; -import { useTabsStore } from "renderer/stores"; -import { type DragItem, TAB_DND_TYPE } from "./types"; - -export function useGroupDrop(groupId: string) { - const dragTabToTab = useTabsStore((state) => state.dragTabToTab); - - const [{ isOver, canDrop }, drop] = useDrop< - DragItem, - void, - { isOver: boolean; canDrop: boolean } - >({ - accept: TAB_DND_TYPE, - drop: (item, monitor) => { - // Only drop if not already handled by a child - const didDrop = monitor.didDrop(); - if (!didDrop && item.tabId !== groupId) { - dragTabToTab(item.tabId, groupId); - } - }, - collect: (monitor) => ({ - isOver: monitor.isOver({ shallow: true }), - canDrop: monitor.canDrop(), - }), - }); - - const isDragOver = isOver && canDrop; - - return { drop, isDragOver }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useTabRename.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useTabRename.ts deleted file mode 100644 index c513eae81ab..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useTabRename.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { useRenameTab } from "renderer/stores"; - -export function useTabRename(tabId: string, tabTitle: string) { - const [isRenaming, setIsRenaming] = useState(false); - const [renameValue, setRenameValue] = useState(tabTitle); - const inputRef = useRef(null); - const renameTab = useRenameTab(); - - // Select input text when rename mode is activated - useEffect(() => { - if (isRenaming && inputRef.current) { - inputRef.current.select(); - } - }, [isRenaming]); - - // Sync rename value when tab title changes - useEffect(() => { - setRenameValue(tabTitle); - }, [tabTitle]); - - const startRename = () => { - setIsRenaming(true); - }; - - const submitRename = () => { - const trimmedValue = renameValue.trim(); - if (trimmedValue && trimmedValue !== tabTitle) { - renameTab(tabId, trimmedValue); - } else { - setRenameValue(tabTitle); - } - setIsRenaming(false); - }; - - const cancelRename = () => { - setRenameValue(tabTitle); - setIsRenaming(false); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - submitRename(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancelRename(); - } - }; - - return { - isRenaming, - renameValue, - inputRef, - setRenameValue, - startRename, - submitRename, - cancelRename, - handleKeyDown, - }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/UngroupDropZone/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/UngroupDropZone/index.tsx deleted file mode 100644 index 38162d709e4..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/UngroupDropZone/index.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { ReactNode } from "react"; -import { useRef, useState } from "react"; -import { useDrop } from "react-dnd"; -import { useReorderTabById, useTabs, useUngroupTab } from "renderer/stores"; -import type { Tab } from "renderer/stores/tabs/types"; -import { type DragItem, TAB_DND_TYPE } from "../TabItem/types"; - -interface UngroupDropZoneProps { - children: ( - draggedTab: Tab | null, - isDragOver: boolean, - dropIndex: number, - ) => ReactNode; -} - -export function UngroupDropZone({ children }: UngroupDropZoneProps) { - const ungroupTab = useUngroupTab(); - const reorderTabById = useReorderTabById(); - const tabs = useTabs(); - const containerRef = useRef(null); - const [dropIndex, setDropIndex] = useState(0); - - const [{ isOver, canDrop, draggedTab }, drop] = useDrop< - DragItem, - void, - { isOver: boolean; canDrop: boolean; draggedTab: Tab | null } - >({ - accept: TAB_DND_TYPE, - drop: (item, monitor) => { - // Only handle drop if not dropped on a tab (which would be handled by the tab's own drop handler) - const didDrop = monitor.didDrop(); - if (!didDrop) { - const draggedTab = tabs.find((t) => t.id === item.tabId); - if (!draggedTab) return; - - // No adjustment needed since we filter out the dragged tab from the visible list - // The dropIndex is already correct relative to the final position - if (draggedTab.parentId) { - ungroupTab(item.tabId, dropIndex); - } else { - reorderTabById(item.tabId, dropIndex); - } - } - }, - hover: (_item, monitor) => { - if (!containerRef.current) return; - - const clientOffset = monitor.getClientOffset(); - if (!clientOffset) return; - - const containerRect = containerRef.current.getBoundingClientRect(); - const hoverY = clientOffset.y - containerRect.top; - - // Get all tab elements - const tabElements = - containerRef.current.querySelectorAll("[data-tab-item]"); - let newDropIndex = 0; - - for (let i = 0; i < tabElements.length; i++) { - const element = tabElements[i] as HTMLElement; - const rect = element.getBoundingClientRect(); - const elementY = rect.top - containerRect.top; - const elementMiddle = elementY + rect.height / 2; - - if (hoverY < elementMiddle) { - newDropIndex = i; - break; - } - newDropIndex = i + 1; - } - - setDropIndex(newDropIndex); - }, - collect: (monitor) => { - const item = monitor.getItem() as DragItem | null; - const draggedTab = item ? tabs.find((t) => t.id === item.tabId) : null; - - return { - isOver: monitor.isOver({ shallow: true }), - canDrop: monitor.canDrop() && draggedTab !== null, - // Return the dragged tab regardless of whether it has a parent - // This allows us to show preview for both top-level and child tabs - draggedTab: draggedTab || null, - }; - }, - }); - - const isDragOver = isOver && canDrop; - - return ( -
{ - containerRef.current = node; - drop(node); - }} - className="flex-1 overflow-auto" - > - {children(isDragOver ? draggedTab : null, isDragOver, dropIndex)} -
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/WindowContextMenu.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/WindowContextMenu.tsx new file mode 100644 index 00000000000..f2941ef5185 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/WindowContextMenu.tsx @@ -0,0 +1,33 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import type React from "react"; + +interface WindowContextMenuProps { + onClose: () => void; + onRename: () => void; + children: React.ReactNode; +} + +export function WindowContextMenu({ + onClose, + onRename, + children, +}: WindowContextMenuProps) { + return ( + + {children} + + Rename Window + + + Close Window + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/index.tsx new file mode 100644 index 00000000000..9fe0383a927 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/index.tsx @@ -0,0 +1,174 @@ +import { Button } from "@superset/ui/button"; +import { useRef, useState } from "react"; +import { useDrag, useDrop } from "react-dnd"; +import { HiMiniXMark } from "react-icons/hi2"; +import { trpc } from "renderer/lib/trpc"; +import { + useRemoveWindow, + useRenameWindow, + useSetActiveWindow, + useWindowNeedsAttention, +} from "renderer/stores"; +import type { Window } from "renderer/stores/tabs/types"; +import { getWindowDisplayName } from "renderer/stores/tabs/utils"; +import { WindowContextMenu } from "./WindowContextMenu"; + +const DRAG_TYPE = "WINDOW"; + +interface DragItem { + type: typeof DRAG_TYPE; + windowId: string; + index: number; +} + +interface WindowItemProps { + window: Window; + index: number; + isActive: boolean; +} + +export function WindowItem({ window, index, isActive }: WindowItemProps) { + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const activeWorkspaceId = activeWorkspace?.id; + const removeWindow = useRemoveWindow(); + const setActiveWindow = useSetActiveWindow(); + const renameWindow = useRenameWindow(); + const needsAttention = useWindowNeedsAttention(window.id); + + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(""); + const inputRef = useRef(null); + + // Drag source for window reordering + const [{ isDragging }, drag] = useDrag< + DragItem, + void, + { isDragging: boolean } + >({ + type: DRAG_TYPE, + item: { type: DRAG_TYPE, windowId: window.id, index }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + + // Drop target (just for visual feedback, actual drop is handled by parent) + const [{ isDragOver }, drop] = useDrop< + DragItem, + void, + { isDragOver: boolean } + >({ + accept: DRAG_TYPE, + collect: (monitor) => ({ + isDragOver: monitor.isOver(), + }), + }); + + const displayName = getWindowDisplayName(window); + + const handleRemoveWindow = (e?: React.MouseEvent) => { + e?.stopPropagation(); + removeWindow(window.id); + }; + + const handleWindowClick = () => { + if (isRenaming) return; + if (activeWorkspaceId) { + setActiveWindow(activeWorkspaceId, window.id); + } + }; + + const startRename = () => { + setRenameValue(window.name || displayName); + setIsRenaming(true); + setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + }; + + const submitRename = () => { + const trimmedValue = renameValue.trim(); + // Only update if the name actually changed + if (trimmedValue && trimmedValue !== window.name) { + renameWindow(window.id, trimmedValue); + } + setIsRenaming(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + submitRename(); + } else if (e.key === "Escape") { + setIsRenaming(false); + } + }; + + const attachRef = (el: HTMLButtonElement | null) => { + drag(el); + drop(el); + }; + + return ( +
+ + + + +
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx index 6843fc3d7a6..a6cce29a82b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx @@ -1,79 +1,152 @@ import { Button } from "@superset/ui/button"; import { LayoutGroup, motion } from "framer-motion"; -import { useMemo } from "react"; +import { useMemo, useRef, useState } from "react"; +import { useDrop } from "react-dnd"; import { HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; -import { useAddTab, useTabs } from "renderer/stores"; -import { TabItem } from "./TabItem"; -import { UngroupDropZone } from "./UngroupDropZone"; +import { + useActiveWindowIds, + useAddWindow, + useReorderWindowById, + useWindows, +} from "renderer/stores"; +import { WindowItem } from "./WindowItem"; + +const DRAG_TYPE = "WINDOW"; + +interface DragItem { + type: typeof DRAG_TYPE; + windowId: string; + index: number; +} export function TabsView() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id; - const allTabs = useTabs(); - const addTab = useAddTab(); + const allWindows = useWindows(); + const addWindow = useAddWindow(); + const reorderWindowById = useReorderWindowById(); + const activeWindowIds = useActiveWindowIds(); + const [dropIndex, setDropIndex] = useState(null); + const containerRef = useRef(null); - const tabs = useMemo( + const windows = useMemo( () => activeWorkspaceId - ? allTabs.filter( - (tab) => tab.workspaceId === activeWorkspaceId && !tab.parentId, - ) + ? allWindows.filter((win) => win.workspaceId === activeWorkspaceId) : [], - [activeWorkspaceId, allTabs], + [activeWorkspaceId, allWindows], ); - const getChildTabs = (parentId: string) => - allTabs.filter((tab) => tab.parentId === parentId); - - const handleAddTab = () => { + const handleAddWindow = () => { if (activeWorkspaceId) { - addTab(activeWorkspaceId); + addWindow(activeWorkspaceId); } }; + // Drop zone for reordering windows + const [{ isOver }, drop] = useDrop({ + accept: DRAG_TYPE, + hover: (item, monitor) => { + if (!containerRef.current) return; + + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) return; + + // Find all window items in the container + const windowItems = + containerRef.current.querySelectorAll("[data-window-item]"); + let newDropIndex = windows.length; + + windowItems.forEach((element, index) => { + const rect = element.getBoundingClientRect(); + const midY = rect.top + rect.height / 2; + + if (clientOffset.y < midY && index < newDropIndex) { + newDropIndex = index; + } + }); + + // Don't show indicator at the dragged item's current position + if (newDropIndex === item.index || newDropIndex === item.index + 1) { + setDropIndex(null); + } else { + setDropIndex(newDropIndex); + } + }, + drop: (item) => { + if (dropIndex !== null && dropIndex !== item.index) { + const targetIndex = dropIndex > item.index ? dropIndex - 1 : dropIndex; + reorderWindowById(item.windowId, targetIndex); + } + setDropIndex(null); + }, + collect: (monitor) => ({ + isOver: monitor.isOver(), + }), + }); + + // Clear drop index when not hovering + if (!isOver && dropIndex !== null) { + setDropIndex(null); + } + return ( - ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index 6b0718a53f9..98d21bca293 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -2,11 +2,12 @@ import { useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; import { - useActiveTabIds, - useAddTab, - useRemoveTab, - useSetActiveTab, - useTabs, + useActiveWindowIds, + useAddWindow, + useFocusedPaneIds, + useRemovePane, + useSetActiveWindow, + useWindows, } from "renderer/stores"; import { HOTKEYS } from "shared/hotkeys"; import { ContentView } from "./ContentView"; @@ -15,55 +16,58 @@ import { Sidebar } from "./Sidebar"; export function WorkspaceView() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id; - const allTabs = useTabs(); - const activeTabIds = useActiveTabIds(); - const addTab = useAddTab(); - const setActiveTab = useSetActiveTab(); - const removeTab = useRemoveTab(); + const allWindows = useWindows(); + const activeWindowIds = useActiveWindowIds(); + const focusedPaneIds = useFocusedPaneIds(); + const addWindow = useAddWindow(); + const setActiveWindow = useSetActiveWindow(); + const removePane = useRemovePane(); - const tabs = useMemo( + const windows = useMemo( () => activeWorkspaceId - ? allTabs.filter( - (tab) => tab.workspaceId === activeWorkspaceId && !tab.parentId, - ) + ? allWindows.filter((win) => win.workspaceId === activeWorkspaceId) : [], - [activeWorkspaceId, allTabs], + [activeWorkspaceId, allWindows], ); - const activeTabId = activeWorkspaceId - ? activeTabIds[activeWorkspaceId] + const activeWindowId = activeWorkspaceId + ? activeWindowIds[activeWorkspaceId] : null; - // Terminal management shortcuts + // Get focused pane ID for the active window + const focusedPaneId = activeWindowId ? focusedPaneIds[activeWindowId] : null; + + // Window management shortcuts useHotkeys(HOTKEYS.NEW_TERMINAL.keys, () => { if (activeWorkspaceId) { - addTab(activeWorkspaceId); + addWindow(activeWorkspaceId); } - }, [activeWorkspaceId, addTab]); + }, [activeWorkspaceId, addWindow]); useHotkeys(HOTKEYS.CLOSE_TERMINAL.keys, () => { - if (activeTabId) { - removeTab(activeTabId); + // Close focused pane (which may close the window if it's the last pane) + if (focusedPaneId) { + removePane(focusedPaneId); } - }, [activeTabId, removeTab]); + }, [focusedPaneId, removePane]); - // Switch between visible terminal panes (⌘+Up/Down) + // Switch between windows (⌘+Up/Down) useHotkeys(HOTKEYS.PREV_TERMINAL.keys, () => { - if (!activeWorkspaceId || !activeTabId) return; - const index = tabs.findIndex((t) => t.id === activeTabId); + if (!activeWorkspaceId || !activeWindowId) return; + const index = windows.findIndex((w) => w.id === activeWindowId); if (index > 0) { - setActiveTab(activeWorkspaceId, tabs[index - 1].id); + setActiveWindow(activeWorkspaceId, windows[index - 1].id); } - }, [activeWorkspaceId, activeTabId, tabs, setActiveTab]); + }, [activeWorkspaceId, activeWindowId, windows, setActiveWindow]); useHotkeys(HOTKEYS.NEXT_TERMINAL.keys, () => { - if (!activeWorkspaceId || !activeTabId) return; - const index = tabs.findIndex((t) => t.id === activeTabId); - if (index < tabs.length - 1) { - setActiveTab(activeWorkspaceId, tabs[index + 1].id); + if (!activeWorkspaceId || !activeWindowId) return; + const index = windows.findIndex((w) => w.id === activeWindowId); + if (index < windows.length - 1) { + setActiveWindow(activeWorkspaceId, windows[index + 1].id); } - }, [activeWorkspaceId, activeTabId, tabs, setActiveTab]); + }, [activeWorkspaceId, activeWindowId, windows, setActiveWindow]); // Open in last used app shortcut const { data: lastUsedApp = "cursor" } = diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 196d6a8702f..754f5d5bbeb 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -8,9 +8,11 @@ import { trpc } from "renderer/lib/trpc"; import { useCurrentView, useOpenSettings } from "renderer/stores/app-state"; import { useSidebarStore } from "renderer/stores/sidebar-state"; import { + useActiveWindowIds, useAgentHookListener, - useSplitTabHorizontal, - useSplitTabVertical, + useFocusedPaneIds, + useSplitPaneHorizontal, + useSplitPaneVertical, } from "renderer/stores/tabs"; import { HOTKEYS } from "shared/hotkeys"; import { dragDropManager } from "../../lib/dnd"; @@ -39,13 +41,19 @@ export function MainScreen() { refetch, } = trpc.workspaces.getActive.useQuery(); const [isRetrying, setIsRetrying] = useState(false); - const splitTabVertical = useSplitTabVertical(); - const splitTabHorizontal = useSplitTabHorizontal(); + const splitPaneVertical = useSplitPaneVertical(); + const splitPaneHorizontal = useSplitPaneHorizontal(); + const activeWindowIds = useActiveWindowIds(); + const focusedPaneIds = useFocusedPaneIds(); // Listen for agent completion hooks from main process useAgentHookListener(); const activeWorkspaceId = activeWorkspace?.id; + const activeWindowId = activeWorkspaceId + ? activeWindowIds[activeWorkspaceId] + : null; + const focusedPaneId = activeWindowId ? focusedPaneIds[activeWindowId] : null; const isWorkspaceView = currentView === "workspace"; // Register global shortcuts @@ -58,16 +66,16 @@ export function MainScreen() { }, [toggleSidebar, isWorkspaceView]); useHotkeys(HOTKEYS.SPLIT_HORIZONTAL.keys, () => { - if (isWorkspaceView && activeWorkspaceId) { - splitTabVertical(activeWorkspaceId); + if (isWorkspaceView && activeWindowId && focusedPaneId) { + splitPaneVertical(activeWindowId, focusedPaneId); } - }, [activeWorkspaceId, splitTabVertical, isWorkspaceView]); + }, [activeWindowId, focusedPaneId, splitPaneVertical, isWorkspaceView]); useHotkeys(HOTKEYS.SPLIT_VERTICAL.keys, () => { - if (isWorkspaceView && activeWorkspaceId) { - splitTabHorizontal(activeWorkspaceId); + if (isWorkspaceView && activeWindowId && focusedPaneId) { + splitPaneHorizontal(activeWindowId, focusedPaneId); } - }, [activeWorkspaceId, splitTabHorizontal, isWorkspaceView]); + }, [activeWindowId, focusedPaneId, splitPaneHorizontal, isWorkspaceView]); const showStartView = !isLoading && !activeWorkspace && currentView !== "settings"; diff --git a/apps/desktop/src/renderer/stores/tabs/drag-logic.test.ts b/apps/desktop/src/renderer/stores/tabs/drag-logic.test.ts deleted file mode 100644 index 250053f268b..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/drag-logic.test.ts +++ /dev/null @@ -1,1081 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { MosaicNode } from "react-mosaic-component"; -import { cleanLayout, handleDragTabToTab } from "./drag-logic"; -import { type Tab, TabType } from "./types"; - -describe("cleanLayout", () => { - test("removes invalid tab IDs from layout", () => { - const layout: MosaicNode = { - direction: "row", - first: "tab-1", - second: "tab-2", - splitPercentage: 50, - }; - - const validTabIds = new Set(["tab-1"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toBe("tab-1"); - }); - - test("preserves valid nested layout", () => { - const layout: MosaicNode = { - direction: "row", - first: "tab-1", - second: { - direction: "column", - first: "tab-2", - second: "tab-3", - splitPercentage: 50, - }, - splitPercentage: 50, - }; - - const validTabIds = new Set(["tab-1", "tab-2", "tab-3"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toEqual(layout); - }); - - test("collapses layout when one branch is invalid", () => { - const layout: MosaicNode = { - direction: "row", - first: "tab-invalid", - second: { - direction: "column", - first: "tab-2", - second: "tab-3", - splitPercentage: 50, - }, - splitPercentage: 50, - }; - - const validTabIds = new Set(["tab-2", "tab-3"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toEqual({ - direction: "column", - first: "tab-2", - second: "tab-3", - splitPercentage: 50, - }); - }); - - test("returns null when all tabs are invalid", () => { - const layout: MosaicNode = { - direction: "row", - first: "tab-invalid-1", - second: "tab-invalid-2", - splitPercentage: 50, - }; - - const validTabIds = new Set(["tab-valid"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toBeNull(); - }); - - test("handles single tab ID layout", () => { - const layout: MosaicNode = "tab-1"; - const validTabIds = new Set(["tab-1"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toBe("tab-1"); - }); - - test("returns null for invalid single tab ID", () => { - const layout: MosaicNode = "tab-invalid"; - const validTabIds = new Set(["tab-valid"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toBeNull(); - }); -}); - -describe("handleDragTabToTab", () => { - const workspaceId = "workspace-1"; - - test("dragging single tab onto itself creates a group with new tab", () => { - const tab1: Tab = { - id: "tab-1", - title: "Tab 1", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [tab1], - activeTabIds: { [workspaceId]: "tab-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - const result = handleDragTabToTab("tab-1", "tab-1", state); - - // Should create 3 tabs: original tab-1 (now with parent), new tab, new group - expect(result.tabs.length).toBe(3); - - // Find the tabs - const originalTab = result.tabs.find((t) => t.id === "tab-1"); - const newTab = result.tabs.find( - (t) => t.id !== "tab-1" && t.type === TabType.Single, - ); - const groupTab = result.tabs.find((t) => t.type === TabType.Group); - - // Original tab should now have a parent - expect(originalTab?.parentId).toBe(groupTab?.id); - - // New tab should also have the same parent - expect(newTab?.parentId).toBe(groupTab?.id); - - // Group should be active - expect(result.activeTabIds[workspaceId]).toBe(groupTab?.id ?? null); - - // Verify group layout contains both tabs - if (groupTab?.type === TabType.Group && newTab) { - expect(groupTab.layout).toEqual({ - direction: "row", - first: "tab-1", - second: newTab.id, - splitPercentage: 50, - }); - } - }); - - test("dragging child tab onto itself creates new tab in parent group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const childTab: Tab = { - id: "child-1", - title: "Child", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const state = { - tabs: [groupTab, childTab], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - const result = handleDragTabToTab("child-1", "child-1", state); - - // Should create a new tab and add it to the group - expect(result.tabs.length).toBe(3); - - // Find the new tab - const newTab = result.tabs.find( - (t) => t.id !== "group-1" && t.id !== "child-1", - ); - expect(newTab).toBeDefined(); - expect(newTab?.parentId).toBe("group-1"); - - // Group layout should be updated - const updatedGroup = result.tabs.find((t) => t.id === "group-1"); - if (updatedGroup?.type === TabType.Group && newTab) { - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: "child-1", - second: newTab.id, - splitPercentage: 50, - }); - } - - // Group should remain active - expect(result.activeTabIds[workspaceId]).toBe("group-1"); - }); - - test("dragging single tab into another single tab creates group with original IDs", () => { - const tab1: Tab = { - id: "tab-1", - title: "Tab 1", - workspaceId, - type: TabType.Single, - }; - - const tab2: Tab = { - id: "tab-2", - title: "Tab 2", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [tab1, tab2], - activeTabIds: { [workspaceId]: "tab-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - const result = handleDragTabToTab("tab-2", "tab-1", state); - - // Should have 3 tabs: original tab-1, original tab-2, new group - expect(result.tabs.length).toBe(3); - - // Find the tabs - const originalTab1 = result.tabs.find((t) => t.id === "tab-1"); - const originalTab2 = result.tabs.find((t) => t.id === "tab-2"); - const groupTab = result.tabs.find((t) => t.type === TabType.Group); - - // Verify original tab IDs are preserved - expect(originalTab1).toBeDefined(); - expect(originalTab2).toBeDefined(); - expect(originalTab1?.id).toBe("tab-1"); - expect(originalTab2?.id).toBe("tab-2"); - - // Verify they now have a parent - expect(originalTab1?.parentId).toBe(groupTab?.id); - expect(originalTab2?.parentId).toBe(groupTab?.id); - - // Verify group layout contains both original IDs - expect(groupTab?.type).toBe(TabType.Group); - if (groupTab?.type === TabType.Group) { - expect(groupTab.layout).toEqual({ - direction: "row", - first: "tab-1", - second: "tab-2", - splitPercentage: 50, - }); - } - }); - - test("dragging single tab into group adds to group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const childTab: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const singleTab: Tab = { - id: "tab-2", - title: "Tab 2", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [groupTab, childTab, singleTab], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - const result = handleDragTabToTab("tab-2", "group-1", state); - - // Find the updated group - const updatedGroup = result.tabs.find((t) => t.id === "group-1") as Tab & { - type: TabType.Group; - }; - - expect(updatedGroup).toBeDefined(); - expect(updatedGroup.type).toBe(TabType.Group); - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: "child-1", - second: "tab-2", - splitPercentage: 50, - }); - - // Verify tab-2 now has the parent - const updatedTab2 = result.tabs.find((t) => t.id === "tab-2"); - expect(updatedTab2?.parentId).toBe("group-1"); - }); - - test("dragging tab from one group to another updates both groups", () => { - const group1: Tab = { - id: "group-1", - title: "Group 1", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const group2: Tab = { - id: "group-2", - title: "Group 2", - workspaceId, - type: TabType.Group, - layout: "child-3", - }; - - const child3: Tab = { - id: "child-3", - title: "Child 3", - workspaceId, - type: TabType.Single, - parentId: "group-2", - }; - - const state = { - tabs: [group1, child1, child2, group2, child3], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag child-2 from group-1 to group-2 - const result = handleDragTabToTab("child-2", "group-2", state); - - // Verify group-1 layout was cleaned - should only contain child-1 now - const updatedGroup1 = result.tabs.find((t) => t.id === "group-1") as Tab & { - type: TabType.Group; - }; - expect(updatedGroup1.layout).toBe("child-1"); - - // Verify group-2 layout was updated to include child-2 - const updatedGroup2 = result.tabs.find((t) => t.id === "group-2") as Tab & { - type: TabType.Group; - }; - expect(updatedGroup2.layout).toEqual({ - direction: "row", - first: "child-3", - second: "child-2", - splitPercentage: 50, - }); - - // Verify child-2 parent was updated - const updatedChild2 = result.tabs.find((t) => t.id === "child-2"); - expect(updatedChild2?.parentId).toBe("group-2"); - }); - - test("dragging into child tab redirects to parent group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const childTab: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const singleTab: Tab = { - id: "tab-2", - title: "Tab 2", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [groupTab, childTab, singleTab], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag tab-2 onto child-1 (should redirect to group-1) - const result = handleDragTabToTab("tab-2", "child-1", state); - - const updatedGroup = result.tabs.find((t) => t.id === "group-1") as Tab & { - type: TabType.Group; - }; - - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: "child-1", - second: "tab-2", - splitPercentage: 50, - }); - }); - - test("dragging tab from complex nested layout cleans correctly", () => { - // Group with 3-way split: (A | B) on top, C on bottom - const group1: Tab = { - id: "group-1", - title: "Group 1", - workspaceId, - type: TabType.Group, - layout: { - direction: "column", - first: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - second: "child-3", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child3: Tab = { - id: "child-3", - title: "Child 3", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const group2: Tab = { - id: "group-2", - title: "Group 2", - workspaceId, - type: TabType.Group, - layout: "child-4", - }; - - const child4: Tab = { - id: "child-4", - title: "Child 4", - workspaceId, - type: TabType.Single, - parentId: "group-2", - }; - - const state = { - tabs: [group1, child1, child2, child3, group2, child4], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag child-2 from group-1 to group-2 - const result = handleDragTabToTab("child-2", "group-2", state); - - // Group-1 should collapse the nested structure since child-2 is removed - const updatedGroup1 = result.tabs.find((t) => t.id === "group-1") as Tab & { - type: TabType.Group; - }; - - // Should collapse to just the remaining two tabs - expect(updatedGroup1.layout).toEqual({ - direction: "column", - first: "child-1", - second: "child-3", - splitPercentage: 50, - }); - - // Group-2 should add child-2 - const updatedGroup2 = result.tabs.find((t) => t.id === "group-2") as Tab & { - type: TabType.Group; - }; - expect(updatedGroup2.layout).toEqual({ - direction: "row", - first: "child-4", - second: "child-2", - splitPercentage: 50, - }); - - // Verify child-2 parent was updated - const updatedChild2 = result.tabs.find((t) => t.id === "child-2"); - expect(updatedChild2?.parentId).toBe("group-2"); - }); - - test("removing last tab from nested layout returns null", () => { - const layout: MosaicNode = { - direction: "column", - first: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - second: "child-3", - splitPercentage: 50, - }; - - // Remove all tabs one by one - let cleaned = cleanLayout(layout, new Set(["child-2", "child-3"])); - expect(cleaned).toEqual({ - direction: "column", - first: "child-2", - second: "child-3", - splitPercentage: 50, - }); - - cleaned = cleanLayout(layout, new Set(["child-3"])); - expect(cleaned).toBe("child-3"); - - cleaned = cleanLayout(layout, new Set([])); - expect(cleaned).toBeNull(); - }); - - test("layout is cleaned when tab is moved to standalone (no longer has parent)", () => { - // This simulates dragging a child tab out to become a standalone tab - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - // Simulate the state AFTER dragging child-2 out - // In reality, some action would clear child-2's parentId - const stateAfterDrag: Tab[] = [ - groupTab, // Still has old layout with both child-1 and child-2 - child1, // Still has parent - { ...child2, parentId: undefined }, // Parent was cleared - ]; - - // Now verify that cleanLayout would remove child-2 from the layout - // since it's no longer a child - const childTabIds = stateAfterDrag - .filter((t) => t.parentId === "group-1") - .map((t) => t.id); - const validTabIds = new Set(childTabIds); - - expect(validTabIds.has("child-1")).toBe(true); - expect(validTabIds.has("child-2")).toBe(false); - - const cleanedLayout = cleanLayout(groupTab.layout, validTabIds); - - // Layout should collapse to just child-1 - expect(cleanedLayout).toBe("child-1"); - }); - - test("layout with invalid tab IDs is cleaned before rendering", () => { - // Simulate a group with a stale layout (contains tabs that no longer exist) - const layout: MosaicNode = { - direction: "row", - first: "valid-tab", - second: "deleted-tab", // This tab was removed but layout wasn't updated - splitPercentage: 50, - }; - - // Only valid-tab actually exists - const validTabIds = new Set(["valid-tab"]); - const cleaned = cleanLayout(layout, validTabIds); - - // Should collapse to just the valid tab - expect(cleaned).toBe("valid-tab"); - }); - - test("complex layout with multiple invalid tabs is fully cleaned", () => { - // Layout: ((A | B) / (C | D)) - // But only A and D still exist - const layout: MosaicNode = { - direction: "column", - first: { - direction: "row", - first: "tab-a", - second: "tab-b", // Removed - splitPercentage: 50, - }, - second: { - direction: "row", - first: "tab-c", // Removed - second: "tab-d", - splitPercentage: 50, - }, - splitPercentage: 50, - }; - - const validTabIds = new Set(["tab-a", "tab-d"]); - const cleaned = cleanLayout(layout, validTabIds); - - // Should collapse to just A and D - expect(cleaned).toEqual({ - direction: "column", - first: "tab-a", - second: "tab-d", - splitPercentage: 50, - }); - }); - - test("after dragging tab out, getChildTabIds correctly excludes it", () => { - // This tests the exact scenario that caused "Tab not found" - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - // Initial state - both children in group - const initialTabs = [groupTab, child1, child2]; - const initialChildIds = initialTabs - .filter((t) => t.parentId === "group-1") - .map((t) => t.id); - - expect(initialChildIds).toEqual(["child-1", "child-2"]); - - // After dragging child-2 out (parentId cleared) - const afterDragTabs = [ - groupTab, // Layout still has both children (stale) - child1, - { ...child2, parentId: undefined }, // Parent cleared - ]; - - const afterDragChildIds = afterDragTabs - .filter((t) => t.parentId === "group-1") - .map((t) => t.id); - - // Only child-1 should be returned - expect(afterDragChildIds).toEqual(["child-1"]); - - // The layout is stale but cleanLayout should fix it - const validIds = new Set(afterDragChildIds); - const cleaned = cleanLayout(groupTab.layout, validIds); - - // Layout should now only contain child-1 - expect(cleaned).toBe("child-1"); - }); - - test("dragging tab already in same group creates new tab in that group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const state = { - tabs: [groupTab, child1, child2], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag child-1 onto its own parent group - const result = handleDragTabToTab("child-1", "group-1", state); - - // Should create a new tab and add it to the group - expect(result.tabs.length).toBe(4); - - // Find the new tab - const newTab = result.tabs.find( - (t) => t.id !== "group-1" && t.id !== "child-1" && t.id !== "child-2", - ); - expect(newTab).toBeDefined(); - expect(newTab?.parentId).toBe("group-1"); - - // Group layout should be updated to include new tab - const updatedGroup = result.tabs.find((t) => t.id === "group-1"); - if (updatedGroup?.type === TabType.Group && newTab) { - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - second: newTab.id, - splitPercentage: 50, - }); - } - - // Group should remain active - expect(result.activeTabIds[workspaceId]).toBe("group-1"); - }); - - test("dragging group onto itself creates new tab in that group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const state = { - tabs: [groupTab, child1, child2], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag group-1 onto itself - const result = handleDragTabToTab("group-1", "group-1", state); - - // Should create a new tab and add it to the group - expect(result.tabs.length).toBe(4); - - // Find the new tab - const newTab = result.tabs.find( - (t) => t.id !== "group-1" && t.id !== "child-1" && t.id !== "child-2", - ); - expect(newTab).toBeDefined(); - expect(newTab?.parentId).toBe("group-1"); - - // Group layout should be updated to include new tab - const updatedGroup = result.tabs.find((t) => t.id === "group-1"); - if (updatedGroup?.type === TabType.Group && newTab) { - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - second: newTab.id, - splitPercentage: 50, - }); - } - - // Group should remain active - expect(result.activeTabIds[workspaceId]).toBe("group-1"); - }); - - test("dragging child into another child of same group creates new tab", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const state = { - tabs: [groupTab, child1, child2], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag child-1 onto child-2 (both in same group) - const result = handleDragTabToTab("child-1", "child-2", state); - - // Should create a new tab and add it to the group - expect(result.tabs.length).toBe(4); - - // Find the new tab - const newTab = result.tabs.find( - (t) => t.id !== "group-1" && t.id !== "child-1" && t.id !== "child-2", - ); - expect(newTab).toBeDefined(); - expect(newTab?.parentId).toBe("group-1"); - - // Group layout should be updated to include new tab - const updatedGroup = result.tabs.find((t) => t.id === "group-1"); - if (updatedGroup?.type === TabType.Group && newTab) { - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - second: newTab.id, - splitPercentage: 50, - }); - } - - // Group should remain active - expect(result.activeTabIds[workspaceId]).toBe("group-1"); - }); - - test("dragging last child from group to another tab removes the group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const singleTab: Tab = { - id: "tab-2", - title: "Tab 2", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [groupTab, child1, singleTab], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag the only child from group-1 to tab-2 - const result = handleDragTabToTab("child-1", "tab-2", state); - - // The group should be removed - const groupStillExists = result.tabs.some((t) => t.id === "group-1"); - expect(groupStillExists).toBe(false); - - // Should have 3 tabs: child-1, tab-2, and a new group containing both - expect(result.tabs.length).toBe(3); - - // Verify child-1 and tab-2 are now in a new group - const newGroup = result.tabs.find((t) => t.type === TabType.Group); - expect(newGroup).toBeDefined(); - expect(newGroup?.id).not.toBe("group-1"); - - const updatedChild1 = result.tabs.find((t) => t.id === "child-1"); - const updatedTab2 = result.tabs.find((t) => t.id === "tab-2"); - - expect(updatedChild1?.parentId).toBe(newGroup?.id); - expect(updatedTab2?.parentId).toBe(newGroup?.id); - }); - - test("dragging last child from group to another group removes the source group", () => { - const group1: Tab = { - id: "group-1", - title: "Group 1", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const group2: Tab = { - id: "group-2", - title: "Group 2", - workspaceId, - type: TabType.Group, - layout: "child-2", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-2", - }; - - const state = { - tabs: [group1, child1, group2, child2], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag the only child from group-1 to group-2 - const result = handleDragTabToTab("child-1", "group-2", state); - - // group-1 should be removed - const group1StillExists = result.tabs.some((t) => t.id === "group-1"); - expect(group1StillExists).toBe(false); - - // group-2 should still exist - const updatedGroup2 = result.tabs.find((t) => t.id === "group-2"); - expect(updatedGroup2).toBeDefined(); - - // group-2 should now contain both children - if (updatedGroup2?.type === TabType.Group) { - expect(updatedGroup2.layout).toEqual({ - direction: "row", - first: "child-2", - second: "child-1", - splitPercentage: 50, - }); - } - - // Verify child-1 now has group-2 as parent - const updatedChild1 = result.tabs.find((t) => t.id === "child-1"); - expect(updatedChild1?.parentId).toBe("group-2"); - - // Should have 3 tabs total: group-2, child-1, child-2 - expect(result.tabs.length).toBe(3); - }); - - test("dragging last child from nested layout to another tab removes the group", () => { - // Group with only one child in a simple layout - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const targetTab: Tab = { - id: "target-tab", - title: "Target", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [groupTab, child1, targetTab], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - const result = handleDragTabToTab("child-1", "target-tab", state); - - // group-1 should be removed - expect(result.tabs.some((t) => t.id === "group-1")).toBe(false); - - // Should create a new group with child-1 and target-tab - const newGroup = result.tabs.find( - (t) => t.type === TabType.Group && t.id !== "group-1", - ); - expect(newGroup).toBeDefined(); - }); -}); diff --git a/apps/desktop/src/renderer/stores/tabs/drag-logic.ts b/apps/desktop/src/renderer/stores/tabs/drag-logic.ts deleted file mode 100644 index 3f1bd5a3e39..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/drag-logic.ts +++ /dev/null @@ -1,467 +0,0 @@ -import type { MosaicNode } from "react-mosaic-component"; -import { type Tab, type TabGroup, TabType } from "./types"; -import { createNewTab } from "./utils"; - -export interface DragTabToTabResult { - tabs: Tab[]; - activeTabIds: Record; - tabHistoryStacks: Record; -} - -/** - * Removes a tab ID from a mosaic layout tree - * Returns null if the layout becomes empty after removal - */ -export const removeTabFromLayout = ( - layout: MosaicNode | null, - tabIdToRemove: string, -): MosaicNode | null => { - if (!layout) return null; - - // If layout is a leaf node (single tab ID) - if (typeof layout === "string") { - return layout === tabIdToRemove ? null : layout; - } - - // Recursively remove from both branches - const newFirst = removeTabFromLayout(layout.first, tabIdToRemove); - const newSecond = removeTabFromLayout(layout.second, tabIdToRemove); - - // If both branches are gone, return null - if (!newFirst && !newSecond) return null; - - // If one branch is gone, return the other - if (!newFirst) return newSecond; - if (!newSecond) return newFirst; - - // Both branches still exist, return updated layout - return { - ...layout, - first: newFirst, - second: newSecond, - }; -}; - -/** - * Validates layout against valid tab IDs and removes any invalid references - */ -export const cleanLayout = ( - layout: MosaicNode | null, - validTabIds: Set, -): MosaicNode | null => { - if (!layout) return null; - - if (typeof layout === "string") { - return validTabIds.has(layout) ? layout : null; - } - - const newFirst = cleanLayout(layout.first, validTabIds); - const newSecond = cleanLayout(layout.second, validTabIds); - - if (!newFirst && !newSecond) return null; - if (!newFirst) return newSecond; - if (!newSecond) return newFirst; - - // If children are identical references, return original layout to avoid churn - if (newFirst === layout.first && newSecond === layout.second) { - return layout; - } - - return { - ...layout, - first: newFirst, - second: newSecond, - }; -}; - -const removeFromOldParent = ( - tabs: Tab[], - tabId: string, - oldParentId: string, -): Tab[] => { - return tabs - .map((tab) => { - if (tab.id === oldParentId && tab.type === TabType.Group) { - const updatedLayout = removeTabFromLayout(tab.layout, tabId); - - return { - ...tab, - layout: updatedLayout, - }; - } - return tab; - }) - .filter((tab) => { - // Remove the parent group if it no longer has any children - if (tab.id === oldParentId && tab.type === TabType.Group) { - // Check if any tabs still have this group as their parent - const hasChildren = tabs.some( - (t) => t.parentId === oldParentId && t.id !== tabId, - ); - return hasChildren; - } - return true; - }); -}; - -const addToParentGroup = ( - parentGroup: TabGroup, - childTabId: string, -): TabGroup => { - const newLayout = - parentGroup.layout === null - ? childTabId - : { - direction: "row" as const, - first: parentGroup.layout, - second: childTabId, - splitPercentage: 50, - }; - - return { - ...parentGroup, - layout: newLayout, - }; -}; - -export const handleDragTabToTab = ( - draggedTabId: string, - targetTabId: string, - state: { - tabs: Tab[]; - activeTabIds: Record; - tabHistoryStacks: Record; - }, -): DragTabToTabResult => { - const draggedTab = state.tabs.find((tab) => tab.id === draggedTabId); - const targetTab = state.tabs.find((tab) => tab.id === targetTabId); - - if (!draggedTab || !targetTab) return state; - - const workspaceId = draggedTab.workspaceId; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - - // Rule 1: Dragging tab into itself - creates new tab and makes a group - if (draggedTabId === targetTabId) { - const newTab = createNewTab(workspaceId, TabType.Single, state.tabs); - - // If dragged tab is a child tab, add new tab to its parent group - if (draggedTab.parentId) { - const parentGroup = state.tabs.find( - (tab) => tab.id === draggedTab.parentId && tab.type === TabType.Group, - ) as TabGroup | undefined; - - if (!parentGroup) return state; - - const updatedNewTab: Tab = { - ...newTab, - parentId: parentGroup.id, - }; - - const updatedParentGroup = addToParentGroup(parentGroup, newTab.id); - - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === parentGroup.id) return updatedParentGroup; - return tab; - }); - - return { - ...state, - tabs: [...updatedTabs, updatedNewTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: parentGroup.id, - }, - }; - } - - // If dragged tab is a group, add new tab to the group - if (draggedTab.type === TabType.Group) { - const updatedNewTab: Tab = { - ...newTab, - parentId: draggedTab.id, - }; - - const updatedDraggedGroup = addToParentGroup(draggedTab, newTab.id); - - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === draggedTab.id) return updatedDraggedGroup; - return tab; - }); - - return { - ...state, - tabs: [...updatedTabs, updatedNewTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: draggedTab.id, - }, - }; - } - - // If dragged tab is a single standalone tab, create a new group - const groupId = `tab-${Date.now()}-group`; - - const updatedTargetTab: Tab = { - ...draggedTab, - parentId: groupId, - }; - - const updatedNewTab: Tab = { - ...newTab, - parentId: groupId, - }; - - const newGroupTab: TabGroup = { - id: groupId, - title: `${draggedTab.title} + ${newTab.title}`, - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: draggedTab.id, - second: newTab.id, - splitPercentage: 50, - }, - }; - - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const targetIndex = workspaceTabs.findIndex((t) => t.id === targetTabId); - - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === draggedTab.id) return updatedTargetTab; - return tab; - }); - - const workspaceTabsUpdated = updatedTabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const otherTabsUpdated = updatedTabs.filter( - (t) => t.workspaceId !== workspaceId || t.parentId, - ); - - workspaceTabsUpdated.splice(targetIndex, 0, newGroupTab); - - return { - ...state, - tabs: [...otherTabsUpdated, ...workspaceTabsUpdated, updatedNewTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: newGroupTab.id, - }, - }; - } - - // Rule 2: Dragging into a child tab - redirects to parent group since child tabs can't be drop targets - if (targetTab.parentId && draggedTab.type === TabType.Single) { - const parentGroup = state.tabs.find( - (tab) => tab.id === targetTab.parentId && tab.type === TabType.Group, - ) as TabGroup | undefined; - - if (!parentGroup) return state; - - // If dragging a child tab into another child tab of the same group, create a new tab - if (draggedTab.parentId === parentGroup.id) { - const newTab = createNewTab(workspaceId, TabType.Single, state.tabs); - - const updatedNewTab: Tab = { - ...newTab, - parentId: parentGroup.id, - }; - - const updatedParentGroup = addToParentGroup(parentGroup, newTab.id); - - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === parentGroup.id) return updatedParentGroup; - return tab; - }); - - return { - ...state, - tabs: [...updatedTabs, updatedNewTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: parentGroup.id, - }, - }; - } - - const updatedDraggedTab: Tab = { - ...draggedTab, - parentId: parentGroup.id, - }; - - const updatedParentGroup = addToParentGroup(parentGroup, draggedTabId); - - let updatedTabs = state.tabs.map((tab) => { - if (tab.id === parentGroup.id) return updatedParentGroup; - if (tab.id === draggedTabId) return updatedDraggedTab; - return tab; - }); - - if (draggedTab.parentId) { - updatedTabs = removeFromOldParent( - updatedTabs, - draggedTabId, - draggedTab.parentId, - ); - } - - return { - ...state, - tabs: updatedTabs, - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: parentGroup.id, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: historyStack.filter((id) => id !== draggedTabId), - }, - }; - } - - // Rule 3: Dragging into a group tab - adds tab to existing split view group - if (targetTab.type === TabType.Group && draggedTab.type === TabType.Single) { - // If dragging a tab from the same group, create a new tab and add to the group - if (draggedTab.parentId === targetTabId) { - const newTab = createNewTab(workspaceId, TabType.Single, state.tabs); - - const updatedNewTab: Tab = { - ...newTab, - parentId: targetTabId, - }; - - const updatedTargetTab = addToParentGroup(targetTab, newTab.id); - - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === targetTabId) return updatedTargetTab; - return tab; - }); - - return { - ...state, - tabs: [...updatedTabs, updatedNewTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: targetTabId, - }, - }; - } - - const updatedDraggedTab: Tab = { - ...draggedTab, - parentId: targetTabId, - }; - - const updatedTargetTab = addToParentGroup(targetTab, draggedTabId); - - let updatedTabs = state.tabs.map((tab) => { - if (tab.id === targetTabId) return updatedTargetTab; - if (tab.id === draggedTabId) return updatedDraggedTab; - return tab; - }); - - if (draggedTab.parentId) { - updatedTabs = removeFromOldParent( - updatedTabs, - draggedTabId, - draggedTab.parentId, - ); - } - - return { - ...state, - tabs: updatedTabs, - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: targetTabId, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: historyStack.filter((id) => id !== draggedTabId), - }, - }; - } - - // Rule 4: Dragging single tab into another single tab - creates new group container for split view - if (targetTab.type === TabType.Single && draggedTab.type === TabType.Single) { - const groupId = `tab-${Date.now()}-group`; - - // Keep original tab IDs stable - just update their parentId - const updatedTargetTab: Tab = { - ...targetTab, - parentId: groupId, - }; - - const updatedDraggedTab: Tab = { - ...draggedTab, - parentId: groupId, - }; - - const newGroupTab: TabGroup = { - id: groupId, - title: `${targetTab.title} + ${draggedTab.title}`, - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: targetTab.id, // Use original ID, not new one - second: draggedTab.id, // Use original ID, not new one - splitPercentage: 50, - }, - }; - - // Find target tab's index in workspace to insert group at that position - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const targetIndex = workspaceTabs.findIndex((t) => t.id === targetTabId); - - // Update existing tabs to set parentId - let updatedTabs = state.tabs.map((tab) => { - if (tab.id === targetTab.id) return updatedTargetTab; - if (tab.id === draggedTab.id) return updatedDraggedTab; - return tab; - }); - - // If dragged tab had an old parent, remove it from that parent (and potentially remove the parent group) - if (draggedTab.parentId) { - updatedTabs = removeFromOldParent( - updatedTabs, - draggedTabId, - draggedTab.parentId, - ); - } - - // Filter to get workspace tabs (excluding child tabs) - const workspaceTabsUpdated = updatedTabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const otherTabsUpdated = updatedTabs.filter( - (t) => t.workspaceId !== workspaceId || t.parentId, - ); - - // Insert the new group at the target's original index - workspaceTabsUpdated.splice(targetIndex, 0, newGroupTab); - - return { - ...state, - tabs: [...otherTabsUpdated, ...workspaceTabsUpdated], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: newGroupTab.id, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: historyStack.filter( - (id) => id !== draggedTabId && id !== targetTabId, - ), - }, - }; - } - - return state; -}; diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/active-tab.ts b/apps/desktop/src/renderer/stores/tabs/helpers/active-tab.ts deleted file mode 100644 index a26b5d3ed29..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/helpers/active-tab.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { Tab, TabsState } from "../types"; - -export const handleSetActiveTab = ( - state: TabsState, - workspaceId: string, - tabId: string, -): Partial => { - 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), - ]; - } - - // Clear needsAttention for the tab being activated - const updatedTabs = state.tabs.map((tab) => - tab.id === tabId && tab.needsAttention - ? { ...tab, needsAttention: false } - : tab, - ); - - return { - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: tabId, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - tabs: updatedTabs, - }; -}; - -export const getTabsByWorkspace = ( - state: TabsState, - workspaceId: string, -): Tab[] => { - return state.tabs.filter((tab) => tab.workspaceId === workspaceId); -}; - -export const getActiveTab = ( - state: TabsState, - workspaceId: string, -): Tab | null => { - const activeTabId = state.activeTabIds[workspaceId]; - if (!activeTabId) return null; - return state.tabs.find((tab) => tab.id === activeTabId) || null; -}; - -export const getLastActiveTabId = ( - state: TabsState, - workspaceId: string, -): string | null => { - const historyStack = state.tabHistoryStacks[workspaceId] || []; - return historyStack[0] || null; -}; diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/group-operations.test.ts b/apps/desktop/src/renderer/stores/tabs/helpers/group-operations.test.ts deleted file mode 100644 index d239134dded..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/helpers/group-operations.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import type { TabsState } from "../types"; -import { TabType } from "../types"; -import { handleRemoveChildTabFromGroup } from "./group-operations"; - -describe("handleRemoveChildTabFromGroup", () => { - it("should update active tab to next sibling when removing active child from group", () => { - const state: TabsState = { - tabs: [ - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: { - direction: "row", - first: "child1", - second: "child2", - }, - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child2", - title: "Child 2", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child3", - title: "Child 3", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - ], - activeTabIds: { workspace1: "child1" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const result = handleRemoveChildTabFromGroup(state, "group1", "child1"); - - expect(result.activeTabIds?.workspace1).toBe("child2"); // Should select next sibling - expect(result.tabs?.find((t) => t.id === "child1")).toBeUndefined(); // Child1 removed - expect(result.tabs?.find((t) => t.id === "child2")).toBeDefined(); // Child2 still exists - expect(result.tabs?.find((t) => t.id === "child3")).toBeDefined(); // Child3 still exists - expect(result.tabs?.find((t) => t.id === "group1")).toBeDefined(); // Group still exists - }); - - it("should update active tab to previous sibling when removing last child from group", () => { - const state: TabsState = { - tabs: [ - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: { - direction: "row", - first: "child1", - second: "child2", - }, - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child2", - title: "Child 2", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - ], - activeTabIds: { workspace1: "child2" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const result = handleRemoveChildTabFromGroup(state, "group1", "child2"); - - expect(result.activeTabIds?.workspace1).toBe("child1"); // Should select previous sibling - expect(result.tabs?.find((t) => t.id === "child2")).toBeUndefined(); // Child2 removed - expect(result.tabs?.find((t) => t.id === "child1")).toBeDefined(); // Child1 still exists - }); - - it("should not change active tab when removing non-active child from group", () => { - const state: TabsState = { - tabs: [ - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: { - direction: "row", - first: "child1", - second: "child2", - }, - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child2", - title: "Child 2", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - ], - activeTabIds: { workspace1: "child1" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const result = handleRemoveChildTabFromGroup(state, "group1", "child2"); - - expect(result.activeTabIds?.workspace1).toBe("child1"); // Should remain on child1 - expect(result.tabs?.find((t) => t.id === "child2")).toBeUndefined(); // Child2 removed - expect(result.tabs?.find((t) => t.id === "child1")).toBeDefined(); // Child1 still exists - }); - - it("should update history stack when removing child tab", () => { - const state: TabsState = { - tabs: [ - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: { - direction: "row", - first: "child1", - second: "child2", - }, - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child2", - title: "Child 2", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - ], - activeTabIds: { workspace1: "child2" }, - tabHistoryStacks: { workspace1: ["child1", "child2"] }, - }; - - const result = handleRemoveChildTabFromGroup(state, "group1", "child2"); - - expect(result.tabHistoryStacks?.workspace1).toEqual(["child1"]); // Child2 removed from history - }); - - it("should remove group when last child is removed", () => { - const state: TabsState = { - tabs: [ - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: "child1", - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - ], - activeTabIds: { workspace1: "child1" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const result = handleRemoveChildTabFromGroup(state, "group1", "child1"); - - expect(result.tabs?.find((t) => t.id === "group1")).toBeUndefined(); // Group removed - expect(result.tabs?.find((t) => t.id === "child1")).toBeUndefined(); // Child removed - expect(result.activeTabIds?.workspace1).toBeNull(); // No tabs left - }); - - it("should handle removing child with history stack fallback", () => { - const state: TabsState = { - tabs: [ - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: { - direction: "row", - first: "child1", - second: "child2", - }, - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child2", - title: "Child 2", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child3", - title: "Child 3", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - ], - activeTabIds: { workspace1: "child2" }, - tabHistoryStacks: { workspace1: ["child3", "child1"] }, // child3 was most recently used - }; - - const result = handleRemoveChildTabFromGroup(state, "group1", "child2"); - - expect(result.activeTabIds?.workspace1).toBe("child3"); // Should prefer history over position - }); -}); diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/group-operations.ts b/apps/desktop/src/renderer/stores/tabs/helpers/group-operations.ts deleted file mode 100644 index 7fd3047235f..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/helpers/group-operations.ts +++ /dev/null @@ -1,311 +0,0 @@ -import type { MosaicNode } from "react-mosaic-component"; -import { removeTabFromLayout } from "../drag-logic"; -import type { Tab, TabsState } from "../types"; -import { TabType } from "../types"; -import { getChildTabIds } from "../utils"; -import { findNextTab } from "./next-tab-finder"; -import { validateGroupLayouts } from "./validation"; - -const handleEmptyGroupRemoval = ( - state: TabsState, - workspaceId: string, - idsToRemove: string[], - fallbackActiveTabId?: string, -): TabsState => { - const remainingTabs = state.tabs.filter( - (tab) => !idsToRemove.includes(tab.id), - ); - const currentActiveId = state.activeTabIds[workspaceId]; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - - const newActiveTabIds = { ...state.activeTabIds }; - const newHistoryStack = historyStack.filter( - (id) => !idsToRemove.includes(id), - ); - - // Ensure a valid tab is active after removal to prevent UI confusion - if (idsToRemove.includes(currentActiveId || "")) { - const workspaceTabs = remainingTabs.filter( - (tab) => tab.workspaceId === workspaceId, - ); - - if (workspaceTabs.length > 0) { - // Prefer fallback tab (e.g., ungrouped tab), then find next by position - if ( - fallbackActiveTabId && - remainingTabs.some((t) => t.id === fallbackActiveTabId) - ) { - newActiveTabIds[workspaceId] = fallbackActiveTabId; - } else if (currentActiveId) { - // Find the next tab based on position/index - const nextTabId = findNextTab(state, currentActiveId); - newActiveTabIds[workspaceId] = nextTabId; - } else { - // No current active tab, default to first workspace tab - newActiveTabIds[workspaceId] = workspaceTabs[0]?.id || null; - } - } else { - newActiveTabIds[workspaceId] = null; - } - } - - return { - tabs: remainingTabs, - activeTabIds: newActiveTabIds, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; -}; - -export const handleUpdateTabGroupLayout = ( - state: TabsState, - id: string, - layout: MosaicNode, -): Partial => { - return { - tabs: state.tabs.map((tab) => - tab.id === id && tab.type === TabType.Group ? { ...tab, layout } : tab, - ), - }; -}; - -export const handleAddChildTabToGroup = ( - state: TabsState, - groupId: string, - childTabId: string, -): Partial => { - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === childTabId) { - return { - ...tab, - parentId: groupId, - }; - } - return tab; - }); - - // Layout updates are handled separately to allow callers to batch operations - - return { - tabs: updatedTabs, - }; -}; - -export const handleRemoveChildTabFromGroup = ( - state: TabsState, - groupId: string, - childTabId: string, -): Partial => { - const group = state.tabs.find( - (tab) => tab.id === groupId && tab.type === TabType.Group, - ); - if (!group || group.type !== TabType.Group) return {}; - - const updatedChildTabIds = getChildTabIds(state.tabs, groupId).filter( - (id: string) => id !== childTabId, - ); - - // Empty groups are invalid and must be removed to prevent orphaned state - if (updatedChildTabIds.length === 0) { - return handleEmptyGroupRemoval(state, group.workspaceId, [ - groupId, - childTabId, - ]); - } - - // Layouts may reference removed tabs, so clean them up - const validatedTabs = validateGroupLayouts( - state.tabs.filter((tab) => tab.id !== childTabId), - ); - - // Update active tab if the removed child was active - const workspaceId = group.workspaceId; - const currentActiveId = state.activeTabIds[workspaceId]; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - const newHistoryStack = historyStack.filter((id) => id !== childTabId); - - const newActiveTabIds = { ...state.activeTabIds }; - if (currentActiveId === childTabId) { - // Find next tab to activate - const nextTabId = findNextTab(state, childTabId); - newActiveTabIds[workspaceId] = nextTabId; - } - - return { - tabs: validatedTabs, - activeTabIds: newActiveTabIds, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; -}; - -export const handleUngroupTab = ( - state: TabsState, - tabId: string, - targetIndex?: number, -): Partial => { - const tab = state.tabs.find((t) => t.id === tabId); - if (!tab || !tab.parentId) return {}; - - const parentGroup = state.tabs.find( - (t) => t.id === tab.parentId && t.type === TabType.Group, - ); - if (!parentGroup || parentGroup.type !== TabType.Group) return {}; - - const updatedTab: Tab = { - ...tab, - parentId: undefined, - }; - - const updatedLayout = removeTabFromLayout( - parentGroup.layout, - tabId, - ) as MosaicNode | null; - - 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 t; - }); - - // Empty groups are invalid and must be removed - if (remainingChildren.length === 0) { - const result = handleEmptyGroupRemoval( - { ...state, tabs: updatedTabs }, - tab.workspaceId, - [parentGroup.id], - tabId, - ); - - 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, - ); - - 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]; - } - } - - return result; - } - - // Layouts may reference removed tabs, so clean them up - let validatedTabs = validateGroupLayouts(updatedTabs); - - if (targetIndex !== undefined) { - const workspaceId = tab.workspaceId; - const workspaceTabs = validatedTabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const otherTabs = validatedTabs.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]; - } - } - - return { - ...state, - tabs: validatedTabs, - }; -}; - -export const handleUngroupTabs = ( - state: TabsState, - groupId: string, -): Partial => { - const group = state.tabs.find( - (t) => t.id === groupId && t.type === TabType.Group, - ); - if (!group || group.type !== TabType.Group) return {}; - - const childTabIds = getChildTabIds(state.tabs, groupId); - if (childTabIds.length === 0) return {}; - - // Preserve tab order by placing ungrouped tabs where the group was - const workspaceId = group.workspaceId; - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const groupIndex = workspaceTabs.findIndex((t) => t.id === groupId); - - const updatedTabs = state.tabs - .map((tab) => { - if (childTabIds.includes(tab.id)) { - return { - ...tab, - parentId: undefined, - }; - } - return tab; - }) - .filter((tab) => tab.id !== groupId); - - const newWorkspaceTabs = updatedTabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const otherTabs = updatedTabs.filter( - (t) => t.workspaceId !== workspaceId || t.parentId, - ); - - const ungroupedTabs = newWorkspaceTabs.filter((t) => - childTabIds.includes(t.id), - ); - const nonUngroupedTabs = newWorkspaceTabs.filter( - (t) => !childTabIds.includes(t.id), - ); - - nonUngroupedTabs.splice(groupIndex, 0, ...ungroupedTabs); - - const finalTabs = [...otherTabs, ...nonUngroupedTabs]; - - // Update active tab if the group was active to prevent UI confusion - const currentActiveId = state.activeTabIds[workspaceId]; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - const newHistoryStack = historyStack.filter((id) => id !== groupId); - - const newActiveTabIds = { ...state.activeTabIds }; - if (currentActiveId === groupId) { - 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, - }, - }; -}; diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/next-tab-finder.test.ts b/apps/desktop/src/renderer/stores/tabs/helpers/next-tab-finder.test.ts deleted file mode 100644 index 7ccd7a1e7a3..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/helpers/next-tab-finder.test.ts +++ /dev/null @@ -1,547 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import type { TabsState } from "../types"; -import { TabType } from "../types"; -import { findNextTab } from "./next-tab-finder"; - -describe("findNextTab", () => { - it("should return next tab by index when closing a middle tab", () => { - const state: TabsState = { - tabs: [ - { - id: "tab1", - title: "Tab 1", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "tab2", - title: "Tab 2", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "tab3", - title: "Tab 3", - workspaceId: "workspace1", - type: TabType.Single, - }, - ], - activeTabIds: { workspace1: "tab2" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "tab2"); - expect(nextTabId).toBe("tab3"); // Should select the next tab (right side) - }); - - it("should return previous tab by index when closing the last tab", () => { - const state: TabsState = { - tabs: [ - { - id: "tab1", - title: "Tab 1", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "tab2", - title: "Tab 2", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "tab3", - title: "Tab 3", - workspaceId: "workspace1", - type: TabType.Single, - }, - ], - activeTabIds: { workspace1: "tab3" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "tab3"); - expect(nextTabId).toBe("tab2"); // Should select the previous tab (left side) - }); - - it("should return null when closing the only tab", () => { - const state: TabsState = { - tabs: [ - { - id: "tab1", - title: "Tab 1", - workspaceId: "workspace1", - type: TabType.Single, - }, - ], - activeTabIds: { workspace1: "tab1" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "tab1"); - expect(nextTabId).toBeNull(); - }); - - it("should find next tab within the same group when closing a child tab", () => { - const state: TabsState = { - tabs: [ - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: { - direction: "row", - first: "child1", - second: "child2", - }, - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child2", - title: "Child 2", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child3", - title: "Child 3", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - ], - activeTabIds: { workspace1: "child2" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "child2"); - expect(nextTabId).toBe("child3"); // Should prefer next tab in the same group - }); - - it("should find previous tab within the same group when closing the last child", () => { - const state: TabsState = { - tabs: [ - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: { - direction: "row", - first: "child1", - second: "child2", - }, - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child2", - title: "Child 2", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - ], - activeTabIds: { workspace1: "child2" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "child2"); - expect(nextTabId).toBe("child1"); // Should select previous tab in the same group - }); - - it("should ignore tabs from other workspaces", () => { - const state: TabsState = { - tabs: [ - { - id: "tab1", - title: "Tab 1", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "tab2", - title: "Tab 2", - workspaceId: "workspace2", - type: TabType.Single, - }, - ], - activeTabIds: { workspace1: "tab1" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "tab1"); - expect(nextTabId).toBeNull(); // Should not select tab from different workspace - }); - - it("should always find a tab if any exist in workspace (ultimate fallback)", () => { - const state: TabsState = { - tabs: [ - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: { - direction: "row", - first: "child1", - second: "child2", - }, - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child2", - title: "Child 2", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "group2", - title: "Group 2", - workspaceId: "workspace1", - type: TabType.Group, - layout: "child3", - }, - { - id: "child3", - title: "Child 3", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group2", - }, - ], - activeTabIds: { workspace1: "child1" }, - tabHistoryStacks: { workspace1: [] }, - }; - - // Closing child1 when there are only groups and child tabs - // Should fallback to group1 (top-level) or another child tab - const nextTabId = findNextTab(state, "child1"); - expect(nextTabId).not.toBeNull(); // Should never be null when tabs exist - if (nextTabId) { - expect(["child2", "child3", "group1", "group2"]).toContain(nextTabId); - } - }); - - it("should prefer history stack over positional fallback", () => { - const state: TabsState = { - tabs: [ - { - id: "tab1", - title: "Tab 1", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "tab2", - title: "Tab 2", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "tab3", - title: "Tab 3", - workspaceId: "workspace1", - type: TabType.Single, - }, - ], - activeTabIds: { workspace1: "tab2" }, - tabHistoryStacks: { workspace1: ["tab1", "tab3"] }, // tab1 was most recently active - }; - - const nextTabId = findNextTab(state, "tab2"); - expect(nextTabId).toBe("tab1"); // Should prefer history over position - }); - - it("should fall back to next tab when history is empty", () => { - const state: TabsState = { - tabs: [ - { - id: "tab1", - title: "Tab 1", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "tab2", - title: "Tab 2", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "tab3", - title: "Tab 3", - workspaceId: "workspace1", - type: TabType.Single, - }, - ], - activeTabIds: { workspace1: "tab1" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "tab1"); - expect(nextTabId).toBe("tab2"); // Should select next positional tab - }); - - it("should fall back to any available tab when closing a tab not in current position", () => { - const state: TabsState = { - tabs: [ - { - id: "tab1", - title: "Tab 1", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: "child1", - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - ], - activeTabIds: { workspace1: "child1" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "child1"); - expect(nextTabId).not.toBeNull(); - // Should fall back to group1 or tab1 - if (nextTabId) { - expect(["tab1", "group1"]).toContain(nextTabId); - } - }); - - it("should handle closing only child in a group with other top-level tabs", () => { - const state: TabsState = { - tabs: [ - { - id: "tab1", - title: "Tab 1", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: "child1", - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "tab2", - title: "Tab 2", - workspaceId: "workspace1", - type: TabType.Single, - }, - ], - activeTabIds: { workspace1: "child1" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "child1"); - expect(nextTabId).not.toBeNull(); - // Should return tab2 (next after parent group) or tab1 - if (nextTabId) { - expect(["tab1", "tab2", "group1"]).toContain(nextTabId); - } - }); - - it("should always return a tab when multiple tabs exist in workspace", () => { - const state: TabsState = { - tabs: [ - { - id: "tab1", - title: "Tab 1", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "tab2", - title: "Tab 2", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "tab3", - title: "Tab 3", - workspaceId: "workspace2", - type: TabType.Single, - }, - ], - activeTabIds: { workspace1: "tab1", workspace2: "tab3" }, - tabHistoryStacks: { workspace1: [], workspace2: [] }, - }; - - const nextTabId = findNextTab(state, "tab1"); - expect(nextTabId).toBe("tab2"); - }); - - it("should handle mixed top-level and grouped tabs correctly", () => { - const state: TabsState = { - tabs: [ - { - id: "tab1", - title: "Tab 1", - workspaceId: "workspace1", - type: TabType.Single, - }, - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: { - direction: "row", - first: "child1", - second: "child2", - }, - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child2", - title: "Child 2", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "tab2", - title: "Tab 2", - workspaceId: "workspace1", - type: TabType.Single, - }, - ], - activeTabIds: { workspace1: "tab1" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "tab1"); - expect(nextTabId).toBe("group1"); // Should select next top-level tab - }); - - it("should select next sibling when closing a child in a group with multiple children", () => { - const state: TabsState = { - tabs: [ - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: { - direction: "row", - first: "child1", - second: { - direction: "column", - first: "child2", - second: "child3", - }, - }, - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child2", - title: "Child 2", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child3", - title: "Child 3", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - ], - activeTabIds: { workspace1: "child1" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "child1"); - expect(nextTabId).toBe("child2"); // Should select next sibling in group - }); - - it("should select previous sibling when closing the last child in a group", () => { - const state: TabsState = { - tabs: [ - { - id: "group1", - title: "Group 1", - workspaceId: "workspace1", - type: TabType.Group, - layout: { - direction: "row", - first: "child1", - second: "child2", - }, - }, - { - id: "child1", - title: "Child 1", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - { - id: "child2", - title: "Child 2", - workspaceId: "workspace1", - type: TabType.Single, - parentId: "group1", - }, - ], - activeTabIds: { workspace1: "child2" }, - tabHistoryStacks: { workspace1: [] }, - }; - - const nextTabId = findNextTab(state, "child2"); - expect(nextTabId).toBe("child1"); // Should select previous sibling in group - }); -}); diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/next-tab-finder.ts b/apps/desktop/src/renderer/stores/tabs/helpers/next-tab-finder.ts deleted file mode 100644 index baa496fd912..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/helpers/next-tab-finder.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { TabsState } from "../types"; - -/** - * Finds the next best tab to activate when closing a tab. - * Priority order (tries each until a tab is found): - * 1. Most recently used tab from history stack - * 2. Next sibling in the same group (for child tabs) - * 3. Next/previous top-level tab by position - * 4. Any remaining tab in the workspace - */ -export const findNextTab = ( - state: TabsState, - tabIdToClose: string, -): string | null => { - const tabToClose = state.tabs.find((tab) => tab.id === tabIdToClose); - if (!tabToClose) return null; - - const workspaceId = tabToClose.workspaceId; - const workspaceTabs = state.tabs.filter( - (tab) => tab.workspaceId === workspaceId && tab.id !== tabIdToClose, - ); - - // Early exit if no tabs remain - if (workspaceTabs.length === 0) return null; - - // Try each strategy in priority order - let nextTabId: string | null = null; - - nextTabId = findNextFromHistory(state, workspaceId, tabIdToClose); - if (nextTabId) return nextTabId; - - if (tabToClose.parentId) { - nextTabId = findNextInGroup(state, tabIdToClose, tabToClose.parentId); - if (nextTabId) return nextTabId; - } - - nextTabId = findNextTopLevelTab(state, tabIdToClose, workspaceId); - if (nextTabId) return nextTabId; - - // Ultimate fallback: return any available tab - return workspaceTabs[0]?.id || null; -}; - -/** - * Priority 1: Find next tab from history stack (most recently used) - */ -function findNextFromHistory( - state: TabsState, - workspaceId: string, - tabIdToClose: string, -): string | null { - const historyStack = state.tabHistoryStacks[workspaceId] || []; - - // Find the first tab in history that still exists and isn't being closed - for (const historyTabId of historyStack) { - if (historyTabId === tabIdToClose) continue; - - const historyTab = state.tabs.find((tab) => tab.id === historyTabId); - if (historyTab && historyTab.workspaceId === workspaceId) { - return historyTabId; - } - } - - return null; -} - -/** - * Priority 2: Find next sibling tab within the same group - */ -function findNextInGroup( - state: TabsState, - tabIdToClose: string, - parentId: string, -): string | null { - const siblings = state.tabs.filter((tab) => tab.parentId === parentId); - const currentIndex = siblings.findIndex((tab) => tab.id === tabIdToClose); - - if (currentIndex === -1) return null; - - // Try next sibling, then previous sibling - const nextIndex = currentIndex + 1; - const prevIndex = currentIndex - 1; - - if (nextIndex < siblings.length) { - return siblings[nextIndex].id; - } - - if (prevIndex >= 0) { - return siblings[prevIndex].id; - } - - return null; -} - -/** - * Priority 3: Find next top-level tab by position - * For child tabs, uses parent group's position - */ -function findNextTopLevelTab( - state: TabsState, - tabIdToClose: string, - workspaceId: string, -): string | null { - const tabToClose = state.tabs.find((tab) => tab.id === tabIdToClose); - if (!tabToClose) return null; - - const topLevelTabs = state.tabs.filter( - (tab) => tab.workspaceId === workspaceId && !tab.parentId, - ); - - // Determine which top-level tab/group to use as reference - const referenceId = tabToClose.parentId || tabIdToClose; - const currentIndex = topLevelTabs.findIndex((tab) => tab.id === referenceId); - - if (currentIndex === -1) return null; - - // Try next, then previous - const nextIndex = currentIndex + 1; - const prevIndex = currentIndex - 1; - - if (nextIndex < topLevelTabs.length) { - return topLevelTabs[nextIndex].id; - } - - if (prevIndex >= 0) { - return topLevelTabs[prevIndex].id; - } - - return null; -} diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/split-operations.ts b/apps/desktop/src/renderer/stores/tabs/helpers/split-operations.ts deleted file mode 100644 index fead6864206..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/helpers/split-operations.ts +++ /dev/null @@ -1,297 +0,0 @@ -import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; -import { updateTree } from "react-mosaic-component"; -import type { Tab, TabsState } from "../types"; -import { TabType } from "../types"; -import { createNewTab } from "../utils"; - -/** - * Find the path to a specific tab ID within a mosaic layout. - * Returns the path array or null if not found. - */ -function findPathInLayout( - layout: MosaicNode | null, - targetId: string, - currentPath: MosaicBranch[] = [], -): MosaicBranch[] | null { - if (!layout) return null; - - if (typeof layout === "string") { - return layout === targetId ? currentPath : null; - } - - const firstPath = findPathInLayout(layout.first, targetId, [ - ...currentPath, - "first", - ]); - if (firstPath) return firstPath; - - const secondPath = findPathInLayout(layout.second, targetId, [ - ...currentPath, - "second", - ]); - return secondPath; -} - -/** - * Find the first tab ID in a mosaic layout (leftmost/topmost leaf). - */ -function findFirstTabInLayout( - layout: MosaicNode | null, -): string | null { - if (!layout) return null; - if (typeof layout === "string") return layout; - return findFirstTabInLayout(layout.first); -} - -/** - * Find a child tab to split when the active tab is a group. - * Checks tab history first, then falls back to first tab in layout. - */ -function findChildTabToSplit( - state: TabsState, - group: Tab & { type: typeof TabType.Group }, - workspaceId: string, -): { tab: Tab; path: MosaicBranch[] } | null { - if (!group.layout) return null; - - // Check history for a recently focused child of this group - const history = state.tabHistoryStacks[workspaceId] || []; - for (const historyTabId of history) { - const historyTab = state.tabs.find((t) => t.id === historyTabId); - if (historyTab && historyTab.parentId === group.id) { - const path = findPathInLayout(group.layout, historyTab.id); - if (path) return { tab: historyTab, path }; - } - } - - // Fall back to first tab in the layout - const firstTabId = findFirstTabInLayout(group.layout); - if (!firstTabId) return null; - - const firstTab = state.tabs.find((t) => t.id === firstTabId); - if (!firstTab) return null; - - const path = findPathInLayout(group.layout, firstTabId); - if (!path) return null; - - return { tab: firstTab, path }; -} - -export const handleSplitTabVertical = ( - state: TabsState, - workspaceId: string, - sourceTabId?: string, - path?: MosaicBranch[], -): Partial => { - // When sourceTabId is provided, use it directly - if (sourceTabId) { - const tabToSplit = state.tabs.find((t) => t.id === sourceTabId); - if (!tabToSplit || tabToSplit.type === TabType.Group) return {}; - - if (tabToSplit.parentId && path) { - return splitPaneInGroup(state, tabToSplit, workspaceId, path, "row"); - } - return convertTabToGroup(state, tabToSplit, workspaceId, "row"); - } - - // Find the active tab (could be top-level, child, or group) - const activeTabId = state.activeTabIds[workspaceId]; - const activeTab = state.tabs.find((t) => t.id === activeTabId); - - if (!activeTab) return {}; - - // If active tab is a group, find a child tab to split - if (activeTab.type === TabType.Group) { - const childToSplit = findChildTabToSplit(state, activeTab, workspaceId); - if (!childToSplit) return {}; - return splitPaneInGroup( - state, - childToSplit.tab, - workspaceId, - childToSplit.path, - "row", - ); - } - - // If the active tab is a child of a group, split within that group - if (activeTab.parentId) { - const parentGroup = state.tabs.find( - (t) => t.id === activeTab.parentId && t.type === TabType.Group, - ); - if (!parentGroup || parentGroup.type !== TabType.Group) return {}; - - const foundPath = findPathInLayout(parentGroup.layout, activeTab.id); - if (!foundPath) return {}; - - return splitPaneInGroup(state, activeTab, workspaceId, foundPath, "row"); - } - - // Top-level single tab - convert to group - return convertTabToGroup(state, activeTab, workspaceId, "row"); -}; - -export const handleSplitTabHorizontal = ( - state: TabsState, - workspaceId: string, - sourceTabId?: string, - path?: MosaicBranch[], -): Partial => { - // When sourceTabId is provided, use it directly - if (sourceTabId) { - const tabToSplit = state.tabs.find((t) => t.id === sourceTabId); - if (!tabToSplit || tabToSplit.type === TabType.Group) return {}; - - if (tabToSplit.parentId && path) { - return splitPaneInGroup(state, tabToSplit, workspaceId, path, "column"); - } - return convertTabToGroup(state, tabToSplit, workspaceId, "column"); - } - - // Find the active tab (could be top-level, child, or group) - const activeTabId = state.activeTabIds[workspaceId]; - const activeTab = state.tabs.find((t) => t.id === activeTabId); - - if (!activeTab) return {}; - - // If active tab is a group, find a child tab to split - if (activeTab.type === TabType.Group) { - const childToSplit = findChildTabToSplit(state, activeTab, workspaceId); - if (!childToSplit) return {}; - return splitPaneInGroup( - state, - childToSplit.tab, - workspaceId, - childToSplit.path, - "column", - ); - } - - // If the active tab is a child of a group, split within that group - if (activeTab.parentId) { - const parentGroup = state.tabs.find( - (t) => t.id === activeTab.parentId && t.type === TabType.Group, - ); - if (!parentGroup || parentGroup.type !== TabType.Group) return {}; - - const foundPath = findPathInLayout(parentGroup.layout, activeTab.id); - if (!foundPath) return {}; - - return splitPaneInGroup(state, activeTab, workspaceId, foundPath, "column"); - } - - // Top-level single tab - convert to group - return convertTabToGroup(state, activeTab, workspaceId, "column"); -}; - -const splitPaneInGroup = ( - state: TabsState, - 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; - - const newTab = createNewTab(workspaceId, TabType.Single, state.tabs); - const newTabWithParent: Tab = { - ...newTab, - parentId: tabToSplit.parentId, - }; - - const newLayout = updateTree(group.layout, [ - { - path, - spec: { - $set: { - direction, - first: tabToSplit.id, - second: newTab.id, - splitPercentage: 50, - }, - }, - }, - ]); - - const updatedTabs = state.tabs.map((tab) => - tab.id === group.id && tab.type === TabType.Group - ? { ...tab, layout: newLayout } - : tab, - ); - - return { - tabs: [...updatedTabs, newTabWithParent], - }; -}; - -const convertTabToGroup = ( - state: TabsState, - tabToSplit: Tab, - workspaceId: string, - direction: "row" | "column", -) => { - 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, - }; - - const newChildTab = createNewTab(workspaceId, TabType.Single, state.tabs); - const newChildTabWithParent: Tab = { - ...newChildTab, - parentId: groupTab.id, - }; - - const updatedSourceTab: Tab = { - ...tabToSplit, - parentId: groupTab.id, - }; - - const layout: MosaicNode = { - direction, - first: tabToSplit.id, - second: newChildTabWithParent.id, - splitPercentage: 50, - }; - - const updatedGroupTab: Tab = { - ...groupTab, - layout, - }; - - // Preserve tab order by inserting the group where the original tab was - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const sourceTabIndex = workspaceTabs.findIndex((t) => t.id === tabToSplit.id); - - 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, - ); - - otherWorkspaceTabs.splice(sourceTabIndex, 0, updatedGroupTab); - - const newTabs = [ - ...nonWorkspaceTabs, - ...otherWorkspaceTabs, - updatedSourceTab, - newChildTabWithParent, - ]; - - return { - tabs: newTabs, - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: updatedGroupTab.id, - }, - }; -}; diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts b/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts deleted file mode 100644 index c1c06cccbdf..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { TabsState } from "../types"; -import { TabType } from "../types"; -import { createNewTab } from "../utils"; -import { findNextTab } from "./next-tab-finder"; - -export const handleAddTab = ( - state: TabsState, - workspaceId: string, - type: TabType = TabType.Single, -): { newState: Partial; tabId: string } => { - const newTab = createNewTab(workspaceId, type, state.tabs); - const currentActiveId = state.activeTabIds[workspaceId]; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - const newHistoryStack = currentActiveId - ? [currentActiveId, ...historyStack.filter((id) => id !== currentActiveId)] - : historyStack; - - return { - newState: { - tabs: [newTab, ...state.tabs], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: newTab.id, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }, - tabId: newTab.id, - }; -}; - -/** - * Removes a tab from state - * Returns null if the operation should be delegated or prevented - */ -export const handleRemoveTab = ( - state: TabsState, - id: string, -): Partial | null => { - const tabToRemove = state.tabs.find((tab) => tab.id === id); - if (!tabToRemove) return null; - - // Group tabs must be ungrouped first to prevent orphaned layouts - if (tabToRemove.type === TabType.Group) { - console.error("Cannot close group tabs directly. Ungroup the tabs first."); - return null; - } - - // Child tabs require group cleanup, so delegate to removeChildTabFromGroup - if (tabToRemove.parentId) { - return null; - } - - const workspaceId = tabToRemove.workspaceId; - 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) { - const nextTabId = findNextTab(state, id); - newActiveTabIds[workspaceId] = nextTabId; - } - - return { - tabs, - activeTabIds: newActiveTabIds, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; -}; - -export const handleRenameTab = ( - state: TabsState, - id: string, - newTitle: string, -): Partial => { - return { - tabs: state.tabs.map((tab) => - tab.id === id ? { ...tab, title: newTitle } : tab, - ), - }; -}; - -export const handleMarkTabAsUsed = ( - state: TabsState, - id: string, -): Partial => { - return { - tabs: state.tabs.map((tab) => - tab.id === id ? { ...tab, isNew: false } : tab, - ), - }; -}; diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/tab-ordering.ts b/apps/desktop/src/renderer/stores/tabs/helpers/tab-ordering.ts deleted file mode 100644 index c95e49d3382..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/helpers/tab-ordering.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { TabsState } from "../types"; - -export const handleReorderTabs = ( - state: TabsState, - workspaceId: string, - startIndex: number, - endIndex: number, -): Partial => { - 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); - - return { tabs: [...otherTabs, ...workspaceTabs] }; -}; - -export const handleReorderTabById = ( - state: TabsState, - tabId: string, - targetIndex: number, -): Partial => { - const tab = state.tabs.find((t) => t.id === tabId); - // Child tabs are ordered by their parent group's layout, not independently - if (!tab || tab.parentId) return {}; - - 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 {}; - - const filteredTabs = workspaceTabs.filter((t) => t.id !== tabId); - filteredTabs.splice(targetIndex, 0, tabToMove); - - return { tabs: [...otherTabs, ...filteredTabs] }; -}; diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/validation.ts b/apps/desktop/src/renderer/stores/tabs/helpers/validation.ts deleted file mode 100644 index 58cd0fb873b..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/helpers/validation.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { cleanLayout } from "../drag-logic"; -import type { Tab } from "../types"; -import { TabType } from "../types"; -import { getChildTabIds } from "../utils"; - -export const validateGroupLayouts = (tabs: Tab[]): Tab[] => { - return tabs.map((tab) => { - if (tab.type !== TabType.Group) return tab; - - // Layouts can reference removed tabs, so clean them to prevent broken references - const validTabIds = new Set(getChildTabIds(tabs, tab.id)); - const cleanedLayout = cleanLayout(tab.layout, validTabIds); - - if (cleanedLayout !== tab.layout) { - return { - ...tab, - layout: cleanedLayout, - }; - } - - return tab; - }); -}; diff --git a/apps/desktop/src/renderer/stores/tabs/hooks.ts b/apps/desktop/src/renderer/stores/tabs/hooks.ts index 6c60cd473fb..1813dfc5e63 100644 --- a/apps/desktop/src/renderer/stores/tabs/hooks.ts +++ b/apps/desktop/src/renderer/stores/tabs/hooks.ts @@ -1,24 +1,57 @@ -import { useTabsStore } from "./store"; +import { useWindowsStore } from "./store"; -export const useTabs = () => useTabsStore((state) => state.tabs); -export const useActiveTabIds = () => - useTabsStore((state) => state.activeTabIds); +// Window hooks +export const useWindows = () => useWindowsStore((state) => state.windows); +export const usePanes = () => useWindowsStore((state) => state.panes); +export const useActiveWindowIds = () => + useWindowsStore((state) => state.activeWindowIds); +export const useFocusedPaneIds = () => + useWindowsStore((state) => state.focusedPaneIds); -export const useAddTab = () => useTabsStore((state) => state.addTab); -export const useRemoveTab = () => useTabsStore((state) => state.removeTab); -export const useRenameTab = () => useTabsStore((state) => state.renameTab); -export const useSetActiveTab = () => - useTabsStore((state) => state.setActiveTab); -export const useReorderTabs = () => useTabsStore((state) => state.reorderTabs); -export const useReorderTabById = () => - useTabsStore((state) => state.reorderTabById); -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); +export const useAddWindow = () => useWindowsStore((state) => state.addWindow); +export const useRemoveWindow = () => + useWindowsStore((state) => state.removeWindow); +export const useRenameWindow = () => + useWindowsStore((state) => state.renameWindow); +export const useSetActiveWindow = () => + useWindowsStore((state) => state.setActiveWindow); +export const useReorderWindows = () => + useWindowsStore((state) => state.reorderWindows); +export const useReorderWindowById = () => + useWindowsStore((state) => state.reorderWindowById); +export const useUpdateWindowLayout = () => + useWindowsStore((state) => state.updateWindowLayout); + +// Pane hooks +export const useAddPane = () => useWindowsStore((state) => state.addPane); +export const useRemovePane = () => useWindowsStore((state) => state.removePane); +export const useSetFocusedPane = () => + useWindowsStore((state) => state.setFocusedPane); +export const useMarkPaneAsUsed = () => + useWindowsStore((state) => state.markPaneAsUsed); export const useSetNeedsAttention = () => - useTabsStore((state) => state.setNeedsAttention); + useWindowsStore((state) => state.setNeedsAttention); + +// Split hooks +export const useSplitPaneVertical = () => + useWindowsStore((state) => state.splitPaneVertical); +export const useSplitPaneHorizontal = () => + useWindowsStore((state) => state.splitPaneHorizontal); + +// Query hooks +export const useGetWindowsByWorkspace = () => + useWindowsStore((state) => state.getWindowsByWorkspace); +export const useGetActiveWindow = () => + useWindowsStore((state) => state.getActiveWindow); +export const useGetPanesForWindow = () => + useWindowsStore((state) => state.getPanesForWindow); +export const useGetFocusedPane = () => + useWindowsStore((state) => state.getFocusedPane); + +// Derived state hooks (efficient selectors) +export const useWindowNeedsAttention = (windowId: string) => + useWindowsStore((state) => + Object.values(state.panes).some( + (p) => p.windowId === windowId && p.needsAttention, + ), + ); diff --git a/apps/desktop/src/renderer/stores/tabs/index.ts b/apps/desktop/src/renderer/stores/tabs/index.ts index bb6e949a188..405e56ca67f 100644 --- a/apps/desktop/src/renderer/stores/tabs/index.ts +++ b/apps/desktop/src/renderer/stores/tabs/index.ts @@ -1,4 +1,3 @@ -export * from "./drag-logic"; export * from "./hooks"; export * from "./store"; export * from "./types"; diff --git a/apps/desktop/src/renderer/stores/tabs/store.test.ts b/apps/desktop/src/renderer/stores/tabs/store.test.ts deleted file mode 100644 index a5f0e3152d5..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/store.test.ts +++ /dev/null @@ -1,776 +0,0 @@ -// biome-ignore-all lint/suspicious/noExplicitAny: Test file uses type casts for test data setup -import { afterEach, describe, expect, mock, test } from "bun:test"; - -// Mock window.electronStore for persistence layer -const mockStore = new Map(); -// @ts-expect-error - mocking global window for tests -globalThis.window = { - electronStore: { - get: mock((key: string) => Promise.resolve(mockStore.get(key))), - set: mock((key: string, value: unknown) => { - mockStore.set(key, value as string); - return Promise.resolve(); - }), - delete: mock((key: string) => { - mockStore.delete(key); - return Promise.resolve(); - }), - }, -}; - -// Mock trpc-client to avoid electronTRPC dependency -mock.module("renderer/lib/trpc-client", () => ({ - trpcClient: { - terminal: { - kill: { mutate: mock(() => Promise.resolve()) }, - }, - }, -})); - -// Import after mocks are set up -const { useTabsStore } = await import("./store"); -const { TabType } = await import("./types"); -type TabGroup = import("./types").TabGroup; - -// Store initial state for cleanup -const initialState = useTabsStore.getState(); - -afterEach(() => { - // Reset store to initial state between tests - useTabsStore.setState(initialState, true); -}); - -describe("removeTab", () => { - test("removing last child from group removes the group", () => { - const store = useTabsStore.getState(); - - // Create a group with one child - const groupTab = { - id: "group-1", - title: "Group", - workspaceId: "workspace-1", - type: TabType.Group, - layout: "child-1" as const, - }; - - const childTab = { - id: "child-1", - title: "Child", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - // Manually set the tabs - useTabsStore.setState({ - tabs: [groupTab, childTab as any], - activeTabIds: { "workspace-1": "group-1" }, - tabHistoryStacks: { "workspace-1": [] }, - }); - - // Remove the child tab - store.removeTab("child-1"); - - // Both the child and group should be removed - const state = useTabsStore.getState(); - expect(state.tabs.some((t) => t.id === "child-1")).toBe(false); - expect(state.tabs.some((t) => t.id === "group-1")).toBe(false); - }); - - test("removing one child from group with multiple children keeps the group", () => { - const store = useTabsStore.getState(); - - // Create a group with two children - const groupTab: TabGroup = { - id: "group-1", - title: "Group", - workspaceId: "workspace-1", - type: TabType.Group, - layout: { - direction: "row" as const, - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1 = { - id: "child-1", - title: "Child 1", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - const child2 = { - id: "child-2", - title: "Child 2", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - useTabsStore.setState({ - tabs: [groupTab, child1 as any, child2 as any], - activeTabIds: { "workspace-1": "group-1" }, - tabHistoryStacks: { "workspace-1": [] }, - }); - - // Remove one child tab - store.removeTab("child-1"); - - // child-1 should be removed, but group and child-2 should remain - const state = useTabsStore.getState(); - expect(state.tabs.some((t) => t.id === "child-1")).toBe(false); - expect(state.tabs.some((t) => t.id === "group-1")).toBe(true); - expect(state.tabs.some((t) => t.id === "child-2")).toBe(true); - - // Group layout should be cleaned to only contain child-2 - const updatedGroup = state.tabs.find((t) => t.id === "group-1"); - if (updatedGroup && updatedGroup.type === TabType.Group) { - expect(updatedGroup.layout).toBe("child-2"); - } - }); - - test("removing top-level tab does not affect groups", () => { - const store = useTabsStore.getState(); - - const topLevelTab = { - id: "top-1", - title: "Top Level", - workspaceId: "workspace-1", - type: TabType.Single, - }; - - const groupTab = { - id: "group-1", - title: "Group", - workspaceId: "workspace-1", - type: TabType.Group, - layout: "child-1" as const, - }; - - const childTab = { - id: "child-1", - title: "Child", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - useTabsStore.setState({ - tabs: [topLevelTab, groupTab, childTab as any], - activeTabIds: { "workspace-1": "top-1" }, - tabHistoryStacks: { "workspace-1": [] }, - }); - - // Remove the top-level tab - store.removeTab("top-1"); - - // Only top-level tab should be removed - const state = useTabsStore.getState(); - expect(state.tabs.some((t) => t.id === "top-1")).toBe(false); - expect(state.tabs.some((t) => t.id === "group-1")).toBe(true); - expect(state.tabs.some((t) => t.id === "child-1")).toBe(true); - }); - - test("removing last child updates active tab correctly", () => { - const store = useTabsStore.getState(); - - const otherTab = { - id: "other-1", - title: "Other", - workspaceId: "workspace-1", - type: TabType.Single, - }; - - const groupTab = { - id: "group-1", - title: "Group", - workspaceId: "workspace-1", - type: TabType.Group, - layout: "child-1" as const, - }; - - const childTab = { - id: "child-1", - title: "Child", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - useTabsStore.setState({ - tabs: [otherTab, groupTab, childTab as any], - activeTabIds: { "workspace-1": "group-1" }, - tabHistoryStacks: { "workspace-1": [] }, - }); - - // Remove the child tab (which will also remove the group) - store.removeTab("child-1"); - - // Active tab should switch to the other tab - const state = useTabsStore.getState(); - 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) => - typeof groupTab.layout !== "string" && - groupTab.layout && - "second" in groupTab.layout && - t.id === groupTab.layout.second && - t.type === TabType.Single, - ); - expect(newChild).toBeDefined(); - expect(newChild?.parentId).toBe(groupTab.id); - - // Active tab should be the group - expect(state.activeTabIds["workspace-1"]).toBe(groupTab.id); - }); - - test("active group uses last-focused child from history", () => { - const store = useTabsStore.getState(); - - const groupTab: TabGroup = { - id: "group-1", - title: "Group", - workspaceId: "workspace-1", - type: TabType.Group, - layout: { - direction: "row" as const, - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1 = { - id: "child-1", - title: "Child 1", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - const child2 = { - id: "child-2", - title: "Child 2", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - useTabsStore.setState({ - tabs: [groupTab, child1 as any, child2 as any], - activeTabIds: { "workspace-1": "group-1" }, - // child-2 was most recently focused - tabHistoryStacks: { "workspace-1": ["child-2", "child-1"] }, - }); - - // Split with group as active - should use child-2 from history - store.splitTabVertical("workspace-1"); - - const state = useTabsStore.getState(); - - // Should have 4 tabs now (group + 3 children) - expect(state.tabs.length).toBe(4); - - // The group's layout should show child-2 was split - const updatedGroup = state.tabs.find( - (t) => t.id === "group-1" && t.type === TabType.Group, - ) as TabGroup; - expect(updatedGroup).toBeDefined(); - - // child-2's position should now be a nested layout - if (typeof updatedGroup.layout === "string" || !updatedGroup.layout) return; - expect(updatedGroup.layout.second).toEqual({ - direction: "row", - first: "child-2", - second: expect.any(String), - splitPercentage: 50, - }); - }); - - test("active group falls back to first child when history is empty", () => { - const store = useTabsStore.getState(); - - const groupTab: TabGroup = { - id: "group-1", - title: "Group", - workspaceId: "workspace-1", - type: TabType.Group, - layout: { - direction: "row" as const, - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1 = { - id: "child-1", - title: "Child 1", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - const child2 = { - id: "child-2", - title: "Child 2", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - useTabsStore.setState({ - tabs: [groupTab, child1 as any, child2 as any], - activeTabIds: { "workspace-1": "group-1" }, - // Empty history - tabHistoryStacks: { "workspace-1": [] }, - }); - - // Split with group as active - should fall back to first child (child-1) - store.splitTabVertical("workspace-1"); - - const state = useTabsStore.getState(); - - // Should have 4 tabs now (group + 3 children) - expect(state.tabs.length).toBe(4); - - // The group's layout should show child-1 was split (first in layout) - const updatedGroup = state.tabs.find( - (t) => t.id === "group-1" && t.type === TabType.Group, - ) as TabGroup; - expect(updatedGroup).toBeDefined(); - - // child-1's position should now be a nested layout - if (typeof updatedGroup.layout === "string" || !updatedGroup.layout) return; - expect(updatedGroup.layout.first).toEqual({ - direction: "row", - first: "child-1", - second: expect.any(String), - splitPercentage: 50, - }); - }); - - test("active child tab splits within group without explicit path", () => { - const store = useTabsStore.getState(); - - const groupTab: TabGroup = { - id: "group-1", - title: "Group", - workspaceId: "workspace-1", - type: TabType.Group, - layout: { - direction: "row" as const, - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1 = { - id: "child-1", - title: "Child 1", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - const child2 = { - id: "child-2", - title: "Child 2", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - useTabsStore.setState({ - tabs: [groupTab, child1 as any, child2 as any], - // child-1 is the active tab (not the group) - activeTabIds: { "workspace-1": "child-1" }, - tabHistoryStacks: { "workspace-1": [] }, - }); - - // Split with child as active - should split child-1 within the group - store.splitTabVertical("workspace-1"); - - const state = useTabsStore.getState(); - - // Should have 4 tabs now (group + 3 children) - expect(state.tabs.length).toBe(4); - - // The group's layout should show child-1 was split - const updatedGroup = state.tabs.find( - (t) => t.id === "group-1" && t.type === TabType.Group, - ) as TabGroup; - expect(updatedGroup).toBeDefined(); - - // child-1's position should now be a nested layout - if (typeof updatedGroup.layout === "string" || !updatedGroup.layout) return; - expect(updatedGroup.layout.first).toEqual({ - direction: "row", - first: "child-1", - second: expect.any(String), - splitPercentage: 50, - }); - - // New child should have the group as parent - const newChild = state.tabs.find( - (t) => - t.type === TabType.Single && - t.parentId === "group-1" && - t.id !== "child-1" && - t.id !== "child-2", - ); - expect(newChild).toBeDefined(); - }); -}); - -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) => - typeof groupTab.layout !== "string" && - groupTab.layout && - "second" in groupTab.layout && - t.id === groupTab.layout.second && - t.type === TabType.Single, - ); - expect(newChild).toBeDefined(); - expect(newChild?.parentId).toBe(groupTab.id); - - // 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(); - }); - - test("active group uses last-focused child from history", () => { - const store = useTabsStore.getState(); - - const groupTab: TabGroup = { - id: "group-1", - title: "Group", - workspaceId: "workspace-1", - type: TabType.Group, - layout: { - direction: "column" as const, - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1 = { - id: "child-1", - title: "Child 1", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - const child2 = { - id: "child-2", - title: "Child 2", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - useTabsStore.setState({ - tabs: [groupTab, child1 as any, child2 as any], - activeTabIds: { "workspace-1": "group-1" }, - // child-2 was most recently focused - tabHistoryStacks: { "workspace-1": ["child-2", "child-1"] }, - }); - - // Split with group as active - should use child-2 from history - store.splitTabHorizontal("workspace-1"); - - const state = useTabsStore.getState(); - - // Should have 4 tabs now (group + 3 children) - expect(state.tabs.length).toBe(4); - - // The group's layout should show child-2 was split - const updatedGroup = state.tabs.find( - (t) => t.id === "group-1" && t.type === TabType.Group, - ) as TabGroup; - expect(updatedGroup).toBeDefined(); - - // child-2's position should now be a nested layout with column direction - if (typeof updatedGroup.layout === "string" || !updatedGroup.layout) return; - expect(updatedGroup.layout.second).toEqual({ - direction: "column", - first: "child-2", - second: expect.any(String), - splitPercentage: 50, - }); - }); - - test("active group falls back to first child when history is empty", () => { - const store = useTabsStore.getState(); - - const groupTab: TabGroup = { - id: "group-1", - title: "Group", - workspaceId: "workspace-1", - type: TabType.Group, - layout: { - direction: "column" as const, - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1 = { - id: "child-1", - title: "Child 1", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - const child2 = { - id: "child-2", - title: "Child 2", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - useTabsStore.setState({ - tabs: [groupTab, child1 as any, child2 as any], - activeTabIds: { "workspace-1": "group-1" }, - // Empty history - tabHistoryStacks: { "workspace-1": [] }, - }); - - // Split with group as active - should fall back to first child (child-1) - store.splitTabHorizontal("workspace-1"); - - const state = useTabsStore.getState(); - - // Should have 4 tabs now (group + 3 children) - expect(state.tabs.length).toBe(4); - - // The group's layout should show child-1 was split (first in layout) - const updatedGroup = state.tabs.find( - (t) => t.id === "group-1" && t.type === TabType.Group, - ) as TabGroup; - expect(updatedGroup).toBeDefined(); - - // child-1's position should now be a nested layout with column direction - if (typeof updatedGroup.layout === "string" || !updatedGroup.layout) return; - expect(updatedGroup.layout.first).toEqual({ - direction: "column", - first: "child-1", - second: expect.any(String), - splitPercentage: 50, - }); - }); - - test("active child tab splits within group without explicit path", () => { - const store = useTabsStore.getState(); - - const groupTab: TabGroup = { - id: "group-1", - title: "Group", - workspaceId: "workspace-1", - type: TabType.Group, - layout: { - direction: "column" as const, - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1 = { - id: "child-1", - title: "Child 1", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - const child2 = { - id: "child-2", - title: "Child 2", - workspaceId: "workspace-1", - type: TabType.Single, - parentId: "group-1", - } as const; - - useTabsStore.setState({ - tabs: [groupTab, child1 as any, child2 as any], - // child-1 is the active tab (not the group) - activeTabIds: { "workspace-1": "child-1" }, - tabHistoryStacks: { "workspace-1": [] }, - }); - - // Split with child as active - should split child-1 within the group - store.splitTabHorizontal("workspace-1"); - - const state = useTabsStore.getState(); - - // Should have 4 tabs now (group + 3 children) - expect(state.tabs.length).toBe(4); - - // The group's layout should show child-1 was split - const updatedGroup = state.tabs.find( - (t) => t.id === "group-1" && t.type === TabType.Group, - ) as TabGroup; - expect(updatedGroup).toBeDefined(); - - // child-1's position should now be a nested layout with column direction - if (typeof updatedGroup.layout === "string" || !updatedGroup.layout) return; - expect(updatedGroup.layout.first).toEqual({ - direction: "column", - first: "child-1", - second: expect.any(String), - splitPercentage: 50, - }); - - // New child should have the group as parent - const newChild = state.tabs.find( - (t) => - t.type === TabType.Single && - t.parentId === "group-1" && - t.id !== "child-1" && - t.id !== "child-2", - ); - expect(newChild).toBeDefined(); - }); -}); diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index fc8dd848b9c..8bb601da3ba 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -1,155 +1,554 @@ +import type { MosaicNode } from "react-mosaic-component"; +import { updateTree } from "react-mosaic-component"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; import { electronStorage } from "../../lib/electron-storage"; -import { handleDragTabToTab } from "./drag-logic"; +import type { WindowsState, WindowsStore } from "./types"; import { - getActiveTab, - getLastActiveTabId, - getTabsByWorkspace, - handleSetActiveTab, -} from "./helpers/active-tab"; -import { - handleAddChildTabToGroup, - handleRemoveChildTabFromGroup, - handleUngroupTab, - handleUngroupTabs, - handleUpdateTabGroupLayout, -} from "./helpers/group-operations"; -import { - handleSplitTabHorizontal, - handleSplitTabVertical, -} from "./helpers/split-operations"; -import { - handleAddTab, - handleMarkTabAsUsed, - handleRemoveTab, - handleRenameTab, -} from "./helpers/tab-crud"; -import { - handleReorderTabById, - handleReorderTabs, -} from "./helpers/tab-ordering"; -import { type TabsStore, TabType } from "./types"; + createPane, + createWindowWithPane, + extractPaneIdsFromLayout, + getFirstPaneId, + getPaneIdsForWindow, + isLastPaneInWindow, + removePaneFromLayout, +} from "./utils"; import { killTerminalForTab } from "./utils/terminal-cleanup"; -export const useTabsStore = create()( +/** + * Finds the next best window to activate when closing a window. + * Priority order: + * 1. Most recently used window from history stack + * 2. Next/previous window by position + * 3. Any remaining window in the workspace + */ +const findNextWindow = ( + state: WindowsState, + windowIdToClose: string, +): string | null => { + const windowToClose = state.windows.find((w) => w.id === windowIdToClose); + if (!windowToClose) return null; + + const workspaceId = windowToClose.workspaceId; + const workspaceWindows = state.windows.filter( + (w) => w.workspaceId === workspaceId && w.id !== windowIdToClose, + ); + + if (workspaceWindows.length === 0) return null; + + // Try history first + const historyStack = state.windowHistoryStacks[workspaceId] || []; + for (const historyWindowId of historyStack) { + if (historyWindowId === windowIdToClose) continue; + if (workspaceWindows.some((w) => w.id === historyWindowId)) { + return historyWindowId; + } + } + + // Try position-based (next, then previous) + const allWorkspaceWindows = state.windows.filter( + (w) => w.workspaceId === workspaceId, + ); + const currentIndex = allWorkspaceWindows.findIndex( + (w) => w.id === windowIdToClose, + ); + + if (currentIndex !== -1) { + const nextIndex = currentIndex + 1; + const prevIndex = currentIndex - 1; + + if ( + nextIndex < allWorkspaceWindows.length && + allWorkspaceWindows[nextIndex].id !== windowIdToClose + ) { + return allWorkspaceWindows[nextIndex].id; + } + if ( + prevIndex >= 0 && + allWorkspaceWindows[prevIndex].id !== windowIdToClose + ) { + return allWorkspaceWindows[prevIndex].id; + } + } + + // Fallback to first available + return workspaceWindows[0]?.id || null; +}; + +export const useWindowsStore = create()( devtools( persist( (set, get) => ({ - tabs: [], - activeTabIds: {}, - tabHistoryStacks: {}, - - addTab: (workspaceId, type = TabType.Single) => { - let tabId = ""; - set((state) => { - const result = handleAddTab(state, workspaceId, type); - tabId = result.tabId; - return result.newState; + windows: [], + panes: {}, + activeWindowIds: {}, + focusedPaneIds: {}, + windowHistoryStacks: {}, + + // Window operations + addWindow: (workspaceId) => { + const state = get(); + const { window, pane } = createWindowWithPane( + workspaceId, + state.panes, + state.windows, + ); + + const currentActiveId = state.activeWindowIds[workspaceId]; + const historyStack = state.windowHistoryStacks[workspaceId] || []; + const newHistoryStack = currentActiveId + ? [ + currentActiveId, + ...historyStack.filter((id) => id !== currentActiveId), + ] + : historyStack; + + set({ + windows: [...state.windows, window], + panes: { ...state.panes, [pane.id]: pane }, + activeWindowIds: { + ...state.activeWindowIds, + [workspaceId]: window.id, + }, + focusedPaneIds: { + ...state.focusedPaneIds, + [window.id]: pane.id, + }, + windowHistoryStacks: { + ...state.windowHistoryStacks, + [workspaceId]: newHistoryStack, + }, }); - return tabId; + + return { windowId: window.id, paneId: pane.id }; }, - removeTab: (id) => { + removeWindow: (windowId) => { const state = get(); - const result = handleRemoveTab(state, id); - if (result === null) { - const tabToRemove = state.tabs.find((tab) => tab.id === id); - if (tabToRemove?.parentId) { - get().removeChildTabFromGroup(tabToRemove.parentId, id); - } - return; + const windowToRemove = state.windows.find((w) => w.id === windowId); + if (!windowToRemove) return; + + // Kill all terminals for panes in this window + const paneIds = getPaneIdsForWindow(state.panes, windowId); + for (const paneId of paneIds) { + killTerminalForTab(paneId); + } + + // Remove all panes belonging to this window + const newPanes = { ...state.panes }; + for (const paneId of paneIds) { + delete newPanes[paneId]; } - killTerminalForTab(id); - set(() => result); + + // Remove window + const newWindows = state.windows.filter((w) => w.id !== windowId); + + // Update active window if needed + const workspaceId = windowToRemove.workspaceId; + const newActiveWindowIds = { ...state.activeWindowIds }; + const newHistoryStack = ( + state.windowHistoryStacks[workspaceId] || [] + ).filter((id) => id !== windowId); + + if (state.activeWindowIds[workspaceId] === windowId) { + newActiveWindowIds[workspaceId] = findNextWindow(state, windowId); + } + + // Clean up focused pane tracking + const newFocusedPaneIds = { ...state.focusedPaneIds }; + delete newFocusedPaneIds[windowId]; + + set({ + windows: newWindows, + panes: newPanes, + activeWindowIds: newActiveWindowIds, + focusedPaneIds: newFocusedPaneIds, + windowHistoryStacks: { + ...state.windowHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }); }, - renameTab: (id, newTitle) => { - set((state) => handleRenameTab(state, id, newTitle)); + renameWindow: (windowId, newName) => { + set((state) => ({ + windows: state.windows.map((w) => + w.id === windowId ? { ...w, name: newName } : w, + ), + })); }, - setActiveTab: (workspaceId, tabId) => { - set((state) => handleSetActiveTab(state, workspaceId, tabId)); + setActiveWindow: (workspaceId, windowId) => { + const state = get(); + const window = state.windows.find((w) => w.id === windowId); + if (!window || window.workspaceId !== workspaceId) { + return; + } + + const currentActiveId = state.activeWindowIds[workspaceId]; + const historyStack = state.windowHistoryStacks[workspaceId] || []; + + let newHistoryStack = historyStack.filter((id) => id !== windowId); + if (currentActiveId && currentActiveId !== windowId) { + newHistoryStack = [ + currentActiveId, + ...newHistoryStack.filter((id) => id !== currentActiveId), + ]; + } + + // Clear needsAttention for the focused pane in the window being activated + const focusedPaneId = state.focusedPaneIds[windowId]; + const newPanes = { ...state.panes }; + if (focusedPaneId && newPanes[focusedPaneId]?.needsAttention) { + newPanes[focusedPaneId] = { + ...newPanes[focusedPaneId], + needsAttention: false, + }; + } + + set({ + activeWindowIds: { + ...state.activeWindowIds, + [workspaceId]: windowId, + }, + windowHistoryStacks: { + ...state.windowHistoryStacks, + [workspaceId]: newHistoryStack, + }, + panes: newPanes, + }); }, - reorderTabs: (workspaceId, startIndex, endIndex) => { - set((state) => - handleReorderTabs(state, workspaceId, startIndex, endIndex), + reorderWindows: (workspaceId, startIndex, endIndex) => { + const state = get(); + const workspaceWindows = state.windows.filter( + (w) => w.workspaceId === workspaceId, + ); + const otherWindows = state.windows.filter( + (w) => w.workspaceId !== workspaceId, ); - }, - reorderTabById: (tabId, targetIndex) => { - set((state) => handleReorderTabById(state, tabId, targetIndex)); + const [removed] = workspaceWindows.splice(startIndex, 1); + workspaceWindows.splice(endIndex, 0, removed); + + set({ windows: [...otherWindows, ...workspaceWindows] }); }, - markTabAsUsed: (id) => { - set((state) => handleMarkTabAsUsed(state, id)); + reorderWindowById: (windowId, targetIndex) => { + const state = get(); + const windowToMove = state.windows.find((w) => w.id === windowId); + if (!windowToMove) return; + + const workspaceId = windowToMove.workspaceId; + const workspaceWindows = state.windows.filter( + (w) => w.workspaceId === workspaceId, + ); + const otherWindows = state.windows.filter( + (w) => w.workspaceId !== workspaceId, + ); + + const currentIndex = workspaceWindows.findIndex( + (w) => w.id === windowId, + ); + if (currentIndex === -1) return; + + workspaceWindows.splice(currentIndex, 1); + workspaceWindows.splice(targetIndex, 0, windowToMove); + + set({ windows: [...otherWindows, ...workspaceWindows] }); }, - updateTabGroupLayout: (id, layout) => { - set((state) => handleUpdateTabGroupLayout(state, id, layout)); + updateWindowLayout: (windowId, layout) => { + const state = get(); + const window = state.windows.find((w) => w.id === windowId); + if (!window) return; + + // Get panes that should exist based on the new layout + const newPaneIds = new Set(extractPaneIdsFromLayout(layout)); + const oldPaneIds = new Set(extractPaneIdsFromLayout(window.layout)); + + // Find removed panes and clean them up + const removedPaneIds = Array.from(oldPaneIds).filter( + (id) => !newPaneIds.has(id), + ); + + const newPanes = { ...state.panes }; + for (const paneId of removedPaneIds) { + killTerminalForTab(paneId); + delete newPanes[paneId]; + } + + // Update focused pane if it was removed + let newFocusedPaneIds = state.focusedPaneIds; + const currentFocusedPaneId = state.focusedPaneIds[windowId]; + if ( + currentFocusedPaneId && + removedPaneIds.includes(currentFocusedPaneId) + ) { + newFocusedPaneIds = { + ...state.focusedPaneIds, + [windowId]: getFirstPaneId(layout), + }; + } + + set({ + windows: state.windows.map((w) => + w.id === windowId ? { ...w, layout } : w, + ), + panes: newPanes, + focusedPaneIds: newFocusedPaneIds, + }); }, - addChildTabToGroup: (groupId, childTabId) => { - set((state) => handleAddChildTabToGroup(state, groupId, childTabId)); + // Pane operations + addPane: (windowId) => { + const state = get(); + const window = state.windows.find((w) => w.id === windowId); + if (!window) return ""; + + const newPane = createPane(windowId, "terminal", state.panes); + + // Add pane to layout (append to the right) + const newLayout: MosaicNode = { + direction: "row", + first: window.layout, + second: newPane.id, + splitPercentage: 50, + }; + + set({ + windows: state.windows.map((w) => + w.id === windowId ? { ...w, layout: newLayout } : w, + ), + panes: { ...state.panes, [newPane.id]: newPane }, + focusedPaneIds: { + ...state.focusedPaneIds, + [windowId]: newPane.id, + }, + }); + + return newPane.id; }, - removeChildTabFromGroup: (groupId, childTabId) => { - killTerminalForTab(childTabId); - set((state) => - handleRemoveChildTabFromGroup(state, groupId, childTabId), - ); + removePane: (paneId) => { + const state = get(); + const pane = state.panes[paneId]; + if (!pane) return; + + const window = state.windows.find((w) => w.id === pane.windowId); + if (!window) return; + + // If this is the last pane, remove the entire window + if (isLastPaneInWindow(state.panes, window.id)) { + get().removeWindow(window.id); + return; + } + + // Kill the terminal + killTerminalForTab(paneId); + + // Remove pane from layout + const newLayout = removePaneFromLayout(window.layout, paneId); + if (!newLayout) { + // This shouldn't happen since we checked isLastPaneInWindow + get().removeWindow(window.id); + return; + } + + // Remove pane from panes map + const newPanes = { ...state.panes }; + delete newPanes[paneId]; + + // Update focused pane if needed + let newFocusedPaneIds = state.focusedPaneIds; + if (state.focusedPaneIds[window.id] === paneId) { + newFocusedPaneIds = { + ...state.focusedPaneIds, + [window.id]: getFirstPaneId(newLayout), + }; + } + + set({ + windows: state.windows.map((w) => + w.id === window.id ? { ...w, layout: newLayout } : w, + ), + panes: newPanes, + focusedPaneIds: newFocusedPaneIds, + }); }, - dragTabToTab: (draggedTabId, targetTabId) => { - set((state) => handleDragTabToTab(draggedTabId, targetTabId, state)); + setFocusedPane: (windowId, paneId) => { + const state = get(); + const pane = state.panes[paneId]; + if (!pane || pane.windowId !== windowId) return; + + // Clear needsAttention for the pane being focused + const newPanes = pane.needsAttention + ? { + ...state.panes, + [paneId]: { ...pane, needsAttention: false }, + } + : state.panes; + + set({ + focusedPaneIds: { + ...state.focusedPaneIds, + [windowId]: paneId, + }, + panes: newPanes, + }); }, - ungroupTab: (tabId, targetIndex) => { - set((state) => handleUngroupTab(state, tabId, targetIndex)); + markPaneAsUsed: (paneId) => { + set((state) => ({ + panes: { + ...state.panes, + [paneId]: state.panes[paneId] + ? { ...state.panes[paneId], isNew: false } + : state.panes[paneId], + }, + })); }, - ungroupTabs: (groupId) => { - set((state) => handleUngroupTabs(state, groupId)); + setNeedsAttention: (paneId, needsAttention) => { + set((state) => ({ + panes: { + ...state.panes, + [paneId]: state.panes[paneId] + ? { ...state.panes[paneId], needsAttention } + : state.panes[paneId], + }, + })); }, - getTabsByWorkspace: (workspaceId) => { - return getTabsByWorkspace(get(), workspaceId); + // Split operations + splitPaneVertical: (windowId, sourcePaneId, path) => { + const state = get(); + const window = state.windows.find((w) => w.id === windowId); + if (!window) return; + + const sourcePane = state.panes[sourcePaneId]; + if (!sourcePane || sourcePane.windowId !== windowId) return; + + const newPane = createPane(windowId, "terminal", state.panes); + + let newLayout: MosaicNode; + if (path && path.length > 0) { + // Split at a specific path in the layout + newLayout = updateTree(window.layout, [ + { + path, + spec: { + $set: { + direction: "row", + first: sourcePaneId, + second: newPane.id, + splitPercentage: 50, + }, + }, + }, + ]); + } else { + // Split the pane directly + newLayout = { + direction: "row", + first: window.layout, + second: newPane.id, + splitPercentage: 50, + }; + } + + set({ + windows: state.windows.map((w) => + w.id === windowId ? { ...w, layout: newLayout } : w, + ), + panes: { ...state.panes, [newPane.id]: newPane }, + focusedPaneIds: { + ...state.focusedPaneIds, + [windowId]: newPane.id, + }, + }); }, - getActiveTab: (workspaceId) => { - return getActiveTab(get(), workspaceId); + splitPaneHorizontal: (windowId, sourcePaneId, path) => { + const state = get(); + const window = state.windows.find((w) => w.id === windowId); + if (!window) return; + + const sourcePane = state.panes[sourcePaneId]; + if (!sourcePane || sourcePane.windowId !== windowId) return; + + const newPane = createPane(windowId, "terminal", state.panes); + + let newLayout: MosaicNode; + if (path && path.length > 0) { + // Split at a specific path in the layout + newLayout = updateTree(window.layout, [ + { + path, + spec: { + $set: { + direction: "column", + first: sourcePaneId, + second: newPane.id, + splitPercentage: 50, + }, + }, + }, + ]); + } else { + // Split the pane directly + newLayout = { + direction: "column", + first: window.layout, + second: newPane.id, + splitPercentage: 50, + }; + } + + set({ + windows: state.windows.map((w) => + w.id === windowId ? { ...w, layout: newLayout } : w, + ), + panes: { ...state.panes, [newPane.id]: newPane }, + focusedPaneIds: { + ...state.focusedPaneIds, + [windowId]: newPane.id, + }, + }); }, - getLastActiveTabId: (workspaceId) => { - return getLastActiveTabId(get(), workspaceId); + // Query helpers + getWindowsByWorkspace: (workspaceId) => { + return get().windows.filter((w) => w.workspaceId === workspaceId); }, - splitTabVertical: (workspaceId, sourceTabId, path) => { - set((state) => - handleSplitTabVertical(state, workspaceId, sourceTabId, path), - ); + + getActiveWindow: (workspaceId) => { + const state = get(); + const activeWindowId = state.activeWindowIds[workspaceId]; + if (!activeWindowId) return null; + return state.windows.find((w) => w.id === activeWindowId) || null; }, - splitTabHorizontal: (workspaceId, sourceTabId, path) => { - set((state) => - handleSplitTabHorizontal(state, workspaceId, sourceTabId, path), + getPanesForWindow: (windowId) => { + const state = get(); + return Object.values(state.panes).filter( + (p) => p.windowId === windowId, ); }, - setNeedsAttention: (tabId, needsAttention) => { - set((state) => ({ - ...state, - tabs: state.tabs.map((tab) => - tab.id === tabId ? { ...tab, needsAttention } : tab, - ), - })); + getFocusedPane: (windowId) => { + const state = get(); + const focusedPaneId = state.focusedPaneIds[windowId]; + if (!focusedPaneId) return null; + return state.panes[focusedPaneId] || null; }, }), { - name: "tabs-storage", + name: "windows-storage", storage: electronStorage, }, ), - { name: "TabsStore" }, + { name: "WindowsStore" }, ), ); diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index aceeda08336..9987e79e794 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -1,66 +1,90 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; -export enum TabType { - Single = "single", - Group = "group", -} +/** + * Pane types that can be displayed within a window + */ +export type PaneType = "terminal" | "editor" | "browser" | "preview" | "port"; -interface BaseTab { +/** + * A Pane represents a single terminal or content area within a Window. + * Panes always belong to a Window and are referenced by ID in the Window's layout. + */ +export interface Pane { id: string; - title: string; - workspaceId: string; + windowId: string; + type: PaneType; + name: string; isNew?: boolean; - parentId?: string; needsAttention?: boolean; + // Terminal-specific properties + command?: string | null; + cwd?: string; + // Browser/preview-specific properties + url?: string; } -export interface SingleTab extends BaseTab { - type: TabType.Single; -} - -export interface TabGroup extends BaseTab { - type: TabType.Group; - layout: MosaicNode | null; +/** + * A Window is a container that holds one or more Panes in a Mosaic layout. + * Windows are displayed in the sidebar and always have at least one Pane. + */ +export interface Window { + id: string; + name: string; + workspaceId: string; + layout: MosaicNode; // Always defined, leaves are paneIds + createdAt: number; } -export type Tab = SingleTab | TabGroup; - -export interface TabsState { - tabs: Tab[]; - activeTabIds: Record; - tabHistoryStacks: Record; +/** + * State for the windows/panes store + */ +export interface WindowsState { + windows: Window[]; + panes: Record; + activeWindowIds: Record; // workspaceId → windowId + focusedPaneIds: Record; // windowId → paneId (last focused pane in each window) + windowHistoryStacks: Record; // workspaceId → windowId[] (MRU history) } -export interface TabsStore extends TabsState { - addTab: (workspaceId: string, type?: TabType) => string; - removeTab: (id: string) => void; - renameTab: (id: string, newTitle: string) => void; - setActiveTab: (workspaceId: string, tabId: string) => void; - reorderTabs: ( +/** + * Actions available on the windows store + */ +export interface WindowsStore extends WindowsState { + // Window operations + addWindow: (workspaceId: string) => { windowId: string; paneId: string }; + removeWindow: (windowId: string) => void; + renameWindow: (windowId: string, newName: string) => void; + setActiveWindow: (workspaceId: string, windowId: string) => void; + reorderWindows: ( workspaceId: string, startIndex: number, endIndex: number, ) => void; - reorderTabById: (tabId: string, targetIndex: number) => void; - markTabAsUsed: (id: string) => void; - updateTabGroupLayout: (id: string, layout: MosaicNode) => void; - addChildTabToGroup: (groupId: string, childTabId: string) => void; - removeChildTabFromGroup: (groupId: string, childTabId: string) => void; - dragTabToTab: (draggedTabId: string, targetTabId: string) => void; - ungroupTab: (tabId: string, targetIndex?: number) => void; - ungroupTabs: (groupId: string) => void; - splitTabVertical: ( - workspaceId: string, - sourceTabId?: string, + reorderWindowById: (windowId: string, targetIndex: number) => void; + updateWindowLayout: (windowId: string, layout: MosaicNode) => void; + + // Pane operations + addPane: (windowId: string) => string; + removePane: (paneId: string) => void; + setFocusedPane: (windowId: string, paneId: string) => void; + markPaneAsUsed: (paneId: string) => void; + setNeedsAttention: (paneId: string, needsAttention: boolean) => void; + + // Split operations + splitPaneVertical: ( + windowId: string, + sourcePaneId: string, path?: MosaicBranch[], ) => void; - splitTabHorizontal: ( - workspaceId: string, - sourceTabId?: string, + splitPaneHorizontal: ( + windowId: string, + sourcePaneId: string, path?: MosaicBranch[], ) => void; - getTabsByWorkspace: (workspaceId: string) => Tab[]; - getActiveTab: (workspaceId: string) => Tab | null; - getLastActiveTabId: (workspaceId: string) => string | null; - setNeedsAttention: (tabId: string, needsAttention: boolean) => void; + + // Query helpers + getWindowsByWorkspace: (workspaceId: string) => Window[]; + getActiveWindow: (workspaceId: string) => Window | null; + getPanesForWindow: (windowId: string) => Pane[]; + getFocusedPane: (windowId: string) => Pane | null; } diff --git a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts index 9f20a7e9288..6f45da9d639 100644 --- a/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts +++ b/apps/desktop/src/renderer/stores/tabs/useAgentHookListener.ts @@ -1,6 +1,6 @@ import { trpc } from "renderer/lib/trpc"; import { useSetActiveWorkspace } from "renderer/react-query/workspaces/useSetActiveWorkspace"; -import { useTabsStore } from "./store"; +import { useWindowsStore } from "./store"; /** * Hook that listens for notification events via tRPC subscription. @@ -13,25 +13,43 @@ export function useAgentHookListener() { trpc.notifications.subscribe.useSubscription(undefined, { onData: (event) => { if (event.type === "agent-complete") { - const { tabId, workspaceId } = event.data; - const state = useTabsStore.getState(); + // paneId is passed as tabId for backwards compatibility + const { tabId: paneId, workspaceId } = event.data; + const state = useWindowsStore.getState(); - // Only show red dot if not already viewing this tab + // Find the window containing this pane + const pane = state.panes[paneId]; + if (!pane) return; + + // Only show red dot if not already viewing this pane + const activeWindowId = state.activeWindowIds[workspaceId]; + const focusedPaneId = + activeWindowId && state.focusedPaneIds[activeWindowId]; const isAlreadyActive = - activeWorkspace?.id === workspaceId && - state.activeTabIds[workspaceId] === tabId; + activeWorkspace?.id === workspaceId && focusedPaneId === paneId; if (!isAlreadyActive) { - state.setNeedsAttention(tabId, true); + state.setNeedsAttention(paneId, true); } } else if (event.type === "focus-tab") { - const { tabId, workspaceId } = event.data; - // Switch to the workspace first (with proper invalidation), then set active tab + // paneId is passed as tabId for backwards compatibility + const { tabId: paneId, workspaceId } = event.data; + const state = useWindowsStore.getState(); + + // Find the window containing this pane + const pane = state.panes[paneId]; + if (!pane) return; + + const windowId = pane.windowId; + + // Switch to the workspace first (with proper invalidation), then set active window and focused pane setActiveWorkspace.mutate( { id: workspaceId }, { onSuccess: () => { - useTabsStore.getState().setActiveTab(workspaceId, tabId); + const currentState = useWindowsStore.getState(); + currentState.setActiveWindow(workspaceId, windowId); + currentState.setFocusedPane(windowId, paneId); }, }, ); diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 4e506a2fb25..f7792f2373d 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -1,50 +1,208 @@ -import { type Tab, TabType } from "./types"; +import type { Pane, PaneType, Window } from "./types"; import { generateTerminalName } from "./utils/terminal-naming"; /** - * Helper function to get child tab IDs for a given parent ID + * Generates a unique ID with the given prefix */ -export const getChildTabIds = (tabs: Tab[], parentId: string): string[] => { - return tabs.filter((t) => t.parentId === parentId).map((t) => t.id); +export const generateId = (prefix: string): string => { + return `${prefix}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; }; -export const createNewTab = ( - workspaceId: string, - type: TabType = TabType.Single, - existingTabs: Tab[] = [], -): Tab => { - const id = `tab-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; - - // Generate unique terminal name based on existing single tabs in the current workspace - const existingNames = existingTabs - .filter( - (tab) => tab.workspaceId === workspaceId && tab.type === TabType.Single, - ) - .map((tab) => tab.title); - const title = - type === TabType.Single - ? generateTerminalName(existingNames) - : "New Split View"; - - const baseTab = { +/** + * Gets the display name for a window + * Now just returns the stored name since names are static at creation + */ +export const getWindowDisplayName = (window: Window): string => { + return window.name || "Window"; +}; + +/** + * Extracts all pane IDs from a mosaic layout tree + */ +export const extractPaneIdsFromLayout = ( + layout: import("react-mosaic-component").MosaicNode, +): string[] => { + if (typeof layout === "string") { + return [layout]; + } + + return [ + ...extractPaneIdsFromLayout(layout.first), + ...extractPaneIdsFromLayout(layout.second), + ]; +}; + +/** + * Creates a new pane with the given properties + */ +export const createPane = ( + windowId: string, + type: PaneType = "terminal", + existingPanes: Record = {}, +): Pane => { + const id = generateId("pane"); + + // Generate unique terminal name based on existing panes + const existingNames = Object.values(existingPanes) + .filter((pane) => pane.type === "terminal") + .map((pane) => pane.name); + const name = + type === "terminal" ? generateTerminalName(existingNames) : `${type} 1`; + + return { id, - title, - workspaceId, + windowId, + type, + name, isNew: true, }; +}; - if (type === TabType.Single) { - return { - ...baseTab, - type: TabType.Single, - }; +/** + * Generates a static window name based on existing windows + * (e.g., "Window 1", "Window 2", finding the next available number) + */ +export const generateWindowName = (existingWindows: Window[]): string => { + const existingNumbers = existingWindows + .map((w) => { + const match = w.name.match(/^Window (\d+)$/); + return match ? Number.parseInt(match[1], 10) : 0; + }) + .filter((n) => n > 0); + + // Find the next available number + let nextNumber = 1; + while (existingNumbers.includes(nextNumber)) { + nextNumber++; } - // For group tabs, just return the basic structure - // Child tabs should be created separately and added via addChildTabToGroup + return `Window ${nextNumber}`; +}; + +/** + * Creates a new window with an initial pane atomically + * This ensures the invariant that windows always have at least one pane + */ +export const createWindowWithPane = ( + workspaceId: string, + existingPanes: Record = {}, + existingWindows: Window[] = [], +): { window: Window; pane: Pane } => { + const windowId = generateId("win"); + const pane = createPane(windowId, "terminal", existingPanes); + + // Filter to same workspace for window naming + const workspaceWindows = existingWindows.filter( + (w) => w.workspaceId === workspaceId, + ); + + const window: Window = { + id: windowId, + name: generateWindowName(workspaceWindows), + workspaceId, + layout: pane.id, // Single pane = leaf node + createdAt: Date.now(), + }; + + return { window, pane }; +}; + +/** + * Gets all pane IDs that belong to a specific window + */ +export const getPaneIdsForWindow = ( + panes: Record, + windowId: string, +): string[] => { + return Object.values(panes) + .filter((pane) => pane.windowId === windowId) + .map((pane) => pane.id); +}; + +/** + * Checks if a window has only one pane remaining + */ +export const isLastPaneInWindow = ( + panes: Record, + windowId: string, +): boolean => { + return getPaneIdsForWindow(panes, windowId).length === 1; +}; + +/** + * Removes a pane ID from a mosaic layout tree + * Returns null if the layout becomes empty after removal + */ +export const removePaneFromLayout = ( + layout: import("react-mosaic-component").MosaicNode | null, + paneIdToRemove: string, +): import("react-mosaic-component").MosaicNode | null => { + if (!layout) return null; + + // If layout is a leaf node (single pane ID) + if (typeof layout === "string") { + return layout === paneIdToRemove ? null : layout; + } + + // Recursively remove from both branches + const newFirst = removePaneFromLayout(layout.first, paneIdToRemove); + const newSecond = removePaneFromLayout(layout.second, paneIdToRemove); + + // If both branches are gone, return null + if (!newFirst && !newSecond) return null; + + // If one branch is gone, return the other + if (!newFirst) return newSecond; + if (!newSecond) return newFirst; + + // Both branches still exist, return updated layout return { - ...baseTab, - type: TabType.Group, - layout: null, + ...layout, + first: newFirst, + second: newSecond, }; }; + +/** + * Validates layout against valid pane IDs and removes any invalid references + */ +export const cleanLayout = ( + layout: import("react-mosaic-component").MosaicNode | null, + validPaneIds: Set, +): import("react-mosaic-component").MosaicNode | null => { + if (!layout) return null; + + if (typeof layout === "string") { + return validPaneIds.has(layout) ? layout : null; + } + + const newFirst = cleanLayout(layout.first, validPaneIds); + const newSecond = cleanLayout(layout.second, validPaneIds); + + if (!newFirst && !newSecond) return null; + if (!newFirst) return newSecond; + if (!newSecond) return newFirst; + + // If children are identical references, return original layout to avoid churn + if (newFirst === layout.first && newSecond === layout.second) { + return layout; + } + + return { + ...layout, + first: newFirst, + second: newSecond, + }; +}; + +/** + * Gets the first pane ID from a layout (useful for focus fallback) + */ +export const getFirstPaneId = ( + layout: import("react-mosaic-component").MosaicNode, +): string => { + if (typeof layout === "string") { + return layout; + } + return getFirstPaneId(layout.first); +}; From 3b1ea88ca0d055be26c63137b8dc4a5c3f60b662 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 1 Dec 2025 16:16:04 -0800 Subject: [PATCH 2/5] Remove trivial hooks wrapper, use useWindowsStore directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Delete hooks.ts containing ~20 one-liner wrapper hooks - Update all consumers to use useWindowsStore selectors directly - Simplify PaneType to just "terminal" (removed speculative unused types) - Remove unused Pane properties (command, cwd, url) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 6 +- .../TabsContent/Terminal/Terminal.tsx | 7 ++- .../TabsContent/WindowView/index.tsx | 24 +++----- .../ContentView/TabsContent/index.tsx | 8 +-- .../Sidebar/TabsView/WindowItem/index.tsx | 19 +++---- .../WorkspaceView/Sidebar/TabsView/index.tsx | 15 ++--- .../main/components/WorkspaceView/index.tsx | 21 +++---- .../src/renderer/screens/main/index.tsx | 17 ++---- .../desktop/src/renderer/stores/tabs/hooks.ts | 57 ------------------- .../desktop/src/renderer/stores/tabs/index.ts | 1 - .../desktop/src/renderer/stores/tabs/types.ts | 7 +-- 11 files changed, 47 insertions(+), 135 deletions(-) delete mode 100644 apps/desktop/src/renderer/stores/tabs/hooks.ts diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx index e94aab90b5e..3bbc7512ea0 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceItem.tsx @@ -8,8 +8,8 @@ import { useReorderWorkspaces, useSetActiveWorkspace, } from "renderer/react-query/workspaces"; -import { usePanes, useWindows } from "renderer/stores"; import { useCloseSettings } from "renderer/stores/app-state"; +import { useWindowsStore } from "renderer/stores/tabs/store"; import { DeleteWorkspaceDialog } from "./DeleteWorkspaceDialog"; import { useWorkspaceRename } from "./useWorkspaceRename"; import { WorkspaceItemContextMenu } from "./WorkspaceItemContextMenu"; @@ -43,8 +43,8 @@ export function WorkspaceItem({ const reorderWorkspaces = useReorderWorkspaces(); const closeSettings = useCloseSettings(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const windows = useWindows(); - const panes = usePanes(); + const windows = useWindowsStore((s) => s.windows); + const panes = useWindowsStore((s) => s.panes); const rename = useWorkspaceRename(id, title); // Check if any pane in windows belonging to this workspace needs attention diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index 662a61b2e53..8623af5e93e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -3,7 +3,8 @@ import type { FitAddon } from "@xterm/addon-fit"; import type { Terminal as XTerm } from "@xterm/xterm"; import { useCallback, useEffect, useRef, useState } from "react"; import { trpc } from "renderer/lib/trpc"; -import { usePanes, useSetFocusedPane, useTerminalTheme } from "renderer/stores"; +import { useWindowsStore } from "renderer/stores/tabs/store"; +import { useTerminalTheme } from "renderer/stores/theme"; import { createTerminalInstance, getDefaultTerminalBg, @@ -17,7 +18,7 @@ import { shellEscapePaths } from "./utils"; export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { // tabId is actually paneId in the new model const paneId = tabId; - const panes = usePanes(); + const panes = useWindowsStore((s) => s.panes); const pane = panes[paneId]; const paneName = pane?.name || "Terminal"; const terminalRef = useRef(null); @@ -26,7 +27,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const isExitedRef = useRef(false); const pendingEventsRef = useRef([]); const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); - const setFocusedPane = useSetFocusedPane(); + const setFocusedPane = useWindowsStore((s) => s.setFocusedPane); const terminalTheme = useTerminalTheme(); // Required for resolving relative file paths in terminal commands diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/index.tsx index f0a30a2d6ee..7771024c396 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/WindowView/index.tsx @@ -8,15 +8,7 @@ import { type MosaicNode, } from "react-mosaic-component"; import { dragDropManager } from "renderer/lib/dnd"; -import { - useFocusedPaneIds, - useRemovePane, - useRemoveWindow, - useSetFocusedPane, - useSplitPaneHorizontal, - useSplitPaneVertical, - useUpdateWindowLayout, -} from "renderer/stores"; +import { useWindowsStore } from "renderer/stores/tabs/store"; import type { Pane, Window } from "renderer/stores/tabs/types"; import { cleanLayout, @@ -31,13 +23,13 @@ interface WindowViewProps { } export function WindowView({ window, panes }: WindowViewProps) { - const updateWindowLayout = useUpdateWindowLayout(); - const removePane = useRemovePane(); - const removeWindow = useRemoveWindow(); - const splitPaneHorizontal = useSplitPaneHorizontal(); - const splitPaneVertical = useSplitPaneVertical(); - const setFocusedPane = useSetFocusedPane(); - const focusedPaneIds = useFocusedPaneIds(); + const updateWindowLayout = useWindowsStore((s) => s.updateWindowLayout); + const removePane = useWindowsStore((s) => s.removePane); + const removeWindow = useWindowsStore((s) => s.removeWindow); + const splitPaneHorizontal = useWindowsStore((s) => s.splitPaneHorizontal); + const splitPaneVertical = useWindowsStore((s) => s.splitPaneVertical); + const setFocusedPane = useWindowsStore((s) => s.setFocusedPane); + const focusedPaneIds = useWindowsStore((s) => s.focusedPaneIds); const focusedPaneId = focusedPaneIds[window.id]; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index 8f9118b65d4..f91cf076f49 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -1,15 +1,15 @@ import { useMemo } from "react"; import { trpc } from "renderer/lib/trpc"; -import { useActiveWindowIds, usePanes, useWindows } from "renderer/stores"; +import { useWindowsStore } from "renderer/stores/tabs/store"; import { EmptyTabView } from "./EmptyTabView"; import { WindowView } from "./WindowView"; export function TabsContent() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id; - const allWindows = useWindows(); - const panes = usePanes(); - const activeWindowIds = useActiveWindowIds(); + const allWindows = useWindowsStore((s) => s.windows); + const panes = useWindowsStore((s) => s.panes); + const activeWindowIds = useWindowsStore((s) => s.activeWindowIds); const windowToRender = useMemo(() => { if (!activeWorkspaceId) return null; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/index.tsx index 9fe0383a927..9ac8c241bfc 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/WindowItem/index.tsx @@ -3,12 +3,7 @@ import { useRef, useState } from "react"; import { useDrag, useDrop } from "react-dnd"; import { HiMiniXMark } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; -import { - useRemoveWindow, - useRenameWindow, - useSetActiveWindow, - useWindowNeedsAttention, -} from "renderer/stores"; +import { useWindowsStore } from "renderer/stores/tabs/store"; import type { Window } from "renderer/stores/tabs/types"; import { getWindowDisplayName } from "renderer/stores/tabs/utils"; import { WindowContextMenu } from "./WindowContextMenu"; @@ -30,10 +25,14 @@ interface WindowItemProps { export function WindowItem({ window, index, isActive }: WindowItemProps) { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id; - const removeWindow = useRemoveWindow(); - const setActiveWindow = useSetActiveWindow(); - const renameWindow = useRenameWindow(); - const needsAttention = useWindowNeedsAttention(window.id); + const removeWindow = useWindowsStore((s) => s.removeWindow); + const setActiveWindow = useWindowsStore((s) => s.setActiveWindow); + const renameWindow = useWindowsStore((s) => s.renameWindow); + const needsAttention = useWindowsStore((s) => + Object.values(s.panes).some( + (p) => p.windowId === window.id && p.needsAttention, + ), + ); const [isRenaming, setIsRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx index a6cce29a82b..566214c0332 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx @@ -4,12 +4,7 @@ import { useMemo, useRef, useState } from "react"; import { useDrop } from "react-dnd"; import { HiMiniPlus } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; -import { - useActiveWindowIds, - useAddWindow, - useReorderWindowById, - useWindows, -} from "renderer/stores"; +import { useWindowsStore } from "renderer/stores/tabs/store"; import { WindowItem } from "./WindowItem"; const DRAG_TYPE = "WINDOW"; @@ -23,10 +18,10 @@ interface DragItem { export function TabsView() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id; - const allWindows = useWindows(); - const addWindow = useAddWindow(); - const reorderWindowById = useReorderWindowById(); - const activeWindowIds = useActiveWindowIds(); + const allWindows = useWindowsStore((s) => s.windows); + const addWindow = useWindowsStore((s) => s.addWindow); + const reorderWindowById = useWindowsStore((s) => s.reorderWindowById); + const activeWindowIds = useWindowsStore((s) => s.activeWindowIds); const [dropIndex, setDropIndex] = useState(null); const containerRef = useRef(null); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index 98d21bca293..fd9e4e0eda2 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -1,14 +1,7 @@ import { useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; -import { - useActiveWindowIds, - useAddWindow, - useFocusedPaneIds, - useRemovePane, - useSetActiveWindow, - useWindows, -} from "renderer/stores"; +import { useWindowsStore } from "renderer/stores/tabs/store"; import { HOTKEYS } from "shared/hotkeys"; import { ContentView } from "./ContentView"; import { Sidebar } from "./Sidebar"; @@ -16,12 +9,12 @@ import { Sidebar } from "./Sidebar"; export function WorkspaceView() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id; - const allWindows = useWindows(); - const activeWindowIds = useActiveWindowIds(); - const focusedPaneIds = useFocusedPaneIds(); - const addWindow = useAddWindow(); - const setActiveWindow = useSetActiveWindow(); - const removePane = useRemovePane(); + const allWindows = useWindowsStore((s) => s.windows); + const activeWindowIds = useWindowsStore((s) => s.activeWindowIds); + const focusedPaneIds = useWindowsStore((s) => s.focusedPaneIds); + const addWindow = useWindowsStore((s) => s.addWindow); + const setActiveWindow = useWindowsStore((s) => s.setActiveWindow); + const removePane = useWindowsStore((s) => s.removePane); const windows = useMemo( () => diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index 754f5d5bbeb..4c54e25a440 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -7,13 +7,8 @@ import { SetupConfigModal } from "renderer/components/SetupConfigModal"; import { trpc } from "renderer/lib/trpc"; import { useCurrentView, useOpenSettings } from "renderer/stores/app-state"; import { useSidebarStore } from "renderer/stores/sidebar-state"; -import { - useActiveWindowIds, - useAgentHookListener, - useFocusedPaneIds, - useSplitPaneHorizontal, - useSplitPaneVertical, -} from "renderer/stores/tabs"; +import { useWindowsStore } from "renderer/stores/tabs/store"; +import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener"; import { HOTKEYS } from "shared/hotkeys"; import { dragDropManager } from "../../lib/dnd"; import { AppFrame } from "./components/AppFrame"; @@ -41,10 +36,10 @@ export function MainScreen() { refetch, } = trpc.workspaces.getActive.useQuery(); const [isRetrying, setIsRetrying] = useState(false); - const splitPaneVertical = useSplitPaneVertical(); - const splitPaneHorizontal = useSplitPaneHorizontal(); - const activeWindowIds = useActiveWindowIds(); - const focusedPaneIds = useFocusedPaneIds(); + const splitPaneVertical = useWindowsStore((s) => s.splitPaneVertical); + const splitPaneHorizontal = useWindowsStore((s) => s.splitPaneHorizontal); + const activeWindowIds = useWindowsStore((s) => s.activeWindowIds); + const focusedPaneIds = useWindowsStore((s) => s.focusedPaneIds); // Listen for agent completion hooks from main process useAgentHookListener(); diff --git a/apps/desktop/src/renderer/stores/tabs/hooks.ts b/apps/desktop/src/renderer/stores/tabs/hooks.ts deleted file mode 100644 index 1813dfc5e63..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/hooks.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useWindowsStore } from "./store"; - -// Window hooks -export const useWindows = () => useWindowsStore((state) => state.windows); -export const usePanes = () => useWindowsStore((state) => state.panes); -export const useActiveWindowIds = () => - useWindowsStore((state) => state.activeWindowIds); -export const useFocusedPaneIds = () => - useWindowsStore((state) => state.focusedPaneIds); - -export const useAddWindow = () => useWindowsStore((state) => state.addWindow); -export const useRemoveWindow = () => - useWindowsStore((state) => state.removeWindow); -export const useRenameWindow = () => - useWindowsStore((state) => state.renameWindow); -export const useSetActiveWindow = () => - useWindowsStore((state) => state.setActiveWindow); -export const useReorderWindows = () => - useWindowsStore((state) => state.reorderWindows); -export const useReorderWindowById = () => - useWindowsStore((state) => state.reorderWindowById); -export const useUpdateWindowLayout = () => - useWindowsStore((state) => state.updateWindowLayout); - -// Pane hooks -export const useAddPane = () => useWindowsStore((state) => state.addPane); -export const useRemovePane = () => useWindowsStore((state) => state.removePane); -export const useSetFocusedPane = () => - useWindowsStore((state) => state.setFocusedPane); -export const useMarkPaneAsUsed = () => - useWindowsStore((state) => state.markPaneAsUsed); -export const useSetNeedsAttention = () => - useWindowsStore((state) => state.setNeedsAttention); - -// Split hooks -export const useSplitPaneVertical = () => - useWindowsStore((state) => state.splitPaneVertical); -export const useSplitPaneHorizontal = () => - useWindowsStore((state) => state.splitPaneHorizontal); - -// Query hooks -export const useGetWindowsByWorkspace = () => - useWindowsStore((state) => state.getWindowsByWorkspace); -export const useGetActiveWindow = () => - useWindowsStore((state) => state.getActiveWindow); -export const useGetPanesForWindow = () => - useWindowsStore((state) => state.getPanesForWindow); -export const useGetFocusedPane = () => - useWindowsStore((state) => state.getFocusedPane); - -// Derived state hooks (efficient selectors) -export const useWindowNeedsAttention = (windowId: string) => - useWindowsStore((state) => - Object.values(state.panes).some( - (p) => p.windowId === windowId && p.needsAttention, - ), - ); diff --git a/apps/desktop/src/renderer/stores/tabs/index.ts b/apps/desktop/src/renderer/stores/tabs/index.ts index 405e56ca67f..4684d253948 100644 --- a/apps/desktop/src/renderer/stores/tabs/index.ts +++ b/apps/desktop/src/renderer/stores/tabs/index.ts @@ -1,4 +1,3 @@ -export * from "./hooks"; export * from "./store"; export * from "./types"; export * from "./useAgentHookListener"; diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index 9987e79e794..f5d10d99a5c 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -3,7 +3,7 @@ import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; /** * Pane types that can be displayed within a window */ -export type PaneType = "terminal" | "editor" | "browser" | "preview" | "port"; +export type PaneType = "terminal"; /** * A Pane represents a single terminal or content area within a Window. @@ -16,11 +16,6 @@ export interface Pane { name: string; isNew?: boolean; needsAttention?: boolean; - // Terminal-specific properties - command?: string | null; - cwd?: string; - // Browser/preview-specific properties - url?: string; } /** From aa39e879f22deb11eff7bb0c719f151de8e4791c Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 1 Dec 2025 16:23:02 -0800 Subject: [PATCH 3/5] Fix flaky test assertion in terminal-manager.test.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test was incorrectly catching the "Directory should not exist" error which has no .code property, causing confusing "Received: undefined" failures instead of clear "directory still exists" messages. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/desktop/src/main/lib/terminal-manager.test.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/lib/terminal-manager.test.ts b/apps/desktop/src/main/lib/terminal-manager.test.ts index f4467f6e8b1..26466f93de7 100644 --- a/apps/desktop/src/main/lib/terminal-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal-manager.test.ts @@ -329,12 +329,11 @@ describe("TerminalManager", () => { testTmpDir, ".superset/terminal-history/workspace-1/tab-delete-history", ); - try { - await fs.stat(historyDir); - throw new Error("Directory should not exist"); - } catch (error) { - expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); - } + const exists = await fs + .stat(historyDir) + .then(() => true) + .catch(() => false); + expect(exists).toBe(false); }); it("should preserve history for recovery after kill without deleteHistory", async () => { From c61effcd85408b9fb671d0dbd04920a5e08f3528 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 1 Dec 2025 16:27:33 -0800 Subject: [PATCH 4/5] Clean up inline import() type syntax in utils.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace verbose inline import("react-mosaic-component").MosaicNode with proper import at top of file for cleaner type annotations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/desktop/src/renderer/stores/tabs/utils.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index f7792f2373d..4a16f2c98c5 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -1,3 +1,4 @@ +import type { MosaicNode } from "react-mosaic-component"; import type { Pane, PaneType, Window } from "./types"; import { generateTerminalName } from "./utils/terminal-naming"; @@ -20,7 +21,7 @@ export const getWindowDisplayName = (window: Window): string => { * Extracts all pane IDs from a mosaic layout tree */ export const extractPaneIdsFromLayout = ( - layout: import("react-mosaic-component").MosaicNode, + layout: MosaicNode, ): string[] => { if (typeof layout === "string") { return [layout]; @@ -134,9 +135,9 @@ export const isLastPaneInWindow = ( * Returns null if the layout becomes empty after removal */ export const removePaneFromLayout = ( - layout: import("react-mosaic-component").MosaicNode | null, + layout: MosaicNode | null, paneIdToRemove: string, -): import("react-mosaic-component").MosaicNode | null => { +): MosaicNode | null => { if (!layout) return null; // If layout is a leaf node (single pane ID) @@ -167,9 +168,9 @@ export const removePaneFromLayout = ( * Validates layout against valid pane IDs and removes any invalid references */ export const cleanLayout = ( - layout: import("react-mosaic-component").MosaicNode | null, + layout: MosaicNode | null, validPaneIds: Set, -): import("react-mosaic-component").MosaicNode | null => { +): MosaicNode | null => { if (!layout) return null; if (typeof layout === "string") { @@ -198,9 +199,7 @@ export const cleanLayout = ( /** * Gets the first pane ID from a layout (useful for focus fallback) */ -export const getFirstPaneId = ( - layout: import("react-mosaic-component").MosaicNode, -): string => { +export const getFirstPaneId = (layout: MosaicNode): string => { if (typeof layout === "string") { return layout; } From 79556cc4179cd5242d6293ac01ece394771b6ce1 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Mon, 1 Dec 2025 16:34:51 -0800 Subject: [PATCH 5/5] Simplify terminal naming to just "Terminal" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove numbered terminal naming (was globally numbered, not per-window) - All terminals now simply named "Terminal" - Delete unused terminal-naming.ts and its test 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../desktop/src/renderer/stores/tabs/store.ts | 7 +- .../desktop/src/renderer/stores/tabs/utils.ts | 14 +- .../stores/tabs/utils/terminal-naming.test.ts | 152 ------------------ .../stores/tabs/utils/terminal-naming.ts | 46 ------ 4 files changed, 5 insertions(+), 214 deletions(-) delete mode 100644 apps/desktop/src/renderer/stores/tabs/utils/terminal-naming.test.ts delete mode 100644 apps/desktop/src/renderer/stores/tabs/utils/terminal-naming.ts diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 8bb601da3ba..307e05dcf34 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -90,7 +90,6 @@ export const useWindowsStore = create()( const state = get(); const { window, pane } = createWindowWithPane( workspaceId, - state.panes, state.windows, ); @@ -306,7 +305,7 @@ export const useWindowsStore = create()( const window = state.windows.find((w) => w.id === windowId); if (!window) return ""; - const newPane = createPane(windowId, "terminal", state.panes); + const newPane = createPane(windowId); // Add pane to layout (append to the right) const newLayout: MosaicNode = { @@ -430,7 +429,7 @@ export const useWindowsStore = create()( const sourcePane = state.panes[sourcePaneId]; if (!sourcePane || sourcePane.windowId !== windowId) return; - const newPane = createPane(windowId, "terminal", state.panes); + const newPane = createPane(windowId); let newLayout: MosaicNode; if (path && path.length > 0) { @@ -478,7 +477,7 @@ export const useWindowsStore = create()( const sourcePane = state.panes[sourcePaneId]; if (!sourcePane || sourcePane.windowId !== windowId) return; - const newPane = createPane(windowId, "terminal", state.panes); + const newPane = createPane(windowId); let newLayout: MosaicNode; if (path && path.length > 0) { diff --git a/apps/desktop/src/renderer/stores/tabs/utils.ts b/apps/desktop/src/renderer/stores/tabs/utils.ts index 4a16f2c98c5..b95cab5148d 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils.ts @@ -1,6 +1,5 @@ import type { MosaicNode } from "react-mosaic-component"; import type { Pane, PaneType, Window } from "./types"; -import { generateTerminalName } from "./utils/terminal-naming"; /** * Generates a unique ID with the given prefix @@ -39,22 +38,14 @@ export const extractPaneIdsFromLayout = ( export const createPane = ( windowId: string, type: PaneType = "terminal", - existingPanes: Record = {}, ): Pane => { const id = generateId("pane"); - // Generate unique terminal name based on existing panes - const existingNames = Object.values(existingPanes) - .filter((pane) => pane.type === "terminal") - .map((pane) => pane.name); - const name = - type === "terminal" ? generateTerminalName(existingNames) : `${type} 1`; - return { id, windowId, type, - name, + name: "Terminal", isNew: true, }; }; @@ -86,11 +77,10 @@ export const generateWindowName = (existingWindows: Window[]): string => { */ export const createWindowWithPane = ( workspaceId: string, - existingPanes: Record = {}, existingWindows: Window[] = [], ): { window: Window; pane: Pane } => { const windowId = generateId("win"); - const pane = createPane(windowId, "terminal", existingPanes); + const pane = createPane(windowId); // Filter to same workspace for window naming const workspaceWindows = existingWindows.filter( diff --git a/apps/desktop/src/renderer/stores/tabs/utils/terminal-naming.test.ts b/apps/desktop/src/renderer/stores/tabs/utils/terminal-naming.test.ts deleted file mode 100644 index 0e958974b26..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/utils/terminal-naming.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { - DefaultTerminalNamingStrategy, - generateTerminalName, - type TerminalNamingStrategy, -} from "./terminal-naming"; - -describe("DefaultTerminalNamingStrategy", () => { - it("should return base name when no collision", () => { - const strategy = new DefaultTerminalNamingStrategy(); - const result = strategy.generateName([]); - expect(result).toBe("Terminal"); - }); - - it("should return base name when no collision with other names", () => { - const strategy = new DefaultTerminalNamingStrategy(); - const result = strategy.generateName(["Other Name", "Another Terminal"]); - expect(result).toBe("Terminal"); - }); - - it("should return Terminal (1) when base name exists", () => { - const strategy = new DefaultTerminalNamingStrategy(); - const result = strategy.generateName(["Terminal"]); - expect(result).toBe("Terminal (1)"); - }); - - it("should increment number on multiple collisions", () => { - const strategy = new DefaultTerminalNamingStrategy(); - const result = strategy.generateName([ - "Terminal", - "Terminal (1)", - "Terminal (2)", - ]); - expect(result).toBe("Terminal (3)"); - }); - - it("should find gaps in numbering", () => { - const strategy = new DefaultTerminalNamingStrategy(); - const result = strategy.generateName(["Terminal", "Terminal (2)"]); - expect(result).toBe("Terminal (1)"); - }); - - it("should handle non-sequential numbers", () => { - const strategy = new DefaultTerminalNamingStrategy(); - const result = strategy.generateName([ - "Terminal", - "Terminal (5)", - "Terminal (10)", - ]); - expect(result).toBe("Terminal (1)"); - }); - - it("should handle custom base name", () => { - const strategy = new DefaultTerminalNamingStrategy("Shell"); - const result = strategy.generateName([]); - expect(result).toBe("Shell"); - }); - - it("should handle custom base name with collision", () => { - const strategy = new DefaultTerminalNamingStrategy("Shell"); - const result = strategy.generateName(["Shell", "Shell (1)"]); - expect(result).toBe("Shell (2)"); - }); - - it("should handle large numbers of terminals", () => { - const strategy = new DefaultTerminalNamingStrategy(); - const existingNames = [ - "Terminal", - ...Array.from({ length: 100 }, (_, i) => `Terminal (${i + 1})`), - ]; - const result = strategy.generateName(existingNames); - expect(result).toBe("Terminal (101)"); - }); - - it("should not be affected by similar named terminals", () => { - const strategy = new DefaultTerminalNamingStrategy(); - const result = strategy.generateName([ - "Terminal Window", - "My Terminal", - "Terminal (x)", - ]); - expect(result).toBe("Terminal"); - }); -}); - -describe("generateTerminalName helper", () => { - it("should use default strategy when no strategy provided", () => { - const result = generateTerminalName([]); - expect(result).toBe("Terminal"); - }); - - it("should use default strategy with collisions", () => { - const result = generateTerminalName(["Terminal", "Terminal (1)"]); - expect(result).toBe("Terminal (2)"); - }); - - it("should accept custom strategy", () => { - class CustomStrategy implements TerminalNamingStrategy { - generateName(_existingNames: string[]): string { - return "Custom Terminal"; - } - } - - const result = generateTerminalName(["Terminal"], new CustomStrategy()); - expect(result).toBe("Custom Terminal"); - }); -}); - -describe("Terminal naming in realistic scenarios", () => { - it("should generate sequential names for multiple new terminals", () => { - const strategy = new DefaultTerminalNamingStrategy(); - const existingNames: string[] = []; - - const name1 = strategy.generateName(existingNames); - expect(name1).toBe("Terminal"); - existingNames.push(name1); - - const name2 = strategy.generateName(existingNames); - expect(name2).toBe("Terminal (1)"); - existingNames.push(name2); - - const name3 = strategy.generateName(existingNames); - expect(name3).toBe("Terminal (2)"); - existingNames.push(name3); - - expect(existingNames).toEqual(["Terminal", "Terminal (1)", "Terminal (2)"]); - }); - - it("should reuse numbers after terminal is closed", () => { - const strategy = new DefaultTerminalNamingStrategy(); - let existingNames = ["Terminal", "Terminal (1)", "Terminal (2)"]; - - // Close Terminal (1) - existingNames = existingNames.filter((name) => name !== "Terminal (1)"); - - const newName = strategy.generateName(existingNames); - expect(newName).toBe("Terminal (1)"); - }); - - it("should handle mixed terminal types", () => { - const strategy = new DefaultTerminalNamingStrategy(); - const existingNames = [ - "Terminal", - "New Split View", - "Terminal (1)", - "Another Tab", - ]; - - const result = strategy.generateName(existingNames); - expect(result).toBe("Terminal (2)"); - }); -}); diff --git a/apps/desktop/src/renderer/stores/tabs/utils/terminal-naming.ts b/apps/desktop/src/renderer/stores/tabs/utils/terminal-naming.ts deleted file mode 100644 index 481e21594ad..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/utils/terminal-naming.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Terminal naming utility - * Generates unique terminal names with incremental numbering only on collision - */ - -export interface TerminalNamingStrategy { - generateName(existingNames: string[]): string; -} - -export class DefaultTerminalNamingStrategy implements TerminalNamingStrategy { - private readonly baseName: string; - - constructor(baseName = "Terminal") { - this.baseName = baseName; - } - - /** - * Generate a unique terminal name - * - Returns "Terminal" if no collision - * - Returns "Terminal (1)", "Terminal (2)", etc. on collision - */ - generateName(existingNames: string[]): string { - // If no collision with base name, use it - if (!existingNames.includes(this.baseName)) { - return this.baseName; - } - - // Find the next available number - let counter = 1; - while (existingNames.includes(`${this.baseName} (${counter})`)) { - counter++; - } - - return `${this.baseName} (${counter})`; - } -} - -/** - * Helper function to generate a unique terminal name - */ -export const generateTerminalName = ( - existingNames: string[], - strategy: TerminalNamingStrategy = new DefaultTerminalNamingStrategy(), -): string => { - return strategy.generateName(existingNames); -};