feat: reconnect all disconnected terminal tabs (#1523)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:48:15 -05:00
1 parent 5860921cbe
commit e161ad22e0
12 files changed
+265 -17

No files matched your search

+2
View File
@@ -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;
+6
View File
@@ -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.
@@ -1357,6 +1357,24 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}, 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<TerminalHandle, SSHTerminalProps>(
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: () => {
@@ -24,6 +24,7 @@ export interface TerminalHostConfig {
export interface TerminalHandle {
disconnect: () => void;
reconnect: () => void;
reconnectIfDisconnected: () => boolean;
isConnected: () => boolean;
fit: () => void;
focus: () => void;
@@ -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<string, unknown> = {}) {
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);
},
);
});
+2
View File
@@ -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;
+28
View File
@@ -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}
+7 -1
View File
@@ -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",
+14
View File
@@ -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")}
</button>
)}
{onReconnectDisconnected && (
<button
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
onClick={() => {
onReconnectDisconnected();
setContextTabId(null);
}}
>
<RefreshCw className="size-3" />
{t("nav.reconnectDisconnectedTerminals")}
</button>
)}
{/* Plugins add entries here, invoked with the tab's surface handle. */}
<ActionSlot
slotId="tab.menu"
+19
View File
@@ -0,0 +1,19 @@
import type { Tab } from "@/types/ui-types";
/** Includes hidden tabs and split children; each plugin decides whether it is idle. */
export function reconnectDisconnectedTabs(tabs: readonly Tab[]) {
const seen = new Set<NonNullable<Tab["terminalRef"]>["current"]>();
let reconnected = 0;
let failed = 0;
for (const tab of tabs) {
const handle = tab.terminalRef?.current;
if (!handle?.reconnectIfDisconnected || seen.has(handle)) continue;
seen.add(handle);
try {
if (handle.reconnectIfDisconnected()) reconnected++;
} catch {
failed++;
}
}
return { reconnected, failed };
}
+20
View File
@@ -159,3 +159,23 @@ describe("TabBar split menu", () => {
});
});
});
describe("bulk reconnect menu", () => {
it("offers the workspace action from a non-session tab and closes the menu", () => {
const onReconnectDisconnected = vi.fn();
const filesTab = {
id: "files",
type: "file_manager",
label: "Files",
} as Tab;
const view = render(
<TabBar
{...props({ tabs: [...tabs, filesTab], onReconnectDisconnected })}
/>,
);
fireEvent.contextMenu(view.getByText("Files"));
fireEvent.click(view.getByText("nav.reconnectDisconnectedTerminals"));
expect(onReconnectDisconnected).toHaveBeenCalledOnce();
expect(view.queryByText("nav.reconnectDisconnectedTerminals")).toBeNull();
});
});
+63
View File
@@ -0,0 +1,63 @@
import { describe, expect, it, vi } from "vitest";
import type { Tab } from "@/types/ui-types";
import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs";
function tab(
id: string,
handle: NonNullable<Tab["terminalRef"]>["current"],
parentSplitTabId?: string,
): Tab {
return {
id,
instanceId: id,
label: id,
type: "terminal",
openedAt: 0,
terminalRef: { current: handle },
parentSplitTabId,
};
}
describe("bulk reconnect", () => {
it("includes hidden tabs and split children without merging sessions for the same host", () => {
const first = vi.fn(() => true);
const second = vi.fn(() => true);
const tabs = [
tab("hidden", { reconnectIfDisconnected: first }),
tab("pane", { reconnectIfDisconnected: second }, "split"),
];
expect(reconnectDisconnectedTabs(tabs)).toEqual({
reconnected: 2,
failed: 0,
});
expect(first).toHaveBeenCalledOnce();
expect(second).toHaveBeenCalledOnce();
});
it("skips absent and unsupported handles and honors each provider's busy guard", () => {
const reconnect = vi.fn();
expect(
reconnectDisconnectedTabs([
tab("unmounted", null),
tab("legacy", { reconnect, isConnected: () => false }),
tab("busy", { reconnectIfDisconnected: () => false }),
]),
).toEqual({ reconnected: 0, failed: 0 });
expect(reconnect).not.toHaveBeenCalled();
});
it("continues after one provider throws and invokes shared handles only once", () => {
const failed = {
reconnectIfDisconnected: vi.fn(() => {
throw new Error("disposed");
}),
};
const good = { reconnectIfDisconnected: vi.fn(() => true) };
expect(
reconnectDisconnectedTabs([
tab("broken", failed),
tab("one", good),
tab("duplicate-reference", good),
]),
).toEqual({ reconnected: 1, failed: 1 });
expect(good.reconnectIfDisconnected).toHaveBeenCalledOnce();
});
});