diff --git a/plugins/ssh-terminal/locales/en.json b/plugins/ssh-terminal/locales/en.json index 219e511a0..40abb5e3a 100644 --- a/plugins/ssh-terminal/locales/en.json +++ b/plugins/ssh-terminal/locales/en.json @@ -44,6 +44,7 @@ "reconnect": "Reconnect", "reconnected": "Reconnected successfully", "reconnecting": "Reconnecting... ({{attempt}}/{{max}})", + "autoReconnecting": "Connection lost, reconnecting...", "searchCaseSensitive": "Match Case", "searchClose": "Close (Escape)", "searchNext": "Next Match (Enter)", @@ -352,6 +353,9 @@ }, "sudoPasswordAutoFill": { "label": "Fill sudo prompts" + }, + "autoReconnect": { + "label": "Reconnect automatically" } }, "user": { @@ -479,6 +483,8 @@ "none": "None", "passwordPromptAutoFillDesc": "Offer to fill in the saved password when the terminal asks for one. Turn off to never auto-fill any password on this host.", "passwordPromptAutoFillLabel": "Offer Password Auto-fill", + "autoReconnectLabel": "Auto Reconnect", + "autoReconnectDesc": "Reconnect on its own when the SSH connection drops, like after a network blip or sleep. Keeps the scrollback.", "rightClickSelectsWord": "Right Click Selects Word", "rightClickSelectsWordLabel": "Right-click Selects Word", "rightClickSelectsWordShortDesc": "Select the word under cursor on right-click", diff --git a/plugins/ssh-terminal/manifest.json b/plugins/ssh-terminal/manifest.json index 5137c013b..1684e7ddc 100644 --- a/plugins/ssh-terminal/manifest.json +++ b/plugins/ssh-terminal/manifest.json @@ -702,6 +702,13 @@ "labelKey": "settings.host.sudoPasswordAutoFill.label", "hidden": true, "default": false + }, + { + "key": "autoReconnect", + "type": "boolean", + "labelKey": "settings.host.autoReconnect.label", + "hidden": true, + "default": false } ] }, diff --git a/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx b/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx index 62c701f74..191ec6344 100644 --- a/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx +++ b/plugins/ssh-terminal/src/frontend/settings/HostTerminalSettings.tsx @@ -720,6 +720,16 @@ export function HostTerminalSettings({ onChange={(v) => setField("autoMosh", v)} /> + + setField("autoReconnect", v)} + /> + ( const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null); const localEchoRef = useRef(null); const sessionOriginRef = useRef<"local" | "remote">("local"); + // Set while an auto reconnect runs, so the old output stays on screen. + const keepScrollbackRef = useRef(false); const customKeybindingsRef = useRef([]); const resizeTimeout = useRef(null); const wasDisconnectedBySSH = useRef(false); @@ -1514,6 +1516,7 @@ const TerminalInner = forwardRef( if (reconnectAttempts.current >= maxReconnectAttempts) { setIsConnecting(false); + keepScrollbackRef.current = false; shouldNotReconnectRef.current = true; setShowDisconnectedOverlay(true); addLog({ @@ -1526,7 +1529,11 @@ const TerminalInner = forwardRef( isReconnectingRef.current = true; - if (terminal && !isAttachingSessionRef.current) { + if ( + terminal && + !isAttachingSessionRef.current && + !keepScrollbackRef.current + ) { terminal.clear(); } @@ -1564,7 +1571,7 @@ const TerminalInner = forwardRef( } if (terminal && hostConfig) { - if (!isAttachingSessionRef.current) { + if (!isAttachingSessionRef.current && !keepScrollbackRef.current) { terminal.clear(); } const cols = terminal.cols; @@ -1929,6 +1936,13 @@ const TerminalInner = forwardRef( errorMessage.toLowerCase().includes("timeout") || errorMessage.toLowerCase().includes("network") ) { + if (keepScrollbackRef.current && !isUnmountingRef.current) { + // The host is still unreachable; try again after the backoff. + isConnectingRef.current = false; + wasDisconnectedBySSH.current = false; + attemptReconnection(); + return; + } updateConnectionError(errorMessage); setIsConnected(false); if (terminal) { @@ -1948,6 +1962,7 @@ const TerminalInner = forwardRef( errorMessage.toLowerCase().includes("key"))) || errorMessage.toLowerCase().includes("incorrect password") ) { + keepScrollbackRef.current = false; updateConnectionError(errorMessage); setIsConnecting(false); shouldNotReconnectRef.current = true; @@ -1960,6 +1975,10 @@ const TerminalInner = forwardRef( updateConnectionError(errorMessage); setIsConnecting(false); } else if (msg.type === "connected") { + if (keepScrollbackRef.current) { + keepScrollbackRef.current = false; + reconnectAttempts.current = 0; + } wasConnectedRef.current = true; setIsConnected(true); setIsConnecting(false); @@ -2061,7 +2080,23 @@ const TerminalInner = forwardRef( if (onClose) onClose(); } else if (wasConnectedRef.current) { wasConnectedRef.current = false; - setShowDisconnectedOverlay(true); + if ( + termSettingsRef.current.autoReconnect && + !isUnmountingRef.current + ) { + wasDisconnectedBySSH.current = false; + shouldNotReconnectRef.current = false; + reconnectAttempts.current = 0; + keepScrollbackRef.current = true; + terminal?.write( + ` +${t("terminal.autoReconnecting")} +`, + ); + attemptReconnection(); + } else { + setShowDisconnectedOverlay(true); + } } else if (!connectionErrorRef.current) { updateConnectionError( msg.message || t("terminal.connectionRejected"), @@ -2386,6 +2421,7 @@ const TerminalInner = forwardRef( if ( !wasConnectedRef.current && !isAttachingSessionRef.current && + !keepScrollbackRef.current && event.wasClean && (event.code === 1005 || event.code === 1000) ) { diff --git a/plugins/ssh-terminal/src/shared/terminal-settings.ts b/plugins/ssh-terminal/src/shared/terminal-settings.ts index da2f6754c..d64fb96d7 100644 --- a/plugins/ssh-terminal/src/shared/terminal-settings.ts +++ b/plugins/ssh-terminal/src/shared/terminal-settings.ts @@ -81,6 +81,7 @@ export interface TerminalBehavior { localEcho: HostLocalEcho; passwordPromptAutoFill: boolean; sudoPasswordAutoFill: boolean; + autoReconnect: boolean; } /** Everything the host editor's Terminal tab edits. */ @@ -121,6 +122,7 @@ export const BEHAVIOR_KEYS = [ "localEcho", "passwordPromptAutoFill", "sudoPasswordAutoFill", + "autoReconnect", ] as const satisfies readonly (keyof TerminalBehavior)[]; export const INHERIT_APPEARANCE_KEY = "inheritAppearance"; @@ -169,6 +171,7 @@ export const DEFAULT_BEHAVIOR: TerminalBehavior = { localEcho: "auto", passwordPromptAutoFill: true, sudoPasswordAutoFill: false, + autoReconnect: false, }; export const DEFAULT_HOST_TERMINAL_SETTINGS: HostTerminalSettings = { diff --git a/plugins/ssh-terminal/tests/frontend/terminal/auto-reconnect.test.ts b/plugins/ssh-terminal/tests/frontend/terminal/auto-reconnect.test.ts new file mode 100644 index 000000000..c738ed229 --- /dev/null +++ b/plugins/ssh-terminal/tests/frontend/terminal/auto-reconnect.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment node +import fs from "node:fs"; +import vm from "node:vm"; +import { describe, expect, it, vi } from "vitest"; + +const source = fs.readFileSync( + new URL("../../../src/frontend/terminal/Terminal.tsx", import.meta.url), + "utf8", +); +const attempt = source.slice( + source.indexOf(" function attemptReconnection()"), + source.indexOf(" // A persisted session that timed out"), +); + +function run(keepScrollback: boolean, attempts = 0) { + const timers: Array<() => void> = []; + const context = { + terminal: { cols: 100, rows: 30, clear: vi.fn() }, + hostConfig: { id: 1 }, + isUnmountingRef: { current: false }, + shouldNotReconnectRef: { current: false }, + isReconnectingRef: { current: false }, + isConnectingRef: { current: false }, + isAttachingSessionRef: { current: false }, + wasDisconnectedBySSH: { current: false }, + reconnectTimeoutRef: { current: null as unknown }, + reconnectAttempts: { current: attempts }, + keepScrollbackRef: { current: keepScrollback }, + maxReconnectAttempts: 8, + setIsConnecting: vi.fn(), + setShowDisconnectedOverlay: vi.fn(), + addLog: vi.fn(), + t: (key: string) => key, + connectToHost: vi.fn(), + setTimeout: (fn: () => void) => { + timers.push(fn); + return 1; + }, + Math, + }; + vm.runInNewContext(`${attempt}\nattemptReconnection();`, context); + timers.forEach((fn) => fn()); + return context; +} + +describe("terminal auto reconnect", () => { + it("keeps the scrollback while reconnecting after a drop", () => { + const context = run(true); + expect(context.terminal.clear).not.toHaveBeenCalled(); + expect(context.connectToHost).toHaveBeenCalledWith(100, 30); + }); + + it("still clears the screen on an ordinary reconnect", () => { + const context = run(false); + expect(context.terminal.clear).toHaveBeenCalled(); + }); + + it("gives up after the last attempt and stops keeping scrollback", () => { + const context = run(true, 8); + expect(context.connectToHost).not.toHaveBeenCalled(); + expect(context.setShowDisconnectedOverlay).toHaveBeenCalledWith(true); + expect(context.keepScrollbackRef.current).toBe(false); + }); +});