Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
131 lines
5.3 KiB
TypeScript
131 lines
5.3 KiB
TypeScript
import React from 'react';
|
|
import { ChevronRight, CornerUpLeft, Folder, FolderOpen, Loader2 } from 'lucide-react';
|
|
import type { SftpFileEntry } from '../../types';
|
|
import { formatBytes, formatDate, getFileIcon, isNavigableDirectory, type SftpColumnVisibility } from './utils';
|
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
|
import { cn } from '../../lib/utils';
|
|
|
|
export type NodeDescriptor =
|
|
| { type: 'node'; entry: SftpFileEntry; entryPath: string; depth: number; isExpanded: boolean; isLoading: boolean }
|
|
| { type: 'loading' | 'error'; key: string; depth: number };
|
|
|
|
// ── Simplified TreeNode (no per-node ContextMenu) ────────────────────
|
|
|
|
interface TreeNodeProps {
|
|
entry: SftpFileEntry;
|
|
entryPath: string;
|
|
depth: number;
|
|
columnTemplate: string;
|
|
visibleColumns: SftpColumnVisibility;
|
|
isSelected: boolean;
|
|
isExpanded: boolean;
|
|
isLoading: boolean;
|
|
isDragOver: boolean;
|
|
onToggleExpand: (entry: SftpFileEntry, entryPath: string) => void;
|
|
onNodeClick: (entry: SftpFileEntry, entryPath: string, e: React.MouseEvent) => void;
|
|
onOpenEntry: (entry: SftpFileEntry, entryPath: string) => void;
|
|
onDragStart: (entry: SftpFileEntry, entryPath: string, isDir: boolean, e: React.DragEvent) => void;
|
|
onDragEnd: () => void;
|
|
onDragOverEntry: (entryPath: string, e: React.DragEvent) => void;
|
|
onDropEntry: (entryPath: string, e: React.DragEvent) => void;
|
|
onDragLeaveEntry: () => void;
|
|
onContextMenu: (entry: SftpFileEntry, entryPath: string, e: React.MouseEvent) => void;
|
|
}
|
|
|
|
export const TREE_ROW_HEIGHT = 28;
|
|
|
|
export const TreeNode = React.memo<TreeNodeProps>(({
|
|
entry, entryPath, depth, columnTemplate, visibleColumns, isSelected,
|
|
isExpanded, isLoading, isDragOver,
|
|
onToggleExpand, onNodeClick, onOpenEntry, onDragStart, onDragEnd,
|
|
onDragOverEntry, onDropEntry, onDragLeaveEntry,
|
|
onContextMenu,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const isParentEntry = entry.name === '..';
|
|
const isDir = isNavigableDirectory(entry);
|
|
const icon = isDir
|
|
? (isExpanded
|
|
? <FolderOpen size={14} className="shrink-0 text-yellow-500" />
|
|
: <Folder size={14} className="shrink-0 text-yellow-500" />)
|
|
: getFileIcon(entry);
|
|
|
|
return (
|
|
<div
|
|
data-section="terminal-sftp-tree-row"
|
|
data-entry-name={entry.name}
|
|
data-entry-type={isDir ? 'directory' : entry.type}
|
|
data-selected={isSelected ? 'true' : 'false'}
|
|
data-expanded={isDir ? (isExpanded ? 'true' : 'false') : undefined}
|
|
data-drag-over={isDragOver ? 'true' : 'false'}
|
|
className={cn(
|
|
'grid items-center gap-x-1 px-2 cursor-pointer select-none text-sm',
|
|
isSelected
|
|
? 'bg-accent text-accent-foreground hover:bg-accent'
|
|
: 'hover:bg-accent/50',
|
|
isDragOver && 'ring-2 ring-primary/50 ring-inset bg-primary/10',
|
|
)}
|
|
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
|
|
onClick={e => onNodeClick(entry, entryPath, e)}
|
|
onDoubleClick={() => onOpenEntry(entry, entryPath)}
|
|
onContextMenu={e => {
|
|
if (!isParentEntry) {
|
|
onContextMenu(entry, entryPath, e);
|
|
}
|
|
}}
|
|
draggable={!isParentEntry}
|
|
onDragStart={e => { if (!isParentEntry) onDragStart(entry, entryPath, isDir, e); }}
|
|
onDragEnd={onDragEnd}
|
|
onDragOver={e => onDragOverEntry(entryPath, e)}
|
|
onDrop={e => onDropEntry(entryPath, e)}
|
|
onDragLeave={onDragLeaveEntry}
|
|
>
|
|
<div
|
|
className="flex min-w-0 items-center gap-1"
|
|
style={{ paddingLeft: depth * 16 + 8 }}
|
|
>
|
|
<span className="shrink-0 w-4 flex items-center justify-center">
|
|
{isParentEntry ? (
|
|
<CornerUpLeft size={14} className="text-muted-foreground" />
|
|
) : isDir ? (
|
|
isLoading ? (
|
|
<Loader2 size={12} className="animate-spin text-muted-foreground" />
|
|
) : (
|
|
<ChevronRight
|
|
size={14}
|
|
className={cn('transition-transform text-muted-foreground', isExpanded && 'rotate-90')}
|
|
onClick={e => { e.stopPropagation(); void onToggleExpand(entry, entryPath); }}
|
|
/>
|
|
)
|
|
) : null}
|
|
</span>
|
|
{!isParentEntry && <span className="shrink-0">{icon}</span>}
|
|
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
|
</div>
|
|
{visibleColumns.modified && (
|
|
<span className="min-w-0 text-muted-foreground text-xs truncate">
|
|
{isParentEntry ? '' : formatDate(entry.lastModified)}
|
|
</span>
|
|
)}
|
|
{visibleColumns.size && (
|
|
<span className="min-w-0 text-right text-muted-foreground text-xs truncate">
|
|
{isParentEntry ? '' : (isDir ? '--' : formatBytes(entry.size ?? 0))}
|
|
</span>
|
|
)}
|
|
{visibleColumns.type && (
|
|
<span className="min-w-0 text-right text-muted-foreground text-xs truncate">
|
|
{isParentEntry ? '' : (isDir ? t('sftp.kind.folder') : (entry.name.split('.').pop()?.toUpperCase() ?? '--'))}
|
|
</span>
|
|
)}
|
|
{visibleColumns.owner && (
|
|
<span className="min-w-0 text-right text-muted-foreground text-xs truncate">
|
|
{isParentEntry ? '' : (entry.owner || '--')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
);
|
|
});
|
|
TreeNode.displayName = 'TreeNode';
|
|
|
|
// ── Tree paths reducer (unchanged) ──────────────────────────────────
|