From f6a4abffdcffb97ec715f12a6be6ac4d50a662e5 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:59:54 -0700 Subject: [PATCH] Display SSH worktrees immediately using persisted metadata Users can now see known worktrees for SSH hosts without waiting for the provider connection to establish. Worktrees are fetched from local metadata and displayed as non-authoritative, then merged without replacing richer live data once the provider becomes available. --- src/main/ipc/worktrees.test.ts | 78 +++++++++ src/main/ipc/worktrees.ts | 41 +++++ src/preload/api-types.ts | 5 + src/preload/index.ts | 3 + src/preload/ssh-authority-forwarding.test.ts | 10 ++ .../src/store/slices/worktrees.test.ts | 165 ++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 100 ++++++++++- .../detected-worktree-provider-contract.ts | 18 ++ 8 files changed, 419 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index c83eece63b6..8db588691d0 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -2589,6 +2589,84 @@ describe('registerWorktreeHandlers', () => { }) }) + it('lists every persisted SSH worktree without accessing the live provider', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + const metaById = { + 'repo-1::/remote/repo': makeWorktreeMeta({ + displayName: 'main', + hostId: sshHostId + }), + 'repo-1::/remote/queued': makeWorktreeMeta({ + displayName: 'queued', + hostId: sshHostId + }), + 'repo-1::/remote/other-host': makeWorktreeMeta({ + displayName: 'other host', + hostId: toSshExecutionHostId('target-b') + }) + } + store.getRepos.mockReturnValue([sshRepo]) + store.getProjectHostSetups.mockReturnValue([]) + store.getAllWorktreeMeta.mockReturnValue(metaById) + store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toMatchObject({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: sshHostId, + result: { + repoId: sshRepo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: [ + expect.objectContaining({ path: '/remote/repo', isMainWorktree: true }), + expect.objectContaining({ path: '/remote/queued', isMainWorktree: false }) + ] + } + }) + expect(getSshGitProviderMock).not.toHaveBeenCalled() + expect(listWorktreesMock).not.toHaveBeenCalled() + }) + + it('rejects ambiguous metadata-only SSH owners', async () => { + const sshHostId = toSshExecutionHostId('target-a') + const sshRepo = { + id: 'repo-1', + path: '/remote/repo-a', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'target-a' + } + store.getRepos.mockReturnValue([sshRepo, { ...sshRepo, path: '/remote/repo-b' }]) + + const result = await handlers['worktrees:listKnownForExecutionHost'](null, { + repoId: sshRepo.id, + executionHostId: sshHostId + }) + + expect(result).toEqual({ + status: 'rejected', + repoId: sshRepo.id, + executionHostId: sshHostId + }) + expect(store.getAllWorktreeMeta).not.toHaveBeenCalled() + expect(getSshGitProviderMock).not.toHaveBeenCalled() + }) + it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => { const sshRepo = { id: 'shared-repo', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index c8dceec6bed..75aa4af3adb 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -49,7 +49,9 @@ import { import { PROVIDER_REQUEST_ID_MAX_UTF8_BYTES, type DirectSshDetectedWorktreeRequest, + type HostQualifiedKnownWorktreeResult, type HostQualifiedDetectedWorktreeResult, + type ListKnownWorktreesForExecutionHostArgs, type ListDetectedWorktreesArgs, type ProviderRequestId } from '../../shared/detected-worktree-provider-contract' @@ -1826,6 +1828,7 @@ export function registerWorktreeHandlers( ipcMain.removeHandler('worktrees:listAll') ipcMain.removeHandler('worktrees:list') ipcMain.removeHandler('worktrees:listDetected') + ipcMain.removeHandler('worktrees:listKnownForExecutionHost') ipcMain.removeHandler('worktrees:cancelListDetected') ipcMain.removeHandler('worktrees:create') ipcMain.removeHandler('worktrees:prefetchCreateBase') @@ -1970,6 +1973,44 @@ export function registerWorktreeHandlers( } }) + ipcMain.handle( + 'worktrees:listKnownForExecutionHost', + (_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => { + const rejected = (): HostQualifiedKnownWorktreeResult => ({ + status: 'rejected', + repoId: args.repoId, + executionHostId: args.executionHostId + }) + const parsedHost = parseExecutionHostId(args.executionHostId) + if (parsedHost?.kind !== 'ssh') { + return rejected() + } + const candidates = store.getRepos().filter((candidate) => candidate.id === args.repoId) + if ( + candidates.some((candidate) => resolveRepoOwnershipEvidence(candidate).status !== 'owned') + ) { + return rejected() + } + const repo = findExactRepoOwner(store, args.repoId, args.executionHostId) + if (!repo || repo.connectionId !== parsedHost.targetId) { + return rejected() + } + const metaIndex = createSshWorktreeMetaIndex(Object.entries(store.getAllWorktreeMeta())) + const worktrees = listDisconnectedSshWorktrees(store, repo, metaIndex) + return { + status: 'complete', + repoId: repo.id, + executionHostId: args.executionHostId, + result: { + repoId: repo.id, + authoritative: false, + source: 'metadata-fallback', + worktrees: buildDisconnectedDetectedWorktrees(store, repo, worktrees) + } + } + } + ) + ipcMain.handle( 'worktrees:listDetected', async ( diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5208f3ffa24..d8cc23c02de 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -30,8 +30,10 @@ import type { ReadClipboardTextOptions } from '../shared/clipboard-text' import type { AppIdentity } from '../shared/app-identity' import type { ReleaseChannel } from '../shared/release-channel' import type { + HostQualifiedKnownWorktreeResult, HostQualifiedDetectedWorktreeResult, LegacyDetectedWorktreeRequest, + ListKnownWorktreesForExecutionHostArgs, ListDetectedWorktreesArgs, ProviderRequestId } from '../shared/detected-worktree-provider-contract' @@ -1396,6 +1398,9 @@ export type PreloadApi = { ): Promise (args: LegacyDetectedWorktreeRequest): Promise } + listKnownForExecutionHost?: ( + args: ListKnownWorktreesForExecutionHostArgs + ) => Promise cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise listAll: () => Promise create: (args: CreateWorktreeArgs) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index f1614048bad..2c7e70b9c0b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -764,6 +764,9 @@ const api = { listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args), + listKnownForExecutionHost: (args) => + ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args), + cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args), listAll: () => ipcRenderer.invoke('worktrees:listAll'), diff --git a/src/preload/ssh-authority-forwarding.test.ts b/src/preload/ssh-authority-forwarding.test.ts index 7a81df602d2..04f13430801 100644 --- a/src/preload/ssh-authority-forwarding.test.ts +++ b/src/preload/ssh-authority-forwarding.test.ts @@ -68,6 +68,16 @@ describe('native preload SSH authority forwarding', () => { >() }) + it('forwards host-qualified metadata-only worktree reads', async () => { + await import('./index') + const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi + const args = { repoId: 'repo-1', executionHostId: 'ssh:ssh-1' as const } + + await api.worktrees.listKnownForExecutionHost?.(args) + + expect(invoke).toHaveBeenCalledWith('worktrees:listKnownForExecutionHost', args) + }) + it('forwards full-pair get and push states without cloning away authority', async () => { const state: SshConnectionState = { targetId: 'ssh-1', diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 1d2b822dffd..bcc19588350 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -21,6 +21,8 @@ import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rp import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { HostQualifiedDetectedWorktreeResult, + HostQualifiedKnownWorktreeResult, + ListKnownWorktreesForExecutionHostArgs, ListDetectedWorktreesArgs } from '../../../../shared/detected-worktree-provider-contract' import type { DirectSshAuthority, SshProviderEpoch } from '../../../../shared/ssh-types' @@ -105,12 +107,17 @@ const listDetectedMock = vi.fn< return qualifyDetectedResult(args, result) }) +const listKnownForExecutionHostMock = vi.fn< + (args: ListKnownWorktreesForExecutionHostArgs) => Promise +>(async (args) => ({ status: 'rejected', ...args })) + const mockApi = { worktrees: { create: vi.fn(), prefetchCreateBase: vi.fn().mockResolvedValue(undefined), list: worktreeListMock, listDetected: listDetectedMock, + listKnownForExecutionHost: listKnownForExecutionHostMock, cancelListDetected: vi.fn().mockResolvedValue(undefined), listLineage: vi.fn().mockResolvedValue({}), remove: vi.fn().mockResolvedValue(undefined), @@ -1350,6 +1357,107 @@ describe('fetchWorktrees', () => { expect(store.getState().detectedWorktreesByRepo).toBe(detectedWorktreesByRepo) }) + it('shows persisted secondary worktrees while SSH is connecting', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued', + displayName: 'queued' + }) + const detected = makeDetectedResult('repo-ssh', [queued], { + authoritative: false, + source: 'metadata-fallback' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: detected + }) + store.setState({ + repos: [sshRepo], + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connecting', + error: null, + reconnectAttempt: 0, + providerEpoch: null + } + ] + ]) + } as Partial) + + await expect(store.getState().fetchWorktrees(sshRepo.id)).resolves.toBe(false) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...queued, hostId: 'ssh:ssh-1' } + ]) + expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1' + }) + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() + }) + + it('adds metadata rows without replacing richer cached SSH worktrees', async () => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const existing = makeWorktree({ + id: 'repo-ssh::/home/orca/existing', + repoId: 'repo-ssh', + path: '/home/orca/existing', + hostId: 'ssh:ssh-1', + head: 'live-head', + branch: 'refs/heads/live-branch' + }) + const metadataExisting = { ...existing, head: '', branch: '' } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult('repo-ssh', [metadataExisting, queued], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo], + sshConnectionStates: new Map(), + worktreesByRepo: { [sshRepo.id]: [existing] } + } as Partial) + + await store.getState().fetchWorktrees(sshRepo.id) + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + existing, + { ...queued, hostId: 'ssh:ssh-1' } + ]) + }) + it('keeps worktree maps byte-identical for stale and malformed direct results', async () => { const store = createTestStore() const existing = makeWorktree({ @@ -7536,6 +7644,63 @@ describe('fetchAllWorktrees hydration-time purge (design ยง4.4)', () => { addedAt: 0 } + it.each([false, true])( + 'hydrates connecting SSH worktrees with hydration purge completed=%s', + async (hasHydratedWorktreePurge) => { + const store = createTestStore() + const sshRepo = { + id: 'repo-ssh', + path: '/home/orca/repo', + displayName: 'SSH Repo', + badgeColor: '#000', + addedAt: 0, + connectionId: 'ssh-1' + } + const queued = makeWorktree({ + id: 'repo-ssh::/home/orca/queued', + repoId: 'repo-ssh', + path: '/home/orca/queued', + displayName: 'queued' + }) + listKnownForExecutionHostMock.mockResolvedValueOnce({ + status: 'complete', + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1', + result: makeDetectedResult(sshRepo.id, [queued], { + authoritative: false, + source: 'metadata-fallback' + }) + }) + store.setState({ + repos: [sshRepo], + hasHydratedWorktreePurge, + sshConnectionStates: new Map([ + [ + 'ssh-1', + { + targetId: 'ssh-1', + status: 'connecting', + error: null, + reconnectAttempt: 0, + providerEpoch: null + } + ] + ]) + } as Partial) + + await store.getState().fetchAllWorktrees() + + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...queued, hostId: 'ssh:ssh-1' } + ]) + expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ + repoId: sshRepo.id, + executionHostId: 'ssh:ssh-1' + }) + expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() + } + ) + it('preserves resolved inline legacy lineage when side-map hydration is absent', async () => { const store = createTestStore() const parent = makeWorktree({ diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 6cd073abbe1..babeb2876f3 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -121,6 +121,7 @@ import { import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression' import type { HostQualifiedDetectedWorktreeResult, + HostQualifiedKnownWorktreeResult, ListDetectedWorktreesArgs, ProviderRequestId, SshExecutionHostId @@ -2980,6 +2981,100 @@ function mergeFetchedWorktrees( return admitted } +function appendMissingWorktreesForHost< + T extends { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } +>( + current: readonly T[] | undefined, + incoming: readonly T[], + hostId: ExecutionHostId, + options: WorktreeHostMatchOptions +): T[] { + const existing = current ?? [] + const existingHostIds = new Set( + existing + .filter((worktree) => worktreeMatchesHost(worktree, hostId, options)) + .map(({ id }) => id) + ) + return [...existing, ...incoming.filter((worktree) => !existingHostIds.has(worktree.id))] +} + +function isAdmittedKnownSshWorktreeResult( + result: HostQualifiedKnownWorktreeResult, + repoId: string, + executionHostId: SshExecutionHostId +): result is Extract { + return ( + result.status === 'complete' && + result.repoId === repoId && + result.executionHostId === executionHostId && + isDetectedWorktreeListResult(result.result) && + result.result.repoId === repoId && + result.result.authoritative === false + ) +} + +async function fetchKnownSshWorktreesForRepo( + set: Parameters>[0], + repoId: string, + executionHostId: SshExecutionHostId +): Promise { + const listKnown = window.api.worktrees.listKnownForExecutionHost + if (typeof listKnown !== 'function') { + return null + } + const result = await listKnown({ repoId, executionHostId }) + if (!isAdmittedKnownSshWorktreeResult(result, repoId, executionHostId)) { + return null + } + let admitted = false + set((state) => { + if (!repoHasExactlyOneExecutionHostOwner(state, repoId, executionHostId, false)) { + return state + } + admitted = true + const setup = getProjectHostSetupForRepoHost(state, repoId, executionHostId) + const matchOptions = worktreeHostMatchOptions(state, repoId, executionHostId) + const incomingDetected = result.result.worktrees.map((worktree) => + withRepoHostOwnership(worktree, executionHostId, setup) + ) + const detected = { + ...result.result, + worktrees: appendMissingWorktreesForHost( + state.detectedWorktreesByRepo[repoId]?.worktrees, + incomingDetected, + executionHostId, + matchOptions + ) + } + const worktrees = appendMissingWorktreesForHost( + state.worktreesByRepo[repoId], + toVisibleWorktrees(result.result, executionHostId, setup), + executionHostId, + matchOptions + ) + const worktreesChanged = !areWorktreesEqual(state.worktreesByRepo[repoId], worktrees) + const detectedChanged = !areDetectedWorktreeResultsEqual( + state.detectedWorktreesByRepo[repoId], + detected + ) + if (!worktreesChanged && !detectedChanged) { + return state + } + return { + ...(worktreesChanged + ? { + worktreesByRepo: { ...state.worktreesByRepo, [repoId]: worktrees }, + sortEpoch: state.sortEpoch + 1 + } + : {}), + ...(detectedChanged + ? { detectedWorktreesByRepo: { ...state.detectedWorktreesByRepo, [repoId]: detected } } + : {}) + } + }) + return admitted ? result.result : null +} + export type DirectSshDetectedWorktreeRefresh = { waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId'] providerRequestId: ProviderRequestId @@ -3110,7 +3205,7 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { - return null + return fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) } const refresh = await listDetectedWorktreesForRepoCoalesced( settingsForRepoOwner(ownerState, repoId, hostId), @@ -3208,6 +3303,7 @@ export const createWorktreeSlice: StateCreator ? (directCallerAuthority ?? getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) return false } const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { @@ -3272,6 +3368,7 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return } const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { @@ -3323,6 +3420,7 @@ export const createWorktreeSlice: StateCreator ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) : undefined if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) return { repoId: r.id, ok: false as const } } const refresh = await listDetectedWorktreesForRepoCoalesced( diff --git a/src/shared/detected-worktree-provider-contract.ts b/src/shared/detected-worktree-provider-contract.ts index 4c5289db8fa..38b38ace37e 100644 --- a/src/shared/detected-worktree-provider-contract.ts +++ b/src/shared/detected-worktree-provider-contract.ts @@ -23,6 +23,24 @@ export type ListDetectedWorktreesArgs = | LocalDetectedWorktreeRequest | DirectSshDetectedWorktreeRequest +export type ListKnownWorktreesForExecutionHostArgs = { + repoId: string + executionHostId: SshExecutionHostId +} + +export type HostQualifiedKnownWorktreeResult = + | { + status: 'complete' + repoId: string + executionHostId: SshExecutionHostId + result: DetectedWorktreeListResult + } + | { + status: 'rejected' + repoId: string + executionHostId: SshExecutionHostId + } + export type AuthoritativeDetectedWorktreeHost = | { kind: 'local'