Files
NetMesh/components/vault/VaultHostListSection.tsx
zhaolei 3c72efcb7f
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

1316 lines
71 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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>;
}