1787 lines
62 KiB
TypeScript
1787 lines
62 KiB
TypeScript
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||
|
|
import React, { useCallback } from "react";
|
||
|
|
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
|
||
|
|
import { usePluginImporterCommit } from "../../application/state/usePluginImporterCommit";
|
||
|
|
import { preserveConcurrentHostLineTimestampUpdate } from "../../domain/host";
|
||
|
|
import {
|
||
|
|
collectVaultGroupPathsForSelectAll,
|
||
|
|
collectVisibleVaultGroupPaths,
|
||
|
|
collectVisibleVaultHostIds,
|
||
|
|
retainVisibleVaultGroupSelection,
|
||
|
|
} from "../../domain/vaultGroupSelection";
|
||
|
|
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from "@/infrastructure/config/storageKeys.ts";
|
||
|
|
import { VaultHostListSection } from "./VaultHostListSection";
|
||
|
|
import { VaultImportProgressPanel } from "./ImportVaultDialog";
|
||
|
|
import {
|
||
|
|
VaultHeaderSearch,
|
||
|
|
VaultPageHeader,
|
||
|
|
vaultHeaderIconButtonClass,
|
||
|
|
vaultHeaderSecondaryButtonClass,
|
||
|
|
} from "./VaultPageHeader";
|
||
|
|
import { useConnectionLogsStore } from "../../application/state/connectionLogsStore";
|
||
|
|
import { useNotesStore } from "../../application/state/notesStore";
|
||
|
|
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
|
||
|
|
import { toast } from "../ui/toast";
|
||
|
|
import { AppWordmark } from "../AppWordmark";
|
||
|
|
|
||
|
|
type VaultViewLayoutContext = Record<string, any>;
|
||
|
|
|
||
|
|
const VaultSectionLoading = () => (
|
||
|
|
<div
|
||
|
|
className="netcatty-lazy-fade-in min-h-[320px] flex-1"
|
||
|
|
aria-hidden="true"
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Notes section subscribes to notesStore instead of taking notes as VaultView
|
||
|
|
* props, so note edits never invalidate the App vault domain bag.
|
||
|
|
*
|
||
|
|
* While inactive (retained but hidden), freeze hosts and drop the store
|
||
|
|
* subscription so Hosts/Keys churn and note publishes do not reconcile the
|
||
|
|
* heavy NotesManager + MDXEditor tree.
|
||
|
|
*/
|
||
|
|
function VaultNotesSection({
|
||
|
|
NotesManager,
|
||
|
|
hosts,
|
||
|
|
isActive,
|
||
|
|
openNoteId,
|
||
|
|
onOpenNoteIdHandled,
|
||
|
|
onOpenHost,
|
||
|
|
}: {
|
||
|
|
NotesManager: React.ComponentType<any>;
|
||
|
|
hosts: any[];
|
||
|
|
isActive: boolean;
|
||
|
|
openNoteId: string | null;
|
||
|
|
onOpenNoteIdHandled: () => void;
|
||
|
|
onOpenHost: (host: any, source?: { noteId?: string }) => void;
|
||
|
|
}) {
|
||
|
|
const { notes, noteGroups, updateNotes, updateNoteGroups } = useNotesStore({
|
||
|
|
enabled: isActive,
|
||
|
|
});
|
||
|
|
return (
|
||
|
|
<NotesManager
|
||
|
|
notes={notes}
|
||
|
|
noteGroups={noteGroups}
|
||
|
|
hosts={hosts}
|
||
|
|
onUpdateNotes={updateNotes}
|
||
|
|
onUpdateNoteGroups={updateNoteGroups}
|
||
|
|
isActive={isActive}
|
||
|
|
openNoteId={openNoteId}
|
||
|
|
onOpenNoteIdHandled={onOpenNoteIdHandled}
|
||
|
|
onOpenHost={onOpenHost}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
const MemoVaultNotesSection = React.memo(
|
||
|
|
VaultNotesSection,
|
||
|
|
(prev, next) => {
|
||
|
|
if (
|
||
|
|
prev.isActive !== next.isActive
|
||
|
|
|| prev.openNoteId !== next.openNoteId
|
||
|
|
|| prev.NotesManager !== next.NotesManager
|
||
|
|
|| prev.onOpenNoteIdHandled !== next.onOpenNoteIdHandled
|
||
|
|
|| prev.onOpenHost !== next.onOpenHost
|
||
|
|
) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (next.isActive && prev.hosts !== next.hosts) return false;
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Logs section subscribes to connectionLogsStore so every session start/exit
|
||
|
|
* append stays out of the App vault/chrome domain bags.
|
||
|
|
*/
|
||
|
|
function VaultConnectionLogsSection({
|
||
|
|
ConnectionLogsManager,
|
||
|
|
hosts,
|
||
|
|
onOpenLogView,
|
||
|
|
}: {
|
||
|
|
ConnectionLogsManager: React.ComponentType<any>;
|
||
|
|
hosts: any[];
|
||
|
|
onOpenLogView: (log: any) => void;
|
||
|
|
}) {
|
||
|
|
const {
|
||
|
|
connectionLogs,
|
||
|
|
toggleConnectionLogSaved,
|
||
|
|
deleteConnectionLog,
|
||
|
|
clearUnsavedConnectionLogs,
|
||
|
|
} = useConnectionLogsStore();
|
||
|
|
return (
|
||
|
|
<ConnectionLogsManager
|
||
|
|
logs={connectionLogs}
|
||
|
|
hosts={hosts}
|
||
|
|
onToggleSaved={toggleConnectionLogSaved}
|
||
|
|
onDelete={deleteConnectionLog}
|
||
|
|
onClearUnsaved={clearUnsavedConnectionLogs}
|
||
|
|
onOpenLogView={onOpenLogView}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||
|
|
const {
|
||
|
|
Activity,
|
||
|
|
allGroupPaths,
|
||
|
|
allTags,
|
||
|
|
AppLogo,
|
||
|
|
Array,
|
||
|
|
Badge,
|
||
|
|
BookMarked,
|
||
|
|
Boolean,
|
||
|
|
bulkDeleteGroupPaths,
|
||
|
|
Button,
|
||
|
|
cancelImport,
|
||
|
|
cancelInlineGroupEdit,
|
||
|
|
CheckSquare,
|
||
|
|
ChevronDown,
|
||
|
|
clearHostSelection,
|
||
|
|
ClipboardCopy,
|
||
|
|
Clock,
|
||
|
|
cn,
|
||
|
|
commitInlineGroupRename,
|
||
|
|
connectSelectedHosts,
|
||
|
|
ContextMenu,
|
||
|
|
ContextMenuContent,
|
||
|
|
ContextMenuItem,
|
||
|
|
ContextMenuTrigger,
|
||
|
|
Copy,
|
||
|
|
currentSection,
|
||
|
|
customGroups,
|
||
|
|
deleteGroupPaths,
|
||
|
|
deleteGroupWithHosts,
|
||
|
|
deleteSelectedHosts,
|
||
|
|
deleteTargetPath,
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
displayedGroups,
|
||
|
|
displayedHosts,
|
||
|
|
DistroAvatar,
|
||
|
|
Download,
|
||
|
|
Dropdown,
|
||
|
|
DropdownContent,
|
||
|
|
DropdownTrigger,
|
||
|
|
Edit2,
|
||
|
|
editingGroupPath,
|
||
|
|
editingHost,
|
||
|
|
editingHostGroupDefaults,
|
||
|
|
FileCode,
|
||
|
|
FileSymlink,
|
||
|
|
FolderPlus,
|
||
|
|
FolderTree,
|
||
|
|
getDropTargetClasses,
|
||
|
|
getEffectiveHostDistro,
|
||
|
|
Globe,
|
||
|
|
groupConfigs,
|
||
|
|
GroupDetailsPanel,
|
||
|
|
groupedDisplayHosts,
|
||
|
|
handleConnectClick,
|
||
|
|
handleCopyCredentials,
|
||
|
|
handleCopyHostname,
|
||
|
|
handleDeleteTag,
|
||
|
|
handleDuplicateHost,
|
||
|
|
handleEditGroupConfig,
|
||
|
|
handleEditHost,
|
||
|
|
handleEditTag,
|
||
|
|
handleExportHosts,
|
||
|
|
handleHostConnect,
|
||
|
|
handleImportFileSelected,
|
||
|
|
handleNewHost,
|
||
|
|
handleProtocolSelect,
|
||
|
|
handleQuickConnect,
|
||
|
|
handleQuickConnectSaveHost,
|
||
|
|
handleSaveGroupConfig,
|
||
|
|
handleSearchKeyDown,
|
||
|
|
handleUnmanageGroup,
|
||
|
|
handleSidebarWidthCommit,
|
||
|
|
hasHostsSidePanel,
|
||
|
|
HostDetailsPanel,
|
||
|
|
hostListScrollRef,
|
||
|
|
hosts,
|
||
|
|
HostTreeView,
|
||
|
|
hotkeyScheme,
|
||
|
|
identities,
|
||
|
|
ImportVaultDialog,
|
||
|
|
Input,
|
||
|
|
isDeleteGroupOpen,
|
||
|
|
isGroupPanelOpen,
|
||
|
|
isHostPanelOpen,
|
||
|
|
isHostsSectionActive,
|
||
|
|
isImportOpen,
|
||
|
|
isMultiSelectMode,
|
||
|
|
isNewFolderOpen,
|
||
|
|
isQuickConnectOpen,
|
||
|
|
isRenameGroupOpen,
|
||
|
|
isSearchQuickConnect,
|
||
|
|
isSerialModalOpen,
|
||
|
|
Key,
|
||
|
|
keyBindings,
|
||
|
|
KeychainManager,
|
||
|
|
keys,
|
||
|
|
knownHostsManagerElement,
|
||
|
|
Label,
|
||
|
|
lastPinnedId,
|
||
|
|
LayoutGrid,
|
||
|
|
LazyConnectionLogsManager,
|
||
|
|
LazyProtocolSelectDialog,
|
||
|
|
List,
|
||
|
|
managedGroupPaths,
|
||
|
|
managedSources,
|
||
|
|
moveGroup,
|
||
|
|
moveHostToGroup,
|
||
|
|
Network,
|
||
|
|
newFolderName,
|
||
|
|
newHostGroupPath,
|
||
|
|
onConnectSerial,
|
||
|
|
onCreateLocalTerminal,
|
||
|
|
onDeleteHost,
|
||
|
|
onImportOrReuseKey,
|
||
|
|
onOpenLogView,
|
||
|
|
onOpenSettings,
|
||
|
|
onRunSnippet,
|
||
|
|
onUpdateCustomGroups,
|
||
|
|
onUpdateGroupConfigs,
|
||
|
|
onUpdateHosts,
|
||
|
|
onUpdateIdentities,
|
||
|
|
onUpdateKeys,
|
||
|
|
onUpdateProxyProfiles,
|
||
|
|
onUpdateSnippetPackages,
|
||
|
|
onUpdateSnippets,
|
||
|
|
Pin,
|
||
|
|
pinnedHosts,
|
||
|
|
pinnedRecentIds,
|
||
|
|
Plug,
|
||
|
|
Plus,
|
||
|
|
PortForwarding,
|
||
|
|
protocolSelectHost,
|
||
|
|
proxyProfiles,
|
||
|
|
ProxyProfilesManager,
|
||
|
|
quickConnectTarget,
|
||
|
|
quickConnectWarnings,
|
||
|
|
QuickConnectWizard,
|
||
|
|
recentHosts,
|
||
|
|
renameGroupError,
|
||
|
|
renameGroupName,
|
||
|
|
renameTargetPath,
|
||
|
|
reorderGroup,
|
||
|
|
reorderHost,
|
||
|
|
RippleButton,
|
||
|
|
rootRef,
|
||
|
|
sanitizeHost,
|
||
|
|
search,
|
||
|
|
selectedGroupPath,
|
||
|
|
selectedGroupPaths,
|
||
|
|
selectedHostIds,
|
||
|
|
selectedTags,
|
||
|
|
SerialConnectModal,
|
||
|
|
SerialHostDetailsPanel,
|
||
|
|
sessionCount,
|
||
|
|
Set,
|
||
|
|
setBulkDeleteGroupPaths,
|
||
|
|
setCurrentSection,
|
||
|
|
setDeleteGroupWithHosts,
|
||
|
|
setDeleteTargetPath,
|
||
|
|
setDragOverDropTarget,
|
||
|
|
setEditingGroupPath,
|
||
|
|
setEditingHost,
|
||
|
|
setGroupDragOverDropTarget,
|
||
|
|
setIsDeleteGroupOpen,
|
||
|
|
setIsGroupPanelOpen,
|
||
|
|
setIsHostPanelOpen,
|
||
|
|
setIsImportOpen,
|
||
|
|
setIsMultiSelectMode,
|
||
|
|
setIsNewFolderOpen,
|
||
|
|
setIsQuickConnectOpen,
|
||
|
|
setIsRenameGroupOpen,
|
||
|
|
setIsSerialModalOpen,
|
||
|
|
setLastPinnedId,
|
||
|
|
setNewFolderName,
|
||
|
|
setNewHostGroupPath,
|
||
|
|
setProtocolSelectHost,
|
||
|
|
setQuickConnectTarget,
|
||
|
|
setQuickConnectWarnings,
|
||
|
|
setRenameGroupError,
|
||
|
|
setRenameGroupName,
|
||
|
|
setRenameTargetPath,
|
||
|
|
setSearch,
|
||
|
|
setSelectedGroupPath,
|
||
|
|
setSelectedHostIds,
|
||
|
|
setSelectedGroupPaths,
|
||
|
|
setSelectedTags,
|
||
|
|
setSidebarCollapsed,
|
||
|
|
setSidebarWidth,
|
||
|
|
setSortMode,
|
||
|
|
setTargetParentPath,
|
||
|
|
Settings,
|
||
|
|
setViewMode,
|
||
|
|
shouldHideEmptyRootHostsSection,
|
||
|
|
showRecentHosts,
|
||
|
|
hostClickBehavior,
|
||
|
|
sidebarCollapsed,
|
||
|
|
sidebarWidth,
|
||
|
|
snippetPackages,
|
||
|
|
snippets,
|
||
|
|
SnippetsManager,
|
||
|
|
SortDropdown,
|
||
|
|
sortMode,
|
||
|
|
splitViewGridStyle,
|
||
|
|
Square,
|
||
|
|
Star,
|
||
|
|
startInlineDeleteGroup,
|
||
|
|
startInlineNewGroup,
|
||
|
|
startInlineRenameGroup,
|
||
|
|
submitNewFolder,
|
||
|
|
submitRenameGroup,
|
||
|
|
Suspense,
|
||
|
|
t,
|
||
|
|
TagFilterDropdown,
|
||
|
|
targetParentPath,
|
||
|
|
terminalFontSize,
|
||
|
|
terminalSettings,
|
||
|
|
TerminalSquare,
|
||
|
|
terminalThemeId,
|
||
|
|
toggleGroupSelection,
|
||
|
|
toggleHostPinned,
|
||
|
|
toggleHostSelection,
|
||
|
|
Tooltip,
|
||
|
|
TooltipContent,
|
||
|
|
TooltipProvider,
|
||
|
|
TooltipTrigger,
|
||
|
|
Trash2,
|
||
|
|
treeExpandedState,
|
||
|
|
treeViewGroupTree,
|
||
|
|
treeViewHosts,
|
||
|
|
Upload,
|
||
|
|
upsertHostById,
|
||
|
|
Usb,
|
||
|
|
viewMode,
|
||
|
|
visibleDisplayedHosts,
|
||
|
|
X,
|
||
|
|
Zap,
|
||
|
|
} = ctx;
|
||
|
|
const {
|
||
|
|
knownHosts,
|
||
|
|
NotebookText,
|
||
|
|
NotesManager,
|
||
|
|
onOpenHostFromNote,
|
||
|
|
onOpenNoteIdHandled,
|
||
|
|
onOpenSnippetIdHandled,
|
||
|
|
openNoteId,
|
||
|
|
openSnippetId,
|
||
|
|
} = ctx;
|
||
|
|
const { importProgress, onCommitPluginImporterData, resetImportProgress } =
|
||
|
|
ctx;
|
||
|
|
const pendingDeleteGroupPaths =
|
||
|
|
bulkDeleteGroupPaths.length > 0
|
||
|
|
? bulkDeleteGroupPaths
|
||
|
|
: deleteTargetPath
|
||
|
|
? [deleteTargetPath]
|
||
|
|
: [];
|
||
|
|
const pendingDeleteHasManagedGroups = managedSources.some((source) =>
|
||
|
|
pendingDeleteGroupPaths.some(
|
||
|
|
(path) =>
|
||
|
|
source.groupName === path || source.groupName.startsWith(path + "/"),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
const handleNotesOpenHost = useCallback((host: any, source?: { noteId?: string }) => {
|
||
|
|
if (source?.noteId && onOpenHostFromNote) {
|
||
|
|
onOpenHostFromNote(host, source);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
handleHostConnect(host);
|
||
|
|
}, [handleHostConnect, onOpenHostFromNote]);
|
||
|
|
const visibleTreeGroupPaths = React.useMemo(
|
||
|
|
() => collectVisibleVaultGroupPaths(treeViewGroupTree),
|
||
|
|
[treeViewGroupTree],
|
||
|
|
);
|
||
|
|
const visibleSelectableGroupPaths = React.useMemo(
|
||
|
|
() => new globalThis.Set<string>(
|
||
|
|
viewMode === "tree"
|
||
|
|
? visibleTreeGroupPaths
|
||
|
|
: displayedGroups.map((group: { path: string }) => group.path),
|
||
|
|
),
|
||
|
|
[displayedGroups, viewMode, visibleTreeGroupPaths],
|
||
|
|
);
|
||
|
|
React.useEffect(() => {
|
||
|
|
setSelectedGroupPaths((current: Set<string>) => {
|
||
|
|
const next = retainVisibleVaultGroupSelection(current, visibleSelectableGroupPaths);
|
||
|
|
return next.size === current.size ? current : next;
|
||
|
|
});
|
||
|
|
}, [setSelectedGroupPaths, visibleSelectableGroupPaths]);
|
||
|
|
const hasActiveHostFilters = search.trim().length > 0 || selectedTags.length > 0;
|
||
|
|
const vaultHostPanelResizeProps = {
|
||
|
|
resizable: true as const,
|
||
|
|
persistWidthStorageKey: STORAGE_KEY_VAULT_HOST_PANEL_WIDTH,
|
||
|
|
resizeAriaLabel: t("vault.panel.resizeWidth"),
|
||
|
|
};
|
||
|
|
const [isSidebarResizing, setIsSidebarResizing] = React.useState(false);
|
||
|
|
const keyListRef = React.useRef(keys);
|
||
|
|
keyListRef.current = keys;
|
||
|
|
const newHostActionsRef = React.useRef<HTMLDivElement>(null);
|
||
|
|
const sessionActionsRef = React.useRef<HTMLDivElement>(null);
|
||
|
|
const { handlePluginPreviewCommit, getPluginPreviewAnalysis } =
|
||
|
|
usePluginImporterCommit({
|
||
|
|
hosts,
|
||
|
|
identities,
|
||
|
|
keys,
|
||
|
|
snippets,
|
||
|
|
customGroups,
|
||
|
|
onCommitPluginImporterData,
|
||
|
|
t,
|
||
|
|
onCommitSuccess: (addedCount) => {
|
||
|
|
toast.success(
|
||
|
|
t("vault.import.plugins.committed", { count: addedCount }),
|
||
|
|
t("vault.import.toast.completedTitle"),
|
||
|
|
);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const sidebarMinWidth = 56;
|
||
|
|
const sidebarMaxWidth = 320;
|
||
|
|
const effectiveSidebarWidth = Math.max(
|
||
|
|
sidebarMinWidth,
|
||
|
|
Math.min(sidebarMaxWidth, Number(sidebarWidth) || 208),
|
||
|
|
);
|
||
|
|
const handleDeleteVaultKey = React.useCallback(
|
||
|
|
(keyId: string) => {
|
||
|
|
void deleteVaultKey({
|
||
|
|
keyId,
|
||
|
|
getKeys: () => keyListRef.current,
|
||
|
|
updateKeys: (updatedKeys) => {
|
||
|
|
keyListRef.current = updatedKeys;
|
||
|
|
void onUpdateKeys(updatedKeys);
|
||
|
|
},
|
||
|
|
}).catch((error) => {
|
||
|
|
console.error(
|
||
|
|
"[Vault] Failed to clear a deleted key's remembered passphrase.",
|
||
|
|
error,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
},
|
||
|
|
[onUpdateKeys],
|
||
|
|
);
|
||
|
|
const handleSidebarResizeStart = React.useCallback(
|
||
|
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||
|
|
event.preventDefault();
|
||
|
|
event.stopPropagation();
|
||
|
|
|
||
|
|
const startX = event.clientX;
|
||
|
|
const startWidth = effectiveSidebarWidth;
|
||
|
|
const previousCursor = document.body.style.cursor;
|
||
|
|
const previousUserSelect = document.body.style.userSelect;
|
||
|
|
|
||
|
|
setIsSidebarResizing(true);
|
||
|
|
document.body.style.cursor = "col-resize";
|
||
|
|
document.body.style.userSelect = "none";
|
||
|
|
|
||
|
|
const clampWidth = (value: number) =>
|
||
|
|
Math.max(sidebarMinWidth, Math.min(sidebarMaxWidth, value));
|
||
|
|
|
||
|
|
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||
|
|
setSidebarWidth(clampWidth(startWidth + moveEvent.clientX - startX));
|
||
|
|
};
|
||
|
|
const handlePointerUp = (upEvent: PointerEvent) => {
|
||
|
|
const nextWidth = clampWidth(startWidth + upEvent.clientX - startX);
|
||
|
|
setSidebarWidth(nextWidth);
|
||
|
|
handleSidebarWidthCommit(nextWidth);
|
||
|
|
setIsSidebarResizing(false);
|
||
|
|
document.body.style.cursor = previousCursor;
|
||
|
|
document.body.style.userSelect = previousUserSelect;
|
||
|
|
window.removeEventListener("pointermove", handlePointerMove);
|
||
|
|
window.removeEventListener("pointerup", handlePointerUp);
|
||
|
|
window.removeEventListener("pointercancel", handlePointerUp);
|
||
|
|
};
|
||
|
|
|
||
|
|
window.addEventListener("pointermove", handlePointerMove);
|
||
|
|
window.addEventListener("pointerup", handlePointerUp);
|
||
|
|
window.addEventListener("pointercancel", handlePointerUp);
|
||
|
|
},
|
||
|
|
[effectiveSidebarWidth, handleSidebarWidthCommit, setSidebarWidth],
|
||
|
|
);
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
if (!isHostPanelOpen) return;
|
||
|
|
const activeElement = document.activeElement;
|
||
|
|
if (!(activeElement instanceof HTMLElement)) return;
|
||
|
|
if (
|
||
|
|
newHostActionsRef.current?.contains(activeElement) ||
|
||
|
|
sessionActionsRef.current?.contains(activeElement)
|
||
|
|
) {
|
||
|
|
activeElement.blur();
|
||
|
|
}
|
||
|
|
}, [isHostPanelOpen]);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={rootRef}
|
||
|
|
className="absolute inset-0 min-h-0 flex bg-secondary"
|
||
|
|
data-section="vault-view"
|
||
|
|
>
|
||
|
|
{/* Sidebar */}
|
||
|
|
<TooltipProvider delayDuration={100}>
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"relative shrink-0 bg-secondary flex flex-col",
|
||
|
|
isSidebarResizing
|
||
|
|
? "transition-none"
|
||
|
|
: "transition-[width] duration-200",
|
||
|
|
)}
|
||
|
|
style={{ width: effectiveSidebarWidth }}
|
||
|
|
data-section="vault-sidebar"
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"pt-5 pb-6 flex items-center",
|
||
|
|
sidebarCollapsed ? "px-2 justify-center" : "px-4",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Tooltip delayDuration={500}>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<button
|
||
|
|
aria-label={sidebarCollapsed ? t("vault.sidebar.expand") : t("vault.sidebar.collapse")}
|
||
|
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||
|
|
className="flex items-center gap-2.5 hover:opacity-80 transition-opacity"
|
||
|
|
>
|
||
|
|
<AppLogo className="h-8 w-8 flex-shrink-0" />
|
||
|
|
{!sidebarCollapsed && (
|
||
|
|
<AppWordmark className="h-5 w-auto text-foreground" />
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
</TooltipTrigger>
|
||
|
|
<TooltipContent side="right">
|
||
|
|
{sidebarCollapsed
|
||
|
|
? t("vault.sidebar.expand")
|
||
|
|
: t("vault.sidebar.collapse")}
|
||
|
|
</TooltipContent>
|
||
|
|
</Tooltip>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div
|
||
|
|
className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
|
||
|
|
>
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<RippleButton
|
||
|
|
variant={currentSection === "hosts" ? "secondary" : "ghost"}
|
||
|
|
className={cn(
|
||
|
|
"w-full h-10",
|
||
|
|
sidebarCollapsed
|
||
|
|
? "justify-center p-0"
|
||
|
|
: "justify-start gap-3",
|
||
|
|
currentSection === "hosts" &&
|
||
|
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||
|
|
)}
|
||
|
|
onClick={() => {
|
||
|
|
setCurrentSection("hosts");
|
||
|
|
setSelectedGroupPath(null);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<LayoutGrid size={16} className="flex-shrink-0" />
|
||
|
|
{!sidebarCollapsed && t("vault.nav.hosts")}
|
||
|
|
</RippleButton>
|
||
|
|
</TooltipTrigger>
|
||
|
|
{sidebarCollapsed && (
|
||
|
|
<TooltipContent side="right">
|
||
|
|
{t("vault.nav.hosts")}
|
||
|
|
</TooltipContent>
|
||
|
|
)}
|
||
|
|
</Tooltip>
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<RippleButton
|
||
|
|
variant={currentSection === "keys" ? "secondary" : "ghost"}
|
||
|
|
className={cn(
|
||
|
|
"w-full h-10",
|
||
|
|
sidebarCollapsed
|
||
|
|
? "justify-center p-0"
|
||
|
|
: "justify-start gap-3",
|
||
|
|
currentSection === "keys" &&
|
||
|
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||
|
|
)}
|
||
|
|
onClick={() => {
|
||
|
|
setCurrentSection("keys");
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Key size={16} className="flex-shrink-0" />
|
||
|
|
{!sidebarCollapsed && t("vault.nav.keychain")}
|
||
|
|
</RippleButton>
|
||
|
|
</TooltipTrigger>
|
||
|
|
{sidebarCollapsed && (
|
||
|
|
<TooltipContent side="right">
|
||
|
|
{t("vault.nav.keychain")}
|
||
|
|
</TooltipContent>
|
||
|
|
)}
|
||
|
|
</Tooltip>
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<RippleButton
|
||
|
|
variant={currentSection === "proxies" ? "secondary" : "ghost"}
|
||
|
|
className={cn(
|
||
|
|
"w-full h-10",
|
||
|
|
sidebarCollapsed
|
||
|
|
? "justify-center p-0"
|
||
|
|
: "justify-start gap-3",
|
||
|
|
currentSection === "proxies" &&
|
||
|
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||
|
|
)}
|
||
|
|
onClick={() => {
|
||
|
|
setCurrentSection("proxies");
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Globe size={16} className="flex-shrink-0" />
|
||
|
|
{!sidebarCollapsed && t("vault.nav.proxies")}
|
||
|
|
</RippleButton>
|
||
|
|
</TooltipTrigger>
|
||
|
|
{sidebarCollapsed && (
|
||
|
|
<TooltipContent side="right">
|
||
|
|
{t("vault.nav.proxies")}
|
||
|
|
</TooltipContent>
|
||
|
|
)}
|
||
|
|
</Tooltip>
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<RippleButton
|
||
|
|
variant={currentSection === "port" ? "secondary" : "ghost"}
|
||
|
|
className={cn(
|
||
|
|
"w-full h-10",
|
||
|
|
sidebarCollapsed
|
||
|
|
? "justify-center p-0"
|
||
|
|
: "justify-start gap-3",
|
||
|
|
currentSection === "port" &&
|
||
|
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||
|
|
)}
|
||
|
|
onClick={() => setCurrentSection("port")}
|
||
|
|
>
|
||
|
|
<Plug size={16} className="flex-shrink-0" />
|
||
|
|
{!sidebarCollapsed && t("vault.nav.portForwarding")}
|
||
|
|
</RippleButton>
|
||
|
|
</TooltipTrigger>
|
||
|
|
{sidebarCollapsed && (
|
||
|
|
<TooltipContent side="right">
|
||
|
|
{t("vault.nav.portForwarding")}
|
||
|
|
</TooltipContent>
|
||
|
|
)}
|
||
|
|
</Tooltip>
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<RippleButton
|
||
|
|
variant={
|
||
|
|
currentSection === "snippets" ? "secondary" : "ghost"
|
||
|
|
}
|
||
|
|
className={cn(
|
||
|
|
"w-full h-10",
|
||
|
|
sidebarCollapsed
|
||
|
|
? "justify-center p-0"
|
||
|
|
: "justify-start gap-3",
|
||
|
|
currentSection === "snippets" &&
|
||
|
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||
|
|
)}
|
||
|
|
onClick={() => {
|
||
|
|
setCurrentSection("snippets");
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<FileCode size={16} className="flex-shrink-0" />
|
||
|
|
{!sidebarCollapsed && t("vault.nav.scripts")}
|
||
|
|
</RippleButton>
|
||
|
|
</TooltipTrigger>
|
||
|
|
{sidebarCollapsed && (
|
||
|
|
<TooltipContent side="right">
|
||
|
|
{t("vault.nav.scripts")}
|
||
|
|
</TooltipContent>
|
||
|
|
)}
|
||
|
|
</Tooltip>
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<RippleButton
|
||
|
|
variant={currentSection === "notes" ? "secondary" : "ghost"}
|
||
|
|
className={cn(
|
||
|
|
"w-full h-10",
|
||
|
|
sidebarCollapsed
|
||
|
|
? "justify-center p-0"
|
||
|
|
: "justify-start gap-3",
|
||
|
|
currentSection === "notes" &&
|
||
|
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||
|
|
)}
|
||
|
|
onClick={() => {
|
||
|
|
setCurrentSection("notes");
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<NotebookText size={16} className="flex-shrink-0" />
|
||
|
|
{!sidebarCollapsed && t("vault.nav.notes")}
|
||
|
|
</RippleButton>
|
||
|
|
</TooltipTrigger>
|
||
|
|
{sidebarCollapsed && (
|
||
|
|
<TooltipContent side="right">
|
||
|
|
{t("vault.nav.notes")}
|
||
|
|
</TooltipContent>
|
||
|
|
)}
|
||
|
|
</Tooltip>
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<RippleButton
|
||
|
|
variant={
|
||
|
|
currentSection === "knownhosts" ? "secondary" : "ghost"
|
||
|
|
}
|
||
|
|
className={cn(
|
||
|
|
"w-full h-10",
|
||
|
|
sidebarCollapsed
|
||
|
|
? "justify-center p-0"
|
||
|
|
: "justify-start gap-3",
|
||
|
|
currentSection === "knownhosts" &&
|
||
|
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||
|
|
)}
|
||
|
|
onClick={() => setCurrentSection("knownhosts")}
|
||
|
|
>
|
||
|
|
<BookMarked size={16} className="flex-shrink-0" />
|
||
|
|
{!sidebarCollapsed && t("vault.nav.knownHosts")}
|
||
|
|
</RippleButton>
|
||
|
|
</TooltipTrigger>
|
||
|
|
{sidebarCollapsed && (
|
||
|
|
<TooltipContent side="right">
|
||
|
|
{t("vault.nav.knownHosts")}
|
||
|
|
</TooltipContent>
|
||
|
|
)}
|
||
|
|
</Tooltip>
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<RippleButton
|
||
|
|
variant={currentSection === "logs" ? "secondary" : "ghost"}
|
||
|
|
className={cn(
|
||
|
|
"w-full h-10",
|
||
|
|
sidebarCollapsed
|
||
|
|
? "justify-center p-0"
|
||
|
|
: "justify-start gap-3",
|
||
|
|
currentSection === "logs" &&
|
||
|
|
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||
|
|
)}
|
||
|
|
onClick={() => setCurrentSection("logs")}
|
||
|
|
>
|
||
|
|
<Activity size={16} className="flex-shrink-0" />
|
||
|
|
{!sidebarCollapsed && t("vault.nav.logs")}
|
||
|
|
</RippleButton>
|
||
|
|
</TooltipTrigger>
|
||
|
|
{sidebarCollapsed && (
|
||
|
|
<TooltipContent side="right">
|
||
|
|
{t("vault.nav.logs")}
|
||
|
|
</TooltipContent>
|
||
|
|
)}
|
||
|
|
</Tooltip>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"mt-auto pb-4 space-y-2",
|
||
|
|
sidebarCollapsed ? "px-1.5" : "px-2.5",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
className={cn(
|
||
|
|
"w-full",
|
||
|
|
sidebarCollapsed
|
||
|
|
? "justify-center p-0"
|
||
|
|
: "justify-start gap-3",
|
||
|
|
)}
|
||
|
|
onClick={onOpenSettings}
|
||
|
|
>
|
||
|
|
<Settings size={16} className="flex-shrink-0" />
|
||
|
|
{!sidebarCollapsed && t("common.settings")}
|
||
|
|
</Button>
|
||
|
|
</TooltipTrigger>
|
||
|
|
{sidebarCollapsed && (
|
||
|
|
<TooltipContent side="right">
|
||
|
|
{t("common.settings")}
|
||
|
|
</TooltipContent>
|
||
|
|
)}
|
||
|
|
</Tooltip>
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
role="separator"
|
||
|
|
aria-orientation="vertical"
|
||
|
|
aria-label={t("vault.sidebar.resize")}
|
||
|
|
className={cn(
|
||
|
|
"app-no-drag absolute right-0 top-0 z-20 h-full w-2 translate-x-1/2 cursor-col-resize",
|
||
|
|
"after:absolute after:right-1/2 after:top-2 after:h-[calc(100%-16px)] after:w-px after:translate-x-1/2 after:bg-border/0 after:transition-colors",
|
||
|
|
"hover:after:bg-border/70",
|
||
|
|
isSidebarResizing && "after:bg-primary/70",
|
||
|
|
)}
|
||
|
|
onPointerDown={handleSidebarResizeStart}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</TooltipProvider>
|
||
|
|
|
||
|
|
<div
|
||
|
|
className="flex min-w-0 flex-1 py-0 pr-2 pb-2 pl-0"
|
||
|
|
data-section="vault-stage"
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm"
|
||
|
|
data-section="vault-surface"
|
||
|
|
>
|
||
|
|
{/* Main Area */}
|
||
|
|
<div
|
||
|
|
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
|
||
|
|
data-section="vault-main"
|
||
|
|
>
|
||
|
|
<VaultPageHeader
|
||
|
|
className={cn(!isHostsSectionActive && "hidden")}
|
||
|
|
dataSection="vault-hosts-header"
|
||
|
|
>
|
||
|
|
<VaultHeaderSearch
|
||
|
|
placeholder={t("vault.hosts.search.placeholder")}
|
||
|
|
className="flex-1"
|
||
|
|
inputClassName={cn(
|
||
|
|
isSearchQuickConnect &&
|
||
|
|
"border-primary/50 ring-1 ring-primary/20",
|
||
|
|
)}
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
onKeyDown={handleSearchKeyDown}
|
||
|
|
rightAdornment={
|
||
|
|
isSearchQuickConnect ? (
|
||
|
|
<Zap size={14} className="text-primary" />
|
||
|
|
) : null
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
variant={isSearchQuickConnect ? "default" : "secondary"}
|
||
|
|
className={cn(
|
||
|
|
"h-10 px-4",
|
||
|
|
!isSearchQuickConnect && vaultHeaderSecondaryButtonClass,
|
||
|
|
)}
|
||
|
|
onClick={handleConnectClick}
|
||
|
|
>
|
||
|
|
{t("vault.hosts.connect")}
|
||
|
|
</Button>
|
||
|
|
{/* View mode, tag filter, and sort controls */}
|
||
|
|
<div className="flex items-center gap-1 app-no-drag">
|
||
|
|
<Dropdown>
|
||
|
|
<DropdownTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className={vaultHeaderIconButtonClass}
|
||
|
|
>
|
||
|
|
{viewMode === "grid" ? (
|
||
|
|
<LayoutGrid size={16} />
|
||
|
|
) : viewMode === "list" ? (
|
||
|
|
<List size={16} />
|
||
|
|
) : (
|
||
|
|
<Network size={16} />
|
||
|
|
)}
|
||
|
|
<ChevronDown size={10} className="ml-0.5" />
|
||
|
|
</Button>
|
||
|
|
</DropdownTrigger>
|
||
|
|
<DropdownContent className="w-32" align="end">
|
||
|
|
<Button
|
||
|
|
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||
|
|
className="w-full justify-start gap-2 h-9"
|
||
|
|
onClick={() => setViewMode("grid")}
|
||
|
|
>
|
||
|
|
<LayoutGrid size={14} /> {t("vault.view.grid")}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||
|
|
className="w-full justify-start gap-2 h-9"
|
||
|
|
onClick={() => setViewMode("list")}
|
||
|
|
>
|
||
|
|
<List size={14} /> {t("vault.view.list")}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant={viewMode === "tree" ? "secondary" : "ghost"}
|
||
|
|
className="w-full justify-start gap-2 h-9"
|
||
|
|
onClick={() => setViewMode("tree")}
|
||
|
|
>
|
||
|
|
<Network size={14} /> {t("vault.view.tree")}
|
||
|
|
</Button>
|
||
|
|
</DropdownContent>
|
||
|
|
</Dropdown>
|
||
|
|
<TagFilterDropdown
|
||
|
|
allTags={allTags}
|
||
|
|
selectedTags={selectedTags}
|
||
|
|
onChange={setSelectedTags}
|
||
|
|
onEditTag={handleEditTag}
|
||
|
|
onDeleteTag={handleDeleteTag}
|
||
|
|
className={vaultHeaderIconButtonClass}
|
||
|
|
/>
|
||
|
|
<SortDropdown
|
||
|
|
value={sortMode}
|
||
|
|
onChange={setSortMode}
|
||
|
|
className={vaultHeaderIconButtonClass}
|
||
|
|
/>
|
||
|
|
<Tooltip>
|
||
|
|
<TooltipTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant={isMultiSelectMode ? "secondary" : "ghost"}
|
||
|
|
size="icon"
|
||
|
|
className={vaultHeaderIconButtonClass}
|
||
|
|
onClick={() => {
|
||
|
|
if (isMultiSelectMode) {
|
||
|
|
clearHostSelection();
|
||
|
|
} else {
|
||
|
|
setIsMultiSelectMode(true);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<CheckSquare size={16} />
|
||
|
|
</Button>
|
||
|
|
</TooltipTrigger>
|
||
|
|
<TooltipContent>
|
||
|
|
{t("vault.hosts.multiSelect")}
|
||
|
|
</TooltipContent>
|
||
|
|
</Tooltip>
|
||
|
|
</div>
|
||
|
|
{/* New Host split button — collapses with an animation when the
|
||
|
|
host details / new-host aside panel is open, since the button
|
||
|
|
would be a no-op in that state. */}
|
||
|
|
<div
|
||
|
|
ref={newHostActionsRef}
|
||
|
|
className={cn(
|
||
|
|
"flex items-center app-no-drag overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
|
||
|
|
isHostPanelOpen
|
||
|
|
? "max-w-0 opacity-0 -ml-2 pointer-events-none"
|
||
|
|
: "max-w-[260px] opacity-100",
|
||
|
|
)}
|
||
|
|
aria-hidden={isHostPanelOpen ? true : undefined}
|
||
|
|
inert={isHostPanelOpen ? true : undefined}
|
||
|
|
>
|
||
|
|
<Dropdown>
|
||
|
|
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
||
|
|
onClick={handleNewHost}
|
||
|
|
tabIndex={isHostPanelOpen ? -1 : 0}
|
||
|
|
>
|
||
|
|
<Plus size={14} className="mr-2" />{" "}
|
||
|
|
{t("vault.hosts.newHost")}
|
||
|
|
</Button>
|
||
|
|
<DropdownTrigger asChild>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
|
||
|
|
tabIndex={isHostPanelOpen ? -1 : 0}
|
||
|
|
>
|
||
|
|
<ChevronDown size={14} />
|
||
|
|
</Button>
|
||
|
|
</DropdownTrigger>
|
||
|
|
</div>
|
||
|
|
<DropdownContent className="w-44" align="end" alignToParent>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
className="w-full justify-start gap-2"
|
||
|
|
onClick={() => {
|
||
|
|
setTargetParentPath(selectedGroupPath);
|
||
|
|
setNewFolderName("");
|
||
|
|
setIsNewFolderOpen(true);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
className="w-full justify-start gap-2"
|
||
|
|
disabled={importProgress?.status === "running"}
|
||
|
|
onClick={() => {
|
||
|
|
if (importProgress?.status === "running") return;
|
||
|
|
setIsImportOpen(true);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Upload size={14} /> {t("vault.hosts.import")}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
className="w-full justify-start gap-2"
|
||
|
|
onClick={handleExportHosts}
|
||
|
|
>
|
||
|
|
<Download size={14} /> {t("vault.hosts.export")}
|
||
|
|
</Button>
|
||
|
|
</DropdownContent>
|
||
|
|
</Dropdown>
|
||
|
|
</div>
|
||
|
|
{/* Terminal + Serial — collapse together with an animation when
|
||
|
|
the host details / new-host aside panel is open, freeing
|
||
|
|
horizontal space for the panel. */}
|
||
|
|
<div
|
||
|
|
ref={sessionActionsRef}
|
||
|
|
className={cn(
|
||
|
|
"flex items-center gap-3 overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
|
||
|
|
isHostPanelOpen
|
||
|
|
? "max-w-0 opacity-0 -ml-3 pointer-events-none"
|
||
|
|
: "max-w-[320px] opacity-100",
|
||
|
|
)}
|
||
|
|
aria-hidden={isHostPanelOpen ? true : undefined}
|
||
|
|
inert={isHostPanelOpen ? true : undefined}
|
||
|
|
>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="secondary"
|
||
|
|
className={vaultHeaderSecondaryButtonClass}
|
||
|
|
onClick={onCreateLocalTerminal}
|
||
|
|
tabIndex={isHostPanelOpen ? -1 : 0}
|
||
|
|
>
|
||
|
|
<TerminalSquare size={14} className="mr-2" />{" "}
|
||
|
|
{t("common.terminal")}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="secondary"
|
||
|
|
className={vaultHeaderSecondaryButtonClass}
|
||
|
|
onClick={() => setIsSerialModalOpen(true)}
|
||
|
|
tabIndex={isHostPanelOpen ? -1 : 0}
|
||
|
|
>
|
||
|
|
<Usb size={14} className="mr-2" /> {t("serial.button")}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</VaultPageHeader>
|
||
|
|
|
||
|
|
{isMultiSelectMode && isHostsSectionActive && (
|
||
|
|
<div className="px-4 py-1.5 bg-background border-b border-border/40 flex items-center gap-2">
|
||
|
|
<span className="flex h-7 items-center text-xs leading-4 text-muted-foreground">
|
||
|
|
{t("vault.hosts.selectedSummary", {
|
||
|
|
hosts: selectedHostIds.size,
|
||
|
|
groups: selectedGroupPaths.size,
|
||
|
|
})}
|
||
|
|
</span>
|
||
|
|
<div className="flex-1" />
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
className="h-7 px-2 text-xs"
|
||
|
|
onClick={() => {
|
||
|
|
const allIds = new Set(
|
||
|
|
collectVisibleVaultHostIds({
|
||
|
|
viewMode,
|
||
|
|
displayedHosts,
|
||
|
|
treeHosts: treeViewHosts,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
setSelectedHostIds(allIds);
|
||
|
|
setSelectedGroupPaths(
|
||
|
|
new Set(
|
||
|
|
collectVaultGroupPathsForSelectAll({
|
||
|
|
hasActiveFilters: hasActiveHostFilters,
|
||
|
|
viewMode,
|
||
|
|
displayedGroupPaths: displayedGroups.map((group) => group.path),
|
||
|
|
visibleTreeGroupPaths,
|
||
|
|
}),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{t("vault.hosts.selectAll")}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="sm"
|
||
|
|
className="h-7 px-2 text-xs"
|
||
|
|
onClick={clearHostSelection}
|
||
|
|
>
|
||
|
|
{t("vault.hosts.deselectAll")}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="default"
|
||
|
|
size="sm"
|
||
|
|
className="h-7 px-2 text-xs"
|
||
|
|
disabled={selectedHostIds.size === 0}
|
||
|
|
onClick={connectSelectedHosts}
|
||
|
|
>
|
||
|
|
<Plug size={12} className="mr-1" />
|
||
|
|
{t("vault.hosts.connectSelected", {
|
||
|
|
count: selectedHostIds.size,
|
||
|
|
})}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="destructive"
|
||
|
|
size="sm"
|
||
|
|
className="h-7 px-2 text-xs"
|
||
|
|
disabled={
|
||
|
|
selectedHostIds.size === 0 && selectedGroupPaths.size === 0
|
||
|
|
}
|
||
|
|
onClick={() => {
|
||
|
|
if (selectedGroupPaths.size === 0) {
|
||
|
|
deleteSelectedHosts();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
setDeleteTargetPath(null);
|
||
|
|
setBulkDeleteGroupPaths(Array.from(selectedGroupPaths));
|
||
|
|
setIsDeleteGroupOpen(true);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<Trash2 size={12} className="mr-1" />
|
||
|
|
{t("vault.hosts.deleteSelected", {
|
||
|
|
count: selectedHostIds.size + selectedGroupPaths.size,
|
||
|
|
})}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-7 w-7"
|
||
|
|
onClick={clearHostSelection}
|
||
|
|
>
|
||
|
|
<X size={12} />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Keep hosts mounted so switching sections does not reset scroll or remount the list. */}
|
||
|
|
|
||
|
|
<VaultHostListSection
|
||
|
|
ctx={{
|
||
|
|
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,
|
||
|
|
handleUnmanageGroup,
|
||
|
|
hasHostsSidePanel,
|
||
|
|
hostListScrollRef,
|
||
|
|
HostTreeView,
|
||
|
|
isHostsSectionActive,
|
||
|
|
isMultiSelectMode,
|
||
|
|
lastPinnedId,
|
||
|
|
LayoutGrid,
|
||
|
|
managedGroupPaths,
|
||
|
|
moveGroup,
|
||
|
|
moveHostToGroup,
|
||
|
|
onDeleteHost,
|
||
|
|
Pin,
|
||
|
|
pinnedHosts,
|
||
|
|
pinnedRecentIds,
|
||
|
|
Plug,
|
||
|
|
recentHosts,
|
||
|
|
reorderGroup,
|
||
|
|
reorderHost,
|
||
|
|
sanitizeHost,
|
||
|
|
search,
|
||
|
|
selectedGroupPath,
|
||
|
|
selectedGroupPaths,
|
||
|
|
selectedHostIds,
|
||
|
|
selectedTags,
|
||
|
|
sessionCount,
|
||
|
|
setDeleteTargetPath,
|
||
|
|
setDragOverDropTarget,
|
||
|
|
setGroupDragOverDropTarget,
|
||
|
|
setIsDeleteGroupOpen,
|
||
|
|
setIsNewFolderOpen,
|
||
|
|
setLastPinnedId,
|
||
|
|
setNewFolderName,
|
||
|
|
setSelectedGroupPath,
|
||
|
|
setTargetParentPath,
|
||
|
|
shouldHideEmptyRootHostsSection,
|
||
|
|
showRecentHosts,
|
||
|
|
hostClickBehavior,
|
||
|
|
sortMode,
|
||
|
|
splitViewGridStyle,
|
||
|
|
Square,
|
||
|
|
Star,
|
||
|
|
startInlineDeleteGroup,
|
||
|
|
startInlineNewGroup,
|
||
|
|
startInlineRenameGroup,
|
||
|
|
t,
|
||
|
|
toggleGroupSelection,
|
||
|
|
toggleHostPinned,
|
||
|
|
toggleHostSelection,
|
||
|
|
Trash2,
|
||
|
|
treeExpandedState,
|
||
|
|
treeViewGroupTree,
|
||
|
|
treeViewHosts,
|
||
|
|
viewMode,
|
||
|
|
visibleDisplayedHosts,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{currentSection === "snippets" && (
|
||
|
|
<LazyLoadBoundary name="Snippets" resetKey="snippets">
|
||
|
|
<Suspense fallback={<VaultSectionLoading />}>
|
||
|
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||
|
|
<SnippetsManager
|
||
|
|
snippets={snippets}
|
||
|
|
packages={snippetPackages}
|
||
|
|
hosts={hosts}
|
||
|
|
customGroups={customGroups}
|
||
|
|
hotkeyScheme={hotkeyScheme}
|
||
|
|
keyBindings={keyBindings}
|
||
|
|
onPackagesChange={onUpdateSnippetPackages}
|
||
|
|
onSave={(s) =>
|
||
|
|
onUpdateSnippets(
|
||
|
|
snippets.find((ex) => ex.id === s.id)
|
||
|
|
? snippets.map((ex) => (ex.id === s.id ? s : ex))
|
||
|
|
: [...snippets, s],
|
||
|
|
)
|
||
|
|
}
|
||
|
|
onBulkSave={onUpdateSnippets}
|
||
|
|
onDelete={(id) =>
|
||
|
|
onUpdateSnippets(snippets.filter((s) => s.id !== id))
|
||
|
|
}
|
||
|
|
onRunSnippet={onRunSnippet}
|
||
|
|
availableKeys={keys}
|
||
|
|
proxyProfiles={proxyProfiles}
|
||
|
|
managedSources={managedSources}
|
||
|
|
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
|
||
|
|
onUpdateHosts={onUpdateHosts}
|
||
|
|
onCreateGroup={(groupPath) =>
|
||
|
|
onUpdateCustomGroups(
|
||
|
|
Array.from(new Set([...customGroups, groupPath])),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
openSnippetId={openSnippetId ?? null}
|
||
|
|
onOpenSnippetIdHandled={onOpenSnippetIdHandled}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</Suspense>
|
||
|
|
</LazyLoadBoundary>
|
||
|
|
)}
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"min-h-0 flex-1",
|
||
|
|
currentSection !== "notes" && "hidden",
|
||
|
|
)}
|
||
|
|
data-section="vault-notes-retained"
|
||
|
|
>
|
||
|
|
<MemoVaultNotesSection
|
||
|
|
NotesManager={NotesManager}
|
||
|
|
hosts={hosts}
|
||
|
|
isActive={currentSection === "notes"}
|
||
|
|
openNoteId={openNoteId ?? null}
|
||
|
|
onOpenNoteIdHandled={onOpenNoteIdHandled}
|
||
|
|
onOpenHost={handleNotesOpenHost}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
{currentSection === "keys" && (
|
||
|
|
<LazyLoadBoundary name="Keychain" resetKey="keys">
|
||
|
|
<Suspense fallback={<VaultSectionLoading />}>
|
||
|
|
<KeychainManager
|
||
|
|
keys={keys}
|
||
|
|
identities={identities}
|
||
|
|
hosts={hosts}
|
||
|
|
proxyProfiles={proxyProfiles}
|
||
|
|
customGroups={customGroups}
|
||
|
|
groupConfigs={groupConfigs}
|
||
|
|
managedSources={managedSources}
|
||
|
|
onSave={(k) => onUpdateKeys([...keys, k])}
|
||
|
|
onUpdate={(k) =>
|
||
|
|
onUpdateKeys(
|
||
|
|
keys.map((existing) =>
|
||
|
|
existing.id === k.id ? k : existing,
|
||
|
|
),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
onReorderKeys={onUpdateKeys}
|
||
|
|
onDelete={handleDeleteVaultKey}
|
||
|
|
onSaveIdentity={(identity) =>
|
||
|
|
onUpdateIdentities(
|
||
|
|
identities.find((ex) => ex.id === identity.id)
|
||
|
|
? identities.map((ex) =>
|
||
|
|
ex.id === identity.id ? identity : ex,
|
||
|
|
)
|
||
|
|
: [...identities, identity],
|
||
|
|
)
|
||
|
|
}
|
||
|
|
onDeleteIdentity={(id) =>
|
||
|
|
onUpdateIdentities(identities.filter((i) => i.id !== id))
|
||
|
|
}
|
||
|
|
onReorderIdentities={onUpdateIdentities}
|
||
|
|
onSaveHost={(host) => {
|
||
|
|
// Update existing host or add new one
|
||
|
|
const existingIndex = hosts.findIndex(
|
||
|
|
(h) => h.id === host.id,
|
||
|
|
);
|
||
|
|
if (existingIndex >= 0) {
|
||
|
|
onUpdateHosts(
|
||
|
|
hosts.map((h) => (h.id === host.id ? host : h)),
|
||
|
|
);
|
||
|
|
} else {
|
||
|
|
onUpdateHosts([...hosts, host]);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
onCreateGroup={(groupPath) =>
|
||
|
|
onUpdateCustomGroups(
|
||
|
|
Array.from(new Set([...customGroups, groupPath])),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</Suspense>
|
||
|
|
</LazyLoadBoundary>
|
||
|
|
)}
|
||
|
|
{currentSection === "proxies" && (
|
||
|
|
<LazyLoadBoundary name="Proxy profiles" resetKey="proxies">
|
||
|
|
<Suspense fallback={<VaultSectionLoading />}>
|
||
|
|
<ProxyProfilesManager
|
||
|
|
proxyProfiles={proxyProfiles}
|
||
|
|
hosts={hosts}
|
||
|
|
groupConfigs={groupConfigs}
|
||
|
|
identities={identities}
|
||
|
|
onUpdateProxyProfiles={onUpdateProxyProfiles}
|
||
|
|
onUpdateHosts={onUpdateHosts}
|
||
|
|
onUpdateGroupConfigs={onUpdateGroupConfigs}
|
||
|
|
/>
|
||
|
|
</Suspense>
|
||
|
|
</LazyLoadBoundary>
|
||
|
|
)}
|
||
|
|
{currentSection === "port" && (
|
||
|
|
<LazyLoadBoundary
|
||
|
|
name="Port forwarding"
|
||
|
|
resetKey="port-forwarding"
|
||
|
|
>
|
||
|
|
<Suspense fallback={<VaultSectionLoading />}>
|
||
|
|
<PortForwarding
|
||
|
|
hosts={hosts}
|
||
|
|
keys={keys}
|
||
|
|
identities={identities}
|
||
|
|
knownHosts={knownHosts}
|
||
|
|
proxyProfiles={proxyProfiles}
|
||
|
|
customGroups={customGroups}
|
||
|
|
managedSources={managedSources}
|
||
|
|
groupConfigs={groupConfigs}
|
||
|
|
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
|
||
|
|
onCreateGroup={(groupPath) =>
|
||
|
|
onUpdateCustomGroups(
|
||
|
|
Array.from(new Set([...customGroups, groupPath])),
|
||
|
|
)
|
||
|
|
}
|
||
|
|
terminalSettings={terminalSettings}
|
||
|
|
/>
|
||
|
|
</Suspense>
|
||
|
|
</LazyLoadBoundary>
|
||
|
|
)}
|
||
|
|
{/* Always render KnownHostsManager but hide with CSS to prevent unmounting */}
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
display: currentSection === "knownhosts" ? "contents" : "none",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{knownHostsManagerElement}
|
||
|
|
</div>
|
||
|
|
{/* Connection Logs */}
|
||
|
|
{currentSection === "logs" && (
|
||
|
|
<LazyLoadBoundary
|
||
|
|
name="Connection logs"
|
||
|
|
resetKey="connection-logs"
|
||
|
|
>
|
||
|
|
<Suspense fallback={<VaultSectionLoading />}>
|
||
|
|
<VaultConnectionLogsSection
|
||
|
|
ConnectionLogsManager={LazyConnectionLogsManager}
|
||
|
|
hosts={hosts}
|
||
|
|
onOpenLogView={onOpenLogView}
|
||
|
|
/>
|
||
|
|
</Suspense>
|
||
|
|
</LazyLoadBoundary>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Group Details Panel */}
|
||
|
|
{currentSection === "hosts" &&
|
||
|
|
isGroupPanelOpen &&
|
||
|
|
editingGroupPath && (
|
||
|
|
<GroupDetailsPanel
|
||
|
|
key={editingGroupPath}
|
||
|
|
groupPath={editingGroupPath}
|
||
|
|
config={groupConfigs.find((c) => c.path === editingGroupPath)}
|
||
|
|
availableKeys={keys}
|
||
|
|
identities={identities}
|
||
|
|
proxyProfiles={proxyProfiles}
|
||
|
|
allHosts={hosts}
|
||
|
|
groups={allGroupPaths}
|
||
|
|
terminalThemeId={terminalThemeId}
|
||
|
|
groupConfigs={groupConfigs}
|
||
|
|
terminalFontSize={terminalFontSize}
|
||
|
|
onSave={handleSaveGroupConfig}
|
||
|
|
onCancel={() => {
|
||
|
|
setIsGroupPanelOpen(false);
|
||
|
|
setEditingGroupPath(null);
|
||
|
|
}}
|
||
|
|
layout="inline"
|
||
|
|
{...vaultHostPanelResizeProps}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Host Details Panel */}
|
||
|
|
{currentSection === "hosts" &&
|
||
|
|
isHostPanelOpen &&
|
||
|
|
editingHost?.protocol !== "serial" && (
|
||
|
|
<HostDetailsPanel
|
||
|
|
initialData={editingHost}
|
||
|
|
availableKeys={keys}
|
||
|
|
identities={identities}
|
||
|
|
proxyProfiles={proxyProfiles}
|
||
|
|
groups={allGroupPaths}
|
||
|
|
managedSources={managedSources}
|
||
|
|
allTags={allTags}
|
||
|
|
allHosts={hosts}
|
||
|
|
defaultGroup={
|
||
|
|
editingHost
|
||
|
|
? undefined
|
||
|
|
: newHostGroupPath || selectedGroupPath
|
||
|
|
}
|
||
|
|
terminalThemeId={terminalThemeId}
|
||
|
|
terminalFontSize={terminalFontSize}
|
||
|
|
groupDefaults={editingHostGroupDefaults}
|
||
|
|
groupConfigs={groupConfigs}
|
||
|
|
snippets={snippets}
|
||
|
|
onSnippetsChange={onUpdateSnippets}
|
||
|
|
onHostsChange={onUpdateHosts}
|
||
|
|
onImportKey={onImportOrReuseKey}
|
||
|
|
onSave={(host) => {
|
||
|
|
const latestHost = hosts.find(
|
||
|
|
(entry: { id: string }) => entry.id === host.id,
|
||
|
|
);
|
||
|
|
const nextHost = preserveConcurrentHostLineTimestampUpdate({
|
||
|
|
draft: host,
|
||
|
|
openedHost: editingHost,
|
||
|
|
latestHost,
|
||
|
|
});
|
||
|
|
onUpdateHosts((prevHosts) => upsertHostById(prevHosts, nextHost));
|
||
|
|
setIsHostPanelOpen(false);
|
||
|
|
setEditingHost(null);
|
||
|
|
setNewHostGroupPath(null);
|
||
|
|
}}
|
||
|
|
onCancel={() => {
|
||
|
|
setIsHostPanelOpen(false);
|
||
|
|
setEditingHost(null);
|
||
|
|
setNewHostGroupPath(null);
|
||
|
|
}}
|
||
|
|
onCreateGroup={(groupPath) => {
|
||
|
|
onUpdateCustomGroups(
|
||
|
|
Array.from(new Set([...customGroups, groupPath])),
|
||
|
|
);
|
||
|
|
}}
|
||
|
|
layout="inline"
|
||
|
|
{...vaultHostPanelResizeProps}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Serial Host Details Panel - for editing serial port hosts */}
|
||
|
|
{currentSection === "hosts" &&
|
||
|
|
isHostPanelOpen &&
|
||
|
|
editingHost?.protocol === "serial" && (
|
||
|
|
<SerialHostDetailsPanel
|
||
|
|
initialData={editingHost}
|
||
|
|
allTags={allTags}
|
||
|
|
groups={allGroupPaths}
|
||
|
|
groupDefaults={editingHostGroupDefaults}
|
||
|
|
onSave={(host) => {
|
||
|
|
onUpdateHosts(upsertHostById(hosts, host));
|
||
|
|
setIsHostPanelOpen(false);
|
||
|
|
setEditingHost(null);
|
||
|
|
setNewHostGroupPath(null);
|
||
|
|
}}
|
||
|
|
onCancel={() => {
|
||
|
|
setIsHostPanelOpen(false);
|
||
|
|
setEditingHost(null);
|
||
|
|
setNewHostGroupPath(null);
|
||
|
|
}}
|
||
|
|
layout="inline"
|
||
|
|
{...vaultHostPanelResizeProps}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Dialog
|
||
|
|
open={isNewFolderOpen}
|
||
|
|
onOpenChange={(open) => {
|
||
|
|
setIsNewFolderOpen(open);
|
||
|
|
if (!open) {
|
||
|
|
setNewFolderName("");
|
||
|
|
setTargetParentPath(null);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>
|
||
|
|
{targetParentPath
|
||
|
|
? t("vault.groups.createSubfolder")
|
||
|
|
: t("vault.groups.createRoot")}
|
||
|
|
</DialogTitle>
|
||
|
|
<DialogDescription className="sr-only">
|
||
|
|
{t("vault.groups.createDialog.desc")}
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="py-4">
|
||
|
|
<Label>{t("vault.groups.field.name")}</Label>
|
||
|
|
<Input
|
||
|
|
value={newFolderName}
|
||
|
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||
|
|
placeholder={t("vault.groups.placeholder.example")}
|
||
|
|
autoFocus
|
||
|
|
onKeyDown={(e) => e.key === "Enter" && submitNewFolder()}
|
||
|
|
/>
|
||
|
|
{targetParentPath && (
|
||
|
|
<p className="text-xs text-muted-foreground mt-2">
|
||
|
|
{t("vault.groups.parentLabel")}:{" "}
|
||
|
|
<span className="font-mono">{targetParentPath}</span>
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="ghost" onClick={() => setIsNewFolderOpen(false)}>
|
||
|
|
{t("common.cancel")}
|
||
|
|
</Button>
|
||
|
|
<Button onClick={submitNewFolder}>{t("common.create")}</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
<Dialog
|
||
|
|
open={isRenameGroupOpen}
|
||
|
|
onOpenChange={(open) => {
|
||
|
|
setIsRenameGroupOpen(open);
|
||
|
|
if (!open) {
|
||
|
|
setRenameTargetPath(null);
|
||
|
|
setRenameGroupName("");
|
||
|
|
setRenameGroupError(null);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<DialogContent>
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>{t("vault.groups.renameDialogTitle")}</DialogTitle>
|
||
|
|
<DialogDescription className="sr-only">
|
||
|
|
{t("vault.groups.renameDialog.desc")}
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="py-4 space-y-2">
|
||
|
|
<Label>{t("vault.groups.field.name")}</Label>
|
||
|
|
<Input
|
||
|
|
value={renameGroupName}
|
||
|
|
onChange={(e) => {
|
||
|
|
setRenameGroupName(e.target.value);
|
||
|
|
setRenameGroupError(null);
|
||
|
|
}}
|
||
|
|
placeholder={t("vault.groups.placeholder.example")}
|
||
|
|
autoFocus
|
||
|
|
onKeyDown={(e) => e.key === "Enter" && submitRenameGroup()}
|
||
|
|
/>
|
||
|
|
{renameTargetPath && (
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
{t("vault.groups.pathLabel")}:{" "}
|
||
|
|
<span className="font-mono">{renameTargetPath}</span>
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{renameGroupError && (
|
||
|
|
<p className="text-xs text-destructive">{renameGroupError}</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="ghost" onClick={() => setIsRenameGroupOpen(false)}>
|
||
|
|
{t("common.cancel")}
|
||
|
|
</Button>
|
||
|
|
<Button onClick={submitRenameGroup}>{t("common.rename")}</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
<Dialog
|
||
|
|
open={isDeleteGroupOpen}
|
||
|
|
onOpenChange={(open) => {
|
||
|
|
setIsDeleteGroupOpen(open);
|
||
|
|
if (!open) {
|
||
|
|
setDeleteTargetPath(null);
|
||
|
|
setBulkDeleteGroupPaths([]);
|
||
|
|
setDeleteGroupWithHosts(false);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<DialogContent className="max-w-[calc(100vw-2rem)] overflow-hidden sm:max-w-lg">
|
||
|
|
<DialogHeader className="min-w-0 pr-6">
|
||
|
|
<DialogTitle className="truncate">
|
||
|
|
{t(
|
||
|
|
pendingDeleteGroupPaths.length > 1
|
||
|
|
? "vault.groups.deleteDialog.bulkTitle"
|
||
|
|
: "vault.groups.deleteDialogTitle",
|
||
|
|
)}
|
||
|
|
</DialogTitle>
|
||
|
|
<DialogDescription className="break-words [overflow-wrap:anywhere]">
|
||
|
|
{pendingDeleteHasManagedGroups
|
||
|
|
? t("vault.groups.deleteDialog.managedDesc")
|
||
|
|
: t(
|
||
|
|
pendingDeleteGroupPaths.length > 1
|
||
|
|
? "vault.groups.deleteDialog.bulkDesc"
|
||
|
|
: "vault.groups.deleteDialog.desc",
|
||
|
|
)}
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
<div className="min-w-0 space-y-4 py-4">
|
||
|
|
{pendingDeleteGroupPaths.length > 0 && (
|
||
|
|
<>
|
||
|
|
<p className="min-w-0 break-words text-sm text-muted-foreground [overflow-wrap:anywhere]">
|
||
|
|
{pendingDeleteGroupPaths.length === 1 ? (
|
||
|
|
<>
|
||
|
|
{t("vault.groups.pathLabel")}:{" "}
|
||
|
|
<span className="font-mono">
|
||
|
|
{pendingDeleteGroupPaths[0]}
|
||
|
|
</span>
|
||
|
|
</>
|
||
|
|
) : (
|
||
|
|
t("vault.groups.selectedCount", {
|
||
|
|
count: pendingDeleteGroupPaths.length,
|
||
|
|
})
|
||
|
|
)}
|
||
|
|
</p>
|
||
|
|
{!pendingDeleteHasManagedGroups && (
|
||
|
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={deleteGroupWithHosts}
|
||
|
|
onChange={(e) =>
|
||
|
|
setDeleteGroupWithHosts(e.target.checked)
|
||
|
|
}
|
||
|
|
className="rounded border-border"
|
||
|
|
/>
|
||
|
|
<span>
|
||
|
|
{t(
|
||
|
|
pendingDeleteGroupPaths.length > 1
|
||
|
|
? "vault.groups.deleteDialog.bulkDeleteHosts"
|
||
|
|
: "vault.groups.deleteDialog.deleteHosts",
|
||
|
|
)}
|
||
|
|
</span>
|
||
|
|
</label>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
<DialogFooter>
|
||
|
|
<Button variant="ghost" onClick={() => setIsDeleteGroupOpen(false)}>
|
||
|
|
{t("common.cancel")}
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="destructive"
|
||
|
|
onClick={async () => {
|
||
|
|
if (pendingDeleteGroupPaths.length > 0) {
|
||
|
|
const isBulkDelete = bulkDeleteGroupPaths.length > 0;
|
||
|
|
try {
|
||
|
|
await deleteGroupPaths(
|
||
|
|
pendingDeleteGroupPaths,
|
||
|
|
deleteGroupWithHosts,
|
||
|
|
isBulkDelete ? selectedHostIds : new Set<string>(),
|
||
|
|
);
|
||
|
|
} catch (error) {
|
||
|
|
toast.error(error instanceof Error ? error.message : t("common.error"));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (isBulkDelete) {
|
||
|
|
const deletedItemCount =
|
||
|
|
selectedHostIds.size + selectedGroupPaths.size;
|
||
|
|
clearHostSelection();
|
||
|
|
toast.success(
|
||
|
|
t("vault.groups.deleteMultiple.success", {
|
||
|
|
count: deletedItemCount,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
setIsDeleteGroupOpen(false);
|
||
|
|
setBulkDeleteGroupPaths([]);
|
||
|
|
setDeleteGroupWithHosts(false);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{t("common.delete")}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
|
||
|
|
<ImportVaultDialog
|
||
|
|
open={isImportOpen}
|
||
|
|
onOpenChange={setIsImportOpen}
|
||
|
|
onFileSelected={handleImportFileSelected}
|
||
|
|
onPluginPreviewCommit={handlePluginPreviewCommit}
|
||
|
|
getPluginPreviewAnalysis={getPluginPreviewAnalysis}
|
||
|
|
groups={allGroupPaths}
|
||
|
|
/>
|
||
|
|
{importProgress && (
|
||
|
|
<VaultImportProgressPanel
|
||
|
|
progress={importProgress}
|
||
|
|
onCancel={cancelImport}
|
||
|
|
onClose={resetImportProgress}
|
||
|
|
t={t}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Quick Connect Wizard */}
|
||
|
|
{isQuickConnectOpen && quickConnectTarget && (
|
||
|
|
<QuickConnectWizard
|
||
|
|
open={isQuickConnectOpen}
|
||
|
|
target={quickConnectTarget}
|
||
|
|
keys={keys}
|
||
|
|
identities={identities}
|
||
|
|
onConnect={handleQuickConnect}
|
||
|
|
onSaveHost={handleQuickConnectSaveHost}
|
||
|
|
onClose={() => {
|
||
|
|
setIsQuickConnectOpen(false);
|
||
|
|
setQuickConnectTarget(null);
|
||
|
|
setQuickConnectWarnings([]);
|
||
|
|
}}
|
||
|
|
warnings={quickConnectWarnings}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Protocol Select Dialog */}
|
||
|
|
{protocolSelectHost && (
|
||
|
|
<LazyLoadBoundary
|
||
|
|
name="Protocol selector"
|
||
|
|
resetKey={protocolSelectHost.id}
|
||
|
|
>
|
||
|
|
<Suspense fallback={null}>
|
||
|
|
<LazyProtocolSelectDialog
|
||
|
|
host={protocolSelectHost}
|
||
|
|
onSelect={handleProtocolSelect}
|
||
|
|
onCancel={() => setProtocolSelectHost(null)}
|
||
|
|
/>
|
||
|
|
</Suspense>
|
||
|
|
</LazyLoadBoundary>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Serial Connect Modal */}
|
||
|
|
<SerialConnectModal
|
||
|
|
open={isSerialModalOpen}
|
||
|
|
onClose={() => setIsSerialModalOpen(false)}
|
||
|
|
onConnect={(config, options) => {
|
||
|
|
if (onConnectSerial) {
|
||
|
|
onConnectSerial(config, options);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
onSaveHost={(host) => {
|
||
|
|
onUpdateHosts([...hosts, host]);
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|