mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: row detail options for hosts and credentials, open manage from sidebar header
This commit is contained in:
1 parent
08db1fcdde
commit
a72aa5b57b
18 files changed
+415
-45
No files matched your search
+8
-4
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user