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
1698 lines
54 KiB
TypeScript
1698 lines
54 KiB
TypeScript
import {
|
|
Activity,
|
|
BookMarked,
|
|
CheckSquare,
|
|
ChevronDown,
|
|
ClipboardCopy,
|
|
Clock,
|
|
Copy,
|
|
Download,
|
|
Edit2,
|
|
FileCode,
|
|
FileSymlink,
|
|
FolderPlus,
|
|
FolderTree,
|
|
Globe,
|
|
Key,
|
|
LayoutGrid,
|
|
List,
|
|
Network,
|
|
NotebookText,
|
|
Pin,
|
|
Plug,
|
|
Plus,
|
|
Search,
|
|
Settings,
|
|
Square,
|
|
Star,
|
|
TerminalSquare,
|
|
Trash2,
|
|
Upload,
|
|
Usb,
|
|
X,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import React, {
|
|
Suspense,
|
|
lazy,
|
|
memo,
|
|
startTransition,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
import { useI18n } from "../application/i18n/I18nProvider";
|
|
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
|
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
|
import { useStoredNumber } from "../application/state/useStoredNumber";
|
|
import { useStoredString } from "../application/state/useStoredString";
|
|
import type { VaultLockHandle } from "../application/state/vaultManagedImportLock";
|
|
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
|
import { useVaultGroupDeletion } from "../application/state/useVaultGroupDeletion";
|
|
import type { VaultHostPersistenceResult } from "../application/state/vaultImportProgress";
|
|
import type {
|
|
VaultGroupMutationResult,
|
|
VaultGroupMutationState,
|
|
} from "../domain/vaultGroupMutation";
|
|
import type { PluginImporterCommitRequest } from "../application/state/usePluginImporterCommit";
|
|
import { buildVaultCsvCredentialOptions } from "../application/vaultCsvExportCredentials";
|
|
import { sanitizeCredentialValue } from "../domain/credentials";
|
|
import {
|
|
resolveGroupDefaults,
|
|
applyGroupDefaults,
|
|
} from "../domain/groupConfig";
|
|
import {
|
|
getEffectiveHostDistro,
|
|
getHostAddressForClipboard,
|
|
resolveTelnetPassword,
|
|
resolveTelnetPort,
|
|
resolveTelnetUsername,
|
|
sanitizeHost,
|
|
upsertHostById,
|
|
} from "../domain/host";
|
|
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
|
|
import {
|
|
remapSnippetTargetGroupPaths,
|
|
} from "../domain/hostGroupPathMutations";
|
|
import {
|
|
reorderVaultItems,
|
|
reorderVaultStrings,
|
|
type VaultOrderPosition,
|
|
} from "../domain/vaultOrder";
|
|
import {
|
|
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
|
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
|
|
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
|
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
|
STORAGE_KEY_VAULT_SIDEBAR_WIDTH,
|
|
} from "../infrastructure/config/storageKeys";
|
|
import { cn } from "../lib/utils";
|
|
import {
|
|
ConnectionLog,
|
|
GroupConfig,
|
|
Host,
|
|
HostProtocol,
|
|
Identity,
|
|
KnownHost,
|
|
ManagedSource,
|
|
ProxyProfile,
|
|
SerialConfig,
|
|
SSHKey,
|
|
Snippet,
|
|
} from "../types";
|
|
import { AppLogo } from "./AppLogo";
|
|
import { connectHostsStaggered } from "./connectHostsStaggered";
|
|
import { DistroAvatar } from "./DistroAvatar";
|
|
import GroupDetailsPanel from "./GroupDetailsPanel";
|
|
import { getVaultHostGridColumnCount } from "./vault/vaultHostGridLayout";
|
|
import HostDetailsPanel from "./HostDetailsPanel";
|
|
import { HostTreeView } from "./HostTreeView";
|
|
import QuickConnectWizard from "./QuickConnectWizard";
|
|
import {
|
|
isQuickConnectInput,
|
|
parseQuickConnectInputWithWarnings,
|
|
} from "../domain/quickConnect";
|
|
import SerialConnectModal from "./SerialConnectModal";
|
|
import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
|
|
import { NotesManager } from "./notes/NotesManager";
|
|
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
|
|
import { HostTreeGroupDeleteDialog } from "./host/HostTreeGroupDeleteDialog";
|
|
import { useHostTreeInlineGroupActions } from "./vault/useHostTreeInlineGroupActions";
|
|
import { useHostTreeInlineHostActions } from "./vault/useHostTreeInlineHostActions";
|
|
import { useRegisterVaultHostTreeActions } from "./vault/useRegisterVaultHostTreeActions";
|
|
import { Button } from "./ui/button";
|
|
import { RippleButton } from "./ui/ripple";
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
} from "./ui/context-menu";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "./ui/dialog";
|
|
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
|
import { Input } from "./ui/input";
|
|
import { Label } from "./ui/label";
|
|
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
|
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
|
|
import { toast } from "./ui/toast";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
TooltipProvider,
|
|
} from "./ui/tooltip";
|
|
import { Badge } from "./ui/badge";
|
|
import { HotkeyScheme, KeyBinding } from "../domain/models";
|
|
import { VaultViewLayout } from "./vault/VaultViewLayout";
|
|
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
|
|
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
|
|
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
|
|
|
|
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
|
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
|
const KeychainManager = lazy(() => import("./KeychainManager"));
|
|
const PortForwarding = lazy(() => import("./PortForwardingNew"));
|
|
const ProxyProfilesManager = lazy(() => import("./ProxyProfilesManager"));
|
|
const SnippetsManager = lazy(() => import("./SnippetsManager"));
|
|
|
|
export type VaultSection =
|
|
| "hosts"
|
|
| "keys"
|
|
| "proxies"
|
|
| "snippets"
|
|
| "notes"
|
|
| "port"
|
|
| "knownhosts"
|
|
| "logs";
|
|
|
|
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
|
|
if (previous.length !== next.length) return false;
|
|
return next.every((host, index) => {
|
|
const current = previous[index];
|
|
return (
|
|
current?.id === host.id &&
|
|
current.order === host.order &&
|
|
current.group === host.group &&
|
|
current.label === host.label &&
|
|
current.managedSourceId === host.managedSourceId
|
|
);
|
|
});
|
|
};
|
|
|
|
const haveSameGroupConfigs = (previous: GroupConfig[], next: GroupConfig[]) => {
|
|
if (previous.length !== next.length) return false;
|
|
return next.every((config, index) => {
|
|
const current = previous[index];
|
|
return current?.path === config.path && current.order === config.order;
|
|
});
|
|
};
|
|
|
|
const VAULT_SIDEBAR_MIN_WIDTH = 56;
|
|
const VAULT_SIDEBAR_DEFAULT_WIDTH = 208;
|
|
const VAULT_SIDEBAR_MAX_WIDTH = 320;
|
|
const VAULT_SIDEBAR_LABEL_THRESHOLD = 132;
|
|
|
|
const isSortMode = (value: string): value is SortMode =>
|
|
value === "manual" ||
|
|
value === "az" ||
|
|
value === "za" ||
|
|
value === "newest" ||
|
|
value === "oldest" ||
|
|
value === "group";
|
|
|
|
// Props without isActive - it's now subscribed internally
|
|
interface VaultViewProps {
|
|
hosts: Host[];
|
|
keys: SSHKey[];
|
|
identities: Identity[];
|
|
proxyProfiles: ProxyProfile[];
|
|
snippets: Snippet[];
|
|
snippetPackages: string[];
|
|
customGroups: string[];
|
|
knownHosts: KnownHost[];
|
|
managedSources: ManagedSource[];
|
|
sessionCount: number;
|
|
hotkeyScheme: HotkeyScheme;
|
|
keyBindings: KeyBinding[];
|
|
terminalThemeId: string;
|
|
terminalFontSize: number;
|
|
onOpenSettings: () => void;
|
|
onOpenQuickSwitcher: () => void;
|
|
onCreateLocalTerminal: () => void;
|
|
onConnectSerial?: (
|
|
config: SerialConfig,
|
|
options?: { charset?: string },
|
|
) => void;
|
|
onDeleteHost: (id: string) => void;
|
|
onConnect: (host: Host) => void;
|
|
onOpenHostFromNote?: (host: Host, source?: { noteId: string }) => void;
|
|
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
|
onReadPersistedHosts: () => Promise<Host[]>;
|
|
onUpdateKeys: (keys: SSHKey[]) => void;
|
|
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
|
|
onUpdateIdentities: (identities: Identity[]) => void;
|
|
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
|
|
onUpdateSnippets: (snippets: Snippet[]) => void;
|
|
onUpdateSnippetPackages: (pkgs: string[]) => void;
|
|
onUpdateCustomGroups: (
|
|
groups: string[] | ((current: string[]) => string[]),
|
|
) => void;
|
|
onCommitPluginImporterData: (request: PluginImporterCommitRequest) => Promise<number>;
|
|
onUpdateKnownHosts: (knownHosts: KnownHost[]) => void;
|
|
onUpdateManagedSources: (
|
|
managedSources: ManagedSource[] | ((current: ManagedSource[]) => ManagedSource[]),
|
|
) => void;
|
|
onReadPersistedManagedSources: () => ManagedSource[];
|
|
onCommitVaultImportTransaction: (
|
|
hosts: Host[],
|
|
updateGroups: (current: string[]) => string[],
|
|
updateSources: (current: ManagedSource[]) => ManagedSource[],
|
|
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
|
|
expectedHosts?: Host[],
|
|
lock?: VaultLockHandle | null,
|
|
) => Promise<
|
|
| {
|
|
status: "persisted";
|
|
groups: string[];
|
|
sources: ManagedSource[];
|
|
groupConfigs: GroupConfig[];
|
|
}
|
|
| { status: "superseded" }
|
|
>;
|
|
onCommitVaultGroupMutation: (
|
|
mutate: (current: VaultGroupMutationState) => VaultGroupMutationResult,
|
|
lock?: VaultLockHandle | null,
|
|
) => Promise<VaultGroupMutationResult | { ok: false; superseded: true }>;
|
|
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<() => Promise<void>>;
|
|
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<() => Promise<void>>;
|
|
onUnmanageSource?: (sourceId: string) => void;
|
|
onConvertKnownHost: (knownHost: KnownHost) => void;
|
|
onOpenLogView: (log: ConnectionLog) => void;
|
|
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
|
groupConfigs: GroupConfig[];
|
|
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
|
|
showRecentHosts: boolean;
|
|
hostClickBehavior?: "connect" | "select";
|
|
showOnlyUngroupedHostsInRoot: boolean;
|
|
// Optional: navigate to a specific section on mount or when changed
|
|
navigateToSection?: VaultSection | null;
|
|
onNavigateToSectionHandled?: () => void;
|
|
deepLinkHostDraft?: Host | null;
|
|
onDeepLinkHostDraftHandled?: () => void;
|
|
vaultFocusRequest?:
|
|
| { type: "note"; noteId: string; requestId: number }
|
|
| { type: "snippet"; snippetId: string; requestId: number }
|
|
| null;
|
|
onVaultFocusRequestHandled?: () => void;
|
|
terminalSettings?: {
|
|
verifyHostKeys: boolean;
|
|
keepaliveInterval: number;
|
|
keepaliveCountMax: number;
|
|
};
|
|
}
|
|
|
|
const VaultViewInner: React.FC<VaultViewProps> = ({
|
|
hosts,
|
|
keys,
|
|
identities,
|
|
proxyProfiles,
|
|
snippets,
|
|
snippetPackages,
|
|
customGroups,
|
|
knownHosts,
|
|
managedSources,
|
|
sessionCount,
|
|
hotkeyScheme,
|
|
keyBindings,
|
|
terminalThemeId,
|
|
terminalFontSize,
|
|
onOpenSettings,
|
|
onOpenQuickSwitcher,
|
|
onCreateLocalTerminal,
|
|
onConnectSerial,
|
|
onDeleteHost,
|
|
onConnect,
|
|
onOpenHostFromNote,
|
|
onUpdateHosts,
|
|
onReadPersistedHosts,
|
|
onUpdateKeys,
|
|
onImportOrReuseKey,
|
|
onUpdateIdentities,
|
|
onUpdateProxyProfiles,
|
|
onUpdateSnippets,
|
|
onUpdateSnippetPackages,
|
|
onUpdateCustomGroups,
|
|
onCommitPluginImporterData,
|
|
onUpdateKnownHosts,
|
|
onUpdateManagedSources,
|
|
onReadPersistedManagedSources,
|
|
onCommitVaultImportTransaction,
|
|
onCommitVaultGroupMutation,
|
|
onClearAndRemoveManagedSource,
|
|
onClearAndRemoveManagedSources,
|
|
onUnmanageSource,
|
|
onConvertKnownHost,
|
|
onOpenLogView,
|
|
onRunSnippet,
|
|
groupConfigs,
|
|
onUpdateGroupConfigs,
|
|
showRecentHosts,
|
|
hostClickBehavior = "connect",
|
|
showOnlyUngroupedHostsInRoot,
|
|
navigateToSection,
|
|
onNavigateToSectionHandled,
|
|
deepLinkHostDraft,
|
|
onDeepLinkHostDraftHandled,
|
|
vaultFocusRequest,
|
|
onVaultFocusRequestHandled,
|
|
terminalSettings,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
const hostsRef = useRef(hosts);
|
|
hostsRef.current = hosts;
|
|
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
|
|
const [search, setSearch] = useState("");
|
|
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [isNewFolderOpen, setIsNewFolderOpen] = useState(false);
|
|
const [newFolderName, setNewFolderName] = useState("");
|
|
const [targetParentPath, setTargetParentPath] = useState<string | null>(null);
|
|
const [isRenameGroupOpen, setIsRenameGroupOpen] = useState(false);
|
|
const [renameTargetPath, setRenameTargetPath] = useState<string | null>(null);
|
|
const [renameGroupName, setRenameGroupName] = useState("");
|
|
const [renameGroupError, setRenameGroupError] = useState<string | null>(null);
|
|
const [isImportOpen, setIsImportOpen] = useState(false);
|
|
const [isSerialModalOpen, setIsSerialModalOpen] = useState(false);
|
|
const [isDeleteGroupOpen, setIsDeleteGroupOpen] = useState(false);
|
|
const [deleteTargetPath, setDeleteTargetPath] = useState<string | null>(null);
|
|
const [bulkDeleteGroupPaths, setBulkDeleteGroupPaths] = useState<string[]>(
|
|
[],
|
|
);
|
|
const [deleteGroupWithHosts, setDeleteGroupWithHosts] = useState(false);
|
|
|
|
// Sidebar collapsed state with localStorage persistence
|
|
const [storedSidebarCollapsed, setStoredSidebarCollapsed] = useStoredBoolean(
|
|
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
|
|
false,
|
|
);
|
|
const [sidebarWidth, setSidebarWidth, persistSidebarWidth] = useStoredNumber(
|
|
STORAGE_KEY_VAULT_SIDEBAR_WIDTH,
|
|
storedSidebarCollapsed
|
|
? VAULT_SIDEBAR_MIN_WIDTH
|
|
: VAULT_SIDEBAR_DEFAULT_WIDTH,
|
|
{
|
|
min: VAULT_SIDEBAR_MIN_WIDTH,
|
|
max: VAULT_SIDEBAR_MAX_WIDTH,
|
|
},
|
|
);
|
|
const sidebarCollapsed = sidebarWidth < VAULT_SIDEBAR_LABEL_THRESHOLD;
|
|
const setSidebarCollapsed = useCallback(
|
|
(nextCollapsed: boolean) => {
|
|
const nextWidth = nextCollapsed
|
|
? VAULT_SIDEBAR_MIN_WIDTH
|
|
: VAULT_SIDEBAR_DEFAULT_WIDTH;
|
|
setSidebarWidth(nextWidth);
|
|
persistSidebarWidth(nextWidth);
|
|
setStoredSidebarCollapsed(nextCollapsed);
|
|
},
|
|
[persistSidebarWidth, setSidebarWidth, setStoredSidebarCollapsed],
|
|
);
|
|
const handleSidebarWidthCommit = useCallback(
|
|
(nextWidth: number) => {
|
|
const clampedWidth = Math.max(
|
|
VAULT_SIDEBAR_MIN_WIDTH,
|
|
Math.min(VAULT_SIDEBAR_MAX_WIDTH, nextWidth),
|
|
);
|
|
setSidebarWidth(clampedWidth);
|
|
persistSidebarWidth(clampedWidth);
|
|
setStoredSidebarCollapsed(clampedWidth < VAULT_SIDEBAR_LABEL_THRESHOLD);
|
|
},
|
|
[persistSidebarWidth, setSidebarWidth, setStoredSidebarCollapsed],
|
|
);
|
|
|
|
// Handle external navigation requests
|
|
useEffect(() => {
|
|
if (navigateToSection) {
|
|
setCurrentSection(navigateToSection);
|
|
onNavigateToSectionHandled?.();
|
|
}
|
|
}, [navigateToSection, onNavigateToSectionHandled]);
|
|
|
|
// View mode, sorting, and tag filter state
|
|
const [viewMode, setViewMode] = useStoredViewMode(
|
|
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
|
"grid",
|
|
);
|
|
const treeExpandedState = useTreeExpandedState(
|
|
STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED,
|
|
);
|
|
const [sortMode, setSortMode] = useStoredString<SortMode>(
|
|
STORAGE_KEY_VAULT_HOSTS_SORT_MODE,
|
|
"manual",
|
|
isSortMode,
|
|
);
|
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
|
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const [selectedGroupPaths, setSelectedGroupPaths] = useState<Set<string>>(
|
|
new Set(),
|
|
);
|
|
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
|
|
|
// Host panel state (local to hosts section)
|
|
const [isHostPanelOpen, setIsHostPanelOpen] = useState(false);
|
|
const [editingHost, setEditingHost] = useState<Host | null>(null);
|
|
const [newHostGroupPath, setNewHostGroupPath] = useState<string | null>(null);
|
|
const [pendingOpenNoteId, setPendingOpenNoteId] = useState<string | null>(
|
|
null,
|
|
);
|
|
const handleOpenNoteIdHandled = useCallback(() => {
|
|
setPendingOpenNoteId(null);
|
|
}, []);
|
|
const [pendingOpenSnippetId, setPendingOpenSnippetId] = useState<
|
|
string | null
|
|
>(null);
|
|
|
|
useEffect(() => {
|
|
if (!deepLinkHostDraft) return;
|
|
setCurrentSection("hosts");
|
|
setSelectedGroupPath(null);
|
|
setNewHostGroupPath(null);
|
|
setEditingHost(deepLinkHostDraft);
|
|
setIsHostPanelOpen(true);
|
|
onDeepLinkHostDraftHandled?.();
|
|
}, [deepLinkHostDraft, onDeepLinkHostDraftHandled]);
|
|
|
|
useEffect(() => {
|
|
if (!vaultFocusRequest) return;
|
|
if (vaultFocusRequest.type === "note") {
|
|
setCurrentSection("notes");
|
|
setPendingOpenNoteId(vaultFocusRequest.noteId);
|
|
} else if (vaultFocusRequest.type === "snippet") {
|
|
setCurrentSection("snippets");
|
|
setPendingOpenSnippetId(vaultFocusRequest.snippetId);
|
|
}
|
|
onVaultFocusRequestHandled?.();
|
|
}, [vaultFocusRequest, onVaultFocusRequestHandled]);
|
|
|
|
// When the side panel is open, Tailwind's viewport-based grid-cols-* can't
|
|
// react to the narrowed content area, so we drive the host-grid column count
|
|
// off the actual container width (measured below). A fixed column count keeps
|
|
// a lone card at one column's width instead of stretching it across the row
|
|
// the way auto-fit + 1fr would. The count is published as a CSS variable
|
|
// (set imperatively) rather than React state, so re-flowing the grid never
|
|
// re-renders this (large) component during panel transitions / window resize.
|
|
const hostListScrollRef = useRef<HTMLDivElement>(null);
|
|
const splitGridColsRef = useRef(0);
|
|
|
|
// Close host panel if the host being edited was deleted.
|
|
// Track previous host IDs so we only close for actual deletions, not for
|
|
// unsaved new/duplicated hosts whose IDs were never in the hosts array.
|
|
const knownHostIdsRef = useRef(new Set(hosts.map((h) => h.id)));
|
|
useEffect(() => {
|
|
const currentIds = new Set(hosts.map((h) => h.id));
|
|
// Check against previous IDs before updating the ref
|
|
if (
|
|
editingHost &&
|
|
knownHostIdsRef.current.has(editingHost.id) &&
|
|
!currentIds.has(editingHost.id)
|
|
) {
|
|
setIsHostPanelOpen(false);
|
|
setEditingHost(null);
|
|
setNewHostGroupPath(null);
|
|
}
|
|
knownHostIdsRef.current = currentIds;
|
|
}, [hosts, editingHost]);
|
|
|
|
// Group panel state
|
|
const [isGroupPanelOpen, setIsGroupPanelOpen] = useState(false);
|
|
const [editingGroupPath, setEditingGroupPath] = useState<string | null>(null);
|
|
|
|
// Compute inherited group defaults for the host being edited
|
|
const editingHostGroupDefaults = useMemo(() => {
|
|
const group = editingHost?.group || newHostGroupPath || selectedGroupPath;
|
|
if (!group) return undefined;
|
|
return resolveGroupDefaults(group, groupConfigs);
|
|
}, [editingHost, newHostGroupPath, selectedGroupPath, groupConfigs]);
|
|
const proxyProfileIdSet = useMemo(
|
|
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
|
[proxyProfiles],
|
|
);
|
|
// Quick connect state
|
|
const [quickConnectTarget, setQuickConnectTarget] = useState<{
|
|
hostname: string;
|
|
username?: string;
|
|
port?: number;
|
|
} | null>(null);
|
|
const [isQuickConnectOpen, setIsQuickConnectOpen] = useState(false);
|
|
const [quickConnectWarnings, setQuickConnectWarnings] = useState<string[]>(
|
|
[],
|
|
);
|
|
|
|
// Protocol select state (for hosts with multiple protocols)
|
|
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
|
|
null,
|
|
);
|
|
|
|
// Check if search input is a quick connect address
|
|
const isSearchQuickConnect = useMemo(() => {
|
|
return isQuickConnectInput(search);
|
|
}, [search]);
|
|
|
|
// Handle connect button click - detect quick connect or regular search
|
|
const handleConnectClick = useCallback(() => {
|
|
if (isSearchQuickConnect) {
|
|
const parsed = parseQuickConnectInputWithWarnings(search);
|
|
if (parsed.target) {
|
|
setQuickConnectTarget(parsed.target);
|
|
setQuickConnectWarnings(parsed.warnings);
|
|
setIsQuickConnectOpen(true);
|
|
}
|
|
} else {
|
|
onOpenQuickSwitcher();
|
|
}
|
|
}, [isSearchQuickConnect, search, onOpenQuickSwitcher]);
|
|
|
|
// Handle search input keydown for quick connect
|
|
const handleSearchKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && isSearchQuickConnect) {
|
|
e.preventDefault();
|
|
handleConnectClick();
|
|
}
|
|
},
|
|
[isSearchQuickConnect, handleConnectClick],
|
|
);
|
|
|
|
// Handle host connect. Resolution order:
|
|
// Telnet set as default (protocol === 'telnet') -> connect Telnet
|
|
// Telnet enabled but not the default -> ask (protocol picker)
|
|
// Mosh enabled -> connect Mosh
|
|
// otherwise -> connect SSH
|
|
const handleHostConnect = useCallback(
|
|
(host: Host) => {
|
|
const effective = host.group
|
|
? applyGroupDefaults(
|
|
host,
|
|
resolveGroupDefaults(host.group, groupConfigs, {
|
|
validProxyProfileIds: proxyProfileIdSet,
|
|
}),
|
|
{ validProxyProfileIds: proxyProfileIdSet },
|
|
)
|
|
: applyGroupDefaults(
|
|
host,
|
|
{},
|
|
{ validProxyProfileIds: proxyProfileIdSet },
|
|
);
|
|
// Only prompt when Telnet is available but isn't the host's default protocol.
|
|
if (effective.telnetEnabled && effective.protocol !== "telnet") {
|
|
setProtocolSelectHost(effective);
|
|
} else if (effective.protocol === "telnet") {
|
|
// Telnet-as-default wins over a stray moshEnabled flag.
|
|
onConnect({ ...host, moshEnabled: false });
|
|
} else {
|
|
onConnect(host);
|
|
}
|
|
},
|
|
[onConnect, groupConfigs, proxyProfileIdSet],
|
|
);
|
|
|
|
// Handle protocol selection
|
|
const handleProtocolSelect = useCallback(
|
|
(protocol: HostProtocol, port: number) => {
|
|
if (protocolSelectHost) {
|
|
const hostWithProtocol: Host = {
|
|
...protocolSelectHost,
|
|
protocol: protocol === "mosh" || protocol === "et" ? "ssh" : protocol,
|
|
port,
|
|
moshEnabled: protocol === "mosh",
|
|
etEnabled: protocol === "et",
|
|
};
|
|
onConnect(hostWithProtocol);
|
|
setProtocolSelectHost(null);
|
|
}
|
|
},
|
|
[protocolSelectHost, onConnect],
|
|
);
|
|
|
|
// Handle quick connect
|
|
const handleQuickConnect = useCallback(
|
|
(host: Host) => {
|
|
onConnect(host);
|
|
setIsQuickConnectOpen(false);
|
|
setQuickConnectTarget(null);
|
|
setQuickConnectWarnings([]);
|
|
setSearch("");
|
|
},
|
|
[onConnect],
|
|
);
|
|
|
|
// Handle quick connect save host
|
|
const handleQuickConnectSaveHost = useCallback(
|
|
(host: Host) => {
|
|
onUpdateHosts([...hosts, host]);
|
|
},
|
|
[hosts, onUpdateHosts],
|
|
);
|
|
|
|
const handleNewHost = useCallback(() => {
|
|
setIsGroupPanelOpen(false);
|
|
setEditingGroupPath(null);
|
|
setEditingHost(null);
|
|
setNewHostGroupPath(null);
|
|
setIsHostPanelOpen(true);
|
|
}, []);
|
|
|
|
const handleEditHost = useCallback((host: Host) => {
|
|
setIsGroupPanelOpen(false);
|
|
setEditingGroupPath(null);
|
|
setEditingHost(host);
|
|
setIsHostPanelOpen(true);
|
|
}, []);
|
|
|
|
const handleDuplicateHost = useCallback(
|
|
(host: Host) => {
|
|
setCurrentSection("hosts");
|
|
setIsGroupPanelOpen(false);
|
|
setEditingGroupPath(null);
|
|
// Create a copy of the host with a new ID and modified label
|
|
const duplicatedHost: Host = {
|
|
...host,
|
|
id: crypto.randomUUID(),
|
|
label: `${host.label} (${t("action.copy")})`,
|
|
createdAt: Date.now(),
|
|
pinned: undefined,
|
|
lastConnectedAt: undefined,
|
|
};
|
|
// Open the edit panel with the duplicated host for modification
|
|
setEditingHost(duplicatedHost);
|
|
setIsHostPanelOpen(true);
|
|
},
|
|
[t],
|
|
);
|
|
|
|
// Export hosts to CSV
|
|
const handleExportHosts = useCallback(async () => {
|
|
if (hosts.length === 0) {
|
|
toast.warning(t("vault.hosts.export.toast.noHosts"));
|
|
return;
|
|
}
|
|
|
|
const {
|
|
keyPathsById,
|
|
keyPassphrasesById,
|
|
keyPassphrases,
|
|
unreadablePassphraseCount,
|
|
} = await buildVaultCsvCredentialOptions(hosts, keys);
|
|
const { csv, exportedCount, skippedCount } = exportHostsToCsvWithStats(
|
|
hosts,
|
|
{
|
|
keyPassphrases,
|
|
keyPassphrasesById,
|
|
keyPathsById,
|
|
},
|
|
);
|
|
|
|
if (exportedCount === 0) {
|
|
toast.warning(t("vault.hosts.export.toast.noHosts"));
|
|
return;
|
|
}
|
|
|
|
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `hosts_export_${new Date().toISOString().slice(0, 10)}.csv`;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
|
|
if (unreadablePassphraseCount > 0) {
|
|
toast.warning(
|
|
t("vault.hosts.export.toast.passphrasesSkipped", {
|
|
count: unreadablePassphraseCount,
|
|
}),
|
|
);
|
|
}
|
|
if (skippedCount > 0) {
|
|
toast.warning(
|
|
t("vault.hosts.export.toast.successWithSkipped", {
|
|
count: exportedCount,
|
|
skipped: skippedCount,
|
|
}),
|
|
);
|
|
} else {
|
|
toast.success(
|
|
t("vault.hosts.export.toast.success", { count: exportedCount }),
|
|
);
|
|
}
|
|
}, [hosts, keys, t]);
|
|
|
|
// Copy hostname/IP for cross-host paste without opening the editor
|
|
const handleCopyHostname = useCallback(
|
|
(host: Host) => {
|
|
const hostname = getHostAddressForClipboard(host);
|
|
if (!hostname) {
|
|
toast.error(t("terminal.statusbar.copyHostname.error"));
|
|
return;
|
|
}
|
|
void navigator.clipboard.writeText(hostname).then(
|
|
() => {
|
|
toast.success(
|
|
t("terminal.statusbar.copyHostname.toast", { hostname }),
|
|
);
|
|
},
|
|
() => {
|
|
toast.error(t("terminal.statusbar.copyHostname.error"));
|
|
},
|
|
);
|
|
},
|
|
[t],
|
|
);
|
|
|
|
// Copy host credentials to clipboard
|
|
const handleCopyCredentials = useCallback(
|
|
(host: Host) => {
|
|
// Apply group defaults so inherited credentials are included
|
|
const effective = host.group
|
|
? applyGroupDefaults(
|
|
host,
|
|
resolveGroupDefaults(host.group, groupConfigs, {
|
|
validProxyProfileIds: proxyProfileIdSet,
|
|
}),
|
|
{ validProxyProfileIds: proxyProfileIdSet },
|
|
)
|
|
: applyGroupDefaults(
|
|
host,
|
|
{},
|
|
{ validProxyProfileIds: proxyProfileIdSet },
|
|
);
|
|
// Only use telnet-specific port and credentials when protocol is explicitly telnet
|
|
// Don't treat telnetEnabled as primary - that's just an optional protocol
|
|
const isTelnet = effective.protocol === "telnet";
|
|
|
|
const defaultPort = isTelnet ? 23 : 22;
|
|
const effectivePort = isTelnet
|
|
? resolveTelnetPort(effective)
|
|
: (effective.port ?? 22);
|
|
|
|
// Bracket IPv6 addresses when appending non-default port
|
|
let address: string;
|
|
if (effectivePort !== defaultPort) {
|
|
const isIPv6 =
|
|
effective.hostname.includes(":") &&
|
|
!effective.hostname.startsWith("[");
|
|
const hostname = isIPv6
|
|
? `[${effective.hostname}]`
|
|
: effective.hostname;
|
|
address = `${hostname}:${effectivePort}`;
|
|
} else {
|
|
address = effective.hostname;
|
|
}
|
|
|
|
// Resolve credentials from identity if configured, otherwise use host credentials
|
|
// For telnet hosts, use telnet-specific credentials
|
|
const identity = effective.identityId
|
|
? identities.find((i) => i.id === effective.identityId)
|
|
: undefined;
|
|
|
|
const username = isTelnet
|
|
? resolveTelnetUsername(effective)
|
|
: identity?.username?.trim() || effective.username?.trim();
|
|
|
|
const rawPassword = isTelnet
|
|
? resolveTelnetPassword(effective)
|
|
: identity?.password || effective.password;
|
|
const password = sanitizeCredentialValue(rawPassword);
|
|
|
|
if (!password) {
|
|
toast.warning(t("vault.hosts.copyCredentials.toast.noPassword"));
|
|
return;
|
|
}
|
|
|
|
const text = `host: ${address}\nusername: ${username ?? ""}\npassword: ${password}`;
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
toast.success(t("vault.hosts.copyCredentials.toast.success"));
|
|
});
|
|
},
|
|
[identities, groupConfigs, proxyProfileIdSet, t],
|
|
);
|
|
|
|
const [lastPinnedId, setLastPinnedId] = useState<string | null>(null);
|
|
const toggleHostPinned = useCallback(
|
|
(hostId: string) => {
|
|
const host = hostsRef.current.find((h) => h.id === hostId);
|
|
const isPinning = host && !host.pinned;
|
|
startTransition(() => {
|
|
onUpdateHosts(
|
|
hostsRef.current.map((h) =>
|
|
h.id === hostId ? { ...h, pinned: !h.pinned } : h,
|
|
),
|
|
);
|
|
});
|
|
setLastPinnedId(isPinning ? hostId : null);
|
|
},
|
|
[onUpdateHosts],
|
|
);
|
|
|
|
const toggleHostSelection = useCallback((hostId: string) => {
|
|
setSelectedHostIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(hostId)) {
|
|
next.delete(hostId);
|
|
} else {
|
|
next.add(hostId);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const toggleGroupSelection = useCallback((groupPath: string) => {
|
|
setSelectedGroupPaths((previous) => {
|
|
const next = new Set(previous);
|
|
if (next.has(groupPath)) {
|
|
next.delete(groupPath);
|
|
} else {
|
|
next.add(groupPath);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const clearHostSelection = useCallback(() => {
|
|
setSelectedHostIds(new Set());
|
|
setSelectedGroupPaths(new Set());
|
|
setIsMultiSelectMode(false);
|
|
}, []);
|
|
|
|
const deleteSelectedHosts = useCallback(() => {
|
|
if (selectedHostIds.size === 0) return;
|
|
const updatedHosts = hosts.filter((h) => !selectedHostIds.has(h.id));
|
|
onUpdateHosts(updatedHosts);
|
|
clearHostSelection();
|
|
toast.success(
|
|
t("vault.hosts.deleteMultiple.success", { count: selectedHostIds.size }),
|
|
);
|
|
}, [selectedHostIds, hosts, onUpdateHosts, clearHostSelection, t]);
|
|
|
|
const connectSelectedHosts = useCallback(() => {
|
|
if (selectedHostIds.size === 0) return;
|
|
// Connect each selected host in list order with its default protocol.
|
|
// We call onConnect directly (not handleHostConnect) so multi-protocol hosts
|
|
// connect with their configured protocol instead of opening a per-host dialog.
|
|
const targets = hosts.filter((h) => selectedHostIds.has(h.id));
|
|
// Stagger the connects across frames so mounting N terminals (each creating
|
|
// a WebGL context) doesn't block one frame and freeze the UI. The first host
|
|
// still connects synchronously so its tab appears immediately.
|
|
connectHostsStaggered(targets, onConnect);
|
|
clearHostSelection();
|
|
toast.success(
|
|
t("vault.hosts.connectMultiple.success", { count: targets.length }),
|
|
);
|
|
}, [selectedHostIds, hosts, onConnect, clearHostSelection, t]);
|
|
const { cancelImport, handleImportFileSelected, importProgress, resetImportProgress } =
|
|
useVaultImportHandlers({
|
|
customGroups,
|
|
hosts,
|
|
keys,
|
|
managedSources,
|
|
notify: toast,
|
|
onReadPersistedHosts,
|
|
onUpdateHosts,
|
|
onUpdateKeys,
|
|
onReadPersistedManagedSources,
|
|
onCommitVaultImportTransaction,
|
|
setIsImportOpen,
|
|
t,
|
|
});
|
|
|
|
const {
|
|
allGroupPaths,
|
|
allTags,
|
|
displayedGroups,
|
|
displayedHosts,
|
|
groupedDisplayHosts,
|
|
handleDeleteTag,
|
|
handleEditTag,
|
|
knownHostsManagerElement,
|
|
pinnedHosts,
|
|
pinnedRecentIds,
|
|
recentHosts,
|
|
shouldHideEmptyRootHostsSection,
|
|
treeViewGroupTree,
|
|
treeViewHosts,
|
|
visibleDisplayedHosts,
|
|
} = useVaultHostCollections({
|
|
customGroups,
|
|
groupConfigs,
|
|
hosts,
|
|
knownHosts,
|
|
onConvertKnownHost,
|
|
onUpdateHosts,
|
|
onUpdateKnownHosts,
|
|
search,
|
|
selectedGroupPath,
|
|
selectedTags,
|
|
showOnlyUngroupedHostsInRoot,
|
|
showRecentHosts,
|
|
sortMode,
|
|
viewMode,
|
|
});
|
|
|
|
const submitNewFolder = () => {
|
|
if (!newFolderName.trim()) return;
|
|
const fullPath = targetParentPath
|
|
? `${targetParentPath}/${newFolderName.trim()}`
|
|
: newFolderName.trim();
|
|
onUpdateCustomGroups(Array.from(new Set([...customGroups, fullPath])));
|
|
setNewFolderName("");
|
|
setTargetParentPath(null);
|
|
setIsNewFolderOpen(false);
|
|
};
|
|
|
|
const commitGroupPathChange = useCallback(async (
|
|
sourcePath: string,
|
|
nextPath: string,
|
|
replacementConfig?: GroupConfig,
|
|
) => {
|
|
try {
|
|
return await onCommitVaultGroupMutation((current) => {
|
|
if (!current.groups.includes(sourcePath)) {
|
|
return { ok: false, error: `Group "${sourcePath}" was not found.` };
|
|
}
|
|
const belongsToTree = (path: string) => (
|
|
path === sourcePath || path.startsWith(`${sourcePath}/`)
|
|
);
|
|
const rename = (path: string) => (
|
|
path === sourcePath
|
|
? nextPath
|
|
: path.startsWith(`${sourcePath}/`)
|
|
? nextPath + path.slice(sourcePath.length)
|
|
: path
|
|
);
|
|
const occupied = new Set([
|
|
...current.groups.filter((path) => !belongsToTree(path)),
|
|
...current.configs.map((config) => config.path).filter((path) => !belongsToTree(path)),
|
|
]);
|
|
const collision = [
|
|
...current.groups,
|
|
...current.configs.map((config) => config.path),
|
|
].filter(belongsToTree).map(rename).find((path) => occupied.has(path));
|
|
if (collision) return { ok: false, error: `Group "${collision}" already exists.` };
|
|
|
|
const configs = current.configs.map((config) => {
|
|
if (replacementConfig && config.path === sourcePath) {
|
|
return { ...replacementConfig, path: nextPath };
|
|
}
|
|
return belongsToTree(config.path) ? { ...config, path: rename(config.path) } : config;
|
|
});
|
|
if (replacementConfig && !current.configs.some((config) => config.path === sourcePath)) {
|
|
configs.push({ ...replacementConfig, path: nextPath });
|
|
}
|
|
return {
|
|
ok: true,
|
|
state: {
|
|
groups: Array.from(new Set(current.groups.map(rename))),
|
|
configs,
|
|
hosts: current.hosts.map((host) => (
|
|
host.group && belongsToTree(host.group) ? { ...host, group: rename(host.group) } : host
|
|
)),
|
|
managedSources: current.managedSources.map((source) => (
|
|
belongsToTree(source.groupName) ? { ...source, groupName: rename(source.groupName) } : source
|
|
)),
|
|
snippets: remapSnippetTargetGroupPaths(current.snippets, sourcePath, nextPath),
|
|
},
|
|
};
|
|
});
|
|
} catch (error) {
|
|
return { ok: false as const, error: error instanceof Error ? error.message : String(error) };
|
|
}
|
|
}, [onCommitVaultGroupMutation]);
|
|
|
|
const submitRenameGroup = async () => {
|
|
if (!renameTargetPath) return;
|
|
|
|
const nextName = renameGroupName.trim();
|
|
if (!nextName) {
|
|
setRenameGroupError(t("vault.groups.errors.required"));
|
|
return;
|
|
}
|
|
if (nextName.includes("/") || nextName.includes("\\")) {
|
|
setRenameGroupError(t("vault.groups.errors.invalidChars"));
|
|
return;
|
|
}
|
|
|
|
const segments = renameTargetPath.split("/").filter(Boolean);
|
|
const parent = segments.slice(0, -1).join("/");
|
|
const nextPath = parent ? `${parent}/${nextName}` : nextName;
|
|
if (nextPath === renameTargetPath) {
|
|
setIsRenameGroupOpen(false);
|
|
return;
|
|
}
|
|
|
|
const result = await commitGroupPathChange(renameTargetPath, nextPath);
|
|
if ("superseded" in result || "error" in result) {
|
|
setRenameGroupError("superseded" in result ? t("common.error") : result.error);
|
|
return;
|
|
}
|
|
if (
|
|
selectedGroupPath &&
|
|
(selectedGroupPath === renameTargetPath ||
|
|
selectedGroupPath.startsWith(renameTargetPath + "/"))
|
|
) {
|
|
const suffix =
|
|
selectedGroupPath === renameTargetPath
|
|
? ""
|
|
: selectedGroupPath.slice(renameTargetPath.length);
|
|
setSelectedGroupPath(nextPath + suffix);
|
|
}
|
|
|
|
setIsRenameGroupOpen(false);
|
|
};
|
|
|
|
const handleEditGroupConfig = useCallback((groupPath: string) => {
|
|
setIsHostPanelOpen(false);
|
|
setEditingHost(null);
|
|
setEditingGroupPath(groupPath);
|
|
setIsGroupPanelOpen(true);
|
|
}, []);
|
|
|
|
const handleSaveGroupConfig = useCallback(
|
|
async (config: GroupConfig, _newName?: string, _newParent?: string | null) => {
|
|
const oldPath = editingGroupPath!;
|
|
const newPath = config.path; // Panel already computed the correct path
|
|
|
|
// Validate no duplicate path on rename/reparent
|
|
if (newPath !== oldPath && customGroups.includes(newPath)) {
|
|
toast.error(t("vault.groups.errors.duplicatePath"));
|
|
return;
|
|
}
|
|
|
|
// Save config (use new path)
|
|
const updatedConfigs = [
|
|
...groupConfigs.filter((c) => c.path !== oldPath),
|
|
config,
|
|
];
|
|
|
|
// Handle path change (rename or parent change)
|
|
if (newPath !== oldPath) {
|
|
const result = await commitGroupPathChange(oldPath, newPath, config);
|
|
if ("superseded" in result || "error" in result) {
|
|
toast.error("superseded" in result ? t("common.error") : result.error);
|
|
return;
|
|
}
|
|
if (selectedGroupPath === oldPath) setSelectedGroupPath(newPath);
|
|
if (selectedGroupPath?.startsWith(oldPath + "/")) {
|
|
setSelectedGroupPath(
|
|
newPath + selectedGroupPath.slice(oldPath.length),
|
|
);
|
|
}
|
|
} else {
|
|
onUpdateGroupConfigs(updatedConfigs);
|
|
}
|
|
|
|
setIsGroupPanelOpen(false);
|
|
setEditingGroupPath(null);
|
|
},
|
|
[
|
|
groupConfigs,
|
|
editingGroupPath,
|
|
customGroups,
|
|
selectedGroupPath,
|
|
commitGroupPathChange,
|
|
onUpdateGroupConfigs,
|
|
t,
|
|
],
|
|
);
|
|
|
|
const handleDeletedGroupPaths = useCallback(
|
|
(selectedRoots: string[]) => {
|
|
if (
|
|
selectedGroupPath &&
|
|
selectedRoots.some(
|
|
(root) =>
|
|
selectedGroupPath === root ||
|
|
selectedGroupPath.startsWith(root + "/"),
|
|
)
|
|
) {
|
|
setSelectedGroupPath(null);
|
|
}
|
|
},
|
|
[selectedGroupPath],
|
|
);
|
|
const deleteGroupPaths = useVaultGroupDeletion({
|
|
customGroups,
|
|
hosts,
|
|
groupConfigs,
|
|
managedSources,
|
|
onReadPersistedHosts,
|
|
onReadPersistedManagedSources,
|
|
onCommitVaultGroupMutation,
|
|
onClearAndRemoveManagedSource,
|
|
onClearAndRemoveManagedSources,
|
|
onDeletedPaths: handleDeletedGroupPaths,
|
|
});
|
|
|
|
const deleteGroupPath = (path: string, deleteHosts: boolean = false) =>
|
|
deleteGroupPaths([path], deleteHosts);
|
|
|
|
const moveGroup = async (sourcePath: string, targetParent: string | null) => {
|
|
const name = sourcePath.split("/").filter(Boolean).pop() || "";
|
|
const newPath = targetParent ? `${targetParent}/${name}` : name;
|
|
if (newPath === sourcePath || newPath.startsWith(sourcePath + "/")) return;
|
|
if (customGroups.includes(newPath)) {
|
|
toast.error(t("vault.groups.errors.duplicatePath"));
|
|
return;
|
|
}
|
|
const result = await commitGroupPathChange(sourcePath, newPath);
|
|
if ("superseded" in result || "error" in result) {
|
|
toast.error("superseded" in result ? t("common.error") : result.error);
|
|
return;
|
|
}
|
|
if (
|
|
selectedGroupPath &&
|
|
(selectedGroupPath === sourcePath ||
|
|
selectedGroupPath.startsWith(sourcePath + "/"))
|
|
) {
|
|
setSelectedGroupPath(newPath + selectedGroupPath.slice(sourcePath.length));
|
|
}
|
|
};
|
|
|
|
const reorderHost = useCallback(
|
|
(
|
|
sourceHostId: string,
|
|
targetHostId: string,
|
|
position: VaultOrderPosition,
|
|
) => {
|
|
const source = hostsRef.current.find((host) => host.id === sourceHostId);
|
|
const target = hostsRef.current.find((host) => host.id === targetHostId);
|
|
if (!source || !target) return;
|
|
const targetGroup = target.group || "";
|
|
const targetManagedSource = managedSources
|
|
.filter(
|
|
(sourceInfo) =>
|
|
targetGroup === sourceInfo.groupName ||
|
|
targetGroup.startsWith(`${sourceInfo.groupName}/`),
|
|
)
|
|
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
|
|
const updatedHosts = hostsRef.current.map((host) =>
|
|
host.id === sourceHostId
|
|
? {
|
|
...host,
|
|
label:
|
|
targetManagedSource &&
|
|
(!host.protocol || host.protocol === "ssh")
|
|
? host.label.replace(/\s/g, "")
|
|
: host.label,
|
|
group: targetGroup,
|
|
managedSourceId:
|
|
targetManagedSource &&
|
|
(!host.protocol || host.protocol === "ssh")
|
|
? targetManagedSource.id
|
|
: undefined,
|
|
}
|
|
: host,
|
|
);
|
|
const reorderedHosts = reorderVaultItems<Host>(
|
|
updatedHosts,
|
|
sourceHostId,
|
|
targetHostId,
|
|
position,
|
|
);
|
|
if (haveSameHostOrderResult(hostsRef.current, reorderedHosts)) return;
|
|
onUpdateHosts(reorderedHosts);
|
|
setSortMode("manual");
|
|
},
|
|
[managedSources, onUpdateHosts, setSortMode],
|
|
);
|
|
|
|
const reorderGroup = useCallback(
|
|
(sourcePath: string, targetPath: string, position: VaultOrderPosition) => {
|
|
const parentOf = (path: string) => {
|
|
const parts = path.split("/").filter(Boolean);
|
|
return parts.slice(0, -1).join("/");
|
|
};
|
|
if (parentOf(sourcePath) !== parentOf(targetPath)) return false;
|
|
const sortableGroups = Array.from(
|
|
new Set([...customGroups, sourcePath, targetPath]),
|
|
);
|
|
const updatedGroups = reorderVaultStrings(
|
|
sortableGroups,
|
|
sourcePath,
|
|
targetPath,
|
|
position,
|
|
);
|
|
const orderByPath = new Map(
|
|
updatedGroups.map((path, index) => [path, (index + 1) * 1000]),
|
|
);
|
|
const configByPath = new Map<string, GroupConfig>(
|
|
groupConfigs.map((config) => [config.path, config]),
|
|
);
|
|
const nextConfigs: GroupConfig[] = [
|
|
...updatedGroups.map((path) => {
|
|
const existing = configByPath.get(path);
|
|
const base: GroupConfig = existing ? { ...existing } : { path };
|
|
return {
|
|
...base,
|
|
order: orderByPath.get(path),
|
|
};
|
|
}),
|
|
...groupConfigs.filter((config) => !orderByPath.has(config.path)),
|
|
];
|
|
if (
|
|
updatedGroups.length === customGroups.length &&
|
|
updatedGroups.every((path, index) => path === customGroups[index]) &&
|
|
haveSameGroupConfigs(groupConfigs, nextConfigs)
|
|
) {
|
|
return true;
|
|
}
|
|
onUpdateCustomGroups(updatedGroups);
|
|
onUpdateGroupConfigs(nextConfigs);
|
|
setSortMode("manual");
|
|
return true;
|
|
},
|
|
[
|
|
customGroups,
|
|
groupConfigs,
|
|
onUpdateCustomGroups,
|
|
onUpdateGroupConfigs,
|
|
setSortMode,
|
|
],
|
|
);
|
|
const {
|
|
getDropTargetClasses,
|
|
handleUnmanageGroup,
|
|
managedGroupPaths,
|
|
moveHostToGroup,
|
|
setDragOverDropTarget,
|
|
setGroupDragOverDropTarget,
|
|
} = useVaultGroupDragHandlers({
|
|
hosts,
|
|
managedSources,
|
|
onUnmanageSource,
|
|
onUpdateHosts,
|
|
onUpdateManagedSources,
|
|
t,
|
|
});
|
|
|
|
const {
|
|
startInlineNewGroup,
|
|
startInlineRenameGroup,
|
|
startInlineDeleteGroup,
|
|
commitInlineGroupRename,
|
|
cancelInlineGroupEdit,
|
|
} = useHostTreeInlineGroupActions({
|
|
customGroups,
|
|
hosts,
|
|
managedSources,
|
|
onUpdateCustomGroups,
|
|
onCommitGroupPathChange: commitGroupPathChange,
|
|
selectedGroupPath,
|
|
setSelectedGroupPath,
|
|
ensurePathExpanded: treeExpandedState.ensurePathExpanded,
|
|
unnamedGroupLabel: t("vault.groups.unnamed"),
|
|
t,
|
|
});
|
|
|
|
const {
|
|
startInlineRenameHost,
|
|
commitInlineHostRename,
|
|
cancelInlineHostEdit,
|
|
} = useHostTreeInlineHostActions({
|
|
hosts,
|
|
onUpdateHosts,
|
|
t,
|
|
});
|
|
|
|
useRegisterVaultHostTreeActions({
|
|
handleCopyCredentials,
|
|
handleCopyHostname,
|
|
handleDuplicateHost,
|
|
startInlineRenameHost,
|
|
onDeleteHost,
|
|
handleUnmanageGroup,
|
|
moveHostToGroup,
|
|
moveGroup,
|
|
reorderHost,
|
|
reorderGroup,
|
|
managedGroupPaths,
|
|
startInlineNewGroup,
|
|
startInlineRenameGroup,
|
|
startInlineDeleteGroup,
|
|
commitInlineGroupRename,
|
|
cancelInlineGroupEdit,
|
|
commitInlineHostRename,
|
|
cancelInlineHostEdit,
|
|
});
|
|
|
|
const isHostsSectionActive = currentSection === "hosts";
|
|
const hasHostsSidePanel =
|
|
isHostsSectionActive &&
|
|
((isGroupPanelOpen && !!editingGroupPath) || isHostPanelOpen);
|
|
// Every host grid uses the same container-based fixed column count. This keeps
|
|
// pinned/recent cards aligned with the virtualized main grid and prevents a
|
|
// single card from stretching across the full row.
|
|
const splitViewGridStyle = {
|
|
gridTemplateColumns: "var(--vault-grid-cols, minmax(0, 1fr))",
|
|
};
|
|
|
|
// Track the host-list container width and derive the column count the same way
|
|
// the auto-fit grid did (≈220px min card + 12px gap), but as a fixed count so
|
|
// lone cards don't stretch. We write the whole grid-template-columns value into
|
|
// a CSS variable imperatively (no setState) and only when the count actually
|
|
// changes, so panel transitions / window resizing reflow the grid natively
|
|
// without re-rendering this component.
|
|
useEffect(() => {
|
|
const el = hostListScrollRef.current;
|
|
if (!el || typeof ResizeObserver === "undefined") return;
|
|
const PADDING_X = 32; // matches px-4 on both sides
|
|
const recompute = () => {
|
|
const usable = el.clientWidth - PADDING_X;
|
|
if (usable <= 0) return; // hidden / not laid out yet
|
|
const next = getVaultHostGridColumnCount(usable);
|
|
if (next === splitGridColsRef.current) return;
|
|
splitGridColsRef.current = next;
|
|
el.style.setProperty(
|
|
"--vault-grid-cols",
|
|
`repeat(${next}, minmax(0, 1fr))`,
|
|
);
|
|
};
|
|
recompute();
|
|
const observer = new ResizeObserver(recompute);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
return (
|
|
<>
|
|
<HostTreeGroupDeleteDialog
|
|
managedGroupPaths={managedGroupPaths}
|
|
onConfirmDelete={deleteGroupPath}
|
|
/>
|
|
<VaultViewLayout
|
|
ctx={{
|
|
Activity,
|
|
allGroupPaths,
|
|
allTags,
|
|
AppLogo,
|
|
Array,
|
|
Badge,
|
|
BookMarked,
|
|
Boolean,
|
|
bulkDeleteGroupPaths,
|
|
Button,
|
|
cancelImport,
|
|
CheckSquare,
|
|
ChevronDown,
|
|
cancelInlineGroupEdit,
|
|
clearHostSelection,
|
|
ClipboardCopy,
|
|
Clock,
|
|
cn,
|
|
commitInlineGroupRename,
|
|
connectSelectedHosts,
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
Copy,
|
|
currentSection,
|
|
customGroups,
|
|
deleteGroupPath,
|
|
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,
|
|
hasHostsSidePanel,
|
|
HostDetailsPanel,
|
|
hostListScrollRef,
|
|
hosts,
|
|
HostTreeView,
|
|
hotkeyScheme,
|
|
identities,
|
|
importProgress,
|
|
ImportVaultDialog,
|
|
Input,
|
|
isDeleteGroupOpen,
|
|
isGroupPanelOpen,
|
|
isHostPanelOpen,
|
|
isHostsSectionActive,
|
|
isImportOpen,
|
|
isMultiSelectMode,
|
|
isNewFolderOpen,
|
|
isQuickConnectOpen,
|
|
isRenameGroupOpen,
|
|
isSearchQuickConnect,
|
|
isSerialModalOpen,
|
|
Key,
|
|
keyBindings,
|
|
KeychainManager,
|
|
keys,
|
|
knownHosts,
|
|
knownHostsManagerElement,
|
|
Label,
|
|
lastPinnedId,
|
|
LayoutGrid,
|
|
LazyConnectionLogsManager,
|
|
LazyProtocolSelectDialog,
|
|
List,
|
|
managedGroupPaths,
|
|
managedSources,
|
|
moveGroup,
|
|
moveHostToGroup,
|
|
Network,
|
|
newFolderName,
|
|
newHostGroupPath,
|
|
NotebookText,
|
|
NotesManager,
|
|
onCommitPluginImporterData,
|
|
onConnectSerial,
|
|
onCreateLocalTerminal,
|
|
onDeleteHost,
|
|
onImportOrReuseKey,
|
|
onOpenHostFromNote,
|
|
onOpenLogView,
|
|
onOpenSettings,
|
|
onRunSnippet,
|
|
onUpdateCustomGroups,
|
|
onUpdateGroupConfigs,
|
|
onUpdateHosts,
|
|
onUpdateIdentities,
|
|
onUpdateKeys,
|
|
onUpdateProxyProfiles,
|
|
onUpdateSnippetPackages,
|
|
onUpdateSnippets,
|
|
openNoteId: pendingOpenNoteId,
|
|
onOpenNoteIdHandled: handleOpenNoteIdHandled,
|
|
openSnippetId: pendingOpenSnippetId,
|
|
onOpenSnippetIdHandled: () => setPendingOpenSnippetId(null),
|
|
Pin,
|
|
pinnedHosts,
|
|
pinnedRecentIds,
|
|
Plug,
|
|
Plus,
|
|
PortForwarding,
|
|
protocolSelectHost,
|
|
proxyProfiles,
|
|
ProxyProfilesManager,
|
|
quickConnectTarget,
|
|
quickConnectWarnings,
|
|
QuickConnectWizard,
|
|
recentHosts,
|
|
renameGroupError,
|
|
renameGroupName,
|
|
renameTargetPath,
|
|
reorderGroup,
|
|
reorderHost,
|
|
resetImportProgress,
|
|
RippleButton,
|
|
rootRef,
|
|
sanitizeHost,
|
|
search,
|
|
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,
|
|
setSelectedGroupPaths,
|
|
setSelectedHostIds,
|
|
setSelectedTags,
|
|
setSidebarCollapsed,
|
|
setSidebarWidth,
|
|
handleSidebarWidthCommit,
|
|
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,
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// Only re-render when data props change - isActive is now managed internally via store subscription
|
|
export const vaultViewAreEqual = (
|
|
prev: VaultViewProps,
|
|
next: VaultViewProps,
|
|
): boolean => {
|
|
const isEqual =
|
|
prev.hosts === next.hosts &&
|
|
prev.keys === next.keys &&
|
|
prev.identities === next.identities &&
|
|
prev.proxyProfiles === next.proxyProfiles &&
|
|
prev.snippets === next.snippets &&
|
|
prev.snippetPackages === next.snippetPackages &&
|
|
prev.customGroups === next.customGroups &&
|
|
prev.knownHosts === next.knownHosts &&
|
|
// shellHistory / notes / connectionLogs are not VaultView props; those
|
|
// sections subscribe to shellHistoryStore / notesStore / connectionLogsStore.
|
|
prev.sessionCount === next.sessionCount &&
|
|
prev.managedSources === next.managedSources &&
|
|
prev.groupConfigs === next.groupConfigs &&
|
|
prev.onReadPersistedHosts === next.onReadPersistedHosts &&
|
|
prev.onReadPersistedManagedSources === next.onReadPersistedManagedSources &&
|
|
prev.onCommitVaultImportTransaction === next.onCommitVaultImportTransaction &&
|
|
prev.onCommitVaultGroupMutation === next.onCommitVaultGroupMutation &&
|
|
prev.onCommitPluginImporterData === next.onCommitPluginImporterData &&
|
|
prev.showRecentHosts === next.showRecentHosts &&
|
|
prev.hostClickBehavior === next.hostClickBehavior &&
|
|
prev.showOnlyUngroupedHostsInRoot === next.showOnlyUngroupedHostsInRoot &&
|
|
prev.terminalThemeId === next.terminalThemeId &&
|
|
prev.terminalFontSize === next.terminalFontSize &&
|
|
prev.navigateToSection === next.navigateToSection &&
|
|
prev.deepLinkHostDraft === next.deepLinkHostDraft &&
|
|
prev.vaultFocusRequest === next.vaultFocusRequest &&
|
|
// Only these terminal connection settings are forwarded to
|
|
// PortForwarding inside the vault, so compare them directly. Other
|
|
// terminal settings (fonts, themes, etc.) don't affect this subtree
|
|
// and we don't want to re-render for them.
|
|
prev.terminalSettings?.verifyHostKeys ===
|
|
next.terminalSettings?.verifyHostKeys &&
|
|
prev.terminalSettings?.keepaliveInterval ===
|
|
next.terminalSettings?.keepaliveInterval &&
|
|
prev.terminalSettings?.keepaliveCountMax ===
|
|
next.terminalSettings?.keepaliveCountMax;
|
|
|
|
return isEqual;
|
|
};
|
|
|
|
const MemoizedVaultViewInner = memo(VaultViewInner, vaultViewAreEqual);
|
|
|
|
// Just export the memoized component directly
|
|
// Visibility control is handled by parent (App.tsx)
|
|
export const VaultView = MemoizedVaultViewInner;
|
|
VaultView.displayName = "VaultView";
|