diff --git a/src/main/ipc/remote-workspace.test.ts b/src/main/ipc/remote-workspace.test.ts index 73b8bdbf7..1e9ca3744 100644 --- a/src/main/ipc/remote-workspace.test.ts +++ b/src/main/ipc/remote-workspace.test.ts @@ -1,9 +1,43 @@ -import { describe, expect, it } from 'vitest' -import { remoteWorkspaceSessionMatchesSnapshot } from './remote-workspace' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ipcMain } from 'electron' +import type { Store } from '../persistence' import type { RemoteWorkspaceSession, RemoteWorkspaceSnapshot } from '../../shared/remote-workspace-types' +import type { SshTarget } from '../../shared/ssh-types' +import type { WorkspaceSessionState } from '../../shared/types' + +const { + getActiveMultiplexerMock, + getSshConnectionStoreMock, + registerRemoteWorkspaceNotificationHandlerMock +} = vi.hoisted(() => ({ + getActiveMultiplexerMock: vi.fn(), + getSshConnectionStoreMock: vi.fn(), + registerRemoteWorkspaceNotificationHandlerMock: vi.fn(() => vi.fn()) +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: vi.fn(), + removeHandler: vi.fn() + } +})) + +vi.mock('./ssh', () => ({ + getActiveMultiplexer: getActiveMultiplexerMock, + getSshConnectionStore: getSshConnectionStoreMock +})) + +vi.mock('./remote-workspace-events', () => ({ + registerRemoteWorkspaceNotificationHandler: registerRemoteWorkspaceNotificationHandlerMock +})) + +import { + registerRemoteWorkspaceHandlers, + remoteWorkspaceSessionMatchesSnapshot +} from './remote-workspace' function snapshot(session: RemoteWorkspaceSession): RemoteWorkspaceSnapshot { return { @@ -15,6 +49,30 @@ function snapshot(session: RemoteWorkspaceSession): RemoteWorkspaceSnapshot { } } +const baseSession = { + activeWorktreeId: null, + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {} +} as WorkspaceSessionState + +const targets: SshTarget[] = [ + { + id: 'target-1', + label: 'Target 1', + host: 'one.example.com', + port: 22, + username: 'alice' + }, + { + id: 'target-2', + label: 'Target 2', + host: 'two.example.com', + port: 22, + username: 'alice' + } +] + describe('remoteWorkspaceSessionMatchesSnapshot', () => { it('matches normalized equivalent sessions', () => { expect( @@ -85,3 +143,103 @@ describe('remoteWorkspaceSessionMatchesSnapshot', () => { ).toBe(false) }) }) + +describe('remoteWorkspace:setForConnectedTargets', () => { + const handlers = new Map unknown>() + const requestByTargetId = new Map>() + const muxByTargetId = new Map }>() + const store = { + getRepo: vi.fn() + } as unknown as Store + + beforeEach(() => { + handlers.clear() + requestByTargetId.clear() + muxByTargetId.clear() + vi.mocked(ipcMain.handle).mockReset() + vi.mocked(ipcMain.handle).mockImplementation((channel, handler) => { + handlers.set(channel, handler as (event: unknown, args: unknown) => unknown) + }) + vi.mocked(ipcMain.removeHandler).mockReset() + getSshConnectionStoreMock.mockReset() + getSshConnectionStoreMock.mockReturnValue({ + listTargets: () => targets + }) + getActiveMultiplexerMock.mockReset() + getActiveMultiplexerMock.mockImplementation((targetId: string) => { + let mux = muxByTargetId.get(targetId) + if (!mux) { + const request = vi.fn().mockImplementation((method: string) => { + if (method === 'workspace.get') { + return Promise.resolve( + snapshot({ + activeWorktreePath: '/previous', + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }) + ) + } + return Promise.resolve({ + ok: true, + snapshot: snapshot({ + activeWorktreePath: null, + activeTabId: null, + tabsByWorktreePath: {}, + terminalLayoutsByTabId: {} + }) + }) + }) + mux = { request } + muxByTargetId.set(targetId, mux) + requestByTargetId.set(targetId, request) + } + return mux + }) + registerRemoteWorkspaceNotificationHandlerMock.mockClear() + + registerRemoteWorkspaceHandlers(store, () => null) + }) + + async function callSetForConnectedTargets(args: { + session: WorkspaceSessionState + hydratedTargetIds?: unknown + }): Promise { + const handler = handlers.get('remoteWorkspace:setForConnectedTargets') + if (!handler) { + throw new Error('remoteWorkspace:setForConnectedTargets handler was never registered') + } + return handler(null, args) + } + + it('does not write without an explicit non-empty hydrated target set', async () => { + await expect(callSetForConnectedTargets({ session: baseSession })).resolves.toEqual([]) + await expect( + callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: [] }) + ).resolves.toEqual([]) + await expect( + callSetForConnectedTargets({ session: baseSession, hydratedTargetIds: ['target-1', 42] }) + ).resolves.toEqual([]) + + expect(getSshConnectionStoreMock).not.toHaveBeenCalled() + expect(getActiveMultiplexerMock).not.toHaveBeenCalled() + }) + + it('writes only to explicitly hydrated connected targets', async () => { + const result = await callSetForConnectedTargets({ + session: baseSession, + hydratedTargetIds: ['target-1', 'missing-target'] + }) + + expect(result).toMatchObject([{ targetId: 'target-1', result: { ok: true } }]) + expect(getActiveMultiplexerMock).toHaveBeenCalledWith('target-1') + expect(getActiveMultiplexerMock).not.toHaveBeenCalledWith('target-2') + expect(requestByTargetId.get('target-1')).toHaveBeenCalledWith( + 'workspace.patch', + expect.objectContaining({ + patch: expect.objectContaining({ kind: 'replace-session' }) + }) + ) + expect(requestByTargetId.get('target-2')).toBeUndefined() + }) +}) diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 29db0172b..5f3a6cc03 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -152,6 +152,17 @@ function normalizeConnectedClients( .filter((entry): entry is RemoteWorkspaceConnectedClient => entry !== null) } +function getExplicitHydratedTargetIds(value: unknown): Set | null { + if ( + !Array.isArray(value) || + value.length === 0 || + value.some((targetId) => typeof targetId !== 'string' || targetId.length === 0) + ) { + return null + } + return new Set(value) +} + function targetForWorktree(store: Store, worktreeId: string): string | null { const repoId = getRepoIdFromWorktreeId(worktreeId) return store.getRepo(repoId)?.connectionId ?? null @@ -191,9 +202,6 @@ function importSessionForTarget( } async function getRemoteSnapshot(target: SshTarget): Promise { - if (!target.remoteWorkspaceSyncEnabled) { - return null - } const mux = getActiveMultiplexer(target.id) if (!mux) { return null @@ -221,7 +229,7 @@ export function handleRemoteWorkspaceNotification( return } const target = getSshConnectionStore()?.getTarget(targetId) - if (!target?.remoteWorkspaceSyncEnabled) { + if (!target) { return } const namespace = getRemoteWorkspaceNamespace(target) @@ -263,18 +271,18 @@ export function registerRemoteWorkspaceHandlers( ipcMain.handle( 'remoteWorkspace:setForConnectedTargets', - async (_event, args: { session: WorkspaceSessionState; hydratedTargetIds?: string[] }) => { - const hydratedTargetIds = Array.isArray(args.hydratedTargetIds) - ? new Set(args.hydratedTargetIds) - : null + async (_event, args: { session: WorkspaceSessionState; hydratedTargetIds?: unknown }) => { + const hydratedTargetIds = getExplicitHydratedTargetIds(args.hydratedTargetIds) + if (!hydratedTargetIds) { + // Why: an omitted hydration set used to broadcast one session to every + // SSH target, overwriting unrelated remote workspace snapshots. + return [] + } const targets = getSshConnectionStore() ?.listTargets() .filter( - (target) => - target.remoteWorkspaceSyncEnabled && - getActiveMultiplexer(target.id) && - (!hydratedTargetIds || hydratedTargetIds.has(target.id)) + (target) => hydratedTargetIds.has(target.id) && getActiveMultiplexer(target.id) ) ?? [] const results: { targetId: string; result: RemoteWorkspacePatchResult }[] = [] @@ -331,7 +339,7 @@ export function registerRemoteWorkspaceHandlers( async () => getSshConnectionStore() ?.listTargets() - .filter((target) => target.remoteWorkspaceSyncEnabled && getActiveMultiplexer(target.id)) + .filter((target) => getActiveMultiplexer(target.id)) .map((target) => target.id) ?? [] ) @@ -344,7 +352,6 @@ export function registerRemoteWorkspaceHandlers( ?.listTargets() .filter( (target) => - target.remoteWorkspaceSyncEnabled && getActiveMultiplexer(target.id) && (!requestedTargetIds || requestedTargetIds.has(target.id)) ) ?? [] diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 43e40ffd5..87a254f82 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -8,7 +8,6 @@ import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { SshRelaySession } from '../ssh/ssh-relay-session' import { SshPortForwardManager } from '../ssh/ssh-port-forward' import { - DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS, type DetectedPort, type SavedPortForward, type SshTarget, @@ -39,15 +38,7 @@ let portForwardManager: SshPortForwardManager | null = null const activeSessions = new Map() function relayGracePeriodForTarget(target: SshTarget | null | undefined): number | undefined { - if (!target?.remoteWorkspaceSyncEnabled) { - return target?.relayGracePeriodSeconds - } - // Why: cross-device sync should survive transient app closes, but an - // unset value must not mean "keep remote PTYs forever" after disconnect. - return ( - target.remoteWorkspaceSyncGracePeriodSeconds ?? - DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS - ) + return target?.relayGracePeriodSeconds } // Why: multiple renderer tabs for the same SSH target can fire ssh:connect diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 7f3c366f6..fe7cdf940 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -301,6 +301,69 @@ describe('Store', () => { expect(repos[0].gitUsername).toBe('testuser') }) + it('normalizes legacy remote workspace sync fields on SSH targets', async () => { + writeDataFile({ + schemaVersion: 1, + repos: [], + worktreeMeta: {}, + settings: {}, + ui: {}, + githubCache: { pr: {}, issue: {} }, + workspaceSession: {}, + sshTargets: [ + { + id: 'ssh-disabled-legacy-grace', + label: 'Disabled legacy grace', + host: 'disabled.example.com', + port: 22, + username: 'dev', + remoteWorkspaceSyncEnabled: false, + remoteWorkspaceSyncGracePeriodSeconds: 0 + }, + { + id: 'ssh-enabled-legacy-grace', + label: 'Enabled legacy grace', + host: 'enabled.example.com', + port: 22, + username: 'dev', + remoteWorkspaceSyncEnabled: true, + remoteWorkspaceSyncGracePeriodSeconds: 0 + }, + { + id: 'ssh-new-grace-period-wins', + label: 'New grace period', + host: 'new.example.com', + port: 22, + username: 'dev', + relayGracePeriodSeconds: 120, + remoteWorkspaceSyncEnabled: true, + remoteWorkspaceSyncGracePeriodSeconds: 0 + } + ] + }) + + const store = await createStore() + const targets = store.getSshTargets() + + expect(targets[0]).not.toHaveProperty('relayGracePeriodSeconds') + expect(targets[1].relayGracePeriodSeconds).toBe(0) + expect(targets[2].relayGracePeriodSeconds).toBe(120) + for (const target of targets) { + expect(target).not.toHaveProperty('remoteWorkspaceSyncEnabled') + expect(target).not.toHaveProperty('remoteWorkspaceSyncGracePeriodSeconds') + } + + store.flush() + const persisted = readDataFile() as { sshTargets?: Record[] } + expect(persisted.sshTargets?.[0]).not.toHaveProperty('relayGracePeriodSeconds') + expect(persisted.sshTargets?.[1]?.relayGracePeriodSeconds).toBe(0) + expect(persisted.sshTargets?.[2]?.relayGracePeriodSeconds).toBe(120) + for (const target of persisted.sshTargets ?? []) { + expect(target).not.toHaveProperty('remoteWorkspaceSyncEnabled') + expect(target).not.toHaveProperty('remoteWorkspaceSyncGracePeriodSeconds') + } + }) + it('drops malformed migration-unsupported PTY entries on load', async () => { const repo = makeRepo() writeDataFile({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 6e2b112b1..f2e08363d 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -240,10 +240,36 @@ function normalizeAutomationSessionReuse(automation: Automation): Automation { } } +type LegacySshTarget = SshTarget & { + remoteWorkspaceSyncEnabled?: unknown + remoteWorkspaceSyncGracePeriodSeconds?: unknown +} + // Why: old persisted targets predate configHost. Default to label-based lookup // so imported SSH aliases keep resolving through ssh -G after upgrade. function normalizeSshTarget(t: SshTarget): SshTarget { - return { ...t, configHost: t.configHost ?? t.label ?? t.host } + const target = { ...(t as LegacySshTarget) } + const legacySyncEnabled = target.remoteWorkspaceSyncEnabled + const currentGracePeriodSeconds = target.relayGracePeriodSeconds + const legacyGracePeriodSeconds = target.remoteWorkspaceSyncGracePeriodSeconds + // Why: remote workspace sync now follows the SSH relay lifecycle, so the + // retired per-target sync opt-out and grace-period fields stop at disk load. + delete target.remoteWorkspaceSyncEnabled + delete target.remoteWorkspaceSyncGracePeriodSeconds + delete target.relayGracePeriodSeconds + const relayGracePeriodSeconds = + currentGracePeriodSeconds ?? + (legacySyncEnabled === true && typeof legacyGracePeriodSeconds === 'number' + ? legacyGracePeriodSeconds + : undefined) + const normalized: SshTarget = { + ...target, + configHost: target.configHost ?? target.label ?? target.host + } + if (relayGracePeriodSeconds !== undefined) { + normalized.relayGracePeriodSeconds = relayGracePeriodSeconds + } + return normalized } // Why: shared by load-time merge and the IPC update handler so the same diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 5f9aede4b..39ef185da 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -52,7 +52,10 @@ vi.mock('./ssh-connection-utils', () => ({ import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand } from './ssh-relay-deploy-helpers' import type { SshConnection } from './ssh-connection' -import { DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../shared/ssh-types' +import { + DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS +} from '../../shared/ssh-types' function makeMockConnection(): SshConnection { return { @@ -151,6 +154,25 @@ describe('deployAndLaunchRelay', () => { expect(launchCommand).toContain('--grace-time 0') }) + it('clamps configured SSH disconnect grace to the seven-day maximum', async () => { + const conn = makeMockConnection() + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand.mockResolvedValueOnce('Linux x86_64') + mockExecCommand.mockResolvedValueOnce('/home/user') + mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + mockExecCommand.mockResolvedValueOnce('DEAD') + mockExecCommand.mockResolvedValueOnce('READY') + + await deployAndLaunchRelay(conn, undefined, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS + 1, 'target-a') + + const launchCommand = vi + .mocked(conn.exec) + .mock.calls.map(([cmd]) => cmd as string) + .find((cmd) => cmd.includes('--detached')) + + expect(launchCommand).toContain(`--grace-time ${MAX_SSH_RELAY_GRACE_PERIOD_SECONDS}`) + }) + it('uses a content-hashed versioned remote install directory', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index d35db6133..27fcf52ab 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -134,6 +134,18 @@ describe('PtyHandler', () => { expect(onExpire).toHaveBeenCalledTimes(1) }) + it('does not expire an unlimited grace timer', () => { + const onExpire = vi.fn() + handler.startGraceTimer(onExpire, 100) + + expect(handler.graceTimerActive).toBe(true) + handler.startGraceTimer(onExpire, 0) + + expect(handler.graceTimerActive).toBe(false) + vi.advanceTimersByTime(100) + expect(onExpire).not.toHaveBeenCalled() + }) + it('spawns a PTY and returns an id', async () => { const result = await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24 }) expect(result).toEqual({ id: 'pty-1' }) diff --git a/src/renderer/src/components/settings/SshPane.tsx b/src/renderer/src/components/settings/SshPane.tsx index 4d950ca97..453639e82 100644 --- a/src/renderer/src/components/settings/SshPane.tsx +++ b/src/renderer/src/components/settings/SshPane.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useState } from 'react' import { toast } from 'sonner' import { Plus, Upload } from 'lucide-react' import { - DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS, DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, MIN_SSH_RELAY_GRACE_PERIOD_SECONDS, @@ -90,23 +89,18 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { return } - const graceSeconds = parseInt(form.relayGracePeriodSeconds, 10) + const graceSeconds = form.relayKeepAliveUntilReset + ? 0 + : parseInt(form.relayGracePeriodSeconds, 10) if ( - isNaN(graceSeconds) || - (graceSeconds !== 0 && graceSeconds < MIN_SSH_RELAY_GRACE_PERIOD_SECONDS) || - graceSeconds > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS + !form.relayKeepAliveUntilReset && + (isNaN(graceSeconds) || + graceSeconds < MIN_SSH_RELAY_GRACE_PERIOD_SECONDS || + graceSeconds > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS) ) { - toast.error('Relay grace period must be 0 or between 60 and 10800 seconds') - return - } - const remoteGraceSeconds = parseInt(form.remoteWorkspaceSyncGracePeriodSeconds, 10) - if ( - form.remoteWorkspaceSyncEnabled && - (isNaN(remoteGraceSeconds) || - remoteGraceSeconds < 0 || - remoteGraceSeconds > MAX_SSH_RELAY_GRACE_PERIOD_SECONDS) - ) { - toast.error('Synced relay grace period must be between 0 and 10800 seconds') + toast.error( + `Relay grace period must be between 60 and ${MAX_SSH_RELAY_GRACE_PERIOD_SECONDS} seconds, or choose keep alive until reset` + ) return } @@ -117,10 +111,6 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { port, username: form.username.trim(), relayGracePeriodSeconds: graceSeconds, - remoteWorkspaceSyncEnabled: form.remoteWorkspaceSyncEnabled, - remoteWorkspaceSyncGracePeriodSeconds: form.remoteWorkspaceSyncEnabled - ? remoteGraceSeconds - : DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS, ...(form.identityFile.trim() ? { identityFile: form.identityFile.trim() } : {}), ...(form.proxyCommand.trim() ? { proxyCommand: form.proxyCommand.trim() } : {}), ...(form.jumpHost.trim() ? { jumpHost: form.jumpHost.trim() } : {}) @@ -183,13 +173,11 @@ export function SshPane(_props: SshPaneProps): React.JSX.Element { proxyCommand: target.proxyCommand ?? '', jumpHost: target.jumpHost ?? '', relayGracePeriodSeconds: String( - target.relayGracePeriodSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS + target.relayGracePeriodSeconds === 0 + ? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS + : (target.relayGracePeriodSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS) ), - remoteWorkspaceSyncEnabled: target.remoteWorkspaceSyncEnabled === true, - remoteWorkspaceSyncGracePeriodSeconds: String( - target.remoteWorkspaceSyncGracePeriodSeconds ?? - DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS - ) + relayKeepAliveUntilReset: target.relayGracePeriodSeconds === 0 }) setShowForm(true) } diff --git a/src/renderer/src/components/settings/SshTargetForm.tsx b/src/renderer/src/components/settings/SshTargetForm.tsx index 6b3483155..a92b8471b 100644 --- a/src/renderer/src/components/settings/SshTargetForm.tsx +++ b/src/renderer/src/components/settings/SshTargetForm.tsx @@ -1,8 +1,8 @@ import { FileKey } from 'lucide-react' import { - DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS, DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS, - MAX_SSH_RELAY_GRACE_PERIOD_SECONDS + MAX_SSH_RELAY_GRACE_PERIOD_SECONDS, + MIN_SSH_RELAY_GRACE_PERIOD_SECONDS } from '../../../../shared/ssh-types' import { Button } from '../ui/button' import { Input } from '../ui/input' @@ -18,8 +18,7 @@ export type EditingTarget = { proxyCommand: string jumpHost: string relayGracePeriodSeconds: string - remoteWorkspaceSyncEnabled: boolean - remoteWorkspaceSyncGracePeriodSeconds: string + relayKeepAliveUntilReset: boolean } export const EMPTY_FORM: EditingTarget = { @@ -32,8 +31,7 @@ export const EMPTY_FORM: EditingTarget = { proxyCommand: '', jumpHost: '', relayGracePeriodSeconds: String(DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS), - remoteWorkspaceSyncEnabled: false, - remoteWorkspaceSyncGracePeriodSeconds: String(DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS) + relayKeepAliveUntilReset: false } type SshTargetFormProps = { @@ -136,60 +134,36 @@ export function SshTargetForm({
onFormChange((f) => ({ ...f, relayGracePeriodSeconds: e.target.value })) } placeholder={String(DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS)} - min={0} + min={MIN_SSH_RELAY_GRACE_PERIOD_SECONDS} max={MAX_SSH_RELAY_GRACE_PERIOD_SECONDS} + disabled={form.relayKeepAliveUntilReset} /> -

- How long the relay keeps terminals alive after disconnect. Default: 10800 (3 hours). 0 - keeps it alive until terminals are ended or the relay is reset. -

-
-
-
diff --git a/src/renderer/src/components/status-bar/SshStatusSegment.tsx b/src/renderer/src/components/status-bar/SshStatusSegment.tsx index c422fe9cb..ca37c2c1f 100644 --- a/src/renderer/src/components/status-bar/SshStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/SshStatusSegment.tsx @@ -105,13 +105,11 @@ function TargetRow({ targetId, label, status, - syncEnabled, syncStatus }: { targetId: string label: string status: SshConnectionStatus - syncEnabled: boolean syncStatus: RemoteWorkspaceSyncStatus | undefined }): React.JSX.Element { const [busy, setBusy] = useState(false) @@ -145,23 +143,17 @@ function TargetRow({
{label}
{STATUS_LABELS[status]} - {syncEnabled && ( - <> - - - {syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? ( - - ) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? ( - - ) : ( - - )} - {syncStatusLabel(syncStatus)} - - - )} + + + {syncStatus?.phase === 'pulling' || syncStatus?.phase === 'pushing' ? ( + + ) : syncStatus?.phase === 'conflict' || syncStatus?.phase === 'error' ? ( + + ) : ( + + )} + {syncStatusLabel(syncStatus)} +
{busy ? ( @@ -196,7 +188,6 @@ export function SshStatusSegment({ }): React.JSX.Element | null { const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) - const sshTargetRemoteSyncEnabled = useAppStore((s) => s.sshTargetRemoteSyncEnabled) const remoteWorkspaceSyncStatusByTargetId = useAppStore( (s) => s.remoteWorkspaceSyncStatusByTargetId ) @@ -209,7 +200,6 @@ export function SshStatusSegment({ id, label, status: (state?.status ?? 'disconnected') as SshConnectionStatus, - syncEnabled: sshTargetRemoteSyncEnabled.get(id) === true, syncStatus: remoteWorkspaceSyncStatusByTargetId[id] } }) @@ -293,7 +283,6 @@ export function SshStatusSegment({ targetId={t.id} label={t.label} status={t.status} - syncEnabled={t.syncEnabled} syncStatus={t.syncStatus} /> ))} diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 4bcb7b81c..38a188fe2 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -363,9 +363,6 @@ async function applyRemoteWorkspaceSnapshot( async function syncRemoteWorkspaceAfterConnect(targetId: string): Promise { const store = useAppStore.getState() - if (store.sshTargetRemoteSyncEnabled.get(targetId) !== true) { - return - } if (!(await prepareRemoteWorkspaceTarget(targetId))) { store.setRemoteWorkspaceSyncStatus(targetId, { phase: 'error', @@ -1588,14 +1585,12 @@ export function useIpcEvents(): void { useAppStore.getState().setPortForwards(target.id, forwards) useAppStore.getState().setDetectedPorts(target.id, detected) } - if (target.remoteWorkspaceSyncEnabled) { - void syncRemoteWorkspaceAfterConnect(target.id).catch((err) => { - useAppStore.getState().setRemoteWorkspaceSyncStatus(target.id, { - phase: 'error', - message: err instanceof Error ? err.message : 'Workspace sync failed' - }) + void syncRemoteWorkspaceAfterConnect(target.id).catch((err) => { + useAppStore.getState().setRemoteWorkspaceSyncStatus(target.id, { + phase: 'error', + message: err instanceof Error ? err.message : 'Workspace sync failed' }) - } + }) } } } diff --git a/src/renderer/src/store/slices/ssh.ts b/src/renderer/src/store/slices/ssh.ts index e2ae98fe5..1206bb280 100644 --- a/src/renderer/src/store/slices/ssh.ts +++ b/src/renderer/src/store/slices/ssh.ts @@ -28,7 +28,6 @@ export type SshSlice = { /** Maps target IDs to their user-facing labels. Populated during hydration * so components can look up labels without per-component IPC calls. */ sshTargetLabels: Map - sshTargetRemoteSyncEnabled: Map remoteWorkspaceHydratedTargetIds: Set remoteWorkspaceSyncStatusByTargetId: Record sshCredentialQueue: SshCredentialRequest[] @@ -46,9 +45,7 @@ export type SshSlice = { detectedPortsByConnection: Record setSshConnectionState: (targetId: string, state: SshConnectionState) => void setSshTargetLabels: (labels: Map) => void - setSshTargetsMetadata: ( - targets: Pick[] - ) => void + setSshTargetsMetadata: (targets: Pick[]) => void markRemoteWorkspaceHydrated: (targetId: string) => void clearRemoteWorkspaceHydrated: (targetId: string) => void setRemoteWorkspaceSyncStatus: (targetId: string, status: RemoteWorkspaceSyncStatus) => void @@ -62,7 +59,6 @@ export type SshSlice = { export const createSshSlice: StateCreator = (set) => ({ sshConnectionStates: new Map(), sshTargetLabels: new Map(), - sshTargetRemoteSyncEnabled: new Map(), remoteWorkspaceHydratedTargetIds: new Set(), remoteWorkspaceSyncStatusByTargetId: {}, sshCredentialQueue: [], @@ -87,10 +83,7 @@ export const createSshSlice: StateCreator = (set) => setSshTargetLabels: (labels) => set({ sshTargetLabels: labels }), setSshTargetsMetadata: (targets) => set({ - sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])), - sshTargetRemoteSyncEnabled: new Map( - targets.map((target) => [target.id, target.remoteWorkspaceSyncEnabled === true]) - ) + sshTargetLabels: new Map(targets.map((target) => [target.id, target.label])) }), markRemoteWorkspaceHydrated: (targetId) => set((s) => { diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index 393c94d19..75640aacb 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -1,10 +1,8 @@ // ─── SSH Connection Types ─────────────────────────────────────────── export const MIN_SSH_RELAY_GRACE_PERIOD_SECONDS = 60 -export const MAX_SSH_RELAY_GRACE_PERIOD_SECONDS = 3 * 60 * 60 +export const MAX_SSH_RELAY_GRACE_PERIOD_SECONDS = 7 * 24 * 60 * 60 export const DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS = 3 * 60 * 60 -export const DEFAULT_REMOTE_WORKSPACE_SYNC_GRACE_PERIOD_SECONDS = - DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS export type SshTarget = { id: string @@ -21,15 +19,8 @@ export type SshTarget = { /** Jump host (ProxyJump), if any. */ jumpHost?: string /** Grace period in seconds before relay shuts down after disconnect. - * 0 disables expiry. Default: 10800 (3 hours). */ + * 0 disables expiry. Default: 10800 (3 hours). Max: 604800 (7 days). */ relayGracePeriodSeconds?: number - /** Opt in to remote-host-owned workspace/session state for this SSH target. - * Classic SSH remains local-session-backed when this is false/absent. */ - remoteWorkspaceSyncEnabled?: boolean - /** Grace period in seconds for synced remote workspace relays. - * 0 disables expiry. Default: 10800 (3 hours). Only applies when - * remoteWorkspaceSyncEnabled is true. */ - remoteWorkspaceSyncGracePeriodSeconds?: number /** Set to true after a successful connection that triggered a credential * prompt (passphrase or password). Persisted so startup reconnect can * partition targets into eager (no passphrase) vs deferred (passphrase)