Include disconnected SSH workspaces

This commit is contained in:
Jinjing
2026-05-15 11:59:07 -07:00
parent fd813e7639
commit e68bc532db
2 changed files with 392 additions and 10 deletions
+298
View File
@@ -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<string, unknown> = {}): Record<string, unknown> {
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
+94 -10
View File
@@ -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<string>()
const loggedWorktreeListFailures = new Set<string>()
const loggedMalformedWorktreeMetaKeys = new Set<string>()
function warnOnce(keySet: Set<string>, 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<string, SshWorktreeMetaCandidate[]>
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<typeof mergeWorktree>[] {
const byWorktreeId = new Map<string, ReturnType<typeof mergeWorktree>>()
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)
}