mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
refactor: extract tab group workspace model and host (#664)
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useAppStore } from '../store'
|
||||
|
||||
export function collectStaleWorktreePtyIds({
|
||||
tabsByWorktree,
|
||||
ptyIdsByTabId,
|
||||
codexRestartNoticeByPtyId,
|
||||
worktreeId
|
||||
}: {
|
||||
tabsByWorktree: Record<string, { id: string }[]>
|
||||
ptyIdsByTabId: Record<string, string[]>
|
||||
codexRestartNoticeByPtyId: Record<string, unknown>
|
||||
worktreeId: string
|
||||
}): string[] {
|
||||
return (tabsByWorktree[worktreeId] ?? []).flatMap((tab) =>
|
||||
(ptyIdsByTabId[tab.id] ?? []).filter((ptyId) => Boolean(codexRestartNoticeByPtyId[ptyId]))
|
||||
)
|
||||
}
|
||||
|
||||
export function dismissStaleWorktreePtyIds(
|
||||
staleWorktreePtyIds: string[],
|
||||
clearCodexRestartNotice: (ptyId: string) => void
|
||||
): void {
|
||||
// Why: restart notices are stored per PTY, but the workspace host presents
|
||||
// one shared prompt. Clearing all matching PTY notices keeps every pane in
|
||||
// that worktree consistent with the dismissal.
|
||||
for (const ptyId of staleWorktreePtyIds) {
|
||||
clearCodexRestartNotice(ptyId)
|
||||
}
|
||||
}
|
||||
|
||||
export default function CodexRestartChip({
|
||||
worktreeId
|
||||
}: {
|
||||
worktreeId: string
|
||||
}): React.JSX.Element | null {
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
|
||||
const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId)
|
||||
const queueCodexPaneRestarts = useAppStore((s) => s.queueCodexPaneRestarts)
|
||||
const clearCodexRestartNotice = useAppStore((s) => s.clearCodexRestartNotice)
|
||||
|
||||
const staleWorktreePtyIds = useMemo(
|
||||
() =>
|
||||
collectStaleWorktreePtyIds({
|
||||
tabsByWorktree,
|
||||
ptyIdsByTabId,
|
||||
codexRestartNoticeByPtyId,
|
||||
worktreeId
|
||||
}),
|
||||
[codexRestartNoticeByPtyId, ptyIdsByTabId, tabsByWorktree, worktreeId]
|
||||
)
|
||||
|
||||
if (staleWorktreePtyIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute right-3 top-3 z-20">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-lg border border-border/80 bg-popover/95 px-2 py-1.5 shadow-lg backdrop-blur-sm">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Codex is using the previous account
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queueCodexPaneRestarts(staleWorktreePtyIds)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-foreground px-2 py-1 text-[11px] font-medium text-background transition-colors hover:opacity-90"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissStaleWorktreePtyIds(staleWorktreePtyIds, clearCodexRestartNotice)}
|
||||
className="rounded-md px-1.5 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import TabBar from './tab-bar/TabBar'
|
||||
import TerminalPane from './terminal-pane/TerminalPane'
|
||||
import {
|
||||
@@ -27,8 +26,9 @@ import { isUpdaterQuitAndInstallInProgress } from '@/lib/updater-beforeunload'
|
||||
import EditorAutosaveController from './editor/EditorAutosaveController'
|
||||
import BrowserPane, { destroyPersistentWebview } from './browser-pane/BrowserPane'
|
||||
import { reconcileTabOrder } from './tab-bar/reconcile-order'
|
||||
import TabGroupSplitLayout from './tab-group/TabGroupSplitLayout'
|
||||
import TabGroupWorkspaceHost from './tab-group/TabGroupWorkspaceHost'
|
||||
import { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
|
||||
import CodexRestartChip from './CodexRestartChip'
|
||||
|
||||
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
|
||||
// Why: the split-group ownership path lands before the rollout switch so we
|
||||
@@ -49,10 +49,6 @@ function Terminal(): React.JSX.Element | null {
|
||||
const setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle)
|
||||
const setTabColor = useAppStore((s) => s.setTabColor)
|
||||
const consumeSuppressedPtyExit = useAppStore((s) => s.consumeSuppressedPtyExit)
|
||||
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
|
||||
const codexRestartNoticeByPtyId = useAppStore((s) => s.codexRestartNoticeByPtyId)
|
||||
const queueCodexPaneRestarts = useAppStore((s) => s.queueCodexPaneRestarts)
|
||||
const clearCodexRestartNotice = useAppStore((s) => s.clearCodexRestartNotice)
|
||||
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
|
||||
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
|
||||
const openFiles = useAppStore((s) => s.openFiles)
|
||||
@@ -69,10 +65,6 @@ function Terminal(): React.JSX.Element | null {
|
||||
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
|
||||
const closeBrowserTab = useAppStore((s) => s.closeBrowserTab)
|
||||
const setActiveBrowserTab = useAppStore((s) => s.setActiveBrowserTab)
|
||||
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
|
||||
const layoutByWorktree = useAppStore((s) => s.layoutByWorktree)
|
||||
const activeGroupIdByWorktree = useAppStore((s) => s.activeGroupIdByWorktree)
|
||||
const ensureWorktreeRootGroup = useAppStore((s) => s.ensureWorktreeRootGroup)
|
||||
const reconcileWorktreeTabModel = useAppStore((s) => s.reconcileWorktreeTabModel)
|
||||
|
||||
const markFileDirty = useAppStore((s) => s.markFileDirty)
|
||||
@@ -91,16 +83,6 @@ function Terminal(): React.JSX.Element | null {
|
||||
setTitlebarTabsTarget(document.getElementById('titlebar-tabs'))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
// Why: worktree restore now depends on the tab-group model even before the
|
||||
// split-group UI is exposed. Ensure every active worktree has a root group
|
||||
// so terminal-first fallback logic can attach new terminals to a real owner.
|
||||
ensureWorktreeRootGroup(activeWorktreeId)
|
||||
}, [activeWorktreeId, ensureWorktreeRootGroup])
|
||||
|
||||
// Filter editor files to only show those belonging to the active worktree
|
||||
const worktreeFiles = activeWorktreeId
|
||||
? openFiles.filter((f) => f.worktreeId === activeWorktreeId)
|
||||
@@ -108,90 +90,6 @@ function Terminal(): React.JSX.Element | null {
|
||||
const worktreeBrowserTabs = activeWorktreeId
|
||||
? (browserTabsByWorktree[activeWorktreeId] ?? [])
|
||||
: []
|
||||
const getEffectiveLayoutForWorktree = useCallback(
|
||||
(worktreeId: string) => {
|
||||
const layout = layoutByWorktree[worktreeId]
|
||||
if (layout) {
|
||||
return layout
|
||||
}
|
||||
const groups = groupsByWorktree[worktreeId] ?? []
|
||||
const fallbackGroupId = activeGroupIdByWorktree[worktreeId] ?? groups[0]?.id ?? null
|
||||
if (!fallbackGroupId) {
|
||||
return undefined
|
||||
}
|
||||
return { type: 'leaf', groupId: fallbackGroupId } as const
|
||||
},
|
||||
[activeGroupIdByWorktree, groupsByWorktree, layoutByWorktree]
|
||||
)
|
||||
const effectiveActiveLayout = activeWorktreeId
|
||||
? ENABLE_SPLIT_GROUPS
|
||||
? getEffectiveLayoutForWorktree(activeWorktreeId)
|
||||
: undefined
|
||||
: undefined
|
||||
const activeWorktree = activeWorktreeId
|
||||
? (allWorktrees.find((worktree) => worktree.id === activeWorktreeId) ?? null)
|
||||
: null
|
||||
const activeTerminalTab = tabs.find((tab) => tab.id === activeTabId) ?? null
|
||||
const activeEditorFile = worktreeFiles.find((file) => file.id === activeFileId) ?? null
|
||||
const activeBrowserTab = worktreeBrowserTabs.find((tab) => tab.id === activeBrowserTabId) ?? null
|
||||
const activeSurfaceLabel =
|
||||
activeTabType === 'browser'
|
||||
? (activeBrowserTab?.title ?? activeBrowserTab?.url ?? 'Browser')
|
||||
: activeTabType === 'editor'
|
||||
? (activeEditorFile?.relativePath ?? activeEditorFile?.filePath ?? 'Editor')
|
||||
: (activeTerminalTab?.customTitle ?? activeTerminalTab?.title ?? 'Terminal')
|
||||
const renderStaleCodexRestartChip = useCallback(
|
||||
(worktreeId: string) => {
|
||||
const worktreeTabs = tabsByWorktree[worktreeId] ?? []
|
||||
const staleWorktreePtyIds = worktreeTabs.flatMap((tab) =>
|
||||
(ptyIdsByTabId[tab.id] ?? []).filter((ptyId) => Boolean(codexRestartNoticeByPtyId[ptyId]))
|
||||
)
|
||||
if (staleWorktreePtyIds.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Why: split-group and legacy workspace rendering both represent the
|
||||
// same worktree-level Codex session state. Keeping one shared chip here
|
||||
// preserves the single-prompt UX across rollout paths instead of letting
|
||||
// one branch silently lose the restart/dismiss affordance.
|
||||
return (
|
||||
<div className="pointer-events-none absolute right-3 top-3 z-20">
|
||||
<div className="pointer-events-auto flex items-center gap-2 rounded-lg border border-border/80 bg-popover/95 px-2 py-1.5 shadow-lg backdrop-blur-sm">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
Codex is using the previous account
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => queueCodexPaneRestarts(staleWorktreePtyIds)}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-foreground px-2 py-1 text-[11px] font-medium text-background transition-colors hover:opacity-90"
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
for (const ptyId of staleWorktreePtyIds) {
|
||||
clearCodexRestartNotice(ptyId)
|
||||
}
|
||||
}}
|
||||
className="rounded-md px-1.5 py-1 text-[11px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[
|
||||
clearCodexRestartNotice,
|
||||
codexRestartNoticeByPtyId,
|
||||
ptyIdsByTabId,
|
||||
queueCodexPaneRestarts,
|
||||
tabsByWorktree
|
||||
]
|
||||
)
|
||||
const activeWorktreeBrowserTabIdsKey = activeWorktreeId
|
||||
? (browserTabsByWorktree[activeWorktreeId] ?? []).map((tab) => tab.id).join(',')
|
||||
: ''
|
||||
@@ -861,7 +759,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
inline like VS Code. The old titlebar portal stays only as a fallback
|
||||
before the root-group layout has been established. */}
|
||||
{activeWorktreeId &&
|
||||
!effectiveActiveLayout &&
|
||||
!ENABLE_SPLIT_GROUPS &&
|
||||
titlebarTabsTarget &&
|
||||
createPortal(
|
||||
<TabBar
|
||||
@@ -899,53 +797,14 @@ function Terminal(): React.JSX.Element | null {
|
||||
titlebarTabsTarget
|
||||
)}
|
||||
|
||||
{activeWorktreeId &&
|
||||
effectiveActiveLayout &&
|
||||
titlebarTabsTarget &&
|
||||
createPortal(
|
||||
<div className="flex h-full min-w-0 items-center px-3 text-xs text-muted-foreground">
|
||||
{/* Why: split layouts can show several independent tab rows, so the
|
||||
titlebar cannot host the real tabs without collapsing multiple
|
||||
groups into one shared surface. A lightweight summary still uses
|
||||
that otherwise empty strip and keeps the window chrome balanced. */}
|
||||
<span className="truncate font-medium text-foreground/80">
|
||||
{activeWorktree?.displayName ?? 'Workspace'}
|
||||
</span>
|
||||
<span className="px-2 text-border">/</span>
|
||||
<span className="truncate">{activeSurfaceLabel}</span>
|
||||
</div>,
|
||||
titlebarTabsTarget
|
||||
)}
|
||||
|
||||
{effectiveActiveLayout ? (
|
||||
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{allWorktrees
|
||||
.filter((wt) => mountedWorktreeIdsRef.current.has(wt.id))
|
||||
.map((worktree) => {
|
||||
const layout = getEffectiveLayoutForWorktree(worktree.id)
|
||||
if (!layout) {
|
||||
return null
|
||||
}
|
||||
const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
|
||||
return (
|
||||
<div
|
||||
key={`tab-groups-${worktree.id}`}
|
||||
className={isVisible ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
{renderStaleCodexRestartChip(worktree.id)}
|
||||
<TabGroupSplitLayout
|
||||
layout={layout}
|
||||
worktreeId={worktree.id}
|
||||
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!effectiveActiveLayout && (
|
||||
{activeWorktreeId && ENABLE_SPLIT_GROUPS && titlebarTabsTarget ? (
|
||||
<TabGroupWorkspaceHost
|
||||
activeView={activeView}
|
||||
activeWorktreeId={activeWorktreeId}
|
||||
mountedWorktreeIds={[...mountedWorktreeIdsRef.current]}
|
||||
titlebarTabsTarget={titlebarTabsTarget}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Why: split-group layouts render their own terminal/browser/editor
|
||||
surfaces inside TabGroupPanel. Keeping the legacy workspace-level
|
||||
@@ -979,7 +838,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
className={isVisible ? 'absolute inset-0' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
{renderStaleCodexRestartChip(worktree.id)}
|
||||
<CodexRestartChip worktreeId={worktree.id} />
|
||||
{worktreeTabs.map((tab) => (
|
||||
<TerminalPane
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { collectStaleWorktreePtyIds, dismissStaleWorktreePtyIds } from './CodexRestartChip'
|
||||
|
||||
describe('CodexRestartChip helpers', () => {
|
||||
it('collects all stale PTY ids for tabs in a worktree', () => {
|
||||
expect(
|
||||
collectStaleWorktreePtyIds({
|
||||
tabsByWorktree: {
|
||||
wt1: [{ id: 'tab-1' }, { id: 'tab-2' }],
|
||||
wt2: [{ id: 'tab-3' }]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1', 'pty-2'],
|
||||
'tab-2': ['pty-3'],
|
||||
'tab-3': ['pty-4']
|
||||
},
|
||||
codexRestartNoticeByPtyId: {
|
||||
'pty-1': { previousAccountLabel: 'a', nextAccountLabel: 'b' },
|
||||
'pty-3': { previousAccountLabel: 'a', nextAccountLabel: 'b' },
|
||||
'pty-4': { previousAccountLabel: 'a', nextAccountLabel: 'b' }
|
||||
},
|
||||
worktreeId: 'wt1'
|
||||
})
|
||||
).toEqual(['pty-1', 'pty-3'])
|
||||
})
|
||||
|
||||
it('returns an empty list when a worktree has no stale PTYs', () => {
|
||||
expect(
|
||||
collectStaleWorktreePtyIds({
|
||||
tabsByWorktree: {
|
||||
wt1: [{ id: 'tab-1' }]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'tab-1': ['pty-1']
|
||||
},
|
||||
codexRestartNoticeByPtyId: {},
|
||||
worktreeId: 'wt1'
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('dismisses every stale PTY notice in the worktree prompt', () => {
|
||||
const clearCodexRestartNotice = vi.fn()
|
||||
|
||||
dismissStaleWorktreePtyIds(['pty-1', 'pty-3'], clearCodexRestartNotice)
|
||||
|
||||
expect(clearCodexRestartNotice).toHaveBeenNthCalledWith(1, 'pty-1')
|
||||
expect(clearCodexRestartNotice).toHaveBeenNthCalledWith(2, 'pty-3')
|
||||
expect(clearCodexRestartNotice).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -218,7 +218,7 @@ export default function MonacoEditor({
|
||||
}
|
||||
}
|
||||
},
|
||||
[queueReveal, setupCopy, filePath, setEditorCursorLine]
|
||||
[queueReveal, setupCopy, filePath, setEditorCursorLine, viewStateKey]
|
||||
)
|
||||
|
||||
const handleChange = useCallback(
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
/* eslint-disable max-lines -- Why: group panels intentionally co-locate group-scoped tab chrome, activation/close handlers, and surface rendering so split groups cannot drift into a separate behavior path from the original root group. */
|
||||
import { lazy, Suspense, useCallback, useMemo } from 'react'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import TabBar from '../tab-bar/TabBar'
|
||||
import TerminalPane from '../terminal-pane/TerminalPane'
|
||||
import BrowserPane from '../browser-pane/BrowserPane'
|
||||
import { useTabGroupController } from './useTabGroupController'
|
||||
import { useTabGroupWorkspaceModel } from './useTabGroupWorkspaceModel'
|
||||
|
||||
const EditorPanel = lazy(() => import('../editor/EditorPanel'))
|
||||
|
||||
type GroupEditorItem = OpenFile & { tabId: string }
|
||||
const EMPTY_GROUPS: readonly never[] = []
|
||||
const EMPTY_TABS: readonly never[] = []
|
||||
const EMPTY_RUNTIME_TERMINALS: readonly never[] = []
|
||||
const EMPTY_BROWSER_TABS: readonly never[] = []
|
||||
|
||||
export default function TabGroupPanel({
|
||||
groupId,
|
||||
worktreeId,
|
||||
@@ -29,169 +18,55 @@ export default function TabGroupPanel({
|
||||
isFocused: boolean
|
||||
hasSplitGroups: boolean
|
||||
}): React.JSX.Element {
|
||||
const worktreeGroups = useAppStore(
|
||||
useShallow((state) => state.groupsByWorktree[worktreeId] ?? EMPTY_GROUPS)
|
||||
)
|
||||
const worktreeUnifiedTabs = useAppStore(
|
||||
useShallow((state) => state.unifiedTabsByWorktree[worktreeId] ?? EMPTY_TABS)
|
||||
)
|
||||
const openFiles = useAppStore((state) => state.openFiles)
|
||||
const worktree = useAppStore(
|
||||
useShallow(
|
||||
(state) =>
|
||||
Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((candidate) => candidate.id === worktreeId) ?? null
|
||||
)
|
||||
)
|
||||
const focusGroup = useAppStore((state) => state.focusGroup)
|
||||
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
|
||||
const setTabColor = useAppStore((state) => state.setTabColor)
|
||||
const consumeSuppressedPtyExit = useAppStore((state) => state.consumeSuppressedPtyExit)
|
||||
const expandedPaneByTabId = useAppStore((state) => state.expandedPaneByTabId)
|
||||
const browserTabsByWorktree = useAppStore((state) => state.browserTabsByWorktree)
|
||||
const runtimeTerminalTabs = useAppStore(
|
||||
(state) => state.tabsByWorktree[worktreeId] ?? EMPTY_RUNTIME_TERMINALS
|
||||
)
|
||||
|
||||
const group = useMemo(
|
||||
() => worktreeGroups.find((item) => item.id === groupId) ?? null,
|
||||
[groupId, worktreeGroups]
|
||||
)
|
||||
const groupTabs = useMemo(
|
||||
() => worktreeUnifiedTabs.filter((item) => item.groupId === groupId),
|
||||
[groupId, worktreeUnifiedTabs]
|
||||
)
|
||||
|
||||
const activeItemId = group?.activeTabId ?? null
|
||||
const activeTab = groupTabs.find((item) => item.id === activeItemId) ?? null
|
||||
|
||||
const terminalTabs = useMemo(
|
||||
() =>
|
||||
groupTabs
|
||||
.filter((item) => item.contentType === 'terminal')
|
||||
.map((item) => ({
|
||||
id: item.entityId,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: item.label,
|
||||
customTitle: item.customLabel,
|
||||
color: item.color,
|
||||
sortOrder: item.sortOrder,
|
||||
createdAt: item.createdAt
|
||||
})),
|
||||
[groupTabs, worktreeId]
|
||||
)
|
||||
|
||||
const editorItems = useMemo<GroupEditorItem[]>(
|
||||
() =>
|
||||
groupTabs
|
||||
.filter(
|
||||
(item) =>
|
||||
item.contentType === 'editor' ||
|
||||
item.contentType === 'diff' ||
|
||||
item.contentType === 'conflict-review'
|
||||
)
|
||||
.map((item) => {
|
||||
const file = openFiles.find((candidate) => candidate.id === item.entityId)
|
||||
return file ? { ...file, tabId: item.id } : null
|
||||
})
|
||||
.filter((item): item is GroupEditorItem => item !== null),
|
||||
[groupTabs, openFiles]
|
||||
)
|
||||
|
||||
const worktreeBrowserTabs = useMemo(
|
||||
() => browserTabsByWorktree[worktreeId] ?? EMPTY_BROWSER_TABS,
|
||||
[browserTabsByWorktree, worktreeId]
|
||||
)
|
||||
|
||||
const browserItems = useMemo(
|
||||
() =>
|
||||
groupTabs
|
||||
.filter((item) => item.contentType === 'browser')
|
||||
.map((item) => {
|
||||
const bt = worktreeBrowserTabs.find((candidate) => candidate.id === item.entityId)
|
||||
return bt ?? null
|
||||
})
|
||||
.filter((item): item is BrowserTabState => item !== null),
|
||||
[groupTabs, worktreeBrowserTabs]
|
||||
)
|
||||
|
||||
const activeBrowserTab = useMemo(
|
||||
() =>
|
||||
activeTab?.contentType === 'browser'
|
||||
? (worktreeBrowserTabs.find((bt) => bt.id === activeTab.entityId) ?? null)
|
||||
: null,
|
||||
[activeTab, worktreeBrowserTabs]
|
||||
)
|
||||
|
||||
const runtimeTerminalTabById = useMemo(
|
||||
() => new Map(runtimeTerminalTabs.map((tab) => [tab.id, tab])),
|
||||
[runtimeTerminalTabs]
|
||||
)
|
||||
|
||||
const controller = useTabGroupController({
|
||||
groupId,
|
||||
worktreeId,
|
||||
group,
|
||||
groupTabs,
|
||||
const model = useTabGroupWorkspaceModel({ groupId, worktreeId })
|
||||
const {
|
||||
activeBrowserTab,
|
||||
activeTab,
|
||||
worktreeBrowserTabs
|
||||
})
|
||||
|
||||
const handleTerminalClose = useCallback(
|
||||
(terminalId: string) => {
|
||||
const item = groupTabs.find(
|
||||
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
|
||||
)
|
||||
if (item) {
|
||||
controller.closeItem(item.id)
|
||||
}
|
||||
},
|
||||
[controller, groupTabs]
|
||||
)
|
||||
|
||||
const handleBrowserClose = useCallback(
|
||||
(browserTabId: string) => {
|
||||
const item = groupTabs.find(
|
||||
(candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser'
|
||||
)
|
||||
if (item) {
|
||||
controller.closeItem(item.id)
|
||||
}
|
||||
},
|
||||
[controller, groupTabs]
|
||||
)
|
||||
browserItems,
|
||||
commands,
|
||||
editorItems,
|
||||
runtimeTerminalTabById,
|
||||
tabBarOrder,
|
||||
terminalTabs,
|
||||
worktreePath
|
||||
} = model
|
||||
|
||||
const tabBar = (
|
||||
<TabBar
|
||||
tabs={terminalTabs}
|
||||
activeTabId={activeTab?.contentType === 'terminal' ? activeTab.entityId : null}
|
||||
worktreeId={worktreeId}
|
||||
expandedPaneByTabId={expandedPaneByTabId}
|
||||
onActivate={controller.activateTerminal}
|
||||
onClose={handleTerminalClose}
|
||||
onCloseOthers={(terminalId) => {
|
||||
const item = groupTabs.find(
|
||||
expandedPaneByTabId={model.expandedPaneByTabId}
|
||||
onActivate={commands.activateTerminal}
|
||||
onClose={(terminalId) => {
|
||||
const item = model.groupTabs.find(
|
||||
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
|
||||
)
|
||||
if (item) {
|
||||
controller.closeOthers(item.id)
|
||||
commands.closeItem(item.id)
|
||||
}
|
||||
}}
|
||||
onCloseOthers={(terminalId) => {
|
||||
const item = model.groupTabs.find(
|
||||
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
|
||||
)
|
||||
if (item) {
|
||||
commands.closeOthers(item.id)
|
||||
}
|
||||
}}
|
||||
onCloseToRight={(terminalId) => {
|
||||
const item = groupTabs.find(
|
||||
const item = model.groupTabs.find(
|
||||
(candidate) => candidate.entityId === terminalId && candidate.contentType === 'terminal'
|
||||
)
|
||||
if (item) {
|
||||
controller.closeToRight(item.id)
|
||||
commands.closeToRight(item.id)
|
||||
}
|
||||
}}
|
||||
onReorder={(_, order) => controller.reorderTabBar(order)}
|
||||
onNewTerminalTab={controller.newTerminalTab}
|
||||
onNewBrowserTab={controller.newBrowserTab}
|
||||
onSetCustomTitle={setTabCustomTitle}
|
||||
onSetTabColor={setTabColor}
|
||||
onReorder={(_, order) => commands.reorderTabBar(order)}
|
||||
onNewTerminalTab={commands.newTerminalTab}
|
||||
onNewBrowserTab={commands.newBrowserTab}
|
||||
onSetCustomTitle={commands.setTabCustomTitle}
|
||||
onSetTabColor={commands.setTabColor}
|
||||
onTogglePaneExpand={() => {}}
|
||||
editorFiles={editorItems}
|
||||
browserTabs={browserItems}
|
||||
@@ -208,23 +83,30 @@ export default function TabGroupPanel({
|
||||
? 'browser'
|
||||
: 'editor'
|
||||
}
|
||||
onActivateFile={controller.activateEditor}
|
||||
onCloseFile={controller.closeItem}
|
||||
onActivateBrowserTab={controller.activateBrowser}
|
||||
onCloseBrowserTab={handleBrowserClose}
|
||||
onCloseAllFiles={controller.closeAllEditorTabsInGroup}
|
||||
onActivateFile={commands.activateEditor}
|
||||
onCloseFile={commands.closeItem}
|
||||
onActivateBrowserTab={commands.activateBrowser}
|
||||
onCloseBrowserTab={(browserTabId) => {
|
||||
const item = model.groupTabs.find(
|
||||
(candidate) => candidate.entityId === browserTabId && candidate.contentType === 'browser'
|
||||
)
|
||||
if (item) {
|
||||
commands.closeItem(item.id)
|
||||
}
|
||||
}}
|
||||
onCloseAllFiles={commands.closeAllEditorTabsInGroup}
|
||||
onPinFile={(_fileId, tabId) => {
|
||||
if (!tabId) {
|
||||
return
|
||||
}
|
||||
const item = groupTabs.find((candidate) => candidate.id === tabId)
|
||||
const item = model.groupTabs.find((candidate) => candidate.id === tabId)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
controller.pinFile(item.entityId, item.id)
|
||||
commands.pinFile(item.entityId, item.id)
|
||||
}}
|
||||
tabBarOrder={controller.tabBarOrder}
|
||||
onCreateSplitGroup={controller.createSplitGroup}
|
||||
tabBarOrder={tabBarOrder}
|
||||
onCreateSplitGroup={commands.createSplitGroup}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -235,7 +117,7 @@ export default function TabGroupPanel({
|
||||
? ` group/tab-group border ${isFocused ? 'border-accent' : 'border-border'}`
|
||||
: ''
|
||||
}`}
|
||||
onPointerDown={() => focusGroup(worktreeId, groupId)}
|
||||
onPointerDown={commands.focusGroup}
|
||||
>
|
||||
{/* Why: every split group must keep its own real tab row because the app
|
||||
can show multiple groups at once, while the window titlebar only has
|
||||
@@ -251,7 +133,7 @@ export default function TabGroupPanel({
|
||||
title="Close Group"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
controller.closeGroup()
|
||||
commands.closeGroup()
|
||||
}}
|
||||
className="mx-1 my-auto flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
@@ -262,14 +144,14 @@ export default function TabGroupPanel({
|
||||
</div>
|
||||
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
{groupTabs
|
||||
{model.groupTabs
|
||||
.filter((item) => item.contentType === 'terminal')
|
||||
.map((item) => (
|
||||
<TerminalPane
|
||||
key={`${item.entityId}-${runtimeTerminalTabById.get(item.entityId)?.generation ?? 0}`}
|
||||
tabId={item.entityId}
|
||||
worktreeId={worktreeId}
|
||||
cwd={worktree?.path}
|
||||
cwd={worktreePath}
|
||||
isActive={
|
||||
isFocused && activeTab?.id === item.id && activeTab.contentType === 'terminal'
|
||||
}
|
||||
@@ -279,12 +161,12 @@ export default function TabGroupPanel({
|
||||
// input. isVisible controls rendering; isActive controls focus.
|
||||
isVisible={activeTab?.id === item.id && activeTab.contentType === 'terminal'}
|
||||
onPtyExit={(ptyId) => {
|
||||
if (consumeSuppressedPtyExit(ptyId)) {
|
||||
if (commands.consumeSuppressedPtyExit(ptyId)) {
|
||||
return
|
||||
}
|
||||
controller.closeItem(item.id)
|
||||
commands.closeItem(item.id)
|
||||
}}
|
||||
onCloseTab={() => controller.closeItem(item.id)}
|
||||
onCloseTab={() => commands.closeItem(item.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '../../store'
|
||||
import CodexRestartChip from '../CodexRestartChip'
|
||||
import TabGroupSplitLayout from './TabGroupSplitLayout'
|
||||
|
||||
export default function TabGroupWorkspaceHost({
|
||||
activeView,
|
||||
activeWorktreeId,
|
||||
mountedWorktreeIds,
|
||||
titlebarTabsTarget
|
||||
}: {
|
||||
activeView: string
|
||||
activeWorktreeId: string
|
||||
mountedWorktreeIds: string[]
|
||||
titlebarTabsTarget: HTMLElement
|
||||
}): React.JSX.Element | null {
|
||||
const {
|
||||
activeBrowserTabId,
|
||||
activeFileId,
|
||||
activeGroupIdByWorktree,
|
||||
activeTabId,
|
||||
activeTabType,
|
||||
browserTabsByWorktree,
|
||||
groupsByWorktree,
|
||||
layoutByWorktree,
|
||||
openFiles,
|
||||
worktreesByRepo
|
||||
} = useAppStore(
|
||||
useShallow((state) => ({
|
||||
activeBrowserTabId: state.activeBrowserTabId,
|
||||
activeFileId: state.activeFileId,
|
||||
activeGroupIdByWorktree: state.activeGroupIdByWorktree,
|
||||
activeTabId: state.activeTabId,
|
||||
activeTabType: state.activeTabType,
|
||||
browserTabsByWorktree: state.browserTabsByWorktree,
|
||||
groupsByWorktree: state.groupsByWorktree,
|
||||
layoutByWorktree: state.layoutByWorktree,
|
||||
openFiles: state.openFiles,
|
||||
worktreesByRepo: state.worktreesByRepo
|
||||
}))
|
||||
)
|
||||
const ensureWorktreeRootGroup = useAppStore((state) => state.ensureWorktreeRootGroup)
|
||||
const tabsByWorktree = useAppStore((state) => state.tabsByWorktree)
|
||||
|
||||
useEffect(() => {
|
||||
// Why: the split host depends on the group model being present even when the
|
||||
// worktree has only legacy terminal tabs. Keep the bootstrap here so the
|
||||
// terminal host only decides which surface path to mount.
|
||||
ensureWorktreeRootGroup(activeWorktreeId)
|
||||
}, [activeWorktreeId, ensureWorktreeRootGroup])
|
||||
|
||||
const allWorktrees = useMemo(() => Object.values(worktreesByRepo).flat(), [worktreesByRepo])
|
||||
const worktreeFiles = openFiles.filter((f) => f.worktreeId === activeWorktreeId)
|
||||
const worktreeBrowserTabs = browserTabsByWorktree[activeWorktreeId] ?? []
|
||||
const activeWorktree = allWorktrees.find((worktree) => worktree.id === activeWorktreeId) ?? null
|
||||
const activeTerminalTab = (tabsByWorktree[activeWorktreeId] ?? []).find(
|
||||
(tab) => tab.id === activeTabId
|
||||
)
|
||||
const activeEditorFile = worktreeFiles.find((file) => file.id === activeFileId) ?? null
|
||||
const activeBrowserTab = worktreeBrowserTabs.find((tab) => tab.id === activeBrowserTabId) ?? null
|
||||
const activeSurfaceLabel =
|
||||
activeTabType === 'browser'
|
||||
? (activeBrowserTab?.title ?? activeBrowserTab?.url ?? 'Browser')
|
||||
: activeTabType === 'editor'
|
||||
? (activeEditorFile?.relativePath ?? activeEditorFile?.filePath ?? 'Editor')
|
||||
: (activeTerminalTab?.customTitle ?? activeTerminalTab?.title ?? 'Terminal')
|
||||
|
||||
const getEffectiveLayoutForWorktree = useCallback(
|
||||
(worktreeId: string) => {
|
||||
const layout = layoutByWorktree[worktreeId]
|
||||
if (layout) {
|
||||
return layout
|
||||
}
|
||||
const groups = groupsByWorktree[worktreeId] ?? []
|
||||
const fallbackGroupId = activeGroupIdByWorktree[worktreeId] ?? groups[0]?.id ?? null
|
||||
if (!fallbackGroupId) {
|
||||
return undefined
|
||||
}
|
||||
return { type: 'leaf', groupId: fallbackGroupId } as const
|
||||
},
|
||||
[activeGroupIdByWorktree, groupsByWorktree, layoutByWorktree]
|
||||
)
|
||||
|
||||
const effectiveActiveLayout = getEffectiveLayoutForWorktree(activeWorktreeId)
|
||||
if (!effectiveActiveLayout) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{createPortal(
|
||||
<div className="flex h-full min-w-0 items-center px-3 text-xs text-muted-foreground">
|
||||
{/* Why: split layouts render a real tab row per group, so the titlebar
|
||||
should only show lightweight workspace context instead of trying to
|
||||
own tab selection for multiple groups at once. */}
|
||||
<span className="truncate font-medium text-foreground/80">
|
||||
{activeWorktree?.displayName ?? 'Workspace'}
|
||||
</span>
|
||||
<span className="px-2 text-border">/</span>
|
||||
<span className="truncate">{activeSurfaceLabel}</span>
|
||||
</div>,
|
||||
titlebarTabsTarget
|
||||
)}
|
||||
|
||||
<div className="relative flex flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{allWorktrees
|
||||
.filter((worktree) => mountedWorktreeIds.includes(worktree.id))
|
||||
.map((worktree) => {
|
||||
const layout = getEffectiveLayoutForWorktree(worktree.id)
|
||||
if (!layout) {
|
||||
return null
|
||||
}
|
||||
const isVisible = activeView !== 'settings' && worktree.id === activeWorktreeId
|
||||
return (
|
||||
<div
|
||||
key={`tab-groups-${worktree.id}`}
|
||||
className={isVisible ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
|
||||
aria-hidden={!isVisible}
|
||||
>
|
||||
<CodexRestartChip worktreeId={worktree.id} />
|
||||
<TabGroupSplitLayout
|
||||
layout={layout}
|
||||
worktreeId={worktree.id}
|
||||
focusedGroupId={activeGroupIdByWorktree[worktree.id]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+197
-84
@@ -1,23 +1,48 @@
|
||||
/* eslint-disable max-lines -- Why: the split-group workspace model intentionally keeps
|
||||
group-scoped activation, close, split, and tab-order rules together so the extracted
|
||||
controller cannot drift from the TabGroupPanel surface it coordinates. */
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { BrowserTab as BrowserTabState, Tab } from '../../../../shared/types'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import type { BrowserTab as BrowserTabState } from '../../../../shared/types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { destroyPersistentWebview } from '../browser-pane/BrowserPane'
|
||||
|
||||
export function useTabGroupController({
|
||||
export type GroupEditorItem = OpenFile & { tabId: string }
|
||||
|
||||
type TerminalTabItem = {
|
||||
id: string
|
||||
ptyId: null
|
||||
worktreeId: string
|
||||
title: string
|
||||
customTitle: string | null
|
||||
color: string | null
|
||||
sortOrder: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export function useTabGroupWorkspaceModel({
|
||||
groupId,
|
||||
worktreeId,
|
||||
group,
|
||||
groupTabs,
|
||||
activeTab,
|
||||
worktreeBrowserTabs
|
||||
worktreeId
|
||||
}: {
|
||||
groupId: string
|
||||
worktreeId: string
|
||||
group: { id: string; tabOrder: string[] } | null
|
||||
groupTabs: Tab[]
|
||||
activeTab: Tab | null
|
||||
worktreeBrowserTabs: BrowserTabState[]
|
||||
}) {
|
||||
const worktreeState = useAppStore(
|
||||
useShallow((state) => ({
|
||||
groups: state.groupsByWorktree[worktreeId] ?? [],
|
||||
unifiedTabs: state.unifiedTabsByWorktree[worktreeId] ?? [],
|
||||
openFiles: state.openFiles,
|
||||
browserTabs: state.browserTabsByWorktree[worktreeId] ?? [],
|
||||
runtimeTerminalTabs: state.tabsByWorktree[worktreeId] ?? [],
|
||||
expandedPaneByTabId: state.expandedPaneByTabId,
|
||||
worktree:
|
||||
Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((candidate) => candidate.id === worktreeId) ?? null
|
||||
}))
|
||||
)
|
||||
|
||||
const focusGroup = useAppStore((state) => state.focusGroup)
|
||||
const activateTab = useAppStore((state) => state.activateTab)
|
||||
const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab)
|
||||
@@ -37,6 +62,79 @@ export function useTabGroupController({
|
||||
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
|
||||
const setActiveBrowserTab = useAppStore((state) => state.setActiveBrowserTab)
|
||||
const copyUnifiedTabToGroup = useAppStore((state) => state.copyUnifiedTabToGroup)
|
||||
const setTabCustomTitle = useAppStore((state) => state.setTabCustomTitle)
|
||||
const setTabColor = useAppStore((state) => state.setTabColor)
|
||||
const consumeSuppressedPtyExit = useAppStore((state) => state.consumeSuppressedPtyExit)
|
||||
|
||||
const group = useMemo(
|
||||
() => worktreeState.groups.find((item) => item.id === groupId) ?? null,
|
||||
[groupId, worktreeState.groups]
|
||||
)
|
||||
const groupTabs = useMemo(
|
||||
() => worktreeState.unifiedTabs.filter((item) => item.groupId === groupId),
|
||||
[groupId, worktreeState.unifiedTabs]
|
||||
)
|
||||
const activeItemId = group?.activeTabId ?? null
|
||||
const activeTab = groupTabs.find((item) => item.id === activeItemId) ?? null
|
||||
|
||||
const terminalTabs = useMemo<TerminalTabItem[]>(
|
||||
() =>
|
||||
groupTabs
|
||||
.filter((item) => item.contentType === 'terminal')
|
||||
.map((item) => ({
|
||||
id: item.entityId,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: item.label,
|
||||
customTitle: item.customLabel ?? null,
|
||||
color: item.color ?? null,
|
||||
sortOrder: item.sortOrder,
|
||||
createdAt: item.createdAt
|
||||
})),
|
||||
[groupTabs, worktreeId]
|
||||
)
|
||||
|
||||
const editorItems = useMemo<GroupEditorItem[]>(
|
||||
() =>
|
||||
groupTabs
|
||||
.filter(
|
||||
(item) =>
|
||||
item.contentType === 'editor' ||
|
||||
item.contentType === 'diff' ||
|
||||
item.contentType === 'conflict-review'
|
||||
)
|
||||
.map((item) => {
|
||||
const file = worktreeState.openFiles.find((candidate) => candidate.id === item.entityId)
|
||||
return file ? { ...file, tabId: item.id } : null
|
||||
})
|
||||
.filter((item): item is GroupEditorItem => item !== null),
|
||||
[groupTabs, worktreeState.openFiles]
|
||||
)
|
||||
|
||||
const browserItems = useMemo(
|
||||
() =>
|
||||
groupTabs
|
||||
.filter((item) => item.contentType === 'browser')
|
||||
.map((item) => {
|
||||
const bt = worktreeState.browserTabs.find((candidate) => candidate.id === item.entityId)
|
||||
return bt ?? null
|
||||
})
|
||||
.filter((item): item is BrowserTabState => item !== null),
|
||||
[groupTabs, worktreeState.browserTabs]
|
||||
)
|
||||
|
||||
const activeBrowserTab = useMemo(
|
||||
() =>
|
||||
activeTab?.contentType === 'browser'
|
||||
? (worktreeState.browserTabs.find((bt) => bt.id === activeTab.entityId) ?? null)
|
||||
: null,
|
||||
[activeTab, worktreeState.browserTabs]
|
||||
)
|
||||
|
||||
const runtimeTerminalTabById = useMemo(
|
||||
() => new Map(worktreeState.runtimeTerminalTabs.map((tab) => [tab.id, tab])),
|
||||
[worktreeState.runtimeTerminalTabs]
|
||||
)
|
||||
|
||||
const closeEditorIfUnreferenced = useCallback(
|
||||
(entityId: string, closingTabId: string) => {
|
||||
@@ -155,13 +253,9 @@ export function useTabGroupController({
|
||||
return
|
||||
}
|
||||
|
||||
// Why: tab context-menu split actions are scoped to the tab that opened
|
||||
// the menu, not whichever tab was already active in the group. Falling
|
||||
// back to the active tab preserves the "+" menu behavior, which creates
|
||||
// a split from the current surface without a tab-specific source ID.
|
||||
|
||||
// Why: VS Code-style split actions leave the original group untouched and
|
||||
// seed the new group with equivalent visible content when possible.
|
||||
// Why: tab context-menu split actions belong to the visible tab that opened
|
||||
// the menu. Keeping that decision inside the workspace model prevents the
|
||||
// view layer from re-implementing "which tab is the source?" rules.
|
||||
if (sourceTab.contentType === 'terminal') {
|
||||
const terminal = createTab(worktreeId, newGroupId)
|
||||
setActiveTab(terminal.id)
|
||||
@@ -170,7 +264,7 @@ export function useTabGroupController({
|
||||
}
|
||||
|
||||
if (sourceTab.contentType === 'browser') {
|
||||
const browserTab = worktreeBrowserTabs.find(
|
||||
const browserTab = worktreeState.browserTabs.find(
|
||||
(candidate) => candidate.id === sourceTab.entityId
|
||||
)
|
||||
if (!browserTab) {
|
||||
@@ -194,74 +288,49 @@ export function useTabGroupController({
|
||||
setActiveTabType('editor')
|
||||
},
|
||||
[
|
||||
activeTab,
|
||||
copyUnifiedTabToGroup,
|
||||
createBrowserTab,
|
||||
createEmptySplitGroup,
|
||||
createTab,
|
||||
copyUnifiedTabToGroup,
|
||||
focusGroup,
|
||||
groupId,
|
||||
groupTabs,
|
||||
activeTab,
|
||||
setActiveFile,
|
||||
setActiveTab,
|
||||
setActiveTabType,
|
||||
worktreeBrowserTabs,
|
||||
worktreeId
|
||||
worktreeId,
|
||||
worktreeState.browserTabs
|
||||
]
|
||||
)
|
||||
|
||||
const tabBarOrder = useMemo(
|
||||
() =>
|
||||
(group?.tabOrder ?? []).map((itemId) => {
|
||||
const item = groupTabs.find((candidate) => candidate.id === itemId)
|
||||
if (!item) {
|
||||
return itemId
|
||||
}
|
||||
// Why: the tab bar renders terminals and browser workspaces by their
|
||||
// backing runtime IDs, while editor tabs render by their unified tab
|
||||
// IDs. Reorder callbacks must round-trip through the same visible IDs
|
||||
// or dnd-kit cannot map the dragged tab back to the stored group order.
|
||||
return item.contentType === 'terminal' || item.contentType === 'browser'
|
||||
? item.entityId
|
||||
: item.id
|
||||
}),
|
||||
[group, groupTabs]
|
||||
)
|
||||
const closeGroup = useCallback(() => {
|
||||
const items = [...(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [])].filter(
|
||||
(item) => item.groupId === groupId
|
||||
)
|
||||
for (const item of items) {
|
||||
closeItem(item.id)
|
||||
}
|
||||
// Why: empty split groups are layout state, not tab state. The workspace
|
||||
// model owns collapsing those placeholder panes so views do not need to
|
||||
// understand when closing tabs is insufficient to remove a group shell.
|
||||
closeEmptyGroup(worktreeId, groupId)
|
||||
}, [closeEmptyGroup, closeItem, groupId, worktreeId])
|
||||
|
||||
return {
|
||||
activateTerminal,
|
||||
activateEditor,
|
||||
activateBrowser,
|
||||
closeItem,
|
||||
closeGroup: () => {
|
||||
const items = [...(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [])].filter(
|
||||
(item) => item.groupId === groupId
|
||||
)
|
||||
for (const item of items) {
|
||||
const closeAllEditorTabsInGroup = useCallback(() => {
|
||||
for (const item of groupTabs) {
|
||||
if (
|
||||
item.contentType === 'editor' ||
|
||||
item.contentType === 'diff' ||
|
||||
item.contentType === 'conflict-review'
|
||||
) {
|
||||
closeItem(item.id)
|
||||
}
|
||||
// Why: split creation can intentionally leave empty placeholder groups
|
||||
// behind. Closing the group chrome must collapse those panes even when
|
||||
// no tabs remain to trigger `closeUnifiedTab` cleanup.
|
||||
closeEmptyGroup(worktreeId, groupId)
|
||||
},
|
||||
closeOthers: (itemId: string) => closeMany(closeOtherTabs(itemId)),
|
||||
closeToRight: (itemId: string) => closeMany(closeTabsToRight(itemId)),
|
||||
closeAllEditorTabsInGroup: () => {
|
||||
// Why: this action is launched from one split group's editor tab menu.
|
||||
// In split layouts it must only close editor surfaces owned by that
|
||||
// group, not every editor tab in the worktree.
|
||||
for (const item of groupTabs) {
|
||||
if (
|
||||
item.contentType === 'editor' ||
|
||||
item.contentType === 'diff' ||
|
||||
item.contentType === 'conflict-review'
|
||||
) {
|
||||
closeItem(item.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
reorderTabBar: (order: string[]) => {
|
||||
}
|
||||
}, [closeItem, groupTabs])
|
||||
|
||||
const reorderTabBar = useCallback(
|
||||
(order: string[]) => {
|
||||
if (!group) {
|
||||
return
|
||||
}
|
||||
@@ -279,18 +348,62 @@ export function useTabGroupController({
|
||||
const remainingIds = group.tabOrder.filter((itemId) => !orderedIds.has(itemId))
|
||||
reorderUnifiedTabs(groupId, itemOrder.concat(remainingIds))
|
||||
},
|
||||
newTerminalTab: () => {
|
||||
const terminal = createTab(worktreeId, groupId)
|
||||
setActiveTab(terminal.id)
|
||||
setActiveTabType('terminal')
|
||||
},
|
||||
newBrowserTab: () => {
|
||||
const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank'
|
||||
createBrowserTab(worktreeId, defaultUrl, { title: 'New Browser Tab' })
|
||||
},
|
||||
pinFile,
|
||||
copyUnifiedTabToGroup,
|
||||
[group, groupId, groupTabs, reorderUnifiedTabs]
|
||||
)
|
||||
|
||||
const tabBarOrder = useMemo(
|
||||
() =>
|
||||
(group?.tabOrder ?? []).map((itemId) => {
|
||||
const item = groupTabs.find((candidate) => candidate.id === itemId)
|
||||
if (!item) {
|
||||
return itemId
|
||||
}
|
||||
return item.contentType === 'terminal' || item.contentType === 'browser'
|
||||
? item.entityId
|
||||
: item.id
|
||||
}),
|
||||
[group, groupTabs]
|
||||
)
|
||||
|
||||
return {
|
||||
group,
|
||||
activeTab,
|
||||
activeBrowserTab,
|
||||
browserItems,
|
||||
editorItems,
|
||||
terminalTabs,
|
||||
tabBarOrder,
|
||||
createSplitGroup
|
||||
groupTabs,
|
||||
worktreePath: worktreeState.worktree?.path,
|
||||
runtimeTerminalTabById,
|
||||
expandedPaneByTabId: worktreeState.expandedPaneByTabId,
|
||||
commands: {
|
||||
focusGroup: () => {
|
||||
focusGroup(worktreeId, groupId)
|
||||
},
|
||||
activateBrowser,
|
||||
activateEditor,
|
||||
activateTerminal,
|
||||
closeAllEditorTabsInGroup,
|
||||
closeGroup,
|
||||
closeItem,
|
||||
closeOthers: (itemId: string) => closeMany(closeOtherTabs(itemId)),
|
||||
closeToRight: (itemId: string) => closeMany(closeTabsToRight(itemId)),
|
||||
consumeSuppressedPtyExit,
|
||||
createSplitGroup,
|
||||
newBrowserTab: () => {
|
||||
const defaultUrl = useAppStore.getState().browserDefaultUrl ?? 'about:blank'
|
||||
createBrowserTab(worktreeId, defaultUrl, { title: 'New Browser Tab' })
|
||||
},
|
||||
newTerminalTab: () => {
|
||||
const terminal = createTab(worktreeId, groupId)
|
||||
setActiveTab(terminal.id)
|
||||
setActiveTabType('terminal')
|
||||
},
|
||||
pinFile,
|
||||
reorderTabBar,
|
||||
setTabColor,
|
||||
setTabCustomTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user