feat(sidebar): add host click behavior setting, default to always opening a new tab

This commit is contained in:
LukeGus committed 2026-09-28 19:56:52 -05:00
1 parent 81e7aa6274
commit 278fc7e32a
9 files changed
+218 -48

No files matched your search

+16 -7
View File
@@ -32,6 +32,10 @@ export type HostDensity = "comfortable" | "compact";
export type HostTrayTrigger = "always" | "hover" | "click" | "actionsOnly"; 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 { export interface HostSidebarFilterState {
status: ("online" | "offline" | "pinned")[]; status: ("online" | "offline" | "pinned")[];
/** SSH auth type ids; plugins add their own. */ /** SSH auth type ids; plugins add their own. */
@@ -52,8 +56,7 @@ export interface HostSidebarDisplayPreferences {
openOnDoubleClick: boolean; openOnDoubleClick: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */ /** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths: boolean; showFolderPaths: boolean;
/** When true, clicking a host with an already-open tab focuses it instead of opening a new one. */ hostClickBehavior: HostClickBehavior;
focusExistingTab: boolean;
} }
export interface HostSidebarPreferences { export interface HostSidebarPreferences {
@@ -89,6 +92,11 @@ const TRAY_TRIGGERS: HostTrayTrigger[] = [
"click", "click",
"actionsOnly", "actionsOnly",
]; ];
const HOST_CLICK_BEHAVIORS: HostClickBehavior[] = [
"newTab",
"focusExisting",
"focusExistingDoubleClickNew",
];
const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"]; const STATUS_COLOR_SCHEMES: StatusColorScheme[] = ["accent", "status"];
const FILTER_STATUS: HostSidebarFilterState["status"] = [ const FILTER_STATUS: HostSidebarFilterState["status"] = [
"online", "online",
@@ -118,7 +126,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
statusColorScheme: "accent", statusColorScheme: "accent",
openOnDoubleClick: false, openOnDoubleClick: false,
showFolderPaths: true, showFolderPaths: true,
focusExistingTab: true, hostClickBehavior: "newTab",
}, },
}; };
} }
@@ -202,10 +210,11 @@ export function sanitizeHostSidebarPreferences(
typeof displayObj.showFolderPaths === "boolean" typeof displayObj.showFolderPaths === "boolean"
? displayObj.showFolderPaths ? displayObj.showFolderPaths
: defaults.display.showFolderPaths, : defaults.display.showFolderPaths,
focusExistingTab: hostClickBehavior: HOST_CLICK_BEHAVIORS.includes(
typeof displayObj.focusExistingTab === "boolean" displayObj.hostClickBehavior as HostClickBehavior,
? displayObj.focusExistingTab )
: defaults.display.focusExistingTab, ? (displayObj.hostClickBehavior as HostClickBehavior)
: defaults.display.hostClickBehavior,
}; };
return { return {
+5 -2
View File
@@ -745,8 +745,11 @@
"openOnDoubleClickDesc": "Require a double click to open a host session instead of a single click", "openOnDoubleClickDesc": "Require a double click to open a host session instead of a single click",
"showFolderPaths": "Show folder paths", "showFolderPaths": "Show folder paths",
"showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.", "showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.",
"focusExistingTab": "Focus existing tab", "hostClickBehavior": "Clicking a host",
"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.", "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", "openInNewTab": "Open in new tab",
"deletedHost": "Deleted {{name}}", "deletedHost": "Deleted {{name}}",
"copiedToClipboard": "Copied to clipboard", "copiedToClipboard": "Copied to clipboard",
+36 -14
View File
@@ -9,6 +9,7 @@ import {
} from "@/components/dialog"; } from "@/components/dialog";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card"; import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type { import type {
HostClickBehavior,
HostDensity, HostDensity,
HostSidebarPreferences, HostSidebarPreferences,
HostTrayTrigger, HostTrayTrigger,
@@ -139,12 +140,47 @@ export function CustomizeSidebarPanel({
<option value="click">{t("hosts.actionsClick")}</option> <option value="click">{t("hosts.actionsClick")}</option>
</select> </select>
</div> </div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.hostClickBehavior")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.hostClickBehaviorDesc")}
</span>
</div>
<select
value={preferences.display.hostClickBehavior}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
hostClickBehavior: e.target.value as HostClickBehavior,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
<option value="focusExisting">
{t("hosts.hostClickFocusExisting")}
</option>
<option value="focusExistingDoubleClickNew">
{t("hosts.hostClickFocusExistingDoubleNew")}
</option>
</select>
</div>
<SettingRow <SettingRow
label={t("hosts.openOnDoubleClick")} label={t("hosts.openOnDoubleClick")}
description={t("hosts.openOnDoubleClickDesc")} description={t("hosts.openOnDoubleClickDesc")}
> >
<FakeSwitch <FakeSwitch
checked={preferences.display.openOnDoubleClick} checked={preferences.display.openOnDoubleClick}
disabled={
preferences.display.hostClickBehavior ===
"focusExistingDoubleClickNew"
}
onChange={(v) => onChange={(v) =>
update((prev) => ({ update((prev) => ({
...prev, ...prev,
@@ -153,20 +189,6 @@ export function CustomizeSidebarPanel({
} }
/> />
</SettingRow> </SettingRow>
<SettingRow
label={t("hosts.focusExistingTab")}
description={t("hosts.focusExistingTabDesc")}
>
<FakeSwitch
checked={preferences.display.focusExistingTab}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, focusExistingTab: v },
}))
}
/>
</SettingRow>
<SettingRow <SettingRow
label={t("newUi.sidebar.userProfile.statusColors")} label={t("newUi.sidebar.userProfile.statusColors")}
description={t("newUi.sidebar.userProfile.statusColorsDesc")} description={t("newUi.sidebar.userProfile.statusColorsDesc")}
+1 -1
View File
@@ -1045,7 +1045,7 @@ export function HostsPanel({
showTags={sidebarPrefs.display.showTags} showTags={sidebarPrefs.display.showTags}
openOnDoubleClick={sidebarPrefs.display.openOnDoubleClick} openOnDoubleClick={sidebarPrefs.display.openOnDoubleClick}
showFolderPaths={sidebarPrefs.display.showFolderPaths} showFolderPaths={sidebarPrefs.display.showFolderPaths}
focusExistingTab={sidebarPrefs.display.focusExistingTab} hostClickBehavior={sidebarPrefs.display.hostClickBehavior}
/> />
</div> </div>
)} )}
@@ -6,6 +6,7 @@ import { ChevronRight, Check, GripVertical } from "lucide-react";
import type { Host, HostFolder, TabType } from "@/types/ui-types"; import type { Host, HostFolder, TabType } from "@/types/ui-types";
import type { import type {
HostDensity, HostDensity,
HostClickBehavior,
HostTrayTrigger, HostTrayTrigger,
} from "@/types/host-sidebar-preferences"; } from "@/types/host-sidebar-preferences";
import { FolderIconEl } from "@/components/folder-style"; import { FolderIconEl } from "@/components/folder-style";
@@ -69,7 +70,7 @@ export function FolderItem({
showTags = true, showTags = true,
openOnDoubleClick = false, openOnDoubleClick = false,
showFolderPaths = true, showFolderPaths = true,
focusExistingTab = true, hostClickBehavior = "newTab",
arrangeMode = false, arrangeMode = false,
isDragging = false, isDragging = false,
onReorderDrop, onReorderDrop,
@@ -122,8 +123,7 @@ export function FolderItem({
openOnDoubleClick?: boolean; openOnDoubleClick?: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */ /** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths?: boolean; showFolderPaths?: boolean;
/** When true, clicking a host with an already-open tab focuses it instead of opening a new one. */ hostClickBehavior?: HostClickBehavior;
focusExistingTab?: boolean;
/** When true (rearranging unlocked), the header can be dragged and its /** When true (rearranging unlocked), the header can be dragged and its
* top/bottom edges become reorder drop zones. The middle still accepts * top/bottom edges become reorder drop zones. The middle still accepts
* hosts dropped into the folder. */ * hosts dropped into the folder. */
@@ -347,7 +347,7 @@ export function FolderItem({
showTags={showTags} showTags={showTags}
openOnDoubleClick={openOnDoubleClick} openOnDoubleClick={openOnDoubleClick}
showFolderPaths={showFolderPaths} showFolderPaths={showFolderPaths}
focusExistingTab={focusExistingTab} hostClickBehavior={hostClickBehavior}
arrangeMode={arrangeMode} arrangeMode={arrangeMode}
onReorderDrop={onReorderDrop} onReorderDrop={onReorderDrop}
onFolderDragStart={onFolderDragStart} onFolderDragStart={onFolderDragStart}
@@ -381,7 +381,7 @@ export function FolderItem({
trayTrigger={trayTrigger} trayTrigger={trayTrigger}
showTags={showTags} showTags={showTags}
openOnDoubleClick={openOnDoubleClick} openOnDoubleClick={openOnDoubleClick}
focusExistingTab={focusExistingTab} hostClickBehavior={hostClickBehavior}
arrangeMode={arrangeMode} arrangeMode={arrangeMode}
onReorderDrop={ onReorderDrop={
onReorderDrop onReorderDrop
+43 -5
View File
@@ -41,6 +41,7 @@ import { getHostPassword } from "@/main-axios";
import type { Host, TabType } from "@/types/ui-types"; import type { Host, TabType } from "@/types/ui-types";
import type { import type {
HostDensity, HostDensity,
HostClickBehavior,
HostTrayTrigger, HostTrayTrigger,
} from "@/types/host-sidebar-preferences"; } from "@/types/host-sidebar-preferences";
import { copyToClipboard } from "@/lib/clipboard"; 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) * differ. Keeping this as one lookup (rather than two parallel JSX trees)
* means a future style tweak only has to be made once. * means a future style tweak only has to be made once.
*/ */
const DOUBLE_CLICK_WINDOW_MS = 250;
const HOST_ITEM_DENSITY_TOKENS = { const HOST_ITEM_DENSITY_TOKENS = {
comfortable: { comfortable: {
rowPadding: "pl-[8.75px] pr-[7px] py-[7px]", rowPadding: "pl-[8.75px] pr-[7px] py-[7px]",
@@ -195,7 +198,7 @@ export function HostItem({
trayTrigger = "hover", trayTrigger = "hover",
showTags = true, showTags = true,
openOnDoubleClick = false, openOnDoubleClick = false,
focusExistingTab = true, hostClickBehavior = "newTab",
showResourceBars = true, showResourceBars = true,
showStatusStripes = true, showStatusStripes = true,
rowActions = "full", rowActions = "full",
@@ -243,8 +246,8 @@ export function HostItem({
showTags?: boolean; showTags?: boolean;
/** Requires a double click to launch instead of a single click. */ /** Requires a double click to launch instead of a single click. */
openOnDoubleClick?: boolean; openOnDoubleClick?: boolean;
/** When true, clicking a host with an already-open tab focuses it instead of opening a new one. */ /** What a click does when the host already has an open tab. */
focusExistingTab?: boolean; hostClickBehavior?: HostClickBehavior;
/** Preset-driven: hides the CPU/RAM bars without changing density. */ /** Preset-driven: hides the CPU/RAM bars without changing density. */
showResourceBars?: boolean; showResourceBars?: boolean;
/** Preset-driven: hides the per-row status color stripe. */ /** Preset-driven: hides the per-row status color stripe. */
@@ -344,6 +347,18 @@ export function HostItem({
showResourceRow: densityTokens.showResourceRow && showResourceBars, showResourceRow: densityTokens.showResourceRow && showResourceBars,
}; };
const isCompact = density === "compact"; 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<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (singleClickTimer.current) clearTimeout(singleClickTimer.current);
},
[],
);
const reorderEdge = isReorderHovered ? reorderHoverEdge : null; const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
const canDrag = const canDrag =
arrangeMode && !selectionMode && !isTouchOnly && canEditHost(host); arrangeMode && !selectionMode && !isTouchOnly && canEditHost(host);
@@ -1033,12 +1048,35 @@ export function HostItem({
} }
return; return;
} }
if (openOnDoubleClick) return;
const forceNewTab = e.ctrlKey || e.metaKey; 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); openHostTab(defaultAction, forceNewTab ? { forceNewTab } : undefined);
}} }}
onDoubleClick={(e) => { 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(); e.stopPropagation();
const forceNewTab = e.ctrlKey || e.metaKey; const forceNewTab = e.ctrlKey || e.metaKey;
openHostTab(defaultAction, forceNewTab ? { forceNewTab } : undefined); openHostTab(defaultAction, forceNewTab ? { forceNewTab } : undefined);
+5 -5
View File
@@ -45,6 +45,7 @@ import type { Host, HostFolder, TabType } from "@/types/ui-types";
import type { SSHHostData } from "@/types/index"; import type { SSHHostData } from "@/types/index";
import type { import type {
HostDensity, HostDensity,
HostClickBehavior,
HostTrayTrigger, HostTrayTrigger,
} from "@/types/host-sidebar-preferences"; } from "@/types/host-sidebar-preferences";
import { resolveHostTabType } from "@/lib/host-connection-tabs"; import { resolveHostTabType } from "@/lib/host-connection-tabs";
@@ -86,7 +87,7 @@ export function SidebarTree({
showTags = true, showTags = true,
openOnDoubleClick = false, openOnDoubleClick = false,
showFolderPaths = true, showFolderPaths = true,
focusExistingTab = true, hostClickBehavior = "newTab",
}: { }: {
children: (Host | HostFolder)[]; children: (Host | HostFolder)[];
onOpenTab: ( onOpenTab: (
@@ -113,8 +114,7 @@ export function SidebarTree({
openOnDoubleClick?: boolean; openOnDoubleClick?: boolean;
/** When false, nested folders hide the parent-path breadcrumb before their name. */ /** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths?: boolean; showFolderPaths?: boolean;
/** When true, clicking a host with an already-open tab focuses it instead of opening a new one. */ hostClickBehavior?: HostClickBehavior;
focusExistingTab?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const hostSwitchPlugins = usePluginHostSections().filter( const hostSwitchPlugins = usePluginHostSections().filter(
@@ -1016,7 +1016,7 @@ export function SidebarTree({
showTags={showTags} showTags={showTags}
openOnDoubleClick={openOnDoubleClick} openOnDoubleClick={openOnDoubleClick}
showFolderPaths={showFolderPaths} showFolderPaths={showFolderPaths}
focusExistingTab={focusExistingTab} hostClickBehavior={hostClickBehavior}
arrangeMode={arrangeMode} arrangeMode={arrangeMode}
isDragging={ isDragging={
draggedReorderKey === `folder:${item.path ?? item.name}` draggedReorderKey === `folder:${item.path ?? item.name}`
@@ -1088,7 +1088,7 @@ export function SidebarTree({
trayTrigger={trayTrigger} trayTrigger={trayTrigger}
showTags={showTags} showTags={showTags}
openOnDoubleClick={openOnDoubleClick} openOnDoubleClick={openOnDoubleClick}
focusExistingTab={focusExistingTab} hostClickBehavior={hostClickBehavior}
showResourceBars={showResourceBars} showResourceBars={showResourceBars}
showStatusStripes={showStatusStripes} showStatusStripes={showStatusStripes}
rowActions={rowActions} rowActions={rowActions}
@@ -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 { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { Host } from "@/types/ui-types"; 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(
<HostItem
host={baseHost}
onOpenTab={onOpenTab}
onDelete={noop}
onDuplicate={noop}
hostClickBehavior={hostClickBehavior}
/>,
);
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();
}
});
});
@@ -27,7 +27,7 @@ describe("defaultHostSidebarPreferences", () => {
statusColorScheme: "accent", statusColorScheme: "accent",
openOnDoubleClick: false, openOnDoubleClick: false,
showFolderPaths: true, showFolderPaths: true,
focusExistingTab: true, hostClickBehavior: "newTab",
}, },
}); });
}); });
@@ -66,7 +66,7 @@ describe("sanitizeHostSidebarPreferences", () => {
statusColorScheme: "status", statusColorScheme: "status",
openOnDoubleClick: true, openOnDoubleClick: true,
showFolderPaths: false, showFolderPaths: false,
focusExistingTab: false, hostClickBehavior: "focusExistingDoubleClickNew",
}, },
}; };
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid); expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
@@ -92,14 +92,19 @@ describe("sanitizeHostSidebarPreferences", () => {
).toBe(true); ).toBe(true);
}); });
it("defaults focusExistingTab to true when missing or not a boolean", () => { it("defaults hostClickBehavior to newTab when missing or invalid", () => {
expect( expect(
sanitizeHostSidebarPreferences({ display: {} }).display.focusExistingTab, sanitizeHostSidebarPreferences({ display: {} }).display.hostClickBehavior,
).toBe(true); ).toBe("newTab");
expect( expect(
sanitizeHostSidebarPreferences({ display: { focusExistingTab: "no" } }) sanitizeHostSidebarPreferences({ display: { hostClickBehavior: "nope" } })
.display.focusExistingTab, .display.hostClickBehavior,
).toBe(true); ).toBe("newTab");
expect(
sanitizeHostSidebarPreferences({
display: { hostClickBehavior: "focusExisting" },
}).display.hostClickBehavior,
).toBe("focusExisting");
}); });
it("falls back to defaults for invalid enum values", () => { it("falls back to defaults for invalid enum values", () => {