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
@@ -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();
});
});