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;
|
||||
/** 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 {
|
||||
|
||||
+15
-4
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -153,6 +153,20 @@ export function CustomizeSidebarPanel({
|
||||
}
|
||||
/>
|
||||
</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
|
||||
label={t("newUi.sidebar.userProfile.statusColors")}
|
||||
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
|
||||
|
||||
@@ -192,7 +192,11 @@ export function HostsPanel({
|
||||
onOpenTab: (
|
||||
host: Host,
|
||||
type: TabType,
|
||||
options?: { endpointId?: string; label?: string },
|
||||
options?: {
|
||||
endpointId?: string;
|
||||
label?: string;
|
||||
forceNewTab?: boolean;
|
||||
},
|
||||
) => 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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
{focusExistingTab && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openHostTab(defaultAction, { forceNewTab: true });
|
||||
}}
|
||||
>
|
||||
<SquarePlus className="size-3.5 mr-2" />
|
||||
{t("hosts.openInNewTab")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{onEditHost && (
|
||||
<DropdownMenuItem
|
||||
@@ -1241,6 +1259,13 @@ export function HostItem({
|
||||
? "bg-muted/15"
|
||||
: ""
|
||||
} ${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) => {
|
||||
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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user