Keep file opens in editor split groups (#3232)

This commit is contained in:
Neil 2026-05-30 19:25:16 -07:00 committed by GitHub
parent 2c55de5589
commit 73e17b947c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 172 additions and 8 deletions

View File

@ -428,6 +428,99 @@ describe('createEditorSlice floating editor activation', () => {
})
})
describe('createEditorSlice split-group editor routing', () => {
function openSourceFile(
store: StoreApi<AppState>,
filePath: string,
options?: Parameters<AppState['openFile']>[1]
): void {
store.getState().openFile(
{
filePath,
relativePath: filePath.replace('/repo/', ''),
worktreeId: 'wt-1',
language: 'typescript',
mode: 'edit'
},
options
)
}
function seedTerminalAndEditorGroups(store: StoreApi<AppState>): {
terminalTabId: string
terminalGroupId: string
editorGroupId: string
} {
const terminalTab = store.getState().createUnifiedTab('wt-1', 'terminal', {
id: 'terminal-tab',
entityId: 'terminal-tab',
label: 'Agent'
})
const terminalGroup = store.getState().groupsByWorktree['wt-1']?.[0]
if (!terminalGroup) {
throw new Error('Expected terminal group')
}
const terminalGroupId = terminalGroup.id
const editorGroupId = store.getState().createEmptySplitGroup('wt-1', terminalGroupId, 'right')
if (!editorGroupId) {
throw new Error('Expected split editor group')
}
openSourceFile(store, '/repo/seed.ts', { targetGroupId: editorGroupId })
store.setState({
activeGroupIdByWorktree: { 'wt-1': terminalGroupId },
activeTabType: 'terminal',
activeTabTypeByWorktree: { 'wt-1': 'terminal' }
} as Partial<AppState>)
return { terminalTabId: terminalTab.id, terminalGroupId, editorGroupId }
}
function findUnifiedTabByEntity(store: StoreApi<AppState>, entityId: string) {
return store.getState().unifiedTabsByWorktree['wt-1']?.find((tab) => tab.entityId === entityId)
}
it('routes implicit file opens to an existing visible editor group', () => {
const store = createEditorTabsStore()
const { terminalTabId, terminalGroupId, editorGroupId } = seedTerminalAndEditorGroups(store)
openSourceFile(store, '/repo/next.ts')
const openedTab = findUnifiedTabByEntity(store, '/repo/next.ts')
const terminalGroup = store
.getState()
.groupsByWorktree['wt-1'].find((group) => group.id === terminalGroupId)
const editorGroup = store
.getState()
.groupsByWorktree['wt-1'].find((group) => group.id === editorGroupId)
expect(openedTab?.groupId).toBe(editorGroupId)
expect(editorGroup?.activeTabId).toBe(openedTab?.id)
expect(terminalGroup?.activeTabId).toBe(terminalTabId)
})
it('uses editor-recent groups when no inactive group is currently showing an editor', () => {
const store = createEditorTabsStore()
const { editorGroupId } = seedTerminalAndEditorGroups(store)
store.getState().createUnifiedTab('wt-1', 'browser', {
id: 'browser-tab',
entityId: 'browser-tab',
label: 'Browser',
targetGroupId: editorGroupId
})
openSourceFile(store, '/repo/recent-target.ts')
expect(findUnifiedTabByEntity(store, '/repo/recent-target.ts')?.groupId).toBe(editorGroupId)
})
it('keeps explicit target groups ahead of default editor routing', () => {
const store = createEditorTabsStore()
const { terminalGroupId } = seedTerminalAndEditorGroups(store)
openSourceFile(store, '/repo/explicit.ts', { targetGroupId: terminalGroupId })
expect(findUnifiedTabByEntity(store, '/repo/explicit.ts')?.groupId).toBe(terminalGroupId)
})
})
describe('createEditorSlice untitled cleanup routing', () => {
const runtimeEnvironmentCallMock = vi.fn()
const runtimeEnvironmentTransportCallMock = vi.fn()

View File

@ -545,10 +545,7 @@ function openWorkspaceEditorItem(
isPreview?: boolean,
targetGroupId?: string
): string {
const resolvedGroupId =
targetGroupId ??
state.activeGroupIdByWorktree?.[worktreeId] ??
state.groupsByWorktree?.[worktreeId]?.[0]?.id
const resolvedGroupId = resolveEditorOpenTargetGroupId(state, worktreeId, targetGroupId)
if (resolvedGroupId) {
const existing = state.findTabForEntityInGroup?.(
worktreeId,
@ -570,6 +567,82 @@ function openWorkspaceEditorItem(
return created?.id ?? fileId
}
function isEditorTabContentType(contentType: Tab['contentType']): boolean {
return contentType === 'editor' || contentType === 'diff' || contentType === 'conflict-review'
}
function getGroupActiveTab(group: TabGroup, tabsById: Map<string, Tab>): Tab | null {
return group.activeTabId ? (tabsById.get(group.activeTabId) ?? null) : null
}
function getMostRecentEditorTabForGroup(group: TabGroup, tabsById: Map<string, Tab>): Tab | null {
const seen = new Set<string>()
const candidateIdLists = [group.recentTabIds ?? [], group.tabOrder]
for (const candidateIds of candidateIdLists) {
for (let index = candidateIds.length - 1; index >= 0; index -= 1) {
const tabId = candidateIds[index]
if (!tabId || seen.has(tabId)) {
continue
}
seen.add(tabId)
const tab = tabsById.get(tabId)
if (tab?.groupId === group.id && isEditorTabContentType(tab.contentType)) {
return tab
}
}
}
return null
}
function resolveEditorOpenTargetGroupId(
state: Pick<AppState, 'activeGroupIdByWorktree' | 'groupsByWorktree' | 'unifiedTabsByWorktree'>,
worktreeId: string,
explicitTargetGroupId?: string
): string | undefined {
if (explicitTargetGroupId) {
return explicitTargetGroupId
}
const groups = state.groupsByWorktree?.[worktreeId] ?? []
if (groups.length === 0) {
return undefined
}
const fallbackGroup = groups[0]
if (!fallbackGroup) {
return undefined
}
const tabsById = new Map(
(state.unifiedTabsByWorktree?.[worktreeId] ?? []).map((tab) => [tab.id, tab])
)
const activeGroup =
groups.find((group) => group.id === state.activeGroupIdByWorktree?.[worktreeId]) ??
fallbackGroup
const activeTab = getGroupActiveTab(activeGroup, tabsById)
if (!activeTab || isEditorTabContentType(activeTab.contentType)) {
return activeGroup.id
}
// Why: file explorer opens should reuse an existing editor pane when the
// focused pane is an agent terminal, instead of turning that terminal pane
// into an editor tab.
const visibleEditorGroup = groups.find((group) => {
if (group.id === activeGroup.id) {
return false
}
const groupActiveTab = getGroupActiveTab(group, tabsById)
return groupActiveTab ? isEditorTabContentType(groupActiveTab.contentType) : false
})
if (visibleEditorGroup) {
return visibleEditorGroup.id
}
const recentEditorGroup = groups.find(
(group) => group.id !== activeGroup.id && getMostRecentEditorTabForGroup(group, tabsById)
)
return recentEditorGroup?.id ?? activeGroup.id
}
function buildEditorActiveResult(
state: Pick<EditorSlice, 'activeFileIdByWorktree' | 'activeTabTypeByWorktree'>,
worktreeId: string,
@ -1232,10 +1305,8 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
// scoped to that group. Opening as preview in group B must not evict a
// preview tab belonging to group A (split tab groups).
const targetGroupId =
options?.targetGroupId ??
s.activeGroupIdByWorktree?.[worktreeId] ??
s.groupsByWorktree?.[worktreeId]?.[0]?.id ??
undefined
resolveEditorOpenTargetGroupId(s, worktreeId, options?.targetGroupId) ?? undefined
editorItemTargetGroupId = targetGroupId
const previewTabByEntity = new Map<string, string>()
if (targetGroupId) {
const tabsForWorktree = s.unifiedTabsByWorktree?.[worktreeId] ?? []