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

+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}`}