mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat(sidebar): add host click behavior setting, default to always opening a new tab
This commit is contained in:
1 parent
81e7aa6274
commit
278fc7e32a
9 files changed
+218
-48
No files matched your search
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
Reference in new issue
Block a user