From e68bc532db149281e9e17f4a97703189f7727afc Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 15 May 2026 11:48:11 -0700 Subject: [PATCH] Include disconnected SSH workspaces --- src/main/ipc/worktrees.test.ts | 298 +++++++++++++++++++++++++++++++++ src/main/ipc/worktrees.ts | 104 ++++++++++-- 2 files changed, 392 insertions(+), 10 deletions(-) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 4a0de9b6e97..6492dad2988 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -153,6 +153,7 @@ describe('registerWorktreeHandlers', () => { getSparsePresets: vi.fn(), getSettings: vi.fn(), getWorktreeMeta: vi.fn(), + getAllWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), removeWorktreeMeta: vi.fn() } @@ -202,6 +203,7 @@ describe('registerWorktreeHandlers', () => { store.getSparsePresets, store.getSettings, store.getWorktreeMeta, + store.getAllWorktreeMeta, store.setWorktreeMeta, store.removeWorktreeMeta, killAllProcessesForWorktreeMock, @@ -241,6 +243,7 @@ describe('registerWorktreeHandlers', () => { workspaceDir: '/workspace' }) store.getWorktreeMeta.mockReturnValue(undefined) + store.getAllWorktreeMeta.mockReturnValue({}) store.setWorktreeMeta.mockReturnValue({}) getGitUsernameMock.mockReturnValue('') getDefaultBaseRefMock.mockReturnValue('origin/main') @@ -327,6 +330,22 @@ describe('registerWorktreeHandlers', () => { ]) } + function makeWorktreeMeta(overrides: Record = {}): Record { + return { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } + } + it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => { // Why: new-workspace flow should silently try improve-dashboard-2, -3, ... // rather than failing and forcing the user back to the name picker. @@ -746,6 +765,285 @@ describe('registerWorktreeHandlers', () => { expect(listWorktreesMock).not.toHaveBeenCalled() }) + it('returns reconstructed rows when an SSH provider is unavailable', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + store.getRepo.mockReturnValue(repo) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ + displayName: 'Feature workspace', + comment: 'persisted comment', + linkedIssue: 123, + linkedPR: 456, + linkedLinearIssue: 'LIN-123', + isArchived: true, + isUnread: true, + isPinned: true, + sortOrder: 7, + lastActivityAt: 42, + workspaceStatus: 'blocked', + diffComments: [ + { + id: 'comment-1', + worktreeId: 'repo-ssh::/remote/feature-wt', + filePath: 'src/app.ts', + lineNumber: 10, + body: 'check this', + createdAt: 1, + updatedAt: 1 + } + ], + sparseDirectories: ['packages/web'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + }) + + const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + + expect(listed).toEqual([ + expect.objectContaining({ + id: 'repo-ssh::/remote/feature-wt', + repoId: 'repo-ssh', + path: '/remote/feature-wt', + head: '', + branch: '', + isBare: false, + isMainWorktree: false, + isSparse: true, + displayName: 'Feature workspace', + comment: 'persisted comment', + linkedIssue: 123, + linkedPR: 456, + linkedLinearIssue: 'LIN-123', + isArchived: true, + isUnread: true, + isPinned: true, + sortOrder: 7, + lastActivityAt: 42, + workspaceStatus: 'blocked', + sparseDirectories: ['packages/web'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1', + diffComments: [ + expect.objectContaining({ + id: 'comment-1', + filePath: 'src/app.ts' + }) + ] + }) + ]) + expect(store.getWorktreeMeta).not.toHaveBeenCalled() + expect(store.setWorktreeMeta).not.toHaveBeenCalled() + }) + + it('falls back to reconstructed SSH rows when provider listing throws', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const provider = { + listWorktrees: vi.fn().mockRejectedValue(new Error('connection lost')) + } + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ + displayName: 'Feature workspace', + lastActivityAt: 42 + }) + }) + + const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + + expect(provider.listWorktrees).toHaveBeenCalledWith('/remote/repo') + expect(listed).toEqual([ + expect.objectContaining({ + id: 'repo-ssh::/remote/feature-wt', + displayName: 'Feature workspace', + lastActivityAt: 42 + }) + ]) + }) + + it('keeps local listing failure behavior as an empty list', async () => { + listWorktreesMock.mockRejectedValue(new Error('filesystem denied')) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-1::/workspace/feature-wt': makeWorktreeMeta({ + displayName: 'Should not appear' + }) + }) + + const listed = await handlers['worktrees:list'](null, { repoId: 'repo-1' }) + + expect(listed).toEqual([]) + expect(store.getAllWorktreeMeta).not.toHaveBeenCalled() + }) + + it('ignores malformed metadata keys during SSH fallback', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + store.getRepo.mockReturnValue(repo) + store.getAllWorktreeMeta.mockReturnValue({ + 'not-a-worktree-id': makeWorktreeMeta({ displayName: 'Bad row' }), + 'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ displayName: 'Good row' }) + }) + + const listed = await handlers['worktrees:list'](null, { repoId: 'repo-ssh' }) + + expect(listed).toEqual([ + expect.objectContaining({ + id: 'repo-ssh::/remote/feature-wt', + displayName: 'Good row' + }) + ]) + }) + + it('does not use the repo display name for sparse fallback rows with empty branches', async () => { + const repo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + store.getRepo.mockReturnValue(repo) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-ssh::/remote/custom-name': makeWorktreeMeta({ + sparseDirectories: ['packages/web'] + }) + }) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as { + displayName: string + isSparse?: boolean + sparseDirectories?: string[] + }[] + + expect(listed[0]).toMatchObject({ + displayName: 'custom-name', + isSparse: true, + sparseDirectories: ['packages/web'] + }) + }) + + it('uses path equivalence to mark the reconstructed SSH main worktree', async () => { + const repo = { + id: 'repo-ssh', + path: 'C:\\Remote\\Repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + store.getRepo.mockReturnValue(repo) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-ssh::c:/remote/repo': makeWorktreeMeta() + }) + + const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })) as { + isMainWorktree: boolean + }[] + + expect(listed[0].isMainWorktree).toBe(true) + }) + + it('includes SSH fallback rows in listAll alongside healthy local rows', async () => { + const sshRepo = { + id: 'repo-ssh', + path: '/remote/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const localRepo = { + id: 'repo-local', + path: '/workspace/local', + displayName: 'Local Repo', + badgeColor: '#111', + addedAt: 0 + } + store.getRepos.mockReturnValue([sshRepo, localRepo]) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-ssh::/remote/feature-wt': makeWorktreeMeta({ displayName: 'Remote cached' }) + }) + listWorktreesMock.mockResolvedValue([ + { + path: '/workspace/local', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + } + ]) + + const listed = await handlers['worktrees:listAll'](null, undefined) + + expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1) + expect(listed).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'repo-ssh::/remote/feature-wt', + displayName: 'Remote cached' + }), + expect.objectContaining({ + id: 'repo-local::/workspace/local', + branch: 'refs/heads/main' + }) + ]) + ) + }) + + it('snapshots SSH fallback metadata once for listAll', async () => { + const sshRepoA = { + id: 'repo-ssh-a', + path: '/remote/a', + displayName: 'SSH A', + badgeColor: '#000', + addedAt: 0, + connectionId: 'conn-1' + } + const sshRepoB = { + id: 'repo-ssh-b', + path: '/remote/b', + displayName: 'SSH B', + badgeColor: '#111', + addedAt: 0, + connectionId: 'conn-2' + } + store.getRepos.mockReturnValue([sshRepoA, sshRepoB]) + store.getAllWorktreeMeta.mockReturnValue({ + 'repo-ssh-a::/remote/a/one': makeWorktreeMeta({ displayName: 'One' }), + 'repo-ssh-b::/remote/b/two': makeWorktreeMeta({ displayName: 'Two' }) + }) + + const listed = await handlers['worktrees:listAll'](null, undefined) + + expect(store.getAllWorktreeMeta).toHaveBeenCalledTimes(1) + expect(listed).toEqual([ + expect.objectContaining({ id: 'repo-ssh-a::/remote/a/one' }), + expect.objectContaining({ id: 'repo-ssh-b::/remote/b/two' }) + ]) + }) + it('stamps lastActivityAt on first discovery so newly-added worktrees sort to the top of Recent', async () => { // Why: a worktree that exists on disk but has no persisted WorktreeMeta // (e.g. a folder repo just added, or a pre-existing worktree in a diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 8bb2b01494a..c8f6f8c08f6 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -34,6 +34,7 @@ import { import { mergeWorktree, parseWorktreeId, + areWorktreePathsEqual, formatWorktreeRemovalError, isOrphanedWorktreeError } from './worktree-logic' @@ -72,6 +73,7 @@ function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string) const loggedUnavailableSshGitProviders = new Set() const loggedWorktreeListFailures = new Set() +const loggedMalformedWorktreeMetaKeys = new Set() function warnOnce(keySet: Set, key: string, message: string, error?: unknown): void { if (keySet.has(key)) { @@ -102,6 +104,68 @@ function rememberLocalWorktreeRoots( ]) } +type SshWorktreeMetaCandidate = { + path: string + meta: WorktreeMeta +} + +type SshWorktreeMetaIndex = Map + +function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorktreeMetaIndex { + const index: SshWorktreeMetaIndex = new Map() + for (const [worktreeId, meta] of entries) { + let parsed: { repoId: string; worktreePath: string } + try { + parsed = parseWorktreeId(worktreeId) + } catch (err) { + warnOnce( + loggedMalformedWorktreeMetaKeys, + worktreeId, + `[worktrees] ignoring malformed persisted worktree metadata key "${worktreeId}"`, + err + ) + continue + } + + const candidates = index.get(parsed.repoId) ?? [] + candidates.push({ path: parsed.worktreePath, meta }) + index.set(parsed.repoId, candidates) + } + return index +} + +function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo { + return { + path, + head: '', + branch: '', + isBare: false, + isMainWorktree: areWorktreePathsEqual(path, repo.path), + ...(meta.sparseDirectories !== undefined || + meta.sparseBaseRef !== undefined || + meta.sparsePresetId !== undefined + ? { isSparse: true } + : {}) + } +} + +function listDisconnectedSshWorktrees( + repo: Repo, + metaIndex: SshWorktreeMetaIndex +): ReturnType[] { + const byWorktreeId = new Map>() + for (const candidate of metaIndex.get(repo.id) ?? []) { + const worktree = mergeWorktree( + repo.id, + synthesizeSshGitWorktree(repo, candidate.path, candidate.meta), + candidate.meta + ) + byWorktreeId.delete(worktree.id) + byWorktreeId.set(worktree.id, worktree) + } + return [...byWorktreeId.values()] +} + export function registerWorktreeHandlers( mainWindow: BrowserWindow, store: Store, @@ -123,6 +187,9 @@ export function registerWorktreeHandlers( ipcMain.handle('worktrees:listAll', async () => { const repos = store.getRepos() + const sshWorktreeMetaIndex = repos.some((repo) => repo.connectionId) + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() // Why: repos are listed in parallel so total time = slowest repo, not // the sum of all repos. Each listRepoWorktrees spawns `git worktree list`. @@ -140,10 +207,20 @@ export function registerWorktreeHandlers( `${repo.connectionId}:${repo.id}`, `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` ) - return [] + return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) } loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) - gitWorktrees = await provider.listWorktrees(repo.path) + try { + gitWorktrees = await provider.listWorktrees(repo.path) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) + } } else { gitWorktrees = await listRepoWorktrees(repo) } @@ -181,6 +258,9 @@ export function registerWorktreeHandlers( if (!repo) { return [] } + const sshWorktreeMetaIndex = repo.connectionId + ? createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + : new Map() try { let gitWorktrees @@ -188,22 +268,26 @@ export function registerWorktreeHandlers( gitWorktrees = [createFolderWorktree(repo)] } else if (repo.connectionId) { const provider = getSshGitProvider(repo.connectionId) - // Why: when SSH is disconnected the provider is null. Return [] so the - // renderer's fetchWorktrees guard (`worktrees.length === 0 && current.length > 0`) - // preserves its cached worktree list. This avoids a console error on every - // fetchAllWorktrees cycle while the connection is being (re-)established — - // worktrees will be properly populated when the SSH `connected` event fires - // and triggers a re-fetch. if (!provider) { warnOnce( loggedUnavailableSshGitProviders, `${repo.connectionId}:${repo.id}`, `[worktrees] SSH git provider unavailable; skipping worktree list for repo "${repo.displayName}" (${repo.id}) at ${repo.path} on connection ${repo.connectionId}` ) - return [] + return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) } loggedUnavailableSshGitProviders.delete(`${repo.connectionId}:${repo.id}`) - gitWorktrees = await provider.listWorktrees(repo.path) + try { + gitWorktrees = await provider.listWorktrees(repo.path) + } catch (err) { + warnOnce( + loggedWorktreeListFailures, + `${repo.id}:${repo.path}`, + `[worktrees] failed to list worktrees for repo "${repo.displayName}" (${repo.id}) at ${repo.path}`, + err + ) + return listDisconnectedSshWorktrees(repo, sshWorktreeMetaIndex) + } } else { gitWorktrees = await listRepoWorktrees(repo) }