Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e74f65729c | ||
|
|
97f53ed87f | ||
|
|
ec4512eb06 | ||
|
|
93c1f1b427 | ||
|
|
58ccd4bfb9 | ||
|
|
2fb82e1cb7 | ||
|
|
159589a09f | ||
|
|
04e1ed569d | ||
|
|
38fb5e8dd4 | ||
|
|
6f2b27206a | ||
|
|
f6eb693fac | ||
|
|
32935e4e87 | ||
|
|
f55c21fc0e | ||
|
|
26d03ace3f | ||
|
|
d85709d42d | ||
|
|
5470e19ae0 | ||
|
|
cd2c18b77c |
@@ -313,6 +313,8 @@ const en: Messages = {
|
||||
'vault.groups.createDialog.desc': 'Create a new group for organizing hosts.',
|
||||
'vault.groups.renameDialogTitle': 'Rename Group',
|
||||
'vault.groups.renameDialog.desc': 'Rename an existing group.',
|
||||
'vault.groups.deleteDialogTitle': 'Delete Group',
|
||||
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
|
||||
'vault.groups.field.name': 'Group Name',
|
||||
'vault.groups.placeholder.example': 'e.g. Production',
|
||||
'vault.groups.parentLabel': 'Parent',
|
||||
@@ -328,6 +330,9 @@ const en: Messages = {
|
||||
'vault.hosts.connect': 'Connect',
|
||||
'vault.view.grid': 'Grid',
|
||||
'vault.view.list': 'List',
|
||||
'vault.view.tree': 'Tree',
|
||||
'vault.tree.expandAll': 'Expand All',
|
||||
'vault.tree.collapseAll': 'Collapse All',
|
||||
'vault.hosts.newHost': 'New Host',
|
||||
'vault.hosts.newGroup': 'New Group',
|
||||
'vault.hosts.import': 'Import',
|
||||
@@ -1162,6 +1167,14 @@ const en: Messages = {
|
||||
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Rename Package',
|
||||
'snippets.renameDialog.currentPath': 'Current path: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Enter new name',
|
||||
'snippets.renameDialog.error.empty': 'Package name cannot be empty',
|
||||
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Connect to Serial Port',
|
||||
|
||||
@@ -184,6 +184,8 @@ const zhCN: Messages = {
|
||||
'vault.groups.createDialog.desc': '创建新的分组用于组织主机。',
|
||||
'vault.groups.renameDialogTitle': '重命名分组',
|
||||
'vault.groups.renameDialog.desc': '重命名已有分组。',
|
||||
'vault.groups.deleteDialogTitle': '删除分组',
|
||||
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
|
||||
'vault.groups.field.name': '分组名称',
|
||||
'vault.groups.placeholder.example': '例如:Production',
|
||||
'vault.groups.parentLabel': '父级',
|
||||
@@ -199,6 +201,9 @@ const zhCN: Messages = {
|
||||
'vault.hosts.connect': '连接',
|
||||
'vault.view.grid': '网格',
|
||||
'vault.view.list': '列表',
|
||||
'vault.view.tree': '树形',
|
||||
'vault.tree.expandAll': '展开全部',
|
||||
'vault.tree.collapseAll': '折叠全部',
|
||||
'vault.hosts.newHost': '新建主机',
|
||||
'vault.hosts.newGroup': '新建分组',
|
||||
'vault.hosts.import': '导入',
|
||||
@@ -1151,6 +1156,14 @@ const zhCN: Messages = {
|
||||
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
|
||||
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': '重命名代码包',
|
||||
'snippets.renameDialog.currentPath': '当前路径:{path}',
|
||||
'snippets.renameDialog.placeholder': '输入新名称',
|
||||
'snippets.renameDialog.error.empty': '代码包名称不能为空',
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
|
||||
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': '串口',
|
||||
'serial.modal.title': '连接串口',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export type ViewMode = "grid" | "list";
|
||||
export type ViewMode = "grid" | "list" | "tree";
|
||||
|
||||
const isViewMode = (value: string | null): value is ViewMode =>
|
||||
value === "grid" || value === "list";
|
||||
value === "grid" || value === "list" || value === "tree";
|
||||
|
||||
export const useStoredViewMode = (
|
||||
storageKey: string,
|
||||
|
||||
47
application/state/useTreeExpandedState.ts
Normal file
47
application/state/useTreeExpandedState.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const useTreeExpandedState = (storageKey: string) => {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
if (stored) {
|
||||
try {
|
||||
const paths = JSON.parse(stored) as string[];
|
||||
return new Set(paths);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
return new Set();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const pathsArray = Array.from(expandedPaths);
|
||||
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
|
||||
}, [storageKey, expandedPaths]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedPaths(newExpanded);
|
||||
};
|
||||
|
||||
const expandAll = (allPaths: string[]) => {
|
||||
setExpandedPaths(new Set(allPaths));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedPaths(new Set());
|
||||
};
|
||||
|
||||
return {
|
||||
expandedPaths,
|
||||
togglePath,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
};
|
||||
};
|
||||
477
components/HostTreeView.tsx
Normal file
477
components/HostTreeView.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import { ChevronRight, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
import { sanitizeHost } from '../domain/host';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface HostTreeViewProps {
|
||||
groupTree: GroupNode[];
|
||||
hosts: Host[];
|
||||
sortMode?: 'az' | 'za' | 'newest' | 'oldest';
|
||||
expandedPaths?: Set<string>;
|
||||
onTogglePath?: (path: string) => void;
|
||||
onExpandAll?: (paths: string[]) => void;
|
||||
onCollapseAll?: () => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: GroupNode;
|
||||
depth: number;
|
||||
sortMode: 'az' | 'za' | 'newest' | 'oldest';
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
}
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
depth,
|
||||
sortMode,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
const nodes = Object.values(node.children) as unknown as GroupNode[];
|
||||
return nodes.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'az':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [node.children, sortMode]);
|
||||
|
||||
const sortedHosts = useMemo(() => {
|
||||
return [...node.hosts].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'newest':
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
}, [node.hosts, sortMode]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Group Node */}
|
||||
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
|
||||
{(hasChildren || node.hosts.length > 0) && (
|
||||
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
|
||||
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
|
||||
</div>
|
||||
<span className="truncate flex-1 font-semibold">{node.name}</span>
|
||||
{(node.hosts.length > 0 || hasChildren) && (
|
||||
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
|
||||
{node.hosts.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onNewHost(node.path)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
|
||||
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteGroup(node.path)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<CollapsibleContent>
|
||||
{/* Child Groups */}
|
||||
{childNodes.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
sortMode={sortMode}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hosts in this group */}
|
||||
{sortedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
depth={depth + 1}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface HostTreeItemProps {
|
||||
host: Host;
|
||||
depth: number;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
}
|
||||
|
||||
const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
host,
|
||||
depth,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
const isTelnet = host.protocol === 'telnet';
|
||||
const displayUsername = isTelnet
|
||||
? (host.telnetUsername?.trim() || host.username?.trim() || '')
|
||||
: (host.username?.trim() || '');
|
||||
const displayPort = isTelnet
|
||||
? (host.telnetPort ?? host.port ?? 23)
|
||||
: (host.port ?? 22);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className="flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg"
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => onConnect(safeHost)}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4" />
|
||||
<div className="mr-3 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{host.protocol && host.protocol !== 'ssh' && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
|
||||
{host.protocol.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<span className="text-xs opacity-60">
|
||||
{tags.slice(0, 2).join(', ')}
|
||||
{tags.length > 2 && '...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onConnect(safeHost)}>
|
||||
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteHost(host)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
groupTree,
|
||||
hosts,
|
||||
sortMode = 'az',
|
||||
expandedPaths: externalExpandedPaths,
|
||||
onTogglePath: externalOnTogglePath,
|
||||
onExpandAll: externalOnExpandAll,
|
||||
onCollapseAll: externalOnCollapseAll,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Use external state if provided, otherwise use local persistent state
|
||||
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
|
||||
const expandedPaths = externalExpandedPaths || localTreeState.expandedPaths;
|
||||
const togglePath = externalOnTogglePath || localTreeState.togglePath;
|
||||
const expandAll = externalOnExpandAll || localTreeState.expandAll;
|
||||
const collapseAll = externalOnCollapseAll || localTreeState.collapseAll;
|
||||
|
||||
// Get all possible group paths for expand/collapse all functionality
|
||||
const getAllGroupPaths = (nodes: GroupNode[]): string[] => {
|
||||
const paths: string[] = [];
|
||||
const traverse = (nodeList: GroupNode[]) => {
|
||||
nodeList.forEach(node => {
|
||||
paths.push(node.path);
|
||||
if (node.children) {
|
||||
traverse(Object.values(node.children) as GroupNode[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
traverse(nodes);
|
||||
return paths;
|
||||
};
|
||||
|
||||
const allGroupPaths = useMemo(() => getAllGroupPaths(groupTree), [groupTree]);
|
||||
|
||||
const handleExpandAll = () => {
|
||||
expandAll(allGroupPaths);
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
collapseAll();
|
||||
};
|
||||
|
||||
// Get ungrouped hosts (hosts without a group or with empty group) and sort them
|
||||
const ungroupedHosts = useMemo(() => {
|
||||
const hosts_without_group = hosts.filter(host => !host.group || host.group === '');
|
||||
return hosts_without_group.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'newest':
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
}, [hosts, sortMode]);
|
||||
|
||||
// Sort group tree based on sort mode
|
||||
const sortedGroupTree = useMemo(() => {
|
||||
return [...groupTree].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'az':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [groupTree, sortMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* Expand/Collapse controls */}
|
||||
{groupTree.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExpandAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Expand size={12} className="mr-1" />
|
||||
{t("vault.tree.expandAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCollapseAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Minimize2 size={12} className="mr-1" />
|
||||
{t("vault.tree.collapseAll")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group tree */}
|
||||
{sortedGroupTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
sortMode={sortMode}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={togglePath}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ungrouped hosts at root level */}
|
||||
{ungroupedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
depth={0}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{ungroupedHosts.length === 0 && groupTree.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Server size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{t("vault.hosts.empty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -67,6 +67,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
|
||||
|
||||
// Rename package state
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renamingPackagePath, setRenamingPackagePath] = useState<string | null>(null);
|
||||
const [renamePackageName, setRenamePackageName] = useState('');
|
||||
const [renameError, setRenameError] = useState('');
|
||||
|
||||
// Search, sort, and view mode state
|
||||
const [search, setSearch] = useState('');
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
@@ -144,23 +150,60 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const displayedPackages = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
const roots = packages
|
||||
// Separate absolute paths (starting with /) from relative paths
|
||||
const absolutePaths = packages.filter(p => p.startsWith('/'));
|
||||
const relativePaths = packages.filter(p => !p.startsWith('/'));
|
||||
|
||||
const results: { name: string; path: string; count: number }[] = [];
|
||||
|
||||
// Process relative paths (traditional behavior)
|
||||
const relativeRoots = relativePaths
|
||||
.map((p) => p.split('/')[0])
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(roots)).map((name) => {
|
||||
const path = name;
|
||||
const count = snippets.filter((s) => (s.package || '') === path).length;
|
||||
return { name, path, count };
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(relativeRoots)).forEach((name: string) => {
|
||||
const path: string = name;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name, path, count });
|
||||
});
|
||||
|
||||
// Process absolute paths - show them as separate roots with "/" prefix
|
||||
const absoluteRoots = absolutePaths
|
||||
.map((p) => {
|
||||
const cleanPath = p.substring(1); // Remove leading slash
|
||||
const firstSegment = cleanPath.split('/')[0];
|
||||
return firstSegment;
|
||||
})
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
|
||||
const path: string = `/${name}`;
|
||||
const displayName: string = `/${name}`; // Show with leading slash to distinguish
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name: displayName, path, count });
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const prefix = selectedPackage + '/';
|
||||
const children = packages
|
||||
.filter((p) => p.startsWith(prefix))
|
||||
.map((p) => p.replace(prefix, '').split('/')[0])
|
||||
.filter(Boolean);
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
return Array.from(new Set(children)).map((name) => {
|
||||
const path = `${selectedPackage}/${name}`;
|
||||
const count = snippets.filter((s) => (s.package || '') === path).length;
|
||||
// Count snippets in this package AND all nested packages
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
return { name, path, count };
|
||||
});
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
@@ -191,28 +234,76 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!selectedPackage) return [];
|
||||
const isAbsolute = selectedPackage.startsWith('/');
|
||||
const parts = selectedPackage.split('/').filter(Boolean);
|
||||
return parts.map((name, idx) => ({ name, path: parts.slice(0, idx + 1).join('/') }));
|
||||
return parts.map((name, idx) => {
|
||||
const pathSegments = parts.slice(0, idx + 1);
|
||||
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
|
||||
return { name, path };
|
||||
});
|
||||
}, [selectedPackage]);
|
||||
|
||||
const createPackage = () => {
|
||||
const name = newPackageName.trim();
|
||||
if (!name) return;
|
||||
const full = selectedPackage ? `${selectedPackage}/${name}` : name;
|
||||
if (!packages.includes(full)) onPackagesChange([...packages, full]);
|
||||
|
||||
// Allow leading slash and validate the rest - allow hyphens anywhere in package names
|
||||
if (!/^\/?([\w-]+(\/[\w-]+)*)\/?$/.test(name)) {
|
||||
// Could add toast notification here for invalid characters
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize path construction to avoid double slashes
|
||||
let full: string;
|
||||
if (selectedPackage) {
|
||||
// Strip leading slash from name when we're inside a package to avoid double slashes
|
||||
const normalizedName = name.startsWith('/') ? name.substring(1) : name;
|
||||
full = `${selectedPackage}/${normalizedName}`;
|
||||
} else {
|
||||
// At root level, preserve the leading slash if user intended it
|
||||
full = name;
|
||||
}
|
||||
|
||||
// Strip trailing slash to ensure consistent path handling
|
||||
if (full.endsWith('/')) {
|
||||
full = full.slice(0, -1);
|
||||
}
|
||||
|
||||
// Check for duplicate package names (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === full.toLowerCase());
|
||||
if (existingPackage) {
|
||||
// Could add toast notification here for duplicate package
|
||||
return;
|
||||
}
|
||||
|
||||
onPackagesChange([...packages, full]);
|
||||
setNewPackageName('');
|
||||
setIsPackageDialogOpen(false);
|
||||
};
|
||||
|
||||
const deletePackage = (path: string) => {
|
||||
// Remove the package and all its children
|
||||
const keep = packages.filter((p) => !(p === path || p.startsWith(path + '/')));
|
||||
|
||||
// Move all snippets from deleted packages to root
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === path || s.package.startsWith(path + '/')) return { ...s, package: '' };
|
||||
if (s.package === path || s.package.startsWith(path + '/')) {
|
||||
return { ...s, package: '' };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
// Update packages first, then save snippets
|
||||
onPackagesChange(keep);
|
||||
updatedSnippets.forEach(onSave);
|
||||
|
||||
// Only save snippets that were actually modified
|
||||
const modifiedSnippets = updatedSnippets.filter((s, index) =>
|
||||
s.package !== snippets[index].package
|
||||
);
|
||||
modifiedSnippets.forEach(onSave);
|
||||
|
||||
// Reset selected package if it was deleted
|
||||
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
|
||||
setSelectedPackage(null);
|
||||
}
|
||||
@@ -220,24 +311,125 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const movePackage = (source: string, target: string | null) => {
|
||||
const name = source.split('/').pop() || '';
|
||||
const newPath = target ? `${target}/${name}` : name;
|
||||
const isAbsolute = source.startsWith('/');
|
||||
const newPath = target ? `${target}/${name}` : (isAbsolute ? `/${name}` : name);
|
||||
if (newPath === source || newPath.startsWith(source + '/')) return;
|
||||
|
||||
// Check if target path already exists
|
||||
if (packages.includes(newPath)) return;
|
||||
|
||||
const updatedPackages = packages.map((p) => {
|
||||
if (p === source) return newPath;
|
||||
if (p.startsWith(source + '/')) return p.replace(source, newPath);
|
||||
// Use more precise replacement to avoid substring issues
|
||||
if (p.startsWith(source + '/')) {
|
||||
return newPath + p.substring(source.length);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === source) return { ...s, package: newPath };
|
||||
if (s.package.startsWith(source + '/')) return { ...s, package: s.package.replace(source, newPath) };
|
||||
// Use more precise replacement to avoid substring issues
|
||||
if (s.package.startsWith(source + '/')) {
|
||||
return { ...s, package: newPath + s.package.substring(source.length) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
if (selectedPackage === source) setSelectedPackage(newPath);
|
||||
};
|
||||
|
||||
const openRenameDialog = (path: string) => {
|
||||
const name = path.split('/').pop() || '';
|
||||
setRenamingPackagePath(path);
|
||||
setRenamePackageName(name);
|
||||
setRenameError('');
|
||||
setIsRenameDialogOpen(true);
|
||||
};
|
||||
|
||||
const renamePackage = () => {
|
||||
if (!renamingPackagePath) return;
|
||||
|
||||
const newName = renamePackageName.trim();
|
||||
|
||||
// Validate: empty name
|
||||
if (!newName) {
|
||||
setRenameError(t('snippets.renameDialog.error.empty'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: same rules as createPackage - only allow letters, numbers, hyphens, underscores
|
||||
// Since we're renaming a single segment (no slashes allowed), use the segment-level pattern
|
||||
if (!/^[\w-]+$/.test(newName)) {
|
||||
setRenameError(t('snippets.renameDialog.error.invalidChars'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build new path
|
||||
const parts = renamingPackagePath.split('/');
|
||||
parts[parts.length - 1] = newName;
|
||||
const newPath = parts.join('/');
|
||||
|
||||
// Validate: same name
|
||||
if (newPath === renamingPackagePath) {
|
||||
setIsRenameDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: duplicate (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
|
||||
if (existingPackage) {
|
||||
setRenameError(t('snippets.renameDialog.error.duplicate'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Update all packages with this path or nested under it
|
||||
const updatedPackages = packages.map((p) => {
|
||||
if (p === renamingPackagePath) return newPath;
|
||||
if (p.startsWith(renamingPackagePath + '/')) {
|
||||
return newPath + p.substring(renamingPackagePath.length);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
// Update all snippets with this package or nested under it
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === renamingPackagePath) return { ...s, package: newPath };
|
||||
if (s.package.startsWith(renamingPackagePath + '/')) {
|
||||
return { ...s, package: newPath + s.package.substring(renamingPackagePath.length) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
|
||||
// Update selected package if it was renamed
|
||||
if (selectedPackage === renamingPackagePath) {
|
||||
setSelectedPackage(newPath);
|
||||
} else if (selectedPackage?.startsWith(renamingPackagePath + '/')) {
|
||||
setSelectedPackage(newPath + selectedPackage.substring(renamingPackagePath.length));
|
||||
}
|
||||
|
||||
// Update editingSnippet.package if it's in the renamed package (fixes stale state when editing)
|
||||
if (editingSnippet.package) {
|
||||
if (editingSnippet.package === renamingPackagePath) {
|
||||
setEditingSnippet(prev => ({ ...prev, package: newPath }));
|
||||
} else if (editingSnippet.package.startsWith(renamingPackagePath + '/')) {
|
||||
setEditingSnippet(prev => ({
|
||||
...prev,
|
||||
package: newPath + prev.package!.substring(renamingPackagePath.length)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
setIsRenameDialogOpen(false);
|
||||
};
|
||||
|
||||
const moveSnippet = (id: string, pkg: string | null) => {
|
||||
const sn = snippets.find((s) => s.id === id);
|
||||
if (!sn) return;
|
||||
@@ -246,11 +438,36 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
// Package options for Combobox
|
||||
const packageOptions: ComboboxOption[] = useMemo(() => {
|
||||
return packages.map(p => ({
|
||||
value: p,
|
||||
label: p.includes('/') ? p.split('/').pop()! : p,
|
||||
sublabel: p.includes('/') ? p : undefined,
|
||||
}));
|
||||
// Generate all possible parent paths for each package
|
||||
const allPaths = new Set<string>();
|
||||
|
||||
packages.forEach(pkg => {
|
||||
// Add the full package path
|
||||
allPaths.add(pkg);
|
||||
|
||||
// Add all parent paths
|
||||
const parts = pkg.split('/').filter(Boolean);
|
||||
const isAbsolute = pkg.startsWith('/');
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const parentPath = (isAbsolute ? '/' : '') + parts.slice(0, i).join('/');
|
||||
allPaths.add(parentPath);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(allPaths)
|
||||
.sort((a, b) => {
|
||||
// Sort by depth first (shorter paths first), then alphabetically
|
||||
const depthA = (a.match(/\//g) || []).length;
|
||||
const depthB = (b.match(/\//g) || []).length;
|
||||
if (depthA !== depthB) return depthA - depthB;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map(p => ({
|
||||
value: p,
|
||||
label: p.includes('/') ? p.split('/').pop()! : p,
|
||||
sublabel: p.includes('/') ? p : undefined,
|
||||
}));
|
||||
}, [packages]);
|
||||
|
||||
// Shell history lazy loading
|
||||
@@ -354,7 +571,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<Combobox
|
||||
options={packageOptions}
|
||||
value={editingSnippet.package || selectedPackage || ''}
|
||||
onValueChange={(val) => setEditingSnippet({ ...editingSnippet, package: val })}
|
||||
onValueChange={(val) => {
|
||||
setEditingSnippet({ ...editingSnippet, package: val });
|
||||
// If selecting an implicit parent path, persist it to packages
|
||||
if (val && !packages.includes(val)) {
|
||||
onPackagesChange([...packages, val]);
|
||||
}
|
||||
}}
|
||||
placeholder={t('snippets.field.packagePlaceholder')}
|
||||
allowCreate={true}
|
||||
onCreateNew={(val) => {
|
||||
@@ -624,6 +847,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => setSelectedPackage(pkg.path)}>{t('action.open')}</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => deletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -729,6 +953,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
value={newPackageName}
|
||||
onChange={(e) => setNewPackageName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
|
||||
pattern="^/?([\w-]+(/[\w-]+)*)?/?$"
|
||||
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
|
||||
</div>
|
||||
@@ -742,6 +968,40 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rename Package Dialog */}
|
||||
{isRenameDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<Card className="w-full max-w-sm p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{t('snippets.renameDialog.title')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('snippets.renameDialog.currentPath', { path: renamingPackagePath })}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('field.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('snippets.renameDialog.placeholder')}
|
||||
value={renamePackageName}
|
||||
onChange={(e) => {
|
||||
setRenamePackageName(e.target.value);
|
||||
setRenameError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && renamePackage()}
|
||||
/>
|
||||
{renameError && (
|
||||
<p className="text-[11px] text-destructive">{renameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsRenameDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={renamePackage}>{t('common.rename')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Panel */}
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Key,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Network,
|
||||
Plug,
|
||||
Plus,
|
||||
Search,
|
||||
@@ -25,10 +26,11 @@ import {
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { sanitizeHost } from "../domain/host";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
ConnectionLog,
|
||||
@@ -46,6 +48,7 @@ import {
|
||||
import { AppLogo } from "./AppLogo";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import HostDetailsPanel from "./HostDetailsPanel";
|
||||
import { HostTreeView } from "./HostTreeView";
|
||||
import KeychainManager from "./KeychainManager";
|
||||
import KnownHostsManager from "./KnownHostsManager";
|
||||
import PortForwarding from "./PortForwardingNew";
|
||||
@@ -166,6 +169,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
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);
|
||||
|
||||
// Handle external navigation requests
|
||||
useEffect(() => {
|
||||
@@ -180,12 +185,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
STORAGE_KEY_VAULT_HOSTS_VIEW_MODE,
|
||||
"grid",
|
||||
);
|
||||
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
// 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);
|
||||
|
||||
// Quick connect state
|
||||
const [quickConnectTarget, setQuickConnectTarget] = useState<{
|
||||
@@ -296,6 +303,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
const handleNewHost = useCallback(() => {
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
setIsHostPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -540,6 +548,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return root;
|
||||
}, [hosts, customGroups]);
|
||||
|
||||
|
||||
const findGroupNode = (path: string | null): GroupNode | null => {
|
||||
if (!path)
|
||||
return {
|
||||
@@ -606,6 +615,79 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return filtered;
|
||||
}, [hosts, selectedGroupPath, search, selectedTags, sortMode]);
|
||||
|
||||
// For tree view: apply search, tag filter, and sorting, but not group filtering
|
||||
const treeViewHosts = useMemo(() => {
|
||||
let filtered = hosts;
|
||||
if (search.trim()) {
|
||||
const s = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(h) =>
|
||||
h.label.toLowerCase().includes(s) ||
|
||||
h.hostname.toLowerCase().includes(s) ||
|
||||
h.tags.some((t) => t.toLowerCase().includes(s)),
|
||||
);
|
||||
}
|
||||
// Apply tag filter
|
||||
if (selectedTags.length > 0) {
|
||||
filtered = filtered.filter((h) =>
|
||||
selectedTags.some((t) => h.tags?.includes(t)),
|
||||
);
|
||||
}
|
||||
// Apply sorting
|
||||
filtered = [...filtered].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case "az":
|
||||
return a.label.localeCompare(b.label);
|
||||
case "za":
|
||||
return b.label.localeCompare(a.label);
|
||||
case "newest":
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case "oldest":
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}, [hosts, search, selectedTags, sortMode]);
|
||||
|
||||
// Create a separate group tree for tree view that uses filtered hosts
|
||||
const buildTreeViewGroupTree = useMemo<Record<string, GroupNode>>(() => {
|
||||
const root: Record<string, GroupNode> = {};
|
||||
const insertPath = (path: string, host?: Host) => {
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
let currentLevel = root;
|
||||
let currentPath = "";
|
||||
parts.forEach((part, index) => {
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
||||
if (!currentLevel[part]) {
|
||||
currentLevel[part] = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
children: {},
|
||||
hosts: [],
|
||||
};
|
||||
}
|
||||
if (host && index === parts.length - 1)
|
||||
currentLevel[part].hosts.push(host);
|
||||
currentLevel = currentLevel[part].children;
|
||||
});
|
||||
};
|
||||
customGroups.forEach((path) => insertPath(path));
|
||||
// Use filtered hosts (treeViewHosts) instead of all hosts to respect search/tag filters
|
||||
treeViewHosts.forEach((host) => {
|
||||
if (host.group && host.group.trim() !== "") {
|
||||
insertPath(host.group, host);
|
||||
}
|
||||
});
|
||||
return root;
|
||||
}, [treeViewHosts, customGroups]);
|
||||
|
||||
// Create tree view specific group tree that excludes ungrouped hosts
|
||||
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
|
||||
return (Object.values(buildTreeViewGroupTree) as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [buildTreeViewGroupTree]);
|
||||
|
||||
// Compute all unique tags across all hosts
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
@@ -986,8 +1068,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 app-no-drag">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
) : viewMode === "list" ? (
|
||||
<List size={16} />
|
||||
) : (
|
||||
<Network size={16} />
|
||||
)}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
@@ -1007,6 +1091,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
>
|
||||
<List size={14} /> {t("vault.view.list")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "tree" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("tree")}
|
||||
>
|
||||
<Network size={14} /> {t("vault.view.tree")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
<TagFilterDropdown
|
||||
@@ -1111,43 +1202,45 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{currentSection === "hosts" && (
|
||||
<>
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<button
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setSelectedGroupPath(null)}
|
||||
>
|
||||
{t("vault.hosts.allHosts")}
|
||||
</button>
|
||||
{selectedGroupPath &&
|
||||
selectedGroupPath
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part, idx, arr) => {
|
||||
const crumbPath = arr.slice(0, idx + 1).join("/");
|
||||
const isLast = idx === arr.length - 1;
|
||||
return (
|
||||
<span
|
||||
key={crumbPath}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<button
|
||||
className={cn(
|
||||
isLast
|
||||
? "text-foreground font-semibold"
|
||||
: "text-primary hover:underline",
|
||||
)}
|
||||
onClick={() =>
|
||||
setSelectedGroupPath(crumbPath)
|
||||
}
|
||||
{viewMode !== "tree" && (
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<button
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setSelectedGroupPath(null)}
|
||||
>
|
||||
{t("vault.hosts.allHosts")}
|
||||
</button>
|
||||
{selectedGroupPath &&
|
||||
selectedGroupPath
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part, idx, arr) => {
|
||||
const crumbPath = arr.slice(0, idx + 1).join("/");
|
||||
const isLast = idx === arr.length - 1;
|
||||
return (
|
||||
<span
|
||||
key={crumbPath}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{displayedGroups.length > 0 && (
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<button
|
||||
className={cn(
|
||||
isLast
|
||||
? "text-foreground font-semibold"
|
||||
: "text-primary hover:underline",
|
||||
)}
|
||||
onClick={() =>
|
||||
setSelectedGroupPath(crumbPath)
|
||||
}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{viewMode !== "tree" && displayedGroups.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
@@ -1159,26 +1252,27 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
displayedGroups.length === 0 ? "hidden" : "",
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, selectedGroupPath);
|
||||
if (groupPath && selectedGroupPath !== null)
|
||||
moveGroup(groupPath, selectedGroupPath);
|
||||
}}
|
||||
>
|
||||
{viewMode !== "tree" && (
|
||||
<div
|
||||
className={cn(
|
||||
displayedGroups.length === 0 ? "hidden" : "",
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, selectedGroupPath);
|
||||
if (groupPath && selectedGroupPath !== null)
|
||||
moveGroup(groupPath, selectedGroupPath);
|
||||
}}
|
||||
>
|
||||
{displayedGroups.map((node) => (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
@@ -1257,6 +1351,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
@@ -1266,120 +1361,161 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("vault.hosts.header.entries", { count: displayedHosts.length })}
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
|
||||
</span>
|
||||
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
|
||||
{t("vault.hosts.header.live", { count: sessions.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{displayedHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const distroBadge = {
|
||||
text: (safeHost.os || "L")[0].toUpperCase(),
|
||||
label: safeHost.distro || safeHost.os || "Linux",
|
||||
};
|
||||
return (
|
||||
<ContextMenu key={host.id}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => handleHostConnect(safeHost)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<DistroAvatar
|
||||
host={safeHost}
|
||||
fallback={distroBadge.text}
|
||||
/>
|
||||
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
|
||||
<div className="text-sm font-semibold truncate leading-5">
|
||||
{safeHost.label}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "list" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
hosts={treeViewHosts} // Use filtered and sorted hosts for tree view
|
||||
sortMode={sortMode}
|
||||
expandedPaths={treeExpandedState.expandedPaths}
|
||||
onTogglePath={treeExpandedState.togglePath}
|
||||
onExpandAll={treeExpandedState.expandAll}
|
||||
onCollapseAll={treeExpandedState.collapseAll}
|
||||
onConnect={handleHostConnect}
|
||||
onEditHost={handleEditHost}
|
||||
onDuplicateHost={handleDuplicateHost}
|
||||
onDeleteHost={(host) => onDeleteHost(host.id)}
|
||||
onCopyCredentials={handleCopyCredentials}
|
||||
onNewHost={(groupPath) => {
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(groupPath || null);
|
||||
setIsHostPanelOpen(true);
|
||||
}}
|
||||
onNewGroup={(parentPath) => {
|
||||
setTargetParentPath(parentPath || null);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
onEditGroup={(groupPath) => {
|
||||
setRenameTargetPath(groupPath);
|
||||
const groupName = groupPath.split('/').pop() || '';
|
||||
setRenameGroupName(groupName);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
onDeleteGroup={(groupPath) => {
|
||||
setDeleteTargetPath(groupPath);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{displayedHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const distroBadge = {
|
||||
text: (safeHost.os || "L")[0].toUpperCase(),
|
||||
label: safeHost.distro || safeHost.os || "Linux",
|
||||
};
|
||||
return (
|
||||
<ContextMenu key={host.id}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => handleHostConnect(safeHost)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<DistroAvatar
|
||||
host={safeHost}
|
||||
fallback={distroBadge.text}
|
||||
/>
|
||||
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
|
||||
<div className="text-sm font-semibold truncate leading-5">
|
||||
{safeHost.label}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "list" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleHostConnect(host)}
|
||||
>
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleEditHost(host)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleDuplicateHost(host)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleCopyCredentials(host)}
|
||||
>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteHost(host.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
{displayedHosts.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleHostConnect(host)}
|
||||
>
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleEditHost(host)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleDuplicateHost(host)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleCopyCredentials(host)}
|
||||
>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteHost(host.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
{displayedHosts.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
</p>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
@@ -1506,7 +1642,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
)}
|
||||
allTags={allTags}
|
||||
allHosts={hosts}
|
||||
defaultGroup={editingHost ? undefined : selectedGroupPath}
|
||||
defaultGroup={editingHost ? undefined : (newHostGroupPath || selectedGroupPath)}
|
||||
onSave={(host) => {
|
||||
// Check if host already exists in the list (for updates vs. new/duplicate)
|
||||
const hostExists = hosts.some((h) => h.id === host.id);
|
||||
@@ -1517,10 +1653,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
onCreateGroup={(groupPath) => {
|
||||
onUpdateCustomGroups(
|
||||
@@ -1647,6 +1785,49 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={isDeleteGroupOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeleteGroupOpen(open);
|
||||
if (!open) {
|
||||
setDeleteTargetPath(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("vault.groups.deleteDialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("vault.groups.deleteDialog.desc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{deleteTargetPath && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("vault.groups.pathLabel")}:{" "}
|
||||
<span className="font-mono">{deleteTargetPath}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setIsDeleteGroupOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (deleteTargetPath) {
|
||||
deleteGroupPath(deleteTargetPath);
|
||||
}
|
||||
setIsDeleteGroupOpen(false);
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ImportVaultDialog
|
||||
open={isImportOpen}
|
||||
onOpenChange={setIsImportOpen}
|
||||
|
||||
@@ -28,6 +28,7 @@ export const STORAGE_KEY_SHELL_HISTORY = 'netcatty_shell_history_v1';
|
||||
export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
|
||||
export const STORAGE_KEY_IDENTITIES = 'netcatty_identities_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1';
|
||||
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
|
||||
|
||||
32
package-lock.json
generated
32
package-lock.json
generated
@@ -1007,7 +1007,6 @@
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -1654,6 +1653,7 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1675,6 +1675,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1691,6 +1692,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1705,6 +1707,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -5618,7 +5621,6 @@
|
||||
"integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
@@ -5648,7 +5650,6 @@
|
||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
@@ -5927,8 +5928,7 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
@@ -5960,7 +5960,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5993,7 +5992,6 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6401,7 +6399,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7061,7 +7058,8 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7301,7 +7299,6 @@
|
||||
"integrity": "sha512-ce4Ogns4VMeisIuCSK0C62umG0lFy012jd8LMZ6w/veHUeX4fqfDrGe+HTWALAEwK6JwKP+dhPvizhArSOsFbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.4.0",
|
||||
"builder-util": "26.3.4",
|
||||
@@ -7627,6 +7624,7 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7647,6 +7645,7 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7871,7 +7870,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10154,7 +10152,6 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -10780,7 +10777,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10839,6 +10835,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -10856,6 +10853,7 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -10956,7 +10954,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10966,7 +10963,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11895,6 +11891,7 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -11958,6 +11955,7 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -11972,6 +11970,7 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -12133,7 +12132,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12336,7 +12334,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12691,7 +12688,6 @@
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user