diff --git a/apps/desktop/bunfig.toml b/apps/desktop/bunfig.toml new file mode 100644 index 00000000000..66f01474d8c --- /dev/null +++ b/apps/desktop/bunfig.toml @@ -0,0 +1,3 @@ +[test] +# Preload test setup before running tests +preload = ["./test-setup.ts"] 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 index 9ba936bcb9d..48f760e658a 100644 --- 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 @@ -15,7 +15,6 @@ interface TabContextMenuProps { hasParent?: boolean; onClose: () => void; onRename: () => void; - onDuplicate?: () => void; onUngroup?: () => void; onMoveOutOfGroup?: () => void; } @@ -26,7 +25,6 @@ export function TabContextMenu({ hasParent = false, onClose, onRename, - onDuplicate, onUngroup, onMoveOutOfGroup, }: TabContextMenuProps) { @@ -44,11 +42,6 @@ export function TabContextMenu({ ) : ( <> Rename Tab - {onDuplicate && ( - - Duplicate Tab - - )} {hasParent && onMoveOutOfGroup && ( <> 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 index eca360dfbbd..94edb87c598 100644 --- 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 @@ -58,11 +58,6 @@ export function TabItem({ tab, childTabs = [] }: TabItemProps) { setIsExpanded(!isExpanded); }; - const handleDuplicate = () => { - // TODO: Implement duplicate functionality - console.log("Duplicate tab:", tab.id); - }; - const handleUngroup = () => { ungroupTabs(tab.id); }; @@ -98,7 +93,6 @@ export function TabItem({ tab, childTabs = [] }: TabItemProps) { hasParent={!!tab.parentId} onClose={handleRemoveTab} onRename={rename.startRename} - onDuplicate={!isGroupTab ? handleDuplicate : undefined} onUngroup={isGroupTab ? handleUngroup : undefined} onMoveOutOfGroup={tab.parentId ? handleMoveOutOfGroup : undefined} > diff --git a/apps/desktop/src/renderer/stores/tabs/helpers/active-tab.ts b/apps/desktop/src/renderer/stores/tabs/helpers/active-tab.ts new file mode 100644 index 00000000000..0e2a1da76ca --- /dev/null +++ b/apps/desktop/src/renderer/stores/tabs/helpers/active-tab.ts @@ -0,0 +1,54 @@ +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), + ]; + } + + return { + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: tabId, + }, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }; +}; + +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.ts b/apps/desktop/src/renderer/stores/tabs/helpers/group-operations.ts new file mode 100644 index 00000000000..acdcb705c51 --- /dev/null +++ b/apps/desktop/src/renderer/stores/tabs/helpers/group-operations.ts @@ -0,0 +1,302 @@ +import type { MosaicNode } from "react-mosaic-component"; +import type { Tab, TabsState } from "../types"; +import { TabType } from "../types"; +import { validateGroupLayouts } from "./validation"; +import { getChildTabIds } from "../utils"; +import { removeTabFromLayout } from "../drag-logic"; + +const handleEmptyGroupRemoval = ( + tabs: Tab[], + activeTabIds: Record, + tabHistoryStacks: Record, + workspaceId: string, + idsToRemove: string[], + fallbackActiveTabId?: string, +): TabsState => { + const remainingTabs = tabs.filter((tab) => !idsToRemove.includes(tab.id)); + const currentActiveId = activeTabIds[workspaceId]; + const historyStack = tabHistoryStacks[workspaceId] || []; + + const newActiveTabIds = { ...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 history, then first available + if ( + fallbackActiveTabId && + remainingTabs.some((t) => t.id === fallbackActiveTabId) + ) { + newActiveTabIds[workspaceId] = fallbackActiveTabId; + } else { + const nextTabFromHistory = newHistoryStack.find((tabId) => + workspaceTabs.some((tab) => tab.id === tabId), + ); + newActiveTabIds[workspaceId] = + nextTabFromHistory || workspaceTabs[0].id; + } + } else { + newActiveTabIds[workspaceId] = null; + } + } + + return { + tabs: remainingTabs, + activeTabIds: newActiveTabIds, + tabHistoryStacks: { + ...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.tabs, + state.activeTabIds, + state.tabHistoryStacks, + group.workspaceId, + [groupId, childTabId], + ); + } + + // Layouts may reference removed tabs, so clean them up + const validatedTabs = validateGroupLayouts( + state.tabs.filter((tab) => tab.id !== childTabId), + ); + + return { + tabs: validatedTabs, + }; +}; + +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( + updatedTabs, + state.activeTabIds, + state.tabHistoryStacks, + 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/split-operations.ts b/apps/desktop/src/renderer/stores/tabs/helpers/split-operations.ts new file mode 100644 index 00000000000..b2ff2528970 --- /dev/null +++ b/apps/desktop/src/renderer/stores/tabs/helpers/split-operations.ts @@ -0,0 +1,180 @@ +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"; + +export const handleSplitTabVertical = ( + state: TabsState, + workspaceId: string, + sourceTabId?: string, + path?: MosaicBranch[], +): Partial => { + const tabToSplit = sourceTabId + ? state.tabs.find((t) => t.id === sourceTabId) + : state.tabs.find( + (t) => + t.id === state.activeTabIds[workspaceId] && !t.parentId, + ); + + if (!tabToSplit || tabToSplit.type === TabType.Group) return {}; + + // Groups can't be split - they already contain multiple panes + if (tabToSplit.parentId && path) { + return splitPaneInGroup( + state, + tabToSplit, + workspaceId, + path, + "row", + ); + } + + return convertTabToGroup(state, tabToSplit, workspaceId, "row"); +}; + +export const handleSplitTabHorizontal = ( + state: TabsState, + workspaceId: string, + sourceTabId?: string, + path?: MosaicBranch[], +): Partial => { + const tabToSplit = sourceTabId + ? state.tabs.find((t) => t.id === sourceTabId) + : state.tabs.find( + (t) => + t.id === state.activeTabIds[workspaceId] && !t.parentId, + ); + + if (!tabToSplit || tabToSplit.type === TabType.Group) return {}; + + // Groups can't be split - they already contain multiple panes + if (tabToSplit.parentId && path) { + return splitPaneInGroup( + state, + tabToSplit, + workspaceId, + path, + "column", + ); + } + + return convertTabToGroup(state, tabToSplit, 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); + 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: Tab = { + id: `tab-${Date.now() + 1}-${Math.random().toString(36).substring(2, 11)}`, + title: "New Tab", + workspaceId, + type: TabType.Single, + parentId: groupTab.id, + isNew: true, + }; + + const updatedSourceTab: Tab = { + ...tabToSplit, + parentId: groupTab.id, + }; + + const layout: MosaicNode = { + direction, + first: tabToSplit.id, + second: newChildTab.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, + newChildTab, + ]; + + 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 new file mode 100644 index 00000000000..40c788583a5 --- /dev/null +++ b/apps/desktop/src/renderer/stores/tabs/helpers/tab-crud.ts @@ -0,0 +1,113 @@ +import type { TabsState } from "../types"; +import { TabType } from "../types"; +import { createNewTab } from "../utils"; + +export const handleAddTab = ( + state: TabsState, + workspaceId: string, + type: TabType = TabType.Single, +): Partial => { + const newTab = createNewTab(workspaceId, type); + const currentActiveId = state.activeTabIds[workspaceId]; + const historyStack = state.tabHistoryStacks[workspaceId] || []; + const newHistoryStack = currentActiveId + ? [currentActiveId, ...historyStack.filter((id) => id !== currentActiveId)] + : historyStack; + + return { + tabs: [...state.tabs, newTab], + activeTabIds: { + ...state.activeTabIds, + [workspaceId]: newTab.id, + }, + tabHistoryStacks: { + ...state.tabHistoryStacks, + [workspaceId]: newHistoryStack, + }, + }; +}; + +/** + * 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 workspaceTabs = state.tabs.filter( + (tab) => tab.workspaceId === workspaceId && tab.id !== id, + ); + const tabs = state.tabs.filter((tab) => tab.id !== id); + + const historyStack = state.tabHistoryStacks[workspaceId] || []; + const newHistoryStack = historyStack.filter((tabId) => tabId !== id); + + const newActiveTabIds = { ...state.activeTabIds }; + if (state.activeTabIds[workspaceId] === id) { + if (workspaceTabs.length > 0) { + const nextTabFromHistory = newHistoryStack.find((tabId) => + workspaceTabs.some((tab) => tab.id === tabId), + ); + if (nextTabFromHistory) { + newActiveTabIds[workspaceId] = nextTabFromHistory; + } else { + const closedIndex = state.tabs + .filter((tab) => tab.workspaceId === workspaceId) + .findIndex((tab) => tab.id === id); + const nextTab = + workspaceTabs[closedIndex] || workspaceTabs[closedIndex - 1]; + newActiveTabIds[workspaceId] = nextTab.id; + } + } else { + newActiveTabIds[workspaceId] = null; + } + } + + 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 new file mode 100644 index 00000000000..c95e49d3382 --- /dev/null +++ b/apps/desktop/src/renderer/stores/tabs/helpers/tab-ordering.ts @@ -0,0 +1,44 @@ +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 new file mode 100644 index 00000000000..49bc1dc8a65 --- /dev/null +++ b/apps/desktop/src/renderer/stores/tabs/helpers/validation.ts @@ -0,0 +1,24 @@ +import type { Tab } from "../types"; +import { TabType } from "../types"; +import { cleanLayout } from "../drag-logic"; +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 new file mode 100644 index 00000000000..7562803f6c0 --- /dev/null +++ b/apps/desktop/src/renderer/stores/tabs/hooks.ts @@ -0,0 +1,23 @@ +import { useTabsStore } from "./store"; + +export const useTabs = () => useTabsStore((state) => state.tabs); +export const useActiveTabIds = () => + useTabsStore((state) => state.activeTabIds); + +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); + diff --git a/apps/desktop/src/renderer/stores/tabs/index.ts b/apps/desktop/src/renderer/stores/tabs/index.ts index 3d58be97d2c..0a5cfa98525 100644 --- a/apps/desktop/src/renderer/stores/tabs/index.ts +++ b/apps/desktop/src/renderer/stores/tabs/index.ts @@ -1,4 +1,5 @@ export * from "./drag-logic"; +export * from "./hooks"; export * from "./store"; export * from "./types"; export * from "./utils"; diff --git a/apps/desktop/src/renderer/stores/tabs/store.ts b/apps/desktop/src/renderer/stores/tabs/store.ts index 051d5cf6f52..9c913f41b5f 100644 --- a/apps/desktop/src/renderer/stores/tabs/store.ts +++ b/apps/desktop/src/renderer/stores/tabs/store.ts @@ -1,330 +1,37 @@ -import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; -import { updateTree } from "react-mosaic-component"; import { create } from "zustand"; import { devtools, persist } from "zustand/middleware"; +import { handleDragTabToTab } from "./drag-logic"; import { - cleanLayout, - handleDragTabToTab, - removeTabFromLayout, -} from "./drag-logic"; -import { type Tab, TabType } from "./types"; -import { createNewTab, getChildTabIds } from "./utils"; + getActiveTab, + getLastActiveTabId, + getTabsByWorkspace, + handleSetActiveTab, +} from "./helpers/active-tab"; +import { + handleAddChildTabToGroup, + handleRemoveChildTabFromGroup, + handleUngroupTab, + handleUngroupTabs, + handleUpdateTabGroupLayout, +} from "./helpers/group-operations"; +import { + handleAddTab, + handleMarkTabAsUsed, + handleRemoveTab, + handleRenameTab, +} from "./helpers/tab-crud"; +import { + handleReorderTabById, + handleReorderTabs, +} from "./helpers/tab-ordering"; +import { + handleSplitTabHorizontal, + handleSplitTabVertical, +} from "./helpers/split-operations"; +import { TabType, type TabsStore } from "./types"; import { electronStorage } from "../../lib/electron-storage"; -interface TabsState { - tabs: Tab[]; - activeTabIds: Record; - tabHistoryStacks: Record; - - addTab: (workspaceId: string, type?: TabType) => void; - removeTab: (id: string) => void; - renameTab: (id: string, newTitle: string) => void; - setActiveTab: (workspaceId: string, tabId: string) => void; - reorderTabs: ( - 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, - path?: MosaicBranch[], - ) => void; - splitTabHorizontal: ( - workspaceId: string, - sourceTabId?: string, - path?: MosaicBranch[], - ) => void; - - getTabsByWorkspace: (workspaceId: string) => Tab[]; - getActiveTab: (workspaceId: string) => Tab | null; - getLastActiveTabId: (workspaceId: string) => string | null; -} - -const createInitialTabs = (): Tab[] => { - const workspaceId = "workspace-1"; - - const singleTab: Tab = { - id: "tab-single-1", - title: "Welcome Tab", - workspaceId, - type: TabType.Single, - isNew: false, - }; - - const childTab1: Tab = { - id: "tab-child-1", - title: "Left Pane", - workspaceId, - type: TabType.Single, - isNew: false, - parentId: "tab-group-1", - }; - - const childTab2: Tab = { - id: "tab-child-2", - title: "Right Pane", - workspaceId, - type: TabType.Single, - isNew: false, - parentId: "tab-group-1", - }; - - const groupTab: Tab = { - id: "tab-group-1", - title: "Split View Example", - workspaceId, - type: TabType.Group, - isNew: false, - layout: { - direction: "row", - first: "tab-child-1", - second: "tab-child-2", - splitPercentage: 50, - }, - }; - - const singleTab2: Tab = { - id: "tab-single-2", - title: "Another Tab", - workspaceId, - type: TabType.Single, - isNew: false, - }; - - return [singleTab, childTab1, childTab2, groupTab, singleTab2]; -}; - -/** - * Validates and cleans all group tabs to ensure layout only contains valid child IDs - */ -const validateGroupLayouts = (tabs: Tab[]): Tab[] => { - return tabs.map((tab) => { - if (tab.type !== TabType.Group) return tab; - - // Derive children from parentId - const validTabIds = new Set(getChildTabIds(tabs, tab.id)); - const cleanedLayout = cleanLayout(tab.layout, validTabIds); - - // Only update if layout actually changed - if (cleanedLayout !== tab.layout) { - return { - ...tab, - layout: cleanedLayout, - }; - } - - return tab; - }); -}; - -/** - * Splits a pane within an existing group - */ -const splitPaneInGroup = ( - state: { - tabs: Tab[]; - activeTabIds: Record; - tabHistoryStacks: Record; - }, - tabToSplit: Tab, - workspaceId: string, - path: MosaicBranch[], - direction: "row" | "column", -) => { - const group = state.tabs.find( - (t) => t.id === tabToSplit.parentId && t.type === TabType.Group, - ); - if (!group || group.type !== TabType.Group || !group.layout) return state; - - // Create a new child tab - const newTab = createNewTab(workspaceId, TabType.Single); - const newTabWithParent: Tab = { - ...newTab, - parentId: tabToSplit.parentId, - }; - - // Update the mosaic layout - const newLayout = updateTree(group.layout, [ - { - path, - spec: { - $set: { - direction, - first: tabToSplit.id, - second: newTab.id, - splitPercentage: 50, - }, - }, - }, - ]); - - // Update the group's layout and add the new tab - const updatedTabs = state.tabs.map((tab) => - tab.id === group.id && tab.type === TabType.Group - ? { ...tab, layout: newLayout } - : tab, - ); - - return { - tabs: [...updatedTabs, newTabWithParent], - }; -}; - -/** - * Converts a top-level tab into a group with a split - */ -const convertTabToGroup = ( - state: { - tabs: Tab[]; - activeTabIds: Record; - tabHistoryStacks: Record; - }, - tabToSplit: Tab, - workspaceId: string, - direction: "row" | "column", -) => { - // Create a new group tab - const groupTab: Tab = { - id: `tab-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, - title: `${tabToSplit.title} - Split`, - workspaceId, - type: TabType.Group, - layout: null, - isNew: false, - }; - - // Create a new child tab for the new pane - const newChildTab: Tab = { - id: `tab-${Date.now() + 1}-${Math.random().toString(36).substring(2, 11)}`, - title: "New Tab", - workspaceId, - type: TabType.Single, - parentId: groupTab.id, - isNew: true, - }; - - // Update the original tab to be a child of the group - const updatedSourceTab: Tab = { - ...tabToSplit, - parentId: groupTab.id, - }; - - // Create the split layout - const layout: MosaicNode = { - direction, - first: tabToSplit.id, - second: newChildTab.id, - splitPercentage: 50, - }; - - const updatedGroupTab: Tab = { - ...groupTab, - layout, - }; - - // Find the position of the original tab - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const sourceTabIndex = workspaceTabs.findIndex((t) => t.id === tabToSplit.id); - - // Replace the source tab with the group and add the new child - const otherTabs = state.tabs.filter((t) => t.id !== tabToSplit.id); - const otherWorkspaceTabs = otherTabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const nonWorkspaceTabs = otherTabs.filter( - (t) => t.workspaceId !== workspaceId || t.parentId, - ); - - // Insert the group at the original position - otherWorkspaceTabs.splice(sourceTabIndex, 0, updatedGroupTab); - - const newTabs = [ - ...nonWorkspaceTabs, - ...otherWorkspaceTabs, - updatedSourceTab, - newChildTab, - ]; - - return { - tabs: newTabs, - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: updatedGroupTab.id, - }, - }; -}; - -/** - * Handles the logic for when an empty group needs to be removed - * Returns updated state with the group removed and active tab/history updated - */ -const handleEmptyGroupRemoval = ( - tabs: Tab[], - activeTabIds: Record, - tabHistoryStacks: Record, - workspaceId: string, - idsToRemove: string[], - fallbackActiveTabId?: string, -) => { - const remainingTabs = tabs.filter((tab) => !idsToRemove.includes(tab.id)); - const currentActiveId = activeTabIds[workspaceId]; - const historyStack = tabHistoryStacks[workspaceId] || []; - - const newActiveTabIds = { ...activeTabIds }; - const newHistoryStack = historyStack.filter( - (id) => !idsToRemove.includes(id), - ); - - // Update active tab if needed - if (idsToRemove.includes(currentActiveId || "")) { - const workspaceTabs = remainingTabs.filter( - (tab) => tab.workspaceId === workspaceId, - ); - - if (workspaceTabs.length > 0) { - // Try to use fallback (e.g., the ungrouped tab), then history, then first available - if ( - fallbackActiveTabId && - remainingTabs.some((t) => t.id === fallbackActiveTabId) - ) { - newActiveTabIds[workspaceId] = fallbackActiveTabId; - } else { - const nextTabFromHistory = newHistoryStack.find((tabId) => - workspaceTabs.some((tab) => tab.id === tabId), - ); - newActiveTabIds[workspaceId] = - nextTabFromHistory || workspaceTabs[0].id; - } - } else { - newActiveTabIds[workspaceId] = null; - } - } - - return { - tabs: remainingTabs, - activeTabIds: newActiveTabIds, - tabHistoryStacks: { - ...tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; -}; - -export const useTabsStore = create()( +export const useTabsStore = create()( devtools( persist( (set, get) => ({ @@ -333,242 +40,57 @@ export const useTabsStore = create()( tabHistoryStacks: {}, addTab: (workspaceId, type = TabType.Single) => { - const newTab = createNewTab(workspaceId, type); - set((state) => { - const currentActiveId = state.activeTabIds[workspaceId]; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - const newHistoryStack = currentActiveId - ? [ - currentActiveId, - ...historyStack.filter((id) => id !== currentActiveId), - ] - : historyStack; - - return { - tabs: [...state.tabs, newTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: newTab.id, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; - }); + set((state) => handleAddTab(state, workspaceId, type)); }, removeTab: (id) => { const state = get(); - const tabToRemove = state.tabs.find((tab) => tab.id === id); - if (!tabToRemove) return; - - // Don't allow closing group tabs directly - if (tabToRemove.type === TabType.Group) { - console.error( - "Cannot close group tabs directly. Ungroup the tabs first.", - ); - return; - } - - // If this tab is a child of a group, delegate to removeChildTabFromGroup - // which handles empty group cleanup - if (tabToRemove.parentId) { - get().removeChildTabFromGroup(tabToRemove.parentId, id); + const result = handleRemoveTab(state, id); + if (result === null) { + // Delegate to removeChildTabFromGroup if tab has parentId + const tabToRemove = state.tabs.find((tab) => tab.id === id); + if (tabToRemove?.parentId) { + get().removeChildTabFromGroup(tabToRemove.parentId, id); + } return; } - - // Otherwise, handle as a top-level tab - set((state) => { - const workspaceId = tabToRemove.workspaceId; - const workspaceTabs = state.tabs.filter( - (tab) => tab.workspaceId === workspaceId && tab.id !== id, - ); - const tabs = state.tabs.filter((tab) => tab.id !== id); - - const historyStack = state.tabHistoryStacks[workspaceId] || []; - const newHistoryStack = historyStack.filter( - (tabId) => tabId !== id, - ); - - const newActiveTabIds = { ...state.activeTabIds }; - if (state.activeTabIds[workspaceId] === id) { - if (workspaceTabs.length > 0) { - const nextTabFromHistory = newHistoryStack.find((tabId) => - workspaceTabs.some((tab) => tab.id === tabId), - ); - if (nextTabFromHistory) { - newActiveTabIds[workspaceId] = nextTabFromHistory; - } else { - const closedIndex = state.tabs - .filter((tab) => tab.workspaceId === workspaceId) - .findIndex((tab) => tab.id === id); - const nextTab = - workspaceTabs[closedIndex] || - workspaceTabs[closedIndex - 1]; - newActiveTabIds[workspaceId] = nextTab.id; - } - } else { - newActiveTabIds[workspaceId] = null; - } - } - - return { - tabs, - activeTabIds: newActiveTabIds, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; - }); + set(() => result); }, renameTab: (id, newTitle) => { - set((state) => ({ - tabs: state.tabs.map((tab) => - tab.id === id ? { ...tab, title: newTitle } : tab, - ), - })); + set((state) => handleRenameTab(state, id, newTitle)); }, setActiveTab: (workspaceId, tabId) => { - set((state) => { - const currentActiveId = state.activeTabIds[workspaceId]; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - - let newHistoryStack = historyStack.filter((id) => id !== tabId); - if (currentActiveId && currentActiveId !== tabId) { - newHistoryStack = [ - currentActiveId, - ...newHistoryStack.filter((id) => id !== currentActiveId), - ]; - } - - return { - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: tabId, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; - }); + set((state) => handleSetActiveTab(state, workspaceId, tabId)); }, reorderTabs: (workspaceId, startIndex, endIndex) => { - set((state) => { - const workspaceTabs = state.tabs.filter( - (tab) => tab.workspaceId === workspaceId, - ); - const otherTabs = state.tabs.filter( - (tab) => tab.workspaceId !== workspaceId, - ); - - const [removed] = workspaceTabs.splice(startIndex, 1); - workspaceTabs.splice(endIndex, 0, removed); - - return { tabs: [...otherTabs, ...workspaceTabs] }; - }); + set((state) => + handleReorderTabs(state, workspaceId, startIndex, endIndex), + ); }, reorderTabById: (tabId, targetIndex) => { - set((state) => { - const tab = state.tabs.find((t) => t.id === tabId); - if (!tab || tab.parentId) return state; // Only reorder top-level tabs - - const workspaceId = tab.workspaceId; - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const otherTabs = state.tabs.filter( - (t) => t.workspaceId !== workspaceId || t.parentId, - ); - - const tabToMove = workspaceTabs.find((t) => t.id === tabId); - if (!tabToMove) return state; - - const filteredTabs = workspaceTabs.filter((t) => t.id !== tabId); - filteredTabs.splice(targetIndex, 0, tabToMove); - - return { tabs: [...otherTabs, ...filteredTabs] }; - }); + set((state) => handleReorderTabById(state, tabId, targetIndex)); }, markTabAsUsed: (id) => { - set((state) => ({ - tabs: state.tabs.map((tab) => - tab.id === id ? { ...tab, isNew: false } : tab, - ), - })); + set((state) => handleMarkTabAsUsed(state, id)); }, updateTabGroupLayout: (id, layout) => { - set((state) => ({ - tabs: state.tabs.map((tab) => - tab.id === id && tab.type === TabType.Group - ? { ...tab, layout } - : tab, - ), - })); + set((state) => handleUpdateTabGroupLayout(state, id, layout)); }, addChildTabToGroup: (groupId, childTabId) => { - set((state) => { - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === childTabId) { - return { - ...tab, - parentId: groupId, - }; - } - return tab; - }); - - // Note: This doesn't update layout - caller is responsible for layout updates - // This is typically used in conjunction with updateTabGroupLayout - - return { - tabs: updatedTabs, - }; - }); + set((state) => handleAddChildTabToGroup(state, groupId, childTabId)); }, removeChildTabFromGroup: (groupId, childTabId) => { - set((state) => { - const group = state.tabs.find( - (tab) => tab.id === groupId && tab.type === TabType.Group, - ); - if (!group || group.type !== TabType.Group) return state; - - // Derive children from parentId - const updatedChildTabIds = getChildTabIds( - state.tabs, - groupId, - ).filter((id: string) => id !== childTabId); - - // If no children left, remove both the child and the group - if (updatedChildTabIds.length === 0) { - return handleEmptyGroupRemoval( - state.tabs, - state.activeTabIds, - state.tabHistoryStacks, - group.workspaceId, - [groupId, childTabId], - ); - } - - // Validate layouts after removing child tab - const validatedTabs = validateGroupLayouts( - state.tabs.filter((tab) => tab.id !== childTabId), - ); - - return { - tabs: validatedTabs, - }; - }); + set((state) => + handleRemoveChildTabFromGroup(state, groupId, childTabId), + ); }, dragTabToTab: (draggedTabId, targetTabId) => { @@ -576,257 +98,34 @@ export const useTabsStore = create()( }, ungroupTab: (tabId, targetIndex) => { - set((state) => { - const tab = state.tabs.find((t) => t.id === tabId); - if (!tab || !tab.parentId) return state; - - const parentGroup = state.tabs.find( - (t) => t.id === tab.parentId && t.type === TabType.Group, - ); - if (!parentGroup || parentGroup.type !== TabType.Group) - return state; - - // Remove parentId from the tab - const updatedTab: Tab = { - ...tab, - parentId: undefined, - }; - - // Remove tab from parent's layout - const updatedLayout = removeTabFromLayout( - parentGroup.layout, - tabId, - ) as MosaicNode | null; - - // Get remaining children - const remainingChildren = state.tabs.filter( - (t) => t.parentId === parentGroup.id && t.id !== tabId, - ); - - const updatedTabs = state.tabs.map((t) => { - if (t.id === tabId) return updatedTab; - if (t.id === parentGroup.id && t.type === TabType.Group) { - return { - ...t, - layout: updatedLayout, - }; - } - return t; - }); - - // If no children left, remove the group - if (remainingChildren.length === 0) { - const result = handleEmptyGroupRemoval( - updatedTabs, - state.activeTabIds, - state.tabHistoryStacks, - tab.workspaceId, - [parentGroup.id], - tabId, // Prefer the ungrouped tab as the new active tab - ); - - // Apply reordering if needed - if (targetIndex !== undefined) { - const workspaceTabs = result.tabs.filter( - (t) => t.workspaceId === tab.workspaceId && !t.parentId, - ); - const otherTabs = result.tabs.filter( - (t) => t.workspaceId !== tab.workspaceId || t.parentId, - ); - - 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; - } - - // Validate layouts after removing tab - let validatedTabs = validateGroupLayouts(updatedTabs); - - // Reorder if targetIndex is provided - 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, - }; - }); + set((state) => handleUngroupTab(state, tabId, targetIndex)); }, ungroupTabs: (groupId) => { - set((state) => { - const group = state.tabs.find( - (t) => t.id === groupId && t.type === TabType.Group, - ); - if (!group || group.type !== TabType.Group) return state; - - // Get all child tabs - const childTabIds = getChildTabIds(state.tabs, groupId); - if (childTabIds.length === 0) return state; - - // Find the group's position in the workspace - const workspaceId = group.workspaceId; - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const groupIndex = workspaceTabs.findIndex((t) => t.id === groupId); - - // Remove parentId from all child tabs - const updatedTabs = state.tabs - .map((tab) => { - if (childTabIds.includes(tab.id)) { - return { - ...tab, - parentId: undefined, - }; - } - return tab; - }) - // Remove the group tab itself - .filter((tab) => tab.id !== groupId); - - // Reorder tabs to place ungrouped tabs where the group was - const newWorkspaceTabs = updatedTabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const otherTabs = updatedTabs.filter( - (t) => t.workspaceId !== workspaceId || t.parentId, - ); - - // Get the ungrouped child tabs - const ungroupedTabs = newWorkspaceTabs.filter((t) => - childTabIds.includes(t.id), - ); - // Get tabs that are not the ungrouped children - const nonUngroupedTabs = newWorkspaceTabs.filter( - (t) => !childTabIds.includes(t.id), - ); - - // Insert ungrouped tabs at the group's original position - nonUngroupedTabs.splice(groupIndex, 0, ...ungroupedTabs); - - const finalTabs = [...otherTabs, ...nonUngroupedTabs]; - - // Clean up active tab and history if the group was active - const currentActiveId = state.activeTabIds[workspaceId]; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - const newHistoryStack = historyStack.filter((id) => id !== groupId); - - const newActiveTabIds = { ...state.activeTabIds }; - if (currentActiveId === groupId) { - // Set the first ungrouped tab as active - if (ungroupedTabs.length > 0) { - newActiveTabIds[workspaceId] = ungroupedTabs[0].id; - } else if (nonUngroupedTabs.length > 0) { - newActiveTabIds[workspaceId] = nonUngroupedTabs[0].id; - } else { - newActiveTabIds[workspaceId] = null; - } - } - - return { - tabs: finalTabs, - activeTabIds: newActiveTabIds, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: newHistoryStack, - }, - }; - }); + set((state) => handleUngroupTabs(state, groupId)); }, getTabsByWorkspace: (workspaceId) => { - return get().tabs.filter((tab) => tab.workspaceId === workspaceId); + return getTabsByWorkspace(get(), workspaceId); }, getActiveTab: (workspaceId) => { - const activeTabId = get().activeTabIds[workspaceId]; - if (!activeTabId) return null; - return get().tabs.find((tab) => tab.id === activeTabId) || null; + return getActiveTab(get(), workspaceId); }, getLastActiveTabId: (workspaceId) => { - const historyStack = get().tabHistoryStacks[workspaceId] || []; - return historyStack[0] || null; + return getLastActiveTabId(get(), workspaceId); }, splitTabVertical: (workspaceId, sourceTabId, path) => { - set((state) => { - // Use provided sourceTabId or get the active tab - const tabToSplit = sourceTabId - ? state.tabs.find((t) => t.id === sourceTabId) - : state.tabs.find( - (t) => - t.id === state.activeTabIds[workspaceId] && !t.parentId, - ); - - if (!tabToSplit || tabToSplit.type === TabType.Group) return state; - - // Check if this tab is within a group (has a parentId) and path is provided - if (tabToSplit.parentId && path) { - return splitPaneInGroup( - state, - tabToSplit, - workspaceId, - path, - "row", - ); - } - - // Convert top-level tab into a group - return convertTabToGroup(state, tabToSplit, workspaceId, "row"); - }); + set((state) => + handleSplitTabVertical(state, workspaceId, sourceTabId, path), + ); }, splitTabHorizontal: (workspaceId, sourceTabId, path) => { - set((state) => { - // Use provided sourceTabId or get the active tab - const tabToSplit = sourceTabId - ? state.tabs.find((t) => t.id === sourceTabId) - : state.tabs.find( - (t) => - t.id === state.activeTabIds[workspaceId] && !t.parentId, - ); - - if (!tabToSplit || tabToSplit.type === TabType.Group) return state; - - // Check if this tab is within a group (has a parentId) and path is provided - if (tabToSplit.parentId && path) { - return splitPaneInGroup( - state, - tabToSplit, - workspaceId, - path, - "column", - ); - } - - // Convert top-level tab into a group - return convertTabToGroup(state, tabToSplit, workspaceId, "column"); - }); + set((state) => + handleSplitTabHorizontal(state, workspaceId, sourceTabId, path), + ); }, }), { @@ -837,24 +136,3 @@ export const useTabsStore = create()( { name: "TabsStore" }, ), ); - -export const useTabs = () => useTabsStore((state) => state.tabs); -export const useActiveTabIds = () => - useTabsStore((state) => state.activeTabIds); - -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); diff --git a/apps/desktop/src/renderer/stores/tabs/types.ts b/apps/desktop/src/renderer/stores/tabs/types.ts index c1770e09e61..3ec8ed1033c 100644 --- a/apps/desktop/src/renderer/stores/tabs/types.ts +++ b/apps/desktop/src/renderer/stores/tabs/types.ts @@ -1,4 +1,4 @@ -import type { MosaicNode } from "react-mosaic-component"; +import type { MosaicBranch, MosaicNode } from "react-mosaic-component"; export enum TabType { Single = "single", @@ -23,3 +23,42 @@ export interface TabGroup extends BaseTab { } export type Tab = SingleTab | TabGroup; + +export interface TabsState { + tabs: Tab[]; + activeTabIds: Record; + tabHistoryStacks: Record; +} + +export interface TabsStore extends TabsState { + addTab: (workspaceId: string, type?: TabType) => void; + removeTab: (id: string) => void; + renameTab: (id: string, newTitle: string) => void; + setActiveTab: (workspaceId: string, tabId: string) => void; + reorderTabs: ( + 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, + path?: MosaicBranch[], + ) => void; + splitTabHorizontal: ( + workspaceId: string, + sourceTabId?: string, + path?: MosaicBranch[], + ) => void; + getTabsByWorkspace: (workspaceId: string) => Tab[]; + getActiveTab: (workspaceId: string) => Tab | null; + getLastActiveTabId: (workspaceId: string) => string | null; +} diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts new file mode 100644 index 00000000000..bfe17a42e9b --- /dev/null +++ b/apps/desktop/test-setup.ts @@ -0,0 +1,18 @@ +/** + * Global test setup for Bun tests + * This file mocks the Electron environment for unit tests + */ + +// Mock window.electronStore for all tests +const mockStorage = new Map(); +global.window = { + electronStore: { + get: async (key: string) => mockStorage.get(key) || null, + set: async (key: string, value: string) => { + mockStorage.set(key, value); + }, + delete: async (key: string) => { + mockStorage.delete(key); + }, + }, +} as any;