mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25: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
@@ -32,7 +32,35 @@ export interface CredentialSidebarFilterState {
|
|||||||
tags: string[];
|
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;
|
density: CredentialDensity;
|
||||||
showTags: boolean;
|
showTags: boolean;
|
||||||
trayTrigger: CredentialTrayTrigger;
|
trayTrigger: CredentialTrayTrigger;
|
||||||
@@ -76,6 +104,7 @@ export function defaultCredentialSidebarPreferences(): CredentialSidebarPreferen
|
|||||||
density: "comfortable",
|
density: "comfortable",
|
||||||
showTags: true,
|
showTags: true,
|
||||||
trayTrigger: "always",
|
trayTrigger: "always",
|
||||||
|
...defaultCredentialRowFields(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -114,7 +143,15 @@ export function sanitizeCredentialSidebarPreferences(
|
|||||||
const openFolders = sanitizeStringArray(obj.openFolders);
|
const openFolders = sanitizeStringArray(obj.openFolders);
|
||||||
|
|
||||||
const displayObj = (obj.display ?? {}) as Record<string, unknown>;
|
const displayObj = (obj.display ?? {}) as Record<string, unknown>;
|
||||||
|
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 = {
|
const display: CredentialSidebarDisplayPreferences = {
|
||||||
|
...rowFields,
|
||||||
density: DENSITIES.includes(displayObj.density as CredentialDensity)
|
density: DENSITIES.includes(displayObj.density as CredentialDensity)
|
||||||
? (displayObj.density as CredentialDensity)
|
? (displayObj.density as CredentialDensity)
|
||||||
: defaults.display.density,
|
: defaults.display.density,
|
||||||
|
|||||||
@@ -47,7 +47,38 @@ export interface HostSidebarFilterState {
|
|||||||
tags: string[];
|
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;
|
density: HostDensity;
|
||||||
showTags: boolean;
|
showTags: boolean;
|
||||||
trayTrigger: HostTrayTrigger;
|
trayTrigger: HostTrayTrigger;
|
||||||
@@ -133,6 +164,7 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
|
|||||||
showFolderPaths: true,
|
showFolderPaths: true,
|
||||||
hostClickBehavior: "newTab",
|
hostClickBehavior: "newTab",
|
||||||
barActions: {},
|
barActions: {},
|
||||||
|
...defaultHostRowFields(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -203,7 +235,15 @@ export function sanitizeHostSidebarPreferences(
|
|||||||
const openFolders = sanitizeStringArray(obj.openFolders);
|
const openFolders = sanitizeStringArray(obj.openFolders);
|
||||||
|
|
||||||
const displayObj = (obj.display ?? {}) as Record<string, unknown>;
|
const displayObj = (obj.display ?? {}) as Record<string, unknown>;
|
||||||
|
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 = {
|
const display: HostSidebarDisplayPreferences = {
|
||||||
|
...rowFields,
|
||||||
density: DENSITIES.includes(displayObj.density as HostDensity)
|
density: DENSITIES.includes(displayObj.density as HostDensity)
|
||||||
? (displayObj.density as HostDensity)
|
? (displayObj.density as HostDensity)
|
||||||
: defaults.display.density,
|
: defaults.display.density,
|
||||||
|
|||||||
+8
-4
@@ -55,6 +55,7 @@ import { reconnectDisconnectedTabs } from "@/shell/reconnect-tabs";
|
|||||||
import { canCloseTab } from "@/shell/tab-close-guards";
|
import { canCloseTab } from "@/shell/tab-close-guards";
|
||||||
import {
|
import {
|
||||||
MANAGE_REQUEST_EVENT,
|
MANAGE_REQUEST_EVENT,
|
||||||
|
type ManageMode,
|
||||||
requestFromLegacyEvent,
|
requestFromLegacyEvent,
|
||||||
requestManage,
|
requestManage,
|
||||||
} from "@/manage/manage-requests";
|
} from "@/manage/manage-requests";
|
||||||
@@ -2963,10 +2964,13 @@ export function AppShell({
|
|||||||
|
|
||||||
const dockProps = (view: RailView) => ({
|
const dockProps = (view: RailView) => ({
|
||||||
title: sidebarTitle(view),
|
title: sidebarTitle(view),
|
||||||
onOpenAsTab:
|
onOpenAsTab: isMobile
|
||||||
!isMobile && promotableIds().includes(view)
|
? undefined
|
||||||
? () => openSingletonTab(view as TabType)
|
: view === "hosts" || view === "credentials"
|
||||||
: undefined,
|
? () => requestManage({ kind: "browse", mode: view as ManageMode })
|
||||||
|
: promotableIds().includes(view)
|
||||||
|
? () => openSingletonTab(view as TabType)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -90,6 +90,18 @@
|
|||||||
"customizeBehaviorTitle": "Behavior",
|
"customizeBehaviorTitle": "Behavior",
|
||||||
"showCredentialTags": "Show Tags",
|
"showCredentialTags": "Show Tags",
|
||||||
"showCredentialTagsDesc": "Display tags in the credential list",
|
"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",
|
"actionsVisibility": "Credential actions",
|
||||||
"actionsVisibilityDesc": "When the connection and management buttons on each credential show up",
|
"actionsVisibilityDesc": "When the connection and management buttons on each credential show up",
|
||||||
"actionsAlways": "Always visible",
|
"actionsAlways": "Always visible",
|
||||||
@@ -657,6 +669,20 @@
|
|||||||
"openOnDoubleClickDesc": "Require a double click to open a host session instead of a single click",
|
"openOnDoubleClickDesc": "Require a double click to open a host session instead of a single click",
|
||||||
"showFolderPaths": "Show folder paths",
|
"showFolderPaths": "Show folder paths",
|
||||||
"showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.",
|
"showFolderPathsDesc": "Show the parent folder path above nested subfolder names. Subfolders stay indented either way.",
|
||||||
|
"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",
|
"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.",
|
"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",
|
"hostClickNewTab": "Always open a new tab",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { requestManage } from "@/manage/manage-requests";
|
import { requestManage } from "@/manage/manage-requests";
|
||||||
import type { Credential, Host } from "@/types/ui-types";
|
import type { Credential, Host } from "@/types/ui-types";
|
||||||
import type {
|
import type {
|
||||||
|
CredentialRowFields,
|
||||||
CredentialSidebarFilterState,
|
CredentialSidebarFilterState,
|
||||||
CredentialSortKey,
|
CredentialSortKey,
|
||||||
} from "@/types/credential-sidebar-preferences";
|
} from "@/types/credential-sidebar-preferences";
|
||||||
@@ -40,6 +41,7 @@ export function CredentialList({
|
|||||||
density = "comfortable",
|
density = "comfortable",
|
||||||
trayTrigger = "hover",
|
trayTrigger = "hover",
|
||||||
showTags = true,
|
showTags = true,
|
||||||
|
rowFields,
|
||||||
onTagsChange,
|
onTagsChange,
|
||||||
}: {
|
}: {
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -49,6 +51,7 @@ export function CredentialList({
|
|||||||
density?: "comfortable" | "compact";
|
density?: "comfortable" | "compact";
|
||||||
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
|
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
|
||||||
showTags?: boolean;
|
showTags?: boolean;
|
||||||
|
rowFields?: CredentialRowFields;
|
||||||
onTagsChange?: (tags: string[]) => void;
|
onTagsChange?: (tags: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -232,6 +235,7 @@ export function CredentialList({
|
|||||||
density={density}
|
density={density}
|
||||||
trayTrigger={trayTrigger}
|
trayTrigger={trayTrigger}
|
||||||
showTags={showTags}
|
showTags={showTags}
|
||||||
|
rowFields={rowFields}
|
||||||
editingFolderName={editingFolderName}
|
editingFolderName={editingFolderName}
|
||||||
editingFolderValue={editingFolderValue}
|
editingFolderValue={editingFolderValue}
|
||||||
onEditingFolderNameChange={setEditingFolderName}
|
onEditingFolderNameChange={setEditingFolderName}
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export function CredentialsPanel({
|
|||||||
density={sidebarPrefs.display.density}
|
density={sidebarPrefs.display.density}
|
||||||
trayTrigger={sidebarPrefs.display.trayTrigger}
|
trayTrigger={sidebarPrefs.display.trayTrigger}
|
||||||
showTags={sidebarPrefs.display.showTags}
|
showTags={sidebarPrefs.display.showTags}
|
||||||
|
rowFields={sidebarPrefs.display}
|
||||||
onTagsChange={setAllTags}
|
onTagsChange={setAllTags}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Select2 } from "@/components/select2";
|
import { Select2 } from "@/components/select2";
|
||||||
import { InlineView } from "@/components/surface/surface-scope";
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||||
import type {
|
import {
|
||||||
CredentialDensity,
|
CREDENTIAL_ROW_FIELD_KEYS,
|
||||||
CredentialSidebarPreferences,
|
type CredentialDensity,
|
||||||
CredentialTrayTrigger,
|
type CredentialSidebarPreferences,
|
||||||
|
type CredentialTrayTrigger,
|
||||||
} from "@/types/credential-sidebar-preferences";
|
} from "@/types/credential-sidebar-preferences";
|
||||||
|
|
||||||
export function CustomizeCredentialsSidebarPanel({
|
export function CustomizeCredentialsSidebarPanel({
|
||||||
@@ -71,6 +72,32 @@ export function CustomizeCredentialsSidebarPanel({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SectionCard>
|
</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
|
<SectionCard
|
||||||
title={t("credentials.customizeBehaviorTitle")}
|
title={t("credentials.customizeBehaviorTitle")}
|
||||||
icon={<SquareStack className="size-3.5" />}
|
icon={<SquareStack className="size-3.5" />}
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
import { Select2 } from "@/components/select2";
|
import { Select2 } from "@/components/select2";
|
||||||
import { InlineView } from "@/components/surface/surface-scope";
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { actionOrder, useHostActions } from "./host-contributions";
|
||||||
import { showsInBar } from "./tree/host-bar-actions";
|
import { showsInBar } from "./tree/host-bar-actions";
|
||||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||||
import type {
|
import type {
|
||||||
HostClickBehavior,
|
HostClickBehavior,
|
||||||
HostDensity,
|
HostDensity,
|
||||||
|
HostRowFields,
|
||||||
HostSidebarPreferences,
|
HostSidebarPreferences,
|
||||||
HostTrayTrigger,
|
HostTrayTrigger,
|
||||||
} from "@/types/host-sidebar-preferences";
|
} 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({
|
export function CustomizeSidebarPanel({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
@@ -77,6 +87,33 @@ export function CustomizeSidebarPanel({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SectionCard>
|
</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
|
<SectionCard
|
||||||
title={t("hosts.customizeBehaviorTitle")}
|
title={t("hosts.customizeBehaviorTitle")}
|
||||||
icon={<SquareStack className="size-3.5" />}
|
icon={<SquareStack className="size-3.5" />}
|
||||||
|
|||||||
@@ -1061,6 +1061,7 @@ export function HostsPanel({
|
|||||||
showTags={sidebarPrefs.display.showTags}
|
showTags={sidebarPrefs.display.showTags}
|
||||||
openOnDoubleClick={sidebarPrefs.display.openOnDoubleClick}
|
openOnDoubleClick={sidebarPrefs.display.openOnDoubleClick}
|
||||||
showFolderPaths={sidebarPrefs.display.showFolderPaths}
|
showFolderPaths={sidebarPrefs.display.showFolderPaths}
|
||||||
|
rowFields={sidebarPrefs.display}
|
||||||
hostClickBehavior={sidebarPrefs.display.hostClickBehavior}
|
hostClickBehavior={sidebarPrefs.display.hostClickBehavior}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,9 +15,11 @@ import { toast } from "sonner";
|
|||||||
import { copyToClipboard } from "@/lib/clipboard";
|
import { copyToClipboard } from "@/lib/clipboard";
|
||||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||||
import type { Credential } from "@/types/ui-types";
|
import type { Credential } from "@/types/ui-types";
|
||||||
import type {
|
import {
|
||||||
CredentialDensity,
|
defaultCredentialRowFields,
|
||||||
CredentialTrayTrigger,
|
type CredentialDensity,
|
||||||
|
type CredentialRowFields,
|
||||||
|
type CredentialTrayTrigger,
|
||||||
} from "@/types/credential-sidebar-preferences";
|
} from "@/types/credential-sidebar-preferences";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +42,8 @@ const CREDENTIAL_ITEM_DENSITY_TOKENS = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const DEFAULT_ROW_FIELDS = defaultCredentialRowFields();
|
||||||
|
|
||||||
export function CredentialItem({
|
export function CredentialItem({
|
||||||
cred,
|
cred,
|
||||||
usedByCount = 0,
|
usedByCount = 0,
|
||||||
@@ -53,6 +57,7 @@ export function CredentialItem({
|
|||||||
density = "comfortable",
|
density = "comfortable",
|
||||||
trayTrigger = "hover",
|
trayTrigger = "hover",
|
||||||
showTags = true,
|
showTags = true,
|
||||||
|
rowFields = DEFAULT_ROW_FIELDS,
|
||||||
arrangeMode = false,
|
arrangeMode = false,
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
onReorderDrop,
|
onReorderDrop,
|
||||||
@@ -80,6 +85,7 @@ export function CredentialItem({
|
|||||||
density?: CredentialDensity;
|
density?: CredentialDensity;
|
||||||
trayTrigger?: CredentialTrayTrigger;
|
trayTrigger?: CredentialTrayTrigger;
|
||||||
showTags?: boolean;
|
showTags?: boolean;
|
||||||
|
rowFields?: CredentialRowFields;
|
||||||
/** When true (rearranging unlocked), the row can be dragged to reorder or
|
/** When true (rearranging unlocked), the row can be dragged to reorder or
|
||||||
* onto a folder header to move. */
|
* onto a folder header to move. */
|
||||||
arrangeMode?: boolean;
|
arrangeMode?: boolean;
|
||||||
@@ -103,6 +109,8 @@ export function CredentialItem({
|
|||||||
const isKey = cred.type === "key";
|
const isKey = cred.type === "key";
|
||||||
const tokens = CREDENTIAL_ITEM_DENSITY_TOKENS[density];
|
const tokens = CREDENTIAL_ITEM_DENSITY_TOKENS[density];
|
||||||
const isCompact = density === "compact";
|
const isCompact = density === "compact";
|
||||||
|
const showUsername = rowFields.showUsername && !!cred.username;
|
||||||
|
const showUsage = rowFields.showUsageCount && usedByCount > 0;
|
||||||
const isTouchOnly =
|
const isTouchOnly =
|
||||||
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
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`}
|
className={`${tokens.nameTextSize} font-semibold truncate text-foreground leading-none tracking-tight`}
|
||||||
>
|
>
|
||||||
{cred.name}
|
{cred.name}
|
||||||
{cred.isShared && (
|
{rowFields.showSharedBadge && cred.isShared && (
|
||||||
<span
|
<span
|
||||||
className="ml-1 inline-flex items-center gap-0.5 text-[9px] uppercase text-accent-brand/80"
|
className="ml-1 inline-flex items-center gap-0.5 text-[9px] uppercase text-accent-brand/80"
|
||||||
title={t("credentials.sharedBy", {
|
title={t("credentials.sharedBy", {
|
||||||
@@ -293,21 +301,27 @@ export function CredentialItem({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
{rowFields.showTypeBadge && (
|
||||||
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"}`}
|
<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>
|
{isKey
|
||||||
|
? t("credentials.keyBadge")
|
||||||
|
: t("credentials.passwordBadge")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<ComponentSlot
|
<ComponentSlot
|
||||||
slotId="credentials.badges"
|
slotId="credentials.badges"
|
||||||
props={{ credentialId: Number(cred.id) }}
|
props={{ credentialId: Number(cred.id) }}
|
||||||
/>
|
/>
|
||||||
{cred.pin && (
|
{rowFields.showPinIcon && cred.pin && (
|
||||||
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
|
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
|
||||||
)}
|
)}
|
||||||
{!shouldUseClickTray && !actionsOnly && (
|
{!shouldUseClickTray && !actionsOnly && (
|
||||||
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0 group-hover:hidden">
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{(shouldUseClickTray || actionsOnly) && (
|
{(shouldUseClickTray || actionsOnly) && (
|
||||||
@@ -331,12 +345,12 @@ export function CredentialItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Username row */}
|
{/* 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">
|
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
|
||||||
{cred.username}
|
{showUsername && cred.username}
|
||||||
{usedByCount > 0 && (
|
{showUsage && (
|
||||||
<span
|
<span
|
||||||
className={`text-muted-foreground/40${cred.username ? " ml-2" : ""}`}
|
className={`text-muted-foreground/40${showUsername ? " ml-2" : ""}`}
|
||||||
>
|
>
|
||||||
{usedByCount}h
|
{usedByCount}h
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ReorderIndicator } from "@/sidebar/ReorderIndicator";
|
|||||||
import type { Credential } from "@/types/ui-types";
|
import type { Credential } from "@/types/ui-types";
|
||||||
import type {
|
import type {
|
||||||
CredentialDensity,
|
CredentialDensity,
|
||||||
|
CredentialRowFields,
|
||||||
CredentialTrayTrigger,
|
CredentialTrayTrigger,
|
||||||
} from "@/types/credential-sidebar-preferences";
|
} from "@/types/credential-sidebar-preferences";
|
||||||
import { CredentialItem } from "./CredentialItem/CredentialItem";
|
import { CredentialItem } from "./CredentialItem/CredentialItem";
|
||||||
@@ -41,6 +42,7 @@ export function CredentialSidebarTree({
|
|||||||
density = "comfortable",
|
density = "comfortable",
|
||||||
trayTrigger = "hover",
|
trayTrigger = "hover",
|
||||||
showTags = true,
|
showTags = true,
|
||||||
|
rowFields,
|
||||||
editingFolderName,
|
editingFolderName,
|
||||||
editingFolderValue,
|
editingFolderValue,
|
||||||
onEditingFolderNameChange,
|
onEditingFolderNameChange,
|
||||||
@@ -63,6 +65,7 @@ export function CredentialSidebarTree({
|
|||||||
density?: CredentialDensity;
|
density?: CredentialDensity;
|
||||||
trayTrigger?: CredentialTrayTrigger;
|
trayTrigger?: CredentialTrayTrigger;
|
||||||
showTags?: boolean;
|
showTags?: boolean;
|
||||||
|
rowFields?: CredentialRowFields;
|
||||||
editingFolderName: string | null;
|
editingFolderName: string | null;
|
||||||
editingFolderValue: string;
|
editingFolderValue: string;
|
||||||
onEditingFolderNameChange: (name: string | null) => void;
|
onEditingFolderNameChange: (name: string | null) => void;
|
||||||
@@ -165,6 +168,11 @@ export function CredentialSidebarTree({
|
|||||||
isOpen,
|
isOpen,
|
||||||
showTags,
|
showTags,
|
||||||
tagCount: row.item.tags?.length ?? 0,
|
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,
|
actionsOnly,
|
||||||
density,
|
density,
|
||||||
showTags,
|
showTags,
|
||||||
|
rowFields,
|
||||||
|
usedByCounts,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -223,6 +233,7 @@ export function CredentialSidebarTree({
|
|||||||
density,
|
density,
|
||||||
trayTrigger,
|
trayTrigger,
|
||||||
showTags,
|
showTags,
|
||||||
|
rowFields,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function toggleFolder(name: string) {
|
function toggleFolder(name: string) {
|
||||||
@@ -422,6 +433,7 @@ export function CredentialSidebarTree({
|
|||||||
density={density}
|
density={density}
|
||||||
trayTrigger={trayTrigger}
|
trayTrigger={trayTrigger}
|
||||||
showTags={showTags}
|
showTags={showTags}
|
||||||
|
rowFields={rowFields}
|
||||||
arrangeMode={arrangeMode}
|
arrangeMode={arrangeMode}
|
||||||
isDragging={draggedReorderKey === `cred:${item.id}`}
|
isDragging={draggedReorderKey === `cred:${item.id}`}
|
||||||
onReorderDrop={(position) =>
|
onReorderDrop={(position) =>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const HEIGHTS = {
|
|||||||
alwaysKey: 100.25,
|
alwaysKey: 100.25,
|
||||||
alwaysPassword: 74,
|
alwaysPassword: 74,
|
||||||
tags: 18.5,
|
tags: 18.5,
|
||||||
|
detail: 15,
|
||||||
},
|
},
|
||||||
compact: {
|
compact: {
|
||||||
base: 23,
|
base: 23,
|
||||||
@@ -20,6 +21,7 @@ const HEIGHTS = {
|
|||||||
alwaysKey: 74.75,
|
alwaysKey: 74.75,
|
||||||
alwaysPassword: 52,
|
alwaysPassword: 52,
|
||||||
tags: 12.5,
|
tags: 12.5,
|
||||||
|
detail: 0,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -31,6 +33,7 @@ export function getCredentialRowHeight({
|
|||||||
isOpen,
|
isOpen,
|
||||||
showTags,
|
showTags,
|
||||||
tagCount,
|
tagCount,
|
||||||
|
hasDetailRow = true,
|
||||||
}: {
|
}: {
|
||||||
density: CredentialDensity;
|
density: CredentialDensity;
|
||||||
isKey: boolean;
|
isKey: boolean;
|
||||||
@@ -39,6 +42,8 @@ export function getCredentialRowHeight({
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
showTags: boolean;
|
showTags: boolean;
|
||||||
tagCount: number;
|
tagCount: number;
|
||||||
|
/** False when the username and usage line has nothing to show. */
|
||||||
|
hasDetailRow?: boolean;
|
||||||
}): number {
|
}): number {
|
||||||
const heights = HEIGHTS[density];
|
const heights = HEIGHTS[density];
|
||||||
let height: number = heights.base;
|
let height: number = heights.base;
|
||||||
@@ -51,5 +56,9 @@ export function getCredentialRowHeight({
|
|||||||
height = isKey ? heights.actionsOnlyKey : heights.actionsOnlyPassword;
|
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 { toast } from "sonner";
|
||||||
import { getHostPassword } from "@/main-axios";
|
import { getHostPassword } from "@/main-axios";
|
||||||
import type { Host, TabType } from "@/types/ui-types";
|
import type { Host, TabType } from "@/types/ui-types";
|
||||||
import type {
|
import {
|
||||||
HostDensity,
|
defaultHostRowFields,
|
||||||
HostClickBehavior,
|
type HostDensity,
|
||||||
HostTrayTrigger,
|
type HostClickBehavior,
|
||||||
|
type HostRowFields,
|
||||||
|
type HostTrayTrigger,
|
||||||
} from "@/types/host-sidebar-preferences";
|
} from "@/types/host-sidebar-preferences";
|
||||||
import { copyToClipboard } from "@/lib/clipboard";
|
import { copyToClipboard } from "@/lib/clipboard";
|
||||||
import {
|
import {
|
||||||
@@ -143,6 +145,17 @@ function canCopyHostSudoPassword(host: Host): boolean {
|
|||||||
*/
|
*/
|
||||||
const DOUBLE_CLICK_WINDOW_MS = 250;
|
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 = {
|
const HOST_ITEM_DENSITY_TOKENS = {
|
||||||
comfortable: {
|
comfortable: {
|
||||||
rowPadding: "pl-[8.75px] pr-[7px] py-[7px]",
|
rowPadding: "pl-[8.75px] pr-[7px] py-[7px]",
|
||||||
@@ -188,6 +201,7 @@ export function HostItem({
|
|||||||
hostClickBehavior = "newTab",
|
hostClickBehavior = "newTab",
|
||||||
showResourceBars = true,
|
showResourceBars = true,
|
||||||
showStatusStripes = true,
|
showStatusStripes = true,
|
||||||
|
rowFields = DEFAULT_ROW_FIELDS,
|
||||||
rowActions = "full",
|
rowActions = "full",
|
||||||
arrangeMode = false,
|
arrangeMode = false,
|
||||||
isDragging = false,
|
isDragging = false,
|
||||||
@@ -239,6 +253,7 @@ export function HostItem({
|
|||||||
showResourceBars?: boolean;
|
showResourceBars?: boolean;
|
||||||
/** Preset-driven: hides the per-row status color stripe. */
|
/** Preset-driven: hides the per-row status color stripe. */
|
||||||
showStatusStripes?: boolean;
|
showStatusStripes?: boolean;
|
||||||
|
rowFields?: HostRowFields;
|
||||||
/** "essential" trims the row's management actions to the common few. */
|
/** "essential" trims the row's management actions to the common few. */
|
||||||
rowActions?: "essential" | "full";
|
rowActions?: "essential" | "full";
|
||||||
/** When true (rearranging unlocked), the row can be dragged: its edges
|
/** 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 densityTokens = HOST_ITEM_DENSITY_TOKENS[density];
|
||||||
const tokens = {
|
const tokens = {
|
||||||
...densityTokens,
|
...densityTokens,
|
||||||
|
showAddressRow: densityTokens.showAddressRow && rowFields.showAddress,
|
||||||
showTagsRow: densityTokens.showTagsRow && showTags,
|
showTagsRow: densityTokens.showTagsRow && showTags,
|
||||||
showResourceRow: densityTokens.showResourceRow && showResourceBars,
|
showResourceRow: densityTokens.showResourceRow && showResourceBars,
|
||||||
};
|
};
|
||||||
@@ -1123,14 +1139,15 @@ export function HostItem({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
{host.pin && (
|
{rowFields.showPinIcon && host.pin && (
|
||||||
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
|
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
|
||||||
)}
|
)}
|
||||||
{badges.map((badge) => {
|
{rowFields.showBadges &&
|
||||||
const Badge = badge.component;
|
badges.map((badge) => {
|
||||||
return <Badge key={badge.id} host={host} />;
|
const Badge = badge.component;
|
||||||
})}
|
return <Badge key={badge.id} host={host} />;
|
||||||
{host.isShared && (
|
})}
|
||||||
|
{rowFields.showSharedBadge && host.isShared && (
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -1166,12 +1183,12 @@ export function HostItem({
|
|||||||
+{host.tags!.length - 2}
|
+{host.tags!.length - 2}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isCompact && (
|
{isCompact && rowFields.showAddress && (
|
||||||
<span
|
<span
|
||||||
className="text-[11px] text-muted-foreground/70 truncate leading-none ml-auto max-w-[50%] shrink-0"
|
className="text-[11px] text-muted-foreground/70 truncate leading-none ml-auto max-w-[50%] shrink-0"
|
||||||
title={host.ip}
|
title={host.ip}
|
||||||
>
|
>
|
||||||
{host.ip}
|
{formatHostAddress(host, { ...rowFields, showUsername: false })}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isCompact && !selectionMode && (
|
{isCompact && !selectionMode && (
|
||||||
@@ -1211,10 +1228,9 @@ export function HostItem({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Address — always visible in comfortable density */}
|
|
||||||
{tokens.showAddressRow && (
|
{tokens.showAddressRow && (
|
||||||
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
|
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
|
||||||
{host.username}@{host.ip}
|
{formatHostAddress(host, rowFields)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import type { SSHHostData } from "@/types/index";
|
|||||||
import type {
|
import type {
|
||||||
HostDensity,
|
HostDensity,
|
||||||
HostClickBehavior,
|
HostClickBehavior,
|
||||||
|
HostRowFields,
|
||||||
HostTrayTrigger,
|
HostTrayTrigger,
|
||||||
} from "@/types/host-sidebar-preferences";
|
} from "@/types/host-sidebar-preferences";
|
||||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||||
@@ -93,6 +94,7 @@ export function SidebarTree({
|
|||||||
openOnDoubleClick = false,
|
openOnDoubleClick = false,
|
||||||
showFolderPaths = true,
|
showFolderPaths = true,
|
||||||
hostClickBehavior = "newTab",
|
hostClickBehavior = "newTab",
|
||||||
|
rowFields,
|
||||||
}: {
|
}: {
|
||||||
children: (Host | HostFolder)[];
|
children: (Host | HostFolder)[];
|
||||||
onOpenTab: (
|
onOpenTab: (
|
||||||
@@ -120,6 +122,7 @@ export function SidebarTree({
|
|||||||
/** When false, nested folders hide the parent-path breadcrumb before their name. */
|
/** When false, nested folders hide the parent-path breadcrumb before their name. */
|
||||||
showFolderPaths?: boolean;
|
showFolderPaths?: boolean;
|
||||||
hostClickBehavior?: HostClickBehavior;
|
hostClickBehavior?: HostClickBehavior;
|
||||||
|
rowFields?: HostRowFields;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hostSettingPlugins = usePluginHostSections();
|
const hostSettingPlugins = usePluginHostSections();
|
||||||
@@ -753,6 +756,8 @@ export function SidebarTree({
|
|||||||
// Tag pills are a separate flex row. Comfortable density adds the row plus
|
// 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.
|
// its gap; compact density pulls it upward by 2px but still needs a slot.
|
||||||
const TAG_ROW_EXTRA = isCompactDensity ? 12.5 : 18.5;
|
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.
|
// showResourceBars:false simply drops RESOURCE_ROW_EXTRA from every row.
|
||||||
|
|
||||||
const rowHeight = useCallback(
|
const rowHeight = useCallback(
|
||||||
@@ -761,7 +766,7 @@ export function SidebarTree({
|
|||||||
if (!row) return FOLDER_ROW_HEIGHT;
|
if (!row) return FOLDER_ROW_HEIGHT;
|
||||||
if (isFolder(row.item)) return FOLDER_ROW_HEIGHT;
|
if (isFolder(row.item)) return FOLDER_ROW_HEIGHT;
|
||||||
if (isCompactDensity) return 29;
|
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.
|
// The resource bars only render for an online host that reported CPU/RAM.
|
||||||
// Reserving their height unconditionally left a gap under every offline
|
// Reserving their height unconditionally left a gap under every offline
|
||||||
// row. measureElement corrects any drift from live status this estimate
|
// row. measureElement corrects any drift from live status this estimate
|
||||||
@@ -773,6 +778,7 @@ export function SidebarTree({
|
|||||||
)
|
)
|
||||||
? RESOURCE_ROW_EXTRA
|
? RESOURCE_ROW_EXTRA
|
||||||
: 0;
|
: 0;
|
||||||
|
const tagExtra = tagRowExtra - ADDRESS_ROW_SAVED;
|
||||||
if (alwaysShowActions)
|
if (alwaysShowActions)
|
||||||
return ALWAYS_ROW_HEIGHT + tagExtra + resourceExtra;
|
return ALWAYS_ROW_HEIGHT + tagExtra + resourceExtra;
|
||||||
const toggledOpen =
|
const toggledOpen =
|
||||||
@@ -818,6 +824,7 @@ export function SidebarTree({
|
|||||||
ACTIONS_ONLY_ROW_HEIGHT,
|
ACTIONS_ONLY_ROW_HEIGHT,
|
||||||
ACTIONS_ONLY_OPEN_ROW_HEIGHT,
|
ACTIONS_ONLY_OPEN_ROW_HEIGHT,
|
||||||
TAG_ROW_EXTRA,
|
TAG_ROW_EXTRA,
|
||||||
|
ADDRESS_ROW_SAVED,
|
||||||
showResourceBars,
|
showResourceBars,
|
||||||
isCompactDensity,
|
isCompactDensity,
|
||||||
],
|
],
|
||||||
@@ -887,7 +894,14 @@ export function SidebarTree({
|
|||||||
parentRef.current
|
parentRef.current
|
||||||
?.querySelectorAll<HTMLElement>("[data-index]")
|
?.querySelectorAll<HTMLElement>("[data-index]")
|
||||||
.forEach((element) => virtualizer.measureElement(element));
|
.forEach((element) => virtualizer.measureElement(element));
|
||||||
}, [virtualizer, density, trayTrigger, showTags, showResourceBars]);
|
}, [
|
||||||
|
virtualizer,
|
||||||
|
density,
|
||||||
|
trayTrigger,
|
||||||
|
showTags,
|
||||||
|
showResourceBars,
|
||||||
|
ADDRESS_ROW_SAVED,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (speedSearch.open) speedSearch.inputRef.current?.focus();
|
if (speedSearch.open) speedSearch.inputRef.current?.focus();
|
||||||
@@ -1168,6 +1182,7 @@ export function SidebarTree({
|
|||||||
hostClickBehavior={hostClickBehavior}
|
hostClickBehavior={hostClickBehavior}
|
||||||
showResourceBars={showResourceBars}
|
showResourceBars={showResourceBars}
|
||||||
showStatusStripes={showStatusStripes}
|
showStatusStripes={showStatusStripes}
|
||||||
|
rowFields={rowFields}
|
||||||
rowActions={rowActions}
|
rowActions={rowActions}
|
||||||
arrangeMode={arrangeMode}
|
arrangeMode={arrangeMode}
|
||||||
isDragging={draggedReorderKey === `host:${item.id}`}
|
isDragging={draggedReorderKey === `host:${item.id}`}
|
||||||
|
|||||||
@@ -56,3 +56,31 @@ describe("getCredentialRowHeight", () => {
|
|||||||
).toBe(base);
|
).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(),
|
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 = {
|
const baseHost: Host = {
|
||||||
id: "1",
|
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",
|
density: "comfortable",
|
||||||
showTags: true,
|
showTags: true,
|
||||||
trayTrigger: "always",
|
trayTrigger: "always",
|
||||||
|
showUsername: true,
|
||||||
|
showUsageCount: true,
|
||||||
|
showTypeBadge: true,
|
||||||
|
showPinIcon: true,
|
||||||
|
showSharedBadge: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -51,6 +56,11 @@ describe("sanitizeCredentialSidebarPreferences", () => {
|
|||||||
density: "compact",
|
density: "compact",
|
||||||
showTags: false,
|
showTags: false,
|
||||||
trayTrigger: "click",
|
trayTrigger: "click",
|
||||||
|
showUsername: false,
|
||||||
|
showUsageCount: false,
|
||||||
|
showTypeBadge: false,
|
||||||
|
showPinIcon: true,
|
||||||
|
showSharedBadge: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(sanitizeCredentialSidebarPreferences(valid)).toEqual(valid);
|
expect(sanitizeCredentialSidebarPreferences(valid)).toEqual(valid);
|
||||||
@@ -90,3 +100,14 @@ describe("sanitizeCredentialSidebarPreferences", () => {
|
|||||||
expect(result.version).toBe(CREDENTIAL_SIDEBAR_PREFS_VERSION);
|
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,
|
showFolderPaths: true,
|
||||||
hostClickBehavior: "newTab",
|
hostClickBehavior: "newTab",
|
||||||
barActions: {},
|
barActions: {},
|
||||||
|
showAddress: true,
|
||||||
|
showUsername: true,
|
||||||
|
showPort: false,
|
||||||
|
showPinIcon: true,
|
||||||
|
showSharedBadge: true,
|
||||||
|
showBadges: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -69,6 +75,12 @@ describe("sanitizeHostSidebarPreferences", () => {
|
|||||||
showFolderPaths: false,
|
showFolderPaths: false,
|
||||||
hostClickBehavior: "focusExistingDoubleClickNew",
|
hostClickBehavior: "focusExistingDoubleClickNew",
|
||||||
barActions: { maintenance: true },
|
barActions: { maintenance: true },
|
||||||
|
showAddress: false,
|
||||||
|
showUsername: false,
|
||||||
|
showPort: true,
|
||||||
|
showPinIcon: false,
|
||||||
|
showSharedBadge: false,
|
||||||
|
showBadges: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
|
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
|
||||||
@@ -174,3 +186,23 @@ describe("barActions", () => {
|
|||||||
).toEqual({});
|
).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