diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c7b40a929..72f93b7bd 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -878,19 +878,28 @@ function App(): React.JSX.Element { // active one) so a cold start that restored a remote workspace doesn't // hide local repos. The sidebar "All hosts" scope then shows them all. await timeRendererStartupStep('fetch-repos', () => actions.fetchReposForAllHosts()) - await timeRendererStartupStep('fetch-project-groups', () => - actions.fetchProjectGroupsForAllHosts() - ) - await timeRendererStartupStep('fetch-folder-workspaces', () => - actions.fetchFolderWorkspacesForAllHosts() - ) - // Why: both of these fan out `git worktree list` per repo on the main - // process. Running them concurrently lets the main process share one + // Why: project-groups/folder-workspaces read neither the repos store nor + // worktrees, so once repos land they can overlap the per-repo + // `git worktree list` fan-out (#7225) instead of queuing ahead of it — a + // slow remote host's 15s scope RPCs no longer block the scan. folders + // still follow project-groups (they read projectGroups); all settle + // before the hydrate steps below. + const projectScopeChain = (async () => { + await timeRendererStartupStep('fetch-project-groups', () => + actions.fetchProjectGroupsForAllHosts() + ) + await timeRendererStartupStep('fetch-folder-workspaces', () => + actions.fetchFolderWorkspacesForAllHosts() + ) + })() + // Why: worktrees + lineage both fan out `git worktree list` per repo on + // the main process. Running them concurrently lets it share one // in-flight scan per repo instead of paying the process-spawn fan-out // twice back-to-back — the dominant renderer-chain cost on Windows // (issue #7225). Lineage only reads settings + its own slice, so it // does not depend on the worktrees fetch having landed. await Promise.all([ + projectScopeChain, timeRendererStartupStep('fetch-worktrees', () => actions.fetchAllWorktrees()), timeRendererStartupStep('fetch-worktree-lineage', () => actions.fetchWorktreeLineage()) ]) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index ad2f480bd..81135b32c 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -6,9 +6,7 @@ describe('renderer startup runtime routing', () => { it('loads settings before repo and worktree hydration', () => { const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') const startupBlockStart = source.indexOf('void (async () => {') - const startupBlockEnd = source.indexOf( - "const persistedUI = await timeRendererStartupStep('ui-get'" - ) + const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise') const startupBlock = source.slice(startupBlockStart, startupBlockEnd) const settingsIndex = startupBlock.indexOf('actions.fetchSettings()') @@ -17,6 +15,31 @@ describe('renderer startup runtime routing', () => { expect(settingsIndex).toBeLessThan(startupBlock.indexOf('actions.fetchAllWorktrees()')) }) + it('overlaps sidebar scope loads with worktree hydration before session hydration', () => { + const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') + const startupBlockStart = source.indexOf('void (async () => {') + const startupBlockEnd = source.indexOf('const persistedUI = await uiGetPromise') + const startupBlock = source.slice(startupBlockStart, startupBlockEnd) + + const reposIndex = startupBlock.indexOf('actions.fetchReposForAllHosts()') + const scopeChainIndex = startupBlock.indexOf('const projectScopeChain = (async () => {') + const projectGroupsIndex = startupBlock.indexOf('actions.fetchProjectGroupsForAllHosts()') + const folderWorkspacesIndex = startupBlock.indexOf('actions.fetchFolderWorkspacesForAllHosts()') + const promiseAllIndex = startupBlock.indexOf('await Promise.all([') + const awaitedScopeChainIndex = startupBlock.indexOf('projectScopeChain', promiseAllIndex) + const worktreesIndex = startupBlock.indexOf('actions.fetchAllWorktrees()') + const lineageIndex = startupBlock.indexOf('actions.fetchWorktreeLineage()') + + expect(reposIndex).toBeGreaterThanOrEqual(0) + expect(scopeChainIndex).toBeGreaterThan(reposIndex) + expect(projectGroupsIndex).toBeGreaterThan(scopeChainIndex) + expect(folderWorkspacesIndex).toBeGreaterThan(projectGroupsIndex) + expect(promiseAllIndex).toBeGreaterThan(scopeChainIndex) + expect(awaitedScopeChainIndex).toBeGreaterThan(promiseAllIndex) + expect(worktreesIndex).toBeGreaterThan(promiseAllIndex) + expect(lineageIndex).toBeGreaterThan(promiseAllIndex) + }) + it('waits for first-window startup services before terminal reconnect', () => { const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8') const reconnectIndex = source.indexOf('await actions.reconnectPersistedTerminals') diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index ce3ba3176..58dfa5cb7 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -91,6 +91,7 @@ const mockApi = { globalThis.window = { api: mockApi } import { + WORKTREE_REFRESH_CONCURRENCY, createWorktreeSlice, getHostedReviewLinkMutationGenerationForTests, getHostedReviewLinkWorktreeAliasCountForTests, @@ -5648,7 +5649,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { it('bounds concurrent repo scans during hydration-time refresh', async () => { const store = createTestStore() - const repos = Array.from({ length: 7 }, (_, index) => ({ + const repos = Array.from({ length: WORKTREE_REFRESH_CONCURRENCY + 2 }, (_, index) => ({ id: `repo-${index}`, path: `/repos/${index}`, displayName: `repo-${index}`, @@ -5670,14 +5671,14 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() - expect(maxActiveScans).toBeLessThanOrEqual(5) + expect(maxActiveScans).toBeLessThanOrEqual(WORKTREE_REFRESH_CONCURRENCY) expect(mockApi.worktrees.list).toHaveBeenCalledTimes(repos.length) expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) it('bounds concurrent repo scans after the hydration purge has run', async () => { const store = createTestStore() - const repos = Array.from({ length: 7 }, (_, index) => ({ + const repos = Array.from({ length: WORKTREE_REFRESH_CONCURRENCY + 2 }, (_, index) => ({ id: `repo-${index}`, path: `/repos/${index}`, displayName: `repo-${index}`, @@ -5702,7 +5703,7 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() - expect(maxActiveScans).toBeLessThanOrEqual(5) + expect(maxActiveScans).toBeLessThanOrEqual(WORKTREE_REFRESH_CONCURRENCY) expect(mockApi.worktrees.list).toHaveBeenCalledTimes(repos.length) expect(store.getState().hasHydratedWorktreePurge).toBe(true) }) diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 2ddc8100a..15d44711a 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -72,7 +72,10 @@ const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000 const ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS = 300 const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450 const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180 -const WORKTREE_REFRESH_CONCURRENCY = 5 +// Why: each repo's `git worktree list` is an independent main-process child, so +// a higher ceiling cuts startup scan batches (#7225) while staying bounded so +// one UI moment can't launch every git probe at once. +export const WORKTREE_REFRESH_CONCURRENCY = 8 const pendingActivationTerminalPrepCancels = new Map void>() const detachedHeadAutoDerivedDisplayNames = new Map() const folderWorkspaceWorktreeCache = new WeakMap()