From a72aa5b57bdcce282de1e41f00ecf625a3e70d1e Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 6 Oct 2026 14:41:57 -0500 Subject: [PATCH] feat: row detail options for hosts and credentials, open manage from sidebar header --- src/types/credential-sidebar-preferences.ts | 39 ++++++++++++++- src/types/host-sidebar-preferences.ts | 42 +++++++++++++++- src/ui/AppShell.tsx | 12 +++-- src/ui/locales/en.json | 26 ++++++++++ src/ui/sidebar/CredentialList.tsx | 4 ++ src/ui/sidebar/CredentialsPanel.tsx | 1 + .../CustomizeCredentialsSidebarPanel.tsx | 37 ++++++++++++-- src/ui/sidebar/CustomizeSidebarPanel.tsx | 39 ++++++++++++++- src/ui/sidebar/HostsPanel.tsx | 1 + .../CredentialItem/CredentialItem.tsx | 44 +++++++++++------ .../credential-tree/CredentialSidebarTree.tsx | 12 +++++ .../credential-tree/credential-row-height.ts | 11 ++++- src/ui/sidebar/tree/HostItem/HostItem.tsx | 44 +++++++++++------ src/ui/sidebar/tree/SidebarTree.tsx | 19 +++++++- .../credential-row-height.test.ts | 28 +++++++++++ .../sidebar/tree/HostItem/HostItem.test.tsx | 48 ++++++++++++++++++- .../credential-sidebar-preferences.test.ts | 21 ++++++++ .../types/host-sidebar-preferences.test.ts | 32 +++++++++++++ 18 files changed, 415 insertions(+), 45 deletions(-) diff --git a/src/types/credential-sidebar-preferences.ts b/src/types/credential-sidebar-preferences.ts index 55e33a302..0772b0cca 100644 --- a/src/types/credential-sidebar-preferences.ts +++ b/src/types/credential-sidebar-preferences.ts @@ -32,7 +32,35 @@ export interface CredentialSidebarFilterState { tags: string[]; } -export interface CredentialSidebarDisplayPreferences { +/** Which parts of a credential row are shown. */ +export interface CredentialRowFields { + showUsername: boolean; + /** The "3h" count of hosts using it. */ + showUsageCount: boolean; + showTypeBadge: boolean; + showPinIcon: boolean; + showSharedBadge: boolean; +} + +export const CREDENTIAL_ROW_FIELD_KEYS: (keyof CredentialRowFields)[] = [ + "showUsername", + "showUsageCount", + "showTypeBadge", + "showPinIcon", + "showSharedBadge", +]; + +export function defaultCredentialRowFields(): CredentialRowFields { + return { + showUsername: true, + showUsageCount: true, + showTypeBadge: true, + showPinIcon: true, + showSharedBadge: true, + }; +} + +export interface CredentialSidebarDisplayPreferences extends CredentialRowFields { density: CredentialDensity; showTags: boolean; trayTrigger: CredentialTrayTrigger; @@ -76,6 +104,7 @@ export function defaultCredentialSidebarPreferences(): CredentialSidebarPreferen density: "comfortable", showTags: true, trayTrigger: "always", + ...defaultCredentialRowFields(), }, }; } @@ -114,7 +143,15 @@ export function sanitizeCredentialSidebarPreferences( const openFolders = sanitizeStringArray(obj.openFolders); const displayObj = (obj.display ?? {}) as Record; + const rowFields = {} as CredentialRowFields; + for (const key of CREDENTIAL_ROW_FIELD_KEYS) { + rowFields[key] = + typeof displayObj[key] === "boolean" + ? (displayObj[key] as boolean) + : defaults.display[key]; + } const display: CredentialSidebarDisplayPreferences = { + ...rowFields, density: DENSITIES.includes(displayObj.density as CredentialDensity) ? (displayObj.density as CredentialDensity) : defaults.display.density, diff --git a/src/types/host-sidebar-preferences.ts b/src/types/host-sidebar-preferences.ts index c4624bf0c..bcc90f7c8 100644 --- a/src/types/host-sidebar-preferences.ts +++ b/src/types/host-sidebar-preferences.ts @@ -47,7 +47,38 @@ export interface HostSidebarFilterState { tags: string[]; } -export interface HostSidebarDisplayPreferences { +/** Which parts of a host row are shown. */ +export interface HostRowFields { + showAddress: boolean; + showUsername: boolean; + showPort: boolean; + showPinIcon: boolean; + showSharedBadge: boolean; + /** Badges plugins add next to the name. */ + showBadges: boolean; +} + +export const HOST_ROW_FIELD_KEYS: (keyof HostRowFields)[] = [ + "showAddress", + "showUsername", + "showPort", + "showPinIcon", + "showSharedBadge", + "showBadges", +]; + +export function defaultHostRowFields(): HostRowFields { + return { + showAddress: true, + showUsername: true, + showPort: false, + showPinIcon: true, + showSharedBadge: true, + showBadges: true, + }; +} + +export interface HostSidebarDisplayPreferences extends HostRowFields { density: HostDensity; showTags: boolean; trayTrigger: HostTrayTrigger; @@ -133,6 +164,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences { showFolderPaths: true, hostClickBehavior: "newTab", barActions: {}, + ...defaultHostRowFields(), }, }; } @@ -203,7 +235,15 @@ export function sanitizeHostSidebarPreferences( const openFolders = sanitizeStringArray(obj.openFolders); const displayObj = (obj.display ?? {}) as Record; + const rowFields = {} as HostRowFields; + for (const key of HOST_ROW_FIELD_KEYS) { + rowFields[key] = + typeof displayObj[key] === "boolean" + ? (displayObj[key] as boolean) + : defaults.display[key]; + } const display: HostSidebarDisplayPreferences = { + ...rowFields, density: DENSITIES.includes(displayObj.density as HostDensity) ? (displayObj.density as HostDensity) : defaults.display.density, diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 61a617316..6e414a309 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -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 ( diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 312afc829..cfa2db017 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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", diff --git a/src/ui/sidebar/CredentialList.tsx b/src/ui/sidebar/CredentialList.tsx index 262545722..e02d24424 100644 --- a/src/ui/sidebar/CredentialList.tsx +++ b/src/ui/sidebar/CredentialList.tsx @@ -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} diff --git a/src/ui/sidebar/CredentialsPanel.tsx b/src/ui/sidebar/CredentialsPanel.tsx index 03c4ff277..3d5d019d9 100644 --- a/src/ui/sidebar/CredentialsPanel.tsx +++ b/src/ui/sidebar/CredentialsPanel.tsx @@ -315,6 +315,7 @@ export function CredentialsPanel({ density={sidebarPrefs.display.density} trayTrigger={sidebarPrefs.display.trayTrigger} showTags={sidebarPrefs.display.showTags} + rowFields={sidebarPrefs.display} onTagsChange={setAllTags} /> diff --git a/src/ui/sidebar/CustomizeCredentialsSidebarPanel.tsx b/src/ui/sidebar/CustomizeCredentialsSidebarPanel.tsx index 9f5835e57..d50b2b8af 100644 --- a/src/ui/sidebar/CustomizeCredentialsSidebarPanel.tsx +++ b/src/ui/sidebar/CustomizeCredentialsSidebarPanel.tsx @@ -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({ + } + > +

+ {t("credentials.rowDetailsDesc")} +

+ {CREDENTIAL_ROW_FIELD_KEYS.map((key) => ( + + + update((prev) => ({ + ...prev, + display: { ...prev.display, [key]: v }, + })) + } + /> + + ))} +
+ } diff --git a/src/ui/sidebar/CustomizeSidebarPanel.tsx b/src/ui/sidebar/CustomizeSidebarPanel.tsx index 3b0bf33d9..64014aaac 100644 --- a/src/ui/sidebar/CustomizeSidebarPanel.tsx +++ b/src/ui/sidebar/CustomizeSidebarPanel.tsx @@ -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({ + } + > +

+ {t("hosts.rowDetailsDesc")} +

+ {ROW_FIELDS.map(({ key, dependsOnAddress }) => ( + + + update((prev) => ({ + ...prev, + display: { ...prev.display, [key]: v }, + })) + } + /> + + ))} +
+ } diff --git a/src/ui/sidebar/HostsPanel.tsx b/src/ui/sidebar/HostsPanel.tsx index 42e688613..d51f9ead5 100644 --- a/src/ui/sidebar/HostsPanel.tsx +++ b/src/ui/sidebar/HostsPanel.tsx @@ -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} /> diff --git a/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx b/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx index 978959e74..8d1c3c8a2 100644 --- a/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx +++ b/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx @@ -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 && ( )} - - {isKey ? t("credentials.keyBadge") : t("credentials.passwordBadge")} - + {rowFields.showTypeBadge && ( + + {isKey + ? t("credentials.keyBadge") + : t("credentials.passwordBadge")} + + )} - {cred.pin && ( + {rowFields.showPinIcon && cred.pin && ( )} {!shouldUseClickTray && !actionsOnly && ( - {usedByCount > 0 ? `${usedByCount}h` : ""} + {rowFields.showUsageCount && usedByCount > 0 + ? `${usedByCount}h` + : ""} )} {(shouldUseClickTray || actionsOnly) && ( @@ -331,12 +345,12 @@ export function CredentialItem({ {/* Username row */} - {tokens.showUsernameRow && (cred.username || usedByCount > 0) && ( + {tokens.showUsernameRow && (showUsername || showUsage) && ( - {cred.username} - {usedByCount > 0 && ( + {showUsername && cred.username} + {showUsage && ( {usedByCount}h diff --git a/src/ui/sidebar/credential-tree/CredentialSidebarTree.tsx b/src/ui/sidebar/credential-tree/CredentialSidebarTree.tsx index 26535552a..fe48928d1 100644 --- a/src/ui/sidebar/credential-tree/CredentialSidebarTree.tsx +++ b/src/ui/sidebar/credential-tree/CredentialSidebarTree.tsx @@ -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) => diff --git a/src/ui/sidebar/credential-tree/credential-row-height.ts b/src/ui/sidebar/credential-tree/credential-row-height.ts index 53b70cc17..e9c7c8abf 100644 --- a/src/ui/sidebar/credential-tree/credential-row-height.ts +++ b/src/ui/sidebar/credential-tree/credential-row-height.ts @@ -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) + ); } diff --git a/src/ui/sidebar/tree/HostItem/HostItem.tsx b/src/ui/sidebar/tree/HostItem/HostItem.tsx index 16be7762d..df0e3ad4c 100644 --- a/src/ui/sidebar/tree/HostItem/HostItem.tsx +++ b/src/ui/sidebar/tree/HostItem/HostItem.tsx @@ -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, + fields: Pick, +): 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({ - {host.pin && ( + {rowFields.showPinIcon && host.pin && ( )} - {badges.map((badge) => { - const Badge = badge.component; - return ; - })} - {host.isShared && ( + {rowFields.showBadges && + badges.map((badge) => { + const Badge = badge.component; + return ; + })} + {rowFields.showSharedBadge && host.isShared && ( @@ -1166,12 +1183,12 @@ export function HostItem({ +{host.tags!.length - 2} )} - {isCompact && ( + {isCompact && rowFields.showAddress && ( - {host.ip} + {formatHostAddress(host, { ...rowFields, showUsername: false })} )} {isCompact && !selectionMode && ( @@ -1211,10 +1228,9 @@ export function HostItem({ )} - {/* Address — always visible in comfortable density */} {tokens.showAddressRow && ( - {host.username}@{host.ip} + {formatHostAddress(host, rowFields)} )} diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx index 0c4a0ca98..2b04baaef 100644 --- a/src/ui/sidebar/tree/SidebarTree.tsx +++ b/src/ui/sidebar/tree/SidebarTree.tsx @@ -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("[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}`} diff --git a/src/ui/tests/sidebar/credential-tree/credential-row-height.test.ts b/src/ui/tests/sidebar/credential-tree/credential-row-height.test.ts index ba4f8d477..a92e5cba5 100644 --- a/src/ui/tests/sidebar/credential-tree/credential-row-height.test.ts +++ b/src/ui/tests/sidebar/credential-tree/credential-row-height.test.ts @@ -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 })); + }); +}); diff --git a/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx b/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx index 5424ecc91..7b510da73 100644 --- a/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx +++ b/src/ui/tests/sidebar/tree/HostItem/HostItem.test.tsx @@ -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>, + ) { + return render( + , + ); + } + + 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"); + }); +}); diff --git a/src/ui/tests/types/credential-sidebar-preferences.test.ts b/src/ui/tests/types/credential-sidebar-preferences.test.ts index 57f80d702..f103b50ef 100644 --- a/src/ui/tests/types/credential-sidebar-preferences.test.ts +++ b/src/ui/tests/types/credential-sidebar-preferences.test.ts @@ -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); + }); +}); diff --git a/src/ui/tests/types/host-sidebar-preferences.test.ts b/src/ui/tests/types/host-sidebar-preferences.test.ts index c53ab22c7..470ab4c95 100644 --- a/src/ui/tests/types/host-sidebar-preferences.test.ts +++ b/src/ui/tests/types/host-sidebar-preferences.test.ts @@ -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); + }); +});