feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)

https://github.com/Termix-SSH/Support/issues/1289
This commit is contained in:
LukeGus committed 2026-09-20 18:59:33 -05:00
1 parent f8a8139031
commit afae2889cb
9 files changed
+109 -12

No files matched your search

+7
View File
@@ -51,6 +51,8 @@ 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. */
focusExistingTab: boolean;
} }
export interface HostSidebarPreferences { export interface HostSidebarPreferences {
@@ -133,6 +135,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
statusColorScheme: "accent", statusColorScheme: "accent",
openOnDoubleClick: false, openOnDoubleClick: false,
showFolderPaths: true, showFolderPaths: true,
focusExistingTab: true,
}, },
}; };
} }
@@ -210,6 +213,10 @@ export function sanitizeHostSidebarPreferences(
typeof displayObj.showFolderPaths === "boolean" typeof displayObj.showFolderPaths === "boolean"
? displayObj.showFolderPaths ? displayObj.showFolderPaths
: defaults.display.showFolderPaths, : defaults.display.showFolderPaths,
focusExistingTab:
typeof displayObj.focusExistingTab === "boolean"
? displayObj.focusExistingTab
: defaults.display.focusExistingTab,
}; };
return { return {
+15 -4
View File
@@ -547,8 +547,7 @@ export function AppShell({
const termRef = terminalRefs.current.get(tabId); const termRef = terminalRefs.current.get(tabId);
( (
termRef?.current as termRef?.current as
| import("@/features/terminal/Terminal").TerminalHandle import("@/features/terminal/Terminal").TerminalHandle | null
| null
)?.reconnect(); )?.reconnect();
return; return;
} }
@@ -1656,8 +1655,20 @@ export function AppShell({
joinShareId?: string | null; joinShareId?: string | null;
collabRoomId?: string; 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 tabId = `${host.name}-${type}-${Date.now()}`;
const instanceId = const instanceId =
restore?.instanceId ?? restore?.instanceId ??
@@ -1763,7 +1774,7 @@ export function AppShell({
function connectHost( function connectHost(
host: Host, host: Host,
preferredType?: TabType, preferredType?: TabType,
options?: { endpointId?: string; label?: string }, options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
) { ) {
const type = resolveHostTabType(host, preferredType); const type = resolveHostTabType(host, preferredType);
// --- tmux-monitor --- singleton tab, not a per-host tab // --- tmux-monitor --- singleton tab, not a per-host tab
+3
View File
@@ -1406,6 +1406,9 @@
"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",
"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}}", "deletedHost": "Deleted {{name}}",
"copiedToClipboard": "Copied to clipboard", "copiedToClipboard": "Copied to clipboard",
"terminalUrlCopied": "Terminal URL copied", "terminalUrlCopied": "Terminal URL copied",
+14
View File
@@ -153,6 +153,20 @@ 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")}
+6 -1
View File
@@ -192,7 +192,11 @@ export function HostsPanel({
onOpenTab: ( onOpenTab: (
host: Host, host: Host,
type: TabType, type: TabType,
options?: { endpointId?: string; label?: string }, options?: {
endpointId?: string;
label?: string;
forceNewTab?: boolean;
},
) => void; ) => void;
onEditHost: (host: Host) => void; onEditHost: (host: Host) => void;
hostTree?: HostFolder; hostTree?: HostFolder;
@@ -1077,6 +1081,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}
/> />
</div> </div>
)} )}
+10 -1
View File
@@ -69,6 +69,7 @@ export function FolderItem({
showTags = true, showTags = true,
openOnDoubleClick = false, openOnDoubleClick = false,
showFolderPaths = true, showFolderPaths = true,
focusExistingTab = true,
arrangeMode = false, arrangeMode = false,
isDragging = false, isDragging = false,
onReorderDrop, onReorderDrop,
@@ -83,7 +84,11 @@ export function FolderItem({
onOpenTab: ( onOpenTab: (
host: Host, host: Host,
type: TabType, type: TabType,
options?: { endpointId?: string; label?: string }, options?: {
endpointId?: string;
label?: string;
forceNewTab?: boolean;
},
) => void; ) => void;
onEditHost?: (host: Host) => void; onEditHost?: (host: Host) => void;
onShareHost?: (host: Host) => void; onShareHost?: (host: Host) => void;
@@ -118,6 +123,8 @@ 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. */
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. */
@@ -342,6 +349,7 @@ export function FolderItem({
showTags={showTags} showTags={showTags}
openOnDoubleClick={openOnDoubleClick} openOnDoubleClick={openOnDoubleClick}
showFolderPaths={showFolderPaths} showFolderPaths={showFolderPaths}
focusExistingTab={focusExistingTab}
arrangeMode={arrangeMode} arrangeMode={arrangeMode}
onReorderDrop={onReorderDrop} onReorderDrop={onReorderDrop}
onFolderDragStart={onFolderDragStart} onFolderDragStart={onFolderDragStart}
@@ -378,6 +386,7 @@ export function FolderItem({
trayTrigger={trayTrigger} trayTrigger={trayTrigger}
showTags={showTags} showTags={showTags}
openOnDoubleClick={openOnDoubleClick} openOnDoubleClick={openOnDoubleClick}
focusExistingTab={focusExistingTab}
arrangeMode={arrangeMode} arrangeMode={arrangeMode}
onReorderDrop={ onReorderDrop={
onReorderDrop onReorderDrop
+32 -5
View File
@@ -28,6 +28,7 @@ import {
Pin, Pin,
Server, Server,
Share2, Share2,
SquarePlus,
Terminal, Terminal,
Trash2, Trash2,
Users, Users,
@@ -268,6 +269,7 @@ export function HostItem({
trayTrigger = "hover", trayTrigger = "hover",
showTags = true, showTags = true,
openOnDoubleClick = false, openOnDoubleClick = false,
focusExistingTab = true,
showResourceBars = true, showResourceBars = true,
showStatusStripes = true, showStatusStripes = true,
rowActions = "full", rowActions = "full",
@@ -285,7 +287,7 @@ export function HostItem({
host: Host; host: Host;
onOpenTab: ( onOpenTab: (
type: TabType, type: TabType,
options?: { endpointId?: string; label?: string }, options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
) => void; ) => void;
onEditHost?: () => void; onEditHost?: () => void;
onShareHost?: () => void; onShareHost?: () => void;
@@ -312,6 +314,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. */
focusExistingTab?: boolean;
/** 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. */
@@ -502,11 +506,14 @@ export function HostItem({
: "terminal"; : "terminal";
const openHostTab = ( const openHostTab = (
type: TabType, type: TabType,
options?: { endpointId?: string; label?: string }, options?: { endpointId?: string; label?: string; forceNewTab?: boolean },
) => { ) => {
markTabSurfaceUsed(type); markTabSurfaceUsed(type);
recordHostActionPreference(host.id, 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 // Mirrors getSshActions: the single Web UI entry carries an endpointId only
@@ -848,6 +855,17 @@ export function HostItem({
)} )}
</DropdownMenuSubContent> </DropdownMenuSubContent>
</DropdownMenuSub> </DropdownMenuSub>
{focusExistingTab && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
openHostTab(defaultAction, { forceNewTab: true });
}}
>
<SquarePlus className="size-3.5 mr-2" />
{t("hosts.openInNewTab")}
</DropdownMenuItem>
)}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{onEditHost && ( {onEditHost && (
<DropdownMenuItem <DropdownMenuItem
@@ -1241,6 +1259,13 @@ export function HostItem({
? "bg-muted/15" ? "bg-muted/15"
: "" : ""
} ${isMenuOpen ? "bg-muted/50" : ""} ${parentDragOver ? "ring-1 ring-inset ring-accent-brand bg-accent-brand/10" : ""} ${isDragging ? "opacity-40" : ""}`} } ${isMenuOpen ? "bg-muted/50" : ""} ${parentDragOver ? "ring-1 ring-inset ring-accent-brand bg-accent-brand/10" : ""} ${isDragging ? "opacity-40" : ""}`}
onMouseDown={(e) => {
// 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) => { onClick={(e) => {
if (selectionMode) { if (selectionMode) {
onToggleSelect?.(); onToggleSelect?.();
@@ -1264,12 +1289,14 @@ export function HostItem({
return; return;
} }
if (openOnDoubleClick) return; if (openOnDoubleClick) return;
openHostTab(defaultAction); const forceNewTab = e.ctrlKey || e.metaKey;
openHostTab(defaultAction, forceNewTab ? { forceNewTab } : undefined);
}} }}
onDoubleClick={(e) => { onDoubleClick={(e) => {
if (selectionMode || isTouchOnly || !openOnDoubleClick) return; if (selectionMode || isTouchOnly || !openOnDoubleClick) return;
e.stopPropagation(); e.stopPropagation();
openHostTab(defaultAction); const forceNewTab = e.ctrlKey || e.metaKey;
openHostTab(defaultAction, forceNewTab ? { forceNewTab } : undefined);
}} }}
> >
{/* Status stripe */} {/* Status stripe */}
+10 -1
View File
@@ -88,12 +88,17 @@ export function SidebarTree({
showTags = true, showTags = true,
openOnDoubleClick = false, openOnDoubleClick = false,
showFolderPaths = true, showFolderPaths = true,
focusExistingTab = true,
}: { }: {
children: (Host | HostFolder)[]; children: (Host | HostFolder)[];
onOpenTab: ( onOpenTab: (
host: Host, host: Host,
type: TabType, type: TabType,
options?: { endpointId?: string; label?: string }, options?: {
endpointId?: string;
label?: string;
forceNewTab?: boolean;
},
) => void; ) => void;
onEditHost: (host: Host) => void; onEditHost: (host: Host) => void;
onShareHost?: (host: Host) => void; onShareHost?: (host: Host) => void;
@@ -111,6 +116,8 @@ 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. */
focusExistingTab?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Knobs with no other owner come straight from the interface preset; the // Knobs with no other owner come straight from the interface preset; the
@@ -1000,6 +1007,7 @@ export function SidebarTree({
showTags={showTags} showTags={showTags}
openOnDoubleClick={openOnDoubleClick} openOnDoubleClick={openOnDoubleClick}
showFolderPaths={showFolderPaths} showFolderPaths={showFolderPaths}
focusExistingTab={focusExistingTab}
arrangeMode={arrangeMode} arrangeMode={arrangeMode}
isDragging={ isDragging={
draggedReorderKey === `folder:${item.path ?? item.name}` draggedReorderKey === `folder:${item.path ?? item.name}`
@@ -1076,6 +1084,7 @@ export function SidebarTree({
trayTrigger={trayTrigger} trayTrigger={trayTrigger}
showTags={showTags} showTags={showTags}
openOnDoubleClick={openOnDoubleClick} openOnDoubleClick={openOnDoubleClick}
focusExistingTab={focusExistingTab}
showResourceBars={showResourceBars} showResourceBars={showResourceBars}
showStatusStripes={showStatusStripes} showStatusStripes={showStatusStripes}
rowActions={rowActions} rowActions={rowActions}
@@ -27,6 +27,7 @@ describe("defaultHostSidebarPreferences", () => {
statusColorScheme: "accent", statusColorScheme: "accent",
openOnDoubleClick: false, openOnDoubleClick: false,
showFolderPaths: true, showFolderPaths: true,
focusExistingTab: true,
}, },
}); });
}); });
@@ -65,6 +66,7 @@ describe("sanitizeHostSidebarPreferences", () => {
statusColorScheme: "status", statusColorScheme: "status",
openOnDoubleClick: true, openOnDoubleClick: true,
showFolderPaths: false, showFolderPaths: false,
focusExistingTab: false,
}, },
}; };
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid); expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
@@ -90,6 +92,16 @@ describe("sanitizeHostSidebarPreferences", () => {
).toBe(true); ).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", () => { it("falls back to defaults for invalid enum values", () => {
const result = sanitizeHostSidebarPreferences({ const result = sanitizeHostSidebarPreferences({
sort: { key: "not-a-real-key", pinnedFirst: "yes" }, sort: { key: "not-a-real-key", pinnedFirst: "yes" },