- Added a comprehensive FAQ section to the README, addressing common user questions about installation, code handling, agent communication, and project support. - Improved cross-platform keyboard shortcut handling in the main application to unify modifier key detection for macOS and Windows/Linux. - Updated shutdown signal handling in standalone mode to ensure compatibility across platforms. - Enhanced WSL user resolution logic to better handle default user retrieval for different distributions. - Improved notification handling to support cross-platform features, including dynamic subtitle management for macOS notifications. - Refactored SSH connection management to include additional default key file types and improve key path resolution for Windows. - Introduced a new utility for killing processes in a cross-platform manner, ensuring graceful shutdowns on Unix and Windows. - Enhanced team data service to support cross-platform process termination and improved error handling in notifications. - Updated editor components to utilize new path utilities for better file handling and display consistency.
878 lines
29 KiB
TypeScript
878 lines
29 KiB
TypeScript
/**
|
|
* Editor file tree — virtualized with @tanstack/react-virtual.
|
|
*
|
|
* Renders project files with file-type icons, sensitive-file lock icons,
|
|
* directory expand/collapse, context menu, inline file creation, and drag & drop.
|
|
*/
|
|
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
PointerSensor,
|
|
useDraggable,
|
|
useDroppable,
|
|
useSensor,
|
|
useSensors,
|
|
} from '@dnd-kit/core';
|
|
import { Button } from '@renderer/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@renderer/components/ui/dialog';
|
|
import { useStore } from '@renderer/store';
|
|
import { sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
|
|
import { getBasename, lastSeparatorIndex } from '@shared/utils/platformPath';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { ChevronDown, ChevronRight, Folder, FolderOpen, Lock } from 'lucide-react';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
|
|
import { EditorContextMenu } from './EditorContextMenu';
|
|
import { FileIcon } from './FileIcon';
|
|
import { GitStatusBadge } from './GitStatusBadge';
|
|
import { NewFileDialog } from './NewFileDialog';
|
|
|
|
import type { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core';
|
|
import type { TreeNode } from '@renderer/utils/fileTreeBuilder';
|
|
import type { FileTreeEntry, GitFileStatusType } from '@shared/types/editor';
|
|
|
|
// =============================================================================
|
|
// Types
|
|
// =============================================================================
|
|
|
|
interface EditorFileTreeProps {
|
|
selectedFilePath: string | null;
|
|
onFileSelect: (filePath: string) => void;
|
|
/** Trigger "Create Task" with a file mention from context menu */
|
|
onCreateTask?: (filePath: string) => void;
|
|
/** Trigger "Write Teammate" with a file mention from context menu */
|
|
onSendMessage?: (filePath: string) => void;
|
|
}
|
|
|
|
interface NewItemState {
|
|
parentDir: string;
|
|
type: 'file' | 'directory';
|
|
}
|
|
|
|
/** Flat item for virtualization */
|
|
interface FlatTreeItem {
|
|
node: TreeNode<FileTreeEntry>;
|
|
depth: number;
|
|
isExpanded: boolean;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Constants
|
|
// =============================================================================
|
|
|
|
const ITEM_HEIGHT = 28;
|
|
const INDENT_PX = 12;
|
|
const MAX_DEPTH = 12;
|
|
const AUTO_EXPAND_DELAY_MS = 500;
|
|
|
|
// =============================================================================
|
|
// Component
|
|
// =============================================================================
|
|
|
|
// Render counter for debugging — tracks how often the tree re-renders
|
|
let fileTreeRenderCount = 0;
|
|
|
|
export const EditorFileTree = ({
|
|
selectedFilePath,
|
|
onFileSelect,
|
|
onCreateTask,
|
|
onSendMessage,
|
|
}: EditorFileTreeProps): React.ReactElement => {
|
|
fileTreeRenderCount++;
|
|
if (fileTreeRenderCount % 5 === 0) {
|
|
console.debug(`[perf] EditorFileTree render #${fileTreeRenderCount}`);
|
|
}
|
|
// Data selectors — grouped with useShallow to prevent unnecessary re-renders
|
|
const { fileTree, expandedDirs, loading, error, gitFiles, projectPath } = useStore(
|
|
useShallow((s) => ({
|
|
fileTree: s.editorFileTree,
|
|
expandedDirs: s.editorExpandedDirs,
|
|
loading: s.editorFileTreeLoading,
|
|
error: s.editorFileTreeError,
|
|
gitFiles: s.editorGitFiles,
|
|
projectPath: s.editorProjectPath,
|
|
}))
|
|
);
|
|
|
|
// Actions — stable references in Zustand, no grouping needed
|
|
const expandDirectory = useStore((s) => s.expandDirectory);
|
|
const collapseDirectory = useStore((s) => s.collapseDirectory);
|
|
const createFileInTree = useStore((s) => s.createFileInTree);
|
|
const createDirInTree = useStore((s) => s.createDirInTree);
|
|
const deleteFileFromTree = useStore((s) => s.deleteFileFromTree);
|
|
const moveFileInTree = useStore((s) => s.moveFileInTree);
|
|
const renameFileInTree = useStore((s) => s.renameFileInTree);
|
|
const openFile = useStore((s) => s.openFile);
|
|
|
|
const [newItemState, setNewItemState] = useState<NewItemState | null>(null);
|
|
const [renamingPath, setRenamingPath] = useState<string | null>(null);
|
|
const [deleteConfirmPath, setDeleteConfirmPath] = useState<string | null>(null);
|
|
const [draggedItem, setDraggedItem] = useState<FlatTreeItem | null>(null);
|
|
const [dropTargetPath, setDropTargetPath] = useState<string | null>(null);
|
|
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Defer DnD initialization — mount tree without drag/drop first, enable after idle
|
|
const [dndReady, setDndReady] = useState(false);
|
|
useEffect(() => {
|
|
const id = requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => setDndReady(true));
|
|
});
|
|
return () => cancelAnimationFrame(id);
|
|
}, []);
|
|
|
|
// Cleanup auto-expand timer on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (autoExpandTimerRef.current) clearTimeout(autoExpandTimerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
// DnD sensors — 5px distance to prevent accidental drags
|
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
|
|
|
// Convert hierarchical FileTreeEntry[] → TreeNode[] (respects entry.type)
|
|
const treeNodes = useMemo(() => {
|
|
if (!fileTree) return [];
|
|
const t0 = performance.now();
|
|
const nodes = sortTreeNodes(convertEntriesToNodes(fileTree));
|
|
const ms = performance.now() - t0;
|
|
if (ms > 2) console.debug(`[perf] treeNodes: ${ms.toFixed(1)}ms, nodes=${nodes.length}`);
|
|
return nodes;
|
|
}, [fileTree]);
|
|
|
|
// Flatten tree into visible items list for virtualization
|
|
// expandedDirs is keyed by absolute path, and node.fullPath = entry.path (absolute)
|
|
const flatItems = useMemo(() => {
|
|
const t0 = performance.now();
|
|
const items: FlatTreeItem[] = [];
|
|
flattenVisible(treeNodes, expandedDirs, items, 0);
|
|
const ms = performance.now() - t0;
|
|
if (ms > 2) console.debug(`[perf] flatItems: ${ms.toFixed(1)}ms, items=${items.length}`);
|
|
return items;
|
|
}, [treeNodes, expandedDirs]);
|
|
|
|
// Lookup: fullPath → FlatTreeItem (for drag start)
|
|
const flatItemsByPath = useMemo(() => {
|
|
const map = new Map<string, FlatTreeItem>();
|
|
for (const item of flatItems) {
|
|
map.set(item.node.fullPath, item);
|
|
}
|
|
return map;
|
|
}, [flatItems]);
|
|
|
|
// Compute insertion index for inline new-item input
|
|
const newItemInsert = useMemo(() => {
|
|
if (!newItemState) return null;
|
|
const { parentDir } = newItemState;
|
|
|
|
const parentIdx = flatItems.findIndex((fi) => fi.node.fullPath === parentDir);
|
|
|
|
if (parentIdx === -1) {
|
|
// parentDir is the project root (not a node in flatItems) — insert at top
|
|
return { index: 0, depth: 0 };
|
|
}
|
|
|
|
// Insert right after the parent directory node (top of its children)
|
|
return { index: parentIdx + 1, depth: flatItems[parentIdx].depth + 1 };
|
|
}, [newItemState, flatItems]);
|
|
|
|
// Virtual scrolling — reduced overscan during initial mount, increase during drag
|
|
const virtualizer = useVirtualizer({
|
|
count: flatItems.length + (newItemInsert ? 1 : 0),
|
|
getScrollElement: () => scrollRef.current,
|
|
estimateSize: () => ITEM_HEIGHT,
|
|
overscan: !dndReady ? 3 : draggedItem ? 20 : 10,
|
|
});
|
|
|
|
// Scroll to file when selectedFilePath changes (e.g. from revealFileInEditor)
|
|
useEffect(() => {
|
|
if (!selectedFilePath) return;
|
|
const idx = flatItems.findIndex((fi) => fi.node.fullPath === selectedFilePath);
|
|
if (idx >= 0) {
|
|
virtualizer.scrollToIndex(idx, { align: 'center' });
|
|
}
|
|
}, [selectedFilePath, flatItems, virtualizer]);
|
|
|
|
// Git status lookup: absolute path → status type
|
|
const gitStatusMap = useMemo(() => {
|
|
const t0 = performance.now();
|
|
const map = new Map<string, GitFileStatusType>();
|
|
if (!gitFiles.length || !projectPath) return map;
|
|
for (const file of gitFiles) {
|
|
const absPath = projectPath.endsWith('/')
|
|
? `${projectPath}${file.path}`
|
|
: `${projectPath}/${file.path}`;
|
|
map.set(absPath, file.status);
|
|
}
|
|
const ms = performance.now() - t0;
|
|
if (ms > 2) console.debug(`[perf] gitStatusMap: ${ms.toFixed(1)}ms, files=${gitFiles.length}`);
|
|
return map;
|
|
}, [gitFiles, projectPath]);
|
|
|
|
// Active node path for selection highlight (fullPath = absolute path)
|
|
const activeNodePath = selectedFilePath;
|
|
|
|
const handleNodeClick = useCallback(
|
|
(node: TreeNode<FileTreeEntry>) => {
|
|
if (!node.data) return;
|
|
if (node.data.isSensitive) return;
|
|
if (node.isFile) {
|
|
onFileSelect(node.data.path);
|
|
} else {
|
|
// fullPath = absolute path = entry.path
|
|
if (expandedDirs[node.fullPath]) {
|
|
collapseDirectory(node.fullPath);
|
|
} else {
|
|
void expandDirectory(node.fullPath);
|
|
}
|
|
}
|
|
},
|
|
[onFileSelect, expandedDirs, expandDirectory, collapseDirectory]
|
|
);
|
|
|
|
// Context menu handlers — expand parent directory so the input appears inline
|
|
const handleNewFile = useCallback(
|
|
(parentDir: string) => {
|
|
if (parentDir !== projectPath && !expandedDirs[parentDir]) {
|
|
void expandDirectory(parentDir);
|
|
}
|
|
setNewItemState({ parentDir, type: 'file' });
|
|
},
|
|
[projectPath, expandedDirs, expandDirectory]
|
|
);
|
|
|
|
const handleNewFolder = useCallback(
|
|
(parentDir: string) => {
|
|
if (parentDir !== projectPath && !expandedDirs[parentDir]) {
|
|
void expandDirectory(parentDir);
|
|
}
|
|
setNewItemState({ parentDir, type: 'directory' });
|
|
},
|
|
[projectPath, expandedDirs, expandDirectory]
|
|
);
|
|
|
|
const handleDelete = useCallback((path: string) => {
|
|
setDeleteConfirmPath(path);
|
|
}, []);
|
|
|
|
const handleConfirmDelete = useCallback(async () => {
|
|
if (!deleteConfirmPath) return;
|
|
await deleteFileFromTree(deleteConfirmPath);
|
|
setDeleteConfirmPath(null);
|
|
}, [deleteConfirmPath, deleteFileFromTree]);
|
|
|
|
const handleCancelDelete = useCallback(() => {
|
|
setDeleteConfirmPath(null);
|
|
}, []);
|
|
|
|
const handleRename = useCallback((path: string) => {
|
|
setRenamingPath(path);
|
|
}, []);
|
|
|
|
const handleRenameSubmit = useCallback(
|
|
async (newName: string) => {
|
|
if (!renamingPath) return;
|
|
await renameFileInTree(renamingPath, newName);
|
|
setRenamingPath(null);
|
|
},
|
|
[renamingPath, renameFileInTree]
|
|
);
|
|
|
|
const handleRenameCancel = useCallback(() => {
|
|
setRenamingPath(null);
|
|
}, []);
|
|
|
|
const handleNewItemSubmit = useCallback(
|
|
async (name: string) => {
|
|
if (!newItemState) return;
|
|
if (newItemState.type === 'file') {
|
|
const filePath = await createFileInTree(newItemState.parentDir, name);
|
|
if (filePath) openFile(filePath);
|
|
} else {
|
|
await createDirInTree(newItemState.parentDir, name);
|
|
}
|
|
setNewItemState(null);
|
|
},
|
|
[newItemState, createFileInTree, createDirInTree, openFile]
|
|
);
|
|
|
|
const handleNewItemCancel = useCallback(() => {
|
|
setNewItemState(null);
|
|
}, []);
|
|
|
|
// ─── Drag & Drop handlers ──────────────────────────────────────────────────
|
|
|
|
const clearAutoExpandTimer = useCallback(() => {
|
|
if (autoExpandTimerRef.current) {
|
|
clearTimeout(autoExpandTimerRef.current);
|
|
autoExpandTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const handleDragStart = useCallback(
|
|
(event: DragStartEvent) => {
|
|
const id = String(event.active.id);
|
|
const item = flatItemsByPath.get(id);
|
|
if (item) setDraggedItem(item);
|
|
},
|
|
[flatItemsByPath]
|
|
);
|
|
|
|
const handleDragOver = useCallback(
|
|
(event: DragOverEvent) => {
|
|
const { over } = event;
|
|
if (!over || !draggedItem) {
|
|
setDropTargetPath(null);
|
|
clearAutoExpandTimer();
|
|
return;
|
|
}
|
|
|
|
const overId = String(over.id);
|
|
let targetDir: string | null = null;
|
|
|
|
if (overId === 'root-drop-zone') {
|
|
targetDir = projectPath;
|
|
} else if (overId.startsWith('drop:')) {
|
|
// Directory drop target
|
|
targetDir = overId.slice(5);
|
|
} else {
|
|
// File — drop into its parent directory
|
|
const item = flatItemsByPath.get(overId);
|
|
if (item) {
|
|
const p = item.node.fullPath;
|
|
targetDir = p.substring(0, lastSeparatorIndex(p));
|
|
}
|
|
}
|
|
|
|
if (targetDir !== dropTargetPath) {
|
|
setDropTargetPath(targetDir);
|
|
clearAutoExpandTimer();
|
|
|
|
// Auto-expand collapsed folders after 500ms hover
|
|
if (targetDir && targetDir !== projectPath && !expandedDirs[targetDir]) {
|
|
autoExpandTimerRef.current = setTimeout(() => {
|
|
void expandDirectory(targetDir);
|
|
}, AUTO_EXPAND_DELAY_MS);
|
|
}
|
|
}
|
|
},
|
|
[
|
|
draggedItem,
|
|
dropTargetPath,
|
|
projectPath,
|
|
flatItemsByPath,
|
|
expandedDirs,
|
|
expandDirectory,
|
|
clearAutoExpandTimer,
|
|
]
|
|
);
|
|
|
|
const handleDragEnd = useCallback(
|
|
(event: DragEndEvent) => {
|
|
clearAutoExpandTimer();
|
|
const sourcePath = draggedItem?.node.fullPath;
|
|
|
|
if (!sourcePath || !dropTargetPath || !event.over) {
|
|
setDraggedItem(null);
|
|
setDropTargetPath(null);
|
|
return;
|
|
}
|
|
|
|
const destDir = dropTargetPath;
|
|
const sourceParent = sourcePath.substring(0, lastSeparatorIndex(sourcePath));
|
|
|
|
// Validation: same folder = no-op
|
|
if (sourceParent === destDir) {
|
|
setDraggedItem(null);
|
|
setDropTargetPath(null);
|
|
return;
|
|
}
|
|
|
|
// Validation: parent → child prevention
|
|
if (destDir.startsWith(sourcePath + '/') || destDir === sourcePath) {
|
|
setDraggedItem(null);
|
|
setDropTargetPath(null);
|
|
return;
|
|
}
|
|
|
|
// Validation: sensitive files
|
|
if (draggedItem?.node.data?.isSensitive) {
|
|
setDraggedItem(null);
|
|
setDropTargetPath(null);
|
|
return;
|
|
}
|
|
|
|
void moveFileInTree(sourcePath, destDir);
|
|
|
|
setDraggedItem(null);
|
|
setDropTargetPath(null);
|
|
},
|
|
[draggedItem, dropTargetPath, moveFileInTree, clearAutoExpandTimer]
|
|
);
|
|
|
|
const handleDragCancel = useCallback(() => {
|
|
clearAutoExpandTimer();
|
|
setDraggedItem(null);
|
|
setDropTargetPath(null);
|
|
}, [clearAutoExpandTimer]);
|
|
|
|
// ─── Early returns ─────────────────────────────────────────────────────────
|
|
|
|
if (error) {
|
|
return <div className="p-3 text-xs text-red-400">Failed to load files: {error}</div>;
|
|
}
|
|
|
|
if (loading && !fileTree) {
|
|
return <div className="p-3 text-xs text-text-muted">Loading files...</div>;
|
|
}
|
|
|
|
if (treeNodes.length === 0) {
|
|
return <div className="p-3 text-xs text-text-muted">No files found</div>;
|
|
}
|
|
|
|
return (
|
|
<EditorContextMenu
|
|
projectPath={projectPath}
|
|
onNewFile={handleNewFile}
|
|
onNewFolder={handleNewFolder}
|
|
onDelete={handleDelete}
|
|
onRename={handleRename}
|
|
onCreateTask={onCreateTask}
|
|
onSendMessage={onSendMessage}
|
|
>
|
|
<DndContext
|
|
sensors={sensors}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragEnd={handleDragEnd}
|
|
onDragCancel={handleDragCancel}
|
|
autoScroll={{ threshold: { x: 0, y: 0.15 } }}
|
|
>
|
|
<RootDropZone
|
|
ref={scrollRef}
|
|
projectPath={projectPath}
|
|
isDropTarget={dropTargetPath === projectPath}
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
|
const { index } = virtualItem;
|
|
|
|
// Render inline new-item input at the correct tree position
|
|
if (index === newItemInsert?.index) {
|
|
return (
|
|
<div
|
|
key="__new-item-input__"
|
|
style={{
|
|
position: 'absolute',
|
|
top: `${virtualItem.start}px`,
|
|
left: 0,
|
|
width: '100%',
|
|
height: `${virtualItem.size}px`,
|
|
paddingLeft: `${Math.min(newItemInsert.depth, MAX_DEPTH) * INDENT_PX}px`,
|
|
}}
|
|
>
|
|
<NewFileDialog
|
|
type={newItemState!.type}
|
|
parentDir={newItemState!.parentDir}
|
|
onSubmit={handleNewItemSubmit}
|
|
onCancel={handleNewItemCancel}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Adjust index for items after the insertion point
|
|
const flatIdx = newItemInsert && index > newItemInsert.index ? index - 1 : index;
|
|
const item = flatItems[flatIdx];
|
|
|
|
return (
|
|
<div
|
|
key={item.node.fullPath}
|
|
style={{
|
|
position: 'absolute',
|
|
top: `${virtualItem.start}px`,
|
|
left: 0,
|
|
width: '100%',
|
|
height: `${virtualItem.size}px`,
|
|
}}
|
|
>
|
|
<DraggableTreeItem
|
|
item={item}
|
|
activeNodePath={activeNodePath}
|
|
gitStatus={gitStatusMap.get(item.node.fullPath)}
|
|
dropTargetPath={dropTargetPath}
|
|
isDragActive={!!draggedItem}
|
|
onClick={handleNodeClick}
|
|
isRenaming={renamingPath === item.node.fullPath}
|
|
onRenameSubmit={handleRenameSubmit}
|
|
onRenameCancel={handleRenameCancel}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{/* Spacer at bottom — drop here to move to project root */}
|
|
{draggedItem && (
|
|
<div className="h-16 w-full shrink-0" aria-label="Drop here for project root" />
|
|
)}
|
|
</RootDropZone>
|
|
<DragOverlay dropAnimation={null}>
|
|
{draggedItem && <DragOverlayFileItem item={draggedItem} />}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
|
|
{/* Delete confirmation dialog */}
|
|
<Dialog open={!!deleteConfirmPath} onOpenChange={(open) => !open && handleCancelDelete()}>
|
|
<DialogContent className="w-96 max-w-96">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-sm">Move to Trash</DialogTitle>
|
|
<DialogDescription>
|
|
Move “{deleteConfirmPath ? getBasename(deleteConfirmPath) : ''}” to Trash?
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="ghost" size="sm" onClick={handleCancelDelete}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" size="sm" onClick={() => void handleConfirmDelete()}>
|
|
Move to Trash
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</EditorContextMenu>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// Root drop zone (drop files to project root)
|
|
// =============================================================================
|
|
|
|
const RootDropZone = React.forwardRef<
|
|
HTMLDivElement,
|
|
{ projectPath: string | null; isDropTarget: boolean; children: React.ReactNode }
|
|
>(({ projectPath, isDropTarget, children }, ref) => {
|
|
const { setNodeRef } = useDroppable({
|
|
id: 'root-drop-zone',
|
|
data: { isRoot: true, path: projectPath },
|
|
});
|
|
|
|
// Combine forwarded ref with droppable ref
|
|
const combinedRef = useCallback(
|
|
(el: HTMLDivElement | null) => {
|
|
setNodeRef(el);
|
|
if (typeof ref === 'function') ref(el);
|
|
// eslint-disable-next-line no-param-reassign -- combining forwarded ref with droppable ref
|
|
else if (ref) ref.current = el;
|
|
},
|
|
[ref, setNodeRef]
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={combinedRef}
|
|
className={`scrollbar-thin h-full overflow-y-auto transition-colors ${
|
|
isDropTarget ? 'bg-blue-400/5 ring-1 ring-inset ring-blue-400/30' : ''
|
|
}`}
|
|
role="tree"
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
RootDropZone.displayName = 'RootDropZone';
|
|
|
|
// =============================================================================
|
|
// Draggable + droppable tree item
|
|
// =============================================================================
|
|
|
|
interface DraggableTreeItemProps {
|
|
item: FlatTreeItem;
|
|
activeNodePath: string | null;
|
|
gitStatus?: GitFileStatusType;
|
|
dropTargetPath: string | null;
|
|
isDragActive: boolean;
|
|
onClick: (node: TreeNode<FileTreeEntry>) => void;
|
|
isRenaming?: boolean;
|
|
onRenameSubmit?: (newName: string) => void;
|
|
onRenameCancel?: () => void;
|
|
}
|
|
|
|
/* eslint-disable react/jsx-props-no-spreading -- dnd-kit requires prop spreading for drag attributes, listeners, and data attributes */
|
|
const DraggableTreeItem = React.memo(
|
|
({
|
|
item,
|
|
activeNodePath,
|
|
gitStatus,
|
|
dropTargetPath,
|
|
isDragActive,
|
|
onClick,
|
|
isRenaming,
|
|
onRenameSubmit,
|
|
onRenameCancel,
|
|
}: DraggableTreeItemProps): React.ReactElement => {
|
|
const { node, depth, isExpanded } = item;
|
|
const isSelected = activeNodePath === node.fullPath;
|
|
const visualDepth = Math.min(depth, MAX_DEPTH);
|
|
const isSensitive = node.data?.isSensitive;
|
|
|
|
// Draggable setup
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef: setDragRef,
|
|
isDragging,
|
|
} = useDraggable({
|
|
id: node.fullPath,
|
|
data: { node, depth },
|
|
disabled: !!isSensitive,
|
|
});
|
|
|
|
// Droppable setup — only directories are drop targets
|
|
const { setNodeRef: setDropRef } = useDroppable({
|
|
id: 'drop:' + node.fullPath,
|
|
data: { node },
|
|
disabled: node.isFile,
|
|
});
|
|
|
|
// Combine refs
|
|
const ref = useCallback(
|
|
(el: HTMLDivElement | null) => {
|
|
setDragRef(el);
|
|
if (!node.isFile) setDropRef(el);
|
|
},
|
|
[setDragRef, setDropRef, node.isFile]
|
|
);
|
|
|
|
// Visual: highlight drop target directory and its visible children
|
|
const isDropTarget = !node.isFile && dropTargetPath === node.fullPath;
|
|
const isInsideDropTarget =
|
|
dropTargetPath != null && node.fullPath.startsWith(dropTargetPath + '/');
|
|
|
|
const dataAttrs: Record<string, string> = {};
|
|
if (node.data) {
|
|
dataAttrs['data-editor-path'] = node.data.path;
|
|
dataAttrs['data-editor-type'] = node.data.type;
|
|
if (node.data.isSensitive) dataAttrs['data-editor-sensitive'] = 'true';
|
|
}
|
|
|
|
const handleClick = (): void => {
|
|
if (!isDragActive) onClick(node);
|
|
};
|
|
const handleKeyDown = (e: React.KeyboardEvent): void => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
handleClick();
|
|
}
|
|
};
|
|
|
|
// Render icon
|
|
let icon: React.ReactNode;
|
|
if (node.data?.isSensitive) {
|
|
icon = <Lock className="size-3.5 shrink-0 text-yellow-500" />;
|
|
} else if (node.isFile) {
|
|
icon = <FileIcon fileName={node.name} className="size-3.5" />;
|
|
} else if (isExpanded) {
|
|
icon = <FolderOpen className="size-3.5 shrink-0 text-text-muted" />;
|
|
} else {
|
|
icon = <Folder className="size-3.5 shrink-0 text-text-muted" />;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
{...attributes}
|
|
{...listeners}
|
|
role="treeitem"
|
|
aria-selected={node.isFile ? isSelected : undefined}
|
|
aria-expanded={!node.isFile ? isExpanded : undefined}
|
|
className={`flex h-full cursor-pointer select-none items-center gap-1 truncate px-2 text-xs transition-colors hover:bg-surface-raised ${
|
|
isSelected ? 'bg-surface-raised text-text' : 'text-text-secondary'
|
|
} ${isDragging ? 'opacity-30' : ''} ${
|
|
isDropTarget ? 'rounded bg-blue-400/10 ring-2 ring-blue-400/50' : ''
|
|
} ${isInsideDropTarget && !isDropTarget ? 'border-l-2 border-l-blue-400/40 bg-blue-400/5' : ''}`}
|
|
style={{ paddingLeft: `${visualDepth * INDENT_PX + 8}px` }}
|
|
onClick={handleClick}
|
|
onKeyDown={handleKeyDown}
|
|
tabIndex={0}
|
|
title={node.data?.path ?? node.fullPath}
|
|
{...dataAttrs}
|
|
>
|
|
{!node.isFile &&
|
|
(isExpanded ? (
|
|
<ChevronDown className="size-3 shrink-0 text-text-muted" />
|
|
) : (
|
|
<ChevronRight className="size-3 shrink-0 text-text-muted" />
|
|
))}
|
|
{icon}
|
|
{isRenaming ? (
|
|
<InlineRenameInput
|
|
initialName={node.name}
|
|
onSubmit={onRenameSubmit!}
|
|
onCancel={onRenameCancel!}
|
|
/>
|
|
) : (
|
|
<span className="truncate">{node.name}</span>
|
|
)}
|
|
{!isRenaming && gitStatus && <GitStatusBadge status={gitStatus} />}
|
|
</div>
|
|
);
|
|
}
|
|
);
|
|
|
|
DraggableTreeItem.displayName = 'DraggableTreeItem';
|
|
/* eslint-enable react/jsx-props-no-spreading -- re-enable after DraggableTreeItem component */
|
|
|
|
// =============================================================================
|
|
// Drag overlay ghost
|
|
// =============================================================================
|
|
|
|
const DragOverlayFileItem = ({ item }: { item: FlatTreeItem }): React.ReactElement => {
|
|
const { node } = item;
|
|
|
|
let icon: React.ReactNode;
|
|
if (node.isFile) {
|
|
icon = <FileIcon fileName={node.name} className="size-3.5" />;
|
|
} else {
|
|
icon = <FolderOpen className="size-3.5 text-text-muted" />;
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-center gap-1.5 rounded border border-border-emphasis bg-surface-overlay px-3 py-1 text-xs text-text shadow-lg">
|
|
{icon}
|
|
<span className="truncate">{node.name}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// Inline rename input
|
|
// =============================================================================
|
|
|
|
const InlineRenameInput = ({
|
|
initialName,
|
|
onSubmit,
|
|
onCancel,
|
|
}: {
|
|
initialName: string;
|
|
onSubmit: (newName: string) => void;
|
|
onCancel: () => void;
|
|
}): React.ReactElement => {
|
|
const [value, setValue] = useState(initialName);
|
|
const submitted = useRef(false);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Focus + select on mount (delayed to survive Radix/DnD focus interference)
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
const input = inputRef.current;
|
|
if (!input) return;
|
|
input.focus();
|
|
const dotIdx = initialName.lastIndexOf('.');
|
|
if (dotIdx > 0) {
|
|
input.setSelectionRange(0, dotIdx);
|
|
} else {
|
|
input.select();
|
|
}
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}, [initialName]);
|
|
|
|
// Click-outside → submit (replaces unreliable onBlur)
|
|
useEffect(() => {
|
|
const handlePointerDown = (e: PointerEvent): void => {
|
|
if (inputRef.current && !inputRef.current.contains(e.target as Node)) {
|
|
doSubmit();
|
|
}
|
|
};
|
|
const timer = setTimeout(() => {
|
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
|
}, 150);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
document.removeEventListener('pointerdown', handlePointerDown, true);
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- doSubmit reads value via ref pattern
|
|
}, []);
|
|
|
|
const doSubmit = (): void => {
|
|
if (submitted.current) return;
|
|
submitted.current = true;
|
|
const trimmed = inputRef.current?.value.trim() ?? '';
|
|
if (trimmed && trimmed !== initialName) {
|
|
onSubmit(trimmed);
|
|
} else {
|
|
onCancel();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<input
|
|
ref={inputRef}
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
doSubmit();
|
|
} else if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onCancel();
|
|
}
|
|
e.stopPropagation();
|
|
}}
|
|
onBlur={() => requestAnimationFrame(() => inputRef.current?.focus())}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="min-w-0 flex-1 rounded border border-blue-400/50 bg-surface px-1 py-0 text-xs text-text outline-none focus:ring-1 focus:ring-blue-400/50"
|
|
/>
|
|
);
|
|
};
|
|
|
|
// =============================================================================
|
|
// Helpers
|
|
// =============================================================================
|
|
|
|
/** Convert hierarchical FileTreeEntry[] into TreeNode[] using entry.type for classification */
|
|
function convertEntriesToNodes(entries: FileTreeEntry[]): TreeNode<FileTreeEntry>[] {
|
|
return entries.map((entry) => ({
|
|
name: entry.name,
|
|
fullPath: entry.path, // absolute path — matches expandedDirs keys
|
|
isFile: entry.type === 'file',
|
|
data: entry,
|
|
children: entry.children ? convertEntriesToNodes(entry.children) : [],
|
|
}));
|
|
}
|
|
|
|
/** Flatten tree into visible items list (DFS, respecting expanded state) */
|
|
function flattenVisible(
|
|
nodes: TreeNode<FileTreeEntry>[],
|
|
expandedPaths: Record<string, boolean>,
|
|
result: FlatTreeItem[],
|
|
depth: number
|
|
): void {
|
|
for (const node of nodes) {
|
|
const isExpanded = !node.isFile && expandedPaths[node.fullPath] === true;
|
|
result.push({ node, depth, isExpanded });
|
|
if (isExpanded && node.children.length > 0) {
|
|
flattenVisible(node.children, expandedPaths, result, depth + 1);
|
|
}
|
|
}
|
|
}
|