mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
fix: restore worktrees through tab group ownership (#645)
This commit is contained in:
parent
c4460f8820
commit
cfe49ff404
11 changed files with 491 additions and 43 deletions
23
docs/split-groups-rollout-pr3.md
Normal file
23
docs/split-groups-rollout-pr3.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Split Groups PR 3: Worktree Restore Ownership
|
||||
|
||||
This branch moves worktree activation and restore logic onto the reconciled
|
||||
tab-group model.
|
||||
|
||||
Scope:
|
||||
- reconcile stale unified tabs before restore
|
||||
- restore active surfaces from the group model first
|
||||
- fall back to terminal when a grouped worktree has no renderable surface
|
||||
- create a root group before initial terminal fallback attaches a new tab
|
||||
|
||||
What Is Actually Hooked Up In This PR:
|
||||
- opening an existing worktree restores from the reconciled group/tab model
|
||||
- reopening an empty grouped worktree falls back to a terminal instead of a blank pane
|
||||
- initial terminal creation is now driven by renderable grouped content instead of one-time init guards
|
||||
|
||||
What Is Not Hooked Up Yet:
|
||||
- no split-group layout is rendered
|
||||
- the visible workspace host is still the legacy terminal/browser/editor surface path
|
||||
- tab-group UI components still are not mounted here
|
||||
|
||||
Non-goals:
|
||||
- no split-group UI enablement yet
|
||||
|
|
@ -27,6 +27,7 @@ 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 { shouldAutoCreateInitialTerminal } from './terminal/initial-terminal'
|
||||
|
||||
const EditorPanel = lazy(() => import('./editor/EditorPanel'))
|
||||
|
||||
|
|
@ -49,6 +50,8 @@ function Terminal(): React.JSX.Element | null {
|
|||
const clearCodexRestartNotice = useAppStore((s) => s.clearCodexRestartNotice)
|
||||
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
|
||||
const workspaceSessionReady = useAppStore((s) => s.workspaceSessionReady)
|
||||
const ensureWorktreeRootGroup = useAppStore((s) => s.ensureWorktreeRootGroup)
|
||||
const reconcileWorktreeTabModel = useAppStore((s) => s.reconcileWorktreeTabModel)
|
||||
const openFiles = useAppStore((s) => s.openFiles)
|
||||
const activeFileId = useAppStore((s) => s.activeFileId)
|
||||
const activeBrowserTabId = useAppStore((s) => s.activeBrowserTabId)
|
||||
|
|
@ -80,6 +83,16 @@ 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)
|
||||
|
|
@ -170,12 +183,6 @@ function Terminal(): React.JSX.Element | null {
|
|||
mountedWorktreeIdsRef.current.delete(id)
|
||||
}
|
||||
}
|
||||
// Why: tracks worktrees that have already been initialized (either by
|
||||
// auto-creating a first tab or by having tabs on first activation). Once a
|
||||
// worktree is in this set, closing all its tabs will NOT auto-spawn a
|
||||
// replacement — the user explicitly chose to close them.
|
||||
const initializedWorktreesRef = useRef(new Set<string>())
|
||||
|
||||
// Auto-create first tab when worktree activates
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady) {
|
||||
|
|
@ -185,27 +192,17 @@ function Terminal(): React.JSX.Element | null {
|
|||
return
|
||||
}
|
||||
|
||||
if (tabs.length > 0 || worktreeFiles.length > 0 || worktreeBrowserTabs.length > 0) {
|
||||
initializedWorktreesRef.current.add(activeWorktreeId)
|
||||
const { renderableTabCount } = reconcileWorktreeTabModel(activeWorktreeId)
|
||||
if (!shouldAutoCreateInitialTerminal(renderableTabCount)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: once a worktree has been initialized (had tabs or auto-created one),
|
||||
// don't auto-create again. This prevents a new terminal from spawning
|
||||
// immediately after the user closes the last tab. Also guards against
|
||||
// React StrictMode double-invocation.
|
||||
if (initializedWorktreesRef.current.has(activeWorktreeId)) {
|
||||
return
|
||||
}
|
||||
initializedWorktreesRef.current.add(activeWorktreeId)
|
||||
createTab(activeWorktreeId)
|
||||
}, [
|
||||
workspaceSessionReady,
|
||||
activeWorktreeId,
|
||||
tabs.length,
|
||||
worktreeFiles.length,
|
||||
worktreeBrowserTabs.length,
|
||||
createTab
|
||||
createTab,
|
||||
reconcileWorktreeTabModel
|
||||
])
|
||||
|
||||
const handleNewTab = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { shouldAutoCreateInitialTerminal } from './initial-terminal'
|
||||
|
||||
describe('shouldAutoCreateInitialTerminal', () => {
|
||||
it('creates a terminal when the tab-group model has no renderable tabs', () => {
|
||||
expect(shouldAutoCreateInitialTerminal(0)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not create a terminal when the tab-group model already has content', () => {
|
||||
expect(shouldAutoCreateInitialTerminal(1)).toBe(false)
|
||||
expect(shouldAutoCreateInitialTerminal(2)).toBe(false)
|
||||
})
|
||||
})
|
||||
7
src/renderer/src/components/terminal/initial-terminal.ts
Normal file
7
src/renderer/src/components/terminal/initial-terminal.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function shouldAutoCreateInitialTerminal(renderableTabCount: number): boolean {
|
||||
// Why: the tab-group model is now the source of truth for visible worktree
|
||||
// content. If it has no renderable tabs, the workspace must synthesize a
|
||||
// terminal instead of deferring to legacy editor/browser restore state,
|
||||
// which can otherwise leave an empty split group with nothing mounted.
|
||||
return renderableTabCount === 0
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ function createMockStore(overrides: Record<string, unknown> = {}) {
|
|||
tabsByWorktree: {} as Record<string, { id: string }[]>,
|
||||
createTab: vi.fn(() => ({ id: 'tab-1' })),
|
||||
setActiveTab: vi.fn(),
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 0 })),
|
||||
queueTabSetupSplit: vi.fn(),
|
||||
queueTabIssueCommandSplit: vi.fn(),
|
||||
...overrides
|
||||
|
|
@ -45,9 +46,9 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not create or queue anything when the worktree already has tabs', () => {
|
||||
it('does not create or queue anything when the worktree already has renderable content', () => {
|
||||
const store = createMockStore({
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-existing' }] }
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 }))
|
||||
})
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', {
|
||||
|
|
@ -61,6 +62,18 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
expect(store.queueTabIssueCommandSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not create a terminal just because the legacy terminal slice is empty', () => {
|
||||
const store = createMockStore({
|
||||
tabsByWorktree: { 'wt-1': [] },
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 2 }))
|
||||
})
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1')
|
||||
|
||||
expect(store.createTab).not.toHaveBeenCalled()
|
||||
expect(store.setActiveTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues an issue command split when issueCommand is provided', () => {
|
||||
const store = createMockStore()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { WorktreeSetupLaunch } from '../../../shared/types'
|
||||
import { shouldAutoCreateInitialTerminal } from '@/components/terminal/initial-terminal'
|
||||
import { buildSetupRunnerCommand } from './setup-runner'
|
||||
import { useAppStore } from '@/store'
|
||||
import { findWorktreeById } from '@/store/slices/worktree-helpers'
|
||||
|
|
@ -7,6 +8,7 @@ type WorktreeActivationStore = {
|
|||
tabsByWorktree: Record<string, { id: string }[]>
|
||||
createTab: (worktreeId: string) => { id: string }
|
||||
setActiveTab: (tabId: string) => void
|
||||
reconcileWorktreeTabModel: (worktreeId: string) => { renderableTabCount: number }
|
||||
queueTabSetupSplit: (
|
||||
tabId: string,
|
||||
startup: { command: string; env?: Record<string, string> }
|
||||
|
|
@ -85,8 +87,11 @@ export function ensureWorktreeHasInitialTerminal(
|
|||
setup?: WorktreeSetupLaunch,
|
||||
issueCommand?: WorktreeSetupLaunch
|
||||
): void {
|
||||
const existingTabs = store.tabsByWorktree[worktreeId] ?? []
|
||||
if (existingTabs.length > 0) {
|
||||
const { renderableTabCount } = store.reconcileWorktreeTabModel(worktreeId)
|
||||
// Why: activation can now restore editor- or browser-only worktrees from the
|
||||
// reconciled tab-group model. Creating a terminal just because the legacy
|
||||
// terminal slice is empty would reopen worktrees with an unexpected extra tab.
|
||||
if (!shouldAutoCreateInitialTerminal(renderableTabCount)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ function createEditorStore(): StoreApi<AppState> {
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return createStore<any>()((...args: any[]) => ({
|
||||
activeWorktreeId: 'wt-1',
|
||||
tabsByWorktree: {},
|
||||
browserTabsByWorktree: {},
|
||||
activeBrowserTabId: null,
|
||||
activeBrowserTabIdByWorktree: {},
|
||||
|
|
@ -257,6 +258,25 @@ describe('createEditorSlice editor drafts', () => {
|
|||
expect(store.getState().activeBrowserTabId).toBe('browser-1')
|
||||
})
|
||||
|
||||
it('returns to the landing state when closing the last editor in a worktree with no other surfaces', () => {
|
||||
const store = createEditorStore()
|
||||
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/notes.md',
|
||||
relativePath: 'notes.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
|
||||
store.getState().closeFile('/repo/notes.md')
|
||||
|
||||
expect(store.getState().activeWorktreeId).toBeNull()
|
||||
expect(store.getState().activeFileId).toBeNull()
|
||||
expect(store.getState().activeBrowserTabId).toBeNull()
|
||||
expect(store.getState().activeTabType).toBe('terminal')
|
||||
})
|
||||
|
||||
it('falls back to a browser tab when closing all editors in the active worktree', () => {
|
||||
const store = createEditorStore()
|
||||
|
||||
|
|
@ -293,6 +313,25 @@ describe('createEditorSlice editor drafts', () => {
|
|||
expect(store.getState().activeTabType).toBe('browser')
|
||||
expect(store.getState().activeBrowserTabId).toBe('browser-1')
|
||||
})
|
||||
|
||||
it('returns to the landing state when closing all editors and no other surfaces remain', () => {
|
||||
const store = createEditorStore()
|
||||
|
||||
store.getState().openFile({
|
||||
filePath: '/repo/a.md',
|
||||
relativePath: 'a.md',
|
||||
worktreeId: 'wt-1',
|
||||
language: 'markdown',
|
||||
mode: 'edit'
|
||||
})
|
||||
|
||||
store.getState().closeAllFiles()
|
||||
|
||||
expect(store.getState().activeWorktreeId).toBeNull()
|
||||
expect(store.getState().activeFileId).toBeNull()
|
||||
expect(store.getState().activeBrowserTabId).toBeNull()
|
||||
expect(store.getState().activeTabType).toBe('terminal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createEditorSlice conflict status reconciliation', () => {
|
||||
|
|
|
|||
|
|
@ -590,6 +590,9 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
const browserTabsForWorktree = activeWorktreeId
|
||||
? (s.browserTabsByWorktree[activeWorktreeId] ?? [])
|
||||
: []
|
||||
const terminalTabsForWorktree = activeWorktreeId
|
||||
? (s.tabsByWorktree[activeWorktreeId] ?? [])
|
||||
: []
|
||||
const fallbackBrowserTabId =
|
||||
activeWorktreeId && browserTabsForWorktree.length > 0
|
||||
? (s.activeBrowserTabIdByWorktree[activeWorktreeId] ??
|
||||
|
|
@ -607,6 +610,11 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
newActiveTabTypeByWorktree[activeWorktreeId] =
|
||||
browserTabsForWorktree.length > 0 ? 'browser' : 'terminal'
|
||||
}
|
||||
const shouldDeactivateWorktree =
|
||||
activeWorktreeId !== null &&
|
||||
remainingForWorktree.length === 0 &&
|
||||
browserTabsForWorktree.length === 0 &&
|
||||
terminalTabsForWorktree.length === 0
|
||||
|
||||
// Why: keep tabBarOrderByWorktree in sync so stale editor IDs don't
|
||||
// linger and cause position shifts the next time the order is reconciled.
|
||||
|
|
@ -625,8 +633,14 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
openFiles: newFiles,
|
||||
editorDrafts: newEditorDrafts,
|
||||
activeFileId: newActiveId,
|
||||
activeBrowserTabId:
|
||||
activeWorktreeId && remainingForWorktree.length === 0
|
||||
// Why: if closing the last editor also leaves the worktree without any
|
||||
// browser or terminal surface, keep parity with the terminal/browser
|
||||
// close handlers and return to the Orca landing state instead of
|
||||
// leaving an active worktree selected with nothing renderable.
|
||||
activeWorktreeId: shouldDeactivateWorktree ? null : s.activeWorktreeId,
|
||||
activeBrowserTabId: shouldDeactivateWorktree
|
||||
? null
|
||||
: activeWorktreeId && remainingForWorktree.length === 0
|
||||
? fallbackBrowserTabId
|
||||
: s.activeBrowserTabId,
|
||||
activeTabType: newActiveTabType,
|
||||
|
|
@ -676,8 +690,11 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
delete newActiveFileIdByWorktree[activeWorktreeId]
|
||||
const newActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
|
||||
const browserTabsForWorktree = s.browserTabsByWorktree[activeWorktreeId] ?? []
|
||||
const terminalTabsForWorktree = s.tabsByWorktree[activeWorktreeId] ?? []
|
||||
newActiveTabTypeByWorktree[activeWorktreeId] =
|
||||
browserTabsForWorktree.length > 0 ? 'browser' : 'terminal'
|
||||
const shouldDeactivateWorktree =
|
||||
browserTabsForWorktree.length === 0 && terminalTabsForWorktree.length === 0
|
||||
|
||||
// Why: remove all closed editor file IDs from tab bar order so stale
|
||||
// entries don't cause position shifts on subsequent tab operations.
|
||||
|
|
@ -697,8 +714,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
openFiles: newFiles,
|
||||
editorDrafts: newEditorDrafts,
|
||||
activeFileId: null,
|
||||
activeBrowserTabId:
|
||||
browserTabsForWorktree.length > 0
|
||||
// Why: closing every editor in the active worktree can leave no
|
||||
// renderable surface at all. Clear the active worktree in that case so
|
||||
// the renderer shows the landing page instead of a blank workspace.
|
||||
activeWorktreeId: shouldDeactivateWorktree ? null : s.activeWorktreeId,
|
||||
activeBrowserTabId: shouldDeactivateWorktree
|
||||
? null
|
||||
: browserTabsForWorktree.length > 0
|
||||
? (s.activeBrowserTabIdByWorktree[activeWorktreeId] ??
|
||||
browserTabsForWorktree[0]?.id ??
|
||||
null)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ import {
|
|||
makeLayout,
|
||||
makeOpenFile,
|
||||
makeTab,
|
||||
makeTabGroup,
|
||||
makeUnifiedTab,
|
||||
makeWorktree,
|
||||
seedStore
|
||||
} from './store-test-helpers'
|
||||
|
|
@ -366,6 +368,201 @@ describe('setActiveWorktree', () => {
|
|||
expect(s.activeFileId).toBeNull()
|
||||
})
|
||||
|
||||
it('prefers the unified active tab over stale legacy browser restore state', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const groupId = 'group-1'
|
||||
const terminalId = 'terminal-1'
|
||||
const browserTabId = 'browser-1'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
tabsByWorktree: {
|
||||
[wt]: [makeTab({ id: terminalId, worktreeId: wt })]
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
[wt]: [
|
||||
{
|
||||
id: browserTabId,
|
||||
worktreeId: wt,
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
activeBrowserTabIdByWorktree: { [wt]: browserTabId },
|
||||
activeTabTypeByWorktree: { [wt]: 'browser' },
|
||||
unifiedTabsByWorktree: {
|
||||
[wt]: [
|
||||
makeUnifiedTab({
|
||||
id: 'tab-terminal-1',
|
||||
entityId: terminalId,
|
||||
worktreeId: wt,
|
||||
groupId,
|
||||
contentType: 'terminal'
|
||||
}),
|
||||
makeUnifiedTab({
|
||||
id: 'tab-browser-1',
|
||||
entityId: browserTabId,
|
||||
worktreeId: wt,
|
||||
groupId,
|
||||
contentType: 'browser'
|
||||
})
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[wt]: [
|
||||
makeTabGroup({
|
||||
id: groupId,
|
||||
worktreeId: wt,
|
||||
activeTabId: 'tab-terminal-1',
|
||||
tabOrder: ['tab-terminal-1', 'tab-browser-1']
|
||||
})
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [wt]: groupId }
|
||||
})
|
||||
|
||||
store.getState().setActiveWorktree(wt)
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.activeWorktreeId).toBe(wt)
|
||||
expect(s.activeTabType).toBe('terminal')
|
||||
expect(s.activeTabTypeByWorktree[wt]).toBe('terminal')
|
||||
expect(s.activeTabId).toBe(terminalId)
|
||||
expect(s.activeBrowserTabId).toBe(browserTabId)
|
||||
})
|
||||
|
||||
it('ignores stale unified tabs and falls back to terminal-first activation for empty groups', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
const groupId = 'group-1'
|
||||
const browserTabId = 'browser-1'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
browserTabsByWorktree: {
|
||||
[wt]: [
|
||||
{
|
||||
id: browserTabId,
|
||||
worktreeId: wt,
|
||||
url: 'https://example.com',
|
||||
title: 'Example',
|
||||
loading: false,
|
||||
faviconUrl: null,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
loadError: null,
|
||||
createdAt: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
activeBrowserTabIdByWorktree: { [wt]: browserTabId },
|
||||
activeTabTypeByWorktree: { [wt]: 'browser' },
|
||||
unifiedTabsByWorktree: {
|
||||
[wt]: [
|
||||
makeUnifiedTab({
|
||||
id: 'stale-terminal-tab',
|
||||
entityId: 'missing-terminal',
|
||||
worktreeId: wt,
|
||||
groupId,
|
||||
contentType: 'terminal'
|
||||
})
|
||||
]
|
||||
},
|
||||
groupsByWorktree: {
|
||||
[wt]: [
|
||||
makeTabGroup({
|
||||
id: groupId,
|
||||
worktreeId: wt,
|
||||
activeTabId: 'stale-terminal-tab',
|
||||
tabOrder: ['stale-terminal-tab']
|
||||
})
|
||||
]
|
||||
},
|
||||
activeGroupIdByWorktree: { [wt]: groupId }
|
||||
})
|
||||
|
||||
store.getState().setActiveWorktree(wt)
|
||||
|
||||
const s = store.getState()
|
||||
expect(s.activeWorktreeId).toBe(wt)
|
||||
expect(s.activeTabType).toBe('terminal')
|
||||
expect(s.activeBrowserTabId).toBe(browserTabId)
|
||||
expect(s.activeTabId).toBeNull()
|
||||
expect(s.unifiedTabsByWorktree[wt]).toEqual([])
|
||||
expect(s.groupsByWorktree[wt][0].activeTabId).toBeNull()
|
||||
})
|
||||
|
||||
it('creates a root tab group when the first terminal opens in a worktree', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
},
|
||||
groupsByWorktree: {},
|
||||
activeGroupIdByWorktree: {},
|
||||
unifiedTabsByWorktree: {}
|
||||
})
|
||||
|
||||
const terminal = store.getState().createTab(wt)
|
||||
const state = store.getState()
|
||||
const groups = state.groupsByWorktree[wt] ?? []
|
||||
const unifiedTabs = state.unifiedTabsByWorktree[wt] ?? []
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(state.activeGroupIdByWorktree[wt]).toBe(groups[0].id)
|
||||
expect(state.layoutByWorktree[wt]).toEqual({ type: 'leaf', groupId: groups[0].id })
|
||||
expect(unifiedTabs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: terminal.id,
|
||||
entityId: terminal.id,
|
||||
worktreeId: wt,
|
||||
groupId: groups[0].id,
|
||||
contentType: 'terminal'
|
||||
})
|
||||
])
|
||||
)
|
||||
expect(groups[0].activeTabId).toBe(terminal.id)
|
||||
expect(groups[0].tabOrder).toEqual([terminal.id])
|
||||
})
|
||||
|
||||
it('reuses the lowest available terminal number after closes', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
|
||||
seedStore(store, {
|
||||
worktreesByRepo: {
|
||||
repo1: [makeWorktree({ id: wt, repoId: 'repo1', path: '/path/wt1' })]
|
||||
}
|
||||
})
|
||||
|
||||
const first = store.getState().createTab(wt)
|
||||
const second = store.getState().createTab(wt)
|
||||
|
||||
expect(first.title).toBe('Terminal 1')
|
||||
expect(second.title).toBe('Terminal 2')
|
||||
|
||||
store.getState().closeTab(first.id)
|
||||
store.getState().closeTab(second.id)
|
||||
|
||||
const replacement = store.getState().createTab(wt)
|
||||
expect(replacement.title).toBe('Terminal 1')
|
||||
})
|
||||
|
||||
it('clears stale background browser tab type when closing the last browser tab', () => {
|
||||
const store = createTestStore()
|
||||
const wt = 'repo1::/path/wt1'
|
||||
|
|
|
|||
|
|
@ -14,6 +14,23 @@ import {
|
|||
ensurePtyDispatcher
|
||||
} from '@/components/terminal-pane/pty-transport'
|
||||
|
||||
function getNextTerminalOrdinal(tabs: TerminalTab[]): number {
|
||||
const usedOrdinals = new Set<number>()
|
||||
for (const tab of tabs) {
|
||||
const match = /^Terminal (\d+)$/.exec(tab.customTitle ?? tab.title)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
usedOrdinals.add(Number(match[1]))
|
||||
}
|
||||
|
||||
let nextOrdinal = 1
|
||||
while (usedOrdinals.has(nextOrdinal)) {
|
||||
nextOrdinal += 1
|
||||
}
|
||||
return nextOrdinal
|
||||
}
|
||||
|
||||
export type TerminalSlice = {
|
||||
tabsByWorktree: Record<string, TerminalTab[]>
|
||||
activeTabId: string | null
|
||||
|
|
@ -46,7 +63,7 @@ export type TerminalSlice = {
|
|||
workspaceSessionReady: boolean
|
||||
pendingReconnectWorktreeIds: string[]
|
||||
pendingReconnectTabByWorktree: Record<string, string[]>
|
||||
createTab: (worktreeId: string) => TerminalTab
|
||||
createTab: (worktreeId: string, targetGroupId?: string) => TerminalTab
|
||||
closeTab: (tabId: string) => void
|
||||
reorderTabs: (worktreeId: string, tabIds: string[]) => void
|
||||
setTabBarOrder: (worktreeId: string, order: string[]) => void
|
||||
|
|
@ -175,16 +192,20 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
}
|
||||
},
|
||||
|
||||
createTab: (worktreeId) => {
|
||||
createTab: (worktreeId, targetGroupId) => {
|
||||
const id = globalThis.crypto.randomUUID()
|
||||
let tab!: TerminalTab
|
||||
set((s) => {
|
||||
const existing = s.tabsByWorktree[worktreeId] ?? []
|
||||
const nextOrdinal = getNextTerminalOrdinal(existing)
|
||||
tab = {
|
||||
id,
|
||||
ptyId: null,
|
||||
worktreeId,
|
||||
title: `Terminal ${existing.length + 1}`,
|
||||
// Why: users expect terminal labels to reflect the currently open set,
|
||||
// not a monotonic creation counter. Reusing the lowest free ordinal
|
||||
// keeps a lone fresh terminal at "Terminal 1" after older tabs close.
|
||||
title: `Terminal ${nextOrdinal}`,
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: existing.length,
|
||||
|
|
@ -195,12 +216,40 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
...s.tabsByWorktree,
|
||||
[worktreeId]: [...existing, tab]
|
||||
},
|
||||
activeGroupIdByWorktree:
|
||||
targetGroupId &&
|
||||
s.groupsByWorktree[worktreeId]?.some((group) => group.id === targetGroupId)
|
||||
? { ...s.activeGroupIdByWorktree, [worktreeId]: targetGroupId }
|
||||
: s.activeGroupIdByWorktree,
|
||||
activeTabId: tab.id,
|
||||
activeTabIdByWorktree: { ...s.activeTabIdByWorktree, [worktreeId]: tab.id },
|
||||
ptyIdsByTabId: { ...s.ptyIdsByTabId, [tab.id]: [] },
|
||||
terminalLayoutsByTabId: { ...s.terminalLayoutsByTabId, [tab.id]: emptyLayoutSnapshot() }
|
||||
}
|
||||
})
|
||||
const state = get()
|
||||
const resolvedTargetGroupId =
|
||||
targetGroupId ??
|
||||
state.activeGroupIdByWorktree[worktreeId] ??
|
||||
state.groupsByWorktree[worktreeId]?.[0]?.id ??
|
||||
state.ensureWorktreeRootGroup?.(worktreeId)
|
||||
if (
|
||||
resolvedTargetGroupId &&
|
||||
!state.findTabForEntityInGroup(worktreeId, resolvedTargetGroupId, id, 'terminal')
|
||||
) {
|
||||
// Why: a brand-new worktree can auto-create its first terminal before
|
||||
// Terminal.tsx has mounted and seeded a root tab group. Force a root
|
||||
// group here so the first terminal always gets a visible unified tab
|
||||
// instead of existing only in the legacy terminal slice.
|
||||
state.createUnifiedTab(worktreeId, 'terminal', {
|
||||
id,
|
||||
entityId: id,
|
||||
label: tab.title,
|
||||
customLabel: tab.customTitle,
|
||||
color: tab.color,
|
||||
targetGroupId: resolvedTargetGroupId
|
||||
})
|
||||
}
|
||||
return tab
|
||||
},
|
||||
|
||||
|
|
@ -276,6 +325,14 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
tabBarOrderByWorktree: nextTabBarOrderByWorktree
|
||||
}
|
||||
})
|
||||
for (const tabs of Object.values(get().unifiedTabsByWorktree)) {
|
||||
const workspaceItem = tabs.find(
|
||||
(entry) => entry.contentType === 'terminal' && entry.entityId === tabId
|
||||
)
|
||||
if (workspaceItem) {
|
||||
get().closeUnifiedTab(workspaceItem.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
reorderTabs: (worktreeId, tabIds) => {
|
||||
|
|
@ -324,7 +381,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
})
|
||||
},
|
||||
|
||||
setActiveTab: (tabId) =>
|
||||
setActiveTab: (tabId) => {
|
||||
set((s) => {
|
||||
const worktreeId = s.activeWorktreeId
|
||||
return {
|
||||
|
|
@ -333,7 +390,14 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
? { ...s.activeTabIdByWorktree, [worktreeId]: tabId }
|
||||
: s.activeTabIdByWorktree
|
||||
}
|
||||
}),
|
||||
})
|
||||
const item = Object.values(get().unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
|
||||
if (item) {
|
||||
get().activateTab(item.id)
|
||||
}
|
||||
},
|
||||
|
||||
updateTabTitle: (tabId, title) => {
|
||||
set((s) => {
|
||||
|
|
@ -366,6 +430,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
? { tabsByWorktree: next }
|
||||
: { tabsByWorktree: next, sortEpoch: s.sortEpoch + 1 }
|
||||
})
|
||||
const item = Object.values(get().unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
|
||||
if (item) {
|
||||
get().setTabLabel(item.id, title)
|
||||
}
|
||||
},
|
||||
|
||||
setRuntimePaneTitle: (tabId, paneId, title) => {
|
||||
|
|
@ -412,6 +482,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
scheduleRuntimeGraphSync()
|
||||
return { tabsByWorktree: next }
|
||||
})
|
||||
const item = Object.values(get().unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
|
||||
if (item) {
|
||||
get().setTabCustomLabel(item.id, title)
|
||||
}
|
||||
},
|
||||
|
||||
setTabColor: (tabId, color) => {
|
||||
|
|
@ -422,6 +498,12 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
|
|||
}
|
||||
return { tabsByWorktree: next }
|
||||
})
|
||||
const item = Object.values(get().unifiedTabsByWorktree)
|
||||
.flat()
|
||||
.find((entry) => entry.contentType === 'terminal' && entry.entityId === tabId)
|
||||
if (item) {
|
||||
get().setUnifiedTabColor(item.id, color)
|
||||
}
|
||||
},
|
||||
|
||||
updateTabPtyId: (tabId, ptyId) => {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
|
|||
})
|
||||
}
|
||||
|
||||
function toVisibleTabType(contentType: string): WorkspaceVisibleTabType {
|
||||
return contentType === 'browser' ? 'browser' : contentType === 'terminal' ? 'terminal' : 'editor'
|
||||
}
|
||||
|
||||
export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> = (set, get) => ({
|
||||
worktreesByRepo: {},
|
||||
activeWorktreeId: null,
|
||||
|
|
@ -162,10 +166,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
delete nextUnifiedTabsByWorktree[worktreeId]
|
||||
const nextGroupsByWorktree = { ...s.groupsByWorktree }
|
||||
delete nextGroupsByWorktree[worktreeId]
|
||||
const nextActiveGroupIdByWorktree = { ...s.activeGroupIdByWorktree }
|
||||
delete nextActiveGroupIdByWorktree[worktreeId]
|
||||
const nextLayoutByWorktree = { ...s.layoutByWorktree }
|
||||
delete nextLayoutByWorktree[worktreeId]
|
||||
const nextActiveGroupIdByWorktree = { ...s.activeGroupIdByWorktree }
|
||||
delete nextActiveGroupIdByWorktree[worktreeId]
|
||||
// Why: git status / compare caches are keyed by worktree and stop being
|
||||
// refreshed once the worktree is deleted. Remove them here so deleted
|
||||
// worktrees cannot retain stale conflict badges, branch diffs, or compare
|
||||
|
|
@ -232,8 +236,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
pendingReconnectTabByWorktree: nextPendingReconnectTabByWorktree,
|
||||
unifiedTabsByWorktree: nextUnifiedTabsByWorktree,
|
||||
groupsByWorktree: nextGroupsByWorktree,
|
||||
activeGroupIdByWorktree: nextActiveGroupIdByWorktree,
|
||||
layoutByWorktree: nextLayoutByWorktree,
|
||||
activeGroupIdByWorktree: nextActiveGroupIdByWorktree,
|
||||
editorDrafts: nextEditorDrafts,
|
||||
markdownViewMode: nextMarkdownViewMode,
|
||||
expandedDirs: nextExpandedDirs,
|
||||
|
|
@ -370,6 +374,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
},
|
||||
|
||||
setActiveWorktree: (worktreeId) => {
|
||||
const reconciledActiveTabId = worktreeId
|
||||
? get().reconcileWorktreeTabModel(worktreeId).activeRenderableTabId
|
||||
: null
|
||||
let shouldClearUnread = false
|
||||
set((s) => {
|
||||
if (!worktreeId) {
|
||||
|
|
@ -385,6 +392,20 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
const restoredFileId = s.activeFileIdByWorktree[worktreeId] ?? null
|
||||
const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[worktreeId] ?? null
|
||||
const restoredTabType = s.activeTabTypeByWorktree[worktreeId] ?? 'terminal'
|
||||
const activeGroupId =
|
||||
s.activeGroupIdByWorktree[worktreeId] ?? s.groupsByWorktree[worktreeId]?.[0]?.id ?? null
|
||||
const activeGroup = activeGroupId
|
||||
? ((s.groupsByWorktree[worktreeId] ?? []).find((group) => group.id === activeGroupId) ??
|
||||
null)
|
||||
: null
|
||||
const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null
|
||||
const activeUnifiedTab =
|
||||
activeUnifiedTabId != null
|
||||
? ((s.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
||||
(tab) =>
|
||||
tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id)
|
||||
) ?? null)
|
||||
: null
|
||||
// Verify the restored file still exists in openFiles
|
||||
const fileStillOpen = restoredFileId
|
||||
? s.openFiles.some((f) => f.id === restoredFileId && f.worktreeId === worktreeId)
|
||||
|
|
@ -393,16 +414,40 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
const browserTabStillOpen = restoredBrowserTabId
|
||||
? browserTabs.some((tab) => tab.id === restoredBrowserTabId)
|
||||
: false
|
||||
const hasGroupOwnedSurface =
|
||||
(s.groupsByWorktree[worktreeId]?.length ?? 0) > 0 || Boolean(s.layoutByWorktree[worktreeId])
|
||||
|
||||
// Why: restore the visible tab surface the user last had active in this
|
||||
// worktree. The 'terminal' case must be handled explicitly — without it,
|
||||
// the fallback branches below see that a file is still open and promote
|
||||
// the surface to 'editor', so the user always lands on a file tab instead
|
||||
// of the terminal they were working in.
|
||||
// Why: worktree activation must restore from the reconciled tab-group
|
||||
// model first. Split groups are now the ownership model for visible
|
||||
// content; if we prefer the legacy activeTabType/browser/file fallbacks
|
||||
// when the two models disagree, the renderer can reopen a surface that
|
||||
// has no backing unified tab and show a blank worktree.
|
||||
let activeFileId: string | null
|
||||
let activeBrowserTabId: string | null
|
||||
let activeTabType: WorkspaceVisibleTabType
|
||||
if (restoredTabType === 'terminal') {
|
||||
if (activeUnifiedTab) {
|
||||
activeFileId =
|
||||
activeUnifiedTab.contentType === 'editor' ||
|
||||
activeUnifiedTab.contentType === 'diff' ||
|
||||
activeUnifiedTab.contentType === 'conflict-review'
|
||||
? activeUnifiedTab.entityId
|
||||
: fileStillOpen
|
||||
? restoredFileId
|
||||
: null
|
||||
activeBrowserTabId =
|
||||
activeUnifiedTab.contentType === 'browser'
|
||||
? activeUnifiedTab.entityId
|
||||
: browserTabStillOpen
|
||||
? restoredBrowserTabId
|
||||
: (browserTabs[0]?.id ?? null)
|
||||
activeTabType = toVisibleTabType(activeUnifiedTab.contentType)
|
||||
} else if (hasGroupOwnedSurface) {
|
||||
activeFileId = fileStillOpen ? restoredFileId : null
|
||||
activeBrowserTabId = browserTabStillOpen
|
||||
? restoredBrowserTabId
|
||||
: (browserTabs[0]?.id ?? null)
|
||||
activeTabType = 'terminal'
|
||||
} else if (restoredTabType === 'terminal') {
|
||||
activeFileId = fileStillOpen ? restoredFileId : null
|
||||
activeBrowserTabId = browserTabStillOpen
|
||||
? restoredBrowserTabId
|
||||
|
|
@ -443,7 +488,12 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
|||
const tabStillExists = restoredTabId
|
||||
? worktreeTabs.some((t) => t.id === restoredTabId)
|
||||
: false
|
||||
const activeTabId = tabStillExists ? restoredTabId : (worktreeTabs[0]?.id ?? null)
|
||||
const activeTabId =
|
||||
activeUnifiedTab?.contentType === 'terminal'
|
||||
? activeUnifiedTab.entityId
|
||||
: tabStillExists
|
||||
? restoredTabId
|
||||
: (worktreeTabs[0]?.id ?? null)
|
||||
|
||||
return {
|
||||
activeWorktreeId: worktreeId,
|
||||
|
|
|
|||
Loading…
Reference in a new issue