From d0d97000bfa74ddf634fab12b704f9df9b80a32c Mon Sep 17 00:00:00 2001 From: dingyi Date: Fri, 26 Jun 2026 12:03:50 +0800 Subject: [PATCH] fix: filter sidebar worktrees by project root --- src/hooks/useGitWorkspaceCatalog.test.tsx | 26 ++++++--- src/hooks/useGitWorkspaceCatalog.ts | 68 ++++++++++++----------- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/src/hooks/useGitWorkspaceCatalog.test.tsx b/src/hooks/useGitWorkspaceCatalog.test.tsx index bae66a83..d2e4fb7c 100644 --- a/src/hooks/useGitWorkspaceCatalog.test.tsx +++ b/src/hooks/useGitWorkspaceCatalog.test.tsx @@ -11,14 +11,14 @@ function createDeferred() { } const getCurrentProjectMock = vi.fn() -const listWorktreesMock = vi.fn() const subscribeToEventsMock = vi.fn() const onServerChangeMock = vi.fn() let latestServerChange: (() => void) | undefined +type ProjectResult = { vcs: string; worktree: string; sandboxes?: string[] } + vi.mock('../api', () => ({ getCurrentProject: (...args: unknown[]) => getCurrentProjectMock(...args), - listWorktrees: (...args: unknown[]) => listWorktreesMock(...args), })) vi.mock('../api/events', () => ({ @@ -34,7 +34,6 @@ vi.mock('../store/serverStore', () => ({ describe('useGitWorkspaceCatalog', () => { beforeEach(() => { getCurrentProjectMock.mockReset() - listWorktreesMock.mockReset() subscribeToEventsMock.mockReset() onServerChangeMock.mockReset() latestServerChange = undefined @@ -44,12 +43,11 @@ describe('useGitWorkspaceCatalog', () => { latestServerChange = listener as () => void return vi.fn() }) - listWorktreesMock.mockResolvedValue(['C:/repo', 'C:/repo-worktree']) }) it('refetches workspace metadata on server endpoint changes while stale requests are in flight', async () => { - const staleRequest = createDeferred<{ vcs: string; worktree: string }>() - const freshRequest = createDeferred<{ vcs: string; worktree: string }>() + const staleRequest = createDeferred() + const freshRequest = createDeferred() getCurrentProjectMock.mockImplementationOnce(() => staleRequest.promise).mockImplementationOnce(() => freshRequest.promise) @@ -66,7 +64,7 @@ describe('useGitWorkspaceCatalog', () => { expect(getCurrentProjectMock).toHaveBeenCalledTimes(2) await act(async () => { - freshRequest.resolve({ vcs: 'git', worktree: 'C:/repo' }) + freshRequest.resolve({ vcs: 'git', worktree: 'C:/repo', sandboxes: ['C:/repo-worktree'] }) await Promise.resolve() await Promise.resolve() }) @@ -76,7 +74,7 @@ describe('useGitWorkspaceCatalog', () => { }) await act(async () => { - staleRequest.resolve({ vcs: 'git', worktree: 'C:/stale' }) + staleRequest.resolve({ vcs: 'git', worktree: 'C:/stale', sandboxes: ['C:/stale-worktree'] }) await Promise.resolve() await Promise.resolve() }) @@ -84,4 +82,16 @@ describe('useGitWorkspaceCatalog', () => { expect(result.current.catalog.get('C:/repo')?.rootDirectory).toBe('C:/repo') expect(result.current.catalog.get('C:/repo')?.workspaces).toEqual(['C:/repo', 'C:/repo-worktree']) }) + + it('does not call experimental worktree listing while building the sidebar catalog', async () => { + getCurrentProjectMock.mockResolvedValue({ vcs: 'git', worktree: 'C:/repo', sandboxes: ['C:/repo-worktree'] }) + + const { result } = renderHook(() => useGitWorkspaceCatalog(['C:\\repo'])) + + await waitFor(() => { + expect(result.current.catalog.get('C:/repo')?.workspaces).toEqual(['C:/repo', 'C:/repo-worktree']) + }) + + expect(getCurrentProjectMock).toHaveBeenCalledWith('C:/repo') + }) }) diff --git a/src/hooks/useGitWorkspaceCatalog.ts b/src/hooks/useGitWorkspaceCatalog.ts index b86b6688..0df3e003 100644 --- a/src/hooks/useGitWorkspaceCatalog.ts +++ b/src/hooks/useGitWorkspaceCatalog.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { getCurrentProject, listWorktrees } from '../api' +import { getCurrentProject } from '../api' import { subscribeToEvents } from '../api/events' import { serverStore } from '../store/serverStore' import { normalizeToForwardSlash } from '../utils' @@ -17,6 +17,35 @@ type RefreshListener = () => void const refreshListeners = new Set() +function normalizeProjectWorkspaces(rootDirectory: string, sandboxes?: string[]) { + const seen = new Set([rootDirectory.toLowerCase()]) + const workspaces = [rootDirectory] + + for (const sandbox of sandboxes ?? []) { + const normalized = normalizeToForwardSlash(sandbox) + const key = normalized.toLowerCase() + if (!normalized || seen.has(key)) continue + seen.add(key) + workspaces.push(normalized) + } + + return workspaces +} + +function mergeProjectWorkspaces(current: string[] | undefined, next: string[]) { + if (!current) return next + + const seen = new Set(current.map(directory => directory.toLowerCase())) + const merged = [...current] + for (const directory of next) { + const key = directory.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + merged.push(directory) + } + return merged +} + export function requestGitWorkspaceCatalogRefresh() { refreshListeners.forEach(listener => listener()) } @@ -59,8 +88,8 @@ export function useGitWorkspaceCatalog(directories: string[]) { if (!mountedRef.current || version !== versionRef.current) return - const rootDirectories = new Set() const directoryToRoot = new Map() + const rootToWorkspaces = new Map() const nextCatalog: GitWorkspaceCatalog = new Map() const previousWorkspacesByRoot = new Map() @@ -81,8 +110,8 @@ export function useGitWorkspaceCatalog(directories: string[]) { if (result.status !== 'fulfilled') { const previousMeta = previousCatalog.get(directory) if (previousMeta?.isGit) { - rootDirectories.add(previousMeta.rootDirectory) directoryToRoot.set(directory, previousMeta.rootDirectory) + rootToWorkspaces.set(previousMeta.rootDirectory, previousMeta.workspaces) } continue } @@ -91,8 +120,11 @@ export function useGitWorkspaceCatalog(directories: string[]) { if (project.vcs === 'git' && project.worktree) { const rootDirectory = normalizeToForwardSlash(project.worktree) - rootDirectories.add(rootDirectory) directoryToRoot.set(directory, rootDirectory) + rootToWorkspaces.set( + rootDirectory, + mergeProjectWorkspaces(rootToWorkspaces.get(rootDirectory), normalizeProjectWorkspaces(rootDirectory, project.sandboxes)), + ) } else { nextCatalog.set(directory, { isGit: false, @@ -102,39 +134,13 @@ export function useGitWorkspaceCatalog(directories: string[]) { } } - const rootDirectoryList = Array.from(rootDirectories) - - const workspaceResults = await Promise.allSettled( - rootDirectoryList.map(async rootDirectory => ({ - rootDirectory, - worktrees: await listWorktrees(rootDirectory), - })), - ) - if (!mountedRef.current || version !== versionRef.current) return - const rootToWorkspaces = new Map() - - for (let index = 0; index < workspaceResults.length; index++) { - const result = workspaceResults[index] - const rootDirectory = rootDirectoryList[index] - - if (result.status !== 'fulfilled') { - rootToWorkspaces.set(rootDirectory, previousWorkspacesByRoot.get(rootDirectory) ?? [rootDirectory]) - continue - } - - const { worktrees } = result.value - const normalizedWorktrees = Array.from(new Set(worktrees.map(worktree => normalizeToForwardSlash(worktree)))) - const sandboxes = normalizedWorktrees.filter(worktree => worktree.toLowerCase() !== rootDirectory.toLowerCase()) - rootToWorkspaces.set(rootDirectory, [rootDirectory, ...sandboxes]) - } - for (const [directory, rootDirectory] of directoryToRoot) { nextCatalog.set(directory, { isGit: true, rootDirectory, - workspaces: rootToWorkspaces.get(rootDirectory) ?? [rootDirectory], + workspaces: rootToWorkspaces.get(rootDirectory) ?? previousWorkspacesByRoot.get(rootDirectory) ?? [rootDirectory], }) }