diff --git a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md index 3e1fe164..d22a10dd 100644 --- a/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md +++ b/docs/prd/assistant-experience/assistant-workbar-and-execution-spaces-prd.md @@ -515,7 +515,9 @@ SSH/远程项目操作;已有 Host、项目和凭据保持不变。若当前 [SSH Host 远程环境准备与直连下载设计](../../architecture/remote-host-environment-provisioning-design.md)。 进入或切回“主机与远程执行”页面只读取本地 Host 列表,不逐台建立 SSH 连接。版本和瞬时 -下载能力仅在用户点击某台 Host 的“刷新版本”时探测。该动作不是项目连接前置条件;用户 +下载能力仅在用户点击某台 Host 的“刷新版本”时探测,成功结果在当前 Renderer 进程内按 +Host 持久化缓存,切换页面或重启应用后返回直接显示;Host 编辑、删除或开始更新远程环境后 +不复用旧结果。该动作不是项目连接前置条件;用户 切换已有远程项目只更新 Renderer 的本地项目选择,不建立 Host/Agent 连接。 新建远程项目弹窗同样只读取本地 Host 验证记录,不扫描所有 Host;浏览目录或保存时才连接 所选 Host。版本号相同时仍允许用户显式重新安装,以修复 registry、签名或 identity 异常, diff --git a/src/renderer/src/SshHostsSettingsSection.test.tsx b/src/renderer/src/SshHostsSettingsSection.test.tsx index fb0b328f..5ee12ebb 100644 --- a/src/renderer/src/SshHostsSettingsSection.test.tsx +++ b/src/renderer/src/SshHostsSettingsSection.test.tsx @@ -749,6 +749,23 @@ describe('SshHostsSettingsSection', () => { ).not.toBeNull() }) + it('keeps the last successful version check when the settings page remounts', async () => { + getSnapshot.mockResolvedValue(verifiedSnapshot) + const first = render() + + await refreshHostEnvironment() + expect(getRemoteEnvironment).toHaveBeenCalledOnce() + first.unmount() + + render() + const host = await screen.findByRole('region', { + name: 'Build host' + }) + + expect(within(host).getByText('GoodBuddy Agent')).toBeInTheDocument() + expect(getRemoteEnvironment).toHaveBeenCalledOnce() + }) + it('does not contact any verified Host when the section opens', async () => { getSnapshot.mockResolvedValue({ hosts: [ diff --git a/src/renderer/src/SshHostsSettingsSection.tsx b/src/renderer/src/SshHostsSettingsSection.tsx index 6ac490d0..c1660a43 100644 --- a/src/renderer/src/SshHostsSettingsSection.tsx +++ b/src/renderer/src/SshHostsSettingsSection.tsx @@ -31,6 +31,11 @@ import type { AppNotificationInput } from './notifications' import { SettingsCategoryHeader } from './SettingsPrimitives' import { displayErrorMessage } from './error-message' import { SshHostDialog } from './SshHostDialog' +import { + getCachedSshHostRemoteEnvironments, + removeCachedSshHostRemoteEnvironment, + setCachedSshHostRemoteEnvironment +} from './ssh-host-remote-environment-cache' import { DestructiveConfirmActions, EmptyState, @@ -112,7 +117,16 @@ export function SshHostsSettingsSection({ >({}) const [remoteEnvironments, setRemoteEnvironments] = useState< Record - >({}) + >(() => + Object.fromEntries( + Object.entries(getCachedSshHostRemoteEnvironments()).map( + ([hostId, value]) => [ + hostId, + { loading: false, value } + ] + ) + ) + ) const [ remoteEnvironmentPreparationMethods, setRemoteEnvironmentPreparationMethods @@ -252,6 +266,7 @@ export function SshHostsSettingsSection({ ...current, [hostId]: { loading: false, value } })) + setCachedSshHostRemoteEnvironment(value) } catch (reason) { if ( !mounted.current || @@ -285,6 +300,7 @@ export function SshHostsSettingsSection({ ) { return } + removeCachedSshHostRemoteEnvironment(host.id) const requestId = remoteEnvironmentUpdateRequestId.current + 1 remoteEnvironmentUpdateRequestId.current = requestId const activeUpdate: ActiveRemoteEnvironmentUpdate = { @@ -474,6 +490,7 @@ export function SshHostsSettingsSection({ const handleSaved = (result: SshHostValidationResult): void => { const edited = hosts.some((host) => host.id === result.host.id) + removeCachedSshHostRemoteEnvironment(result.host.id) setHosts((current) => upsertHost(current, result.host)) setTestResults((current) => ({ ...current, @@ -500,6 +517,7 @@ export function SshHostsSettingsSection({ result.deletedProjects.map((project) => project.id) ) setConfirmingDeleteId(undefined) + removeCachedSshHostRemoteEnvironment(host.id) setHosts((current) => current.filter((candidate) => candidate.id !== host.id) ) diff --git a/src/renderer/src/ssh-host-remote-environment-cache.test.ts b/src/renderer/src/ssh-host-remote-environment-cache.test.ts new file mode 100644 index 00000000..64846bfe --- /dev/null +++ b/src/renderer/src/ssh-host-remote-environment-cache.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { SshHostRemoteEnvironment } from '../../shared/ssh-host-contracts' +import { SshHostRemoteEnvironmentCache } from './ssh-host-remote-environment-cache' + +const hostId = '00000000-0000-4000-8000-000000000104' +const version = { + version: '1.2.3', + architecture: 'x64' as const +} +const remoteEnvironment: SshHostRemoteEnvironment = { + hostId, + checkedAt: '2026-08-31T00:00:00.000Z', + architecture: 'x64', + agent: { + state: 'current', + expected: version, + installed: version + }, + runtimes: [ + { + runtimeId: 'opencode', + provider: 'opencode', + state: 'current', + expected: version, + installed: version + } + ], + remoteDownload: { + available: true, + source: 'mirror', + packageSize: 1024 + } +} + +describe('SshHostRemoteEnvironmentCache', () => { + beforeEach(() => { + localStorage.clear() + }) + + it('restores successful checks in a new cache instance', () => { + const first = new SshHostRemoteEnvironmentCache(localStorage) + first.set(remoteEnvironment) + + const restarted = new SshHostRemoteEnvironmentCache(localStorage) + expect(restarted.getAll()).toEqual({ + [hostId]: remoteEnvironment + }) + }) + + it('persists Host invalidation across cache instances', () => { + const first = new SshHostRemoteEnvironmentCache(localStorage) + first.set(remoteEnvironment) + first.remove(hostId) + + const restarted = new SshHostRemoteEnvironmentCache(localStorage) + expect(restarted.getAll()).toEqual({}) + }) + + it('ignores malformed persisted values', () => { + localStorage.setItem( + 'goodbuddy.ssh-host-remote-environments.v1', + JSON.stringify([ + remoteEnvironment, + { ...remoteEnvironment, checkedAt: 'not-a-date' } + ]) + ) + + const restarted = new SshHostRemoteEnvironmentCache(localStorage) + expect(restarted.getAll()).toEqual({ + [hostId]: remoteEnvironment + }) + }) +}) diff --git a/src/renderer/src/ssh-host-remote-environment-cache.ts b/src/renderer/src/ssh-host-remote-environment-cache.ts new file mode 100644 index 00000000..24640514 --- /dev/null +++ b/src/renderer/src/ssh-host-remote-environment-cache.ts @@ -0,0 +1,119 @@ +import { + SSH_HOST_LIMITS, + sshHostRemoteEnvironmentSchema, + type SshHostRemoteEnvironment +} from '../../shared/ssh-host-contracts' + +type CacheStorage = Pick< + Storage, + 'getItem' | 'removeItem' | 'setItem' +> + +const storageKey = 'goodbuddy.ssh-host-remote-environments.v1' +const maximumPersistedPayloadLength = 1_000_000 + +function getLocalStorage(): CacheStorage | undefined { + try { + return typeof window === 'undefined' ? undefined : window.localStorage + } catch { + return undefined + } +} + +export class SshHostRemoteEnvironmentCache { + private readonly remoteEnvironments = new Map< + string, + SshHostRemoteEnvironment + >() + + constructor( + private readonly storage: CacheStorage | undefined = getLocalStorage() + ) { + this.load() + } + + getAll(): Readonly> { + return Object.fromEntries(this.remoteEnvironments) + } + + set(environment: SshHostRemoteEnvironment): void { + this.remoteEnvironments.set(environment.hostId, environment) + this.persist() + } + + remove(hostId: string): void { + if (this.remoteEnvironments.delete(hostId)) { + this.persist() + } + } + + clear(): void { + this.remoteEnvironments.clear() + try { + this.storage?.removeItem(storageKey) + } catch { + // The in-memory cache remains usable when persistence is unavailable. + } + } + + private load(): void { + try { + const raw = this.storage?.getItem(storageKey) + if (!raw || raw.length > maximumPersistedPayloadLength) { + return + } + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) { + return + } + for (const candidate of parsed.slice( + 0, + SSH_HOST_LIMITS.maximumHosts + )) { + const environment = + sshHostRemoteEnvironmentSchema.safeParse(candidate) + if (environment.success) { + this.remoteEnvironments.set( + environment.data.hostId, + environment.data + ) + } + } + } catch { + // Corrupt or inaccessible storage behaves like an empty cache. + } + } + + private persist(): void { + try { + this.storage?.setItem( + storageKey, + JSON.stringify([...this.remoteEnvironments.values()]) + ) + } catch { + // Version checks still remain cached for the current process. + } + } +} + +const cache = new SshHostRemoteEnvironmentCache() + +export function getCachedSshHostRemoteEnvironments(): Readonly< + Record +> { + return cache.getAll() +} + +export function setCachedSshHostRemoteEnvironment( + environment: SshHostRemoteEnvironment +): void { + cache.set(environment) +} + +export function removeCachedSshHostRemoteEnvironment(hostId: string): void { + cache.remove(hostId) +} + +export function clearSshHostRemoteEnvironmentCache(): void { + cache.clear() +} diff --git a/src/renderer/src/test-setup.ts b/src/renderer/src/test-setup.ts index 013ff0b2..0d735671 100644 --- a/src/renderer/src/test-setup.ts +++ b/src/renderer/src/test-setup.ts @@ -1,12 +1,14 @@ import '@testing-library/jest-dom/vitest' import { beforeEach, vi } from 'vitest' import i18n from './i18n' +import { clearSshHostRemoteEnvironmentCache } from './ssh-host-remote-environment-cache' if (typeof Element !== 'undefined') { Element.prototype.scrollTo = vi.fn() } beforeEach(async () => { + clearSshHostRemoteEnvironmentCache() if (typeof localStorage === 'undefined' || typeof document === 'undefined') { return }