perf(repos): update watcher targets incrementally

This commit is contained in:
Neil
2026-08-23 17:00:42 -07:00
parent ab3b1d07cd
commit 9d41c03f45
18 changed files with 1765 additions and 175 deletions
@@ -6,6 +6,7 @@ const { reposMocks, moduleMocks } = await vi.hoisted(async () => {
const moduleMocks = await import('./repos-remote-test-harness')
return { reposMocks: moduleMocks.createReposIpcMocks(), moduleMocks }
})
const scheduleWorktreeWatcherSyncMock = vi.hoisted(() => vi.fn())
vi.mock('electron', () => moduleMocks.electronModuleMock(reposMocks))
vi.mock('../git/repo', async (importOriginal) =>
@@ -21,6 +22,9 @@ vi.mock('./registered-worktree-roots-cache', () =>
vi.mock('../worktree-root-preparation', () =>
moduleMocks.worktreeRootPreparationModuleMock(reposMocks)
)
vi.mock('./worktree-base-directory-watcher', () => ({
scheduleCurrentWorktreeBaseDirectoryWatcherSync: scheduleWorktreeWatcherSyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(reposMocks))
vi.mock('../providers/ssh-filesystem-dispatch', () =>
moduleMocks.sshFilesystemDispatchModuleMock(reposMocks)
@@ -54,6 +58,7 @@ describe('repos:add + repos:clone', () => {
captureHandlers(handleMock)
resetLocalRepoMocks(reposMocks)
mockWindow.webContents.send.mockReset()
scheduleWorktreeWatcherSyncMock.mockClear()
registerRepoHandlers(mockWindow as never, mockStore as never)
})
@@ -193,16 +198,26 @@ describe('repos:add + repos:clone', () => {
mockStore.getProjects.mockReturnValue([project])
mockStore.getProjectHostSetups.mockReturnValue([setup])
mockStore.updateRepo.mockReturnValue(aligned)
const preparation = Promise.withResolvers<void>()
prepareLocalWorktreeRootForRepoMock.mockReturnValueOnce(preparation.promise)
await handlers.get('projectHostSetups:setupExistingFolder')!(null, {
const setupResult = handlers.get('projectHostSetups:setupExistingFolder')!(null, {
projectId: project.id,
hostId: 'local',
path: existing.path,
kind: 'git',
setupMethod: 'imported-existing-folder'
})
await vi.waitFor(() =>
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, aligned)
)
expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled()
preparation.resolve()
await setupResult
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, aligned)
expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({
dirtyRepoIdentities: [{ repoId: aligned.id, hostId: 'local' }]
})
expect(mockStore.addRepo).not.toHaveBeenCalled()
})
@@ -331,7 +346,7 @@ describe('repos:add + repos:clone', () => {
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
})
it('prepares and invalidates roots when repos:update changes worktree base path', () => {
it('prepares and invalidates roots when repos:update changes worktree base path', async () => {
const updated = {
id: 'repo-update-root',
path: '/tmp/repo-update-root',
@@ -342,7 +357,7 @@ describe('repos:add + repos:clone', () => {
}
mockStore.updateRepo.mockReturnValue(updated)
const result = handlers.get('repos:update')!(null, {
const result = await handlers.get('repos:update')!(null, {
repoId: updated.id,
updates: { worktreeBasePath: ' ../worktrees ' }
})
@@ -355,7 +370,36 @@ describe('repos:add + repos:clone', () => {
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
})
it('persists agent worktree visibility through local repos:update', () => {
it('waits for deferred root preparation before scheduling the watcher', async () => {
const updated = {
id: 'repo-deferred-root',
path: '/tmp/repo-deferred-root',
displayName: 'repo-deferred-root',
kind: 'git',
badgeColor: '#22c55e',
worktreeBasePath: '../worktrees'
}
const preparation = Promise.withResolvers<void>()
mockStore.updateRepo.mockReturnValue(updated)
prepareLocalWorktreeRootForRepoMock.mockReturnValueOnce(preparation.promise)
const result = handlers.get('repos:update')!(null, {
repoId: updated.id,
updates: { worktreeBasePath: '../worktrees' }
})
await vi.waitFor(() =>
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, updated)
)
expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled()
preparation.resolve()
await expect(result).resolves.toBe(updated)
expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({
dirtyRepoIdentities: [{ repoId: updated.id, hostId: 'local' }]
})
})
it('persists agent worktree visibility through local repos:update', async () => {
const updated = {
id: 'repo-agent-visibility',
path: '/tmp/repo-agent-visibility',
@@ -366,7 +410,7 @@ describe('repos:add + repos:clone', () => {
}
mockStore.updateRepo.mockReturnValue(updated)
const result = handlers.get('repos:update')!(null, {
const result = await handlers.get('repos:update')!(null, {
repoId: updated.id,
updates: { agentWorktreeVisibility: 'show' }
})
@@ -377,7 +421,7 @@ describe('repos:add + repos:clone', () => {
})
})
it('validates source definitions and preferences through local repos:update', () => {
it('validates source definitions and preferences through local repos:update', async () => {
const updated = {
id: 'repo-source-visibility',
path: '/tmp/repo-source-visibility',
@@ -387,7 +431,7 @@ describe('repos:add + repos:clone', () => {
}
mockStore.updateRepo.mockReturnValue(updated)
handlers.get('repos:update')!(null, {
await handlers.get('repos:update')!(null, {
repoId: updated.id,
updates: {
customWorktreeVisibilitySources: [
@@ -410,7 +454,7 @@ describe('repos:add + repos:clone', () => {
})
})
it('prepares and invalidates roots when project host setup update changes worktree base path', () => {
it('prepares and invalidates roots when project host setup update changes worktree base path', async () => {
const repo = {
id: 'repo-setup-update-root',
path: '/tmp/repo-setup-update-root',
@@ -426,12 +470,12 @@ describe('repos:add + repos:clone', () => {
}
mockStore.updateProjectHostSetup.mockReturnValue(result)
expect(
await expect(
handlers.get('projectHostSetups:update')!(null, {
setupId: 'setup-1',
updates: { worktreeBasePath: '../worktrees' }
})
).toBe(result)
).resolves.toBe(result)
expect(prepareLocalWorktreeRootForRepoMock).toHaveBeenCalledWith(mockStore, repo)
expect(invalidateAuthorizedRootsCacheMock).toHaveBeenCalled()
+47 -31
View File
@@ -91,6 +91,7 @@ import { getActiveMultiplexer } from './ssh'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
import { track } from '../telemetry/client'
import { scheduleCurrentWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher'
import { getWorktreeBaseRepoIdentity } from './worktree-base-directory-watch-target-cache'
import { wakeFolderRepoGitUpgradeWatch } from './folder-repo-git-upgrade-wake'
import { getCohortAtEmit } from '../telemetry/cohort-classifier'
import type { RepoMethod } from '../../shared/telemetry-events'
@@ -1329,7 +1330,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
// Why one shared reference: enrichment dedupes coalesced callers by callback identity, so a fresh
// closure per list call would stack up (and re-broadcast) for the length of a slow sweep.
const broadcastReposChanged = (): void => notifyReposChanged(mainWindow)
const broadcastReposChanged = (): void => notifyReposChanged(mainWindow, null)
ipcMain.handle('repos:list', () => {
enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged })
@@ -1375,14 +1376,14 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if (!result) {
throw new Error(`Project not found: ${args.projectId}`)
}
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, null)
return result
}
)
ipcMain.handle(
'projectHostSetups:update',
(_event, rawArgs: ProjectHostSetupUpdateArgs): ProjectHostSetupUpdateResult => {
async (_event, rawArgs: ProjectHostSetupUpdateArgs): Promise<ProjectHostSetupUpdateResult> => {
const args = parseProjectGroupIpcArgs(
ProjectHostSetupUpdateIpcArgs,
rawArgs,
@@ -1393,10 +1394,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
throw new Error(`Project host setup not found: ${args.setupId}`)
}
if ('worktreeBasePath' in args.updates && result.repo) {
void prepareLocalWorktreeRootForRepo(store, result.repo)
await prepareLocalWorktreeRootForRepo(store, result.repo)
invalidateAuthorizedRootsCache()
}
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, result.repo ? [result.repo] : null)
return result
}
)
@@ -1413,7 +1414,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if (!result) {
throw new Error(`Project host setup not found: ${args.setupId}`)
}
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, result.repo ? [result.repo] : null)
return result
}
)
@@ -1468,11 +1469,11 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
throw err
}
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
emitRepoAdded('folder_picker', result.alreadyExisted)
if (result.alreadyExisted) {
await prepareLocalWorktreeRootForRepo(store, aligned.repo)
}
notifyReposChanged(mainWindow, [aligned.repo])
return aligned
}
)
@@ -1525,7 +1526,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
...args,
creatorProvenance: { kind: 'host' }
})
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, null)
return workspace
}
)
@@ -1564,7 +1565,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
const updated = store.updateFolderWorkspace(args.folderWorkspaceId, args.updates)
if (updated) {
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, null)
}
return updated
}
@@ -1578,7 +1579,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
)
const deleted = store.removeFolderWorkspace(args.folderWorkspaceId)
if (deleted) {
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, null)
}
return deleted
})
@@ -1596,7 +1597,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
parentGroupId: args.parentGroupId ?? null,
createdFrom: args.createdFrom ?? 'manual'
})
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, null)
return group
})
@@ -1608,7 +1609,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
)
const updated = store.updateProjectGroup(args.groupId, args.updates)
if (updated) {
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, null)
}
return updated
})
@@ -1621,7 +1622,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
)
const deleted = store.deleteProjectGroup(args.groupId)
if (deleted) {
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, null)
}
return deleted
})
@@ -1634,7 +1635,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
)
const moved = store.moveProjectToGroup(args.projectId, args.groupId, args.order)
if (moved) {
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, null)
}
return moved
})
@@ -1698,6 +1699,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
error: 'Repository was not found in the nested repo scan result'
})
)
const watchChangedRepos: Repo[] = []
const importedProjectIdsByRepoPath = new Map<string, string>()
const importTargetResolver = createNestedRepoImportTargetResolver()
@@ -1779,6 +1781,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
: {})
}
store.addRepo(repo)
watchChangedRepos.push(repo)
await prepareLocalWorktreeRootForRepo(store, repo)
if (args.connectionId) {
getActiveMultiplexer(args.connectionId)?.notify('session.registerRoot', {
@@ -1807,7 +1810,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
}
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, watchChangedRepos.length > 0 ? watchChangedRepos : null)
const rootGroup = groupResolver.getRootGroup()
return {
...(rootGroup && importedCount + alreadyKnownCount > 0 ? { group: rootGroup } : {}),
@@ -1833,7 +1836,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
await prepareLocalWorktreeRootForRepo(store, result.repo)
}
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, [result.repo])
emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git')
return { repo: result.repo }
}
@@ -1854,7 +1857,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if ('error' in result) {
return result
}
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, [result.repo])
emitRepoAdded('folder_picker', result.alreadyExisted, result.repo.kind === 'git')
return { repo: result.repo }
}
@@ -1875,7 +1878,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if ('error' in result) {
return result
}
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, [result.repo])
return result
}
)
@@ -2037,7 +2040,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
store.addRepo(repo)
await prepareLocalWorktreeRootForRepo(store, repo)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, [repo])
// Why: repos:create git-inits when kind is 'git', so repoKind is the true git-vs-folder signal.
emitRepoAdded('folder_picker', false, repoKind === 'git')
return { repo }
@@ -2051,7 +2054,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : []
const applied = store.reorderRepos(ids)
if (applied) {
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, null)
return { status: 'applied' }
}
return { status: 'rejected' }
@@ -2071,7 +2074,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
const ids = Array.isArray(args?.orderedIds) ? args.orderedIds : []
const applied = store.reorderReposForHost(ids, hostId)
if (applied) {
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, null)
return { status: 'applied' }
}
return { status: 'rejected' }
@@ -2079,9 +2082,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
)
ipcMain.handle('repos:remove', async (_event, args: { repoId: string }) => {
const removedRepo = store.getRepo(args.repoId)
store.removeProject(args.repoId)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, removedRepo ? [removedRepo] : null)
})
// Why: forget a project on one execution host without disturbing the same repo id on other hosts (SSH-workspace forget flow).
@@ -2092,15 +2096,18 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if (!hostId) {
throw new Error(`Invalid host ID: ${args.hostId}`)
}
const removedRepo = store
.getRepos()
.find((repo) => repo.id === args.repoId && getRepoExecutionHostId(repo) === hostId)
store.removeProjectForHost(args.repoId, hostId)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, removedRepo ? [removedRepo] : null)
}
)
ipcMain.handle(
'repos:update',
(
async (
_event,
args: {
repoId: string
@@ -2289,10 +2296,10 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
: store.updateRepo(args.repoId, updates)
if (updated) {
if ('worktreeBasePath' in updates) {
void prepareLocalWorktreeRootForRepo(store, updated)
await prepareLocalWorktreeRootForRepo(store, updated)
invalidateAuthorizedRootsCache()
}
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, [updated])
}
return updated
}
@@ -2528,7 +2535,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
if (updated) {
await prepareLocalWorktreeRootForRepo(store, updated)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, [updated])
// Why: folder→git upgrade is a real new git repo provisioning event.
emitRepoAdded('clone_url', false, true)
return updated
@@ -2554,7 +2561,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
store.addRepo(repo)
await prepareLocalWorktreeRootForRepo(store, repo)
invalidateAuthorizedRootsCache()
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, [repo])
emitRepoAdded('clone_url', false, true)
return repo
} finally {
@@ -2574,7 +2581,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
args: { connectionId: string; url: string; destination: string }
): Promise<Repo> => {
const repo = await cloneRemoteRepo(store, mainWindow, args)
notifyReposChanged(mainWindow)
notifyReposChanged(mainWindow, [repo])
return repo
}
)
@@ -2771,7 +2778,10 @@ function getRepoForExecutionHost(
)
}
export function notifyReposChanged(mainWindow: BrowserWindow): void {
export function notifyReposChanged(
mainWindow: BrowserWindow,
watcherChangedRepos?: readonly Repo[] | null
): void {
wakeFolderRepoGitUpgradeWatch()
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('repos:changed')
@@ -2784,7 +2794,13 @@ export function notifyReposChanged(mainWindow: BrowserWindow): void {
// Why: a broadcast failure must never fail the mutation the user actually asked for.
console.error('[repos] failed to notify remote clients of repo change', err)
}
scheduleCurrentWorktreeBaseDirectoryWatcherSync()
if (watcherChangedRepos !== null) {
scheduleCurrentWorktreeBaseDirectoryWatcherSync(
watcherChangedRepos
? { dirtyRepoIdentities: watcherChangedRepos.map(getWorktreeBaseRepoIdentity) }
: { fullRebuild: true }
)
}
}
function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void {
+34 -2
View File
@@ -11,7 +11,8 @@ const {
previewWarpThemeImportMock,
prepareLocalWorktreeRootsForReposMock,
resolveEnvironmentMock,
rebuildAppMenuMock
rebuildAppMenuMock,
scheduleWorktreeWatcherSyncMock
} = vi.hoisted(() => ({
applyAppIconMock: vi.fn(),
applyAgentStatusHooksEnabledMock: vi.fn(),
@@ -23,7 +24,8 @@ const {
previewWarpThemeImportMock: vi.fn(),
prepareLocalWorktreeRootsForReposMock: vi.fn(),
resolveEnvironmentMock: vi.fn(),
rebuildAppMenuMock: vi.fn()
rebuildAppMenuMock: vi.fn(),
scheduleWorktreeWatcherSyncMock: vi.fn()
}))
vi.mock('electron', () => ({
@@ -57,6 +59,10 @@ vi.mock('../worktree-root-preparation', () => ({
prepareLocalWorktreeRootsForRepos: prepareLocalWorktreeRootsForReposMock
}))
vi.mock('./worktree-base-directory-watcher', () => ({
scheduleCurrentWorktreeBaseDirectoryWatcherSync: scheduleWorktreeWatcherSyncMock
}))
vi.mock('../menu/register-app-menu', () => ({
rebuildAppMenu: rebuildAppMenuMock
}))
@@ -100,6 +106,7 @@ describe('registerSettingsHandlers', () => {
return { id: 'windows-2' }
})
rebuildAppMenuMock.mockClear()
scheduleWorktreeWatcherSyncMock.mockClear()
browserWindowGetAllWindowsMock.mockReset()
store.getSettings.mockReset()
store.updateSettings.mockReset()
@@ -395,6 +402,7 @@ describe('registerSettingsHandlers', () => {
await handler(settingsInvokeEvent, { workspaceDir: '/new/workspaces' })
expect(prepareLocalWorktreeRootsForReposMock).toHaveBeenCalledWith(store)
expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({ fullRebuild: true })
})
it('prepares local worktree roots when workspace nesting changes', async () => {
@@ -410,6 +418,29 @@ describe('registerSettingsHandlers', () => {
await handler(settingsInvokeEvent, { nestWorkspaces: true })
expect(prepareLocalWorktreeRootsForReposMock).toHaveBeenCalledWith(store)
expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({ fullRebuild: true })
})
it('waits for deferred root creation before scheduling a full watcher rebuild', async () => {
const preparation = Promise.withResolvers<void>()
store.getSettings.mockReturnValue({ workspaceDir: '/old/workspaces', nestWorkspaces: false })
store.updateSettings.mockReturnValue({ workspaceDir: '/new/workspaces', nestWorkspaces: false })
prepareLocalWorktreeRootsForReposMock.mockReturnValueOnce(preparation.promise)
registerSettingsHandlers(store as never)
const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as (
_event: unknown,
args: unknown
) => Promise<unknown>
const result = handler(settingsInvokeEvent, { workspaceDir: '/new/workspaces' })
await vi.waitFor(() =>
expect(prepareLocalWorktreeRootsForReposMock).toHaveBeenCalledWith(store)
)
expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled()
preparation.resolve()
await result
expect(scheduleWorktreeWatcherSyncMock).toHaveBeenCalledWith({ fullRebuild: true })
})
it('does not prepare local worktree roots when workspace layout values do not change', async () => {
@@ -425,6 +456,7 @@ describe('registerSettingsHandlers', () => {
await handler(settingsInvokeEvent, { workspaceDir: '/workspaces', nestWorkspaces: false })
expect(prepareLocalWorktreeRootsForReposMock).not.toHaveBeenCalled()
expect(scheduleWorktreeWatcherSyncMock).not.toHaveBeenCalled()
})
it('does not accept floating workspace trust grants from renderer settings IPC', async () => {
+2 -2
View File
@@ -232,8 +232,8 @@ export function registerSettingsHandlers(
('workspaceDir' in sanitizedArgs && before.workspaceDir !== result.workspaceDir) ||
('nestWorkspaces' in sanitizedArgs && before.nestWorkspaces !== result.nestWorkspaces)
) {
void prepareLocalWorktreeRootsForRepos(store)
scheduleCurrentWorktreeBaseDirectoryWatcherSync()
await prepareLocalWorktreeRootsForRepos(store)
scheduleCurrentWorktreeBaseDirectoryWatcherSync({ fullRebuild: true })
}
if (APPEARANCE_MENU_KEYS.some((key) => key in sanitizedArgs)) {
rebuildAppMenu()
@@ -30,6 +30,7 @@ export type WorktreeBaseWatchTarget = {
kind: WorktreeBaseWatchKind
path: string
connectionId?: string
providerGeneration?: number
repos: Map<string, WorktreeBaseRepoWatchConfig>
/** Exact upstream leaf selected by accepted active-worktree status. */
gitStatusRefPaths?: ReadonlySet<string>
@@ -0,0 +1,154 @@
import type { Store } from '../persistence'
import { getSshFilesystemProviderGeneration } from '../providers/ssh-filesystem-dispatch'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import { resolveWorktreeBaseDirectoryWatchTargetsForRepo } from './worktree-base-directory-watch-targets'
import { getWorktreePathSettings } from './worktree-logic'
export type WorktreeBaseRepoIdentity = {
repoId: string
hostId: string
}
export type WorktreeBaseDirectoryWatchTargetBuildOptions = {
fullRebuild?: boolean
dirtyRepoIdentities?: readonly WorktreeBaseRepoIdentity[]
dirtyConnectionIds?: readonly string[]
signal?: AbortSignal
}
type CachedRepoTargets = {
fingerprint: string
targets: Map<string, WorktreeBaseWatchTarget>
}
export type WorktreeBaseDirectoryWatchTargetRetry = {
identity: WorktreeBaseRepoIdentity
version: string
connectionId?: string
providerGeneration: number
}
export type WorktreeBaseDirectoryWatchTargetBuildResult = {
targets: Map<string, WorktreeBaseWatchTarget>
retries: WorktreeBaseDirectoryWatchTargetRetry[]
}
const repoTargetCache = new Map<string, CachedRepoTargets>()
export function getWorktreeBaseRepoIdentity(repo: Repo): WorktreeBaseRepoIdentity {
return {
repoId: repo.id,
hostId: getRepoExecutionHostId(repo)
}
}
function getIdentityKey(identity: WorktreeBaseRepoIdentity): string {
return `${identity.hostId}\0${identity.repoId}`
}
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw signal.reason ?? new Error('Worktree base directory watch target build aborted')
}
}
function getRepoTargetFingerprint(repo: Repo, settings: GlobalSettings): string {
const pathSettings = getWorktreePathSettings(repo, settings)
return JSON.stringify([
repo.id,
getRepoExecutionHostId(repo),
repo.path,
repo.kind ?? 'git',
repo.connectionId ?? null,
repo.worktreeBasePath ?? null,
pathSettings.workspaceDir,
pathSettings.nestWorkspaces,
repo.connectionId ? getSshFilesystemProviderGeneration(repo.connectionId) : 0
])
}
function mergeRepoTargets(
targets: Map<string, WorktreeBaseWatchTarget>,
repoTargets: Map<string, WorktreeBaseWatchTarget>
): void {
for (const [key, target] of repoTargets) {
const existing = targets.get(key)
if (existing) {
for (const [repoId, config] of target.repos) {
existing.repos.set(repoId, config)
}
continue
}
targets.set(key, { ...target, repos: new Map(target.repos) })
}
}
export async function buildWorktreeBaseDirectoryWatchTargets(
store: Store,
options: WorktreeBaseDirectoryWatchTargetBuildOptions = {}
): Promise<WorktreeBaseDirectoryWatchTargetBuildResult> {
const settings = store.getSettings()
const signal = options.signal
const liveRepos = new Map(
store
.getRepos()
.map((repo) => [getIdentityKey(getWorktreeBaseRepoIdentity(repo)), repo] as const)
)
const fullRebuild = options.fullRebuild === true || repoTargetCache.size === 0
const dirtyRepoKeys = new Set(options.dirtyRepoIdentities?.map(getIdentityKey) ?? [])
const dirtyConnectionIds = new Set(options.dirtyConnectionIds ?? [])
const nextCache = new Map<string, CachedRepoTargets>(repoTargetCache)
const retries: WorktreeBaseDirectoryWatchTargetRetry[] = []
for (const key of nextCache.keys()) {
if (!liveRepos.has(key)) {
nextCache.delete(key)
}
}
for (const [identityKey, repo] of liveRepos) {
throwIfAborted(signal)
const fingerprint = getRepoTargetFingerprint(repo, settings)
const cached = nextCache.get(identityKey)
const isDirty =
fullRebuild ||
dirtyRepoKeys.has(identityKey) ||
(repo.connectionId ? dirtyConnectionIds.has(repo.connectionId) : false) ||
cached?.fingerprint !== fingerprint
if (!isDirty) {
continue
}
const resolution = await resolveWorktreeBaseDirectoryWatchTargetsForRepo(repo, settings, signal)
throwIfAborted(signal)
if (resolution.transientFailure) {
retries.push({
identity: getWorktreeBaseRepoIdentity(repo),
version: fingerprint,
...(repo.connectionId ? { connectionId: repo.connectionId } : {}),
providerGeneration: repo.connectionId
? getSshFilesystemProviderGeneration(repo.connectionId)
: 0
})
continue
}
nextCache.set(identityKey, { fingerprint, targets: resolution.targets })
}
throwIfAborted(signal)
repoTargetCache.clear()
for (const [key, entry] of nextCache) {
repoTargetCache.set(key, entry)
}
const targets = new Map<string, WorktreeBaseWatchTarget>()
for (const entry of repoTargetCache.values()) {
mergeRepoTargets(targets, entry.targets)
}
return { targets, retries }
}
export function clearWorktreeBaseDirectoryWatchTargetCache(): void {
repoTargetCache.clear()
}
@@ -1,7 +1,6 @@
import { normalize } from 'node:path'
import { realpath, stat } from 'node:fs/promises'
import type { Stats } from 'node:fs'
import type { Store } from '../persistence'
import type { FileStat, IFilesystemProvider } from '../providers/types'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
@@ -15,14 +14,17 @@ import {
resolveRuntimePath
} from '../../shared/cross-platform-path'
import { isWslUncPath } from '../../shared/wsl-paths'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
getSshFilesystemProvider,
getSshFilesystemProviderGeneration
} from '../providers/ssh-filesystem-dispatch'
import {
computeWorkspaceRoot,
getWorktreePathSettings,
hasRepoWorktreeBasePath
} from './worktree-logic'
import { shouldEmitBoundedWarning } from './bounded-warning-dedupe'
import { resolveWorktreeCommonGitDirectory } from './worktree-common-git-directory'
import { probeWorktreeCommonGitDirectory } from './worktree-common-git-directory'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchKind,
@@ -31,6 +33,19 @@ import type {
const missingRootWarnings = new Set<string>()
const skippedWslWarnings = new Set<string>()
function throwIfAborted(signal: AbortSignal | undefined): void {
if (signal?.aborted) {
throw signal.reason ?? new Error('Worktree base directory watch target build aborted')
}
}
function isMissingPathError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
)
}
function normalizeWatchKey(pathValue: string): string {
return normalizeRuntimePathForComparison(normalize(pathValue))
@@ -70,9 +85,11 @@ async function addTarget(
kind: WorktreeBaseWatchKind,
pathValue: string,
config: WorktreeBaseRepoWatchConfig,
connectionId?: string
connectionId?: string,
signal?: AbortSignal
): Promise<void> {
const watchedPath = await canonicalizeExistingPath(pathValue, connectionId)
throwIfAborted(signal)
const key = `${kind}:${connectionId ?? 'local'}:${normalizeWatchKey(watchedPath)}`
const existing = targets.get(key)
if (existing) {
@@ -83,7 +100,12 @@ async function addTarget(
key,
kind,
path: watchedPath,
...(connectionId ? { connectionId } : {}),
...(connectionId
? {
connectionId,
providerGeneration: getSshFilesystemProviderGeneration(connectionId)
}
: {}),
repos: new Map([[config.repoId, config]])
})
}
@@ -125,8 +147,9 @@ async function maybeAddBaseTarget(
targets: Map<string, WorktreeBaseWatchTarget>,
repo: Repo,
settings: GlobalSettings,
connectionId?: string
): Promise<void> {
connectionId?: string,
signal?: AbortSignal
): Promise<boolean> {
const pathSettings = getWorktreePathSettings(repo, settings)
const { workspaceRoot, nestWorkspaces } = getBaseWatchLayout(repo, pathSettings, connectionId)
// Why: WSL UNC roots are unreliable for native watching; avoid project-level polling.
@@ -137,7 +160,7 @@ async function maybeAddBaseTarget(
`[worktree-base-watcher] skipping WSL worktree root watcher for ${workspaceRoot}`
)
}
return
return false
}
const config = {
@@ -147,23 +170,27 @@ async function maybeAddBaseTarget(
}
const remoteProvider = getRemoteProvider(connectionId)
if (connectionId && !remoteProvider) {
return
return true
}
let transientFailure = false
try {
const rootStat = remoteProvider
? await remoteProvider.stat(workspaceRoot)
: await stat(workspaceRoot)
if (isDirectoryStat(rootStat)) {
await addTarget(targets, 'base', workspaceRoot, config, connectionId)
await addTarget(targets, 'base', workspaceRoot, config, connectionId, signal)
}
} catch {
} catch (error) {
throwIfAborted(signal)
transientFailure = !isMissingPathError(error)
const key = normalizeWatchKey(workspaceRoot)
if (shouldEmitBoundedWarning(missingRootWarnings, key)) {
console.warn(`[worktree-base-watcher] worktree root unavailable: ${workspaceRoot}`)
}
}
throwIfAborted(signal)
const commonDir = await resolveWorktreeCommonGitDirectory(
const commonDirProbe = await probeWorktreeCommonGitDirectory(
repo,
remoteProvider
? {
@@ -172,28 +199,36 @@ async function maybeAddBaseTarget(
}
: undefined
)
if (commonDir) {
await addTarget(targets, 'git-common', commonDir, config, connectionId)
throwIfAborted(signal)
if (commonDirProbe.path) {
await addTarget(targets, 'git-common', commonDirProbe.path, config, connectionId, signal)
}
return transientFailure || commonDirProbe.transientFailure
}
export async function buildWorktreeBaseDirectoryWatchTargets(
store: Store
): Promise<Map<string, WorktreeBaseWatchTarget>> {
const settings = store.getSettings()
export type WorktreeBaseDirectoryRepoTargetResolution = {
targets: Map<string, WorktreeBaseWatchTarget>
transientFailure: boolean
}
export async function resolveWorktreeBaseDirectoryWatchTargetsForRepo(
repo: Repo,
settings: GlobalSettings,
signal: AbortSignal | undefined
): Promise<WorktreeBaseDirectoryRepoTargetResolution> {
const targets = new Map<string, WorktreeBaseWatchTarget>()
for (const repo of store.getRepos()) {
if (isFolderRepo(repo)) {
continue
}
const executionHostId = getRepoExecutionHostId(repo)
if (executionHostId === LOCAL_EXECUTION_HOST_ID) {
await maybeAddBaseTarget(targets, repo, settings)
} else if (repo.connectionId) {
await maybeAddBaseTarget(targets, repo, settings, repo.connectionId)
}
if (isFolderRepo(repo)) {
return { targets, transientFailure: false }
}
return targets
const executionHostId = getRepoExecutionHostId(repo)
const transientFailure =
executionHostId === LOCAL_EXECUTION_HOST_ID
? await maybeAddBaseTarget(targets, repo, settings, undefined, signal)
: repo.connectionId
? await maybeAddBaseTarget(targets, repo, settings, repo.connectionId, signal)
: false
throwIfAborted(signal)
return { targets, transientFailure }
}
export function clearWorktreeBaseDirectoryWatchTargetWarnings(): void {
@@ -0,0 +1,764 @@
import { realpath, stat } from 'node:fs/promises'
import { join, sep } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { GlobalSettings } from '../../shared/global-settings-types'
import type { Repo } from '../../shared/repo-types'
const { providerGenerations, providerGenerationListeners } = vi.hoisted(() => ({
providerGenerations: new Map<string, number>(),
providerGenerationListeners: new Set<(connectionId: string) => void>()
}))
vi.mock('fs/promises', () => ({
readFile: vi.fn(async () => ''),
realpath: vi.fn(async (path: string) => path),
stat: vi.fn(async () => ({ isDirectory: () => true }))
}))
vi.mock('./worktree-base-directory-poller', () => ({
createWorktreePollerWindowVisibility: vi.fn(() => ({
isWindowVisible: () => true,
onWindowBecameVisible: () => () => {}
})),
startWorktreeBaseDirectoryPoller: vi.fn()
}))
vi.mock('./worktree-remote', () => ({
notifyWorktreeGitStatusMetadataChanged: vi.fn(),
notifyWorktreeHeadIdentitiesChanged: vi.fn(),
notifyWorktreesChanged: vi.fn()
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn(),
getSshFilesystemProviderGeneration: vi.fn(
(connectionId: string) => providerGenerations.get(connectionId) ?? 0
),
onSshFilesystemProviderGenerationChanged: vi.fn((listener: (connectionId: string) => void) => {
providerGenerationListeners.add(listener)
return () => providerGenerationListeners.delete(listener)
})
}))
vi.mock('./worktree-head-identity-reader', () => ({
readGitCommonHeadIdentities: vi.fn(async () => [])
}))
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { startWorktreeBaseDirectoryPoller } from './worktree-base-directory-poller'
import { notifyWorktreesChanged } from './worktree-remote'
import {
disposeWorktreeBaseDirectoryWatchers,
scheduleWorktreeBaseDirectoryWatcherSync,
setWorktreeBaseDirectoryWatcherSyncContext,
syncWorktreeBaseDirectoryWatchers
} from './worktree-base-directory-watcher'
const absolutePath = (...parts: string[]): string => join(sep, ...parts)
const WORKTREE_ROOT = absolutePath('workspace', 'worktrees')
const settings = {
workspaceDir: WORKTREE_ROOT,
nestWorkspaces: true
} as GlobalSettings
const unsubscribeMocks = new Map<string, ReturnType<typeof vi.fn>>()
const pollerRepoGetters = new Map<string, () => ReadonlyMap<string, unknown>>()
function makeRepo(overrides: Partial<Repo> = {}): Repo {
return {
id: 'repo-1',
path: absolutePath('workspace', 'projects', 'project'),
displayName: 'Project',
badgeColor: '#000000',
addedAt: 1,
...overrides
} as Repo
}
function makeRepoFleet(): Repo[] {
return [
...Array.from({ length: 50 }, (_, index) =>
makeRepo({
id: `repo-${index}`,
path: absolutePath('workspace', 'projects', `local-${index}`)
})
),
...Array.from({ length: 25 }, (_, index) =>
makeRepo({ id: `repo-${index}`, path: `/srv/a/repo-${index}`, connectionId: 'ssh-a' })
),
...Array.from({ length: 25 }, (_, index) =>
makeRepo({
id: `repo-${index + 25}`,
path: `/srv/b/repo-${index + 25}`,
connectionId: 'ssh-b'
})
)
]
}
function makeStore(repos: Repo[]) {
return {
getSettings: () => settings,
getRepos: () => repos
}
}
function makeWindow(options: { destroyed?: () => boolean } = {}) {
return {
isDestroyed: () => options.destroyed?.() ?? false,
webContents: { send: vi.fn() }
}
}
function makeRemoteFilesystemProvider() {
const unwatch = vi.fn()
const callbacks = new Map<string, (events: never[]) => void>()
return {
stat: vi.fn(async () => ({ type: 'directory', size: 0, mtime: 0 })),
realpath: vi.fn(async (path: string) => path),
readFile: vi.fn(async () => ({ content: '', isBinary: false })),
watch: vi.fn(async (path: string, callback: (events: never[]) => void) => {
callbacks.set(path, callback)
return unwatch
}),
unwatch,
callbacks
}
}
describe('incremental worktree base directory watcher synchronization', () => {
beforeEach(() => {
vi.useFakeTimers()
settings.workspaceDir = WORKTREE_ROOT
settings.nestWorkspaces = true
unsubscribeMocks.clear()
pollerRepoGetters.clear()
providerGenerations.clear()
providerGenerationListeners.clear()
vi.mocked(stat).mockImplementation(async () => ({ isDirectory: () => true }) as never)
vi.mocked(getSshFilesystemProvider).mockReturnValue(undefined)
vi.mocked(realpath).mockImplementation(async (path) => String(path))
vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementation(async (target, getRepos) => {
const unsubscribe = vi.fn(async () => {})
unsubscribeMocks.set(target.path, unsubscribe)
pollerRepoGetters.set(target.path, getRepos)
return { unsubscribe }
})
})
afterEach(async () => {
await disposeWorktreeBaseDirectoryWatchers()
vi.useRealTimers()
vi.clearAllMocks()
})
function useFleetProviders() {
const providerA = makeRemoteFilesystemProvider()
const providerB = makeRemoteFilesystemProvider()
vi.mocked(getSshFilesystemProvider).mockImplementation((connectionId) =>
connectionId === 'ssh-a' ? (providerA as never) : (providerB as never)
)
return { providerA, providerB }
}
it('probes only one host-qualified repo in a 100-repo local and SSH fleet', async () => {
const repos = makeRepoFleet()
const store = makeStore(repos)
const mainWindow = makeWindow()
const { providerA, providerB } = useFleetProviders()
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
vi.mocked(stat).mockClear()
vi.mocked(realpath).mockClear()
providerA.stat.mockClear()
providerB.stat.mockClear()
repos[3] = { ...repos[3], path: absolutePath('workspace', 'renamed', 'local-3') }
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
dirtyRepoIdentities: [{ repoId: 'repo-3', hostId: 'local' }]
})
expect(stat).toHaveBeenCalledTimes(2)
expect(realpath).toHaveBeenCalledTimes(2)
expect(providerA.stat).not.toHaveBeenCalled()
expect(providerB.stat).not.toHaveBeenCalled()
})
it('removes only a deleted repo membership without re-probing the fleet', async () => {
const repos = makeRepoFleet()
const deletedPath = repos[3].path
const store = makeStore(repos)
const { providerA, providerB } = useFleetProviders()
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
vi.mocked(stat).mockClear()
providerA.stat.mockClear()
providerB.stat.mockClear()
repos.splice(3, 1)
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
dirtyRepoIdentities: [{ repoId: 'repo-3', hostId: 'local' }]
})
expect(stat).not.toHaveBeenCalled()
expect(providerA.stat).not.toHaveBeenCalled()
expect(providerB.stat).not.toHaveBeenCalled()
expect([...pollerRepoGetters.get(WORKTREE_ROOT)!().keys()]).toHaveLength(49)
expect(pollerRepoGetters.get(WORKTREE_ROOT)!().has('repo-3')).toBe(false)
expect(pollerRepoGetters.get(WORKTREE_ROOT)!().has('repo-4')).toBe(true)
expect(unsubscribeMocks.get(join(deletedPath, '.git'))).toHaveBeenCalledOnce()
expect(unsubscribeMocks.get(join(repos[3].path, '.git'))).not.toHaveBeenCalled()
})
it('fully rebuilds every target for root and nesting settings', async () => {
const repos = makeRepoFleet()
const store = makeStore(repos)
const { providerA, providerB } = useFleetProviders()
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
const clearProbeCounts = () => {
vi.mocked(stat).mockClear()
providerA.stat.mockClear()
providerB.stat.mockClear()
}
const expectFullProbe = () => {
expect(stat).toHaveBeenCalledTimes(100)
expect(providerA.stat).toHaveBeenCalledTimes(50)
expect(providerB.stat).toHaveBeenCalledTimes(50)
}
clearProbeCounts()
settings.nestWorkspaces = false
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
fullRebuild: true
})
expectFullProbe()
clearProbeCounts()
settings.workspaceDir = absolutePath('workspace', 'new-worktrees')
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
fullRebuild: true
})
expectFullProbe()
})
it('invalidates only a reconnected SSH host, then only a path-changed repo', async () => {
const repos = makeRepoFleet()
const store = makeStore(repos)
const mainWindow = makeWindow()
const { providerA, providerB } = useFleetProviders()
setWorktreeBaseDirectoryWatcherSyncContext(store as never, mainWindow as never)
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
vi.mocked(stat).mockClear()
providerA.stat.mockClear()
providerB.stat.mockClear()
providerA.watch.mockClear()
providerGenerations.set('ssh-a', 1)
for (const listener of providerGenerationListeners) {
listener('ssh-a')
}
await vi.advanceTimersByTimeAsync(100)
await vi.waitFor(() => expect(providerA.watch).toHaveBeenCalledTimes(26))
expect(providerA.stat).toHaveBeenCalledTimes(50)
expect(providerB.stat).not.toHaveBeenCalled()
expect(stat).not.toHaveBeenCalled()
providerA.stat.mockClear()
const changedIndex = repos.findIndex(
(repo) => repo.connectionId === 'ssh-b' && repo.id === 'repo-25'
)
repos[changedIndex] = { ...repos[changedIndex], path: '/srv/b/renamed-repo-25' }
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
dirtyRepoIdentities: [{ repoId: 'repo-25', hostId: 'ssh:ssh-b' }]
})
expect(providerA.stat).not.toHaveBeenCalled()
expect(providerB.stat).toHaveBeenCalledTimes(2)
expect(stat).not.toHaveBeenCalled()
})
it('retains local watches across a transient target probe and installs after retry', async () => {
const repo = makeRepo()
const store = makeStore([repo])
const mainWindow = makeWindow()
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
const oldRootUnsubscribe = unsubscribeMocks.get(WORKTREE_ROOT)
const recoveredRoot = absolutePath('workspace', 'recovered-worktrees')
let rootProbeFails = true
vi.mocked(stat).mockImplementation(async (path) => {
if (String(path) === recoveredRoot && rootProbeFails) {
throw Object.assign(new Error('temporarily unavailable'), { code: 'EACCES' })
}
return { isDirectory: () => true } as never
})
vi.mocked(startWorktreeBaseDirectoryPoller).mockClear()
repo.worktreeBasePath = recoveredRoot
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
})
expect(oldRootUnsubscribe).not.toHaveBeenCalled()
expect(startWorktreeBaseDirectoryPoller).not.toHaveBeenCalled()
rootProbeFails = false
await vi.advanceTimersByTimeAsync(250)
await vi.waitFor(() =>
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledWith(
expect.objectContaining({ path: recoveredRoot }),
expect.any(Function),
expect.any(Function),
expect.any(Object)
)
)
expect(oldRootUnsubscribe).toHaveBeenCalledOnce()
})
it('retains SSH watches across transient probes and retries the affected repo', async () => {
const repo = makeRepo({ connectionId: 'ssh-a', path: '/srv/a/project' })
const store = makeStore([repo])
const provider = makeRemoteFilesystemProvider()
vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never)
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
provider.stat.mockReset().mockRejectedValue(new Error('relay unavailable'))
provider.unwatch.mockClear()
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'ssh:ssh-a' }]
})
expect(provider.unwatch).not.toHaveBeenCalled()
provider.stat.mockResolvedValue({ type: 'directory', size: 0, mtime: 0 })
await vi.advanceTimersByTimeAsync(250)
await vi.waitFor(() => expect(provider.stat).toHaveBeenCalledTimes(4))
expect(provider.unwatch).not.toHaveBeenCalled()
})
it('keeps an old local watch until a path replacement subscribes', async () => {
const repo = makeRepo()
const store = makeStore([repo])
const mainWindow = makeWindow()
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
const oldRootUnsubscribe = unsubscribeMocks.get(WORKTREE_ROOT)
const replacementRoot = absolutePath('workspace', 'replacement-worktrees')
vi.mocked(startWorktreeBaseDirectoryPoller).mockRejectedValueOnce(
new Error('native watcher capacity')
)
repo.worktreeBasePath = replacementRoot
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
})
expect(oldRootUnsubscribe).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(250)
await vi.waitFor(() => expect(oldRootUnsubscribe).toHaveBeenCalledOnce())
})
it('keeps old SSH subscriptions until a replacement retry succeeds', async () => {
const repo = makeRepo({ connectionId: 'ssh-a', path: '/srv/a/project' })
const store = makeStore([repo])
const oldProvider = makeRemoteFilesystemProvider()
const newProvider = makeRemoteFilesystemProvider()
vi.mocked(getSshFilesystemProvider).mockReturnValue(oldProvider as never)
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
oldProvider.unwatch.mockClear()
newProvider.watch.mockRejectedValue(new Error('watch transport unavailable'))
vi.mocked(getSshFilesystemProvider).mockReturnValue(newProvider as never)
providerGenerations.set('ssh-a', 1)
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
dirtyConnectionIds: ['ssh-a']
})
expect(oldProvider.unwatch).not.toHaveBeenCalled()
expect(newProvider.watch).toHaveBeenCalledTimes(2)
newProvider.watch.mockResolvedValue(newProvider.unwatch)
await vi.advanceTimersByTimeAsync(250)
await vi.waitFor(() => expect(oldProvider.unwatch).toHaveBeenCalledTimes(2))
})
it('refreshes retained reconnect routing for shared-root membership and window changes', async () => {
const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/srv/a/one' })
const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/srv/a/two' })
const repos = [repoOne, repoTwo]
const store = makeStore(repos)
let firstWindowDestroyed = false
const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed })
const nextWindow = makeWindow()
const oldProvider = makeRemoteFilesystemProvider()
const replacementProvider = makeRemoteFilesystemProvider()
vi.mocked(getSshFilesystemProvider).mockReturnValue(oldProvider as never)
await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never)
repos.splice(0, 1)
repos.push(makeRepo({ id: 'repo-three', connectionId: 'ssh-a', path: '/srv/a/three' }))
replacementProvider.watch.mockRejectedValue(new Error('replacement unavailable'))
vi.mocked(getSshFilesystemProvider).mockReturnValue(replacementProvider as never)
providerGenerations.set('ssh-a', 1)
firstWindowDestroyed = true
await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, {
dirtyConnectionIds: ['ssh-a']
})
oldProvider.callbacks.get('/srv/a')?.([
{ kind: 'create', absolutePath: '/srv/a/external-worktree/.git' }
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two')
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-three')
expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-one')
})
it('refreshes changed-key retention after failed replacements exhaust', async () => {
const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/srv/a/one' })
const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/srv/a/two' })
const repos = [repoOne, repoTwo]
const store = makeStore(repos)
let firstWindowDestroyed = false
const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed })
const nextWindow = makeWindow()
const provider = makeRemoteFilesystemProvider()
vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never)
await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never)
repos.splice(
0,
repos.length,
{ ...repoTwo, path: '/srv/b/two' },
makeRepo({ id: 'repo-three', connectionId: 'ssh-a', path: '/srv/b/three' })
)
provider.watch.mockImplementation(async (path, callback) => {
if (path.startsWith('/srv/b')) {
throw new Error('replacement unavailable')
}
provider.callbacks.set(path, callback)
return provider.unwatch
})
firstWindowDestroyed = true
await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, {
dirtyConnectionIds: ['ssh-a']
})
await vi.advanceTimersByTimeAsync(250)
await vi.advanceTimersByTimeAsync(1_000)
await vi.advanceTimersByTimeAsync(4_000)
const exhaustedAttempts = provider.watch.mock.calls.length
await vi.advanceTimersByTimeAsync(10_000)
expect(provider.watch).toHaveBeenCalledTimes(exhaustedAttempts)
provider.callbacks.get('/srv/a')?.([
{ kind: 'create', absolutePath: '/srv/a/external-worktree/.git' }
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two')
expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-three')
expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-one')
})
it('unions distinct failed replacement memberships for one retained shared watch', async () => {
const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/srv/a/one' })
const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/srv/a/two' })
const repos = [repoOne, repoTwo]
const store = makeStore(repos)
let firstWindowDestroyed = false
const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed })
const nextWindow = makeWindow()
const provider = makeRemoteFilesystemProvider()
vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never)
await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never)
repos[0] = { ...repoOne, path: '/srv/b/one' }
repos[1] = { ...repoTwo, path: '/srv/c/two' }
provider.watch.mockImplementation(async (path, callback) => {
if (path.startsWith('/srv/b') || path.startsWith('/srv/c')) {
throw new Error('replacement unavailable')
}
provider.callbacks.set(path, callback)
return provider.unwatch
})
firstWindowDestroyed = true
await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, {
dirtyConnectionIds: ['ssh-a']
})
provider.callbacks.get('/srv/a')?.([
{ kind: 'create', absolutePath: '/srv/a/external-worktree/.git' }
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-one')
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two')
})
it('keeps inverse-merge failed replacements scoped to each old watch', async () => {
const repoOne = makeRepo({ id: 'repo-one', connectionId: 'ssh-a', path: '/old-a/one' })
const repoTwo = makeRepo({ id: 'repo-two', connectionId: 'ssh-a', path: '/old-b/two' })
const repos = [repoOne, repoTwo]
const store = makeStore(repos)
const provider = makeRemoteFilesystemProvider()
vi.mocked(getSshFilesystemProvider).mockReturnValue(provider as never)
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
repos[0] = { ...repoOne, path: '/new/one' }
repos[1] = { ...repoTwo, path: '/new/two' }
provider.watch.mockImplementation(async (path, callback) => {
if (path.startsWith('/new')) {
throw new Error('replacement unavailable')
}
provider.callbacks.set(path, callback)
return provider.unwatch
})
const nextWindow = makeWindow()
await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, {
dirtyConnectionIds: ['ssh-a']
})
provider.callbacks.get('/old-a')?.([
{ kind: 'create', absolutePath: '/old-a/external-worktree/.git' }
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-one')
expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-two')
vi.mocked(notifyWorktreesChanged).mockClear()
provider.callbacks.get('/old-b')?.([
{ kind: 'create', absolutePath: '/old-b/external-worktree/.git' }
] as never[])
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(nextWindow, 'repo-two')
expect(notifyWorktreesChanged).not.toHaveBeenCalledWith(expect.anything(), 'repo-one')
})
it('refreshes a same-version retry callback when the main window changes', async () => {
const repo = makeRepo()
const store = makeStore([repo])
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' })
vi.mocked(stat).mockImplementation(async (path) => {
if (String(path).endsWith('.git')) {
throw missing
}
return { isDirectory: () => true } as never
})
vi.mocked(startWorktreeBaseDirectoryPoller)
.mockRejectedValueOnce(new Error('watch unavailable in first window'))
.mockRejectedValueOnce(new Error('watch unavailable in next window'))
let firstWindowDestroyed = false
const firstWindow = makeWindow({ destroyed: () => firstWindowDestroyed })
const nextWindow = makeWindow()
await syncWorktreeBaseDirectoryWatchers(store as never, firstWindow as never)
await syncWorktreeBaseDirectoryWatchers(store as never, nextWindow as never, {
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
})
firstWindowDestroyed = true
await vi.advanceTimersByTimeAsync(250)
await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(3))
})
it('bounds repeated subscription failures', async () => {
const repo = makeRepo()
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' })
vi.mocked(stat).mockImplementation(async (path) => {
if (String(path).endsWith('.git')) {
throw missing
}
return { isDirectory: () => true } as never
})
vi.mocked(startWorktreeBaseDirectoryPoller).mockRejectedValue(new Error('watch denied'))
await syncWorktreeBaseDirectoryWatchers(makeStore([repo]) as never, makeWindow() as never)
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(250)
await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2))
await vi.advanceTimersByTimeAsync(1_000)
await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(3))
await vi.advanceTimersByTimeAsync(4_000)
await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(4))
await vi.advanceTimersByTimeAsync(10_000)
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(4)
})
it('cancels subscription retries after target deletion and disposal', async () => {
const repos = [makeRepo()]
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' })
vi.mocked(stat).mockImplementation(async (path) => {
if (String(path).endsWith('.git')) {
throw missing
}
return { isDirectory: () => true } as never
})
vi.mocked(startWorktreeBaseDirectoryPoller).mockRejectedValue(new Error('watch denied'))
const store = makeStore(repos)
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
repos.splice(0, 1)
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
dirtyRepoIdentities: [{ repoId: 'repo-1', hostId: 'local' }]
})
await vi.advanceTimersByTimeAsync(10_000)
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledOnce()
repos.push(makeRepo({ id: 'repo-dispose' }))
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
dirtyRepoIdentities: [{ repoId: 'repo-dispose', hostId: 'local' }]
})
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2)
await disposeWorktreeBaseDirectoryWatchers()
await vi.advanceTimersByTimeAsync(10_000)
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2)
})
it('cancels stale provider-generation retries', async () => {
const repo = makeRepo({ connectionId: 'ssh-a', path: '/srv/a/project' })
const store = makeStore([repo])
const failingProvider = makeRemoteFilesystemProvider()
failingProvider.watch.mockRejectedValue(new Error('watch unavailable'))
vi.mocked(getSshFilesystemProvider).mockReturnValue(failingProvider as never)
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
const currentProvider = makeRemoteFilesystemProvider()
providerGenerations.set('ssh-a', 1)
vi.mocked(getSshFilesystemProvider).mockReturnValue(currentProvider as never)
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
dirtyConnectionIds: ['ssh-a']
})
expect(currentProvider.watch).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(10_000)
expect(currentProvider.watch).toHaveBeenCalledTimes(2)
})
it('merges an aborted first invalidation into one non-overlapping trailing pass', async () => {
const repos = makeRepoFleet()
const store = makeStore(repos)
useFleetProviders()
await syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never)
const firstProbeGate = Promise.withResolvers<void>()
let activeProbes = 0
let maxActiveProbes = 0
let deferFirstProbe = true
vi.mocked(stat).mockClear()
vi.mocked(stat).mockImplementation(async () => {
activeProbes++
maxActiveProbes = Math.max(maxActiveProbes, activeProbes)
if (deferFirstProbe) {
deferFirstProbe = false
await firstProbeGate.promise
}
activeProbes--
return { isDirectory: () => true } as never
})
repos[0] = { ...repos[0], path: absolutePath('workspace', 'projects', 'repo-0-v1') }
const first = syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
dirtyRepoIdentities: [{ repoId: 'repo-0', hostId: 'local' }]
})
await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce())
repos[1] = { ...repos[1], path: absolutePath('workspace', 'projects', 'repo-1-v2') }
const second = syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
dirtyRepoIdentities: [{ repoId: 'repo-1', hostId: 'local' }]
})
repos[2] = { ...repos[2], path: absolutePath('workspace', 'projects', 'repo-2-v3') }
const third = syncWorktreeBaseDirectoryWatchers(store as never, makeWindow() as never, {
dirtyRepoIdentities: [{ repoId: 'repo-2', hostId: 'local' }]
})
firstProbeGate.resolve()
await Promise.all([first, second, third])
expect(stat).toHaveBeenCalledTimes(7)
expect(maxActiveProbes).toBe(1)
})
it('carries the active invalidation into a scheduled trailing pass', async () => {
const repos = makeRepoFleet()
const store = makeStore(repos)
const mainWindow = makeWindow()
useFleetProviders()
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
const firstProbeGate = Promise.withResolvers<void>()
let deferFirstProbe = true
vi.mocked(stat).mockClear()
vi.mocked(stat).mockImplementation(async () => {
if (deferFirstProbe) {
deferFirstProbe = false
await firstProbeGate.promise
}
return { isDirectory: () => true } as never
})
repos[0] = { ...repos[0], path: absolutePath('workspace', 'projects', 'repo-0-active') }
const active = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
dirtyRepoIdentities: [{ repoId: 'repo-0', hostId: 'local' }]
})
await vi.waitFor(() => expect(stat).toHaveBeenCalledOnce())
repos[1] = { ...repos[1], path: absolutePath('workspace', 'projects', 'repo-1-scheduled') }
scheduleWorktreeBaseDirectoryWatcherSync(store as never, mainWindow as never, {
dirtyRepoIdentities: [{ repoId: 'repo-1', hostId: 'local' }]
})
firstProbeGate.resolve()
await active
await vi.advanceTimersByTimeAsync(100)
await vi.waitFor(() => expect(stat).toHaveBeenCalledTimes(5))
})
it('cancels retry timers before waiting for a blocked synchronization to drain', async () => {
const repo = makeRepo()
const store = makeStore([repo])
const mainWindow = makeWindow()
const missing = Object.assign(new Error('missing'), { code: 'ENOENT' })
vi.mocked(stat).mockImplementation(async (path) => {
if (String(path).endsWith('.git')) {
throw missing
}
return { isDirectory: () => true } as never
})
const blockedInstall = Promise.withResolvers<{ unsubscribe: () => Promise<void> }>()
vi.mocked(startWorktreeBaseDirectoryPoller)
.mockRejectedValueOnce(new Error('initial watch unavailable'))
.mockImplementationOnce(async () => blockedInstall.promise)
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
const blockedSync = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
})
await vi.waitFor(() => expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2))
const disposal = disposeWorktreeBaseDirectoryWatchers()
await vi.advanceTimersByTimeAsync(250)
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2)
blockedInstall.resolve({ unsubscribe: vi.fn(async () => {}) })
await Promise.all([blockedSync, disposal])
await vi.advanceTimersByTimeAsync(10_000)
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledTimes(2)
})
it('cannot reinstall a stale path after its subscription completes', async () => {
const repo = makeRepo()
const store = makeStore([repo])
const mainWindow = makeWindow()
await syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never)
vi.mocked(startWorktreeBaseDirectoryPoller).mockClear()
const staleRoot = absolutePath('workspace', 'stale-worktrees')
const currentRoot = absolutePath('workspace', 'current-worktrees')
const staleUnsubscribe = vi.fn(async () => {})
const staleInstall = Promise.withResolvers<{ unsubscribe: () => Promise<void> }>()
vi.mocked(startWorktreeBaseDirectoryPoller).mockImplementationOnce(
async () => staleInstall.promise
)
repo.worktreeBasePath = staleRoot
const staleSync = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
})
await vi.waitFor(() =>
expect(startWorktreeBaseDirectoryPoller).toHaveBeenCalledWith(
expect.objectContaining({ path: staleRoot }),
expect.any(Function),
expect.any(Function),
expect.any(Object)
)
)
repo.worktreeBasePath = currentRoot
const currentSync = syncWorktreeBaseDirectoryWatchers(store as never, mainWindow as never, {
dirtyRepoIdentities: [{ repoId: repo.id, hostId: 'local' }]
})
staleInstall.resolve({ unsubscribe: staleUnsubscribe })
await Promise.all([staleSync, currentSync])
expect(staleUnsubscribe).toHaveBeenCalledOnce()
expect(pollerRepoGetters.has(staleRoot)).toBe(false)
expect(pollerRepoGetters.has(currentRoot)).toBe(true)
})
})
@@ -0,0 +1,72 @@
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
import { buildWorktreeBaseDirectoryWatchTargets } from './worktree-base-directory-watch-target-cache'
import type { WorktreeBaseDirectoryWatcherRetries } from './worktree-base-directory-watcher-retries'
import type { PendingWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher-sync-queue'
export type WorktreeBaseDirectoryWatchReplacementResult = 'ready' | 'failed' | 'stale'
export async function reconcileWorktreeBaseDirectoryWatchers(
request: PendingWorktreeBaseDirectoryWatcherSync,
signal: AbortSignal,
activeWatches: ReadonlyMap<string, WorktreeBaseWatchTarget>,
retries: WorktreeBaseDirectoryWatcherRetries,
replaceWatch: (
target: WorktreeBaseWatchTarget,
signal: AbortSignal
) => Promise<WorktreeBaseDirectoryWatchReplacementResult>,
removeWatch: (key: string) => Promise<void>,
refreshRetainedWatch: (key: string, repos: Map<string, WorktreeBaseRepoWatchConfig>) => void
): Promise<void> {
const buildResult = await buildWorktreeBaseDirectoryWatchTargets(request.store, {
fullRebuild: request.fullRebuild,
dirtyRepoIdentities: [...request.dirtyRepoIdentities.values()],
dirtyConnectionIds: [...request.dirtyConnectionIds],
signal
})
if (signal.aborted) {
return
}
const { targets } = buildResult
retries.reconcileIntent(targets, buildResult.retries, request.store, request.mainWindow)
const failedTargets: WorktreeBaseWatchTarget[] = []
for (const target of targets.values()) {
if (signal.aborted) {
return
}
const result = await replaceWatch(target, signal)
retries.recordSubscriptionResult(target, result, request.store, request.mainWindow)
if (result === 'failed') {
failedTargets.push(target)
}
}
for (const [key, watch] of activeWatches) {
if (signal.aborted) {
return
}
const failedReplacementRepos = new Map<string, WorktreeBaseRepoWatchConfig>()
for (const target of failedTargets) {
if (
target.kind === watch.kind &&
target.connectionId === watch.connectionId &&
[...target.repos.keys()].some((repoId) => watch.repos.has(repoId))
) {
for (const [repoId, config] of target.repos) {
if (watch.repos.has(repoId)) {
failedReplacementRepos.set(repoId, config)
}
}
}
}
if (!targets.has(key)) {
if (failedReplacementRepos.size > 0) {
refreshRetainedWatch(key, failedReplacementRepos)
} else {
await removeWatch(key)
}
}
}
}
@@ -0,0 +1,109 @@
import type { BrowserWindow } from 'electron'
import { getSshFilesystemProviderGeneration } from '../providers/ssh-filesystem-dispatch'
import type { Store } from '../persistence'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import {
getWorktreeBaseRepoIdentity,
type WorktreeBaseDirectoryWatchTargetRetry,
type WorktreeBaseRepoIdentity
} from './worktree-base-directory-watch-target-cache'
import { WorktreeBaseDirectoryWatcherRetryScheduler } from './worktree-base-directory-watcher-retry-scheduler'
export class WorktreeBaseDirectoryWatcherRetries {
private readonly scheduler = new WorktreeBaseDirectoryWatcherRetryScheduler()
private readonly desiredTargetVersions = new Map<string, string>()
constructor(
private readonly requestSync: (
store: Store,
mainWindow: BrowserWindow,
identities: WorktreeBaseRepoIdentity[]
) => void
) {}
private targetVersion(target: WorktreeBaseWatchTarget): string {
return `${target.providerGeneration ?? 0}\0${target.path}`
}
reconcileIntent(
targets: Map<string, WorktreeBaseWatchTarget>,
retries: WorktreeBaseDirectoryWatchTargetRetry[],
store: Store,
mainWindow: BrowserWindow
): void {
this.desiredTargetVersions.clear()
const validRetryKeys = new Set<string>()
for (const target of targets.values()) {
this.desiredTargetVersions.set(target.key, this.targetVersion(target))
validRetryKeys.add(`subscribe:${target.key}`)
}
for (const retry of retries) {
validRetryKeys.add(`resolve:${retry.identity.hostId}\0${retry.identity.repoId}`)
}
this.scheduler.retainKeys(validRetryKeys)
for (const retry of retries) {
const retryKey = `resolve:${retry.identity.hostId}\0${retry.identity.repoId}`
this.scheduler.schedule(retryKey, retry.version, () => {
const repoStillExists = store.getRepos().some((repo) => {
const repoHostId = getRepoExecutionHostId(repo)
return repo.id === retry.identity.repoId && repoHostId === retry.identity.hostId
})
const providerIsCurrent =
!retry.connectionId ||
getSshFilesystemProviderGeneration(retry.connectionId) === retry.providerGeneration
if (!repoStillExists || !providerIsCurrent || mainWindow.isDestroyed()) {
this.scheduler.clear(retryKey)
return
}
this.requestSync(store, mainWindow, [retry.identity])
})
}
}
recordSubscriptionResult(
target: WorktreeBaseWatchTarget,
result: 'ready' | 'failed' | 'stale',
store: Store,
mainWindow: BrowserWindow
): void {
const retryKey = `subscribe:${target.key}`
if (result === 'ready') {
this.scheduler.clear(retryKey)
return
}
if (result !== 'failed') {
return
}
const identities = store
.getRepos()
.filter((repo) => {
const identity = getWorktreeBaseRepoIdentity(repo)
return (
target.repos.has(repo.id) &&
(target.connectionId
? repo.connectionId === target.connectionId
: identity.hostId === 'local')
)
})
.map(getWorktreeBaseRepoIdentity)
const version = this.targetVersion(target)
this.scheduler.schedule(retryKey, version, () => {
if (this.desiredTargetVersions.get(target.key) !== version || mainWindow.isDestroyed()) {
this.scheduler.clear(retryKey)
return
}
this.requestSync(store, mainWindow, identities)
})
}
activate(): void {
this.scheduler.activate()
}
dispose(): void {
this.scheduler.dispose()
this.desiredTargetVersions.clear()
}
}
@@ -0,0 +1,74 @@
type RetryEntry = {
version: string
attempts: number
timer: NodeJS.Timeout | null
action: () => void
}
const RETRY_DELAYS_MS = [250, 1_000, 4_000] as const
export class WorktreeBaseDirectoryWatcherRetryScheduler {
private readonly entries = new Map<string, RetryEntry>()
private disposed = false
schedule(key: string, version: string, action: () => void): boolean {
if (this.disposed) {
return false
}
const previous = this.entries.get(key)
if (previous?.version === version && previous.timer) {
previous.action = action
return true
}
if (previous?.version !== version) {
clearTimeout(previous?.timer ?? undefined)
this.entries.delete(key)
}
const attempts = previous?.version === version ? previous.attempts : 0
if (attempts >= RETRY_DELAYS_MS.length) {
return false
}
const entry: RetryEntry = {
version,
attempts: attempts + 1,
timer: null,
action
}
entry.timer = setTimeout(() => {
if (this.disposed || this.entries.get(key) !== entry) {
return
}
entry.timer = null
entry.action()
}, RETRY_DELAYS_MS[attempts])
this.entries.set(key, entry)
return true
}
clear(key: string): void {
const entry = this.entries.get(key)
clearTimeout(entry?.timer ?? undefined)
this.entries.delete(key)
}
retainKeys(keys: ReadonlySet<string>): void {
for (const key of this.entries.keys()) {
if (!keys.has(key)) {
this.clear(key)
}
}
}
activate(): void {
this.disposed = false
}
dispose(): void {
this.disposed = true
for (const entry of this.entries.values()) {
clearTimeout(entry.timer ?? undefined)
}
this.entries.clear()
}
}
@@ -0,0 +1,148 @@
import type { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import type { WorktreeBaseRepoIdentity } from './worktree-base-directory-watch-target-cache'
export type WorktreeBaseDirectoryWatcherSyncRequest = {
fullRebuild?: boolean
dirtyRepoIdentities?: readonly WorktreeBaseRepoIdentity[]
dirtyConnectionIds?: readonly string[]
}
export type PendingWorktreeBaseDirectoryWatcherSync = {
store: Store
mainWindow: BrowserWindow
fullRebuild: boolean
dirtyRepoIdentities: Map<string, WorktreeBaseRepoIdentity>
dirtyConnectionIds: Set<string>
}
type RunSyncPass = (
request: PendingWorktreeBaseDirectoryWatcherSync,
signal: AbortSignal
) => Promise<void>
export class WorktreeBaseDirectoryWatcherSyncQueue {
private scheduledTimer: NodeJS.Timeout | null = null
private scheduledRequest: PendingWorktreeBaseDirectoryWatcherSync | null = null
private pendingRequest: PendingWorktreeBaseDirectoryWatcherSync | null = null
private flight: Promise<void> | null = null
private activeAbort: AbortController | null = null
private activeRequest: PendingWorktreeBaseDirectoryWatcherSync | null = null
constructor(private readonly runPass: RunSyncPass) {}
private mergeRequest(
current: PendingWorktreeBaseDirectoryWatcherSync | null,
store: Store,
mainWindow: BrowserWindow,
request: WorktreeBaseDirectoryWatcherSyncRequest | undefined
): PendingWorktreeBaseDirectoryWatcherSync {
const merged = current ?? {
store,
mainWindow,
fullRebuild: false,
dirtyRepoIdentities: new Map(),
dirtyConnectionIds: new Set()
}
merged.store = store
merged.mainWindow = mainWindow
merged.fullRebuild ||= request === undefined || request.fullRebuild === true
for (const identity of request?.dirtyRepoIdentities ?? []) {
merged.dirtyRepoIdentities.set(`${identity.hostId}\0${identity.repoId}`, identity)
}
for (const connectionId of request?.dirtyConnectionIds ?? []) {
merged.dirtyConnectionIds.add(connectionId)
}
return merged
}
private mergeActiveRequest(
current: PendingWorktreeBaseDirectoryWatcherSync | null
): PendingWorktreeBaseDirectoryWatcherSync | null {
if (!this.activeRequest) {
return current
}
return this.mergeRequest(current, this.activeRequest.store, this.activeRequest.mainWindow, {
fullRebuild: this.activeRequest.fullRebuild,
dirtyRepoIdentities: [...this.activeRequest.dirtyRepoIdentities.values()],
dirtyConnectionIds: [...this.activeRequest.dirtyConnectionIds]
})
}
private ensureFlight(): Promise<void> {
if (!this.flight) {
this.flight = (async () => {
while (this.pendingRequest) {
const request = this.pendingRequest
this.pendingRequest = null
this.activeRequest = request
const controller = new AbortController()
this.activeAbort = controller
try {
await this.runPass(request, controller.signal)
} finally {
if (this.activeRequest === request) {
this.activeRequest = null
}
if (this.activeAbort === controller) {
this.activeAbort = null
}
}
}
})().finally(() => {
this.flight = null
if (this.pendingRequest) {
void this.ensureFlight()
}
})
}
return this.flight
}
async sync(
store: Store,
mainWindow: BrowserWindow,
request?: WorktreeBaseDirectoryWatcherSyncRequest
): Promise<void> {
this.pendingRequest = this.mergeActiveRequest(this.pendingRequest)
this.pendingRequest = this.mergeRequest(this.pendingRequest, store, mainWindow, request)
this.activeAbort?.abort(new Error('Superseded by a newer worktree watcher synchronization'))
do {
await this.ensureFlight()
} while (this.pendingRequest || this.flight)
}
schedule(
store: Store,
mainWindow: BrowserWindow,
request?: WorktreeBaseDirectoryWatcherSyncRequest
): void {
this.scheduledRequest = this.mergeActiveRequest(this.scheduledRequest)
this.scheduledRequest = this.mergeRequest(this.scheduledRequest, store, mainWindow, request)
this.activeAbort?.abort(new Error('Superseded by a scheduled worktree watcher synchronization'))
clearTimeout(this.scheduledTimer ?? undefined)
this.scheduledTimer = setTimeout(() => {
this.scheduledTimer = null
const nextRequest = this.scheduledRequest
this.scheduledRequest = null
if (!nextRequest || nextRequest.mainWindow.isDestroyed()) {
return
}
void this.sync(nextRequest.store, nextRequest.mainWindow, {
fullRebuild: nextRequest.fullRebuild,
dirtyRepoIdentities: [...nextRequest.dirtyRepoIdentities.values()],
dirtyConnectionIds: [...nextRequest.dirtyConnectionIds]
})
}, 100)
}
async dispose(): Promise<void> {
this.pendingRequest = null
this.scheduledRequest = null
this.activeAbort?.abort(new Error('Worktree base directory watchers disposed'))
if (this.scheduledTimer) {
clearTimeout(this.scheduledTimer)
this.scheduledTimer = null
}
await this.flight
}
}
@@ -0,0 +1,67 @@
import type { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import { onSshFilesystemProviderGenerationChanged } from '../providers/ssh-filesystem-dispatch'
import {
WorktreeBaseDirectoryWatcherSyncQueue,
type PendingWorktreeBaseDirectoryWatcherSync,
type WorktreeBaseDirectoryWatcherSyncRequest
} from './worktree-base-directory-watcher-sync-queue'
export type WorktreeBaseDirectoryWatcherSync = {
sync: (
store: Store,
mainWindow: BrowserWindow,
request?: WorktreeBaseDirectoryWatcherSyncRequest
) => Promise<void>
setContext: (store: Store, mainWindow: BrowserWindow) => void
schedule: (
store: Store,
mainWindow: BrowserWindow,
request?: WorktreeBaseDirectoryWatcherSyncRequest
) => void
scheduleCurrent: (request?: WorktreeBaseDirectoryWatcherSyncRequest) => void
dispose: () => Promise<void>
}
export function createWorktreeBaseDirectoryWatcherSync(
runPass: (request: PendingWorktreeBaseDirectoryWatcherSync, signal: AbortSignal) => Promise<void>
): WorktreeBaseDirectoryWatcherSync {
const queue = new WorktreeBaseDirectoryWatcherSyncQueue(runPass)
let latestContext: { mainWindow: BrowserWindow; store: Store } | null = null
let unsubscribeFromProviderGenerationChanges: (() => void) | null = null
const scheduleCurrent = (request?: WorktreeBaseDirectoryWatcherSyncRequest): void => {
if (!latestContext || latestContext.mainWindow.isDestroyed()) {
return
}
queue.schedule(latestContext.store, latestContext.mainWindow, request)
}
return {
sync: (store, mainWindow, request) => queue.sync(store, mainWindow, request),
setContext: (store, mainWindow) => {
latestContext = { store, mainWindow }
unsubscribeFromProviderGenerationChanges ??= onSshFilesystemProviderGenerationChanged(
(connectionId) => scheduleCurrent({ dirtyConnectionIds: [connectionId] })
)
// Why: lean BrowserWindow test doubles omit once; real windows clear stale chrome authority.
if (typeof mainWindow.once === 'function') {
mainWindow.once('closed', () => {
if (latestContext?.mainWindow === mainWindow) {
latestContext = null
}
})
}
},
schedule: (store, mainWindow, request) => queue.schedule(store, mainWindow, request),
scheduleCurrent,
dispose: async () => {
latestContext = null
unsubscribeFromProviderGenerationChanges?.()
unsubscribeFromProviderGenerationChanges = null
await queue.dispose()
}
}
}
export type { WorktreeBaseDirectoryWatcherSyncRequest }
@@ -28,7 +28,9 @@ vi.mock('./worktree-remote', () => ({
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: vi.fn()
getSshFilesystemProvider: vi.fn(),
getSshFilesystemProviderGeneration: vi.fn(() => 0),
onSshFilesystemProviderGenerationChanged: vi.fn(() => () => {})
}))
vi.mock('./worktree-head-identity-reader', () => ({
+88 -89
View File
@@ -1,5 +1,4 @@
import type { BrowserWindow } from 'electron'
import type { Store } from '../persistence'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import {
createWorktreeHeadIdentityRefreshState,
@@ -17,10 +16,15 @@ import {
supportsWorktreeHeadIdentityRefresh
} from './worktree-base-directory-notifications'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import { clearWorktreeBaseDirectoryWatchTargetCache } from './worktree-base-directory-watch-target-cache'
import { clearWorktreeBaseDirectoryWatchTargetWarnings } from './worktree-base-directory-watch-targets'
import {
buildWorktreeBaseDirectoryWatchTargets,
clearWorktreeBaseDirectoryWatchTargetWarnings
} from './worktree-base-directory-watch-targets'
reconcileWorktreeBaseDirectoryWatchers,
type WorktreeBaseDirectoryWatchReplacementResult
} from './worktree-base-directory-watcher-reconciler'
import { WorktreeBaseDirectoryWatcherRetries } from './worktree-base-directory-watcher-retries'
import { createWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher-sync'
import type { PendingWorktreeBaseDirectoryWatcherSync } from './worktree-base-directory-watcher-sync-queue'
import {
createWorktreePollerWindowVisibility,
startWorktreeBaseDirectoryPoller
@@ -49,9 +53,7 @@ type ActiveWatch = WorktreeBaseWatchTarget & {
}
const activeWatches = new Map<string, ActiveWatch>()
let syncGeneration = 0
let scheduledSync: ReturnType<typeof setTimeout> | null = null
let latestSyncContext: { mainWindow: BrowserWindow; store: Store } | null = null
export function setWorktreeGitStatusRefWatch(
args: GitStatusRefBindingRequest,
resolveUpstreamRef: (signal: AbortSignal) => Promise<string | undefined>
@@ -197,31 +199,57 @@ async function subscribeTarget(
return activeWatch
}
type ReplaceWatchResult = WorktreeBaseDirectoryWatchReplacementResult
async function disposeWatch(watch: ActiveWatch): Promise<void> {
watch.disposed = true
clearTimeout(watch.notifyTimer ?? undefined)
clearPendingWorktreeBaseNotifications(watch)
await watch.subscription.unsubscribe().catch((error) => {
console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error)
})
}
async function replaceWatch(
target: WorktreeBaseWatchTarget,
mainWindow: BrowserWindow,
generation: number
): Promise<void> {
signal: AbortSignal
): Promise<ReplaceWatchResult> {
const previous = activeWatches.get(target.key)
if (previous) {
// Keep the surviving subscription's routing current while a replacement is pending.
previous.repos = target.repos
previous.mainWindow = mainWindow
applyActiveGitStatusRefBinding(previous)
return
if (previous.providerGeneration === target.providerGeneration) {
return 'ready'
}
}
try {
const activeWatch = await subscribeTarget(target, mainWindow)
if (generation !== syncGeneration) {
activeWatch.disposed = true
await activeWatch.subscription.unsubscribe().catch((error) => {
console.warn(`[worktree-base-watcher] failed to unwatch stale ${target.path}:`, error)
})
return
if (signal.aborted) {
await disposeWatch(activeWatch)
return 'stale'
}
applyActiveGitStatusRefBinding(activeWatch)
activeWatches.set(target.key, activeWatch)
if (previous) {
await disposeWatch(previous)
if (signal.aborted) {
if (activeWatches.get(target.key) === activeWatch) {
activeWatches.delete(target.key)
}
await disposeWatch(activeWatch)
return 'stale'
}
}
return 'ready'
} catch (error) {
console.warn(`[worktree-base-watcher] failed to watch ${target.path}:`, error)
if (!signal.aborted) {
console.warn(`[worktree-base-watcher] failed to watch ${target.path}:`, error)
return 'failed'
}
return 'stale'
}
}
@@ -231,92 +259,63 @@ async function removeWatch(key: string): Promise<void> {
return
}
activeWatches.delete(key)
watch.disposed = true
clearTimeout(watch.notifyTimer ?? undefined)
clearPendingWorktreeBaseNotifications(watch)
await watch.subscription.unsubscribe().catch((error) => {
console.warn(`[worktree-base-watcher] failed to unwatch ${watch.path}:`, error)
})
await disposeWatch(watch)
}
export async function syncWorktreeBaseDirectoryWatchers(
store: Store,
function refreshRetainedWatch(
key: string,
repos: WorktreeBaseWatchTarget['repos'],
mainWindow: BrowserWindow
): void {
const watch = activeWatches.get(key)
if (!watch) {
return
}
watch.repos = repos
watch.mainWindow = mainWindow
applyActiveGitStatusRefBinding(watch)
}
async function runSyncPass(
request: PendingWorktreeBaseDirectoryWatcherSync,
signal: AbortSignal
): Promise<void> {
const generation = ++syncGeneration
const targets = await buildWorktreeBaseDirectoryWatchTargets(store)
if (generation !== syncGeneration) {
watcherRetries.activate()
if (request.mainWindow.isDestroyed()) {
return
}
for (const key of activeWatches.keys()) {
if (generation !== syncGeneration) {
return
}
if (!targets.has(key)) {
await removeWatch(key)
if (generation !== syncGeneration) {
return
}
}
}
for (const target of targets.values()) {
if (generation !== syncGeneration) {
return
}
await replaceWatch(target, mainWindow, generation)
if (generation !== syncGeneration) {
return
try {
await reconcileWorktreeBaseDirectoryWatchers(
request,
signal,
activeWatches,
watcherRetries,
(target, replacementSignal) => replaceWatch(target, request.mainWindow, replacementSignal),
removeWatch,
(key, repos) => refreshRetainedWatch(key, repos, request.mainWindow)
)
} catch (error) {
if (!signal.aborted) {
console.warn('[worktree-base-watcher] failed to synchronize watch targets:', error)
}
}
}
export function setWorktreeBaseDirectoryWatcherSyncContext(
store: Store,
mainWindow: BrowserWindow
): void {
latestSyncContext = { store, mainWindow }
// Why: older integration tests use lean BrowserWindow stubs; real windows still
// clear this context on close so stale watcher syncs cannot target dead chrome.
if (typeof mainWindow.once === 'function') {
mainWindow.once('closed', () => {
if (latestSyncContext?.mainWindow === mainWindow) {
latestSyncContext = null
}
})
}
}
const watcherSync = createWorktreeBaseDirectoryWatcherSync(runSyncPass)
const watcherRetries = new WorktreeBaseDirectoryWatcherRetries((store, mainWindow, identities) => {
void watcherSync.sync(store, mainWindow, { dirtyRepoIdentities: identities })
})
export function scheduleWorktreeBaseDirectoryWatcherSync(
store: Store,
mainWindow: BrowserWindow
): void {
if (scheduledSync) {
clearTimeout(scheduledSync)
}
scheduledSync = setTimeout(() => {
scheduledSync = null
if (mainWindow.isDestroyed()) {
return
}
void syncWorktreeBaseDirectoryWatchers(store, mainWindow)
}, 100)
}
export function scheduleCurrentWorktreeBaseDirectoryWatcherSync(): void {
if (!latestSyncContext || latestSyncContext.mainWindow.isDestroyed()) {
return
}
scheduleWorktreeBaseDirectoryWatcherSync(latestSyncContext.store, latestSyncContext.mainWindow)
}
export const syncWorktreeBaseDirectoryWatchers = watcherSync.sync
export const setWorktreeBaseDirectoryWatcherSyncContext = watcherSync.setContext
export const scheduleWorktreeBaseDirectoryWatcherSync = watcherSync.schedule
export const scheduleCurrentWorktreeBaseDirectoryWatcherSync = watcherSync.scheduleCurrent
export async function disposeWorktreeBaseDirectoryWatchers(): Promise<void> {
syncGeneration++
latestSyncContext = null
clearActiveGitStatusRefBinding()
if (scheduledSync) {
clearTimeout(scheduledSync)
scheduledSync = null
}
watcherRetries.dispose()
await watcherSync.dispose()
await Promise.all([...activeWatches.keys()].map((key) => removeWatch(key)))
clearWorktreeBaseDirectoryWatchTargetCache()
clearWorktreeBaseDirectoryWatchTargetWarnings()
}
+25 -9
View File
@@ -34,33 +34,49 @@ function runtimeDirname(pathValue: string): string {
}
return normalized.slice(0, index)
}
export type WorktreeCommonGitDirectoryProbe = {
path: string | null
transientFailure: boolean
}
export async function resolveWorktreeCommonGitDirectory(
function isMissingPathError(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
(error.code === 'ENOENT' || error.code === 'ENOTDIR')
)
}
export async function probeWorktreeCommonGitDirectory(
repo: Repo,
access: GitDirectoryAccess = {}
): Promise<string | null> {
): Promise<WorktreeCommonGitDirectoryProbe> {
const dotGitPath = resolveRuntimePath(repo.path, '.git')
const statPath = access.stat ?? stat
const readText = access.readFile ?? ((path: string) => readFile(path, 'utf8'))
try {
const dotGitStat = await statPath(dotGitPath)
if (isDirectoryStat(dotGitStat)) {
return dotGitPath
return { path: dotGitPath, transientFailure: false }
}
if (!isFileStat(dotGitStat)) {
return null
return { path: null, transientFailure: false }
}
const content = await readText(dotGitPath)
const gitDir = content.match(/^gitdir:\s*(.+)\s*$/m)?.[1]?.trim()
if (!gitDir) {
return null
return { path: null, transientFailure: false }
}
const resolvedGitDir = resolveRuntimePath(repo.path, gitDir)
return getRuntimePathBasename(runtimeDirname(resolvedGitDir)) === 'worktrees'
? runtimeDirname(runtimeDirname(resolvedGitDir))
: resolvedGitDir
return {
path:
getRuntimePathBasename(runtimeDirname(resolvedGitDir)) === 'worktrees'
? runtimeDirname(runtimeDirname(resolvedGitDir))
: resolvedGitDir,
transientFailure: false
}
} catch (error) {
console.warn(`[worktree-base-watcher] cannot resolve git common dir for ${repo.id}:`, error)
return null
return { path: null, transientFailure: !isMissingPathError(error) }
}
}
@@ -2,6 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import {
getSshFilesystemProvider,
getSshFilesystemProviderGeneration,
onSshFilesystemProviderGenerationChanged,
onSshFilesystemProviderRegistered,
registerSshFilesystemProvider,
unregisterSshFilesystemProvider
@@ -66,4 +68,28 @@ describe('onSshFilesystemProviderRegistered', () => {
unregisterSshFilesystemProvider('conn-4')
warnSpy.mockRestore()
})
it('advances the provider generation across reconnect and disconnect boundaries', () => {
const before = getSshFilesystemProviderGeneration('conn-generation')
registerSshFilesystemProvider('conn-generation', provider)
const registered = getSshFilesystemProviderGeneration('conn-generation')
registerSshFilesystemProvider('conn-generation', {} as IFilesystemProvider)
const reconnected = getSshFilesystemProviderGeneration('conn-generation')
unregisterSshFilesystemProvider('conn-generation')
expect(registered).toBe(before + 1)
expect(reconnected).toBe(registered + 1)
expect(getSshFilesystemProviderGeneration('conn-generation')).toBe(reconnected + 1)
})
it('notifies generation subscribers for both reconnect and disconnect', () => {
const listener = vi.fn()
const unsubscribe = onSshFilesystemProviderGenerationChanged(listener)
registerSshFilesystemProvider('conn-lifecycle', provider)
unregisterSshFilesystemProvider('conn-lifecycle')
expect(listener).toHaveBeenNthCalledWith(1, 'conn-lifecycle')
expect(listener).toHaveBeenNthCalledWith(2, 'conn-lifecycle')
unsubscribe()
})
})
+32 -1
View File
@@ -1,6 +1,7 @@
import type { IFilesystemProvider } from './types'
const sshProviders = new Map<string, IFilesystemProvider>()
const sshProviderGenerations = new Map<string, number>()
export const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE =
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
@@ -8,6 +9,27 @@ export const SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE =
// Why: a reconnect builds a fresh provider, so anything holding remote state tied to the old
// transport (file watches) needs a signal to rebuild it — nothing else marks that boundary.
const registrationListeners = new Set<(connectionId: string) => void>()
const generationChangeListeners = new Set<(connectionId: string) => void>()
function notifyGenerationChanged(connectionId: string): void {
for (const listener of generationChangeListeners) {
try {
listener(connectionId)
} catch (error) {
// Why: provider lifecycle must not fail because a cache invalidator threw.
console.warn('[ssh-filesystem] provider generation listener failed:', error)
}
}
}
export function onSshFilesystemProviderGenerationChanged(
listener: (connectionId: string) => void
): () => void {
generationChangeListeners.add(listener)
return () => {
generationChangeListeners.delete(listener)
}
}
export function onSshFilesystemProviderRegistered(
listener: (connectionId: string) => void
@@ -22,7 +44,9 @@ export function registerSshFilesystemProvider(
connectionId: string,
provider: IFilesystemProvider
): void {
sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1)
sshProviders.set(connectionId, provider)
notifyGenerationChanged(connectionId)
for (const listener of registrationListeners) {
try {
listener(connectionId)
@@ -34,13 +58,20 @@ export function registerSshFilesystemProvider(
}
export function unregisterSshFilesystemProvider(connectionId: string): void {
sshProviders.delete(connectionId)
if (sshProviders.delete(connectionId)) {
sshProviderGenerations.set(connectionId, (sshProviderGenerations.get(connectionId) ?? 0) + 1)
notifyGenerationChanged(connectionId)
}
}
export function getSshFilesystemProvider(connectionId: string): IFilesystemProvider | undefined {
return sshProviders.get(connectionId)
}
export function getSshFilesystemProviderGeneration(connectionId: string): number {
return sshProviderGenerations.get(connectionId) ?? 0
}
export function requireSshFilesystemProvider(connectionId: string): IFilesystemProvider {
const provider = getSshFilesystemProvider(connectionId)
if (!provider) {