Files
Termix/src/ui/sidebar/credential-tree/CredentialItem/CredentialItem.tsx
T

410 lines
14 KiB
TypeScript

import { useTranslation } from "react-i18next";
import {
ChevronRight,
Copy,
CopyPlus,
GripVertical,
Pencil,
Pin,
Trash2,
Share2,
Upload,
} from "lucide-react";
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,
} from "@/types/credential-sidebar-preferences";
export function credentialMatchesQuery(cred: Credential, query: string) {
return (
cred.name.toLowerCase().includes(query) ||
cred.username.toLowerCase().includes(query) ||
cred.tags?.some((t) => t.toLowerCase().includes(query))
);
}
/**
* Per-density layout knobs, mirroring HOST_ITEM_DENSITY_TOKENS in
* HostItem.tsx -- one implementation with a token lookup instead of two
* parallel JSX trees, so a future tweak only has to be made once.
*/
const CREDENTIAL_ITEM_DENSITY_TOKENS = {
comfortable: {
rowPadding: "pl-2.5 pr-2 py-2",
nameTextSize: "text-[13px]",
showUsernameRow: true,
showTagsRow: true,
},
compact: {
rowPadding: "pl-2 pr-1.5 py-[5px]",
nameTextSize: "text-[12px]",
showUsernameRow: false,
showTagsRow: false,
},
} as const;
export function CredentialItem({
cred,
usedByCount = 0,
query = "",
stripeIndex = 0,
isMenuOpen = false,
onMenuOpenChange,
isTrayOpen = false,
onTrayOpenChange,
onDragStart,
onDragEnd,
depth = 0,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
arrangeMode = false,
isDragging = false,
onReorderDrop,
isReorderHovered = false,
reorderHoverEdge = null,
onReorderHoverChange,
onDeploy,
onEdit,
onClone,
onDelete,
onShare,
}: {
cred: Credential;
usedByCount?: number;
query?: string;
stripeIndex?: number;
isMenuOpen?: boolean;
onMenuOpenChange?: (open: boolean) => void;
isTrayOpen?: boolean;
onTrayOpenChange?: (open: boolean) => void;
onDragStart?: () => void;
onDragEnd?: () => void;
/** Nesting level when rendered in a flattened virtual list. */
depth?: number;
density?: CredentialDensity;
trayTrigger?: CredentialTrayTrigger;
showTags?: boolean;
/** When true (rearranging unlocked), the row can be dragged to reorder or
* onto a folder header to move. */
arrangeMode?: boolean;
/** True while this row is the one being dragged. */
isDragging?: boolean;
onReorderDrop?: (position: "before" | "after") => void;
/** Whether THIS row is the current reorder drop target -- lifted to the
* parent tree so only one row can ever show the drop-indicator bar at a
* time. See HostItem's identical prop for the full rationale. */
isReorderHovered?: boolean;
reorderHoverEdge?: "before" | "after" | null;
onReorderHoverChange?: (edge: "before" | "after" | null) => void;
onDeploy: () => void;
onEdit: () => void;
onClone: () => void;
onDelete: () => void;
onShare?: () => void;
}) {
const { t } = useTranslation();
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
const isKey = cred.type === "key";
const tokens = CREDENTIAL_ITEM_DENSITY_TOKENS[density];
const isCompact = density === "compact";
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
const alwaysShowTray = trayTrigger === "always";
const actionsOnly = trayTrigger === "actionsOnly";
const shouldUseClickTray =
!alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly);
const canDrag = arrangeMode && !isTouchOnly;
const depthStyle =
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
const trayButtonClass =
"flex items-center justify-center size-6.5 text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
const connectionButtons = isKey ? (
<>
<button
title={t("credentials.deploySSHKeyTitle")}
onClick={(e) => {
e.stopPropagation();
onDeploy();
}}
className={trayButtonClass}
>
<Upload className="size-3.5" />
</button>
<button
title={t("credentials.copyDeployCommand")}
onClick={async (e) => {
e.stopPropagation();
const pubKey = cred.publicKey;
if (!pubKey) {
toast.error(t("credentials.noPublicKeyAvailable"));
return;
}
const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "${pubKey}" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
const ok = await copyToClipboard(cmd);
if (ok) toast.success(t("credentials.deployCommandCopied"));
else toast.error(t("common.copyFailed"));
}}
className={trayButtonClass}
>
<Copy className="size-3.5" />
</button>
</>
) : null;
// A recipient sees only what their grant allows: "manage" may edit and
// re-share, "use" may only use. Cloning and deleting stay with the owner.
const canEdit = !cred.isShared || cred.permissionLevel === "manage";
const canShare =
!!onShare && (!cred.isShared || cred.permissionLevel === "manage");
const managementButtons = (
<>
{canEdit && (
<button
title={t("credentials.editCredentialAction")}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className={trayButtonClass}
>
<Pencil className="size-3.5" />
</button>
)}
{canShare && (
<button
title={t("credentials.shareCredentialAction")}
onClick={(e) => {
e.stopPropagation();
onShare?.();
}}
className={trayButtonClass}
>
<Share2 className="size-3.5" />
</button>
)}
{!cred.isShared && (
<>
<button
title={t("credentials.cloneCredentialAction")}
onClick={(e) => {
e.stopPropagation();
onClone();
}}
className={trayButtonClass}
>
<CopyPlus className="size-3.5" />
</button>
<button
title={t("credentials.deleteCredentialAction")}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className={`${trayButtonClass} hover:text-destructive`}
>
<Trash2 className="size-3.5" />
</button>
</>
)}
</>
);
const trayOpenState = isTrayOpen || isMenuOpen;
// A collapsed tray clipped to max-h-0 is still a flex item, so it earns the
// text column's gap-1 and leaves a gap under every closed row. The negative
// margin cancels it while keeping the element in flow to animate open.
const trayCollapsed = `max-h-0 opacity-0 ${isCompact ? "" : "-mt-1"}`;
const trayVisibilityClass =
alwaysShowTray || actionsOnly
? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[60px] opacity-100" : trayCollapsed}`
: shouldUseClickTray
? `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayOpenState ? "max-h-[60px] opacity-100" : trayCollapsed}`
: `overflow-hidden transition-[max-height,opacity,margin] duration-150 ease-out ${trayCollapsed} group-hover:max-h-[60px] group-hover:opacity-100 ${isCompact ? "" : "group-hover:mt-0"} ${isMenuOpen ? `!max-h-[60px] !opacity-100 ${isCompact ? "" : "!mt-0"}` : ""}`;
return (
<div
draggable={canDrag}
onDragStart={(e) => {
if (!canDrag) return;
e.dataTransfer.effectAllowed = "move";
// Drag the name row, not the whole row with its open tray, so the
// ghost stays a compact label instead of a tall overlapping block.
const label =
e.currentTarget.querySelector<HTMLElement>("[data-drag-label]");
if (label) {
const rect = label.getBoundingClientRect();
e.dataTransfer.setDragImage(
label,
Math.min(e.clientX - rect.left, rect.width),
rect.height / 2,
);
}
onDragStart?.();
}}
onDragEnd={() => {
onReorderHoverChange?.(null);
onDragEnd?.();
}}
onDragOver={(e) => {
if (!arrangeMode || !onReorderDrop) return;
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
onReorderHoverChange?.(
e.clientY - rect.top < rect.height / 2 ? "before" : "after",
);
}}
onDrop={(e) => {
if (!arrangeMode || !onReorderDrop || !reorderEdge) return;
e.preventDefault();
e.stopPropagation();
onReorderDrop(reorderEdge);
onReorderHoverChange?.(null);
}}
style={depthStyle}
className={`group relative flex items-stretch select-none transition-colors hover:bg-muted/50 ${
canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-default"
} ${stripeIndex % 2 === 1 ? "bg-muted/15" : ""} ${isMenuOpen ? "bg-muted/50" : ""} ${isDragging ? "opacity-40" : ""}`}
>
{canDrag && (
<div className="flex items-center justify-center w-4 shrink-0 text-muted-foreground/35 group-hover:text-muted-foreground/70 transition-colors">
<GripVertical className="size-3" />
</div>
)}
<div
className={`flex flex-col flex-1 min-w-0 ${tokens.rowPadding} ${isCompact ? "" : "gap-1"}`}
>
{/* Name row */}
<div data-drag-label className="flex items-center gap-1.5 min-w-0">
<span
className={`${tokens.nameTextSize} font-semibold truncate text-foreground leading-none tracking-tight`}
>
{cred.name}
{cred.isShared && (
<span
className="ml-1 inline-flex items-center gap-0.5 text-[9px] uppercase text-accent-brand/80"
title={t("credentials.sharedBy", {
owner: cred.ownerUsername ?? "",
})}
>
<Share2 className="size-2.5" />
{t("credentials.sharedBadge")}
</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>
<ComponentSlot
slotId="credentials.badges"
props={{ credentialId: Number(cred.id) }}
/>
{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` : ""}
</span>
)}
{(shouldUseClickTray || actionsOnly) && (
<button
title={
isTrayOpen
? t("credentials.collapseActions")
: t("credentials.expandActions")
}
onClick={(e) => {
e.stopPropagation();
onTrayOpenChange?.(!isTrayOpen);
}}
className="ml-auto flex items-center justify-center size-5 rounded text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted-foreground/10 transition-colors shrink-0"
>
<ChevronRight
className={`size-3 transition-transform duration-150 ${isTrayOpen ? "rotate-90" : ""}`}
/>
</button>
)}
</div>
{/* Username row */}
{tokens.showUsernameRow && (cred.username || usedByCount > 0) && (
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
{cred.username}
{usedByCount > 0 && (
<span className="text-muted-foreground/40">
{cred.username ? " · " : ""}
{usedByCount}h
</span>
)}
</span>
)}
{/* Tag pills */}
{showTags && cred.tags && cred.tags.length > 0 && (
<div
className={`flex items-center gap-1 min-w-0 overflow-hidden ${tokens.showTagsRow ? "" : "-mt-0.5"}`}
>
{cred.tags.slice(0, isCompact ? 2 : 4).map((tag) => (
<span
key={tag}
className="text-[9px] px-1.5 py-[1px] bg-muted/60 text-muted-foreground/70 lowercase shrink-0 leading-[1.4]"
>
{tag}
</span>
))}
{cred.tags.length > (isCompact ? 2 : 4) && (
<span className="text-[9px] text-muted-foreground/40 shrink-0">
+{cred.tags.length - (isCompact ? 2 : 4)}
</span>
)}
</div>
)}
{/* Connection buttons: permanent in "always"/"actionsOnly" modes, or shown once the chevron opens the tray in click mode */}
{connectionButtons &&
(alwaysShowTray ||
actionsOnly ||
(shouldUseClickTray && isTrayOpen)) && (
<div className="flex items-center flex-wrap gap-1">
{connectionButtons}
</div>
)}
{/* Action tray */}
<div className={trayVisibilityClass}>
<div
className={`flex flex-col gap-0.5 ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "" : "pt-1.5"}`}
>
{connectionButtons &&
!alwaysShowTray &&
!actionsOnly &&
!shouldUseClickTray && (
<div className="flex items-center flex-wrap gap-0.5">
{connectionButtons}
</div>
)}
<div
className={`flex items-center gap-0.5 ${connectionButtons ? "border-t border-border/30" : ""} ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "pt-1.5" : "pt-1 mt-0.5"}`}
>
{managementButtons}
</div>
</div>
</div>
</div>
</div>
);
}