diff --git a/src/types/host-sidebar-preferences.ts b/src/types/host-sidebar-preferences.ts
index 3b455c5dd..6cab4d43f 100644
--- a/src/types/host-sidebar-preferences.ts
+++ b/src/types/host-sidebar-preferences.ts
@@ -51,6 +51,8 @@ export interface HostSidebarDisplayPreferences {
openOnDoubleClick: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths: boolean;
+ /** When true, clicking a host with an already-open tab focuses it instead of opening a new one. */
+ focusExistingTab: boolean;
}
export interface HostSidebarPreferences {
@@ -133,6 +135,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
statusColorScheme: "accent",
openOnDoubleClick: false,
showFolderPaths: true,
+ focusExistingTab: true,
},
};
}
@@ -210,6 +213,10 @@ export function sanitizeHostSidebarPreferences(
typeof displayObj.showFolderPaths === "boolean"
? displayObj.showFolderPaths
: defaults.display.showFolderPaths,
+ focusExistingTab:
+ typeof displayObj.focusExistingTab === "boolean"
+ ? displayObj.focusExistingTab
+ : defaults.display.focusExistingTab,
};
return {
diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx
index e251469a4..13f398789 100644
--- a/src/ui/AppShell.tsx
+++ b/src/ui/AppShell.tsx
@@ -547,8 +547,7 @@ export function AppShell({
const termRef = terminalRefs.current.get(tabId);
(
termRef?.current as
- | import("@/features/terminal/Terminal").TerminalHandle
- | null
+ import("@/features/terminal/Terminal").TerminalHandle | null
)?.reconnect();
return;
}
@@ -1656,8 +1655,20 @@ export function AppShell({
joinShareId?: string | null;
collabRoomId?: string;
},
- options?: { endpointId?: string; label?: string },
+ options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
) {
+ if (!restore && !options?.forceNewTab) {
+ const existing = tabsRef.current.find(
+ (t) =>
+ t.type === type &&
+ t.host?.id === host.id &&
+ t.endpointId === options?.endpointId,
+ );
+ if (existing) {
+ setActiveTabId(existing.id);
+ return existing.id;
+ }
+ }
const tabId = `${host.name}-${type}-${Date.now()}`;
const instanceId =
restore?.instanceId ??
@@ -1763,7 +1774,7 @@ export function AppShell({
function connectHost(
host: Host,
preferredType?: TabType,
- options?: { endpointId?: string; label?: string },
+ options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
) {
const type = resolveHostTabType(host, preferredType);
// --- tmux-monitor --- singleton tab, not a per-host tab
diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json
index 5acff02c5..4ff6c41dc 100644
--- a/src/ui/locales/en.json
+++ b/src/ui/locales/en.json
@@ -1406,6 +1406,9 @@
"openOnDoubleClickDesc": "Require a double click to open a host session instead of a single click",
"showFolderPaths": "Show folder paths",
"showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.",
+ "focusExistingTab": "Focus existing tab",
+ "focusExistingTabDesc": "Clicking a host with an open tab switches to it instead of opening a new session. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.",
+ "openInNewTab": "Open in new tab",
"deletedHost": "Deleted {{name}}",
"copiedToClipboard": "Copied to clipboard",
"terminalUrlCopied": "Terminal URL copied",
diff --git a/src/ui/sidebar/CustomizeSidebarPanel.tsx b/src/ui/sidebar/CustomizeSidebarPanel.tsx
index a1c5c8961..981e9ee5c 100644
--- a/src/ui/sidebar/CustomizeSidebarPanel.tsx
+++ b/src/ui/sidebar/CustomizeSidebarPanel.tsx
@@ -153,6 +153,20 @@ export function CustomizeSidebarPanel({
}
/>
+
+
+ update((prev) => ({
+ ...prev,
+ display: { ...prev.display, focusExistingTab: v },
+ }))
+ }
+ />
+
void;
onEditHost: (host: Host) => void;
hostTree?: HostFolder;
@@ -1077,6 +1081,7 @@ export function HostsPanel({
showTags={sidebarPrefs.display.showTags}
openOnDoubleClick={sidebarPrefs.display.openOnDoubleClick}
showFolderPaths={sidebarPrefs.display.showFolderPaths}
+ focusExistingTab={sidebarPrefs.display.focusExistingTab}
/>
)}
diff --git a/src/ui/sidebar/tree/FolderItem/FolderItem.tsx b/src/ui/sidebar/tree/FolderItem/FolderItem.tsx
index 1b52f010c..800adbff1 100644
--- a/src/ui/sidebar/tree/FolderItem/FolderItem.tsx
+++ b/src/ui/sidebar/tree/FolderItem/FolderItem.tsx
@@ -69,6 +69,7 @@ export function FolderItem({
showTags = true,
openOnDoubleClick = false,
showFolderPaths = true,
+ focusExistingTab = true,
arrangeMode = false,
isDragging = false,
onReorderDrop,
@@ -83,7 +84,11 @@ export function FolderItem({
onOpenTab: (
host: Host,
type: TabType,
- options?: { endpointId?: string; label?: string },
+ options?: {
+ endpointId?: string;
+ label?: string;
+ forceNewTab?: boolean;
+ },
) => void;
onEditHost?: (host: Host) => void;
onShareHost?: (host: Host) => void;
@@ -118,6 +123,8 @@ export function FolderItem({
openOnDoubleClick?: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths?: boolean;
+ /** When true, clicking a host with an already-open tab focuses it instead of opening a new one. */
+ focusExistingTab?: boolean;
/** When true (rearranging unlocked), the header can be dragged and its
* top/bottom edges become reorder drop zones. The middle still accepts
* hosts dropped into the folder. */
@@ -342,6 +349,7 @@ export function FolderItem({
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
showFolderPaths={showFolderPaths}
+ focusExistingTab={focusExistingTab}
arrangeMode={arrangeMode}
onReorderDrop={onReorderDrop}
onFolderDragStart={onFolderDragStart}
@@ -378,6 +386,7 @@ export function FolderItem({
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
+ focusExistingTab={focusExistingTab}
arrangeMode={arrangeMode}
onReorderDrop={
onReorderDrop
diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx
index b3296f2ba..a5d1b7c48 100644
--- a/src/ui/sidebar/tree/HostItem/HostItem.tsx
+++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx
@@ -28,6 +28,7 @@ import {
Pin,
Server,
Share2,
+ SquarePlus,
Terminal,
Trash2,
Users,
@@ -268,6 +269,7 @@ export function HostItem({
trayTrigger = "hover",
showTags = true,
openOnDoubleClick = false,
+ focusExistingTab = true,
showResourceBars = true,
showStatusStripes = true,
rowActions = "full",
@@ -285,7 +287,7 @@ export function HostItem({
host: Host;
onOpenTab: (
type: TabType,
- options?: { endpointId?: string; label?: string },
+ options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
) => void;
onEditHost?: () => void;
onShareHost?: () => void;
@@ -312,6 +314,8 @@ export function HostItem({
showTags?: boolean;
/** Requires a double click to launch instead of a single click. */
openOnDoubleClick?: boolean;
+ /** When true, clicking a host with an already-open tab focuses it instead of opening a new one. */
+ focusExistingTab?: boolean;
/** Preset-driven: hides the CPU/RAM bars without changing density. */
showResourceBars?: boolean;
/** Preset-driven: hides the per-row status color stripe. */
@@ -502,11 +506,14 @@ export function HostItem({
: "terminal";
const openHostTab = (
type: TabType,
- options?: { endpointId?: string; label?: string },
+ options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
) => {
markTabSurfaceUsed(type);
recordHostActionPreference(host.id, type);
- onOpenTab(type, options);
+ onOpenTab(type, {
+ ...options,
+ forceNewTab: options?.forceNewTab ?? !focusExistingTab,
+ });
};
// Mirrors getSshActions: the single Web UI entry carries an endpointId only
@@ -848,6 +855,17 @@ export function HostItem({
)}
+ {focusExistingTab && (
+ {
+ e.stopPropagation();
+ openHostTab(defaultAction, { forceNewTab: true });
+ }}
+ >
+
+ {t("hosts.openInNewTab")}
+
+ )}
{onEditHost && (
{
+ // Middle-click always opens a new tab, matching browser tab behavior.
+ if (e.button === 1 && !selectionMode && !isTouchOnly) {
+ e.preventDefault();
+ openHostTab(defaultAction, { forceNewTab: true });
+ }
+ }}
onClick={(e) => {
if (selectionMode) {
onToggleSelect?.();
@@ -1264,12 +1289,14 @@ export function HostItem({
return;
}
if (openOnDoubleClick) return;
- openHostTab(defaultAction);
+ const forceNewTab = e.ctrlKey || e.metaKey;
+ openHostTab(defaultAction, forceNewTab ? { forceNewTab } : undefined);
}}
onDoubleClick={(e) => {
if (selectionMode || isTouchOnly || !openOnDoubleClick) return;
e.stopPropagation();
- openHostTab(defaultAction);
+ const forceNewTab = e.ctrlKey || e.metaKey;
+ openHostTab(defaultAction, forceNewTab ? { forceNewTab } : undefined);
}}
>
{/* Status stripe */}
diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx
index 59931a4ab..d736a5b54 100644
--- a/src/ui/sidebar/tree/SidebarTree.tsx
+++ b/src/ui/sidebar/tree/SidebarTree.tsx
@@ -88,12 +88,17 @@ export function SidebarTree({
showTags = true,
openOnDoubleClick = false,
showFolderPaths = true,
+ focusExistingTab = true,
}: {
children: (Host | HostFolder)[];
onOpenTab: (
host: Host,
type: TabType,
- options?: { endpointId?: string; label?: string },
+ options?: {
+ endpointId?: string;
+ label?: string;
+ forceNewTab?: boolean;
+ },
) => void;
onEditHost: (host: Host) => void;
onShareHost?: (host: Host) => void;
@@ -111,6 +116,8 @@ export function SidebarTree({
openOnDoubleClick?: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths?: boolean;
+ /** When true, clicking a host with an already-open tab focuses it instead of opening a new one. */
+ focusExistingTab?: boolean;
}) {
const { t } = useTranslation();
// Knobs with no other owner come straight from the interface preset; the
@@ -1000,6 +1007,7 @@ export function SidebarTree({
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
showFolderPaths={showFolderPaths}
+ focusExistingTab={focusExistingTab}
arrangeMode={arrangeMode}
isDragging={
draggedReorderKey === `folder:${item.path ?? item.name}`
@@ -1076,6 +1084,7 @@ export function SidebarTree({
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
+ focusExistingTab={focusExistingTab}
showResourceBars={showResourceBars}
showStatusStripes={showStatusStripes}
rowActions={rowActions}
diff --git a/src/ui/tests/types/host-sidebar-preferences.test.ts b/src/ui/tests/types/host-sidebar-preferences.test.ts
index ca468e9fc..263403e62 100644
--- a/src/ui/tests/types/host-sidebar-preferences.test.ts
+++ b/src/ui/tests/types/host-sidebar-preferences.test.ts
@@ -27,6 +27,7 @@ describe("defaultHostSidebarPreferences", () => {
statusColorScheme: "accent",
openOnDoubleClick: false,
showFolderPaths: true,
+ focusExistingTab: true,
},
});
});
@@ -65,6 +66,7 @@ describe("sanitizeHostSidebarPreferences", () => {
statusColorScheme: "status",
openOnDoubleClick: true,
showFolderPaths: false,
+ focusExistingTab: false,
},
};
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
@@ -90,6 +92,16 @@ describe("sanitizeHostSidebarPreferences", () => {
).toBe(true);
});
+ it("defaults focusExistingTab to true when missing or not a boolean", () => {
+ expect(
+ sanitizeHostSidebarPreferences({ display: {} }).display.focusExistingTab,
+ ).toBe(true);
+ expect(
+ sanitizeHostSidebarPreferences({ display: { focusExistingTab: "no" } })
+ .display.focusExistingTab,
+ ).toBe(true);
+ });
+
it("falls back to defaults for invalid enum values", () => {
const result = sanitizeHostSidebarPreferences({
sort: { key: "not-a-real-key", pinnedFirst: "yes" },