From 278fc7e32a51ee9f5a659124cb63ccbe71025898 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 28 Sep 2026 19:56:52 -0500 Subject: [PATCH] feat(sidebar): add host click behavior setting, default to always opening a new tab --- src/types/host-sidebar-preferences.ts | 23 +++-- src/ui/locales/en.json | 7 +- src/ui/sidebar/CustomizeSidebarPanel.tsx | 50 +++++++--- src/ui/sidebar/HostsPanel.tsx | 2 +- src/ui/sidebar/tree/FolderItem/FolderItem.tsx | 10 +- src/ui/sidebar/tree/HostItem/HostItem.tsx | 48 +++++++++- src/ui/sidebar/tree/SidebarTree.tsx | 10 +- .../sidebar/tree/HostItem/HostItem.test.tsx | 95 ++++++++++++++++++- .../types/host-sidebar-preferences.test.ts | 21 ++-- 9 files changed, 218 insertions(+), 48 deletions(-) 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", () => {