Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions src/hooks/useGitWorkspaceCatalog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ function createDeferred<T>() {
}

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', () => ({
Expand All @@ -34,7 +34,6 @@ vi.mock('../store/serverStore', () => ({
describe('useGitWorkspaceCatalog', () => {
beforeEach(() => {
getCurrentProjectMock.mockReset()
listWorktreesMock.mockReset()
subscribeToEventsMock.mockReset()
onServerChangeMock.mockReset()
latestServerChange = undefined
Expand All @@ -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<ProjectResult>()
const freshRequest = createDeferred<ProjectResult>()

getCurrentProjectMock.mockImplementationOnce(() => staleRequest.promise).mockImplementationOnce(() => freshRequest.promise)

Expand All @@ -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()
})
Expand All @@ -76,12 +74,24 @@ 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()
})

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')
})
})
68 changes: 37 additions & 31 deletions src/hooks/useGitWorkspaceCatalog.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -17,6 +17,35 @@ type RefreshListener = () => void

const refreshListeners = new Set<RefreshListener>()

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())
}
Expand Down Expand Up @@ -59,8 +88,8 @@ export function useGitWorkspaceCatalog(directories: string[]) {

if (!mountedRef.current || version !== versionRef.current) return

const rootDirectories = new Set<string>()
const directoryToRoot = new Map<string, string>()
const rootToWorkspaces = new Map<string, string[]>()
const nextCatalog: GitWorkspaceCatalog = new Map()
const previousWorkspacesByRoot = new Map<string, string[]>()

Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -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<string, string[]>()

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],
})
}

Expand Down