mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-09-24 15:49:09 +00:00
cd2c18b77c
- Add tree view mode alongside existing grid and list views - Implement hierarchical display of hosts organized by groups - Add expand/collapse all controls with Chinese translations - Support all sorting modes (A-Z, Z-A, newest, oldest) in tree view - Persist expand/collapse state across view switches and app restarts - Hide Groups section in tree view to avoid duplication - Display ungrouped hosts at root level instead of "General" group - Add missing delete group dialog with proper translations - Maintain full functionality: search, filtering, drag-drop, context menus Technical changes: - Create HostTreeView component with TreeNode recursive structure - Add useTreeExpandedState hook for persistent state management - Extend ViewMode type to include "tree" option - Add sortMode prop to enable dynamic sorting in tree structure - Separate group tree logic for tree view vs other view modes - Add comprehensive English and Chinese translations
467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
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 { 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,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const paddingLeft = `${depth * 20 + 12}px`;
|
|
|
|
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)}
|
|
onDoubleClick={() => onConnect(host)}
|
|
>
|
|
<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">
|
|
{host.username}@{host.hostname}:{host.port}
|
|
</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>
|
|
)}
|
|
{host.tags.length > 0 && (
|
|
<span className="text-xs opacity-60">
|
|
{host.tags.slice(0, 2).join(', ')}
|
|
{host.tags.length > 2 && '...'}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem onClick={() => onConnect(host)}>
|
|
<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>
|
|
)}
|
|
|
|
{/* 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}
|
|
/>
|
|
))}
|
|
|
|
{/* 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}
|
|
/>
|
|
))}
|
|
|
|
{/* 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>
|
|
);
|
|
}; |