Files
NetMesh/components/vault/VaultHostListSection.tsx

1316 lines
71 KiB
TypeScript
Raw Normal View History

/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
import { HostNotesIndicator } from "../host/HostNotesIndicator";
import {
VirtualizedGroupedHostCollection,
VirtualizedHostCollection,
} from "./VirtualizedHostCollection";
import { VaultEntityIcon, vaultPrimaryIconClass } from "./VaultEntityIcon";
import {
clearVaultDropIndicator,
getVaultDropIntent,
getVaultDropPosition,
hasVaultDragType,
handleVaultHostDropToGroup,
handleVaultRootDrop,
markVaultDropIndicator,
useVaultGridLayoutAnimation,
} from "./vaultReorderDrag";
import {
hostCardFocusClassName,
isHostClickFocusSelected,
resolveGroupActivateAction,
resolveHostActivateAction,
shouldClearHostFocusOnBackgroundClick,
type HostClickBehavior,
} from "../../domain/hostClickBehavior";
import type { GroupNode, Host } from "../../domain/models";
import { isPluginHostProtocol } from "../../domain/pluginConnection";
import { OpenDualPaneSftpMenuItem } from "../host/HostTreeContextMenus";
type VaultHostListSectionContext = Record<string, any>;
export const getVaultTreeAutoExpandKey = (
search: string | undefined,
selectedTags: string[] | undefined,
): string | undefined => {
const normalizedSearch = search?.trim() ?? "";
const normalizedTags = [...(selectedTags ?? [])].sort();
return normalizedSearch || normalizedTags.length > 0
? JSON.stringify([normalizedSearch, normalizedTags])
: undefined;
};
const isRelatedTargetInside = (
currentTarget: HTMLElement,
relatedTarget: EventTarget | null,
) => {
return (
typeof Node !== "undefined" &&
relatedTarget instanceof Node &&
currentTarget.contains(relatedTarget)
);
};
const EMPTY_GROUP_PATH_SET = new Set<string>();
export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext }) {
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
const hostClickBehavior: HostClickBehavior = hostClickBehaviorProp === 'select' ? 'select' : 'connect';
const multiSelectedGroupPaths: Set<string> = selectedGroupPaths ?? EMPTY_GROUP_PATH_SET;
const [draggingHostId, setDraggingHostId] = React.useState<string | null>(null);
const draggingHostIdRef = React.useRef<string | null>(null);
const lastPreviewReorderRef = React.useRef<string | null>(null);
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(hostListScrollRef);
const [focusedHostId, setFocusedHostId] = React.useState<string | null>(null);
const [focusedGroupPath, setFocusedGroupPath] = React.useState<string | null>(null);
const hostListFilterFocusKey = React.useMemo(
() => getVaultTreeAutoExpandKey(search, selectedTags) ?? "",
[search, selectedTags],
);
const [prevHostListFilterFocusKey, setPrevHostListFilterFocusKey] = React.useState(
hostListFilterFocusKey,
);
// Clear keyboard/selection focus as soon as search or tags change so the
// virtual list cannot steal DOM focus back from the search input on the
// same commit (useEffect would run too late).
if (hostListFilterFocusKey !== prevHostListFilterFocusKey) {
setPrevHostListFilterFocusKey(hostListFilterFocusKey);
setFocusedHostId(null);
setFocusedGroupPath(null);
}
const hostCollectionLayoutKey = [
displayedGroups.length,
hasHostsSidePanel ? "panel" : "full",
lastPinnedId ?? "",
pinnedHosts.length,
recentHosts.length,
selectedGroupPath ?? "root",
showRecentHosts ? "recent" : "hidden",
sortMode,
viewMode,
].join("|");
// Stable wrappers so HostTreeView memo can skip parent vault re-renders.
const handleTreeDeleteHost = React.useCallback(
(host: Host) => {
onDeleteHost(host.id);
},
[onDeleteHost],
);
const handleTreeGroupDropClasses = React.useCallback(
(path: string) => getDropTargetClasses({ kind: "group", path }),
[getDropTargetClasses],
);
const treeAutoExpandGroupsKey = React.useMemo(
() => getVaultTreeAutoExpandKey(search, selectedTags),
[search, selectedTags],
);
React.useEffect(() => {
if (isMultiSelectMode) {
setFocusedHostId(null);
setFocusedGroupPath(null);
}
}, [isMultiSelectMode]);
React.useEffect(() => {
setFocusedHostId(null);
setFocusedGroupPath(null);
}, [selectedGroupPath, viewMode, hostClickBehavior]);
const activateHost = React.useCallback((host: Host) => {
const action = resolveHostActivateAction({
behavior: hostClickBehavior,
isMultiSelectMode,
focusedHostId,
hostId: host.id,
});
if (action === "toggle-multi") {
toggleHostSelection(host.id);
return;
}
if (action === "select") {
setFocusedHostId(host.id);
setFocusedGroupPath(null);
return;
}
handleHostConnect(host);
}, [focusedHostId, handleHostConnect, hostClickBehavior, isMultiSelectMode, toggleHostSelection]);
const focusHost = React.useCallback((host: Host) => {
setFocusedHostId(host.id);
setFocusedGroupPath(null);
}, []);
const mainKeyboardHosts = groupedDisplayHosts
? groupedDisplayHosts.flatMap((group) => group.hosts)
: visibleDisplayedHosts;
const focusHostAndElement = (host: Host) => {
focusHost(host);
queueMicrotask(() => {
const scrollElement = hostListScrollRef.current as HTMLElement | null;
const element = [...(scrollElement?.querySelectorAll<HTMLElement>("[data-host-id]") ?? [])]
.find((candidate) => candidate.dataset.hostId === host.id);
element?.focus();
});
};
const focusGroupAndElement = (group: GroupNode) => {
setFocusedGroupPath(group.path);
setFocusedHostId(null);
queueMicrotask(() => {
const scrollElement = hostListScrollRef.current as HTMLElement | null;
const element = [...(scrollElement?.querySelectorAll<HTMLElement>("[data-group-path]") ?? [])]
.find((candidate) => candidate.dataset.groupPath === group.path);
element?.focus();
});
};
const keyboardHostSections = [
{
key: "pinned",
hasItems: pinnedHosts.length > 0,
focusEdge: (direction: "previous" | "next") => focusHostAndElement(
direction === "next" ? pinnedHosts[0] : pinnedHosts.at(-1)!,
),
},
{
key: "recent",
hasItems: showRecentHosts && recentHosts.length > 0,
focusEdge: (direction: "previous" | "next") => focusHostAndElement(
direction === "next" ? recentHosts[0] : recentHosts.at(-1)!,
),
},
{
key: "groups",
hasItems: displayedGroups.length > 0,
focusEdge: (direction: "previous" | "next") => focusGroupAndElement(
direction === "next" ? displayedGroups[0] : displayedGroups.at(-1)!,
),
},
{
key: "main",
hasItems: mainKeyboardHosts.length > 0,
focusEdge: (direction: "previous" | "next") => focusHostAndElement(
direction === "next" ? mainKeyboardHosts[0] : mainKeyboardHosts.at(-1)!,
),
},
];
const navigateHostSection = (
sectionKey: "pinned" | "recent" | "groups" | "main",
direction: "previous" | "next",
) => {
const currentSectionIndex = keyboardHostSections.findIndex((section) => section.key === sectionKey);
const step = direction === "next" ? 1 : -1;
let sectionIndex = currentSectionIndex + step;
while (sectionIndex >= 0 && sectionIndex < keyboardHostSections.length) {
const section = keyboardHostSections[sectionIndex];
if (section.hasItems) {
section.focusEdge(direction);
return;
}
sectionIndex += step;
}
};
const initialKeyboardHostId = pinnedHosts[0]?.id
?? (showRecentHosts ? recentHosts[0]?.id : undefined)
?? groupedDisplayHosts?.[0]?.hosts[0]?.id
?? visibleDisplayedHosts[0]?.id;
const focusedHostIsVisible = Boolean(
focusedHostId
&& [
...pinnedHosts,
...(showRecentHosts ? recentHosts : []),
...mainKeyboardHosts,
].some((host) => host.id === focusedHostId),
);
const getHostTabIndex = (hostId: string) => (
(focusedHostIsVisible ? focusedHostId === hostId : initialKeyboardHostId === hostId) ? 0 : -1
);
const initialKeyboardGroupPath = displayedGroups[0]?.path;
const focusedGroupIsVisible = Boolean(
focusedGroupPath && displayedGroups.some((group) => group.path === focusedGroupPath),
);
const getGroupTabIndex = (groupPath: string) => (
(focusedGroupIsVisible ? focusedGroupPath === groupPath : initialKeyboardGroupPath === groupPath)
? 0
: -1
);
const isHostFocusSelected = (hostId: string) => (
isHostClickFocusSelected({
behavior: hostClickBehavior,
isMultiSelectMode,
focusedHostId,
hostId,
})
);
const isGroupFocusSelected = (groupPath: string) => (
hostClickBehavior === "select" && !isMultiSelectMode && focusedGroupPath === groupPath
);
const activateGroup = React.useCallback((groupPath: string) => {
if (isMultiSelectMode) {
toggleGroupSelection(groupPath);
return;
}
const action = resolveGroupActivateAction({
behavior: hostClickBehavior,
focusedGroupPath,
groupPath,
});
if (action === "select") {
setFocusedGroupPath(groupPath);
setFocusedHostId(null);
return;
}
setSelectedGroupPath(groupPath);
}, [focusedGroupPath, hostClickBehavior, isMultiSelectMode, setSelectedGroupPath, toggleGroupSelection]);
const handleHostListClick = React.useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target;
const clickedWithinHostList = target instanceof Node
&& event.currentTarget.contains(target);
const clickedHostOrGroup = target instanceof Element
&& !!target.closest("[data-host-id], [data-group-path]");
if (!shouldClearHostFocusOnBackgroundClick({
behavior: hostClickBehavior,
isMultiSelectMode,
clickedWithinHostList,
clickedHostOrGroup,
})) return;
setFocusedHostId(null);
setFocusedGroupPath(null);
}, [hostClickBehavior, isMultiSelectMode]);
const resetHostDragState = React.useCallback(() => {
draggingHostIdRef.current = null;
setDraggingHostId(null);
lastPreviewReorderRef.current = null;
setDragOverDropTarget(null);
}, [setDragOverDropTarget]);
const handleHostDragStart = React.useCallback((e: React.DragEvent, hostId: string) => {
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", hostId);
draggingHostIdRef.current = hostId;
setDraggingHostId(hostId);
lastPreviewReorderRef.current = null;
// Grid preview reorder can move the card into another virtual row, and rows
// are separate React parents, so the source node gets unmounted mid-drag.
// A detached node no longer bubbles dragend up to the container handler, so
// bind the cleanup natively on the node itself - it still receives dragend.
const sourceNode = e.currentTarget as HTMLElement;
const handleNativeDragEnd = () => {
sourceNode.removeEventListener("dragend", handleNativeDragEnd);
clearVaultDropIndicator();
resetHostDragState();
};
sourceNode.addEventListener("dragend", handleNativeDragEnd);
}, [resetHostDragState]);
const renderHostEditButton = (host: any, compact = false) => (
<Button
variant="ghost"
size="icon"
aria-label={`Edit ${host.label || "host"}`}
data-vault-host-edit-button={host.id}
className={cn(
"opacity-0 group-hover:opacity-100 transition-opacity shrink-0",
compact ? "h-6 w-6" : "h-8 w-8",
)}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleEditHost(host);
}}
>
<Edit2 size={compact ? 13 : 14} />
</Button>
);
const renderGroupEditButton = (groupPath: string, compact = false) => (
<Button
variant="ghost"
size="icon"
aria-label={`Edit ${groupPath || "group"}`}
data-vault-group-edit-button={groupPath}
className={cn(
"opacity-0 group-hover:opacity-100 transition-opacity shrink-0",
compact ? "h-6 w-6" : "h-8 w-8",
)}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
handleEditGroupConfig(groupPath);
}}
>
<Edit2 size={compact ? 13 : 14} />
</Button>
);
return <div
ref={hostListScrollRef}
className={cn(
"flex-1 overflow-auto px-4 pb-4 space-y-3",
viewMode === "tree" ? "pt-1.5" : "pt-0",
!isHostsSectionActive && "hidden",
)}
data-section="vault-host-list"
onClick={handleHostListClick}
onDragOverCapture={(e) => {
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
if (target) e.preventDefault();
if (!(target instanceof HTMLElement)) return;
const draggedGroupPath = e.dataTransfer.getData("group-path");
const isDraggingGroup = hasVaultDragType(e.dataTransfer, "group-path");
const targetGroupPath = target.getAttribute("data-group-path");
if (isDraggingGroup && targetGroupPath && draggedGroupPath !== targetGroupPath) {
const intent = getVaultDropIntent(target, e.clientX, e.clientY, viewMode === "grid");
if (intent === "inside") {
clearVaultDropIndicator();
return;
}
markVaultDropIndicator(target, intent, viewMode === "grid" ? "x" : "y");
return;
}
if (viewMode !== "grid") {
markVaultDropIndicator(target, getVaultDropPosition(target, e.clientX, e.clientY));
return;
}
const draggedHostId = draggingHostIdRef.current || e.dataTransfer.getData("host-id");
const targetHostId = target.getAttribute("data-host-id");
if (!draggedHostId || !targetHostId || draggedHostId === targetHostId) return;
const position = getVaultDropPosition(target, e.clientX, e.clientY, true);
const previewKey = `${draggedHostId}:${targetHostId}:${position}`;
if (lastPreviewReorderRef.current === previewKey) return;
prepareGridLayoutAnimation();
lastPreviewReorderRef.current = previewKey;
reorderHost(draggedHostId, targetHostId, position);
}}
onDragOver={(e) => {
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
if (!(target instanceof HTMLElement) || viewMode === "grid") return;
const draggedGroupPath = e.dataTransfer.getData("group-path");
const isDraggingGroup = hasVaultDragType(e.dataTransfer, "group-path");
const targetGroupPath = target.getAttribute("data-group-path");
if (isDraggingGroup && targetGroupPath && draggedGroupPath !== targetGroupPath) {
const intent = getVaultDropIntent(target, e.clientX, e.clientY, false);
if (intent === "inside") {
clearVaultDropIndicator();
return;
}
markVaultDropIndicator(target, intent);
return;
}
markVaultDropIndicator(target, getVaultDropPosition(target, e.clientX, e.clientY));
}}
onDropCapture={(e) => {
clearVaultDropIndicator();
const draggedHostId = e.dataTransfer.getData("host-id");
const draggedGroupPath = e.dataTransfer.getData("group-path");
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
// Always clear the dimmed drag styling: in grid view the live preview
// reorder can leave the dragged card itself under the cursor, which
// used to fall through every branch below without resetting.
if (draggedHostId) resetHostDragState();
if (!(target instanceof HTMLElement)) return;
const targetHostId = target.getAttribute("data-host-id");
const targetGroupPath = target.getAttribute("data-group-path");
if (draggedHostId && targetHostId && draggedHostId !== targetHostId) {
e.preventDefault();
e.stopPropagation();
const position = getVaultDropPosition(target, e.clientX, e.clientY, viewMode === "grid");
const previewKey = `${draggedHostId}:${targetHostId}:${position}`;
if (viewMode !== "grid" || lastPreviewReorderRef.current !== previewKey) {
prepareGridLayoutAnimation();
reorderHost(draggedHostId, targetHostId, position);
}
resetHostDragState();
return;
}
if (draggedGroupPath && targetGroupPath && draggedGroupPath !== targetGroupPath) {
const intent = getVaultDropIntent(target, e.clientX, e.clientY, viewMode === "grid");
if (intent === "inside") return;
prepareGridLayoutAnimation();
const handled = reorderGroup(draggedGroupPath, targetGroupPath, intent);
if (handled) {
e.preventDefault();
e.stopPropagation();
}
}
}}
onDragEndCapture={() => {
clearVaultDropIndicator();
resetHostDragState();
}}
>
{viewMode !== "tree" && (
<section className="space-y-2 pt-2">
<div className="flex items-center gap-2 text-sm font-semibold">
<button
className={cn(
"text-primary hover:underline transition-colors duration-150 rounded px-1 -mx-1",
getDropTargetClasses({ kind: "root" }),
)}
onClick={() => setSelectedGroupPath(null)}
onDragOver={(e) => {
e.preventDefault();
setDragOverDropTarget({ kind: "root" });
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (isRelatedTargetInside(e.currentTarget, nextTarget)) {
return;
}
setDragOverDropTarget((current) =>
current?.kind === "root" ? null : current,
);
}}
onDrop={(e) => {
handleVaultRootDrop({
dataTransfer: e.dataTransfer,
preventDefault: () => e.preventDefault(),
setDragOverDropTarget,
moveGroup,
moveHostToGroup,
resetHostDragState,
});
}}
>
{t("vault.hosts.allHosts")}
</button>
{selectedGroupPath &&
selectedGroupPath
.split("/")
.filter(Boolean)
.map((part, idx, arr) => {
const crumbPath = arr.slice(0, idx + 1).join("/");
const isLast = idx === arr.length - 1;
return (
<span
key={crumbPath}
className="flex items-center gap-2"
>
<span className="text-muted-foreground"></span>
<button
className={cn(
isLast
? "text-foreground font-semibold"
: "text-primary hover:underline",
)}
onClick={() =>
setSelectedGroupPath(crumbPath)
}
>
{part}
</button>
</span>
);
})}
</div>
</section>
)}
{/* Pinned hosts section - only at root level */}
{viewMode !== "tree" && !selectedGroupPath && pinnedHosts.length > 0 && (
<section className="space-y-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground inline-flex items-center gap-1.5">
<Pin size={14} className="shrink-0 -translate-y-[1px]" />
{t("vault.hosts.pinned")}
</h3>
<VirtualizedHostCollection<Host>
items={pinnedHosts}
itemKey={(host) => host.id}
scrollRef={hostListScrollRef}
viewMode={viewMode}
layoutKey={`pinned:${hostCollectionLayoutKey}`}
ariaLabel={t("vault.hosts.pinned")}
onActiveItemChange={focusHost}
activeItemKey={focusedHostId}
onBoundaryNavigation={(direction) => navigateHostSection("pinned", direction)}
renderItem={(host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
text: (safeHost.os || "L")[0].toUpperCase(),
label: effectiveDistro || safeHost.os || "Linux",
};
return (
<ContextMenu key={host.id}>
<ContextMenuTrigger>
<div
className={cn(
"vault-drop-indicator-row group cursor-pointer relative",
viewMode === "grid"
? cn(
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
"h-14 px-2 py-2 rounded-lg transition-colors",
isHostFocusSelected(host.id)
? hostCardFocusClassName("list", true)
: "hover:bg-secondary/60",
),
)}
data-host-id={host.id}
data-vault-grid-item={`pinned:${host.id}`}
role={isMultiSelectMode ? "checkbox" : "button"}
aria-checked={isMultiSelectMode
? selectedHostIds.has(host.id)
: undefined}
tabIndex={getHostTabIndex(host.id)}
style={lastPinnedId === host.id ? { animation: "pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both" } : undefined}
onAnimationEnd={() => { if (lastPinnedId === host.id) setLastPinnedId(null); }}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
activateHost(safeHost);
}}
>
{viewMode === "grid" && (
<Star size={10} className="absolute top-1.5 right-1.5 text-amber-400 fill-amber-400" />
)}
<div className="flex items-center gap-3 h-full">
{isMultiSelectMode && (
<div className="shrink-0">
{selectedHostIds.has(host.id) ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
<DistroAvatar host={safeHost} fallback={distroBadge.text} size="lg" />
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold truncate leading-5">
{safeHost.label}
</span>
{viewMode !== "grid" && renderHostEditButton(host, true)}
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{safeHost.username}@{safeHost.hostname}
</div>
</div>
{viewMode === "grid" && renderHostEditButton(host)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleHostConnect(host)}>
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={host} />
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
{!isPluginHostProtocol(host.protocol) ? (
<ContextMenuItem onClick={() => handleCopyHostname(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('terminal.statusbar.copyHostname.label')}
</ContextMenuItem>
) : null}
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {t('vault.hosts.unpin')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onDeleteHost(host.id)}>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}}
/>
</section>
)}
{/* Recently Connected section - only at root level, toggleable */}
{viewMode !== "tree" && !selectedGroupPath && showRecentHosts && recentHosts.length > 0 && (
<section className="space-y-2 mb-4">
<h3 className="text-sm font-semibold text-muted-foreground inline-flex items-center gap-1.5">
<Clock size={14} className="shrink-0 -translate-y-[1px]" />
{t("vault.hosts.recentlyConnected")}
</h3>
<VirtualizedHostCollection<Host>
items={recentHosts}
itemKey={(host) => host.id}
scrollRef={hostListScrollRef}
viewMode={viewMode}
layoutKey={`recent:${hostCollectionLayoutKey}`}
ariaLabel={t("vault.hosts.recentlyConnected")}
onActiveItemChange={focusHost}
activeItemKey={focusedHostId}
onBoundaryNavigation={(direction) => navigateHostSection("recent", direction)}
renderItem={(host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
text: (safeHost.os || "L")[0].toUpperCase(),
label: effectiveDistro || safeHost.os || "Linux",
};
return (
<ContextMenu key={host.id}>
<ContextMenuTrigger>
<div
className={cn(
"vault-drop-indicator-row group cursor-pointer relative",
viewMode === "grid"
? cn(
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
"h-14 px-2 py-2 rounded-lg transition-colors",
isHostFocusSelected(host.id)
? hostCardFocusClassName("list", true)
: "hover:bg-secondary/60",
),
)}
data-host-id={host.id}
data-vault-grid-item={`recent:${host.id}`}
role={isMultiSelectMode ? "checkbox" : "button"}
aria-checked={isMultiSelectMode
? selectedHostIds.has(host.id)
: undefined}
tabIndex={getHostTabIndex(host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
activateHost(safeHost);
}}
>
<div className="flex items-center gap-3 h-full">
{isMultiSelectMode && (
<div className="shrink-0">
{selectedHostIds.has(host.id) ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
<DistroAvatar host={safeHost} fallback={distroBadge.text} size="lg" />
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold truncate leading-5">
{safeHost.label}
</span>
{viewMode !== "grid" && renderHostEditButton(host, true)}
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{safeHost.username}@{safeHost.hostname}
</div>
</div>
{viewMode === "grid" && renderHostEditButton(host)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleHostConnect(host)}>
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={host} />
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem onClick={() => handleDuplicateHost(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
{!isPluginHostProtocol(host.protocol) ? (
<ContextMenuItem onClick={() => handleCopyHostname(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('terminal.statusbar.copyHostname.label')}
</ContextMenuItem>
) : null}
<ContextMenuItem onClick={() => handleCopyCredentials(host)}>
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onDeleteHost(host.id)}>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}}
/>
</section>
)}
{viewMode !== "tree" && displayedGroups.length > 0 && (
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
{t("vault.groups.title")}
</h3>
<div className="text-xs text-muted-foreground">
{t("vault.groups.total", { count: displayedGroups.length })}
</div>
</div>
)}
{viewMode !== "tree" && (
<VirtualizedHostCollection<GroupNode>
items={displayedGroups}
itemKey={(node) => node.path}
scrollRef={hostListScrollRef}
viewMode={viewMode}
layoutKey={`groups:${hostCollectionLayoutKey}`}
ariaLabel={t("vault.groups.title")}
activeItemKey={focusedGroupIsVisible ? focusedGroupPath : null}
onActiveItemChange={(node) => {
setFocusedGroupPath(node.path);
setFocusedHostId(null);
}}
onBoundaryNavigation={(direction) => navigateHostSection("groups", direction)}
onDragOver={(e) => {
e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
if (handleVaultHostDropToGroup({
dataTransfer: e.dataTransfer,
groupPath: selectedGroupPath,
moveHostToGroup,
resetHostDragState,
})) return;
const groupPath = e.dataTransfer.getData("group-path");
if (groupPath && selectedGroupPath !== null)
moveGroup(groupPath, selectedGroupPath);
}}
renderItem={(node) => (
<ContextMenu key={node.path}>
<ContextMenuTrigger asChild>
<div
className={cn(
"vault-drop-indicator-row group cursor-pointer transition-colors duration-150",
viewMode === "grid"
? cn(
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[box-shadow,border-color,background-color] duration-150",
hostCardFocusClassName(
"grid",
isGroupFocusSelected(node.path) || multiSelectedGroupPaths.has(node.path),
),
)
: cn(
"h-14 px-2 py-2 rounded-lg transition-colors",
isGroupFocusSelected(node.path) || multiSelectedGroupPaths.has(node.path)
? hostCardFocusClassName("list", true)
: "hover:bg-secondary/60",
),
getDropTargetClasses({ kind: "group", path: node.path }),
)}
data-group-path={node.path}
data-vault-grid-item={`group:${node.path}`}
data-vault-focus-target
role={isMultiSelectMode ? "checkbox" : "button"}
aria-checked={isMultiSelectMode
? multiSelectedGroupPaths.has(node.path)
: undefined}
tabIndex={getGroupTabIndex(node.path)}
draggable={!isMultiSelectMode}
onDragStart={(e) =>
e.dataTransfer.setData("group-path", node.path)
}
onDoubleClick={() => {
if (!isMultiSelectMode) setSelectedGroupPath(node.path);
}}
onClick={() => activateGroup(node.path)}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
activateGroup(node.path);
}}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
if (hasVaultDragType(e.dataTransfer, "group-path")) {
const intent = getVaultDropIntent(e.currentTarget, e.clientX, e.clientY, viewMode === "grid");
if (intent !== "inside") {
setDragOverDropTarget((current) =>
current?.kind === "group" && current.path === node.path ? null : current,
);
return;
}
}
setDragOverDropTarget({ kind: "group", path: node.path });
}}
onDragLeave={(e) => {
const nextTarget = e.relatedTarget;
if (isRelatedTargetInside(e.currentTarget, nextTarget)) {
return;
}
setDragOverDropTarget((current) =>
current?.kind === "group" && current.path === node.path ? null : current,
);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverDropTarget(null);
if (handleVaultHostDropToGroup({
dataTransfer: e.dataTransfer,
groupPath: node.path,
moveHostToGroup,
resetHostDragState,
})) return;
const groupPath =
e.dataTransfer.getData("group-path");
if (groupPath) {
const intent = getVaultDropIntent(e.currentTarget, e.clientX, e.clientY, viewMode === "grid");
if (intent === "inside") moveGroup(groupPath, node.path);
}
}}
>
<div className="flex items-center gap-3 h-full">
<VaultEntityIcon
className={vaultPrimaryIconClass}
icon={isMultiSelectMode
? multiSelectedGroupPaths.has(node.path)
? <CheckSquare size={20} />
: <Square size={20} />
: <FolderTree size={20} />}
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold flex items-center gap-1.5 min-w-0">
<span className="truncate">{node.name}</span>
{!isMultiSelectMode && viewMode !== "grid" && renderGroupEditButton(node.path, true)}
{managedGroupPaths.has(node.path) && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
<FileSymlink size={10} />
Managed
</span>
)}
</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
</div>
</div>
{!isMultiSelectMode && viewMode === "grid" && renderGroupEditButton(node.path)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
setTargetParentPath(node.path);
setNewFolderName("");
setIsNewFolderOpen(true);
}}
>
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleEditGroupConfig(node.path)}
>
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.settings")}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => {
setDeleteTargetPath(node.path);
setIsDeleteGroupOpen(true);
}}
>
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
/>
)}
{!shouldHideEmptyRootHostsSection && (
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">
{t("vault.nav.hosts")}
</h3>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : visibleDisplayedHosts.length })}
</span>
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
{t("vault.hosts.header.live", { count: sessionCount })}
</div>
</div>
</div>
{viewMode === "tree" ? (
<HostTreeView
groupTree={treeViewGroupTree}
hosts={treeViewHosts}
sortMode={sortMode}
expandedPaths={treeExpandedState.expandedPaths}
onTogglePath={treeExpandedState.togglePath}
onExpandAll={treeExpandedState.expandAll}
onCollapseAll={treeExpandedState.collapseAll}
onConnect={handleHostConnect}
onEditHost={handleEditHost}
onDuplicateHost={handleDuplicateHost}
onDeleteHost={handleTreeDeleteHost}
onCopyCredentials={handleCopyCredentials}
onCopyHostname={handleCopyHostname}
onNewGroup={startInlineNewGroup}
onRenameGroup={startInlineRenameGroup}
onEditGroup={handleEditGroupConfig}
commitInlineGroupRename={commitInlineGroupRename}
cancelInlineGroupEdit={cancelInlineGroupEdit}
onDeleteGroup={startInlineDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={handleUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
selectedGroupPaths={multiSelectedGroupPaths}
toggleHostSelection={toggleHostSelection}
toggleGroupSelection={toggleGroupSelection}
hostClickBehavior={hostClickBehavior}
focusedHostId={focusedHostId}
onFocusHost={setFocusedHostId}
focusedGroupPath={focusedGroupPath}
onFocusGroup={setFocusedGroupPath}
getDropTargetClasses={handleTreeGroupDropClasses}
setDragOverDropTarget={setGroupDragOverDropTarget}
groupConfigs={groupConfigs}
scrollRef={hostListScrollRef}
autoExpandGroupsKey={treeAutoExpandGroupsKey}
/>
) : sortMode === "group" && groupedDisplayHosts ? (
<>
<VirtualizedGroupedHostCollection<Host>
groups={groupedDisplayHosts}
itemKey={(host) => host.id}
scrollRef={hostListScrollRef}
viewMode={viewMode}
layoutKey={hostCollectionLayoutKey}
ariaLabel={t("vault.nav.hosts")}
onActiveItemChange={focusHost}
activeItemKey={focusedHostId}
onBoundaryNavigation={(direction) => navigateHostSection("main", direction)}
renderGroupHeader={(group) => (
<div className="flex w-full items-center gap-2 border-b border-border/40 pb-2">
<FolderTree size={14} className="text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
{group.name || t("vault.groups.ungrouped")}
</span>
<span className="text-xs text-muted-foreground/60">
({group.hosts.length})
</span>
</div>
)}
renderItem={(host: Host, group) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
text: (safeHost.os || "L")[0].toUpperCase(),
label: effectiveDistro || safeHost.os || "Linux",
};
return (
<ContextMenu key={host.id}>
<ContextMenuTrigger>
<div
className={cn(
"vault-drop-indicator-row group cursor-pointer relative",
viewMode === "grid"
? cn(
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
"h-14 px-2 py-2 rounded-lg transition-colors",
isHostFocusSelected(host.id)
? hostCardFocusClassName("list", true)
: "hover:bg-secondary/60",
),
)}
data-host-id={host.id}
data-vault-grid-item={`grouped:${group.name || "__ungrouped__"}:${host.id}`}
role={isMultiSelectMode ? "checkbox" : "button"}
aria-checked={isMultiSelectMode
? selectedHostIds.has(host.id)
: undefined}
tabIndex={getHostTabIndex(host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
activateHost(safeHost);
}}
>
{host.pinned && viewMode === "grid" && (
<Star size={10} className="absolute top-1.5 right-1.5 text-amber-400 fill-amber-400" />
)}
<div className="flex items-center gap-3 h-full">
{isMultiSelectMode && (
<div className="shrink-0" aria-hidden="true">
{selectedHostIds.has(host.id) ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
<DistroAvatar
host={safeHost}
fallback={distroBadge.text}
size="lg"
/>
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold truncate leading-5">
{safeHost.label}
</span>
{viewMode !== "grid" && renderHostEditButton(host, true)}
{safeHost.managedSourceId && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
managed
</Badge>
)}
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{safeHost.username}@{safeHost.hostname}
</div>
</div>
{viewMode === "grid" && renderHostEditButton(host)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => handleHostConnect(host)}
>
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={host} />
<ContextMenuItem
onClick={() => handleEditHost(host)}
>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleDuplicateHost(host)}
>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
{!isPluginHostProtocol(host.protocol) ? (
<ContextMenuItem onClick={() => handleCopyHostname(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('terminal.statusbar.copyHostname.label')}
</ContextMenuItem>
) : null}
<ContextMenuItem
onClick={() => handleCopyCredentials(host)}
>
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => onDeleteHost(host.id)}
>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}}
/>
{groupedDisplayHosts.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<LayoutGrid size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{t('vault.hosts.empty.title')}
</h3>
<p className="text-sm text-center max-w-sm">
{t('vault.hosts.empty.desc')}
</p>
</div>
)}
</>
) : (
<>
<VirtualizedHostCollection<Host>
items={visibleDisplayedHosts}
itemKey={(host) => host.id}
scrollRef={hostListScrollRef}
viewMode={viewMode}
layoutKey={hostCollectionLayoutKey}
ariaLabel={t("vault.nav.hosts")}
onActiveItemChange={focusHost}
activeItemKey={focusedHostId}
onBoundaryNavigation={(direction) => navigateHostSection("main", direction)}
renderItem={(host: Host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
text: (safeHost.os || "L")[0].toUpperCase(),
label: effectiveDistro || safeHost.os || "Linux",
};
return (
<ContextMenu key={host.id}>
<ContextMenuTrigger>
<div
className={cn(
"vault-drop-indicator-row group cursor-pointer relative",
viewMode === "grid"
? cn(
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
draggingHostId === host.id && "opacity-45",
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
)
: cn(
"h-14 px-2 py-2 rounded-lg transition-colors",
isHostFocusSelected(host.id)
? hostCardFocusClassName("list", true)
: "hover:bg-secondary/60",
),
)}
data-host-id={host.id}
data-vault-grid-item={`main:${host.id}`}
role={isMultiSelectMode ? "checkbox" : "button"}
aria-checked={isMultiSelectMode
? selectedHostIds.has(host.id)
: undefined}
tabIndex={getHostTabIndex(host.id)}
draggable={!isMultiSelectMode}
onDragStart={(e) => handleHostDragStart(e, host.id)}
onClick={() => {
activateHost(safeHost);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
activateHost(safeHost);
}}
>
{host.pinned && viewMode === "grid" && (
<Star size={10} className="absolute top-1.5 right-1.5 text-amber-400 fill-amber-400" />
)}
<div className="flex items-center gap-3 h-full">
{isMultiSelectMode && (
<div className="shrink-0" aria-hidden="true">
{selectedHostIds.has(host.id) ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
<DistroAvatar
host={safeHost}
fallback={distroBadge.text}
size="lg"
/>
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold truncate leading-5">
{safeHost.label}
</span>
{viewMode !== "grid" && renderHostEditButton(host, true)}
{safeHost.managedSourceId && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
managed
</Badge>
)}
<HostNotesIndicator notes={safeHost.notes} />
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
{safeHost.username}@{safeHost.hostname}
</div>
</div>
{viewMode === "grid" && renderHostEditButton(host)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => handleHostConnect(host)}
>
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={host} />
<ContextMenuItem
onClick={() => handleEditHost(host)}
>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleDuplicateHost(host)}
>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
{!isPluginHostProtocol(host.protocol) ? (
<ContextMenuItem onClick={() => handleCopyHostname(host)}>
<Copy className="mr-2 h-4 w-4" /> {t('terminal.statusbar.copyHostname.label')}
</ContextMenuItem>
) : null}
<ContextMenuItem
onClick={() => handleCopyCredentials(host)}
>
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem onClick={() => toggleHostPinned(host.id)}>
<Pin className="mr-2 h-4 w-4" /> {host.pinned ? t('vault.hosts.unpin') : t('vault.hosts.pinToTop')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => onDeleteHost(host.id)}
>
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}}
/>
{displayedHosts.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<LayoutGrid size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
{t('vault.hosts.empty.title')}
</h3>
<p className="text-sm text-center max-w-sm">
{t('vault.hosts.empty.desc')}
</p>
</div>
)}
</>
)}
</section>
)}
</div>;
}