diff --git a/config/scripts/check-id-only-store-write-ratchet.mjs b/config/scripts/check-id-only-store-write-ratchet.mjs index 6ee1118666a..93e760e174c 100644 --- a/config/scripts/check-id-only-store-write-ratchet.mjs +++ b/config/scripts/check-id-only-store-write-ratchet.mjs @@ -21,6 +21,7 @@ const MEMBER = /** `Class.param` first parameters whose id has no host twin, with the reason. */ export const NOT_HOST_TWINNED_PARAMS = new Map([ ['AutomationPersistence.automationId', 'automation ids are minted UUIDs'], + ['HostRepoCatalogSnapshotPersistence.environmentId', 'the id names the host itself'], ['OrcadSourceExportPersistence.targetId', 'the id names the host itself'], ['RemoteLayoutSnapshotPersistence.environmentId', 'the id names the host itself'], ['SessionHostPartitionOperations.hostId', 'the id names the host itself'], diff --git a/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-persistence.ts b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-persistence.ts new file mode 100644 index 00000000000..6642c8fbbae --- /dev/null +++ b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-persistence.ts @@ -0,0 +1,46 @@ +import type { HostRepoCatalogSnapshot } from '../../shared/host-repo-catalog-snapshot' +import type { StoreRuntimeState } from '../persistence/loading-store/store-runtime-state' +import type { WriteSchedulingOperations } from '../persistence/loading-store/write-scheduling' +import { scheduleSave } from '../persistence/loading-store/write-scheduling' + +type HostRepoCatalogSnapshotRuntime = Pick + +const hostRepoCatalogSnapshotPersistenceContext = Symbol('HostRepoCatalogSnapshotPersistence') +type HostRepoCatalogSnapshotPersistenceContext = { + runtime: HostRepoCatalogSnapshotRuntime + scheduling: WriteSchedulingOperations +} + +export class HostRepoCatalogSnapshotPersistence { + readonly [hostRepoCatalogSnapshotPersistenceContext]: HostRepoCatalogSnapshotPersistenceContext + + constructor(runtime: HostRepoCatalogSnapshotRuntime, scheduling: WriteSchedulingOperations) { + this[hostRepoCatalogSnapshotPersistenceContext] = { runtime, scheduling } + } + + getHostRepoCatalogSnapshots(): Record { + return ( + this[hostRepoCatalogSnapshotPersistenceContext].runtime.state.hostRepoCatalogSnapshots ?? {} + ) + } + + /** Null forgets the host. Saves only on change: every catalog refresh lands here. */ + setHostRepoCatalogSnapshot( + environmentId: string, + snapshot: HostRepoCatalogSnapshot | null + ): void { + const { runtime, scheduling } = this[hostRepoCatalogSnapshotPersistenceContext] + const { [environmentId]: previous, ...others } = runtime.state.hostRepoCatalogSnapshots ?? {} + const unchanged = + snapshot === null + ? previous === undefined + : previous !== undefined && JSON.stringify(previous) === JSON.stringify(snapshot) + if (unchanged) { + return + } + runtime.state.hostRepoCatalogSnapshots = snapshot + ? { ...others, [environmentId]: snapshot } + : others + scheduleSave(scheduling, ['hostRepoCatalogSnapshots']) + } +} diff --git a/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder.ts b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder.ts new file mode 100644 index 00000000000..5c403b5d89e --- /dev/null +++ b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder.ts @@ -0,0 +1,53 @@ +// Keep a WSL host's project list from the catalog answers the window already asks for; never ask the host itself. + +import { + hostRepoCatalogSnapshotFromRepoList, + type HostRepoCatalogSnapshot +} from '../../shared/host-repo-catalog-snapshot' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { + isWslHostRuntimeEnvironment, + type OrcadDeploymentLink +} from '../../shared/runtime-environments' + +export type HostRepoCatalogSnapshotStore = { + getHostRepoCatalogSnapshots(): Record + setHostRepoCatalogSnapshot(environmentId: string, snapshot: HostRepoCatalogSnapshot | null): void +} + +type SavedEnvironment = { id: string; orcadDeployment?: OrcadDeploymentLink | null } + +/** Paired servers and recipe VMs are out of scope: only a WSL host is routinely stopped while saved. */ +export function recordHostRepoCatalogAnswer( + store: Pick, + environment: SavedEnvironment, + method: string, + response: RuntimeRpcResponse +): void { + if (method !== 'repo.list' || !response.ok || !isWslHostRuntimeEnvironment(environment)) { + return + } + const snapshot = hostRepoCatalogSnapshotFromRepoList(response.result) + if (snapshot) { + store.setHostRepoCatalogSnapshot(environment.id, snapshot) + } +} + +/** Saved snapshots of hosts still saved; a host removed outside Settings must not list again. */ +export function listHostRepoCatalogSnapshots( + store: Pick, + savedEnvironments: readonly SavedEnvironment[] +): Record { + const saved = store.getHostRepoCatalogSnapshots() + const result: Record = {} + for (const environment of savedEnvironments) { + const snapshot = isWslHostRuntimeEnvironment(environment) + ? // Re-validated: the saved shape is the answer shape, and a damaged profile must not seed bad rows. + hostRepoCatalogSnapshotFromRepoList(saved[environment.id]) + : null + if (snapshot) { + result[environment.id] = snapshot + } + } + return result +} diff --git a/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot.test.ts b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot.test.ts new file mode 100644 index 00000000000..6f4ba400a79 --- /dev/null +++ b/src/main/host-repo-catalog-snapshot/host-repo-catalog-snapshot.test.ts @@ -0,0 +1,235 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { getDefaultPersistedState } from '../../shared/constants' +import { setSecretStore } from '../../shared/secret-store' +import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import { + listHostRepoCatalogSnapshots, + recordHostRepoCatalogAnswer +} from './host-repo-catalog-snapshot-recorder' +import { ProfileStateSqliteAuthority } from '../persistence/profile-state/profile-state-sqlite-authority' +import { Store } from '../persistence/loading-store/store' +import { retireRemovedRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-removal-cleanup' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../ssh/config/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const directories: string[] = [] +const stores: Store[] = [] + +beforeEach(() => { + setSecretStore({ + isEncryptionAvailable: () => true, + encryptString: (plaintext) => Buffer.from(plaintext), + decryptString: (ciphertext) => ciphertext.toString(), + describeProtectionGap: () => null + }) + vi.spyOn(ProfileStateSqliteAuthority.prototype, 'scheduleBackup').mockImplementation(() => {}) +}) + +afterEach(async () => { + for (const store of stores.splice(0)) { + store.freezeWrites() + await store.flushAsync() + } + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + vi.restoreAllMocks() +}) + +function profile() { + const directory = mkdtempSync(join(tmpdir(), 'orca-host-repo-catalog-')) + directories.push(directory) + const databasePath = join(directory, 'profile-state.db') + const profileId = 'host-repo-catalog' + new ProfileStateSqliteAuthority(databasePath, profileId).writeSerializedState( + Buffer.from(JSON.stringify(getDefaultPersistedState(directory))) + ) + const open = () => { + const store = new Store({ + dataFile: join(directory, 'orca-data.json'), + profileStateAuthority: new ProfileStateSqliteAuthority(databasePath, profileId) + }) + stores.push(store) + return store + } + return { open } +} + +const wslHost = { + id: 'env-wsl', + orcadDeployment: { kind: 'wsl' as const, distro: 'Ubuntu', localPort: 41000, remotePort: 6768 } +} +const otherWslHost = { + id: 'env-wsl-2', + orcadDeployment: { kind: 'wsl' as const, distro: 'Debian', localPort: 41001, remotePort: 6768 } +} +const pairedServer = { id: 'env-paired', orcadDeployment: null } + +function repoListAnswer(repos: unknown[]): RuntimeRpcResponse { + return { id: 'repo.list', ok: true, result: { repos }, _meta: { runtimeId: 'server' } } +} + +const helloWorld = { + id: 'hello', + path: '/home/me/Hello-World', + displayName: 'Hello-World', + badgeColor: '#22c55e', + addedAt: 1, + kind: 'git', + // Host-only detail the sidebar row does not need. + hookSettings: { setupScript: 'make' } +} +const django = { + id: 'django', + path: '/home/me/django', + displayName: 'django', + badgeColor: '#737373', + addedAt: 2 +} +const notes = { + id: 'notes', + path: '/home/me/notes', + displayName: 'notes', + badgeColor: '#737373', + addedAt: 3, + kind: 'folder' as const +} + +it("keeps a WSL host's projects across a restart, with only the row fields", () => { + const { open } = profile() + const store = open() + recordHostRepoCatalogAnswer( + store, + wslHost, + 'repo.list', + repoListAnswer([helloWorld, django, notes]) + ) + store.flushOrThrow() + + const restarted = open() + expect(listHostRepoCatalogSnapshots(restarted, [wslHost])).toEqual({ + 'env-wsl': { + repos: [ + { + id: 'hello', + path: '/home/me/Hello-World', + displayName: 'Hello-World', + badgeColor: '#22c55e', + addedAt: 1, + kind: 'git' + }, + { + id: 'django', + path: '/home/me/django', + displayName: 'django', + badgeColor: '#737373', + addedAt: 2 + }, + // A folder project keeps its kind so it renders as a folder while the host sleeps. + expect.objectContaining({ id: 'notes', kind: 'folder' }) + ] + } + }) +}) + +it('replaces the saved list with each answer and saves only when it changed', () => { + const { open } = profile() + const store = open() + let writes = 0 + store.onStateWritten((domains) => { + if (domains?.includes('hostRepoCatalogSnapshots')) { + writes++ + } + }) + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld, django])) + expect(writes).toBe(1) + + // Every catalog refresh lands here; an identical answer must not schedule a save. + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld, django])) + expect(writes).toBe(1) + + // django was deleted on the host. + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld])) + expect(writes).toBe(2) + store.flushOrThrow() + expect( + listHostRepoCatalogSnapshots(open(), [wslHost])['env-wsl']?.repos.map((repo) => repo.id) + ).toEqual(['hello']) +}) + +it('keeps each distro separately and ignores failures, other methods and paired servers', () => { + const { open } = profile() + const store = open() + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld])) + recordHostRepoCatalogAnswer(store, otherWslHost, 'repo.list', repoListAnswer([django])) + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', { + id: 'repo.list', + ok: false, + error: { code: 'wsl_distro_stopped', message: 'stopped' }, + _meta: { runtimeId: 'server' } + }) + recordHostRepoCatalogAnswer(store, wslHost, 'worktree.list', repoListAnswer([])) + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', { + id: 'repo.list', + ok: true, + result: { nope: true }, + _meta: { runtimeId: 'server' } + }) + recordHostRepoCatalogAnswer(store, pairedServer, 'repo.list', repoListAnswer([notes])) + store.flushOrThrow() + + // Checked in storage, not through the listing, which filters paired servers on its own. + const stored = open().getHostRepoCatalogSnapshots() + expect(Object.keys(stored).sort()).toEqual(['env-wsl', 'env-wsl-2']) + expect(stored['env-wsl']?.repos.map((repo) => repo.id)).toEqual(['hello']) + expect(stored['env-wsl-2']?.repos.map((repo) => repo.id)).toEqual(['django']) +}) + +it('lists only WSL hosts, even when a paired server has a saved entry', () => { + const { open } = profile() + const store = open() + store.setHostRepoCatalogSnapshot('env-paired', { repos: [notes] }) + store.setHostRepoCatalogSnapshot('env-wsl', { repos: [django] }) + expect(Object.keys(listHostRepoCatalogSnapshots(store, [wslHost, pairedServer]))).toEqual([ + 'env-wsl' + ]) +}) + +it('never lists a host no longer saved (removed outside Settings)', () => { + const { open } = profile() + const store = open() + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld])) + recordHostRepoCatalogAnswer(store, otherWslHost, 'repo.list', repoListAnswer([django])) + expect(Object.keys(listHostRepoCatalogSnapshots(store, [wslHost]))).toEqual(['env-wsl']) +}) + +it('does not seed stale projects when a removed WSL host is added back under the same id', async () => { + const { open } = profile() + const store = open() + recordHostRepoCatalogAnswer(store, wslHost, 'repo.list', repoListAnswer([helloWorld, django])) + + await retireRemovedRuntimeEnvironment(wslHost.id, () => undefined, store) + store.flushOrThrow() + + // WSL host ids are deterministic per distro, so re-adding it saves the same id again. + expect(listHostRepoCatalogSnapshots(open(), [wslHost])).toEqual({}) +}) diff --git a/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts b/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts index 2a271058a9b..eddb101e12d 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handler-cleanup.test.ts @@ -206,7 +206,8 @@ it("retires a recipe server's session partition once its VM is destroyed (#33805 })), removeWorkspaceSessionHost, getRemoteLayoutSnapshot: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } as never) await expect( diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index c3a729a6fe9..434e9207253 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -22,6 +22,7 @@ export function registerRepoHandlers( // macOS window recreation re-registers these handlers. ipcMain.removeHandler('repos:list') ipcMain.removeHandler('repos:listWslMoved') + ipcMain.removeHandler('repos:listHostCatalogSnapshots') ipcMain.removeHandler('repos:add') ipcMain.removeHandler('repos:removeForHost') ipcMain.removeHandler('repos:reorder') diff --git a/src/main/ipc/repos/repo-catalog-handlers.ts b/src/main/ipc/repos/repo-catalog-handlers.ts index 3121210319f..6780dbd9d32 100644 --- a/src/main/ipc/repos/repo-catalog-handlers.ts +++ b/src/main/ipc/repos/repo-catalog-handlers.ts @@ -21,6 +21,10 @@ import { isWslMoveSourceHost } from '../../persistence/moving-wsl-projects/wsl-move-scope-guard' import { listWslMovedProjects } from '../../persistence/moving-wsl-projects/wsl-moved-projects' +import { listHostRepoCatalogSnapshots } from '../../host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder' +import { listEnvironments } from '../../../shared/runtime-environment-store' +import { getRuntimeEnvironmentRegistryRoot } from '../runtime-environments/runtime-environment-registry-root' +import { isGatedOffWslHost } from '../../wsl/wsl-host-gate' export function registerRepoCatalogHandlers( mainWindow: BrowserWindow, @@ -42,6 +46,16 @@ export function registerRepoCatalogHandlers( listWslMovedProjects(store.getRepos(), store.getFolderWorkspaces()) ) + // Desktop-only reads (environment registry and saved state): never wakes a stopped distro. + ipcMain.handle('repos:listHostCatalogSnapshots', () => + listHostRepoCatalogSnapshots( + store, + listEnvironments(getRuntimeEnvironmentRegistryRoot()).filter( + (environment) => !isGatedOffWslHost(environment) + ) + ) + ) + ipcMain.handle('projects:list', () => { enrichMissingRepoGitRemoteIdentities(store, { onChanged: broadcastReposChanged }) return store.getProjects() diff --git a/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.test.ts b/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.test.ts index b3893b29cce..1222910d6eb 100644 --- a/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.test.ts +++ b/src/main/ipc/runtime-environments/orcad-runtime-maintenance-handlers.test.ts @@ -33,7 +33,8 @@ const clearHostServerNotes = vi.fn() const store = { removeWorkspaceSessionHost: vi.fn(), getRemoteLayoutSnapshot: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } function handler(channel: string): (_event: unknown, args: unknown) => Promise { diff --git a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts index f725d15fde9..f95ddf6ed38 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-connectivity-handlers.ts @@ -178,9 +178,12 @@ export function registerRuntimeEnvironmentConnectivityHandlers({ ) } -export function registerRuntimeEnvironmentPassiveHandlers(getUserDataPath: () => string): void { +export function registerRuntimeEnvironmentPassiveHandlers( + getUserDataPath: () => string, + recordAnswer?: RecordRuntimeEnvironmentAnswer +): void { registerPassiveStatusHandler(getUserDataPath) - registerPassiveCallHandler(getUserDataPath) + registerPassiveCallHandler(getUserDataPath, recordAnswer) } function closeLegacySelectorTransport(selector: string, environmentId: string): void { @@ -215,7 +218,16 @@ function registerPassiveStatusHandler(getUserDataPath: () => string): void { ) } -function registerPassiveCallHandler(getUserDataPath: () => string): void { +type RecordRuntimeEnvironmentAnswer = ( + environment: ReturnType, + method: string, + response: RuntimeRpcResponse +) => void + +function registerPassiveCallHandler( + getUserDataPath: () => string, + recordAnswer?: RecordRuntimeEnvironmentAnswer +): void { ipcMain.handle( 'runtimeEnvironments:call', async ( @@ -279,6 +291,7 @@ function registerPassiveCallHandler(getUserDataPath: () => string): void { throw error } observeWslOrcadHostResponse(environment.id, args.method, response) + recordAnswer?.(environment, args.method, response) return isRuntimeEnvironmentManuallyDisconnected(environment.id) ? manuallyDisconnectedResponse(environment) : response diff --git a/src/main/ipc/runtime-environments/runtime-environment-removal-cleanup.ts b/src/main/ipc/runtime-environments/runtime-environment-removal-cleanup.ts index c043a93606b..973ad597823 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-removal-cleanup.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-removal-cleanup.ts @@ -9,10 +9,12 @@ import { forgetRemoteLayoutSnapshot, type RemoteLayoutSnapshotStore } from '../../remote-layout-snapshot/remote-layout-snapshot-recorder' +import type { HostRepoCatalogSnapshotStore } from '../../host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder' -export type RemovedServerStore = RemoteLayoutSnapshotStore & { - removeWorkspaceSessionHost: (hostId: ExecutionHostId) => void -} +export type RemovedServerStore = RemoteLayoutSnapshotStore & + Pick & { + removeWorkspaceSessionHost: (hostId: ExecutionHostId) => void + } /** Retires a removed server's client-side state; resolves once its transport is invalidated. */ export function retireRemovedRuntimeEnvironment( @@ -29,6 +31,7 @@ export function retireRemovedRuntimeEnvironment( // Retained session partitions make listings advertise the removed server as a known host. store.removeWorkspaceSessionHost(toRuntimeExecutionHostId(environmentId)) forgetRemoteLayoutSnapshot(store, environmentId) + store.setHostRepoCatalogSnapshot(environmentId, null) clearRuntimeEnvironmentManualDisconnect(environmentId) const retiring = Promise.resolve(invalidateTransport(environmentId)) // Explicit removal retires browser storage after client hosts release their live partitions. diff --git a/src/main/ipc/runtime-environments/runtime-environment-removal-storage.test.ts b/src/main/ipc/runtime-environments/runtime-environment-removal-storage.test.ts index ec2004d87ef..1ff2c48ef83 100644 --- a/src/main/ipc/runtime-environments/runtime-environment-removal-storage.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environment-removal-storage.test.ts @@ -51,7 +51,8 @@ describe('runtime environment removal storage clearing', () => { store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => teardown @@ -76,7 +77,8 @@ describe('runtime environment removal storage clearing', () => { store: { getSettings: () => ({}), removeWorkspaceSessionHost: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } as never, getUserDataPath: () => '/tmp/orca-user-data', invalidateTransport: () => Promise.resolve() diff --git a/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts b/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts index 71c8da501e2..f6ddf3a5eda 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-call-routing.test.ts @@ -88,6 +88,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { let store: { getSettings: () => { activeRuntimeEnvironmentId: string | null } updateSettings: ReturnType + setHostRepoCatalogSnapshot: ReturnType } beforeEach(() => { @@ -95,6 +96,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { activeRuntimeEnvironmentId = null store = { getSettings: () => ({ activeRuntimeEnvironmentId }), + setHostRepoCatalogSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) @@ -206,6 +208,8 @@ describe('registerRuntimeEnvironmentHandlers', () => { ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES ) expect(sendRemoteRuntimeConnectionRequestMock).not.toHaveBeenCalled() + // Only WSL hosts keep a saved project list; a paired server is listed live. + expect(store.setHostRepoCatalogSnapshot).not.toHaveBeenCalled() }) it('falls back to one-shot RPC when the saved runtime lacks shared-control support', async () => { diff --git a/src/main/ipc/runtime-environments/runtime-environments-capability-cache.test.ts b/src/main/ipc/runtime-environments/runtime-environments-capability-cache.test.ts index d4998d8aaf0..71a4f60cb5d 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-capability-cache.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-capability-cache.test.ts @@ -83,6 +83,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType setRemoteLayoutSnapshot: ReturnType + setHostRepoCatalogSnapshot: ReturnType } beforeEach(() => { @@ -92,6 +93,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) diff --git a/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts b/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts index 14d8914756e..19b4b564cf5 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-pairing.test.ts @@ -103,6 +103,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType setRemoteLayoutSnapshot: ReturnType + setHostRepoCatalogSnapshot: ReturnType } beforeEach(() => { @@ -112,6 +113,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) @@ -221,6 +223,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { `runtime:${encodeURIComponent(added.environment.id)}` ) expect(store.setRemoteLayoutSnapshot).toHaveBeenCalledWith(added.environment.id, null, 'now') + expect(store.setHostRepoCatalogSnapshot).toHaveBeenCalledWith(added.environment.id, null) expect(JSON.stringify(removed)).not.toContain('device-token') expect(await list(null, undefined)).toEqual([]) }) diff --git a/src/main/ipc/runtime-environments/runtime-environments-repair.test.ts b/src/main/ipc/runtime-environments/runtime-environments-repair.test.ts index 4d80add3f78..f6a2dfa6d95 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-repair.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-repair.test.ts @@ -87,7 +87,8 @@ describe('runtimeEnvironments:repairFromPairingCode', () => { getSettings: () => ({ activeRuntimeEnvironmentId: null }), updateSettings: vi.fn(), removeWorkspaceSessionHost: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The registrar only reads the fixture's settings and workspace-session methods. registerRuntimeEnvironmentHandlers(store as never) diff --git a/src/main/ipc/runtime-environments/runtime-environments-subscription-teardown.test.ts b/src/main/ipc/runtime-environments/runtime-environments-subscription-teardown.test.ts index b97817d6374..85f8ce7523e 100644 --- a/src/main/ipc/runtime-environments/runtime-environments-subscription-teardown.test.ts +++ b/src/main/ipc/runtime-environments/runtime-environments-subscription-teardown.test.ts @@ -91,6 +91,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { updateSettings: ReturnType removeWorkspaceSessionHost: ReturnType setRemoteLayoutSnapshot: ReturnType + setHostRepoCatalogSnapshot: ReturnType } beforeEach(() => { @@ -100,6 +101,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getSettings: () => ({ activeRuntimeEnvironmentId }), removeWorkspaceSessionHost: vi.fn(), setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn(), updateSettings: vi.fn((updates: { activeRuntimeEnvironmentId: string | null }) => { activeRuntimeEnvironmentId = updates.activeRuntimeEnvironmentId }) diff --git a/src/main/ipc/runtime-environments/runtime-environments.ts b/src/main/ipc/runtime-environments/runtime-environments.ts index d3e3e5129c0..9939d6c0bff 100644 --- a/src/main/ipc/runtime-environments/runtime-environments.ts +++ b/src/main/ipc/runtime-environments/runtime-environments.ts @@ -10,6 +10,7 @@ import { getRuntimeEnvironmentRegistryRoot } from './runtime-environment-registr import { listEnvironments } from '../../../shared/runtime-environment-store' import type { Store } from '../../persistence/loading-store/store' import { recordRemoteLayoutFrame } from '../../remote-layout-snapshot/remote-layout-snapshot-recorder' +import { recordHostRepoCatalogAnswer } from '../../host-repo-catalog-snapshot/host-repo-catalog-snapshot-recorder' import { isRuntimeEnvironmentManuallyDisconnected, registerRuntimeEnvironmentConnectivityHandlers, @@ -153,7 +154,9 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { getUserDataPath, invalidateTransport: invalidateRuntimeEnvironmentTransport }) - registerRuntimeEnvironmentPassiveHandlers(getUserDataPath) + registerRuntimeEnvironmentPassiveHandlers(getUserDataPath, (environment, method, response) => + recordHostRepoCatalogAnswer(store, environment, method, response) + ) setManagedOrcadPairingRepairedListener(invalidateRuntimeEnvironmentTransport) setRefusedPairingHandlers({ recover: (environmentId) => recoverRefusedManagedPairing(getUserDataPath(), environmentId), diff --git a/src/main/ipc/wsl-host-gate-off.test.ts b/src/main/ipc/wsl-host-gate-off.test.ts index 94f027bd49f..7b1cb241c1a 100644 --- a/src/main/ipc/wsl-host-gate-off.test.ts +++ b/src/main/ipc/wsl-host-gate-off.test.ts @@ -126,7 +126,8 @@ describe('WSL hosts with the internal gate closed', () => { getWorkspaceSessionHostIds: () => [], updateSettings: vi.fn(), removeWorkspaceSessionHost: vi.fn(), - setRemoteLayoutSnapshot: vi.fn() + setRemoteLayoutSnapshot: vi.fn(), + setHostRepoCatalogSnapshot: vi.fn() } beforeEach(() => { diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index dff51b6d5fe..dbb19cc6228 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -109,6 +109,7 @@ export class StateSerializationSecretHandlingOperations { break case 'sshRemotePtyLeases': case 'remoteLayoutSnapshots': + case 'hostRepoCatalogSnapshots': case 'legacyPaneKeyAliasEntries': case 'migrationUnsupportedPtyEntries': stateToSave[domain] = this.runtime.state[domain] diff --git a/src/main/persistence/loading-store/store-domain-composition.ts b/src/main/persistence/loading-store/store-domain-composition.ts index a4541f3c710..bcac1130081 100644 --- a/src/main/persistence/loading-store/store-domain-composition.ts +++ b/src/main/persistence/loading-store/store-domain-composition.ts @@ -18,6 +18,7 @@ import { ProjectCollectionOperations } from './project-collection-operations' import { AutomationPersistence } from './automation-persistence' import { MobileTabSelectionPersistence } from './mobile-tab-selection-persistence' import { RemoteLayoutSnapshotPersistence } from '../../remote-layout-snapshot/remote-layout-snapshot-persistence' +import { HostRepoCatalogSnapshotPersistence } from '../../host-repo-catalog-snapshot/host-repo-catalog-snapshot-persistence' import { SparsePresetPersistence } from './sparse-preset-persistence' import { PtyBindingPersistenceOperations } from './pty-binding-persistence' import { LayoutCommandPersistence } from '../terminal-topology/layout-command-persistence' @@ -34,6 +35,7 @@ export type StoreDomainOperations = WriteSchedulingOperations & RepoLifecycleOperations & MobileTabSelectionPersistence & RemoteLayoutSnapshotPersistence & + HostRepoCatalogSnapshotPersistence & SparsePresetPersistence & AutomationPersistence & MetadataLineageOperations & @@ -68,6 +70,7 @@ export type StoreDomains = { automations: AutomationPersistence mobileTabSelections: MobileTabSelectionPersistence remoteLayoutSnapshots: RemoteLayoutSnapshotPersistence + hostRepoCatalogSnapshots: HostRepoCatalogSnapshotPersistence sparsePresets: SparsePresetPersistence ptyBindings: PtyBindingPersistenceOperations layoutCommands: LayoutCommandPersistence @@ -86,6 +89,7 @@ export const STORE_DOMAIN_OPERATION_CLASSES = [ RepoLifecycleOperations, MobileTabSelectionPersistence, RemoteLayoutSnapshotPersistence, + HostRepoCatalogSnapshotPersistence, SparsePresetPersistence, AutomationPersistence, MetadataLineageOperations, @@ -165,6 +169,7 @@ export function createStoreDomains(runtime: StoreRuntimeState): StoreDomains { automations, mobileTabSelections, remoteLayoutSnapshots, + hostRepoCatalogSnapshots: new HostRepoCatalogSnapshotPersistence(runtime, scheduling), sparsePresets, ptyBindings, layoutCommands: new LayoutCommandPersistence( diff --git a/src/main/persistence/profile-state/profile-state-section-labels.ts b/src/main/persistence/profile-state/profile-state-section-labels.ts index 3dfd392fa04..5e6b9d22267 100644 --- a/src/main/persistence/profile-state/profile-state-section-labels.ts +++ b/src/main/persistence/profile-state/profile-state-section-labels.ts @@ -7,6 +7,7 @@ function sectionLabel(domain: string, translate: ProfileStateSectionLabelTransla case 'repos': case 'projects': case 'projectHostSetups': + case 'hostRepoCatalogSnapshots': return translate('profileState.section.projects', 'Projects') case 'projectGroups': return translate('profileState.section.projectGroups', 'Project groups') diff --git a/src/preload/api/repos-bridge.ts b/src/preload/api/repos-bridge.ts index 4b845f31742..a5c1ec58e36 100644 --- a/src/preload/api/repos-bridge.ts +++ b/src/preload/api/repos-bridge.ts @@ -8,6 +8,8 @@ export const reposApi = { listWslMoved: () => ipcRenderer.invoke('repos:listWslMoved'), + listHostCatalogSnapshots: () => ipcRenderer.invoke('repos:listHostCatalogSnapshots'), + add: (args) => ipcRenderer.invoke('repos:add', args), create: (args) => ipcRenderer.invoke('repos:create', args), diff --git a/src/preload/api/repository-api.ts b/src/preload/api/repository-api.ts index 31e57b929d0..6d5b8657439 100644 --- a/src/preload/api/repository-api.ts +++ b/src/preload/api/repository-api.ts @@ -22,11 +22,14 @@ import type { } from '../../shared/project-types' import type { BaseRefDefaultResult, BaseRefSearchResult, Repo } from '../../shared/repo-types' import type { WslMovedProject } from '../../shared/wsl-move/wsl-moved-project' +import type { HostRepoCatalogSnapshot } from '../../shared/host-repo-catalog-snapshot' export type RepositoryApi = { list: () => Promise /** Local projects a WSL host now owns: read-only, listed only for their existing chats. */ listWslMoved: () => Promise + /** Saved WSL host project lists by environment id; desktop-only, never contacts a host. */ + listHostCatalogSnapshots: () => Promise> add: (args: { path: string kind?: 'git' | 'folder' diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index eae3feb1767..272ffd32921 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -108,6 +108,10 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Before the session read: a moved project's local rows must leave before the host's merge in. // Started before the repo list too; this startup load does not prune, so it need not wait. const wslMovedProjectsPromise = useAppStore.getState().fetchWslMovedProjects() + // Desktop-only read; must land before the first live host catalog so that answer can drop deleted projects. + const hostCatalogSeedPromise = timeRendererStartupStep('seed-host-repo-catalogs', () => + useAppStore.getState().seedReposFromHostCatalogSnapshots() + ) // Skip remote catalogs for first paint; saved runtimes may wait a full connect timeout. await timeRendererStartupStep('fetch-repos-local', () => actions.fetchReposForAllHosts({ remoteHosts: 'skip' }) @@ -124,18 +128,21 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.fetchFolderWorkspacesForAllHosts({ remoteHosts: 'skip' }) ) })() - const sessionReadPromise = Promise.all([runtimeHostsPromise, wslMovedProjectsPromise]).then( - ([startupRuntimeHosts]) => - // Restore cached runtime-host partitions without network waits; skip unreadable ones. - timeRendererStartupStep('session-get', () => - fetchWorkspaceSessionWithRuntimeHostOwners( - // A moved workspace's retained rows share tab ids with its host's live ones. - wslMovedSessionReadApi(useAppStore.getState().wslMovedProjects), - useAppStore.getState().repos, - startupRuntimeHosts.hostIds, - startupRuntimeHosts.sshHostIdByRuntimeHostId - ) + const sessionReadPromise = Promise.all([ + runtimeHostsPromise, + wslMovedProjectsPromise, + hostCatalogSeedPromise + ]).then(([startupRuntimeHosts]) => + // Restore cached runtime-host partitions without network waits; skip unreadable ones. + timeRendererStartupStep('session-get', () => + fetchWorkspaceSessionWithRuntimeHostOwners( + // A moved workspace's retained rows share tab ids with its host's live ones. + wslMovedSessionReadApi(useAppStore.getState().wslMovedProjects), + useAppStore.getState().repos, + startupRuntimeHosts.hostIds, + startupRuntimeHosts.sshHostIdByRuntimeHostId ) + ) ) const hydrationSessionChain = sessionReadPromise.then(async (sessionRead) => { const hydrationRepoIds = collectWorktreeHydrationRepoIdsFromSession( diff --git a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts index e5ab4a399cd..78be1315e27 100644 --- a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts @@ -33,6 +33,7 @@ import { import { getRuntimeTargetHostId } from '../../runtime/runtime-client-target' import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' +import { noteLiveRepoCatalogApplied } from './saved-host-catalog-rows' import { withWslMovedRepoIds, withWslMovedSetupScriptDismissals @@ -104,6 +105,7 @@ export function createAllHostRepoCatalogActions( setupScriptPromptDismissedRepoIds: s.setupScriptPromptDismissedRepoIds } }) + noteLiveRepoCatalogApplied(get, catalog.hostId) // Why: keep the safe-auto fork sync (as fetchRepos does) so cold-start, which now routes here, still updates safe-auto forks. scheduleSafeAutoForkSync(hostRepos) if (options?.onHostLoaded) { diff --git a/src/renderer/src/store/repos/host-repo-catalog-seed.test.ts b/src/renderer/src/store/repos/host-repo-catalog-seed.test.ts new file mode 100644 index 00000000000..acb22e955ec --- /dev/null +++ b/src/renderer/src/store/repos/host-repo-catalog-seed.test.ts @@ -0,0 +1,212 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from '../slices/store-test-helpers' +import type { Repo } from '../../../../shared/repo-types' +import type { HostRepoCatalogSnapshot } from '../../../../shared/host-repo-catalog-snapshot' +import { + createCompatibleRuntimeStatusResponseIfNeeded, + type RuntimeEnvironmentCallRequest +} from '../../runtime/runtime-compatibility-test-fixture' +import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' + +const HOST = 'runtime:env-wsl' + +const localRepo: Repo = { + id: 'local-repo', + path: '/local/orca', + displayName: 'orca', + badgeColor: '#22c55e', + addedAt: 1 +} +const helloWorld = { + id: 'hello', + path: '/home/me/Hello-World', + displayName: 'Hello-World', + badgeColor: '#737373', + addedAt: 2, + kind: 'git' as const +} +const django = { ...helloWorld, id: 'django', path: '/home/me/django', displayName: 'django' } +const notes = { + ...helloWorld, + id: 'notes', + path: '/home/me/notes', + displayName: 'notes', + kind: 'folder' as const +} + +const saved: Record = { + 'env-wsl': { repos: [helloWorld, django, notes] } +} + +const listHostCatalogSnapshots = vi.fn() +const runtimeCall = vi.fn() +const runtimeGetStatus = vi.fn() +const wslApi = { listDistros: vi.fn(), startHost: vi.fn(), getHostStatus: vi.fn() } +let hostRepos: unknown[] | 'stopped' = 'stopped' + +function hostRows(repos: readonly Repo[]): { id: string; kind?: string }[] { + return repos + .filter((repo) => repo.executionHostId === HOST) + .map((repo) => ({ id: repo.id, ...(repo.kind ? { kind: repo.kind } : {}) })) +} + +beforeEach(() => { + clearRuntimeCompatibilityCacheForTests() + vi.clearAllMocks() + hostRepos = 'stopped' + listHostCatalogSnapshots.mockResolvedValue(structuredClone(saved)) + runtimeCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { + if (hostRepos === 'stopped') { + return { + id: args.method, + ok: false, + error: { code: 'wsl_distro_stopped', message: 'The distro is stopped.' }, + _meta: { runtimeId: 'unknown' } + } + } + const compatible = createCompatibleRuntimeStatusResponseIfNeeded(args) + if (compatible) { + return compatible + } + return { + id: args.method, + ok: true, + result: args.method === 'repo.list' ? { repos: structuredClone(hostRepos) } : {}, + _meta: { runtimeId: 'runtime-wsl' } + } + }) + vi.stubGlobal('window', { + api: { + repos: { list: vi.fn().mockResolvedValue([localRepo]), listHostCatalogSnapshots }, + projects: { + list: vi.fn().mockResolvedValue([]), + listHostSetups: vi.fn().mockResolvedValue([]) + }, + projectGroups: { list: vi.fn().mockResolvedValue([]) }, + folderWorkspaces: { list: vi.fn().mockResolvedValue([]) }, + runtimeEnvironments: { + call: runtimeCall, + getStatus: runtimeGetStatus, + list: vi.fn().mockResolvedValue([{ id: 'env-wsl', name: 'orca-demo' }]) + }, + wsl: wslApi + }, + dispatchEvent: vi.fn() + }) +}) + +describe('saved WSL host project lists', () => { + it('lists every saved project of a stopped host after a restart, without contacting it', async () => { + const store = createTestStore() + await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) + await store.getState().seedReposFromHostCatalogSnapshots() + + expect(hostRows(store.getState().repos)).toEqual([ + { id: 'hello', kind: 'git' }, + { id: 'django', kind: 'git' }, + // Folder projects come back as folders. + { id: 'notes', kind: 'folder' } + ]) + expect(store.getState().repos.map((repo) => repo.id)).toContain('local-repo') + // Loading the saved list is a desktop read: no host RPC, no status probe, no wsl.exe. + expect(listHostCatalogSnapshots).toHaveBeenCalledTimes(1) + expect(runtimeCall).not.toHaveBeenCalled() + expect(runtimeGetStatus).not.toHaveBeenCalled() + for (const call of Object.values(wslApi)) { + expect(call).not.toHaveBeenCalled() + } + + // The host is still stopped: its failed catalog leaves the saved rows in place. + await store.getState().fetchReposForAllHosts() + expect(hostRows(store.getState().repos).map((row) => row.id)).toEqual([ + 'hello', + 'django', + 'notes' + ]) + }) + + it('lets the first live list replace the saved one, dropping projects deleted on the host', async () => { + const store = createTestStore() + await store.getState().seedReposFromHostCatalogSnapshots() + hostRepos = [{ ...helloWorld, displayName: 'Hello World (renamed)' }, notes] + + await store.getState().fetchReposForAllHosts() + + const rows = store.getState().repos.filter((repo) => repo.executionHostId === HOST) + expect(rows.map((repo) => [repo.id, repo.displayName])).toEqual([ + ['hello', 'Hello World (renamed)'], + ['notes', 'notes'] + ]) + }) + + it('does not bring back a host removed this session, or duplicate rows already loaded', async () => { + const store = createTestStore() + store.setState({ removedRuntimeEnvironmentIds: new Set(['env-wsl']) }) + await store.getState().seedReposFromHostCatalogSnapshots() + expect(hostRows(store.getState().repos)).toEqual([]) + + const live = createTestStore() + live.setState({ + repos: [{ ...helloWorld, displayName: 'live', executionHostId: HOST, connectionId: null }] + }) + await live.getState().seedReposFromHostCatalogSnapshots() + const liveRows = live.getState().repos.filter((repo) => repo.executionHostId === HOST) + expect(liveRows.map((repo) => [repo.id, repo.displayName])).toEqual([ + ['hello', 'live'], + ['django', 'django'], + ['notes', 'notes'] + ]) + }) + + it('skips a host whose live list already applied, so a late seed cannot restore a deleted project', async () => { + const store = createTestStore() + hostRepos = [helloWorld] + await store.getState().fetchReposForAllHosts() + await store.getState().seedReposFromHostCatalogSnapshots() + + expect(hostRows(store.getState().repos).map((row) => row.id)).toEqual(['hello']) + }) + + it('lets a live list requested before the seed landed still drop a deleted project', async () => { + const store = createTestStore() + hostRepos = [helloWorld] + let markRequested: () => void = () => undefined + const requested = new Promise((resolve) => { + markRequested = resolve + }) + let releaseAnswer: () => void = () => undefined + const answerReleased = new Promise((resolve) => { + releaseAnswer = resolve + }) + const answerLive = runtimeCall.getMockImplementation() + runtimeCall.mockImplementation(async (args: RuntimeEnvironmentCallRequest) => { + if (args.method === 'repo.list') { + markRequested() + await answerReleased + } + return answerLive?.(args) + }) + + const fetching = store.getState().fetchReposForAllHosts() + await requested + await store.getState().seedReposFromHostCatalogSnapshots() + expect(hostRows(store.getState().repos).map((row) => row.id)).toEqual([ + 'hello', + 'django', + 'notes' + ]) + + releaseAnswer() + await fetching + expect(hostRows(store.getState().repos).map((row) => row.id)).toEqual(['hello']) + }) + + it('keeps startup going when the saved list cannot be read', async () => { + listHostCatalogSnapshots.mockRejectedValue(new Error('ipc down')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const store = createTestStore() + await store.getState().seedReposFromHostCatalogSnapshots() + expect(store.getState().repos).toEqual([]) + expect(warn).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/store/repos/host-repo-catalog-seed.ts b/src/renderer/src/store/repos/host-repo-catalog-seed.ts new file mode 100644 index 00000000000..6fee0e010e7 --- /dev/null +++ b/src/renderer/src/store/repos/host-repo-catalog-seed.ts @@ -0,0 +1,67 @@ +// Stopped WSL hosts still list their projects after a restart: seed rows from the desktop's saved answer. +import type { StateCreator } from 'zustand' +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' +import type { HostRepoCatalogSnapshot } from '../../../../shared/host-repo-catalog-snapshot' +import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' +import { adoptFromEndpoint } from '../adopt-from-endpoint' +import { getRepoRowIdentity } from '../slices/repo-host-identity' +import { isRemovedRuntimeHostId } from '../slices/stale-runtime-host-rows' +import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' +import { hasLiveRepoCatalogApplied, markSavedHostCatalogRow } from './saved-host-catalog-rows' + +/** Adds saved rows the store lacks; the host's next live list replaces them like any other row. */ +export function withHostCatalogSnapshotRepos( + repos: readonly Repo[], + snapshots: Readonly>, + removedEnvironmentIds: ReadonlySet, + hasLiveList: (hostId: string) => boolean = () => false +): readonly Repo[] { + const present = new Set(repos.map(getRepoRowIdentity)) + const added: Repo[] = [] + for (const [environmentId, snapshot] of Object.entries(snapshots)) { + const hostId = toRuntimeExecutionHostId(environmentId) + if (isRemovedRuntimeHostId(hostId, removedEnvironmentIds) || hasLiveList(hostId)) { + continue + } + const target = { kind: 'environment' as const, environmentId } + for (const row of snapshot.repos) { + const repo = adoptFromEndpoint(target, { kind: 'repo', row }) + const identity = getRepoRowIdentity(repo) + if (!present.has(identity)) { + present.add(identity) + markSavedHostCatalogRow(repo) + added.push(repo) + } + } + } + return added.length > 0 ? [...repos, ...added] : repos +} + +/** Run before any live host catalog request, so that answer can drop a project deleted on the host. */ +export async function seedReposFromHostCatalogSnapshots( + set: Parameters>[0], + get: Parameters>[1] +): Promise { + const list = window.api?.repos?.listHostCatalogSnapshots + if (typeof list !== 'function') { + return + } + let snapshots: Record + try { + snapshots = await list() + } catch (error) { + // Without the saved list a stopped host shows only its open workspaces, as before. + console.warn('[repos] Could not read saved host project lists:', error) + return + } + set((s) => { + const repos = withHostCatalogSnapshotRepos( + s.repos, + snapshots, + s.removedRuntimeEnvironmentIds, + (hostId) => hasLiveRepoCatalogApplied(get, hostId) + ) + return repos === s.repos ? s : { repos: applyManualRepoOrder(repos, s.manualRepoOrder) } + }) +} diff --git a/src/renderer/src/store/repos/repo-catalog-identity.ts b/src/renderer/src/store/repos/repo-catalog-identity.ts index 3ac7a6d8be4..75afe352fdc 100644 --- a/src/renderer/src/store/repos/repo-catalog-identity.ts +++ b/src/renderer/src/store/repos/repo-catalog-identity.ts @@ -7,6 +7,7 @@ import type { RepoSlice } from './repo-state' import { mergeFetchedProjectCompatibilityForHost } from '../../../../shared/project-compatibility-host-merge' import { projectCompatibilityFromRepos } from '../../../../shared/project-compatibility-merge' import { mergeByIdentity } from '../catalog-identity' +import { isSavedHostCatalogRow } from './saved-host-catalog-rows' export function mergeFetchedReposForHost( previous: readonly Repo[], @@ -24,7 +25,7 @@ export function mergeFetchedReposForHost( getRepoExecutionHostId(repo) !== hostId || fetchedIdentities.has(identity) || // Added after the request: an answer that predates it cannot drop it. - (requested !== null && !requested.has(identity)) + (requested !== null && !requested.has(identity) && !isSavedHostCatalogRow(repo)) ) }) return reconcileFetchedRepos( diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index 6f2c62a1822..7eada941ded 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -172,6 +172,8 @@ export type RepoSlice = { /** Local projects a WSL host now owns: never in `repos`/`folderWorkspaces`, read-only, chats only. */ wslMovedProjects: readonly WslMovedProject[] fetchWslMovedProjects: () => Promise + /** Startup only: a stopped WSL host's saved project list, before its live catalog is asked for. */ + seedReposFromHostCatalogSnapshots: () => Promise folderWorkspacePathStatuses: Record /** Host-resolved floating workspace directory; null until the host has answered. */ floatingWorkspacePath: string | null diff --git a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts index 091e957fd20..00533fba824 100644 --- a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -21,6 +21,7 @@ import { import { getRuntimeTargetHostId } from '../../runtime/runtime-client-target' import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import { scheduleSafeAutoForkSync } from './safe-auto-fork-sync' +import { noteLiveRepoCatalogApplied } from './saved-host-catalog-rows' import { withWslMovedRepoIds, withWslMovedSetupScriptDismissals @@ -132,6 +133,7 @@ export function createRuntimeRepoCatalogActions( ) } }) + noteLiveRepoCatalogApplied(get, targetHostId) scheduleSafeAutoForkSync(finalizedHostRepos) return finalizedHostRepos } catch (err) { diff --git a/src/renderer/src/store/repos/saved-host-catalog-rows.ts b/src/renderer/src/store/repos/saved-host-catalog-rows.ts new file mode 100644 index 00000000000..66188c19d90 --- /dev/null +++ b/src/renderer/src/store/repos/saved-host-catalog-rows.ts @@ -0,0 +1,29 @@ +// Rows seeded from a WSL host's saved project list, and the hosts whose live list already applied. +import type { AppState } from '../types' +import type { Repo } from '../../../../shared/repo-types' + +const savedRows = new WeakSet() +const liveListHostsByStore = new WeakMap<() => AppState, Set>() + +export function markSavedHostCatalogRow(repo: Repo): void { + savedRows.add(repo) +} + +/** Any live answer may drop a saved row, even one requested before the seed landed. */ +export function isSavedHostCatalogRow(repo: Repo): boolean { + return savedRows.has(repo) +} + +export function noteLiveRepoCatalogApplied(get: () => AppState, hostId: string): void { + let hosts = liveListHostsByStore.get(get) + if (!hosts) { + hosts = new Set() + liveListHostsByStore.set(get, hosts) + } + hosts.add(hostId) +} + +/** A seed landing after the host's live list must not bring back what that list dropped. */ +export function hasLiveRepoCatalogApplied(get: () => AppState, hostId: string): boolean { + return liveListHostsByStore.get(get)?.has(hostId) === true +} diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index aacac43f136..b8b17208ac0 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -17,6 +17,7 @@ import { createProjectUpdateActions } from '../projects/project-update' import { createRepoUpdateActions } from '../repos/repo-update' import { createRepoOrderingActions } from '../repos/repo-ordering' import { fetchWslMovedProjects } from '../repos/wsl-moved-projects' +import { seedReposFromHostCatalogSnapshots } from '../repos/host-repo-catalog-seed' export const createRepoSlice: StateCreator = (set, get) => { const repoCatalogActions = createRepoCatalogActions(set, get) @@ -42,6 +43,7 @@ export const createRepoSlice: StateCreator = (set, folderWorkspaces: [], wslMovedProjects: [], fetchWslMovedProjects: () => fetchWslMovedProjects(set), + seedReposFromHostCatalogSnapshots: () => seedReposFromHostCatalogSnapshots(set, get), folderWorkspacePathStatuses: {}, floatingWorkspacePath: null, setFloatingWorkspacePath: (path) => diff --git a/src/renderer/src/web/preload-api/web-repositories-api.ts b/src/renderer/src/web/preload-api/web-repositories-api.ts index 91bd76b8396..2f0385d1cc8 100644 --- a/src/renderer/src/web/preload-api/web-repositories-api.ts +++ b/src/renderer/src/web/preload-api/web-repositories-api.ts @@ -19,6 +19,8 @@ export function createReposApi(): NonNullable['repos']> { }, // A WSL move is a desktop's own; a paired web client never holds the retained local rows. listWslMoved: async () => [], + // WSL hosts are a desktop's own; a paired web client lists its one runtime live. + listHostCatalogSnapshots: async () => ({}), add: async ({ path, kind, displayName }) => { invalidateRuntimeWorktreeCaches() const owned = await callRuntimeResultWithOwner<{ repo: Repo } | { error: string }>( diff --git a/src/shared/host-repo-catalog-snapshot.ts b/src/shared/host-repo-catalog-snapshot.ts new file mode 100644 index 00000000000..6752f631b18 --- /dev/null +++ b/src/shared/host-repo-catalog-snapshot.ts @@ -0,0 +1,56 @@ +import type { Repo } from './repo-types' +import { DEFAULT_REPO_BADGE_COLOR } from './constants' + +/** The fields a sidebar project row needs; the host stamp is re-applied on read. */ +export type HostRepoCatalogSnapshotRepo = Pick< + Repo, + 'id' | 'path' | 'displayName' | 'badgeColor' | 'addedAt' | 'kind' | 'connectionId' +> + +/** A WSL host's last answered `repo.list`, kept on the desktop so a stopped distro still lists its projects. */ +export type HostRepoCatalogSnapshot = { + repos: HostRepoCatalogSnapshotRepo[] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function snapshotRepo(value: unknown): HostRepoCatalogSnapshotRepo | null { + if ( + !isRecord(value) || + typeof value.id !== 'string' || + !value.id || + typeof value.path !== 'string' || + typeof value.displayName !== 'string' + ) { + return null + } + return { + id: value.id, + path: value.path, + displayName: value.displayName, + badgeColor: typeof value.badgeColor === 'string' ? value.badgeColor : DEFAULT_REPO_BADGE_COLOR, + addedAt: typeof value.addedAt === 'number' ? value.addedAt : 0, + ...(value.kind === 'git' || value.kind === 'folder' ? { kind: value.kind } : {}), + // Part of row identity: on a host's row it names that host's nested SSH target. + ...(typeof value.connectionId === 'string' ? { connectionId: value.connectionId } : {}) + } +} + +/** Null when the payload is not a whole catalog: a partial answer must not erase cached projects. */ +export function hostRepoCatalogSnapshotFromRepoList( + result: unknown +): HostRepoCatalogSnapshot | null { + if (!isRecord(result) || !Array.isArray(result.repos)) { + return null + } + const repos: HostRepoCatalogSnapshotRepo[] = [] + for (const entry of result.repos) { + const repo = snapshotRepo(entry) + if (repo) { + repos.push(repo) + } + } + return { repos } +} diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index 98f07422cf3..4cbaa168572 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -18,6 +18,7 @@ import type { OnboardingState } from './onboarding-state-types' import type { PersistedUIState } from './persisted-ui-state-types' import type { ProjectGroup } from './project-group-types' import type { RemoteLayoutSnapshot } from './remote-layout-snapshot' +import type { HostRepoCatalogSnapshot } from './host-repo-catalog-snapshot' import type { Project, ProjectHostSetup } from './project-types' import type { Repo } from './repo-types' import type { SparsePreset } from './worktree/create-types' @@ -95,6 +96,8 @@ export type PersistedState = { /** Paired server’s last display layout, keyed by environment and written only by the snapshot recorder; * separate from runtime: partitions that older builds overwrite. */ remoteLayoutSnapshots?: Record + /** WSL host's last project list, keyed by environment; display-only until the host answers again. */ + hostRepoCatalogSnapshots?: Record sshTargets: SshTarget[] /** Highest SSH target registration generation issued; prevents identity reuse after rollback. */ sshTargetGenerationCounter?: number diff --git a/src/shared/wsl-drive-unc-state-dispositions.ts b/src/shared/wsl-drive-unc-state-dispositions.ts index 9265dae5412..69d9b3f4936 100644 --- a/src/shared/wsl-drive-unc-state-dispositions.ts +++ b/src/shared/wsl-drive-unc-state-dispositions.ts @@ -37,6 +37,7 @@ export const WSL_DRIVE_UNC_STATE_DISPOSITIONS = { // Partitions of SSH and paired hosts; the local partition is `workspaceSession`. workspaceSessionsByHostId: 'keep', remoteLayoutSnapshots: 'keep', + hostRepoCatalogSnapshots: 'keep', sshTargets: 'keep', sshTargetGenerationCounter: 'keep', deletedSshConfigAliases: 'keep', diff --git a/src/shared/wsl-move/wsl-move-dispositions.ts b/src/shared/wsl-move/wsl-move-dispositions.ts index 35c11545cb1..f31e9463e5a 100644 --- a/src/shared/wsl-move/wsl-move-dispositions.ts +++ b/src/shared/wsl-move/wsl-move-dispositions.ts @@ -77,6 +77,11 @@ export const PERSISTED_STATE_DISPOSITIONS = { item: 47, note: "Rebuilt from the host's first tab stream; the seeded workspaceSessionsByHostId gives first paint." }, + hostRepoCatalogSnapshots: { + disposition: 'rederive', + item: 47, + note: "Rebuilt from the host's next project list; it caches only that answer." + }, sshTargets: notApplicableDisposition(62), sshTargetGenerationCounter: notApplicableDisposition(62), deletedSshConfigAliases: notApplicableDisposition(62),