From 66456acf8d0a77236f97ea3b13697183fb090bf2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:39:45 -0700 Subject: [PATCH] refactor: extract tab group workspace model and host (#664) --- .../src/components/CodexRestartChip.tsx | 85 ++++++ src/renderer/src/components/Terminal.tsx | 165 +--------- .../src/components/codex-restart-chip.test.ts | 51 ++++ .../src/components/editor/MonacoEditor.tsx | 2 +- .../components/tab-group/TabGroupPanel.tsx | 230 ++++---------- .../tab-group/TabGroupWorkspaceHost.tsx | 134 +++++++++ ...roller.ts => useTabGroupWorkspaceModel.ts} | 281 ++++++++++++------ 7 files changed, 536 insertions(+), 412 deletions(-) create mode 100644 src/renderer/src/components/CodexRestartChip.tsx create mode 100644 src/renderer/src/components/codex-restart-chip.test.ts create mode 100644 src/renderer/src/components/tab-group/TabGroupWorkspaceHost.tsx rename src/renderer/src/components/tab-group/{useTabGroupController.ts => useTabGroupWorkspaceModel.ts} (54%) diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx new file mode 100644 index 00000000000..42c6c6a23cf --- /dev/null +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -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 + ptyIdsByTabId: Record + codexRestartNoticeByPtyId: Record + 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 ( +
+
+ + Codex is using the previous account + +
+ + +
+
+
+ ) +} diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index 2c261f14923..297bedbc296 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -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 ( -
-
- - Codex is using the previous account - -
- - -
-
-
- ) - }, - [ - 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( - {/* 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. */} - - {activeWorktree?.displayName ?? 'Workspace'} - - / - {activeSurfaceLabel} - , - titlebarTabsTarget - )} - - {effectiveActiveLayout ? ( -
- {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 ( -
- {renderStaleCodexRestartChip(worktree.id)} - -
- ) - })} -
- ) : null} - - {!effectiveActiveLayout && ( + {activeWorktreeId && ENABLE_SPLIT_GROUPS && 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)} + {worktreeTabs.map((tab) => ( { + 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) + }) +}) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index 142650830a2..5b36ee9075b 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -218,7 +218,7 @@ export default function MonacoEditor({ } } }, - [queueReveal, setupCopy, filePath, setEditorCursorLine] + [queueReveal, setupCopy, filePath, setEditorCursorLine, viewStateKey] ) const handleChange = useCallback( diff --git a/src/renderer/src/components/tab-group/TabGroupPanel.tsx b/src/renderer/src/components/tab-group/TabGroupPanel.tsx index 08450c465f8..2024905438e 100644 --- a/src/renderer/src/components/tab-group/TabGroupPanel.tsx +++ b/src/renderer/src/components/tab-group/TabGroupPanel.tsx @@ -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( - () => - 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 = ( { - 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({
- {groupTabs + {model.groupTabs .filter((item) => item.contentType === 'terminal') .map((item) => ( { - 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)} /> ))} diff --git a/src/renderer/src/components/tab-group/TabGroupWorkspaceHost.tsx b/src/renderer/src/components/tab-group/TabGroupWorkspaceHost.tsx new file mode 100644 index 00000000000..41062421c68 --- /dev/null +++ b/src/renderer/src/components/tab-group/TabGroupWorkspaceHost.tsx @@ -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( +
+ {/* 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. */} + + {activeWorktree?.displayName ?? 'Workspace'} + + / + {activeSurfaceLabel} +
, + titlebarTabsTarget + )} + +
+ {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 ( +
+ + +
+ ) + })} +
+ + ) +} diff --git a/src/renderer/src/components/tab-group/useTabGroupController.ts b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts similarity index 54% rename from src/renderer/src/components/tab-group/useTabGroupController.ts rename to src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts index 20f41f72e6c..32ac3692df5 100644 --- a/src/renderer/src/components/tab-group/useTabGroupController.ts +++ b/src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts @@ -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( + () => + 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( + () => + 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 + } } }