mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
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:
1 parent
f8a8139031
commit
afae2889cb
9 files changed
+109
-12
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in new issue
Block a user