feat: row detail options for hosts and credentials, open manage from sidebar header

This commit is contained in:
LukeGus committed 2026-10-06 14:41:57 -05:00
1 parent 08db1fcdde
commit a72aa5b57b
18 files changed
+415 -45

No files matched your search

+8 -4
View File
@@ -55,6 +55,7 @@ import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs";
import { canCloseTab } from "@/shell/tab-close-guards";
import {
MANAGE_REQUEST_EVENT,
type ManageMode,
requestFromLegacyEvent,
requestManage,
} from "@/manage/manage-requests";
@@ -2963,10 +2964,13 @@ export function AppShell({
const dockProps = (view: RailView) => ({
title: sidebarTitle(view),
onOpenAsTab:
!isMobile && promotableIds().includes(view)
? () => openSingletonTab(view as TabType)
: undefined,
onOpenAsTab: isMobile
? undefined
: view === "hosts" || view === "credentials"
? () => requestManage({ kind: "browse", mode: view as ManageMode })
: promotableIds().includes(view)
? () => openSingletonTab(view as TabType)
: undefined,
});
return (
+26
View File
@@ -90,6 +90,18 @@
"customizeBehaviorTitle": "Behavior",
"showCredentialTags": "Show Tags",
"showCredentialTagsDesc": "Display tags in the credential list",
"rowDetailsTitle": "Row details",
"rowDetailsDesc": "Choose what shows on each credential.",
"showUsername": "Username",
"showUsernameDesc": "Show the username under the credential name.",
"showUsageCount": "Host count",
"showUsageCountDesc": "Show how many hosts use the credential.",
"showTypeBadge": "Type badge",
"showTypeBadgeDesc": "Show the key or password badge.",
"showPinIcon": "Pin icon",
"showPinIconDesc": "Show a pin next to pinned credentials.",
"showSharedBadge": "Shared badge",
"showSharedBadgeDesc": "Show a badge on credentials shared with you.",
"actionsVisibility": "Credential actions",
"actionsVisibilityDesc": "When the connection and management buttons on each credential show up",
"actionsAlways": "Always visible",
@@ -657,6 +669,20 @@
"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.",
"rowDetailsTitle": "Row details",
"rowDetailsDesc": "Choose what shows on each host.",
"showAddress": "Address",
"showAddressDesc": "Show the host address under the name.",
"showUsername": "Username",
"showUsernameDesc": "Show the username before the address.",
"showPort": "Port",
"showPortDesc": "Show the port after the address.",
"showPinIcon": "Pin icon",
"showPinIconDesc": "Show a pin next to pinned hosts.",
"showSharedBadge": "Shared badge",
"showSharedBadgeDesc": "Show a badge on hosts shared with you.",
"showBadges": "Feature badges",
"showBadgesDesc": "Show badges that features add next to the host name.",
"hostClickBehavior": "Clicking a host",
"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",
+4
View File
@@ -17,6 +17,7 @@ import {
import { requestManage } from "@/manage/manage-requests";
import type { Credential, Host } from "@/types/ui-types";
import type {
CredentialRowFields,
CredentialSidebarFilterState,
CredentialSortKey,
} from "@/types/credential-sidebar-preferences";
@@ -40,6 +41,7 @@ export function CredentialList({
density = "comfortable",
trayTrigger = "hover",
showTags = true,
rowFields,
onTagsChange,
}: {
search?: string;
@@ -49,6 +51,7 @@ export function CredentialList({
density?: "comfortable" | "compact";
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
showTags?: boolean;
rowFields?: CredentialRowFields;
onTagsChange?: (tags: string[]) => void;
}) {
const { t } = useTranslation();
@@ -232,6 +235,7 @@ export function CredentialList({
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
rowFields={rowFields}
editingFolderName={editingFolderName}
editingFolderValue={editingFolderValue}
onEditingFolderNameChange={setEditingFolderName}
+1
View File
@@ -315,6 +315,7 @@ export function CredentialsPanel({
density={sidebarPrefs.display.density}
trayTrigger={sidebarPrefs.display.trayTrigger}
showTags={sidebarPrefs.display.showTags}
rowFields={sidebarPrefs.display}
onTagsChange={setAllTags}
/>
</div>
@@ -1,12 +1,13 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
import { ListChecks, Rows3, SquareStack } from "lucide-react";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
CredentialDensity,
CredentialSidebarPreferences,
CredentialTrayTrigger,
import {
CREDENTIAL_ROW_FIELD_KEYS,
type CredentialDensity,
type CredentialSidebarPreferences,
type CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
export function CustomizeCredentialsSidebarPanel({
@@ -71,6 +72,32 @@ export function CustomizeCredentialsSidebarPanel({
</SettingRow>
</SectionCard>
<SectionCard
title={t("credentials.rowDetailsTitle")}
icon={<ListChecks className="size-3.5" />}
>
<p className="pt-2 text-xs text-muted-foreground">
{t("credentials.rowDetailsDesc")}
</p>
{CREDENTIAL_ROW_FIELD_KEYS.map((key) => (
<SettingRow
key={key}
label={t(`credentials.${key}`)}
description={t(`credentials.${key}Desc`)}
>
<FakeSwitch
checked={preferences.display[key]}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, [key]: v },
}))
}
/>
</SettingRow>
))}
</SectionCard>
<SectionCard
title={t("credentials.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
+38 -1
View File
@@ -1,17 +1,27 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { PanelTop, Rows3, SquareStack } from "lucide-react";
import { ListChecks, PanelTop, Rows3, SquareStack } from "lucide-react";
import { actionOrder, useHostActions } from "./host-contributions";
import { showsInBar } from "./tree/host-bar-actions";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
HostClickBehavior,
HostDensity,
HostRowFields,
HostSidebarPreferences,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
const ROW_FIELDS: { key: keyof HostRowFields; dependsOnAddress?: boolean }[] = [
{ key: "showAddress" },
{ key: "showUsername", dependsOnAddress: true },
{ key: "showPort", dependsOnAddress: true },
{ key: "showPinIcon" },
{ key: "showSharedBadge" },
{ key: "showBadges" },
];
export function CustomizeSidebarPanel({
open,
onOpenChange,
@@ -77,6 +87,33 @@ export function CustomizeSidebarPanel({
</SettingRow>
</SectionCard>
<SectionCard
title={t("hosts.rowDetailsTitle")}
icon={<ListChecks className="size-3.5" />}
>
<p className="pt-2 text-xs text-muted-foreground">
{t("hosts.rowDetailsDesc")}
</p>
{ROW_FIELDS.map(({ key, dependsOnAddress }) => (
<SettingRow
key={key}
label={t(`hosts.${key}`)}
description={t(`hosts.${key}Desc`)}
>
<FakeSwitch
checked={preferences.display[key]}
disabled={dependsOnAddress && !preferences.display.showAddress}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, [key]: v },
}))
}
/>
</SettingRow>
))}
</SectionCard>
<SectionCard
title={t("hosts.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
+1
View File
@@ -1061,6 +1061,7 @@ export function HostsPanel({
showTags={sidebarPrefs.display.showTags}
openOnDoubleClick={sidebarPrefs.display.openOnDoubleClick}
showFolderPaths={sidebarPrefs.display.showFolderPaths}
rowFields={sidebarPrefs.display}
hostClickBehavior={sidebarPrefs.display.hostClickBehavior}
/>
</div>
@@ -15,9 +15,11 @@ import { toast } from "sonner";
import { copyToClipboard } from "@/lib/clipboard";
import { ComponentSlot } from "@/shell/ActionSlot";
import type { Credential } from "@/types/ui-types";
import type {
CredentialDensity,
CredentialTrayTrigger,
import {
defaultCredentialRowFields,
type CredentialDensity,
type CredentialRowFields,
type CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
/**
@@ -40,6 +42,8 @@ const CREDENTIAL_ITEM_DENSITY_TOKENS = {
},
} as const;
const DEFAULT_ROW_FIELDS = defaultCredentialRowFields();
export function CredentialItem({
cred,
usedByCount = 0,
@@ -53,6 +57,7 @@ export function CredentialItem({
density = "comfortable",
trayTrigger = "hover",
showTags = true,
rowFields = DEFAULT_ROW_FIELDS,
arrangeMode = false,
isDragging = false,
onReorderDrop,
@@ -80,6 +85,7 @@ export function CredentialItem({
density?: CredentialDensity;
trayTrigger?: CredentialTrayTrigger;
showTags?: boolean;
rowFields?: CredentialRowFields;
/** When true (rearranging unlocked), the row can be dragged to reorder or
* onto a folder header to move. */
arrangeMode?: boolean;
@@ -103,6 +109,8 @@ export function CredentialItem({
const isKey = cred.type === "key";
const tokens = CREDENTIAL_ITEM_DENSITY_TOKENS[density];
const isCompact = density === "compact";
const showUsername = rowFields.showUsername && !!cred.username;
const showUsage = rowFields.showUsageCount && usedByCount > 0;
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
@@ -281,7 +289,7 @@ export function CredentialItem({
className={`${tokens.nameTextSize} font-semibold truncate text-foreground leading-none tracking-tight`}
>
{cred.name}
{cred.isShared && (
{rowFields.showSharedBadge && cred.isShared && (
<span
className="ml-1 inline-flex items-center gap-0.5 text-[9px] uppercase text-accent-brand/80"
title={t("credentials.sharedBy", {
@@ -293,21 +301,27 @@ export function CredentialItem({
</span>
)}
</span>
<span
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
>
{isKey ? t("credentials.keyBadge") : t("credentials.passwordBadge")}
</span>
{rowFields.showTypeBadge && (
<span
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
>
{isKey
? t("credentials.keyBadge")
: t("credentials.passwordBadge")}
</span>
)}
<ComponentSlot
slotId="credentials.badges"
props={{ credentialId: Number(cred.id) }}
/>
{cred.pin && (
{rowFields.showPinIcon && cred.pin && (
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
)}
{!shouldUseClickTray && !actionsOnly && (
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0 group-hover:hidden">
{usedByCount > 0 ? `${usedByCount}h` : ""}
{rowFields.showUsageCount && usedByCount > 0
? `${usedByCount}h`
: ""}
</span>
)}
{(shouldUseClickTray || actionsOnly) && (
@@ -331,12 +345,12 @@ export function CredentialItem({
</div>
{/* Username row */}
{tokens.showUsernameRow && (cred.username || usedByCount > 0) && (
{tokens.showUsernameRow && (showUsername || showUsage) && (
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
{cred.username}
{usedByCount > 0 && (
{showUsername && cred.username}
{showUsage && (
<span
className={`text-muted-foreground/40${cred.username ? " ml-2" : ""}`}
className={`text-muted-foreground/40${showUsername ? " ml-2" : ""}`}
>
{usedByCount}h
</span>
@@ -15,6 +15,7 @@ import { ReorderIndicator } from "@/sidebar/ReorderIndicator";
import type { Credential } from "@/types/ui-types";
import type {
CredentialDensity,
CredentialRowFields,
CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
import { CredentialItem } from "./CredentialItem/CredentialItem";
@@ -41,6 +42,7 @@ export function CredentialSidebarTree({
density = "comfortable",
trayTrigger = "hover",
showTags = true,
rowFields,
editingFolderName,
editingFolderValue,
onEditingFolderNameChange,
@@ -63,6 +65,7 @@ export function CredentialSidebarTree({
density?: CredentialDensity;
trayTrigger?: CredentialTrayTrigger;
showTags?: boolean;
rowFields?: CredentialRowFields;
editingFolderName: string | null;
editingFolderValue: string;
onEditingFolderNameChange: (name: string | null) => void;
@@ -165,6 +168,11 @@ export function CredentialSidebarTree({
isOpen,
showTags,
tagCount: row.item.tags?.length ?? 0,
hasDetailRow:
!rowFields ||
(rowFields.showUsername && !!row.item.username) ||
(rowFields.showUsageCount &&
(usedByCounts?.get(row.item.id) ?? 0) > 0),
});
},
[
@@ -176,6 +184,8 @@ export function CredentialSidebarTree({
actionsOnly,
density,
showTags,
rowFields,
usedByCounts,
],
);
@@ -223,6 +233,7 @@ export function CredentialSidebarTree({
density,
trayTrigger,
showTags,
rowFields,
]);
function toggleFolder(name: string) {
@@ -422,6 +433,7 @@ export function CredentialSidebarTree({
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
rowFields={rowFields}
arrangeMode={arrangeMode}
isDragging={draggedReorderKey === `cred:${item.id}`}
onReorderDrop={(position) =>
@@ -10,6 +10,7 @@ const HEIGHTS = {
alwaysKey: 100.25,
alwaysPassword: 74,
tags: 18.5,
detail: 15,
},
compact: {
base: 23,
@@ -20,6 +21,7 @@ const HEIGHTS = {
alwaysKey: 74.75,
alwaysPassword: 52,
tags: 12.5,
detail: 0,
},
} as const;
@@ -31,6 +33,7 @@ export function getCredentialRowHeight({
isOpen,
showTags,
tagCount,
hasDetailRow = true,
}: {
density: CredentialDensity;
isKey: boolean;
@@ -39,6 +42,8 @@ export function getCredentialRowHeight({
isOpen: boolean;
showTags: boolean;
tagCount: number;
/** False when the username and usage line has nothing to show. */
hasDetailRow?: boolean;
}): number {
const heights = HEIGHTS[density];
let height: number = heights.base;
@@ -51,5 +56,9 @@ export function getCredentialRowHeight({
height = isKey ? heights.actionsOnlyKey : heights.actionsOnlyPassword;
}
return height + (showTags && tagCount > 0 ? heights.tags : 0);
return (
height +
(showTags && tagCount > 0 ? heights.tags : 0) -
(hasDetailRow ? 0 : heights.detail)
);
}
+30 -14
View File
@@ -41,10 +41,12 @@ import {
import { toast } from "sonner";
import { getHostPassword } from "@/main-axios";
import type { Host, TabType } from "@/types/ui-types";
import type {
HostDensity,
HostClickBehavior,
HostTrayTrigger,
import {
defaultHostRowFields,
type HostDensity,
type HostClickBehavior,
type HostRowFields,
type HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
import { copyToClipboard } from "@/lib/clipboard";
import {
@@ -143,6 +145,17 @@ function canCopyHostSudoPassword(host: Host): boolean {
*/
const DOUBLE_CLICK_WINDOW_MS = 250;
const DEFAULT_ROW_FIELDS = defaultHostRowFields();
export function formatHostAddress(
host: Pick<Host, "username" | "ip" | "port">,
fields: Pick<HostRowFields, "showUsername" | "showPort">,
): string {
const user = fields.showUsername && host.username ? `${host.username}@` : "";
const port = fields.showPort && host.port ? `:${host.port}` : "";
return `${user}${host.ip}${port}`;
}
const HOST_ITEM_DENSITY_TOKENS = {
comfortable: {
rowPadding: "pl-[8.75px] pr-[7px] py-[7px]",
@@ -188,6 +201,7 @@ export function HostItem({
hostClickBehavior = "newTab",
showResourceBars = true,
showStatusStripes = true,
rowFields = DEFAULT_ROW_FIELDS,
rowActions = "full",
arrangeMode = false,
isDragging = false,
@@ -239,6 +253,7 @@ export function HostItem({
showResourceBars?: boolean;
/** Preset-driven: hides the per-row status color stripe. */
showStatusStripes?: boolean;
rowFields?: HostRowFields;
/** "essential" trims the row's management actions to the common few. */
rowActions?: "essential" | "full";
/** When true (rearranging unlocked), the row can be dragged: its edges
@@ -320,6 +335,7 @@ export function HostItem({
const densityTokens = HOST_ITEM_DENSITY_TOKENS[density];
const tokens = {
...densityTokens,
showAddressRow: densityTokens.showAddressRow && rowFields.showAddress,
showTagsRow: densityTokens.showTagsRow && showTags,
showResourceRow: densityTokens.showResourceRow && showResourceBars,
};
@@ -1123,14 +1139,15 @@ export function HostItem({
</TooltipContent>
</Tooltip>
</TooltipProvider>
{host.pin && (
{rowFields.showPinIcon && host.pin && (
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
)}
{badges.map((badge) => {
const Badge = badge.component;
return <Badge key={badge.id} host={host} />;
})}
{host.isShared && (
{rowFields.showBadges &&
badges.map((badge) => {
const Badge = badge.component;
return <Badge key={badge.id} host={host} />;
})}
{rowFields.showSharedBadge && host.isShared && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
@@ -1166,12 +1183,12 @@ export function HostItem({
+{host.tags!.length - 2}
</span>
)}
{isCompact && (
{isCompact && rowFields.showAddress && (
<span
className="text-[11px] text-muted-foreground/70 truncate leading-none ml-auto max-w-[50%] shrink-0"
title={host.ip}
>
{host.ip}
{formatHostAddress(host, { ...rowFields, showUsername: false })}
</span>
)}
{isCompact && !selectionMode && (
@@ -1211,10 +1228,9 @@ export function HostItem({
)}
</div>
{/* Address — always visible in comfortable density */}
{tokens.showAddressRow && (
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
{host.username}@{host.ip}
{formatHostAddress(host, rowFields)}
</span>
)}
+17 -2
View File
@@ -50,6 +50,7 @@ import type { SSHHostData } from "@/types/index";
import type {
HostDensity,
HostClickBehavior,
HostRowFields,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
@@ -93,6 +94,7 @@ export function SidebarTree({
openOnDoubleClick = false,
showFolderPaths = true,
hostClickBehavior = "newTab",
rowFields,
}: {
children: (Host | HostFolder)[];
onOpenTab: (
@@ -120,6 +122,7 @@ export function SidebarTree({
/** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths?: boolean;
hostClickBehavior?: HostClickBehavior;
rowFields?: HostRowFields;
}) {
const { t } = useTranslation();
const hostSettingPlugins = usePluginHostSections();
@@ -753,6 +756,8 @@ export function SidebarTree({
// Tag pills are a separate flex row. Comfortable density adds the row plus
// its gap; compact density pulls it upward by 2px but still needs a slot.
const TAG_ROW_EXTRA = isCompactDensity ? 12.5 : 18.5;
// The username@ip line plus its gap.
const ADDRESS_ROW_SAVED = rowFields && !rowFields.showAddress ? 14.5 : 0;
// showResourceBars:false simply drops RESOURCE_ROW_EXTRA from every row.
const rowHeight = useCallback(
@@ -761,7 +766,7 @@ export function SidebarTree({
if (!row) return FOLDER_ROW_HEIGHT;
if (isFolder(row.item)) return FOLDER_ROW_HEIGHT;
if (isCompactDensity) return 29;
const tagExtra = showTags && row.item.tags?.length ? TAG_ROW_EXTRA : 0;
const tagRowExtra = showTags && row.item.tags?.length ? TAG_ROW_EXTRA : 0;
// The resource bars only render for an online host that reported CPU/RAM.
// Reserving their height unconditionally left a gap under every offline
// row. measureElement corrects any drift from live status this estimate
@@ -773,6 +778,7 @@ export function SidebarTree({
)
? RESOURCE_ROW_EXTRA
: 0;
const tagExtra = tagRowExtra - ADDRESS_ROW_SAVED;
if (alwaysShowActions)
return ALWAYS_ROW_HEIGHT + tagExtra + resourceExtra;
const toggledOpen =
@@ -818,6 +824,7 @@ export function SidebarTree({
ACTIONS_ONLY_ROW_HEIGHT,
ACTIONS_ONLY_OPEN_ROW_HEIGHT,
TAG_ROW_EXTRA,
ADDRESS_ROW_SAVED,
showResourceBars,
isCompactDensity,
],
@@ -887,7 +894,14 @@ export function SidebarTree({
parentRef.current
?.querySelectorAll<HTMLElement>("[data-index]")
.forEach((element) => virtualizer.measureElement(element));
}, [virtualizer, density, trayTrigger, showTags, showResourceBars]);
}, [
virtualizer,
density,
trayTrigger,
showTags,
showResourceBars,
ADDRESS_ROW_SAVED,
]);
useEffect(() => {
if (speedSearch.open) speedSearch.inputRef.current?.focus();
@@ -1168,6 +1182,7 @@ export function SidebarTree({
hostClickBehavior={hostClickBehavior}
showResourceBars={showResourceBars}
showStatusStripes={showStatusStripes}
rowFields={rowFields}
rowActions={rowActions}
arrangeMode={arrangeMode}
isDragging={draggedReorderKey === `host:${item.id}`}
@@ -56,3 +56,31 @@ describe("getCredentialRowHeight", () => {
).toBe(base);
});
});
describe("detail row", () => {
it("drops the username line height in comfortable density only", () => {
const shape = {
isKey: false,
alwaysShowActions: false,
actionsOnly: false,
isOpen: false,
showTags: false,
tagCount: 0,
};
const full = getCredentialRowHeight({ density: "comfortable", ...shape });
expect(
getCredentialRowHeight({
density: "comfortable",
...shape,
hasDetailRow: false,
}),
).toBe(full - 15);
expect(
getCredentialRowHeight({
density: "compact",
...shape,
hasDetailRow: false,
}),
).toBe(getCredentialRowHeight({ density: "compact", ...shape }));
});
});
@@ -43,7 +43,11 @@ vi.mock("@/main-axios", () => ({
getHostPassword: vi.fn(),
}));
import { HostItem } from "../../../../sidebar/tree/HostItem/HostItem";
import {
HostItem,
formatHostAddress,
} from "../../../../sidebar/tree/HostItem/HostItem";
import { defaultHostRowFields } from "@/types/host-sidebar-preferences";
const baseHost: Host = {
id: "1",
@@ -356,3 +360,45 @@ describe("HostItem click behavior", () => {
}
});
});
describe("HostItem row fields", () => {
function renderWith(
fields: Partial<ReturnType<typeof defaultHostRowFields>>,
) {
return render(
<HostItem
host={baseHost}
onOpenTab={noop}
onEditHost={noop}
onDelete={noop}
onDuplicate={noop}
density="comfortable"
rowFields={{ ...defaultHostRowFields(), ...fields }}
/>,
);
}
it("shows user@address by default", () => {
renderWith({});
expect(screen.getByText("root@10.0.0.5")).toBeTruthy();
});
it("hides the address when turned off", () => {
renderWith({ showAddress: false });
expect(screen.queryByText(/10\.0\.0\.5/)).toBeNull();
});
it("hides the pin icon when turned off", () => {
renderWith({ showPinIcon: false });
expect(document.querySelector(".lucide-pin")).toBeNull();
});
it("formats the address from the chosen parts", () => {
expect(
formatHostAddress(baseHost, { showUsername: false, showPort: true }),
).toBe("10.0.0.5:22");
expect(
formatHostAddress(baseHost, { showUsername: true, showPort: false }),
).toBe("root@10.0.0.5");
});
});
@@ -20,6 +20,11 @@ describe("defaultCredentialSidebarPreferences", () => {
density: "comfortable",
showTags: true,
trayTrigger: "always",
showUsername: true,
showUsageCount: true,
showTypeBadge: true,
showPinIcon: true,
showSharedBadge: true,
},
});
});
@@ -51,6 +56,11 @@ describe("sanitizeCredentialSidebarPreferences", () => {
density: "compact",
showTags: false,
trayTrigger: "click",
showUsername: false,
showUsageCount: false,
showTypeBadge: false,
showPinIcon: true,
showSharedBadge: false,
},
};
expect(sanitizeCredentialSidebarPreferences(valid)).toEqual(valid);
@@ -90,3 +100,14 @@ describe("sanitizeCredentialSidebarPreferences", () => {
expect(result.version).toBe(CREDENTIAL_SIDEBAR_PREFS_VERSION);
});
});
describe("credential row fields", () => {
it("keeps boolean choices and defaults the rest", () => {
const prefs = sanitizeCredentialSidebarPreferences({
display: { showUsername: false, showTypeBadge: "off" },
});
expect(prefs.display.showUsername).toBe(false);
expect(prefs.display.showTypeBadge).toBe(true);
expect(prefs.display.showUsageCount).toBe(true);
});
});
@@ -29,6 +29,12 @@ describe("defaultHostSidebarPreferences", () => {
showFolderPaths: true,
hostClickBehavior: "newTab",
barActions: {},
showAddress: true,
showUsername: true,
showPort: false,
showPinIcon: true,
showSharedBadge: true,
showBadges: true,
},
});
});
@@ -69,6 +75,12 @@ describe("sanitizeHostSidebarPreferences", () => {
showFolderPaths: false,
hostClickBehavior: "focusExistingDoubleClickNew",
barActions: { maintenance: true },
showAddress: false,
showUsername: false,
showPort: true,
showPinIcon: false,
showSharedBadge: false,
showBadges: true,
},
};
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
@@ -174,3 +186,23 @@ describe("barActions", () => {
).toEqual({});
});
});
describe("row fields", () => {
it("keeps boolean row field choices", () => {
const prefs = sanitizeHostSidebarPreferences({
display: { showAddress: false, showPort: true, showBadges: false },
});
expect(prefs.display.showAddress).toBe(false);
expect(prefs.display.showPort).toBe(true);
expect(prefs.display.showBadges).toBe(false);
expect(prefs.display.showUsername).toBe(true);
});
it("falls back to defaults for non-boolean values", () => {
const prefs = sanitizeHostSidebarPreferences({
display: { showAddress: "no", showPort: 1 },
});
expect(prefs.display.showAddress).toBe(true);
expect(prefs.display.showPort).toBe(false);
});
});