From b60341ff0ce440bec93cb69180a824cdbed1f8a5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:45:23 -0700 Subject: [PATCH] Revert "fix: prevent terminal reset when splitting tab groups" (#403) --- src/renderer/src/App.tsx | 27 +- src/renderer/src/components/Terminal.tsx | 198 ++++-------- .../src/components/editor/EditorPanel.tsx | 61 +--- .../src/components/editor/MonacoEditor.tsx | 5 - .../editor/editor-autosave-controller.ts | 1 - .../src/components/tab-bar/EditorFileTab.tsx | 33 +- .../src/components/tab-bar/SortableTab.tsx | 31 +- .../src/components/tab-bar/TabBar.tsx | 8 +- .../components/tab-group/TabGroupPanel.tsx | 286 ------------------ .../tab-group/TabGroupSplitLayout.tsx | 111 ------- .../components/terminal-pane/TerminalPane.tsx | 17 +- .../terminal-pane/pty-connection.ts | 7 +- .../use-terminal-pane-global-effects.ts | 31 +- .../use-terminal-pane-lifecycle.ts | 3 - src/renderer/src/store/slices/editor.ts | 66 +--- .../src/store/slices/tab-group-layout-ops.ts | 108 ------- .../src/store/slices/tabs-bulk-actions.ts | 115 ------- src/renderer/src/store/slices/tabs-helpers.ts | 21 +- .../src/store/slices/tabs-hydration.ts | 42 +-- .../src/store/slices/tabs-split-actions.ts | 176 ----------- src/renderer/src/store/slices/tabs.test.ts | 3 +- src/renderer/src/store/slices/tabs.ts | 182 ++++++----- src/renderer/src/store/slices/terminals.ts | 6 +- src/shared/types.ts | 17 -- 24 files changed, 219 insertions(+), 1336 deletions(-) delete mode 100644 src/renderer/src/components/tab-group/TabGroupPanel.tsx delete mode 100644 src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx delete mode 100644 src/renderer/src/store/slices/tab-group-layout-ops.ts delete mode 100644 src/renderer/src/store/slices/tabs-bulk-actions.ts delete mode 100644 src/renderer/src/store/slices/tabs-split-actions.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index cfcf61e90a0..4fa2418fa50 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -100,7 +100,6 @@ function App(): React.JSX.Element { const refreshAllGitHub = useAppStore((s) => s.refreshAllGitHub) const hydrateWorkspaceSession = useAppStore((s) => s.hydrateWorkspaceSession) const hydrateEditorSession = useAppStore((s) => s.hydrateEditorSession) - const hydrateTabsSession = useAppStore((s) => s.hydrateTabsSession) const reconnectPersistedTerminals = useAppStore((s) => s.reconnectPersistedTerminals) const hydratePersistedUI = useAppStore((s) => s.hydratePersistedUI) const openModal = useAppStore((s) => s.openModal) @@ -119,10 +118,6 @@ function App(): React.JSX.Element { const activeTabTypeByWorktree = useAppStore((s) => s.activeTabTypeByWorktree) const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree) - // Unified tab state for session persistence and titlebar hiding - const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) - const groupsByWorktree = useAppStore((s) => s.groupsByWorktree) - const layoutByWorktree = useAppStore((s) => s.layoutByWorktree) // Right sidebar + editor state const toggleRightSidebar = useAppStore((s) => s.toggleRightSidebar) const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) @@ -161,7 +156,6 @@ function App(): React.JSX.Element { hydratePersistedUI(persistedUI) hydrateWorkspaceSession(session) hydrateEditorSession(session) - hydrateTabsSession(session) await reconnectPersistedTerminals(abortController.signal) syncZoomCSSVar() } @@ -212,7 +206,6 @@ function App(): React.JSX.Element { hydratePersistedUI, hydrateWorkspaceSession, hydrateEditorSession, - hydrateTabsSession, reconnectPersistedTerminals ]) @@ -251,10 +244,7 @@ function App(): React.JSX.Element { terminalLayoutsByTabId, activeWorktreeIdsOnShutdown, activeTabIdByWorktree, - ...buildEditorSessionData(openFiles, activeFileIdByWorktree, activeTabTypeByWorktree), - unifiedTabs: unifiedTabsByWorktree, - tabGroups: groupsByWorktree, - tabGroupLayouts: layoutByWorktree + ...buildEditorSessionData(openFiles, activeFileIdByWorktree, activeTabTypeByWorktree) }) }, 150) @@ -269,10 +259,7 @@ function App(): React.JSX.Element { openFiles, activeFileIdByWorktree, activeTabTypeByWorktree, - activeTabIdByWorktree, - unifiedTabsByWorktree, - groupsByWorktree, - layoutByWorktree + activeTabIdByWorktree ]) // On shutdown, capture terminal scrollback buffers and flush to disk. @@ -305,10 +292,7 @@ function App(): React.JSX.Element { state.openFiles, state.activeFileIdByWorktree, state.activeTabTypeByWorktree - ), - unifiedTabs: state.unifiedTabsByWorktree, - tabGroups: state.groupsByWorktree, - tabGroupLayouts: state.layoutByWorktree + ) }) } window.addEventListener('beforeunload', captureAndFlush) @@ -549,10 +533,7 @@ function App(): React.JSX.Element { {/* Why: portal target for the TabBar rendered by Terminal.tsx. Hidden when tabs should not be visible (settings view, no active worktree) - so the portal content does not leak through. When splits are active, - Terminal.tsx skips the portal so the div stays empty, but it must - remain visible as a flex-1 spacer to keep the right sidebar toggle - pushed to the far right. */} + so the portal content does not leak through. */}
import('./editor/EditorPanel')) + export default function Terminal(): React.JSX.Element | null { const activeWorktreeId = useAppStore((s) => s.activeWorktreeId) - + const activeView = useAppStore((s) => s.activeView) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) const activeTabId = useAppStore((s) => s.activeTabId) @@ -52,18 +53,6 @@ export default function Terminal(): React.JSX.Element | null { const tabBarOrderByWorktree = useAppStore((s) => s.tabBarOrderByWorktree) const tabBarOrder = activeWorktreeId ? tabBarOrderByWorktree[activeWorktreeId] : undefined - // Tab group split state - const layout = useAppStore((s) => - activeWorktreeId ? s.layoutByWorktree[activeWorktreeId] : undefined - ) - const hasSplitGroups = layout?.type === 'split' - const focusedGroupId = useAppStore((s) => - activeWorktreeId ? s.activeGroupIdByWorktree[activeWorktreeId] : undefined - ) - const splitTabToGroup = useAppStore((s) => s.splitTabToGroup) - const createUnifiedTab = useAppStore((s) => s.createUnifiedTab) - const closeUnifiedTab = useAppStore((s) => s.closeUnifiedTab) - const tabs = activeWorktreeId ? (tabsByWorktree[activeWorktreeId] ?? []) : [] const allWorktrees = Object.values(worktreesByRepo).flat() @@ -96,9 +85,8 @@ export default function Terminal(): React.JSX.Element | null { return } closeFile(fileId) - closeUnifiedTab(fileId) }, - [closeFile, closeUnifiedTab] + [closeFile] ) const handleSaveDialogSave = useCallback(async () => { @@ -129,9 +117,8 @@ export default function Terminal(): React.JSX.Element | null { await requestEditorSaveQuiesce({ fileId: saveDialogFileId }) markFileDirty(saveDialogFileId, false) closeFile(saveDialogFileId) - closeUnifiedTab(saveDialogFileId) setSaveDialogFileId(null) - }, [saveDialogFileId, closeFile, closeUnifiedTab, markFileDirty]) + }, [saveDialogFileId, closeFile, markFileDirty]) const handleSaveDialogCancel = useCallback(() => { setSaveDialogFileId(null) @@ -190,25 +177,14 @@ export default function Terminal(): React.JSX.Element | null { return } initialTabCreationGuardRef.current = activeWorktreeId - const newTab = createTab(activeWorktreeId) - // Why: keep TabsSlice in sync so tab group splits can find this tab. - createUnifiedTab(activeWorktreeId, 'terminal', { id: newTab.id, label: newTab.title }) - }, [ - workspaceSessionReady, - activeWorktreeId, - tabs.length, - worktreeFiles.length, - createTab, - createUnifiedTab - ]) + createTab(activeWorktreeId) + }, [workspaceSessionReady, activeWorktreeId, tabs.length, worktreeFiles.length, createTab]) const handleNewTab = useCallback(() => { if (!activeWorktreeId) { return } const newTab = createTab(activeWorktreeId) - // Why: keep TabsSlice in sync so tab group splits can find this tab. - createUnifiedTab(activeWorktreeId, 'terminal', { id: newTab.id, label: newTab.title }) setActiveTabType('terminal') // Why: persist the tab bar order with the new terminal at the end of the // current visual order. Without this, reconcileOrder falls back to @@ -233,7 +209,7 @@ export default function Terminal(): React.JSX.Element | null { const order = base.filter((id) => id !== newTab.id) order.push(newTab.id) setTabBarOrder(activeWorktreeId, order) - }, [activeWorktreeId, createTab, createUnifiedTab, setActiveTabType, setTabBarOrder]) + }, [activeWorktreeId, createTab, setActiveTabType, setTabBarOrder]) const handleCloseTab = useCallback( (tabId: string) => { @@ -250,7 +226,6 @@ export default function Terminal(): React.JSX.Element | null { const currentTabs = state.tabsByWorktree[owningWorktreeId] ?? [] if (currentTabs.length <= 1) { closeTab(tabId) - closeUnifiedTab(tabId) if (state.activeWorktreeId === owningWorktreeId) { // Why: only deactivate the worktree when no tabs of any kind remain. // Editor files are a separate tab type; closing the last terminal tab @@ -275,9 +250,8 @@ export default function Terminal(): React.JSX.Element | null { } } closeTab(tabId) - closeUnifiedTab(tabId) }, - [closeTab, closeUnifiedTab, setActiveTab, setActiveFile, setActiveTabType, setActiveWorktree] + [closeTab, setActiveTab, setActiveFile, setActiveTabType, setActiveWorktree] ) const handlePtyExit = useCallback( @@ -300,11 +274,10 @@ export default function Terminal(): React.JSX.Element | null { for (const tab of currentTabs) { if (tab.id !== tabId) { closeTab(tab.id) - closeUnifiedTab(tab.id) } } }, - [activeWorktreeId, closeTab, closeUnifiedTab, setActiveTab] + [activeWorktreeId, closeTab, setActiveTab] ) const handleCloseTabsToRight = useCallback( @@ -320,10 +293,9 @@ export default function Terminal(): React.JSX.Element | null { const rightTabs = currentTabs.slice(index + 1) for (const tab of rightTabs) { closeTab(tab.id) - closeUnifiedTab(tab.id) } }, - [activeWorktreeId, closeTab, closeUnifiedTab] + [activeWorktreeId, closeTab] ) const handleActivateTab = useCallback( @@ -348,31 +320,6 @@ export default function Terminal(): React.JSX.Element | null { [setActiveTab] ) - const handleSplitTab = useCallback( - (tabId: string, direction: 'left' | 'right' | 'up' | 'down') => { - splitTabToGroup(tabId, direction) - // Why: after splitting, the new group's terminal tab needs a PTY. - // The new tab was created in TabsSlice but TerminalSlice also needs - // a matching entry for PTY lifecycle. We create it in TerminalSlice - // so TerminalPane can mount and spawn a PTY. - if (activeWorktreeId) { - const state = useAppStore.getState() - const newGroupId = state.activeGroupIdByWorktree[activeWorktreeId] - const newGroupTabs = (state.unifiedTabsByWorktree[activeWorktreeId] ?? []).filter( - (t) => t.groupId === newGroupId && t.contentType === 'terminal' - ) - // The newest terminal tab in the new group needs a TerminalSlice entry - for (const ut of newGroupTabs) { - const exists = (state.tabsByWorktree[activeWorktreeId] ?? []).some((t) => t.id === ut.id) - if (!exists) { - createTab(activeWorktreeId, ut.id) - } - } - } - }, - [splitTabToGroup, activeWorktreeId, createTab] - ) - // Keyboard shortcuts useEffect(() => { if (!activeWorktreeId) { @@ -406,36 +353,16 @@ export default function Terminal(): React.JSX.Element | null { // Cmd/Ctrl+Shift+] and Cmd/Ctrl+Shift+[ - switch tabs if (mod && e.shiftKey && (e.key === ']' || e.key === '[') && !e.repeat) { const state = useAppStore.getState() + const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? [] + const currentEditorFiles = activeWorktreeId + ? state.openFiles.filter((f) => f.worktreeId === activeWorktreeId) + : [] - // Why: when splits are active, tab cycling must stay within the - // focused group so the user doesn't jump between panels unexpectedly. - const currentGroupId = state.activeGroupIdByWorktree[activeWorktreeId] - const hasLayout = state.layoutByWorktree[activeWorktreeId]?.type === 'split' - const groupFilter = - hasLayout && currentGroupId - ? (t: { groupId?: string }) => t.groupId === currentGroupId - : () => true - - const unifiedTabs = (state.unifiedTabsByWorktree[activeWorktreeId] ?? []).filter( - groupFilter - ) - - const allTabIds: { type: 'terminal' | 'editor'; id: string }[] = unifiedTabs.map((t) => ({ - type: t.contentType === 'terminal' ? ('terminal' as const) : ('editor' as const), - id: t.id - })) - - // Fallback for single-group mode without unified tabs populated - if (allTabIds.length === 0) { - const currentTerminalTabs = state.tabsByWorktree[activeWorktreeId] ?? [] - const currentEditorFiles = activeWorktreeId - ? state.openFiles.filter((f) => f.worktreeId === activeWorktreeId) - : [] - allTabIds.push( - ...currentTerminalTabs.map((t) => ({ type: 'terminal' as const, id: t.id })), - ...currentEditorFiles.map((f) => ({ type: 'editor' as const, id: f.id })) - ) - } + // Build unified tab list: terminal tabs then editor tabs + const allTabIds: { type: 'terminal' | 'editor'; id: string }[] = [ + ...currentTerminalTabs.map((t) => ({ type: 'terminal' as const, id: t.id })), + ...currentEditorFiles.map((f) => ({ type: 'editor' as const, id: f.id })) + ] if (allTabIds.length > 1) { e.preventDefault() @@ -508,11 +435,10 @@ export default function Terminal(): React.JSX.Element | null { > - {/* Why: when tab groups are split, each group renders its own inline - tab bar inside TabGroupPanel. The titlebar portal is skipped so it - doesn't show a duplicate set of tabs. */} - {!hasSplitGroups && - activeWorktreeId && + {/* Why: the tab bar is rendered into the titlebar via a portal so it + shares the same visual row as the "Orca" title. The portal target + (#titlebar-tabs) lives in App.tsx's titlebar. */} + {activeWorktreeId && titlebarTabsTarget && createPortal( , titlebarTabsTarget )} - {/* Why: always render through TabGroupSplitLayout — even for a single - group — so that splitting never unmounts the original TabGroupPanel. - The CSS Grid flat rendering in TabGroupSplitLayout keeps all - TabGroupPanels as stable keyed siblings, preserving xterm instances - and PTY connections across layout changes. */} - {activeWorktreeId && layout && ( - - )} - - {/* Why: non-active worktrees keep their TerminalPanes mounted (hidden) - so PTY connections survive worktree switches. The active worktree's - terminals are rendered by TabGroupPanel inside TabGroupSplitLayout. */} -
+ {/* Terminal panes container - hidden when editor tab active */} +
0 ? 'hidden' : ''}`} + > {allWorktrees - .filter((wt) => mountedWorktreeIdsRef.current.has(wt.id) && wt.id !== activeWorktreeId) + .filter((wt) => mountedWorktreeIdsRef.current.has(wt.id)) .map((worktree) => { const worktreeTabs = tabsByWorktree[worktree.id] ?? [] - return worktreeTabs.map((tab) => ( - handlePtyExit(tab.id, ptyId)} - onCloseTab={() => handleCloseTab(tab.id)} - /> - )) + const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId + + return ( +
+ {worktreeTabs.map((tab) => ( + handlePtyExit(tab.id, ptyId)} + onCloseTab={() => handleCloseTab(tab.id)} + /> + ))} +
+ ) })}
+ {/* Why: v1.0.85 only mounted the visible editor surface, which kept + hidden editor effects out of app shutdown. Autosave now lives in the + narrow EditorAutosaveController above, so the full EditorPanel can go + back to the safer "mount only while visible" lifecycle. */} + {activeWorktreeId && activeTabType === 'editor' && worktreeFiles.length > 0 && ( + + Loading editor... +
+ } + > + + + )} + {/* Save confirmation dialog */} () -const diffContentCache = new Map() - -type EditorPanelProps = { - /** When provided, overrides the global activeFileId from EditorSlice. - * Used by TabGroupPanel so each split group shows its own active file. */ - activeFileId?: string | null -} - -export default function EditorPanel({ - activeFileId: activeFileIdProp -}: EditorPanelProps = {}): React.JSX.Element | null { +export default function EditorPanel(): React.JSX.Element | null { const openFiles = useAppStore((s) => s.openFiles) - const globalActiveFileId = useAppStore((s) => s.activeFileId) - const activeFileId = activeFileIdProp ?? globalActiveFileId + const activeFileId = useAppStore((s) => s.activeFileId) const markFileDirty = useAppStore((s) => s.markFileDirty) const pendingEditorReveal = useAppStore((s) => s.pendingEditorReveal) const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) @@ -64,44 +47,8 @@ export default function EditorPanel({ const activeFile = openFiles.find((f) => f.id === activeFileId) ?? null - const [fileContents, setFileContentsRaw] = useState>(() => - Object.fromEntries(fileContentCache) - ) - const [diffContents, setDiffContentsRaw] = useState>(() => - Object.fromEntries(diffContentCache) - ) - - // Wrapper that keeps the module-level cache in sync with local state - const setFileContents: typeof setFileContentsRaw = useCallback((update) => { - setFileContentsRaw((prev) => { - const next = typeof update === 'function' ? update(prev) : update - for (const [id, content] of Object.entries(next)) { - fileContentCache.set(id, content) - } - // Remove entries deleted from state - for (const id of Object.keys(prev)) { - if (!(id in next)) { - fileContentCache.delete(id) - } - } - return next - }) - }, []) - - const setDiffContents: typeof setDiffContentsRaw = useCallback((update) => { - setDiffContentsRaw((prev) => { - const next = typeof update === 'function' ? update(prev) : update - for (const [id, content] of Object.entries(next)) { - diffContentCache.set(id, content) - } - for (const id of Object.keys(prev)) { - if (!(id in next)) { - diffContentCache.delete(id) - } - } - return next - }) - }, []) + const [fileContents, setFileContents] = useState>({}) + const [diffContents, setDiffContents] = useState>({}) const [copiedPathToast, setCopiedPathToast] = useState<{ fileId: string; token: number } | null>( null ) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index f0607e036cc..99d4854d00e 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -276,11 +276,6 @@ export default function MonacoEditor({ } }} path={filePath} - // Why: when editor tabs are split across groups, multiple Editor instances - // share the same Monaco model (keyed by path). The default behavior disposes - // the model on unmount, which blanks every other editor showing that file. - // keepCurrentModel prevents model disposal so sibling editors survive. - keepCurrentModel /> {copyToast ? ( diff --git a/src/renderer/src/components/editor/editor-autosave-controller.ts b/src/renderer/src/components/editor/editor-autosave-controller.ts index 47eb0fc87fd..950ba0aad3c 100644 --- a/src/renderer/src/components/editor/editor-autosave-controller.ts +++ b/src/renderer/src/components/editor/editor-autosave-controller.ts @@ -231,7 +231,6 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void { } } store.getState().closeFile(fileId) - store.getState().closeUnifiedTab(fileId) } const handleSaveFile = async (event: Event): Promise => { diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index ca04926b039..a8cd61292d9 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -1,17 +1,7 @@ import { useEffect, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import { - X, - FileCode, - GitCompareArrows, - Copy, - ShieldAlert, - PanelLeft, - PanelRight, - PanelTop, - PanelBottom -} from 'lucide-react' +import { X, FileCode, GitCompareArrows, Copy, ShieldAlert } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -35,8 +25,7 @@ export default function EditorFileTab({ onClose, onCloseToRight, onCloseAll, - onPin, - onSplitTab + onPin }: { file: OpenFile isActive: boolean @@ -47,7 +36,6 @@ export default function EditorFileTab({ onCloseToRight: () => void onCloseAll: () => void onPin?: () => void - onSplitTab?: (direction: 'left' | 'right' | 'up' | 'down') => void }): React.JSX.Element { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: file.id @@ -187,23 +175,6 @@ export default function EditorFileTab({ Close All Editor Tabs - onSplitTab?.('left')}> - - Split Left - - onSplitTab?.('right')}> - - Split Right - - onSplitTab?.('up')}> - - Split Up - - onSplitTab?.('down')}> - - Split Down - - { void window.api.ui.writeClipboardText(file.filePath) diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 2931de5c03f..3ffb0728586 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -1,15 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' -import { - X, - Terminal as TerminalIcon, - Minimize2, - PanelLeft, - PanelRight, - PanelTop, - PanelBottom -} from 'lucide-react' +import { X, Terminal as TerminalIcon, Minimize2 } from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -42,7 +34,6 @@ type SortableTabProps = { onSetCustomTitle: (tabId: string, title: string | null) => void onSetTabColor: (tabId: string, color: string | null) => void onToggleExpand: (tabId: string) => void - onSplitTab?: (tabId: string, direction: 'left' | 'right' | 'up' | 'down') => void } export const TAB_COLORS = [ @@ -72,8 +63,7 @@ export default function SortableTab({ onCloseToRight, onSetCustomTitle, onSetTabColor, - onToggleExpand, - onSplitTab + onToggleExpand }: SortableTabProps): React.JSX.Element { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id @@ -228,23 +218,6 @@ export default function SortableTab({ Close Tabs To The Right - onSplitTab?.(tab.id, 'left')}> - - Split Left - - onSplitTab?.(tab.id, 'right')}> - - Split Right - - onSplitTab?.(tab.id, 'up')}> - - Split Up - - onSplitTab?.(tab.id, 'down')}> - - Split Down - - Change Title
Tab Color
diff --git a/src/renderer/src/components/tab-bar/TabBar.tsx b/src/renderer/src/components/tab-bar/TabBar.tsx index 6b4e4787e45..7a7419400fc 100644 --- a/src/renderer/src/components/tab-bar/TabBar.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.tsx @@ -39,7 +39,6 @@ type TabBarProps = { onCloseAllFiles?: () => void onPinFile?: (fileId: string) => void tabBarOrder?: string[] - onSplitTab?: (tabId: string, direction: 'left' | 'right' | 'up' | 'down') => void } type TabItem = @@ -67,8 +66,7 @@ export default function TabBar({ onCloseFile, onCloseAllFiles, onPinFile, - tabBarOrder, - onSplitTab + tabBarOrder }: TabBarProps): React.JSX.Element { const sensors = useSensors( useSensor(PointerSensor, { @@ -181,7 +179,6 @@ export default function TabBar({ onSetCustomTitle={onSetCustomTitle} onSetTabColor={onSetTabColor} onToggleExpand={onTogglePaneExpand} - onSplitTab={onSplitTab} /> ) } @@ -197,9 +194,6 @@ export default function TabBar({ onCloseToRight={() => onCloseToRight(item.id)} onCloseAll={() => onCloseAllFiles?.()} onPin={() => onPinFile?.(item.id)} - onSplitTab={ - onSplitTab ? (direction) => onSplitTab(item.id, direction) : undefined - } /> ) })} diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx deleted file mode 100644 index 08f32d75d1c..00000000000 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ /dev/null @@ -1,286 +0,0 @@ -import { useCallback, useMemo, lazy, Suspense } from 'react' -import { useShallow } from 'zustand/react/shallow' -import { useAppStore } from '../../store' -import TabBar from '../tab-bar/TabBar' -import TerminalPane from '../terminal-pane/TerminalPane' - -const EditorPanel = lazy(() => import('../editor/EditorPanel')) - -type TabGroupPanelProps = { - groupId: string - worktreeId: string - isFocused: boolean - // Why: in single-group mode the titlebar portal renders the tab bar, so - // the inline tab bar inside TabGroupPanel must be hidden to avoid duplicates. - hasSplitGroups: boolean - onSplitTab: (tabId: string, direction: 'left' | 'right' | 'up' | 'down') => void -} - -export default function TabGroupPanel({ - groupId, - worktreeId, - isFocused, - hasSplitGroups, - onSplitTab -}: TabGroupPanelProps): React.JSX.Element { - // Why: useShallow prevents infinite re-renders — .find()/.filter() create - // new references on every call, which fails Zustand's Object.is check. - const group = useAppStore( - useShallow((s) => (s.groupsByWorktree[worktreeId] ?? []).find((g) => g.id === groupId) ?? null) - ) - const groupTabs = useAppStore( - useShallow((s) => - (s.unifiedTabsByWorktree[worktreeId] ?? []).filter((t) => t.groupId === groupId) - ) - ) - const focusGroup = useAppStore((s) => s.focusGroup) - const activateTab = useAppStore((s) => s.activateTab) - const closeUnifiedTab = useAppStore((s) => s.closeUnifiedTab) - const closeOtherTabs = useAppStore((s) => s.closeOtherTabs) - const closeTabsToRight = useAppStore((s) => s.closeTabsToRight) - const setTabCustomLabel = useAppStore((s) => s.setTabCustomLabel) - const setUnifiedTabColor = useAppStore((s) => s.setUnifiedTabColor) - - // Bridge: TerminalSlice state for PTY lifecycle - const createTab = useAppStore((s) => s.createTab) - const closeTab = useAppStore((s) => s.closeTab) - const createUnifiedTab = useAppStore((s) => s.createUnifiedTab) - const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId) - const setActiveTab = useAppStore((s) => s.setActiveTab) - const setActiveTabType = useAppStore((s) => s.setActiveTabType) - const setActiveFile = useAppStore((s) => s.setActiveFile) - const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit) - const openFiles = useAppStore((s) => s.openFiles) - const closeFile = useAppStore((s) => s.closeFile) - const closeAllFiles = useAppStore((s) => s.closeAllFiles) - const pinFile = useAppStore((s) => s.pinFile) - - const activeTabId = group?.activeTabId ?? null - const activeTab = groupTabs.find((t) => t.id === activeTabId) ?? null - - // Convert unified tabs to TerminalTab shape for TabBar compatibility - const terminalTabs = useMemo(() => { - return groupTabs - .filter((t) => t.contentType === 'terminal') - .map((t) => ({ - id: t.id, - ptyId: null as string | null, - worktreeId, - title: t.label, - customTitle: t.customLabel, - color: t.color, - sortOrder: t.sortOrder, - createdAt: t.createdAt - })) - }, [groupTabs, worktreeId]) - - const editorFiles = useMemo(() => { - const editorTabIds = new Set( - groupTabs - .filter( - (t) => - t.contentType === 'editor' || - t.contentType === 'diff' || - t.contentType === 'conflict-review' - ) - .map((t) => t.id) - ) - return openFiles.filter((f) => f.worktreeId === worktreeId && editorTabIds.has(f.id)) - }, [groupTabs, openFiles, worktreeId]) - - const worktree = useAppStore( - useShallow( - (s) => - Object.values(s.worktreesByRepo) - .flat() - .find((wt) => wt.id === worktreeId) ?? null - ) - ) - - const handleFocusClick = useCallback(() => { - focusGroup(worktreeId, groupId) - }, [focusGroup, worktreeId, groupId]) - - const handleActivate = useCallback( - (tabId: string) => { - focusGroup(worktreeId, groupId) - activateTab(tabId) - setActiveTab(tabId) - setActiveTabType('terminal') - }, - [focusGroup, worktreeId, groupId, activateTab, setActiveTab, setActiveTabType] - ) - - // Why: all worktree tabs (not just this group's) are needed to check whether - // an editor file is still referenced by another group before closing it. - const allWorktreeTabs = useAppStore(useShallow((s) => s.unifiedTabsByWorktree[worktreeId] ?? [])) - - const handleClose = useCallback( - (tabId: string) => { - const tab = groupTabs.find((t) => t.id === tabId) - if (!tab) { - return - } - if (tab.contentType === 'terminal') { - closeTab(tabId) - } else { - // Why: editor tabs share the same ID (filePath) across groups. Only - // close the OpenFile entry when no OTHER group still references it, - // otherwise the other group's EditorPanel would lose its file data. - const otherGroupHasFile = allWorktreeTabs.some( - (t) => t.id === tabId && t.groupId !== groupId - ) - if (!otherGroupHasFile) { - closeFile(tabId) - } - } - // Why: pass groupId so editor tabs (which can share the same filePath ID - // across split groups) only get removed from THIS group, not all groups. - closeUnifiedTab(tabId, groupId) - }, - [groupTabs, allWorktreeTabs, groupId, closeTab, closeFile, closeUnifiedTab] - ) - - const handleCloseOthers = useCallback( - (tabId: string) => { - const closedIds = closeOtherTabs(tabId) - // Bridge: also clean up TerminalSlice/EditorSlice for each closed tab - for (const id of closedIds) { - const tab = groupTabs.find((t) => t.id === id) - if (tab?.contentType === 'terminal') { - closeTab(id) - } else if (tab) { - const otherGroupHasFile = allWorktreeTabs.some( - (t) => t.id === id && t.groupId !== groupId - ) - if (!otherGroupHasFile) { - closeFile(id) - } - } - } - }, - [closeOtherTabs, groupTabs, allWorktreeTabs, groupId, closeTab, closeFile] - ) - - const handleCloseToRight = useCallback( - (tabId: string) => { - const closedIds = closeTabsToRight(tabId) - for (const id of closedIds) { - const tab = groupTabs.find((t) => t.id === id) - if (tab?.contentType === 'terminal') { - closeTab(id) - } else if (tab) { - const otherGroupHasFile = allWorktreeTabs.some( - (t) => t.id === id && t.groupId !== groupId - ) - if (!otherGroupHasFile) { - closeFile(id) - } - } - } - }, - [closeTabsToRight, groupTabs, allWorktreeTabs, groupId, closeTab, closeFile] - ) - - const handleNewTab = useCallback(() => { - focusGroup(worktreeId, groupId) - const newTab = createUnifiedTab(worktreeId, 'terminal') - createTab(worktreeId, newTab.id) - setActiveTab(newTab.id) - setActiveTabType('terminal') - }, [focusGroup, worktreeId, groupId, createUnifiedTab, createTab, setActiveTab, setActiveTabType]) - - const handlePtyExit = useCallback( - (tabId: string, ptyId: string) => { - if (consumeSuppressedPtyExit(ptyId)) { - return - } - handleClose(tabId) - }, - [consumeSuppressedPtyExit, handleClose] - ) - - const handleActivateFile = useCallback( - (fileId: string) => { - focusGroup(worktreeId, groupId) - activateTab(fileId) - setActiveFile(fileId) - setActiveTabType('editor') - }, - [focusGroup, worktreeId, groupId, activateTab, setActiveFile, setActiveTabType] - ) - - const isActiveTerminal = activeTab?.contentType === 'terminal' - const isActiveEditor = activeTab?.contentType === 'editor' || activeTab?.contentType === 'diff' - - return ( -
- {/* Why: in single-group mode, the titlebar portal renders the tab bar, - so the inline version is hidden to avoid duplication. */} -
- {}} - onNewTab={handleNewTab} - onSetCustomTitle={(tabId, title) => setTabCustomLabel(tabId, title)} - onSetTabColor={(tabId, color) => setUnifiedTabColor(tabId, color)} - onTogglePaneExpand={() => {}} - editorFiles={editorFiles} - activeFileId={isActiveEditor ? activeTabId : null} - activeTabType={isActiveTerminal ? 'terminal' : 'editor'} - onActivateFile={handleActivateFile} - onCloseFile={(fileId) => handleClose(fileId)} - onCloseAllFiles={closeAllFiles} - onPinFile={pinFile} - onSplitTab={onSplitTab} - /> -
- - {/* Content area */} -
- {/* Terminal panes for this group's terminal tabs */} - {groupTabs - .filter((t) => t.contentType === 'terminal') - .map((tab) => ( - handlePtyExit(tab.id, ptyId)} - onCloseTab={() => handleClose(tab.id)} - /> - ))} - - {/* Editor panel for the active editor tab */} - {isActiveEditor && ( - - Loading editor... -
- } - > - - - )} -
-
- ) -} diff --git a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx b/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx deleted file mode 100644 index 420d6324777..00000000000 --- a/src/renderer/src/components/tab-group/TabGroupSplitLayout.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { useMemo } from 'react' -import type { TabGroupLayoutNode } from '../../../../shared/types' -import TabGroupPanel from './TabGroupPanel' - -type GroupPlacement = { - groupId: string - gridColumn: string - gridRow: string -} - -// Why: recursive rendering changes the React tree structure when the layout -// changes (leaf → split), which unmounts and remounts TabGroupPanels — -// destroying xterm instances and killing PTY processes. CSS Grid with a flat -// list of keyed children keeps TabGroupPanels mounted across layout changes -// because React matches them by key, not by tree position. - -function maxSplitDepth(node: TabGroupLayoutNode, dir: 'horizontal' | 'vertical'): number { - if (node.type === 'leaf') { - return 0 - } - if (node.direction === dir) { - return 1 + Math.max(maxSplitDepth(node.first, dir), maxSplitDepth(node.second, dir)) - } - return Math.max(maxSplitDepth(node.first, dir), maxSplitDepth(node.second, dir)) -} - -function collectPlacements( - node: TabGroupLayoutNode, - col: number, - colSpan: number, - row: number, - rowSpan: number, - out: GroupPlacement[] -): void { - if (node.type === 'leaf') { - out.push({ - groupId: node.groupId, - gridColumn: `${col} / ${col + colSpan}`, - gridRow: `${row} / ${row + rowSpan}` - }) - return - } - if (node.direction === 'horizontal') { - const half = colSpan / 2 - collectPlacements(node.first, col, half, row, rowSpan, out) - collectPlacements(node.second, col + half, half, row, rowSpan, out) - } else { - const half = rowSpan / 2 - collectPlacements(node.first, col, colSpan, row, half, out) - collectPlacements(node.second, col, colSpan, row + half, half, out) - } -} - -function computeGridLayout(layout: TabGroupLayoutNode): { - columns: number - rows: number - placements: GroupPlacement[] -} { - const hDepth = maxSplitDepth(layout, 'horizontal') - const vDepth = maxSplitDepth(layout, 'vertical') - const columns = Math.max(1, Math.pow(2, hDepth)) - const rows = Math.max(1, Math.pow(2, vDepth)) - const placements: GroupPlacement[] = [] - collectPlacements(layout, 1, columns, 1, rows, placements) - return { columns, rows, placements } -} - -type TabGroupSplitLayoutProps = { - layout: TabGroupLayoutNode - worktreeId: string - focusedGroupId: string | undefined - hasSplitGroups: boolean - onSplitTab: (tabId: string, direction: 'left' | 'right' | 'up' | 'down') => void -} - -export default function TabGroupSplitLayout({ - layout, - worktreeId, - focusedGroupId, - hasSplitGroups, - onSplitTab -}: TabGroupSplitLayoutProps): React.JSX.Element { - const { columns, rows, placements } = useMemo(() => computeGridLayout(layout), [layout]) - - return ( -
- {placements.map(({ groupId, gridColumn, gridRow }) => ( -
- -
- ))} -
- ) -} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index aab49b882fc..4f9c2580936 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -37,11 +37,6 @@ type TerminalPaneProps = { worktreeId: string cwd?: string isActive: boolean - // Why: in multi-group splits, the active tab in each group must be visible - // (display: flex) but only the focused group's terminal should receive - // keyboard input. When provided, isVisible controls display independently - // of isActive. When omitted, isActive controls both (single-group behavior). - isVisible?: boolean onPtyExit: (ptyId: string) => void onCloseTab: () => void } @@ -51,7 +46,6 @@ export default function TerminalPane({ worktreeId, cwd, isActive, - isVisible, onPtyExit, onCloseTab }: TerminalPaneProps): React.JSX.Element { @@ -64,11 +58,8 @@ export default function TerminalPane({ ) const paneTransportsRef = useRef>(new Map()) const pendingWritesRef = useRef>(new Map()) - const effectiveVisible = isVisible ?? isActive const isActiveRef = useRef(isActive) isActiveRef.current = isActive - const effectiveVisibleRef = useRef(effectiveVisible) - effectiveVisibleRef.current = effectiveVisible const [expandedPaneId, setExpandedPaneId] = useState(null) const [searchOpen, setSearchOpen] = useState(false) @@ -240,7 +231,6 @@ export default function TerminalPane({ paneTransportsRef, pendingWritesRef, isActiveRef, - effectiveVisibleRef, onPtyExitRef, onPtyErrorRef, clearTabPtyId, @@ -262,7 +252,7 @@ export default function TerminalPane({ useTerminalFontZoom({ isActive, managerRef, paneFontSizesRef, settingsRef }) useTerminalKeyboardShortcuts({ - isActive: isActive, + isActive, managerRef, paneTransportsRef, expandedPaneIdRef, @@ -278,7 +268,6 @@ export default function TerminalPane({ useTerminalPaneGlobalEffects({ tabId, isActive, - isVisible: effectiveVisible, managerRef, containerRef, paneTransportsRef, @@ -514,7 +503,7 @@ export default function TerminalPane({ : null const terminalContainerStyle: CSSProperties = { - display: effectiveVisible ? 'flex' : 'none', + display: isActive ? 'flex' : 'none', ['--orca-terminal-divider-color' as string]: effectiveAppearance?.dividerColor ?? DEFAULT_TERMINAL_DIVIDER_DARK, ['--orca-terminal-divider-color-strong' as string]: normalizeColor( @@ -561,7 +550,7 @@ export default function TerminalPane({ transport.sendInput(shellEscapePath(filePath)) }} /> - {terminalError && effectiveVisible && ( + {terminalError && isActive && ( setTerminalError(null)} /> )} {activePane?.container && diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 02035e5d884..59e0b231227 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -17,7 +17,6 @@ type PtyConnectionDeps = { paneTransportsRef: React.RefObject> pendingWritesRef: React.RefObject> isActiveRef: React.RefObject - effectiveVisibleRef: React.RefObject onPtyExitRef: React.RefObject<(ptyId: string) => void> onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void> clearTabPtyId: (tabId: string, ptyId: string) => void @@ -181,11 +180,7 @@ export function connectPanePty( } const dataCallback = (data: string): void => { - // Why: in split-group mode, a non-focused group's terminal is visible - // (display:flex) but not active (no keyboard focus). PTY output must - // still be written to xterm so the user sees content. Only buffer - // output when the terminal is truly hidden (e.g., a background tab). - if (deps.effectiveVisibleRef.current) { + if (deps.isActiveRef.current) { pane.terminal.write(data) } else { const pending = deps.pendingWritesRef.current diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index 1501743e876..f6efa8d638b 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -8,9 +8,6 @@ import type { PtyTransport } from './pty-transport' type UseTerminalPaneGlobalEffectsArgs = { tabId: string isActive: boolean - // Why: in multi-group splits, isVisible controls rendering/display while - // isActive controls keyboard focus. When not provided, isActive is used. - isVisible?: boolean managerRef: React.RefObject containerRef: React.RefObject paneTransportsRef: React.RefObject> @@ -22,7 +19,6 @@ type UseTerminalPaneGlobalEffectsArgs = { export function useTerminalPaneGlobalEffects({ tabId, isActive, - isVisible, managerRef, containerRef, paneTransportsRef, @@ -30,15 +26,14 @@ export function useTerminalPaneGlobalEffects({ isActiveRef, toggleExpandPane }: UseTerminalPaneGlobalEffectsArgs): void { - const wasVisibleRef = useRef(false) + const wasActiveRef = useRef(false) useEffect(() => { - const effectiveVisible = isVisible ?? isActive const manager = managerRef.current if (!manager) { return } - if (effectiveVisible) { + if (isActive) { manager.resumeRendering() for (const [paneId, pendingBuffer] of pendingWritesRef.current.entries()) { if (pendingBuffer.length > 0) { @@ -49,23 +44,14 @@ export function useTerminalPaneGlobalEffects({ pendingWritesRef.current.set(paneId, '') } } - // Why: fit all visible panes so they fill their container, but only - // focus the terminal when the pane is the keyboard-active one. This - // prevents non-focused split groups from stealing xterm focus. - requestAnimationFrame(() => { - if (isActive) { - fitAndFocusPanes(manager) - } else { - fitPanes(manager) - } - }) - } else if (wasVisibleRef.current) { + requestAnimationFrame(() => fitAndFocusPanes(manager)) + } else if (wasActiveRef.current) { manager.suspendRendering() } - wasVisibleRef.current = effectiveVisible + wasActiveRef.current = isActive isActiveRef.current = isActive // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isActive, isVisible]) + }, [isActive]) useEffect(() => { const onToggleExpand = (event: Event): void => { @@ -93,8 +79,7 @@ export function useTerminalPaneGlobalEffects({ }, [tabId]) useEffect(() => { - const effectiveVisible = isVisible ?? isActive - if (!effectiveVisible) { + if (!isActive) { return } const container = containerRef.current @@ -133,7 +118,7 @@ export function useTerminalPaneGlobalEffects({ } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isActive, isVisible]) + }, [isActive]) useEffect(() => { return window.api.ui.onFileDrop(({ path, target }) => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index c7a0e36d1f4..346e51d201a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -38,7 +38,6 @@ type UseTerminalPaneLifecycleDeps = { paneTransportsRef: React.RefObject> pendingWritesRef: React.RefObject> isActiveRef: React.RefObject - effectiveVisibleRef: React.RefObject onPtyExitRef: React.RefObject<(ptyId: string) => void> onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void> clearTabPtyId: (tabId: string, ptyId: string) => void @@ -77,7 +76,6 @@ export function useTerminalPaneLifecycle({ paneTransportsRef, pendingWritesRef, isActiveRef, - effectiveVisibleRef, onPtyExitRef, onPtyErrorRef, clearTabPtyId, @@ -174,7 +172,6 @@ export function useTerminalPaneLifecycle({ paneTransportsRef, pendingWritesRef, isActiveRef, - effectiveVisibleRef, onPtyExitRef, onPtyErrorRef, clearTabPtyId, diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 2209c057597..f82c4c0edce 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -254,7 +254,7 @@ export type EditorSlice = { hydrateEditorSession: (session: WorkspaceSessionState) => void } -export const createEditorSlice: StateCreator = (set, get) => ({ +export const createEditorSlice: StateCreator = (set) => ({ editorDrafts: {}, setEditorDraft: (fileId, content) => set((s) => ({ @@ -342,7 +342,7 @@ export const createEditorSlice: StateCreator = (s } }), - openFile: (file, options) => { + openFile: (file, options) => set((s) => { const id = file.filePath const existing = s.openFiles.find((f) => f.id === id) @@ -393,13 +393,9 @@ export const createEditorSlice: StateCreator = (s } } - // If opening as preview, replace the existing preview tab for this worktree. - // Why: in multi-group (split) mode, the worktree-wide preview replacement - // would remove an OpenFile still displayed in another group's EditorPanel. - // Unified tabs handle per-group preview replacement, so skip it here. + // If opening as preview, replace the existing preview tab for this worktree let newFiles = s.openFiles - const hasMultipleGroups = Boolean(s.layoutByWorktree?.[worktreeId]) - if (isPreview && !hasMultipleGroups) { + if (isPreview) { const existingPreviewIdx = s.openFiles.findIndex( (f) => f.worktreeId === worktreeId && f.isPreview ) @@ -475,34 +471,7 @@ export const createEditorSlice: StateCreator = (s ...tabBarUpdate, ...activeResult } - }) - - // Why: keep TabsSlice in sync so tab group splits can find and display - // editor tabs. The unified tab's ID matches the file's ID (filePath) - // so TabGroupPanel can correlate them. - const state = get() - if (state.createUnifiedTab) { - const contentType = file.mode === 'edit' ? 'editor' : file.mode - const fileId = file.filePath - const focusedGroupId = state.activeGroupIdByWorktree?.[file.worktreeId] - // Why: check if the file already has a unified tab in the FOCUSED group. - // Editor tabs can exist in multiple groups (via splits), so we must check - // the specific group rather than the whole worktree. If the file is open - // in group A but the user opens it from group B, we create a new tab in B. - const existingInFocusedGroup = (state.unifiedTabsByWorktree[file.worktreeId] ?? []).find( - (t) => t.id === fileId && t.groupId === focusedGroupId - ) - if (!existingInFocusedGroup) { - state.createUnifiedTab(file.worktreeId, contentType, { - id: fileId, - label: file.relativePath, - isPreview: options?.preview - }) - } else { - state.activateTab(fileId) - } - } - }, + }), pinFile: (fileId) => set((s) => { @@ -520,7 +489,7 @@ export const createEditorSlice: StateCreator = (s // remain visible until the file leaves the sidebar, the session resets, or // the file becomes live-unresolved again. trackedConflictPaths is tied to // sidebar presence, not tab lifecycle. - closeFile: (fileId) => { + closeFile: (fileId) => set((s) => { const closedFile = s.openFiles.find((f) => f.id === fileId) const idx = s.openFiles.findIndex((f) => f.id === fileId) @@ -578,18 +547,12 @@ export const createEditorSlice: StateCreator = (s markdownViewMode: newMarkdownViewMode, pendingEditorReveal: null } - }) + }), - // Why: keep TabsSlice in sync — remove the unified tab for this editor file. - get().closeUnifiedTab?.(fileId) - }, - - closeAllFiles: () => { - const closedFileIds: string[] = [] + closeAllFiles: () => set((s) => { const activeWorktreeId = s.activeWorktreeId if (!activeWorktreeId) { - closedFileIds.push(...s.openFiles.map((f) => f.id)) return { openFiles: [], editorDrafts: {}, @@ -600,8 +563,6 @@ export const createEditorSlice: StateCreator = (s } } // Only close files for the current worktree - const closing = s.openFiles.filter((f) => f.worktreeId === activeWorktreeId) - closedFileIds.push(...closing.map((f) => f.id)) const newFiles = s.openFiles.filter((f) => f.worktreeId !== activeWorktreeId) const remainingFileIds = new Set(newFiles.map((f) => f.id)) const newEditorDrafts = Object.fromEntries( @@ -628,16 +589,7 @@ export const createEditorSlice: StateCreator = (s // to an old match unexpectedly. pendingEditorReveal: null } - }) - - // Why: keep TabsSlice in sync — remove unified tabs for closed editor files. - const state = get() - if (state.closeUnifiedTab) { - for (const fileId of closedFileIds) { - state.closeUnifiedTab(fileId) - } - } - }, + }), setActiveFile: (fileId) => set((s) => { diff --git a/src/renderer/src/store/slices/tab-group-layout-ops.ts b/src/renderer/src/store/slices/tab-group-layout-ops.ts deleted file mode 100644 index fabd9a8a05f..00000000000 --- a/src/renderer/src/store/slices/tab-group-layout-ops.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { TabGroupLayoutNode, TabGroupSplitDirection } from '../../../../shared/types' - -/** - * Replace a leaf node with a new node (typically a split containing the original leaf - * and a new leaf). Used by splitTabToGroup to insert a new group adjacent to an existing one. - */ -export function replaceLeaf( - root: TabGroupLayoutNode, - targetGroupId: string, - replacement: TabGroupLayoutNode -): TabGroupLayoutNode { - if (root.type === 'leaf') { - return root.groupId === targetGroupId ? replacement : root - } - return { - ...root, - first: replaceLeaf(root.first, targetGroupId, replacement), - second: replaceLeaf(root.second, targetGroupId, replacement) - } -} - -/** - * Build the split node that replaces a leaf when splitting a group. - * `position` is where the NEW group appears relative to the original. - */ -export function buildSplitNode( - existingGroupId: string, - newGroupId: string, - direction: TabGroupSplitDirection, - position: 'first' | 'second' -): TabGroupLayoutNode { - const existingLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: existingGroupId } - const newLeaf: TabGroupLayoutNode = { type: 'leaf', groupId: newGroupId } - return { - type: 'split', - direction, - first: position === 'first' ? newLeaf : existingLeaf, - second: position === 'second' ? newLeaf : existingLeaf - } -} - -/** - * Remove a leaf and promote its sibling to take the parent's place. - * Returns null if the root itself is the removed leaf (tree is now empty). - */ -export function removeLeaf(root: TabGroupLayoutNode, groupId: string): TabGroupLayoutNode | null { - if (root.type === 'leaf') { - return root.groupId === groupId ? null : root - } - - // If one direct child is the target leaf, promote the other child - if (root.first.type === 'leaf' && root.first.groupId === groupId) { - return root.second - } - if (root.second.type === 'leaf' && root.second.groupId === groupId) { - return root.first - } - - // Recurse into children - const newFirst = removeLeaf(root.first, groupId) - const newSecond = removeLeaf(root.second, groupId) - - // If a subtree collapsed to null, promote the other - if (newFirst === null) { - return newSecond - } - if (newSecond === null) { - return newFirst - } - - return { ...root, first: newFirst, second: newSecond } -} - -/** - * Find the nearest sibling group for focus fallback when a group is removed. - * Returns the groupId of the sibling in the same parent split, or null. - */ -export function findSiblingGroupId(root: TabGroupLayoutNode, groupId: string): string | null { - if (root.type === 'leaf') { - return null - } - - // Check if one direct child is the target — if so, return first leaf of the other - if (root.first.type === 'leaf' && root.first.groupId === groupId) { - return firstLeafGroupId(root.second) - } - if (root.second.type === 'leaf' && root.second.groupId === groupId) { - return firstLeafGroupId(root.first) - } - - // Recurse - return findSiblingGroupId(root.first, groupId) ?? findSiblingGroupId(root.second, groupId) -} - -/** Collect all group IDs present in the layout tree. */ -export function collectGroupIds(root: TabGroupLayoutNode): string[] { - if (root.type === 'leaf') { - return [root.groupId] - } - return [...collectGroupIds(root.first), ...collectGroupIds(root.second)] -} - -function firstLeafGroupId(node: TabGroupLayoutNode): string { - if (node.type === 'leaf') { - return node.groupId - } - return firstLeafGroupId(node.first) -} diff --git a/src/renderer/src/store/slices/tabs-bulk-actions.ts b/src/renderer/src/store/slices/tabs-bulk-actions.ts deleted file mode 100644 index e00eeab035a..00000000000 --- a/src/renderer/src/store/slices/tabs-bulk-actions.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { Tab, TabGroup } from '../../../../shared/types' -import { findTabAndWorktree, findGroupForTab, updateGroup } from './tabs-helpers' - -type TabsBulkState = { - unifiedTabsByWorktree: Record - groupsByWorktree: Record -} - -type SetGet = { - set: (fn: (s: TabsBulkState) => Partial) => void - get: () => TabsBulkState -} - -export function createCloseOtherTabs({ set, get }: SetGet) { - return (tabId: string): string[] => { - const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return [] - } - - const { tab, worktreeId } = found - const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - if (!group) { - return [] - } - - const tabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const closedIds = tabs - .filter((t) => t.id !== tabId && !t.isPinned && t.groupId === group.id) - .map((t) => t.id) - - if (closedIds.length === 0) { - return [] - } - - const closedSet = new Set(closedIds) - - set((s) => { - const currentTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] - const remainingTabs = currentTabs.filter((t) => !closedSet.has(t.id)) - const remainingOrder = group.tabOrder.filter((tid) => !closedSet.has(tid)) - const updatedGroupObj: TabGroup = { ...group, activeTabId: tabId, tabOrder: remainingOrder } - - return { - unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [worktreeId]: remainingTabs }, - groupsByWorktree: { - ...s.groupsByWorktree, - [worktreeId]: updateGroup(s.groupsByWorktree[worktreeId] ?? [], updatedGroupObj) - } - } - }) - - return closedIds - } -} - -export function createCloseTabsToRight({ set, get }: SetGet) { - return (tabId: string): string[] => { - const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - if (!found) { - return [] - } - - const { tab, worktreeId } = found - const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) - if (!group) { - return [] - } - - const idx = group.tabOrder.indexOf(tabId) - if (idx === -1) { - return [] - } - - const idsToRight = group.tabOrder.slice(idx + 1) - const tabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const tabMap = new Map(tabs.map((t) => [t.id, t])) - - const closedIds = idsToRight.filter((tid) => { - const t = tabMap.get(tid) - return t && !t.isPinned - }) - - if (closedIds.length === 0) { - return [] - } - - const closedSet = new Set(closedIds) - - set((s) => { - const currentTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] - const remainingTabs = currentTabs.filter((t) => !closedSet.has(t.id)) - const remainingOrder = group.tabOrder.filter((tid) => !closedSet.has(tid)) - - const newActiveTabId = closedSet.has(group.activeTabId ?? '') ? tabId : group.activeTabId - const updatedGroupObj: TabGroup = { - ...group, - activeTabId: newActiveTabId, - tabOrder: remainingOrder - } - - return { - unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [worktreeId]: remainingTabs }, - groupsByWorktree: { - ...s.groupsByWorktree, - [worktreeId]: updateGroup(s.groupsByWorktree[worktreeId] ?? [], updatedGroupObj) - } - } - }) - - return closedIds - } -} diff --git a/src/renderer/src/store/slices/tabs-helpers.ts b/src/renderer/src/store/slices/tabs-helpers.ts index 1b29ad56ff1..59d14f4cbd8 100644 --- a/src/renderer/src/store/slices/tabs-helpers.ts +++ b/src/renderer/src/store/slices/tabs-helpers.ts @@ -2,14 +2,10 @@ import type { Tab, TabGroup } from '../../../../shared/types' export function findTabAndWorktree( tabsByWorktree: Record, - tabId: string, - // Why: editor tabs can share the same ID (filePath) across groups when split. - // When provided, groupId narrows the search to a specific group so operations - // like close/activate target the correct group's tab, not the first match. - groupId?: string + tabId: string ): { tab: Tab; worktreeId: string } | null { for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { - const tab = tabs.find((t) => t.id === tabId && (!groupId || t.groupId === groupId)) + const tab = tabs.find((t) => t.id === tabId) if (tab) { return { tab, worktreeId } } @@ -29,23 +25,12 @@ export function findGroupForTab( export function ensureGroup( groupsByWorktree: Record, activeGroupIdByWorktree: Record, - worktreeId: string, - targetGroupId?: string + worktreeId: string ): { group: TabGroup groupsByWorktree: Record activeGroupIdByWorktree: Record } { - // Why: in multi-group mode, new tabs must go to the group the user is - // interacting with. targetGroupId is checked first so the caller can - // direct creation into a specific group rather than always landing in [0]. - if (targetGroupId) { - const target = groupsByWorktree[worktreeId]?.find((g) => g.id === targetGroupId) - if (target) { - return { group: target, groupsByWorktree, activeGroupIdByWorktree } - } - } - const existing = groupsByWorktree[worktreeId]?.[0] if (existing) { return { group: existing, groupsByWorktree, activeGroupIdByWorktree } diff --git a/src/renderer/src/store/slices/tabs-hydration.ts b/src/renderer/src/store/slices/tabs-hydration.ts index b2aaf0f3f08..ee0cc2fd772 100644 --- a/src/renderer/src/store/slices/tabs-hydration.ts +++ b/src/renderer/src/store/slices/tabs-hydration.ts @@ -1,16 +1,9 @@ -import type { - Tab, - TabGroup, - TabGroupLayoutNode, - WorkspaceSessionState -} from '../../../../shared/types' -import { collectGroupIds } from './tab-group-layout-ops' +import type { Tab, TabGroup, WorkspaceSessionState } from '../../../../shared/types' type HydratedTabState = { unifiedTabsByWorktree: Record groupsByWorktree: Record activeGroupIdByWorktree: Record - layoutByWorktree: Record } function hydrateUnifiedFormat( @@ -52,31 +45,7 @@ function hydrateUnifiedFormat( activeGroupIdByWorktree[worktreeId] = validatedGroups[0].id } - // Restore layout trees with validation - const layoutByWorktree: Record = {} - if (session.tabGroupLayouts) { - for (const [worktreeId, layout] of Object.entries(session.tabGroupLayouts)) { - if (!validWorktreeIds.has(worktreeId)) { - continue - } - const validGroupIds = new Set((groupsByWorktree[worktreeId] ?? []).map((g) => g.id)) - const layoutGroupIds = collectGroupIds(layout) - // Why: if any group ID in the layout tree is invalid (e.g. from a - // corrupted session), discard the entire layout. The groups and tabs - // still exist — the user just loses the split arrangement, which is - // safer than rendering a broken tree. - if (layoutGroupIds.every((id) => validGroupIds.has(id))) { - layoutByWorktree[worktreeId] = layout - } - } - } - - return { - unifiedTabsByWorktree: tabsByWorktree, - groupsByWorktree, - activeGroupIdByWorktree, - layoutByWorktree - } + return { unifiedTabsByWorktree: tabsByWorktree, groupsByWorktree, activeGroupIdByWorktree } } function hydrateLegacyFormat( @@ -149,12 +118,7 @@ function hydrateLegacyFormat( activeGroupIdByWorktree[worktreeId] = groupId } - return { - unifiedTabsByWorktree: tabsByWorktree, - groupsByWorktree, - activeGroupIdByWorktree, - layoutByWorktree: {} - } + return { unifiedTabsByWorktree: tabsByWorktree, groupsByWorktree, activeGroupIdByWorktree } } export function buildHydratedTabState( diff --git a/src/renderer/src/store/slices/tabs-split-actions.ts b/src/renderer/src/store/slices/tabs-split-actions.ts deleted file mode 100644 index 6604be202a6..00000000000 --- a/src/renderer/src/store/slices/tabs-split-actions.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { Tab, TabGroup, TabGroupLayoutNode } from '../../../../shared/types' -import type { TabSplitDirection } from './tabs' -import { findTabAndWorktree } from './tabs-helpers' -import { replaceLeaf, buildSplitNode, removeLeaf, findSiblingGroupId } from './tab-group-layout-ops' - -type TabsState = { - unifiedTabsByWorktree: Record - groupsByWorktree: Record - activeGroupIdByWorktree: Record - layoutByWorktree: Record -} - -type SetGet = { - set: (fn: (s: TabsState) => Partial) => void - get: () => TabsState -} - -export function createSplitTabToGroup({ set, get }: SetGet) { - return (tabId: string, direction: TabSplitDirection): void => { - const state = get() - // Why: editor/diff tabs reuse the file path as their tab ID, so the same - // ID can appear in multiple groups after a split. Prefer the focused group - // to find the correct tab instance — the context menu's pointerDown event - // focuses the group before the split action fires. - let found: { tab: Tab; worktreeId: string } | null = null - for (const [wId, tabs] of Object.entries(state.unifiedTabsByWorktree)) { - const focusedGroupId = state.activeGroupIdByWorktree[wId] - if (focusedGroupId) { - const tab = tabs.find((t) => t.id === tabId && t.groupId === focusedGroupId) - if (tab) { - found = { tab, worktreeId: wId } - break - } - } - } - if (!found) { - found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) - } - if (!found) { - return - } - - const { tab, worktreeId } = found - const sourceGroupId = tab.groupId - - // Map user-facing direction to layout tree direction and position - const splitDirection = direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical' - // Why: 'left'/'up' = new group appears as the *first* child (before the original), - // 'right'/'down' = new group appears as the *second* child (after the original). - const newGroupPosition = direction === 'left' || direction === 'up' ? 'first' : 'second' - - const newGroupId = globalThis.crypto.randomUUID() - // Why: editor/diff tabs use filePath as their unified tab ID to match the - // OpenFile entry in EditorSlice. Reusing the source tab's ID lets - // TabGroupPanel.editorFiles match the OpenFile by ID. Terminal tabs always - // get a fresh UUID because each terminal has its own PTY lifecycle. - const newTabId = tab.contentType === 'terminal' ? globalThis.crypto.randomUUID() : tab.id - - const newTab: Tab = { - id: newTabId, - groupId: newGroupId, - worktreeId, - contentType: tab.contentType, - label: tab.contentType === 'terminal' ? 'Terminal' : tab.label, - customLabel: null, - color: null, - sortOrder: 0, - createdAt: Date.now(), - isPreview: false, - isPinned: false - } - - const newGroup: TabGroup = { - id: newGroupId, - worktreeId, - activeTabId: newTabId, - tabOrder: [newTabId] - } - - set((s) => { - // Initialize the layout tree if it doesn't exist yet - const currentLayout: TabGroupLayoutNode = s.layoutByWorktree[worktreeId] ?? { - type: 'leaf', - groupId: sourceGroupId - } - - const splitNode = buildSplitNode(sourceGroupId, newGroupId, splitDirection, newGroupPosition) - const newLayout = replaceLeaf(currentLayout, sourceGroupId, splitNode) - - const existingTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] - const existingGroups = s.groupsByWorktree[worktreeId] ?? [] - - return { - unifiedTabsByWorktree: { - ...s.unifiedTabsByWorktree, - [worktreeId]: [...existingTabs, newTab] - }, - groupsByWorktree: { - ...s.groupsByWorktree, - [worktreeId]: [...existingGroups, newGroup] - }, - layoutByWorktree: { - ...s.layoutByWorktree, - [worktreeId]: newLayout - }, - activeGroupIdByWorktree: { - ...s.activeGroupIdByWorktree, - [worktreeId]: newGroupId - } - } - }) - } -} - -export function createFocusGroup({ set }: Pick) { - return (worktreeId: string, groupId: string): void => { - set((s) => ({ - activeGroupIdByWorktree: { - ...s.activeGroupIdByWorktree, - [worktreeId]: groupId - } - })) - } -} - -export function createCloseGroupIfEmpty({ set, get }: SetGet) { - return (worktreeId: string, groupId: string): void => { - const state = get() - const tabs = state.unifiedTabsByWorktree[worktreeId] ?? [] - const groupHasTabs = tabs.some((t) => t.groupId === groupId) - if (groupHasTabs) { - return - } - - const layout = state.layoutByWorktree[worktreeId] - if (!layout) { - // No layout tree means single group — just clean up the group record - set((s) => ({ - groupsByWorktree: { - ...s.groupsByWorktree, - [worktreeId]: (s.groupsByWorktree[worktreeId] ?? []).filter((g) => g.id !== groupId) - } - })) - return - } - - // Find a sibling group to receive focus before removing - const siblingId = findSiblingGroupId(layout, groupId) - const collapsed = removeLeaf(layout, groupId) - - set((s) => { - const nextGroups = (s.groupsByWorktree[worktreeId] ?? []).filter((g) => g.id !== groupId) - const nextLayout = { ...s.layoutByWorktree } - - // Why: keep a leaf layout even when collapsing to a single group so that - // TabGroupSplitLayout stays mounted — deleting the layout would unmount - // TabGroupPanel, destroying xterm instances and killing PTY processes. - if (!collapsed) { - delete nextLayout[worktreeId] - } else { - nextLayout[worktreeId] = collapsed - } - - const nextActiveGroup = { ...s.activeGroupIdByWorktree } - if (s.activeGroupIdByWorktree[worktreeId] === groupId && siblingId) { - nextActiveGroup[worktreeId] = siblingId - } - - return { - groupsByWorktree: { ...s.groupsByWorktree, [worktreeId]: nextGroups }, - layoutByWorktree: nextLayout, - activeGroupIdByWorktree: nextActiveGroup - } - }) - } -} diff --git a/src/renderer/src/store/slices/tabs.test.ts b/src/renderer/src/store/slices/tabs.test.ts index 8285cfb1d4c..956a1c4d8dd 100644 --- a/src/renderer/src/store/slices/tabs.test.ts +++ b/src/renderer/src/store/slices/tabs.test.ts @@ -182,8 +182,7 @@ describe('TabsSlice', () => { expect(result?.wasLastTab).toBe(true) expect(store.getState().unifiedTabsByWorktree[WT]).toHaveLength(0) - // Why: closeGroupIfEmpty removes the empty group in single-group mode - expect(store.getState().groupsByWorktree[WT]).toHaveLength(0) + expect(store.getState().groupsByWorktree[WT][0].activeTabId).toBeNull() }) it('does not change active tab when closing a non-active tab', () => { diff --git a/src/renderer/src/store/slices/tabs.ts b/src/renderer/src/store/slices/tabs.ts index 01bb7854884..3c52f7a98b1 100644 --- a/src/renderer/src/store/slices/tabs.ts +++ b/src/renderer/src/store/slices/tabs.ts @@ -1,12 +1,6 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' -import type { - Tab, - TabGroup, - TabContentType, - TabGroupLayoutNode, - WorkspaceSessionState -} from '../../../../shared/types' +import type { Tab, TabGroup, TabContentType, WorkspaceSessionState } from '../../../../shared/types' import { findTabAndWorktree, findGroupForTab, @@ -16,21 +10,12 @@ import { patchTab } from './tabs-helpers' import { buildHydratedTabState } from './tabs-hydration' -import { - createSplitTabToGroup, - createFocusGroup, - createCloseGroupIfEmpty -} from './tabs-split-actions' -import { createCloseOtherTabs, createCloseTabsToRight } from './tabs-bulk-actions' - -export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' export type TabsSlice = { // ─── State ────────────────────────────────────────────────────────── unifiedTabsByWorktree: Record groupsByWorktree: Record activeGroupIdByWorktree: Record - layoutByWorktree: Record // ─── Actions ──────────────────────────────────────────────────────── createUnifiedTab: ( @@ -39,8 +24,7 @@ export type TabsSlice = { init?: Partial> ) => Tab closeUnifiedTab: ( - tabId: string, - groupId?: string + tabId: string ) => { closedTabId: string; wasLastTab: boolean; worktreeId: string } | null activateTab: (tabId: string) => void reorderUnifiedTabs: (groupId: string, tabIds: string[]) => void @@ -54,30 +38,23 @@ export type TabsSlice = { getActiveTab: (worktreeId: string) => Tab | null getTab: (tabId: string) => Tab | null hydrateTabsSession: (session: WorkspaceSessionState) => void - splitTabToGroup: (tabId: string, direction: TabSplitDirection) => void - focusGroup: (worktreeId: string, groupId: string) => void - closeGroupIfEmpty: (worktreeId: string, groupId: string) => void } export const createTabsSlice: StateCreator = (set, get) => ({ unifiedTabsByWorktree: {}, groupsByWorktree: {}, activeGroupIdByWorktree: {}, - layoutByWorktree: {}, createUnifiedTab: (worktreeId, contentType, init) => { const id = init?.id ?? globalThis.crypto.randomUUID() let tab!: Tab set((s) => { - // Why: pass the active (focused) group so new tabs land in the group - // the user is interacting with, not an arbitrary first group. - const targetGroupId = s.activeGroupIdByWorktree[worktreeId] const { group, groupsByWorktree: nextGroups, activeGroupIdByWorktree: nextActiveGroups - } = ensureGroup(s.groupsByWorktree, s.activeGroupIdByWorktree, worktreeId, targetGroupId) + } = ensureGroup(s.groupsByWorktree, s.activeGroupIdByWorktree, worktreeId) const existing = s.unifiedTabsByWorktree[worktreeId] ?? [] @@ -87,11 +64,7 @@ export const createTabsSlice: StateCreator = (set, if (init?.isPreview) { const existingPreview = existing.find((t) => t.isPreview && t.groupId === group.id) if (existingPreview) { - // Why: filter by both id AND groupId so editor tabs with the same - // filePath ID in other groups are not accidentally removed. - filtered = existing.filter( - (t) => !(t.id === existingPreview.id && t.groupId === existingPreview.groupId) - ) + filtered = existing.filter((t) => t.id !== existingPreview.id) removedPreviewId = existingPreview.id } } @@ -117,30 +90,22 @@ export const createTabsSlice: StateCreator = (set, const updatedGroupObj: TabGroup = { ...group, activeTabId: tab.id, tabOrder: newTabOrder } - // Why: always ensure a layout exists so TabGroupSplitLayout can render - // for every worktree, even before a split. This avoids the single-group - // → split-group rendering transition that would unmount TerminalPanes. - const nextLayout = s.layoutByWorktree[worktreeId] - ? s.layoutByWorktree - : { ...s.layoutByWorktree, [worktreeId]: { type: 'leaf' as const, groupId: group.id } } - return { unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [worktreeId]: [...filtered, tab] }, groupsByWorktree: { ...nextGroups, [worktreeId]: updateGroup(nextGroups[worktreeId] ?? [], updatedGroupObj) }, - activeGroupIdByWorktree: nextActiveGroups, - layoutByWorktree: nextLayout + activeGroupIdByWorktree: nextActiveGroups } }) return tab }, - closeUnifiedTab: (tabId, groupId?) => { + closeUnifiedTab: (tabId) => { const state = get() - const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId, groupId) + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) if (!found) { return null } @@ -161,9 +126,7 @@ export const createTabsSlice: StateCreator = (set, set((s) => { const tabs = s.unifiedTabsByWorktree[worktreeId] ?? [] - // Why: editor tabs can share the same ID (filePath) across groups. - // Filter by both id and groupId to only remove the tab from this group. - const nextTabs = tabs.filter((t) => !(t.id === tabId && t.groupId === tab.groupId)) + const nextTabs = tabs.filter((t) => t.id !== tabId) const updatedGroupObj: TabGroup = { ...group, activeTabId: newActiveTabId, @@ -179,33 +142,12 @@ export const createTabsSlice: StateCreator = (set, } }) - // Why: when the last tab in a group is closed, collapse the layout tree - // so the empty group disappears and its sibling fills the space. - if (wasLastTab) { - get().closeGroupIfEmpty(worktreeId, tab.groupId) - } - return { closedTabId: tabId, wasLastTab, worktreeId } }, activateTab: (tabId) => { set((s) => { - // Why: editor/diff tabs share the same ID (filePath) across groups. - // Prefer the focused group so we activate the correct group's tab. - let found: { tab: Tab; worktreeId: string } | null = null - for (const [wId, tabs] of Object.entries(s.unifiedTabsByWorktree)) { - const focusedGroupId = s.activeGroupIdByWorktree[wId] - if (focusedGroupId) { - const tab = tabs.find((t) => t.id === tabId && t.groupId === focusedGroupId) - if (tab) { - found = { tab, worktreeId: wId } - break - } - } - } - if (!found) { - found = findTabAndWorktree(s.unifiedTabsByWorktree, tabId) - } + const found = findTabAndWorktree(s.unifiedTabsByWorktree, tabId) if (!found) { return {} } @@ -276,8 +218,104 @@ export const createTabsSlice: StateCreator = (set, set((s) => patchTab(s.unifiedTabsByWorktree, tabId, { isPinned: false }) ?? {}) }, - closeOtherTabs: createCloseOtherTabs({ set, get }), - closeTabsToRight: createCloseTabsToRight({ set, get }), + closeOtherTabs: (tabId) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return [] + } + + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return [] + } + + const tabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const closedIds = tabs + .filter((t) => t.id !== tabId && !t.isPinned && t.groupId === group.id) + .map((t) => t.id) + + if (closedIds.length === 0) { + return [] + } + + const closedSet = new Set(closedIds) + + set((s) => { + const currentTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] + const remainingTabs = currentTabs.filter((t) => !closedSet.has(t.id)) + const remainingOrder = group.tabOrder.filter((tid) => !closedSet.has(tid)) + const updatedGroupObj: TabGroup = { ...group, activeTabId: tabId, tabOrder: remainingOrder } + + return { + unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [worktreeId]: remainingTabs }, + groupsByWorktree: { + ...s.groupsByWorktree, + [worktreeId]: updateGroup(s.groupsByWorktree[worktreeId] ?? [], updatedGroupObj) + } + } + }) + + return closedIds + }, + + closeTabsToRight: (tabId) => { + const state = get() + const found = findTabAndWorktree(state.unifiedTabsByWorktree, tabId) + if (!found) { + return [] + } + + const { tab, worktreeId } = found + const group = findGroupForTab(state.groupsByWorktree, worktreeId, tab.groupId) + if (!group) { + return [] + } + + const idx = group.tabOrder.indexOf(tabId) + if (idx === -1) { + return [] + } + + const idsToRight = group.tabOrder.slice(idx + 1) + const tabs = state.unifiedTabsByWorktree[worktreeId] ?? [] + const tabMap = new Map(tabs.map((t) => [t.id, t])) + + const closedIds = idsToRight.filter((tid) => { + const t = tabMap.get(tid) + return t && !t.isPinned + }) + + if (closedIds.length === 0) { + return [] + } + + const closedSet = new Set(closedIds) + + set((s) => { + const currentTabs = s.unifiedTabsByWorktree[worktreeId] ?? [] + const remainingTabs = currentTabs.filter((t) => !closedSet.has(t.id)) + const remainingOrder = group.tabOrder.filter((tid) => !closedSet.has(tid)) + + const newActiveTabId = closedSet.has(group.activeTabId ?? '') ? tabId : group.activeTabId + const updatedGroupObj: TabGroup = { + ...group, + activeTabId: newActiveTabId, + tabOrder: remainingOrder + } + + return { + unifiedTabsByWorktree: { ...s.unifiedTabsByWorktree, [worktreeId]: remainingTabs }, + groupsByWorktree: { + ...s.groupsByWorktree, + [worktreeId]: updateGroup(s.groupsByWorktree[worktreeId] ?? [], updatedGroupObj) + } + } + }) + + return closedIds + }, getActiveTab: (worktreeId) => { const state = get() @@ -310,9 +348,5 @@ export const createTabsSlice: StateCreator = (set, .map((w) => w.id) ) set(buildHydratedTabState(session, validWorktreeIds)) - }, - - splitTabToGroup: createSplitTabToGroup({ set, get }), - focusGroup: createFocusGroup({ set }), - closeGroupIfEmpty: createCloseGroupIfEmpty({ set, get }) + } }) diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 65f7c703e39..0ebf644ef46 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -30,7 +30,7 @@ export type TerminalSlice = { workspaceSessionReady: boolean pendingReconnectWorktreeIds: string[] pendingReconnectTabByWorktree: Record - createTab: (worktreeId: string, tabId?: string) => TerminalTab + createTab: (worktreeId: string) => TerminalTab closeTab: (tabId: string) => void reorderTabs: (worktreeId: string, tabIds: string[]) => void setTabBarOrder: (worktreeId: string, order: string[]) => void @@ -133,8 +133,8 @@ export const createTerminalSlice: StateCreator } }, - createTab: (worktreeId, tabId) => { - const id = tabId ?? globalThis.crypto.randomUUID() + createTab: (worktreeId) => { + const id = globalThis.crypto.randomUUID() let tab!: TerminalTab set((s) => { const existing = s.tabsByWorktree[worktreeId] ?? [] diff --git a/src/shared/types.ts b/src/shared/types.ts index 7213a08bf40..cac4f4ee626 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -55,20 +55,6 @@ export type WorktreeMeta = { lastActivityAt: number } -// ─── Tab Group Layout ───────────────────────────────────────────── -export type TabGroupSplitDirection = 'horizontal' | 'vertical' - -/** Recursive binary tree describing how tab groups are arranged in the workspace. - * Mirrors the TerminalPaneLayoutNode pattern used for intra-tab pane splits. */ -export type TabGroupLayoutNode = - | { type: 'leaf'; groupId: string } - | { - type: 'split' - direction: TabGroupSplitDirection - first: TabGroupLayoutNode - second: TabGroupLayoutNode - } - // ─── Unified Tab ──────────────────────────────────────────────────── export type TabContentType = 'terminal' | 'editor' | 'diff' | 'conflict-review' @@ -170,9 +156,6 @@ export type WorkspaceSessionState = { unifiedTabs?: Record /** Tab group model — present alongside unifiedTabs. */ tabGroups?: Record - /** Layout tree for tab group splits, keyed by worktree ID. - * Absent when all worktrees use the default single-group layout. */ - tabGroupLayouts?: Record } // ─── GitHub ──────────────────────────────────────────────────────────