Some checks failed
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
1732 lines
55 KiB
TypeScript
1732 lines
55 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";
|
||
import { useInspectionScheduler } from "../application/state/useInspectionScheduler";
|
||
|
||
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"
|
||
| "inspection"
|
||
| "alerts"
|
||
| "alertRules";
|
||
|
||
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;
|
||
};
|
||
productName?: string;
|
||
}
|
||
|
||
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,
|
||
productName,
|
||
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]);
|
||
|
||
// 巡检调度器
|
||
const inspectionScheduler = useInspectionScheduler(hosts, keys);
|
||
|
||
// 监听 hosts 加载完成后触发一次巡检(StrictMode 下多次触发也没关系,scanLoop 有 runningRef 保护)
|
||
const prevHostsLenRef = useRef(0);
|
||
useEffect(() => {
|
||
if (hosts.length > 0 && prevHostsLenRef.current === 0) {
|
||
// hosts 从空变成非空,触发一次巡检(传入 hosts 参数,避免闭包问题)
|
||
console.log('[VaultView] hosts 加载完成,触发 scanLoop');
|
||
setTimeout(() => {
|
||
inspectionScheduler.scanLoop(hosts);
|
||
}, 500);
|
||
}
|
||
prevHostsLenRef.current = hosts.length;
|
||
}, [hosts.length]);
|
||
|
||
// 启动周期性巡检(30 秒一次)
|
||
useEffect(() => {
|
||
if (hosts.length === 0) return;
|
||
const id = setInterval(() => {
|
||
console.log('[VaultView] 定时触发 scanLoop');
|
||
inspectionScheduler.scanLoop(hosts);
|
||
}, 30000);
|
||
return () => clearInterval(id);
|
||
}, [hosts.length]);
|
||
|
||
// 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,
|
||
productName,
|
||
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 &&
|
||
prev.productName === next.productName;
|
||
|
||
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";
|