mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
Revert "fix: prevent terminal reset when splitting tab groups" (#403)
This commit is contained in:
@@ -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' : ''}`}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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] ?? []
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user