From e161ad22e05b28b593e4c5aebdb03520e7edc23e Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Wed, 30 Sep 2026 22:48:15 +0800 Subject: [PATCH] feat: reconnect all disconnected terminal tabs (#1523) --- packages/plugin-sdk/src/frontend.ts | 2 + plugins/ssh-terminal/README.md | 6 ++ .../src/frontend/terminal/Terminal.tsx | 47 +++++++----- .../src/frontend/terminal/terminal-types.ts | 1 + .../terminal/reconnect-disconnected.test.ts | 72 +++++++++++++++++++ src/types/ui-types.ts | 2 + src/ui/AppShell.tsx | 28 ++++++++ src/ui/locales/en.json | 8 ++- src/ui/shell/TabBar.tsx | 14 ++++ src/ui/shell/reconnect-tabs.ts | 19 +++++ src/ui/tests/shell/TabBar.test.tsx | 20 ++++++ src/ui/tests/shell/reconnect-tabs.test.ts | 63 ++++++++++++++++ 12 files changed, 265 insertions(+), 17 deletions(-) create mode 100644 plugins/ssh-terminal/tests/frontend/terminal/reconnect-disconnected.test.ts create mode 100644 src/ui/shell/reconnect-tabs.ts create mode 100644 src/ui/tests/shell/reconnect-tabs.test.ts diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index 45d514e96..bca0c6734 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -314,6 +314,8 @@ export interface TabHandle { focus?: () => void; fit?: () => void; reconnect?: () => void; + /** Start a manual reconnect only when disconnected and idle; return whether it started. */ + reconnectIfDisconnected?: () => boolean; disconnect?: () => void; isConnected?: () => boolean; sendInput?: (data: string) => void; diff --git a/plugins/ssh-terminal/README.md b/plugins/ssh-terminal/README.md index e490b922a..0f169082d 100644 --- a/plugins/ssh-terminal/README.md +++ b/plugins/ssh-terminal/README.md @@ -52,3 +52,9 @@ npm run build # build into dist/ npm run test # run this plugin's tests npm run typecheck # type-check this plugin ``` + +## Reconnecting a workspace + +Use **Reconnect all disconnected terminals** from a tab's context menu or the command palette after a network interruption. It retries mounted SSH terminal tabs, including hidden tabs and split panes, through their existing manual reconnect flow. Connected terminals and terminals already connecting or waiting for an automatic retry are skipped. Invoking the action again does not interrupt an in-flight connection; failures in one tab do not stop the others. The notification counts reconnect attempts started, not successful connections. + +This is a manual workspace action. It does not add automatic SSH-error retries, resume/network listeners, tmux-session restoration or scrollback preservation. Existing authentication prompts, host-key validation and manual-reconnect behavior still apply. Other session plugins participate only if they expose the SDK's optional `reconnectIfDisconnected` handle method. diff --git a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx index 5ff36c487..fbc0e7613 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/Terminal.tsx @@ -1357,6 +1357,24 @@ const TerminalInner = forwardRef( }, 15000); } + function reconnectTerminal() { + isUnmountingRef.current = false; + shouldNotReconnectRef.current = false; + isReconnectingRef.current = false; + isConnectingRef.current = false; + reconnectAttempts.current = 0; + wasDisconnectedBySSH.current = false; + wasConnectedRef.current = false; + updateConnectionError(null); + setShowDisconnectedOverlay(false); + if (terminal) { + terminal.clear(); + const cols = terminal.cols; + const rows = terminal.rows; + connectToHost(cols, rows); + } + } + useImperativeHandle( ref, () => ({ @@ -1396,22 +1414,19 @@ const TerminalInner = forwardRef( setIsConnected(false); setIsConnecting(false); }, - reconnect: () => { - isUnmountingRef.current = false; - shouldNotReconnectRef.current = false; - isReconnectingRef.current = false; - isConnectingRef.current = false; - reconnectAttempts.current = 0; - wasDisconnectedBySSH.current = false; - wasConnectedRef.current = false; - updateConnectionError(null); - setShowDisconnectedOverlay(false); - if (terminal) { - terminal.clear(); - const cols = terminal.cols; - const rows = terminal.rows; - connectToHost(cols, rows); - } + reconnect: reconnectTerminal, + reconnectIfDisconnected: () => { + if ( + !terminal || + isConnected || + isUnmountingRef.current || + isConnectingRef.current || + isReconnectingRef.current || + reconnectTimeoutRef.current !== null + ) + return false; + reconnectTerminal(); + return true; }, isConnected: () => isConnected, fit: () => { diff --git a/plugins/ssh-terminal/src/frontend/terminal/terminal-types.ts b/plugins/ssh-terminal/src/frontend/terminal/terminal-types.ts index de15feb46..8c71f9535 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/terminal-types.ts +++ b/plugins/ssh-terminal/src/frontend/terminal/terminal-types.ts @@ -24,6 +24,7 @@ export interface TerminalHostConfig { export interface TerminalHandle { disconnect: () => void; reconnect: () => void; + reconnectIfDisconnected: () => boolean; isConnected: () => boolean; fit: () => void; focus: () => void; diff --git a/plugins/ssh-terminal/tests/frontend/terminal/reconnect-disconnected.test.ts b/plugins/ssh-terminal/tests/frontend/terminal/reconnect-disconnected.test.ts new file mode 100644 index 000000000..3203a6f3d --- /dev/null +++ b/plugins/ssh-terminal/tests/frontend/terminal/reconnect-disconnected.test.ts @@ -0,0 +1,72 @@ +// @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 reconnect = source.slice( + source.indexOf(" function reconnectTerminal()"), + source.indexOf(" useImperativeHandle("), +); +const methods = source.slice( + source.indexOf(" reconnect: reconnectTerminal,"), + source.indexOf(" isConnected: () => isConnected,"), +); + +function createHandle(overrides: Record = {}) { + const context = { + terminal: { cols: 80, rows: 24, clear: vi.fn() }, + isConnected: false, + isUnmountingRef: { current: false }, + shouldNotReconnectRef: { current: true }, + isReconnectingRef: { current: false }, + isConnectingRef: { current: false }, + reconnectTimeoutRef: { current: null }, + reconnectAttempts: { current: 8 }, + wasDisconnectedBySSH: { current: true }, + wasConnectedRef: { current: false }, + updateConnectionError: vi.fn(), + setShowDisconnectedOverlay: vi.fn(), + connectToHost: vi.fn(() => { + context.isConnectingRef.current = true; + }), + ...overrides, + }; + const handle = vm.runInNewContext( + reconnect + `\n({${methods}})`, + context, + ) as { reconnectIfDisconnected: () => boolean; reconnect: () => void }; + return { handle, context }; +} + +describe("terminal bulk reconnect handle", () => { + it("uses the manual reconnect flow after a dropped SSH session and rejects duplicate requests", () => { + const { handle, context } = createHandle(); + expect(handle.reconnectIfDisconnected()).toBe(true); + expect(context.connectToHost).toHaveBeenCalledWith(80, 24); + expect(context.shouldNotReconnectRef.current).toBe(false); + expect(context.reconnectAttempts.current).toBe(0); + expect(context.wasDisconnectedBySSH.current).toBe(false); + expect(handle.reconnectIfDisconnected()).toBe(false); + expect(context.connectToHost).toHaveBeenCalledOnce(); + }); + it.each([ + { isConnected: true }, + { isConnectingRef: { current: true } }, + { isReconnectingRef: { current: true } }, + { reconnectTimeoutRef: { current: 1 } }, + { isUnmountingRef: { current: true } }, + { terminal: null }, + ])( + "does not interrupt a connected, busy or unavailable terminal: %j", + (overrides) => { + const { handle, context } = createHandle(overrides); + expect(handle.reconnectIfDisconnected()).toBe(false); + expect(context.connectToHost).not.toHaveBeenCalled(); + expect(context.shouldNotReconnectRef.current).toBe(true); + }, + ); +}); diff --git a/src/types/ui-types.ts b/src/types/ui-types.ts index ca79c9869..33ea4e621 100644 --- a/src/types/ui-types.ts +++ b/src/types/ui-types.ts @@ -182,6 +182,8 @@ export type Tab = { subscribeOutput?: (listener: (data: string) => void) => () => void; paste?: (text: string) => void; reconnect?: () => void; + /** Start a manual reconnect only when disconnected and idle; return whether it started. */ + reconnectIfDisconnected?: () => boolean; fit?: () => void; notifyResize?: () => void; refresh?: () => void; diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 938977eb4..e8ba72e7a 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -53,6 +53,7 @@ import { } from "@/shell/split/EmptyPanePicker"; import { renderTabContent } from "@/shell/tabUtils"; import { TabBar } from "@/shell/TabBar"; +import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs"; import { dispatchCtrlW, createCommandPaletteShortcutMatcher, @@ -1796,6 +1797,24 @@ export function AppShell({ }); } + const reconnectAllRef = useRef<() => void>(() => {}); + function reconnectAllDisconnected() { + const { reconnected, failed } = reconnectDisconnectedTabs(tabsRef.current); + if (reconnected || !failed) + toast( + t( + reconnected + ? "nav.reconnectingTerminals" + : "nav.noDisconnectedTerminals", + { count: reconnected }, + ), + ); + if (failed) + toast.error(t("nav.reconnectTerminalsFailed", { count: failed })); + } + + reconnectAllRef.current = reconnectAllDisconnected; + function refreshTab(id: string) { const tab = tabs.find((t) => t.id === id); const handle = tab?.terminalRef?.current; @@ -2158,6 +2177,14 @@ export function AppShell({ tabsRef.current.find((tab) => tab.id === activeTabIdRef.current), ); const entries = [ + registerPaletteEntry({ + id: "core.reconnectDisconnected", + titleKey: "nav.reconnectDisconnectedTerminals", + icon: RotateCcw, + keywords: ["reconnect", "disconnected", "ssh", "all", "network"], + scope: "global", + run: () => reconnectAllRef.current(), + }), registerPaletteEntry({ id: "core.split.right", titleKey: "splitScreen.splitRight", @@ -2995,6 +3022,7 @@ export function AppShell({ onSetActiveTab={setActiveTabId} onCloseTab={closeTab} onRefreshTab={refreshTab} + onReconnectDisconnected={reconnectAllDisconnected} onReorderTabs={reorderTopLevelTabs} onSplitAction={handleTabSplitAction} onRenameTab={renameTab} diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 82b396228..5194fa4f2 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -293,7 +293,13 @@ "collapseSideMenu": "Collapse Menu", "roleAdministrator": "Administrator", "roleUser": "User", - "sync": "Sync" + "sync": "Sync", + "reconnectDisconnectedTerminals": "Reconnect all disconnected terminals", + "reconnectingTerminals_one": "Reconnecting {{count}} terminal", + "reconnectingTerminals_other": "Reconnecting {{count}} terminals", + "noDisconnectedTerminals": "No disconnected terminals are ready to reconnect", + "reconnectTerminalsFailed_one": "Could not start reconnecting {{count}} terminal", + "reconnectTerminalsFailed_other": "Could not start reconnecting {{count}} terminals" }, "hosts": { "hosts": "Hosts", diff --git a/src/ui/shell/TabBar.tsx b/src/ui/shell/TabBar.tsx index e27652d21..6963ca369 100644 --- a/src/ui/shell/TabBar.tsx +++ b/src/ui/shell/TabBar.tsx @@ -67,6 +67,7 @@ export function TabBar({ onSetActiveTab, onCloseTab, onRefreshTab, + onReconnectDisconnected, onReorderTabs, onSplitAction, onRenameTab, @@ -85,6 +86,7 @@ export function TabBar({ onSetActiveTab: (id: string) => void; onCloseTab: (id: string) => void; onRefreshTab: (id: string) => void; + onReconnectDisconnected?: () => void; onReorderTabs: (tabs: Tab[]) => void; onSplitAction: (action: TabSplitAction) => void; onRenameTab?: (tabId: string, newLabel: string) => void; @@ -709,6 +711,18 @@ export function TabBar({ {t("nav.refreshTab")} )} + {onReconnectDisconnected && ( + + )} {/* Plugins add entries here, invoked with the tab's surface handle. */}