Revert "fix: prevent terminal reset when splitting tab groups" (#403)

This commit is contained in:
Neil
2026-04-08 20:45:23 -07:00
committed by GitHub
parent 02a092b52f
commit b60341ff0c
24 changed files with 219 additions and 1336 deletions
+4 -23
View File
@@ -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 {
</div>
{/* 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. */}
<div
id="titlebar-tabs"
className={`flex flex-1 min-w-0 self-stretch${activeView === 'settings' || !activeWorktreeId ? ' hidden' : ''}`}
+67 -131
View File
@@ -1,6 +1,6 @@
/* eslint-disable max-lines */
import { useEffect, useCallback, useRef, useState } from 'react'
import { useEffect, useCallback, useRef, useState, lazy, Suspense } from 'react'
import { createPortal } from 'react-dom'
import { TOGGLE_TERMINAL_PANE_EXPAND_EVENT } from '@/constants/terminal'
import { useAppStore } from '../store'
@@ -15,7 +15,6 @@ import {
import { Button } from '@/components/ui/button'
import TabBar from './tab-bar/TabBar'
import TerminalPane from './terminal-pane/TerminalPane'
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
import {
ORCA_EDITOR_SAVE_AND_CLOSE_EVENT,
requestEditorSaveQuiesce
@@ -23,9 +22,11 @@ import {
import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload'
import EditorAutosaveController from './editor/EditorAutosaveController'
const EditorPanel = lazy(() => 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 {
>
<EditorAutosaveController />
{/* 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(
<TabBar
@@ -540,48 +466,58 @@ export default function Terminal(): React.JSX.Element | null {
onCloseAllFiles={closeAllFiles}
onPinFile={pinFile}
tabBarOrder={tabBarOrder}
onSplitTab={handleSplitTab}
/>,
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 && (
<TabGroupSplitLayout
layout={layout}
worktreeId={activeWorktreeId}
focusedGroupId={focusedGroupId}
hasSplitGroups={hasSplitGroups}
onSplitTab={handleSplitTab}
/>
)}
{/* 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. */}
<div className="hidden">
{/* Terminal panes container - hidden when editor tab active */}
<div
className={`relative flex-1 min-h-0 overflow-hidden ${activeTabType === 'editor' && worktreeFiles.length > 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) => (
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
tabId={tab.id}
worktreeId={worktree.id}
cwd={worktree.path}
isActive={false}
onPtyExit={(ptyId) => handlePtyExit(tab.id, ptyId)}
onCloseTab={() => handleCloseTab(tab.id)}
/>
))
const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
return (
<div
key={worktree.id}
className={isVisible ? 'absolute inset-0' : 'absolute inset-0 hidden'}
aria-hidden={!isVisible}
>
{worktreeTabs.map((tab) => (
<TerminalPane
key={`${tab.id}-${tab.generation ?? 0}`}
tabId={tab.id}
worktreeId={worktree.id}
cwd={worktree.path}
isActive={isVisible && tab.id === activeTabId && activeTabType === 'terminal'}
onPtyExit={(ptyId) => handlePtyExit(tab.id, ptyId)}
onCloseTab={() => handleCloseTab(tab.id)}
/>
))}
</div>
)
})}
</div>
{/* 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 && (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
Loading editor...
</div>
}
>
<EditorPanel />
</Suspense>
)}
{/* Save confirmation dialog */}
<Dialog
open={saveDialogFileId !== null}
@@ -31,26 +31,9 @@ type FileContent = {
type DiffContent = GitDiffResult
// Why: when the layout tree changes (e.g., a split creates a new branch), React
// unmounts and remounts TabGroupPanel instances in the restructured subtree. The
// remounted EditorPanel loses its local fileContents/diffContents state and would
// show a blank flash while the IPC re-load completes. This module-level cache
// lets remounted instances recover content instantly without another IPC round-trip.
const fileContentCache = new Map<string, FileContent>()
const diffContentCache = new Map<string, DiffContent>()
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<Record<string, FileContent>>(() =>
Object.fromEntries(fileContentCache)
)
const [diffContents, setDiffContentsRaw] = useState<Record<string, DiffContent>>(() =>
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<Record<string, FileContent>>({})
const [diffContents, setDiffContents] = useState<Record<string, DiffContent>>({})
const [copiedPathToast, setCopiedPathToast] = useState<{ fileId: string; token: number } | null>(
null
)
@@ -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 ? (
@@ -231,7 +231,6 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
}
}
store.getState().closeFile(fileId)
store.getState().closeUnifiedTab(fileId)
}
const handleSaveFile = async (event: Event): Promise<void> => {
@@ -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({
</DropdownMenuItem>
<DropdownMenuItem onSelect={onCloseAll}>Close All Editor Tabs</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onSplitTab?.('left')}>
<PanelLeft className="w-3.5 h-3.5 mr-1.5" />
Split Left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitTab?.('right')}>
<PanelRight className="w-3.5 h-3.5 mr-1.5" />
Split Right
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitTab?.('up')}>
<PanelTop className="w-3.5 h-3.5 mr-1.5" />
Split Up
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitTab?.('down')}>
<PanelBottom className="w-3.5 h-3.5 mr-1.5" />
Split Down
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
void window.api.ui.writeClipboardText(file.filePath)
@@ -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
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onSplitTab?.(tab.id, 'left')}>
<PanelLeft className="w-3.5 h-3.5 mr-1.5" />
Split Left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitTab?.(tab.id, 'right')}>
<PanelRight className="w-3.5 h-3.5 mr-1.5" />
Split Right
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitTab?.(tab.id, 'up')}>
<PanelTop className="w-3.5 h-3.5 mr-1.5" />
Split Up
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onSplitTab?.(tab.id, 'down')}>
<PanelBottom className="w-3.5 h-3.5 mr-1.5" />
Split Down
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={handleRenameOpen}>Change Title</DropdownMenuItem>
<div className="px-2 pt-1.5 pb-1">
<div className="text-xs font-medium text-muted-foreground mb-1.5">Tab Color</div>
@@ -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
}
/>
)
})}
@@ -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 (
<div
className={`flex flex-col flex-1 min-w-0 min-h-0 overflow-hidden${
hasSplitGroups ? ` border ${isFocused ? 'border-accent' : 'border-border'}` : ''
}`}
onPointerDown={handleFocusClick}
>
{/* Why: in single-group mode, the titlebar portal renders the tab bar,
so the inline version is hidden to avoid duplication. */}
<div
className={`items-stretch h-9 shrink-0 border-b border-border bg-card${hasSplitGroups ? ' flex' : ' hidden'}`}
>
<TabBar
tabs={terminalTabs}
activeTabId={isActiveTerminal ? activeTabId : null}
worktreeId={worktreeId}
expandedPaneByTabId={expandedPaneByTabId}
onActivate={handleActivate}
onClose={handleClose}
onCloseOthers={handleCloseOthers}
onCloseToRight={handleCloseToRight}
onReorder={() => {}}
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}
/>
</div>
{/* Content area */}
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Terminal panes for this group's terminal tabs */}
{groupTabs
.filter((t) => t.contentType === 'terminal')
.map((tab) => (
<TerminalPane
key={tab.id}
tabId={tab.id}
worktreeId={worktreeId}
cwd={worktree?.path}
isVisible={tab.id === activeTabId && isActiveTerminal}
isActive={isFocused && tab.id === activeTabId && isActiveTerminal}
onPtyExit={(ptyId) => handlePtyExit(tab.id, ptyId)}
onCloseTab={() => handleClose(tab.id)}
/>
))}
{/* Editor panel for the active editor tab */}
{isActiveEditor && (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
Loading editor...
</div>
}
>
<EditorPanel activeFileId={activeTabId} />
</Suspense>
)}
</div>
</div>
)
}
@@ -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 (
<div
className="flex-1 min-w-0 min-h-0 overflow-hidden"
style={{
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridTemplateRows: `repeat(${rows}, 1fr)`
}}
>
{placements.map(({ groupId, gridColumn, gridRow }) => (
<div
key={groupId}
className="flex min-w-0 min-h-0 overflow-hidden"
style={{ gridColumn, gridRow }}
>
<TabGroupPanel
groupId={groupId}
worktreeId={worktreeId}
isFocused={groupId === focusedGroupId}
hasSplitGroups={hasSplitGroups}
onSplitTab={onSplitTab}
/>
</div>
))}
</div>
)
}
@@ -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<Map<number, PtyTransport>>(new Map())
const pendingWritesRef = useRef<Map<number, string>>(new Map())
const effectiveVisible = isVisible ?? isActive
const isActiveRef = useRef(isActive)
isActiveRef.current = isActive
const effectiveVisibleRef = useRef(effectiveVisible)
effectiveVisibleRef.current = effectiveVisible
const [expandedPaneId, setExpandedPaneId] = useState<number | null>(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 && (
<TerminalErrorToast error={terminalError} onDismiss={() => setTerminalError(null)} />
)}
{activePane?.container &&
@@ -17,7 +17,6 @@ type PtyConnectionDeps = {
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
pendingWritesRef: React.RefObject<Map<number, string>>
isActiveRef: React.RefObject<boolean>
effectiveVisibleRef: React.RefObject<boolean>
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
@@ -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<PaneManager | null>
containerRef: React.RefObject<HTMLDivElement | null>
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
@@ -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 }) => {
@@ -38,7 +38,6 @@ type UseTerminalPaneLifecycleDeps = {
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
pendingWritesRef: React.RefObject<Map<number, string>>
isActiveRef: React.RefObject<boolean>
effectiveVisibleRef: React.RefObject<boolean>
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,
+9 -57
View File
@@ -254,7 +254,7 @@ export type EditorSlice = {
hydrateEditorSession: (session: WorkspaceSessionState) => void
}
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (set, get) => ({
export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (set) => ({
editorDrafts: {},
setEditorDraft: (fileId, content) =>
set((s) => ({
@@ -342,7 +342,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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) => {
@@ -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)
}
@@ -1,115 +0,0 @@
import type { Tab, TabGroup } from '../../../../shared/types'
import { findTabAndWorktree, findGroupForTab, updateGroup } from './tabs-helpers'
type TabsBulkState = {
unifiedTabsByWorktree: Record<string, Tab[]>
groupsByWorktree: Record<string, TabGroup[]>
}
type SetGet = {
set: (fn: (s: TabsBulkState) => Partial<TabsBulkState>) => 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
}
}
+3 -18
View File
@@ -2,14 +2,10 @@ import type { Tab, TabGroup } from '../../../../shared/types'
export function findTabAndWorktree(
tabsByWorktree: Record<string, Tab[]>,
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<string, TabGroup[]>,
activeGroupIdByWorktree: Record<string, string>,
worktreeId: string,
targetGroupId?: string
worktreeId: string
): {
group: TabGroup
groupsByWorktree: Record<string, TabGroup[]>
activeGroupIdByWorktree: Record<string, string>
} {
// 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 }
@@ -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<string, Tab[]>
groupsByWorktree: Record<string, TabGroup[]>
activeGroupIdByWorktree: Record<string, string>
layoutByWorktree: Record<string, TabGroupLayoutNode>
}
function hydrateUnifiedFormat(
@@ -52,31 +45,7 @@ function hydrateUnifiedFormat(
activeGroupIdByWorktree[worktreeId] = validatedGroups[0].id
}
// Restore layout trees with validation
const layoutByWorktree: Record<string, TabGroupLayoutNode> = {}
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(
@@ -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<string, Tab[]>
groupsByWorktree: Record<string, TabGroup[]>
activeGroupIdByWorktree: Record<string, string>
layoutByWorktree: Record<string, TabGroupLayoutNode>
}
type SetGet = {
set: (fn: (s: TabsState) => Partial<TabsState>) => 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<SetGet, 'set'>) {
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
}
})
}
}
+1 -2
View File
@@ -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', () => {
+108 -74
View File
@@ -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<string, Tab[]>
groupsByWorktree: Record<string, TabGroup[]>
activeGroupIdByWorktree: Record<string, string>
layoutByWorktree: Record<string, TabGroupLayoutNode>
// ─── Actions ────────────────────────────────────────────────────────
createUnifiedTab: (
@@ -39,8 +24,7 @@ export type TabsSlice = {
init?: Partial<Pick<Tab, 'id' | 'label' | 'customLabel' | 'color' | 'isPreview' | 'isPinned'>>
) => 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<AppState, [], [], TabsSlice> = (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<AppState, [], [], TabsSlice> = (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<AppState, [], [], TabsSlice> = (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<AppState, [], [], TabsSlice> = (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<AppState, [], [], TabsSlice> = (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<AppState, [], [], TabsSlice> = (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<AppState, [], [], TabsSlice> = (set,
.map((w) => w.id)
)
set(buildHydratedTabState(session, validWorktreeIds))
},
splitTabToGroup: createSplitTabToGroup({ set, get }),
focusGroup: createFocusGroup({ set }),
closeGroupIfEmpty: createCloseGroupIfEmpty({ set, get })
}
})
+3 -3
View File
@@ -30,7 +30,7 @@ export type TerminalSlice = {
workspaceSessionReady: boolean
pendingReconnectWorktreeIds: string[]
pendingReconnectTabByWorktree: Record<string, string[]>
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<AppState, [], [], TerminalSlice>
}
},
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] ?? []
-17
View File
@@ -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<string, Tab[]>
/** Tab group model — present alongside unifiedTabs. */
tabGroups?: Record<string, TabGroup[]>
/** Layout tree for tab group splits, keyed by worktree ID.
* Absent when all worktrees use the default single-group layout. */
tabGroupLayouts?: Record<string, TabGroupLayoutNode>
}
// ─── GitHub ──────────────────────────────────────────────────────────