mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-12 14:49:19 +00:00
feat: reconnect all disconnected terminal tabs (#1523)
This commit is contained in:
1 parent
5860921cbe
commit
e161ad22e0
12 files changed
+265
-17
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user