diff --git a/src/types/host-sidebar-preferences.ts b/src/types/host-sidebar-preferences.ts
index 616d8aac6..d7b4f932d 100644
--- a/src/types/host-sidebar-preferences.ts
+++ b/src/types/host-sidebar-preferences.ts
@@ -32,6 +32,10 @@ export type HostDensity = "comfortable" | "compact";
export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
+/** What clicking a host does when it already has an open tab. */
+export type HostClickBehavior =
+ "newTab" | "focusExisting" | "focusExistingDoubleClickNew";
+
export interface HostSidebarFilterState {
status: ("online" | "offline" | "pinned")[];
/** SSH auth type ids; plugins add their own. */
@@ -52,8 +56,7 @@ 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;
+ hostClickBehavior: HostClickBehavior;
}
export interface HostSidebarPreferences {
@@ -89,6 +92,11 @@ const TRAY_TRIGGERS: HostTrayTrigger[] = [
"click",
"actionsOnly",
];
+const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
+ "newTab",
+ "focusExisting",
+ "focusExistingDoubleClickNew",
+];
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
const FILTER_STATUS: HostSidebarFilterState["status"] = [
"online",
@@ -118,7 +126,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
statusColorScheme: "accent",
openOnDoubleClick: false,
showFolderPaths: true,
- focusExistingTab: true,
+ hostClickBehavior: "newTab",
},
};
}
@@ -202,10 +210,11 @@ export function sanitizeHostSidebarPreferences(
typeof displayObj.showFolderPaths === "boolean"
? displayObj.showFolderPaths
: defaults.display.showFolderPaths,
- focusExistingTab:
- typeof displayObj.focusExistingTab === "boolean"
- ? displayObj.focusExistingTab
- : defaults.display.focusExistingTab,
+ hostClickBehavior: HOST_CLICK_BEHAVIORS.includes(
+ displayObj.hostClickBehavior as HostClickBehavior,
+ )
+ ? (displayObj.hostClickBehavior as HostClickBehavior)
+ : defaults.display.hostClickBehavior,
};
return {
diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json
index 9c5beff8b..5f7483034 100644
--- a/src/ui/locales/en.json
+++ b/src/ui/locales/en.json
@@ -745,8 +745,11 @@
"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.",
+ "hostClickBehavior": "Clicking a host",
+ "hostClickBehaviorDesc": "What happens when you click a host that already has an open tab. Ctrl/Cmd click, middle click, or \"Open in new tab\" always start a new one.",
+ "hostClickNewTab": "Always open a new tab",
+ "hostClickFocusExisting": "Switch to the open tab",
+ "hostClickFocusExistingDoubleNew": "Click to switch, double click for a new tab",
"openInNewTab": "Open in new tab",
"deletedHost": "Deleted {{name}}",
"copiedToClipboard": "Copied to clipboard",
diff --git a/src/ui/sidebar/CustomizeSidebarPanel.tsx b/src/ui/sidebar/CustomizeSidebarPanel.tsx
index 981e9ee5c..fd2d24b76 100644
--- a/src/ui/sidebar/CustomizeSidebarPanel.tsx
+++ b/src/ui/sidebar/CustomizeSidebarPanel.tsx
@@ -9,6 +9,7 @@ import {
} from "@/components/dialog";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
+ HostClickBehavior,
HostDensity,
HostSidebarPreferences,
HostTrayTrigger,
@@ -139,12 +140,47 @@ export function CustomizeSidebarPanel({
+
+
+
+ {t("hosts.hostClickBehavior")}
+
+
+ {t("hosts.hostClickBehaviorDesc")}
+
+
+
+
update((prev) => ({
...prev,
@@ -153,20 +189,6 @@ export function CustomizeSidebarPanel({
}
/>
-
-
- update((prev) => ({
- ...prev,
- display: { ...prev.display, focusExistingTab: v },
- }))
- }
- />
-
)}
diff --git a/src/ui/sidebar/tree/FolderItem/FolderItem.tsx b/src/ui/sidebar/tree/FolderItem/FolderItem.tsx
index d4cf6ea28..f5b7c617a 100644
--- a/src/ui/sidebar/tree/FolderItem/FolderItem.tsx
+++ b/src/ui/sidebar/tree/FolderItem/FolderItem.tsx
@@ -6,6 +6,7 @@ import { ChevronRight, Check, GripVertical } from "lucide-react";
import type { Host, HostFolder, TabType } from "@/types/ui-types";
import type {
HostDensity,
+ HostClickBehavior,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
import { FolderIconEl } from "@/components/folder-style";
@@ -69,7 +70,7 @@ export function FolderItem({
showTags = true,
openOnDoubleClick = false,
showFolderPaths = true,
- focusExistingTab = true,
+ hostClickBehavior = "newTab",
arrangeMode = false,
isDragging = false,
onReorderDrop,
@@ -122,8 +123,7 @@ 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;
+ hostClickBehavior?: HostClickBehavior;
/** 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. */
@@ -347,7 +347,7 @@ export function FolderItem({
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
showFolderPaths={showFolderPaths}
- focusExistingTab={focusExistingTab}
+ hostClickBehavior={hostClickBehavior}
arrangeMode={arrangeMode}
onReorderDrop={onReorderDrop}
onFolderDragStart={onFolderDragStart}
@@ -381,7 +381,7 @@ export function FolderItem({
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
- focusExistingTab={focusExistingTab}
+ hostClickBehavior={hostClickBehavior}
arrangeMode={arrangeMode}
onReorderDrop={
onReorderDrop
diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx
index c9ece024b..7745418cd 100644
--- a/src/ui/sidebar/tree/HostItem/HostItem.tsx
+++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx
@@ -41,6 +41,7 @@ import { getHostPassword } from "@/main-axios";
import type { Host, TabType } from "@/types/ui-types";
import type {
HostDensity,
+ HostClickBehavior,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
import { copyToClipboard } from "@/lib/clipboard";
@@ -153,6 +154,8 @@ function canCopyHostSudoPassword(host: Host): boolean {
* differ. Keeping this as one lookup (rather than two parallel JSX trees)
* means a future style tweak only has to be made once.
*/
+const DOUBLE_CLICK_WINDOW_MS = 250;
+
const HOST_ITEM_DENSITY_TOKENS = {
comfortable: {
rowPadding: "pl-[8.75px] pr-[7px] py-[7px]",
@@ -195,7 +198,7 @@ export function HostItem({
trayTrigger = "hover",
showTags = true,
openOnDoubleClick = false,
- focusExistingTab = true,
+ hostClickBehavior = "newTab",
showResourceBars = true,
showStatusStripes = true,
rowActions = "full",
@@ -243,8 +246,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;
+ /** What a click does when the host already has an open tab. */
+ hostClickBehavior?: HostClickBehavior;
/** Preset-driven: hides the CPU/RAM bars without changing density. */
showResourceBars?: boolean;
/** Preset-driven: hides the per-row status color stripe. */
@@ -344,6 +347,18 @@ export function HostItem({
showResourceRow: densityTokens.showResourceRow && showResourceBars,
};
const isCompact = density === "compact";
+ const focusExistingTab = hostClickBehavior !== "newTab";
+ const doubleClickOpensNew =
+ hostClickBehavior === "focusExistingDoubleClickNew";
+ // In double click mode a single click waits out the double click window,
+ // so a double click doesn't also switch to (or open) a tab first.
+ const singleClickTimer = useRef | null>(null);
+ useEffect(
+ () => () => {
+ if (singleClickTimer.current) clearTimeout(singleClickTimer.current);
+ },
+ [],
+ );
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
const canDrag =
arrangeMode && !selectionMode && !isTouchOnly && canEditHost(host);
@@ -1033,12 +1048,35 @@ export function HostItem({
}
return;
}
- if (openOnDoubleClick) return;
const forceNewTab = e.ctrlKey || e.metaKey;
+ if (doubleClickOpensNew) {
+ if (singleClickTimer.current) clearTimeout(singleClickTimer.current);
+ singleClickTimer.current = null;
+ if (e.detail > 1) return;
+ if (forceNewTab) {
+ openHostTab(defaultAction, { forceNewTab });
+ } else {
+ singleClickTimer.current = setTimeout(() => {
+ singleClickTimer.current = null;
+ openHostTab(defaultAction);
+ }, DOUBLE_CLICK_WINDOW_MS);
+ }
+ return;
+ }
+ if (openOnDoubleClick) return;
openHostTab(defaultAction, forceNewTab ? { forceNewTab } : undefined);
}}
onDoubleClick={(e) => {
- if (selectionMode || isTouchOnly || !openOnDoubleClick) return;
+ if (selectionMode || isTouchOnly) return;
+ if (doubleClickOpensNew) {
+ e.stopPropagation();
+ if (singleClickTimer.current) clearTimeout(singleClickTimer.current);
+ singleClickTimer.current = null;
+ if (e.ctrlKey || e.metaKey) return;
+ openHostTab(defaultAction, { forceNewTab: true });
+ return;
+ }
+ if (!openOnDoubleClick) return;
e.stopPropagation();
const forceNewTab = e.ctrlKey || e.metaKey;
openHostTab(defaultAction, forceNewTab ? { forceNewTab } : undefined);
diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx
index 04eef4f56..bb16f0b57 100644
--- a/src/ui/sidebar/tree/SidebarTree.tsx
+++ b/src/ui/sidebar/tree/SidebarTree.tsx
@@ -45,6 +45,7 @@ import type { Host, HostFolder, TabType } from "@/types/ui-types";
import type { SSHHostData } from "@/types/index";
import type {
HostDensity,
+ HostClickBehavior,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
@@ -86,7 +87,7 @@ export function SidebarTree({
showTags = true,
openOnDoubleClick = false,
showFolderPaths = true,
- focusExistingTab = true,
+ hostClickBehavior = "newTab",
}: {
children: (Host | HostFolder)[];
onOpenTab: (
@@ -113,8 +114,7 @@ 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;
+ hostClickBehavior?: HostClickBehavior;
}) {
const { t } = useTranslation();
const hostSwitchPlugins = usePluginHostSections().filter(
@@ -1016,7 +1016,7 @@ export function SidebarTree({
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
showFolderPaths={showFolderPaths}
- focusExistingTab={focusExistingTab}
+ hostClickBehavior={hostClickBehavior}
arrangeMode={arrangeMode}
isDragging={
draggedReorderKey === `folder:${item.path ?? item.name}`
@@ -1088,7 +1088,7 @@ export function SidebarTree({
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
- focusExistingTab={focusExistingTab}
+ hostClickBehavior={hostClickBehavior}
showResourceBars={showResourceBars}
showStatusStripes={showStatusStripes}
rowActions={rowActions}
diff --git a/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx b/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx
index 17dcaf022..13898258d 100644
--- a/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx
+++ b/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx
@@ -1,4 +1,10 @@
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+} from "@testing-library/react";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Host } from "@/types/ui-types";
@@ -234,3 +240,90 @@ describe("HostItem density parity", () => {
}
});
});
+
+describe("HostItem click behavior", () => {
+ function setup(
+ hostClickBehavior:
+ "newTab" | "focusExisting" | "focusExistingDoubleClickNew",
+ ) {
+ const Icon = (() => null) as never;
+ const dispose = registerHostAction({
+ id: "terminal_test",
+ titleKey: "Terminal",
+ icon: Icon,
+ kind: "connect",
+ tabType: "terminal",
+ when: () => true,
+ });
+ const onOpenTab = vi.fn();
+ render(
+ ,
+ );
+ const row = screen.getByText("web-01").closest(".cursor-pointer")!;
+ return { onOpenTab, row, dispose };
+ }
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("always opens a new tab by default", () => {
+ const { onOpenTab, row, dispose } = setup("newTab");
+ try {
+ fireEvent.click(row);
+ expect(onOpenTab).toHaveBeenCalledWith("terminal", { forceNewTab: true });
+ } finally {
+ dispose();
+ }
+ });
+
+ it("switches to the open tab when set to focus existing", () => {
+ const { onOpenTab, row, dispose } = setup("focusExisting");
+ try {
+ fireEvent.click(row);
+ expect(onOpenTab).toHaveBeenCalledWith("terminal", {
+ forceNewTab: false,
+ });
+ fireEvent.click(row, { ctrlKey: true });
+ expect(onOpenTab).toHaveBeenLastCalledWith("terminal", {
+ forceNewTab: true,
+ });
+ } finally {
+ dispose();
+ }
+ });
+
+ it("switches on a single click and opens a new tab on a double click", () => {
+ vi.useFakeTimers();
+ const { onOpenTab, row, dispose } = setup("focusExistingDoubleClickNew");
+ try {
+ fireEvent.click(row, { detail: 1 });
+ expect(onOpenTab).not.toHaveBeenCalled();
+ act(() => {
+ vi.runAllTimers();
+ });
+ expect(onOpenTab).toHaveBeenCalledTimes(1);
+ expect(onOpenTab).toHaveBeenCalledWith("terminal", {
+ forceNewTab: false,
+ });
+
+ onOpenTab.mockClear();
+ fireEvent.click(row, { detail: 1 });
+ fireEvent.click(row, { detail: 2 });
+ fireEvent.doubleClick(row);
+ act(() => {
+ vi.runAllTimers();
+ });
+ expect(onOpenTab).toHaveBeenCalledTimes(1);
+ expect(onOpenTab).toHaveBeenCalledWith("terminal", { forceNewTab: true });
+ } finally {
+ dispose();
+ }
+ });
+});
diff --git a/src/ui/tests/types/host-sidebar-preferences.test.ts b/src/ui/tests/types/host-sidebar-preferences.test.ts
index 1483f5988..ce10c6261 100644
--- a/src/ui/tests/types/host-sidebar-preferences.test.ts
+++ b/src/ui/tests/types/host-sidebar-preferences.test.ts
@@ -27,7 +27,7 @@ describe("defaultHostSidebarPreferences", () => {
statusColorScheme: "accent",
openOnDoubleClick: false,
showFolderPaths: true,
- focusExistingTab: true,
+ hostClickBehavior: "newTab",
},
});
});
@@ -66,7 +66,7 @@ describe("sanitizeHostSidebarPreferences", () => {
statusColorScheme: "status",
openOnDoubleClick: true,
showFolderPaths: false,
- focusExistingTab: false,
+ hostClickBehavior: "focusExistingDoubleClickNew",
},
};
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
@@ -92,14 +92,19 @@ describe("sanitizeHostSidebarPreferences", () => {
).toBe(true);
});
- it("defaults focusExistingTab to true when missing or not a boolean", () => {
+ it("defaults hostClickBehavior to newTab when missing or invalid", () => {
expect(
- sanitizeHostSidebarPreferences({ display: {} }).display.focusExistingTab,
- ).toBe(true);
+ sanitizeHostSidebarPreferences({ display: {} }).display.hostClickBehavior,
+ ).toBe("newTab");
expect(
- sanitizeHostSidebarPreferences({ display: { focusExistingTab: "no" } })
- .display.focusExistingTab,
- ).toBe(true);
+ sanitizeHostSidebarPreferences({ display: { hostClickBehavior: "nope" } })
+ .display.hostClickBehavior,
+ ).toBe("newTab");
+ expect(
+ sanitizeHostSidebarPreferences({
+ display: { hostClickBehavior: "focusExisting" },
+ }).display.hostClickBehavior,
+ ).toBe("focusExisting");
});
it("falls back to defaults for invalid enum values", () => {