mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +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
@@ -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