diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index d8a554a5197..8819a9339a7 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -97,6 +97,22 @@ function ownedEditorFileId( return `editor:${encodeURIComponent(worktreeId)}:${encodeURIComponent(runtimeKey)}:${encodeURIComponent(filePath)}` } +/** Counts how often store code reads `entityId` off a worktree's unified tabs — + * a scan-count proxy that pins complexity without timing the wall clock. */ +function withCountedEntityIdReads(tabs: readonly Tab[], onRead: () => void): Tab[] { + return tabs.map((tab) => { + const { entityId, ...rest } = tab + return Object.defineProperty(rest, 'entityId', { + get: () => { + onRead() + return entityId + }, + enumerable: true, + configurable: true + }) as Tab + }) +} + function mirroredEditorUnifiedTab(id: string, entityId: string, worktreeId: string): Tab { return { id, @@ -1712,6 +1728,50 @@ describe('createEditorSlice recently closed editor tabs', () => { expect(store.getState().openFiles[0]?.id).toBe(firstId) expect(store.getState().tabBarOrderByWorktree['wt-1']).toEqual([firstId]) }) + + it('captures close-all snapshot positions without rescanning tab state per closed tab', () => { + const countEntityIdReads = (fileCount: number): number => { + const store = createEditorTabsStore() + for (let index = 0; index < fileCount; index += 1) { + store.getState().openFile( + { + filePath: `/repo/file-${index}.ts`, + relativePath: `file-${index}.ts`, + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit' + }, + { preview: false } + ) + } + let reads = 0 + store.setState({ + unifiedTabsByWorktree: { + 'wt-1': withCountedEntityIdReads( + store.getState().unifiedTabsByWorktree['wt-1'] ?? [], + () => { + reads += 1 + } + ) + } + } as Partial) + reads = 0 + + store.getState().closeAllFiles() + + expect(store.getState().openFiles).toHaveLength(0) + expect(store.getState().recentlyClosedEditorTabsByWorktree['wt-1']?.[0]?.position).toEqual({ + tabBarIndex: 0, + groupId: expect.any(String), + groupIndex: 0 + }) + return reads + } + + // Why: resolving each closed tab's position by rescanning tab order and group membership + // made close-all cubic — 4x the tabs cost ~64x the scans and froze the renderer for seconds. + expect(countEntityIdReads(80)).toBeLessThanOrEqual(countEntityIdReads(20) * 8) + }) }) describe('createEditorSlice markdown view state', () => { diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index c3c2d630710..a0dee8ce790 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -2,6 +2,7 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import { + createRecentlyClosedTabPositionIndex, getRecentlyClosedTabPosition, restoreRecentlyClosedTabPosition, pushRecentlyClosedTabKind @@ -2394,6 +2395,8 @@ export const createEditorSlice: StateCreator = (s const closingFiles = s.openFiles.filter((f) => f.worktreeId === activeWorktreeId) let nextRecentClosed = s.recentlyClosedEditorTabsByWorktree[activeWorktreeId] ?? [] let capturedCloseCount = 0 + // Why: one shared index — a per-file position lookup rescans tab order and group membership, making close-all cubic. + const positionIndex = createRecentlyClosedTabPositionIndex(s, activeWorktreeId) for (const f of [...closingFiles].toReversed()) { // Why: skip untitled non-dirty files (deleted from disk after close) and ephemeral preview tabs so the reopen stack has no vanished/junk paths. if ( @@ -2403,7 +2406,7 @@ export const createEditorSlice: StateCreator = (s continue } const { id: _id, isDirty: _dirty, mirroredFromRuntimeSession: _mirrored, ...snap } = f - const position = getRecentlyClosedTabPosition(s, activeWorktreeId, f.id) + const position = positionIndex.positionFor(f.id) nextRecentClosed = [ { ...(snap as ClosedEditorTabSnapshot), diff --git a/src/renderer/src/store/slices/recently-closed-tab-position.ts b/src/renderer/src/store/slices/recently-closed-tab-position.ts new file mode 100644 index 00000000000..69a144534c5 --- /dev/null +++ b/src/renderer/src/store/slices/recently-closed-tab-position.ts @@ -0,0 +1,167 @@ +import type { AppState } from '../types' + +export type RecentlyClosedTabPosition = { + tabBarIndex?: number + groupId?: string + groupIndex?: number +} + +/** Reusable position lookup for one worktree. Bulk closes resolve a position per + * closed tab; without a shared index every lookup rescans tab order and group + * membership, which makes close-all cubic in the tab count. */ +export type RecentlyClosedTabPositionIndex = { + positionFor: (entityId: string) => RecentlyClosedTabPosition | undefined +} + +export function createRecentlyClosedTabPositionIndex( + state: Pick, + worktreeId: string +): RecentlyClosedTabPositionIndex { + const tabBarOrder = state.tabBarOrderByWorktree?.[worktreeId] + const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? [] + const groups = state.groupsByWorktree?.[worktreeId] ?? [] + + // First occurrence wins everywhere, matching the `indexOf`/`find` scans this replaces. + const tabBarIndexByEntityId = new Map() + const tabByEntityId = new Map() + const tabById = new Map() + for (const tab of unifiedTabs) { + if (!tabByEntityId.has(tab.entityId)) { + tabByEntityId.set(tab.entityId, tab) + } + if (!tabById.has(tab.id)) { + tabById.set(tab.id, tab) + } + } + const tabBarEntityIdsByGroupId = new Map() + tabBarOrder?.forEach((entityId, index) => { + if (!tabBarIndexByEntityId.has(entityId)) { + tabBarIndexByEntityId.set(entityId, index) + } + const tab = tabByEntityId.get(entityId) + if (!tab?.groupId) { + return + } + const bucket = tabBarEntityIdsByGroupId.get(tab.groupId) + if (bucket) { + bucket.push(tab.entityId) + } else { + tabBarEntityIdsByGroupId.set(tab.groupId, [tab.entityId]) + } + }) + + const groupById = new Map() + const tabOrderIndexByGroupId = new Map>() + const groupOrderMatchesTabBarByGroupId = new Map() + for (const group of groups) { + if (groupById.has(group.id)) { + continue + } + groupById.set(group.id, group) + const indexByTabId = new Map() + const groupTabEntityIds = group.tabOrder.map((tabId, index) => { + if (!indexByTabId.has(tabId)) { + indexByTabId.set(tabId, index) + } + return tabById.get(tabId)?.entityId + }) + tabOrderIndexByGroupId.set(group.id, indexByTabId) + const tabBarGroupEntityIds = tabBarEntityIdsByGroupId.get(group.id) ?? [] + groupOrderMatchesTabBarByGroupId.set( + group.id, + groupTabEntityIds.length === tabBarGroupEntityIds.length && + groupTabEntityIds.every((entityId, index) => entityId === tabBarGroupEntityIds[index]) + ) + } + + return { + positionFor: (entityId) => { + const tabBarIndex = tabBarOrder ? (tabBarIndexByEntityId.get(entityId) ?? -1) : -1 + const unifiedTab = tabByEntityId.get(entityId) + const group = unifiedTab ? groupById.get(unifiedTab.groupId) : undefined + const groupIndex = + group && unifiedTab ? (tabOrderIndexByGroupId.get(group.id)?.get(unifiedTab.id) ?? -1) : -1 + if (tabBarIndex < 0 && (!group || groupIndex < 0)) { + return undefined + } + const groupOrderMatchesTabBar = + !group || (groupOrderMatchesTabBarByGroupId.get(group.id) ?? true) + return { + ...(tabBarIndex >= 0 && groupOrderMatchesTabBar ? { tabBarIndex } : {}), + ...(group && groupIndex >= 0 ? { groupId: group.id, groupIndex } : {}) + } + } + } +} + +export function getRecentlyClosedTabPosition( + state: Pick, + worktreeId: string, + entityId: string +): RecentlyClosedTabPosition | undefined { + return createRecentlyClosedTabPositionIndex(state, worktreeId).positionFor(entityId) +} + +export function insertTabAtRecentlyClosedPosition( + order: readonly string[], + tabId: string, + position?: RecentlyClosedTabPosition +): string[] { + const nextOrder = order.filter((id) => id !== tabId) + const index = position?.tabBarIndex + if (index === undefined) { + return [...nextOrder, tabId] + } + nextOrder.splice(Math.min(Math.max(index, 0), nextOrder.length), 0, tabId) + return nextOrder +} + +export function restoreRecentlyClosedTabPosition( + getState: () => Pick< + AppState, + | 'tabBarOrderByWorktree' + | 'groupsByWorktree' + | 'unifiedTabsByWorktree' + | 'setTabBarOrder' + | 'reorderUnifiedTabs' + >, + worktreeId: string, + entityId: string, + position?: RecentlyClosedTabPosition +): void { + if (!position) { + return + } + const state = getState() + const order = state.tabBarOrderByWorktree?.[worktreeId] + if (order && typeof state.setTabBarOrder === 'function') { + state.setTabBarOrder(worktreeId, insertTabAtRecentlyClosedPosition(order, entityId, position)) + } + + if (position?.groupIndex === undefined) { + return + } + const unifiedTab = (getState().unifiedTabsByWorktree?.[worktreeId] ?? []).find( + (candidate) => + candidate.entityId === entityId && + (position.groupId === undefined || candidate.groupId === position.groupId) + ) + if (!unifiedTab) { + return + } + const group = (getState().groupsByWorktree?.[worktreeId] ?? []).find( + (candidate) => candidate.id === unifiedTab.groupId + ) + if (!group) { + return + } + if (typeof getState().reorderUnifiedTabs === 'function') { + getState().reorderUnifiedTabs( + group.id, + insertTabAtRecentlyClosedPosition(group.tabOrder, unifiedTab.id, { + tabBarIndex: position.groupIndex + }), + { recordInteraction: false } + ) + } +} diff --git a/src/renderer/src/store/slices/recently-closed-tabs.ts b/src/renderer/src/store/slices/recently-closed-tabs.ts index 7faebebfa4f..d7904eb6c8f 100644 --- a/src/renderer/src/store/slices/recently-closed-tabs.ts +++ b/src/renderer/src/store/slices/recently-closed-tabs.ts @@ -5,114 +5,21 @@ import { isWindowsAbsolutePathLike, relativePathInsideRoot } from '../../../../shared/cross-platform-path' +import { + restoreRecentlyClosedTabPosition, + type RecentlyClosedTabPosition +} from './recently-closed-tab-position' -export type RecentlyClosedTabPosition = { - tabBarIndex?: number - groupId?: string - groupIndex?: number -} - -export function getRecentlyClosedTabPosition( - state: Pick, - worktreeId: string, - entityId: string -): RecentlyClosedTabPosition | undefined { - const tabBarOrder = state.tabBarOrderByWorktree?.[worktreeId] - const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? [] - const tabBarIndex = tabBarOrder?.indexOf(entityId) ?? -1 - const unifiedTab = unifiedTabs.find((tab) => tab.entityId === entityId) - const group = unifiedTab - ? (state.groupsByWorktree?.[worktreeId] ?? []).find( - (candidate) => candidate.id === unifiedTab.groupId - ) - : undefined - const groupIndex = group?.tabOrder.indexOf(unifiedTab?.id ?? '') ?? -1 - const groupTabEntityIds = group - ? group.tabOrder.map((tabId) => unifiedTabs.find((tab) => tab.id === tabId)?.entityId) - : [] - const tabBarGroupEntityIds = group - ? (tabBarOrder ?? []) - .map((tabId) => unifiedTabs.find((tab) => tab.entityId === tabId)) - .filter((tab) => tab?.groupId === group.id) - .map((tab) => tab?.entityId) - : [] - const groupOrderMatchesTabBar = - !group || - (groupTabEntityIds.length === tabBarGroupEntityIds.length && - groupTabEntityIds.every((entityId, index) => entityId === tabBarGroupEntityIds[index])) - if (tabBarIndex < 0 && (!group || groupIndex < 0)) { - return undefined - } - - return { - ...(tabBarIndex >= 0 && groupOrderMatchesTabBar ? { tabBarIndex } : {}), - ...(group && groupIndex >= 0 ? { groupId: group.id, groupIndex } : {}) - } -} - -export function insertTabAtRecentlyClosedPosition( - order: readonly string[], - tabId: string, - position?: RecentlyClosedTabPosition -): string[] { - const nextOrder = order.filter((id) => id !== tabId) - const index = position?.tabBarIndex - if (index === undefined) { - return [...nextOrder, tabId] - } - nextOrder.splice(Math.min(Math.max(index, 0), nextOrder.length), 0, tabId) - return nextOrder -} - -export function restoreRecentlyClosedTabPosition( - getState: () => Pick< - AppState, - | 'tabBarOrderByWorktree' - | 'groupsByWorktree' - | 'unifiedTabsByWorktree' - | 'setTabBarOrder' - | 'reorderUnifiedTabs' - >, - worktreeId: string, - entityId: string, - position?: RecentlyClosedTabPosition -): void { - if (!position) { - return - } - const state = getState() - const order = state.tabBarOrderByWorktree?.[worktreeId] - if (order && typeof state.setTabBarOrder === 'function') { - state.setTabBarOrder(worktreeId, insertTabAtRecentlyClosedPosition(order, entityId, position)) - } - - if (position?.groupIndex === undefined) { - return - } - const unifiedTab = (getState().unifiedTabsByWorktree?.[worktreeId] ?? []).find( - (candidate) => - candidate.entityId === entityId && - (position.groupId === undefined || candidate.groupId === position.groupId) - ) - if (!unifiedTab) { - return - } - const group = (getState().groupsByWorktree?.[worktreeId] ?? []).find( - (candidate) => candidate.id === unifiedTab.groupId - ) - if (!group) { - return - } - if (typeof getState().reorderUnifiedTabs === 'function') { - getState().reorderUnifiedTabs( - group.id, - insertTabAtRecentlyClosedPosition(group.tabOrder, unifiedTab.id, { - tabBarIndex: position.groupIndex - }), - { recordInteraction: false } - ) - } -} +export { + createRecentlyClosedTabPositionIndex, + getRecentlyClosedTabPosition, + insertTabAtRecentlyClosedPosition, + restoreRecentlyClosedTabPosition +} from './recently-closed-tab-position' +export type { + RecentlyClosedTabPosition, + RecentlyClosedTabPositionIndex +} from './recently-closed-tab-position' /** Snapshot of a terminal tab captured at user-initiated close time. Reopen * recreates a fresh shell in the same startup directory (Ghostty semantics) —