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" },