diff --git a/src/renderer/src/components/settings/SshPane.test.tsx b/src/renderer/src/components/settings/SshPane.test.tsx index fc9184a1025..3809f4895af 100644 --- a/src/renderer/src/components/settings/SshPane.test.tsx +++ b/src/renderer/src/components/settings/SshPane.test.tsx @@ -3,6 +3,7 @@ import { act, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' +import { resetSshConfigPassiveSyncForTests } from '../../lib/ssh-config-passive-sync' import type { SshTarget } from '../../../../shared/ssh-types' import { setRendererUiLanguage } from '@/i18n/i18n' import { SshPane } from './SshPane' @@ -54,6 +55,7 @@ afterEach(async () => { container = null root = null Reflect.deleteProperty(window, 'api') + resetSshConfigPassiveSyncForTests() vi.clearAllMocks() await setRendererUiLanguage('en') }) diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index f697a586b72..7844a2da2a9 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -19,6 +19,7 @@ import { getAllWorktreesFromState } from '@/store/selectors' import { toSshExecutionHostId } from '../../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { useSshAddTargetIntent } from './use-ssh-add-target-intent' +import { syncSshConfigTargetsPassively } from '../../lib/ssh-config-passive-sync' export { getSshPaneSearchEntries } from './ssh-search' type SshPaneProps = { addTargetIntentSignal?: number } @@ -76,12 +77,7 @@ export function SshPane({ addTargetIntentSignal }: SshPaneProps): React.JSX.Elem // and newly added hosts appear without a manual Import click. Best-effort — // a sync failure must not block listing the already-known targets. void (async () => { - try { - const result = await window.api.ssh.importConfig() - useAppStore.getState().recordSshRepoReadoptions(result.repoReadoptions) - } catch { - // Surfaced on demand via the explicit Import button; ignore here. - } + await syncSshConfigTargetsPassively() if (abortController.signal.aborted) { return } diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index 7eb5d7d03f2..a04880019ef 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -23,6 +23,7 @@ import { isListedRemoteServer } from '../../../../shared/runtime-environments' import { RuntimeHostStatusRow } from './RuntimeHostStatusRow' import { connectedHostCountLabel, connectingHostsLabel } from './ssh-status-segment-copy' import { SshTargetStatusRow } from './SshTargetStatusRow' +import { syncSshConfigTargetsPassively } from '../../lib/ssh-config-passive-sync' import { connectRuntimeHostAndReloadProjects } from './runtime-environment-explicit-connect' import { overallDotColor, @@ -159,6 +160,7 @@ export function SshStatusSegment({ onOpenChange={(open) => { if (open) { void hydrateRuntimeEnvironmentStatuses() + void syncSshConfigTargetsPassively() recordFeatureInteraction('ssh') } }} diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts index 4873d5481bc..46830e22334 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-hydration-target-metadata.test.ts @@ -6,8 +6,13 @@ import { } from '@/store/slices/runtime-environment-ssh' import type { SshConnectionState } from '../../../../shared/ssh-types' import { registerDirectSshStateIpcBridge } from './direct-ssh-state-ipc-bridge' +import { resetSshConfigPassiveSyncForTests } from '../../lib/ssh-config-passive-sync' type SshApiStub = { + importConfig?: () => Promise<{ + targets: { id: string; label: string }[] + repoReadoptions: never[] + }> listTargets: () => Promise<{ id: string; label: string }[]> listRemovedTargetLabels: () => Promise> getState: (args: { targetId: string }) => Promise @@ -53,6 +58,7 @@ describe('direct SSH hydration target metadata', () => { const unsubs: (() => void)[] = [] beforeEach(() => { + resetSshConfigPassiveSyncForTests() onStateChangedHandler = null useAppStore.setState({ sshTargetLabels: new Map(), @@ -70,6 +76,32 @@ describe('direct SSH hydration target metadata', () => { vi.restoreAllMocks() }) + it('syncs ~/.ssh/config after the saved hosts load, so a newly added host shows up', async () => { + const importConfig = vi + .fn>() + .mockResolvedValue({ targets: [{ id: 'ssh-new', label: 'new-box' }], repoReadoptions: [] }) + const listTargets = vi + .fn() + .mockResolvedValueOnce([{ id: 'ssh-old', label: 'old-box' }]) + .mockResolvedValue([ + { id: 'ssh-old', label: 'old-box' }, + { id: 'ssh-new', label: 'new-box' } + ]) + stubWindow({ + importConfig, + listTargets, + listRemovedTargetLabels: () => Promise.resolve({}), + getState: () => Promise.resolve(null) + }) + + registerDirectSshStateIpcBridge(unsubs) + await settle() + await settle() + + expect(importConfig).toHaveBeenCalledWith() + expect(useAppStore.getState().sshTargetLabels.get('ssh-new')).toBe('new-box') + }) + it('names a ghost host once its tombstone label lands', async () => { let resolveRemovedLabels!: (labels: Record) => void stubWindow({ diff --git a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts index 1c5b83d36ce..a05f36e5aad 100644 --- a/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/direct-ssh-state-ipc-bridge.ts @@ -7,6 +7,7 @@ import { type DirectSshConnectedStateOrigin } from '../direct-ssh-state-routing' import { hydrateDirectSshInitialState } from './direct-ssh-initial-state-hydration' +import { syncSshConfigTargetsPassively } from '../../lib/ssh-config-passive-sync' import { applySshManagedServerTransition } from './ssh-managed-server-state-effects' type AuthorityDeadline = { timer: ReturnType; settle: () => void } @@ -56,7 +57,12 @@ export function registerDirectSshStateIpcBridge(unsubs: (() => void)[]): () => v ) => void void hydrateDirectSshInitialState(isStopped, sshStateWatermarkByTargetId, (targetId, state) => applySshConnectionStateChange(targetId, state, 'initial-hydration') - ) + ).then(() => { + // Why: after the saved list paints, so a slow config parse never delays the first host list. + if (!isStopped()) { + void syncSshConfigTargetsPassively() + } + }) unsubs.push( window.api.ssh.onCredentialRequest((data) => { useAppStore.getState().enqueueSshCredentialRequest(data) diff --git a/src/renderer/src/lib/ssh-config-passive-sync.test.ts b/src/renderer/src/lib/ssh-config-passive-sync.test.ts new file mode 100644 index 00000000000..223e9d47777 --- /dev/null +++ b/src/renderer/src/lib/ssh-config-passive-sync.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '../store' +import { + resetSshConfigPassiveSyncForTests, + syncSshConfigTargetsPassively +} from './ssh-config-passive-sync' + +const importConfig = vi.fn() +const listTargets = vi.fn() + +beforeEach(() => { + vi.useFakeTimers() + resetSshConfigPassiveSyncForTests() + useAppStore.setState(useAppStore.getInitialState(), true) + importConfig.mockReset().mockResolvedValue({ targets: [], repoReadoptions: [] }) + listTargets.mockReset().mockResolvedValue([]) + vi.stubGlobal('window', { api: { ssh: { importConfig, listTargets } } }) +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('syncSshConfigTargetsPassively', () => { + it('imports without re-adopting deleted hosts and publishes newly added ones', async () => { + importConfig.mockResolvedValue({ + targets: [{ id: 'ssh-new', label: 'new-box' }], + repoReadoptions: [] + }) + listTargets.mockResolvedValue([ + { id: 'ssh-old', label: 'old-box' }, + { id: 'ssh-new', label: 'new-box' } + ]) + + await syncSshConfigTargetsPassively() + + expect(importConfig).toHaveBeenCalledWith() + expect(useAppStore.getState().sshTargetLabels.get('ssh-new')).toBe('new-box') + }) + + it('skips the listing when the config added or changed nothing', async () => { + await syncSshConfigTargetsPassively() + + expect(importConfig).toHaveBeenCalledTimes(1) + expect(listTargets).not.toHaveBeenCalled() + }) + + it('shares one sync between callers and waits out the interval before the next', async () => { + await Promise.all([syncSshConfigTargetsPassively(), syncSshConfigTargetsPassively()]) + await syncSshConfigTargetsPassively() + expect(importConfig).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(10_000) + await syncSshConfigTargetsPassively() + expect(importConfig).toHaveBeenCalledTimes(2) + }) + + it('retries the listing when the import already saved hosts but listing them failed', async () => { + importConfig.mockResolvedValueOnce({ + targets: [{ id: 'ssh-new', label: 'new-box' }], + repoReadoptions: [] + }) + listTargets.mockRejectedValueOnce(new Error('ipc busy')) + await syncSshConfigTargetsPassively() + + listTargets.mockResolvedValue([{ id: 'ssh-new', label: 'new-box' }]) + vi.advanceTimersByTime(10_000) + await syncSshConfigTargetsPassively() + + expect(useAppStore.getState().sshTargetLabels.get('ssh-new')).toBe('new-box') + }) + + it('swallows a failed import so callers still list known hosts', async () => { + importConfig.mockRejectedValue(new Error('bad config')) + + await expect(syncSshConfigTargetsPassively()).resolves.toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/ssh-config-passive-sync.ts b/src/renderer/src/lib/ssh-config-passive-sync.ts new file mode 100644 index 00000000000..92455f9648c --- /dev/null +++ b/src/renderer/src/lib/ssh-config-passive-sync.ts @@ -0,0 +1,48 @@ +import { useAppStore } from '../store' + +// Why: launch, the hosts menu and Settings → SSH can all ask within a second; one sync covers them. +const SSH_CONFIG_SYNC_MIN_INTERVAL_MS = 10_000 + +let inFlight: Promise | null = null +let lastStartedAt = Number.NEGATIVE_INFINITY +// An import persists hosts before they are listed; a failed listing must be retried next time. +let metadataRefreshPending = false + +/** + * Non-destructive ~/.ssh/config sync: no reAdopt, so hosts the user deleted stay deleted. Settings → + * SSH used to be its only trigger, which left the status-bar hosts menu stale for everyone else. + */ +export function syncSshConfigTargetsPassively(): Promise { + if (inFlight) { + return inFlight + } + const now = Date.now() + if (now - lastStartedAt < SSH_CONFIG_SYNC_MIN_INTERVAL_MS) { + return Promise.resolve() + } + lastStartedAt = now + inFlight = (async () => { + try { + const result = await window.api.ssh.importConfig() + const store = useAppStore.getState() + store.recordSshRepoReadoptions(result.repoReadoptions) + // Unchanged config is the common case; skip the extra listing and store write. + if (result.targets.length > 0 || metadataRefreshPending) { + metadataRefreshPending = true + useAppStore.getState().setSshTargetsMetadata(await window.api.ssh.listTargets()) + metadataRefreshPending = false + } + } catch { + // Best-effort; the explicit Import button surfaces failures. + } finally { + inFlight = null + } + })() + return inFlight +} + +export function resetSshConfigPassiveSyncForTests(): void { + inFlight = null + lastStartedAt = Number.NEGATIVE_INFINITY + metadataRefreshPending = false +}