From 73e17b947caedf28084d85aef40698f5c2880857 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 19:25:16 -0700 Subject: [PATCH] Keep file opens in editor split groups (#3232) --- src/renderer/src/store/slices/editor.test.ts | 93 ++++++++++++++++++++ src/renderer/src/store/slices/editor.ts | 87 ++++++++++++++++-- 2 files changed, 172 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 2dfba653e87..225ae348a2c 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -428,6 +428,99 @@ describe('createEditorSlice floating editor activation', () => { }) }) +describe('createEditorSlice split-group editor routing', () => { + function openSourceFile( + store: StoreApi, + filePath: string, + options?: Parameters[1] + ): void { + store.getState().openFile( + { + filePath, + relativePath: filePath.replace('/repo/', ''), + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }, + options + ) + } + + function seedTerminalAndEditorGroups(store: StoreApi): { + 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) + return { terminalTabId: terminalTab.id, terminalGroupId, editorGroupId } + } + + function findUnifiedTabByEntity(store: StoreApi, 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() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index e2fd16f9869..ae8cb4578bf 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -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): Tab | null { + return group.activeTabId ? (tabsById.get(group.activeTabId) ?? null) : null +} + +function getMostRecentEditorTabForGroup(group: TabGroup, tabsById: Map): Tab | null { + const seen = new Set() + 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, + 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, worktreeId: string, @@ -1232,10 +1305,8 @@ export const createEditorSlice: StateCreator = (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() if (targetGroupId) { const tabsForWorktree = s.unifiedTabsByWorktree?.[worktreeId] ?? []