Files
Termix/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx
ZacharyZcR dea1ca90b1 fix: clean up empty blocks in file manager and credential editor
修复了 5 个空块:
- FileManagerGrid.tsx: 移除 1 个空 else 块和 1 个空 if 块
- CredentialEditor.tsx: 修复 1 个空 catch 块,移除 2 个空 if/else 块

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 21:05:36 +08:00

1395 lines
44 KiB
TypeScript

import React, { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import {
Folder,
File,
FileText,
FileImage,
FileVideo,
FileAudio,
Archive,
Code,
Settings,
Download,
Upload,
ChevronLeft,
ChevronRight,
RefreshCw,
ArrowUp,
FileSymlink,
Move,
GitCompare,
Edit,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FileItem } from "../../../types/index.js";
interface CreateIntent {
id: string;
type: "file" | "directory";
defaultName: string;
currentName: string;
}
function formatFileSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
return `${formattedSize} ${units[unitIndex]}`;
}
interface DragState {
type: "none" | "internal" | "external";
files: FileItem[];
draggedFiles?: FileItem[];
target?: FileItem;
counter: number;
mousePosition?: { x: number; y: number };
}
interface FileManagerGridProps {
files: FileItem[];
selectedFiles: FileItem[];
onFileSelect: (file: FileItem, multiSelect?: boolean) => void;
onFileOpen: (file: FileItem) => void;
onSelectionChange: (files: FileItem[]) => void;
currentPath: string;
isLoading?: boolean;
onPathChange: (path: string) => void;
onRefresh: () => void;
onUpload?: (files: FileList) => void;
onDownload?: (files: FileItem[]) => void;
onContextMenu?: (event: React.MouseEvent, file?: FileItem) => void;
viewMode?: "grid" | "list";
onRename?: (file: FileItem, newName: string) => void;
editingFile?: FileItem | null;
onStartEdit?: (file: FileItem) => void;
onCancelEdit?: () => void;
onDelete?: (files: FileItem[]) => void;
onCopy?: (files: FileItem[]) => void;
onCut?: (files: FileItem[]) => void;
onPaste?: () => void;
onUndo?: () => void;
onFileDrop?: (draggedFiles: FileItem[], targetFile: FileItem) => void;
onFileDiff?: (file1: FileItem, file2: FileItem) => void;
onSystemDragStart?: (files: FileItem[]) => void;
onSystemDragEnd?: (e: DragEvent, files: FileItem[]) => void;
hasClipboard?: boolean;
createIntent?: CreateIntent | null;
onConfirmCreate?: (name: string) => void;
onCancelCreate?: () => void;
}
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
if (file.type === "directory") {
return <Folder className={`${iconClass} text-muted-foreground`} />;
}
if (file.type === "link") {
return <FileSymlink className={`${iconClass} text-muted-foreground`} />;
}
const ext = file.name.split(".").pop()?.toLowerCase();
switch (ext) {
case "txt":
case "md":
case "readme":
return <FileText className={`${iconClass} text-muted-foreground`} />;
case "png":
case "jpg":
case "jpeg":
case "gif":
case "bmp":
case "svg":
return <FileImage className={`${iconClass} text-muted-foreground`} />;
case "mp4":
case "avi":
case "mkv":
case "mov":
return <FileVideo className={`${iconClass} text-muted-foreground`} />;
case "mp3":
case "wav":
case "flac":
case "ogg":
return <FileAudio className={`${iconClass} text-muted-foreground`} />;
case "zip":
case "tar":
case "gz":
case "rar":
case "7z":
return <Archive className={`${iconClass} text-muted-foreground`} />;
case "js":
case "ts":
case "jsx":
case "tsx":
case "py":
case "java":
case "cpp":
case "c":
case "cs":
case "php":
case "rb":
case "go":
case "rs":
return <Code className={`${iconClass} text-muted-foreground`} />;
case "json":
case "xml":
case "yaml":
case "yml":
case "toml":
case "ini":
case "conf":
case "config":
return <Settings className={`${iconClass} text-muted-foreground`} />;
default:
return <File className={`${iconClass} text-muted-foreground`} />;
}
};
export function FileManagerGrid({
files,
selectedFiles,
onFileSelect,
onFileOpen,
onSelectionChange,
currentPath,
isLoading,
onPathChange,
onRefresh,
onUpload,
onDownload,
onContextMenu,
viewMode = "grid",
onRename,
editingFile,
onStartEdit,
onCancelEdit,
onDelete,
onCopy,
onCut,
onPaste,
onUndo,
onFileDrop,
onFileDiff,
onSystemDragStart,
onSystemDragEnd,
hasClipboard,
createIntent,
onConfirmCreate,
onCancelCreate,
}: FileManagerGridProps) {
const { t } = useTranslation();
const gridRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState("");
const [dragState, setDragState] = useState<DragState>({
type: "none",
files: [],
counter: 0,
});
useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => {
if (dragState.type === "internal" && dragState.files.length > 0) {
setDragState((prev) => ({
...prev,
mousePosition: { x: e.clientX, y: e.clientY },
}));
}
};
if (dragState.type === "internal" && dragState.files.length > 0) {
document.addEventListener("mousemove", handleGlobalMouseMove);
return () =>
document.removeEventListener("mousemove", handleGlobalMouseMove);
}
}, [dragState.type, dragState.files.length]);
const editInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingFile) {
setEditingName(editingFile.name);
setTimeout(() => {
editInputRef.current?.focus();
editInputRef.current?.select();
}, 0);
}
}, [editingFile]);
const handleEditConfirm = () => {
if (
editingFile &&
onRename &&
editingName.trim() &&
editingName !== editingFile.name
) {
onRename(editingFile, editingName.trim());
}
onCancelEdit?.();
};
const handleEditCancel = () => {
setEditingName("");
onCancelEdit?.();
};
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleEditConfirm();
} else if (e.key === "Escape") {
e.preventDefault();
handleEditCancel();
}
};
const handleFileDragStart = (e: React.DragEvent, file: FileItem) => {
const filesToDrag = selectedFiles.includes(file) ? selectedFiles : [file];
setDragState({
type: "internal",
files: filesToDrag,
draggedFiles: filesToDrag,
counter: 0,
mousePosition: { x: e.clientX, y: e.clientY },
});
const dragData = {
type: "internal_files",
files: filesToDrag.map((f) => f.path),
};
e.dataTransfer.setData("text/plain", JSON.stringify(dragData));
e.dataTransfer.effectAllowed = "move";
};
const handleFileDragOver = (e: React.DragEvent, targetFile: FileItem) => {
e.preventDefault();
e.stopPropagation();
if (
dragState.type === "internal" &&
!dragState.files.some((f) => f.path === targetFile.path)
) {
setDragState((prev) => ({ ...prev, target: targetFile }));
e.dataTransfer.dropEffect = "move";
}
};
const handleFileDragLeave = (e: React.DragEvent, targetFile: FileItem) => {
e.preventDefault();
e.stopPropagation();
if (dragState.target?.path === targetFile.path) {
setDragState((prev) => ({ ...prev, target: undefined }));
}
};
const handleFileDrop = (e: React.DragEvent, targetFile: FileItem) => {
e.preventDefault();
e.stopPropagation();
if (dragState.type !== "internal" || dragState.files.length === 0) {
setDragState((prev) => ({ ...prev, target: undefined }));
return;
}
const isDroppingOnSelf = dragState.files.some(
(f) => f.path === targetFile.path,
);
if (isDroppingOnSelf) {
setDragState({ type: "none", files: [], counter: 0 });
return;
}
if (targetFile.type === "directory") {
onFileDrop?.(dragState.files, targetFile);
} else if (
targetFile.type === "file" &&
dragState.files.length === 1 &&
dragState.files[0].type === "file"
) {
onFileDiff?.(dragState.files[0], targetFile);
}
setDragState({ type: "none", files: [], counter: 0 });
};
const handleFileDragEnd = (e: React.DragEvent) => {
const draggedFiles = dragState.draggedFiles || [];
setDragState({ type: "none", files: [], counter: 0 });
onSystemDragEnd?.(e.nativeEvent, draggedFiles);
};
const [isSelecting, setIsSelecting] = useState(false);
const [selectionStart, setSelectionStart] = useState<{
x: number;
y: number;
} | null>(null);
const [selectionRect, setSelectionRect] = useState<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
const [navigationHistory, setNavigationHistory] = useState<string[]>([
currentPath,
]);
const [historyIndex, setHistoryIndex] = useState(0);
const [isEditingPath, setIsEditingPath] = useState(false);
const [editPathValue, setEditPathValue] = useState(currentPath);
useEffect(() => {
const lastPath = navigationHistory[historyIndex];
if (currentPath !== lastPath) {
const newHistory = navigationHistory.slice(0, historyIndex + 1);
newHistory.push(currentPath);
setNavigationHistory(newHistory);
setHistoryIndex(newHistory.length - 1);
}
}, [currentPath]);
const goBack = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
setHistoryIndex(newIndex);
onPathChange(navigationHistory[newIndex]);
}
};
const goForward = () => {
if (historyIndex < navigationHistory.length - 1) {
const newIndex = historyIndex + 1;
setHistoryIndex(newIndex);
onPathChange(navigationHistory[newIndex]);
}
};
const goUp = () => {
const parts = currentPath.split("/").filter(Boolean);
if (parts.length > 0) {
parts.pop();
const parentPath = "/" + parts.join("/");
onPathChange(parentPath);
} else if (currentPath !== "/") {
onPathChange("/");
}
};
const pathParts = currentPath.split("/").filter(Boolean);
const navigateToPath = (index: number) => {
if (index === -1) {
onPathChange("/");
} else {
const newPath = "/" + pathParts.slice(0, index + 1).join("/");
onPathChange(newPath);
}
};
const startEditingPath = () => {
setEditPathValue(currentPath);
setIsEditingPath(true);
};
const cancelEditingPath = () => {
setIsEditingPath(false);
setEditPathValue(currentPath);
};
const confirmEditingPath = () => {
const trimmedPath = editPathValue.trim();
if (trimmedPath) {
const normalizedPath = trimmedPath.startsWith("/")
? trimmedPath
: "/" + trimmedPath;
onPathChange(normalizedPath);
}
setIsEditingPath(false);
};
const handlePathInputKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
confirmEditingPath();
} else if (e.key === "Escape") {
e.preventDefault();
cancelEditingPath();
}
};
useEffect(() => {
if (!isEditingPath) {
setEditPathValue(currentPath);
}
}, [currentPath, isEditingPath]);
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const isInternalDrag = dragState.type === "internal";
if (!isInternalDrag) {
setDragState((prev) => ({
...prev,
type: "external",
counter: prev.counter + 1,
}));
}
},
[dragState.type],
);
const handleDragLeave = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const isInternalDrag = dragState.type === "internal";
if (!isInternalDrag && dragState.type === "external") {
setDragState((prev) => {
const newCounter = prev.counter - 1;
return {
...prev,
counter: newCounter,
type: newCounter <= 0 ? "none" : "external",
};
});
}
},
[dragState.type, dragState.counter],
);
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const isInternalDrag = dragState.type === "internal";
if (isInternalDrag) {
setDragState((prev) => ({
...prev,
mousePosition: { x: e.clientX, y: e.clientY },
}));
e.dataTransfer.dropEffect = "move";
} else {
e.dataTransfer.dropEffect = "copy";
}
},
[dragState.type],
);
const handleWheel = useCallback((e: React.WheelEvent) => {
e.stopPropagation();
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.target === e.currentTarget && e.button === 0) {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const startX = e.clientX - rect.left;
const startY = e.clientY - rect.top;
setIsSelecting(true);
setSelectionStart({ x: startX, y: startY });
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
setJustFinishedSelecting(false);
}
}, []);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (isSelecting && selectionStart && gridRef.current) {
const rect = gridRef.current.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
const x = Math.min(selectionStart.x, currentX);
const y = Math.min(selectionStart.y, currentY);
const width = Math.abs(currentX - selectionStart.x);
const height = Math.abs(currentY - selectionStart.y);
setSelectionRect({ x, y, width, height });
if (gridRef.current) {
const fileElements =
gridRef.current.querySelectorAll("[data-file-path]");
const selectedPaths: string[] = [];
fileElements.forEach((element) => {
const elementRect = element.getBoundingClientRect();
const containerRect = gridRef.current!.getBoundingClientRect();
const relativeElementRect = {
left: elementRect.left - containerRect.left,
top: elementRect.top - containerRect.top,
right: elementRect.right - containerRect.left,
bottom: elementRect.bottom - containerRect.top,
};
const selectionBox = {
left: x,
top: y,
right: x + width,
bottom: y + height,
};
const intersects = !(
relativeElementRect.right < selectionBox.left ||
relativeElementRect.left > selectionBox.right ||
relativeElementRect.bottom < selectionBox.top ||
relativeElementRect.top > selectionBox.bottom
);
if (intersects) {
const filePath = element.getAttribute("data-file-path");
if (filePath) {
selectedPaths.push(filePath);
}
}
});
const newSelection = files.filter((file) =>
selectedPaths.includes(file.path),
);
onSelectionChange(newSelection);
}
}
},
[isSelecting, selectionStart, files, onSelectionChange],
);
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
if (isSelecting) {
setIsSelecting(false);
setSelectionStart(null);
setSelectionRect(null);
const startPos = selectionStart;
if (startPos) {
const rect = gridRef.current?.getBoundingClientRect();
if (rect) {
const endX = e.clientX - rect.left;
const endY = e.clientY - rect.top;
const distance = Math.sqrt(
Math.pow(endX - startPos.x, 2) + Math.pow(endY - startPos.y, 2),
);
if (distance > 5) {
setJustFinishedSelecting(true);
setTimeout(() => {
setJustFinishedSelecting(false);
}, 50);
} else {
setJustFinishedSelecting(false);
}
}
}
}
},
[isSelecting, selectionStart],
);
useEffect(() => {
const handleGlobalMouseUp = (e: MouseEvent) => {
if (isSelecting) {
setIsSelecting(false);
setSelectionStart(null);
setSelectionRect(null);
setJustFinishedSelecting(true);
setTimeout(() => {
setJustFinishedSelecting(false);
}, 50);
}
};
const handleGlobalMouseMove = (e: MouseEvent) => {
if (isSelecting && selectionStart && gridRef.current) {
const rect = gridRef.current.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
const x = Math.min(selectionStart.x, currentX);
const y = Math.min(selectionStart.y, currentY);
const width = Math.abs(currentX - selectionStart.x);
const height = Math.abs(currentY - selectionStart.y);
setSelectionRect({ x, y, width, height });
}
};
if (isSelecting) {
document.addEventListener("mouseup", handleGlobalMouseUp);
document.addEventListener("mousemove", handleGlobalMouseMove);
return () => {
document.removeEventListener("mouseup", handleGlobalMouseUp);
document.removeEventListener("mousemove", handleGlobalMouseMove);
};
}
}, [isSelecting, selectionStart]);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (dragState.type === "internal") {
setDragState({ type: "none", files: [], counter: 0 });
} else if (dragState.type === "external") {
if (onUpload && e.dataTransfer.files.length > 0) {
onUpload(e.dataTransfer.files);
}
}
setDragState({ type: "none", files: [], counter: 0 });
},
[onUpload, onDownload, dragState],
);
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
event.stopPropagation();
if (gridRef.current) {
gridRef.current.focus();
}
if (event.detail === 2) {
onFileOpen(file);
} else {
const multiSelect = event.ctrlKey || event.metaKey;
const rangeSelect = event.shiftKey;
if (rangeSelect && selectedFiles.length > 0) {
const lastSelected = selectedFiles[selectedFiles.length - 1];
const currentIndex = files.findIndex((f) => f.path === file.path);
const lastIndex = files.findIndex((f) => f.path === lastSelected.path);
if (currentIndex !== -1 && lastIndex !== -1) {
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
const rangeFiles = files.slice(start, end + 1);
onSelectionChange(rangeFiles);
}
} else if (multiSelect) {
const isSelected = selectedFiles.some((f) => f.path === file.path);
if (isSelected) {
onSelectionChange(selectedFiles.filter((f) => f.path !== file.path));
} else {
onSelectionChange([...selectedFiles, file]);
}
} else {
onSelectionChange([file]);
}
}
};
const handleGridClick = (event: React.MouseEvent) => {
if (gridRef.current) {
gridRef.current.focus();
}
if (
event.target === event.currentTarget &&
!isSelecting &&
!justFinishedSelecting
) {
onSelectionChange([]);
}
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement;
if (
activeElement &&
(activeElement.tagName === "INPUT" ||
activeElement.tagName === "TEXTAREA" ||
activeElement.contentEditable === "true")
) {
return;
}
switch (event.key) {
case "Escape":
onSelectionChange([]);
break;
case "a":
case "A":
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
onSelectionChange([...files]);
}
break;
case "c":
case "C":
if (
(event.ctrlKey || event.metaKey) &&
selectedFiles.length > 0 &&
onCopy
) {
event.preventDefault();
onCopy(selectedFiles);
}
break;
case "x":
case "X":
if (
(event.ctrlKey || event.metaKey) &&
selectedFiles.length > 0 &&
onCut
) {
event.preventDefault();
onCut(selectedFiles);
}
break;
case "v":
case "V":
if ((event.ctrlKey || event.metaKey) && onPaste && hasClipboard) {
event.preventDefault();
onPaste();
}
break;
case "z":
case "Z":
if ((event.ctrlKey || event.metaKey) && onUndo) {
event.preventDefault();
onUndo();
}
break;
case "Delete":
if (selectedFiles.length > 0 && onDelete) {
onDelete(selectedFiles);
}
break;
case "F6":
if (selectedFiles.length === 1 && onStartEdit) {
event.preventDefault();
onStartEdit(selectedFiles[0]);
}
break;
case "y":
case "Y":
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
onRefresh();
}
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [
selectedFiles,
files,
onSelectionChange,
onRefresh,
onDelete,
onCopy,
onCut,
onPaste,
onUndo,
]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-dark-bg overflow-hidden">
<div className="flex-shrink-0 border-b border-dark-border">
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
<button
onClick={goBack}
disabled={historyIndex <= 0}
className={cn(
"p-1 rounded hover:bg-dark-hover",
historyIndex <= 0 && "opacity-50 cursor-not-allowed",
)}
title={t("common.back")}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={goForward}
disabled={historyIndex >= navigationHistory.length - 1}
className={cn(
"p-1 rounded hover:bg-dark-hover",
historyIndex >= navigationHistory.length - 1 &&
"opacity-50 cursor-not-allowed",
)}
title={t("common.forward")}
>
<ChevronRight className="w-4 h-4" />
</button>
<button
onClick={goUp}
disabled={currentPath === "/"}
className={cn(
"p-1 rounded hover:bg-dark-hover",
currentPath === "/" && "opacity-50 cursor-not-allowed",
)}
title={t("fileManager.parentDirectory")}
>
<ArrowUp className="w-4 h-4" />
</button>
<button
onClick={onRefresh}
className="p-1 rounded hover:bg-dark-hover"
title={t("common.refresh")}
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="flex items-center px-3 py-2 text-sm">
{isEditingPath ? (
<div className="flex-1 flex items-center gap-2">
<input
type="text"
value={editPathValue}
onChange={(e) => setEditPathValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
confirmEditingPath();
} else if (e.key === "Escape") {
cancelEditingPath();
}
}}
className="flex-1 px-2 py-1 bg-dark-hover border border-dark-border rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary"
placeholder={t("fileManager.enterPath")}
autoFocus
/>
<button
onClick={confirmEditingPath}
className="px-2 py-1 bg-primary text-primary-foreground rounded text-sm hover:bg-primary/80"
>
{t("fileManager.confirm")}
</button>
<button
onClick={cancelEditingPath}
className="px-2 py-1 bg-secondary text-secondary-foreground rounded text-sm hover:bg-secondary/80"
>
{t("fileManager.cancel")}
</button>
</div>
) : (
<>
<button
onClick={() => navigateToPath(-1)}
className="hover:text-primary hover:underline mr-1"
>
/
</button>
{pathParts.map((part, index) => (
<React.Fragment key={index}>
<button
onClick={() => navigateToPath(index)}
className="hover:text-primary hover:underline"
>
{part}
</button>
{index < pathParts.length - 1 && (
<span className="mx-1 text-muted-foreground">/</span>
)}
</React.Fragment>
))}
<button
onClick={startEditingPath}
className="ml-2 p-1 rounded hover:bg-dark-hover opacity-60 hover:opacity-100 flex items-center justify-center"
title={t("fileManager.editPath")}
>
<Edit className="w-3 h-3" />
</button>
</>
)}
</div>
</div>
<div className="flex-1 relative overflow-hidden">
<div
ref={gridRef}
className={cn(
"absolute inset-0 p-4 overflow-y-auto thin-scrollbar",
dragState.type === "external" &&
"bg-muted/20 border-2 border-dashed border-primary",
)}
onClick={handleGridClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onWheel={handleWheel}
onContextMenu={(e) => onContextMenu?.(e)}
tabIndex={0}
>
{dragState.type === "external" && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10 pointer-events-none animate-in fade-in-0">
<div className="text-center p-8 bg-background/95 border-2 border-dashed border-primary rounded-lg shadow-lg">
<Upload className="w-16 h-16 mx-auto mb-4 text-primary" />
<p className="text-xl font-semibold text-foreground mb-2">
{t("fileManager.dragFilesToUpload")}
</p>
<p className="text-sm text-muted-foreground">
{t("fileManager.dragSystemFilesToUpload")}
</p>
</div>
</div>
)}
{files.length === 0 && !createIntent ? (
<div className="h-full flex items-center justify-center p-8">
<div className="text-center">
<Folder className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<p className="text-lg font-medium text-foreground mb-4">
{t("fileManager.emptyFolder")}
</p>
<div className="space-y-3">
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
<Upload className="w-4 h-4" />
{t("fileManager.dragSystemFilesToUpload")}
</div>
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground bg-muted/50 rounded-md px-3 py-2">
<Download className="w-4 h-4" />
{t("fileManager.dragFilesToWindowToDownload")}
</div>
</div>
</div>
</div>
) : viewMode === "grid" ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4">
{createIntent && (
<CreateIntentGridItem
intent={createIntent}
onConfirm={onConfirmCreate}
onCancel={onCancelCreate}
/>
)}
{files.map((file) => {
const isSelected = selectedFiles.some(
(f) => f.path === file.path,
);
return (
<div
key={file.path}
data-file-path={file.path}
draggable={true}
className={cn(
"group p-3 rounded-lg cursor-pointer",
"hover:bg-accent hover:text-accent-foreground border-2 border-transparent",
isSelected && "bg-primary/20 border-primary",
dragState.target?.path === file.path &&
"bg-muted border-primary border-dashed relative z-10",
dragState.files.some((f) => f.path === file.path) &&
"opacity-50",
)}
title={`${file.name} - Selected: ${isSelected} - SelectedCount: ${selectedFiles.length}`}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(e, file);
}}
onDragStart={(e) => handleFileDragStart(e, file)}
onDragOver={(e) => handleFileDragOver(e, file)}
onDragLeave={(e) => handleFileDragLeave(e, file)}
onDrop={(e) => handleFileDrop(e, file)}
onDragEnd={handleFileDragEnd}
>
<div className="flex flex-col items-center text-center">
<div className="mb-2">{getFileIcon(file, viewMode)}</div>
<div className="w-full flex flex-col items-center">
{editingFile?.path === file.path ? (
<input
ref={editInputRef}
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={handleEditConfirm}
className={cn(
"max-w-[120px] min-w-[60px] w-fit rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs shadow-xs transition-[color,box-shadow] outline-none",
"text-center text-foreground placeholder:text-muted-foreground",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<p
className="text-xs text-foreground break-words px-1 py-0.5 rounded text-center leading-tight w-full"
title={file.name}
>
{file.name}
</p>
)}
{file.type === "file" &&
file.size !== undefined &&
file.size !== null && (
<p className="text-xs text-muted-foreground mt-1">
{formatFileSize(file.size)}
</p>
)}
{file.type === "link" && file.linkTarget && (
<p
className="text-xs text-primary mt-1 break-words w-full leading-tight"
title={file.linkTarget}
>
{file.linkTarget}
</p>
)}
</div>
</div>
</div>
);
})}
</div>
) : (
/* List view */
<div className="space-y-1">
{createIntent && (
<CreateIntentListItem
intent={createIntent}
onConfirm={onConfirmCreate}
onCancel={onCancelCreate}
/>
)}
{files.map((file) => {
const isSelected = selectedFiles.some(
(f) => f.path === file.path,
);
return (
<div
key={file.path}
data-file-path={file.path}
draggable={true}
className={cn(
"flex items-center gap-3 p-2 rounded cursor-pointer",
"hover:bg-accent hover:text-accent-foreground",
isSelected && "bg-primary/20",
dragState.target?.path === file.path &&
"bg-muted border-primary border-dashed relative z-10",
dragState.files.some((f) => f.path === file.path) &&
"opacity-50",
)}
onClick={(e) => handleFileClick(file, e)}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(e, file);
}}
onDragStart={(e) => handleFileDragStart(e, file)}
onDragOver={(e) => handleFileDragOver(e, file)}
onDragLeave={(e) => handleFileDragLeave(e, file)}
onDrop={(e) => handleFileDrop(e, file)}
onDragEnd={handleFileDragEnd}
>
<div className="flex-shrink-0">
{getFileIcon(file, viewMode)}
</div>
<div className="flex-1 min-w-0">
{editingFile?.path === file.path ? (
<input
ref={editInputRef}
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={handleEditKeyDown}
onBlur={handleEditConfirm}
className={cn(
"flex-1 min-w-0 max-w-[200px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none",
"text-foreground placeholder:text-muted-foreground",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
)}
onClick={(e) => e.stopPropagation()}
/>
) : (
<p
className="text-sm text-foreground break-words px-1 py-0.5 rounded leading-tight"
title={file.name}
>
{file.name}
</p>
)}
{file.type === "link" && file.linkTarget && (
<p
className="text-xs text-primary break-words leading-tight"
title={file.linkTarget}
>
{file.linkTarget}
</p>
)}
{file.modified && (
<p className="text-xs text-muted-foreground">
{file.modified}
</p>
)}
</div>
<div className="flex-shrink-0 text-right">
{file.type === "file" &&
file.size !== undefined &&
file.size !== null && (
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</p>
)}
</div>
<div className="flex-shrink-0 text-right w-20">
{file.permissions && (
<p className="text-xs text-muted-foreground font-mono">
{file.permissions}
</p>
)}
</div>
</div>
);
})}
</div>
)}
{isSelecting && selectionRect && (
<div
className="absolute pointer-events-none border-2 border-primary bg-primary/10 z-50"
style={{
left: selectionRect.x,
top: selectionRect.y,
width: selectionRect.width,
height: selectionRect.height,
}}
/>
)}
</div>
</div>
<div className="flex-shrink-0 border-t border-dark-border px-4 py-2 text-xs text-muted-foreground">
<div className="flex justify-between items-center">
<span>{t("fileManager.itemCount", { count: files.length })}</span>
{selectedFiles.length > 0 && (
<span>
{t("fileManager.selectedCount", { count: selectedFiles.length })}
</span>
)}
</div>
</div>
{dragState.type === "internal" &&
(dragState.files.length > 0 || dragState.draggedFiles?.length > 0) &&
dragState.mousePosition &&
createPortal(
<div
className="fixed pointer-events-none"
style={{
left: Math.min(
Math.max(dragState.mousePosition.x + 40, 0),
window.innerWidth - 300,
),
top: Math.max(
Math.min(
dragState.mousePosition.y - 80,
window.innerHeight - 100,
),
0,
),
zIndex: 999999,
}}
>
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
{(() => {
const files =
dragState.files.length > 0
? dragState.files
: dragState.draggedFiles || [];
return dragState.target ? (
dragState.target.type === "directory" ? (
<>
<Move className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-foreground">
{t("fileManager.moveTo", {
name: dragState.target.name,
})}
</span>
</>
) : (
<>
<GitCompare className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-foreground">
{t("fileManager.diffCompareWith", {
name: dragState.target.name,
})}
</span>
</>
)
) : (
<>
<Download className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-foreground">
{t("fileManager.dragOutsideToDownload", {
count: files.length,
})}
</span>
</>
);
})()}
</div>
</div>,
document.body,
)}
</div>
);
}
function CreateIntentGridItem({
intent,
onConfirm,
onCancel,
}: {
intent: CreateIntent;
onConfirm?: (name: string) => void;
onCancel?: () => void;
}) {
const { t } = useTranslation();
const [inputName, setInputName] = useState(intent.currentName);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm?.(inputName.trim());
} else if (e.key === "Escape") {
e.preventDefault();
onCancel?.();
}
};
return (
<div className="group p-3 rounded-lg border-2 border-dashed border-primary bg-primary/10">
<div className="flex flex-col items-center text-center">
<div className="mb-2">
{intent.type === "directory" ? (
<Folder className="w-8 h-8 text-primary" />
) : (
<File className="w-8 h-8 text-primary" />
)}
</div>
<input
ref={inputRef}
type="text"
value={inputName}
onChange={(e) => setInputName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onConfirm?.(inputName.trim())}
className="w-full max-w-[120px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-center text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
placeholder={
intent.type === "directory"
? t("fileManager.folderName")
: t("fileManager.fileName")
}
/>
</div>
</div>
);
}
function CreateIntentListItem({
intent,
onConfirm,
onCancel,
}: {
intent: CreateIntent;
onConfirm?: (name: string) => void;
onCancel?: () => void;
}) {
const { t } = useTranslation();
const [inputName, setInputName] = useState(intent.currentName);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm?.(inputName.trim());
} else if (e.key === "Escape") {
e.preventDefault();
onCancel?.();
}
};
return (
<div className="flex items-center gap-3 p-2 rounded border-2 border-dashed border-primary bg-primary/10">
<div className="flex-shrink-0">
{intent.type === "directory" ? (
<Folder className="w-6 h-6 text-primary" />
) : (
<File className="w-6 h-6 text-primary" />
)}
</div>
<input
ref={inputRef}
type="text"
value={inputName}
onChange={(e) => setInputName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => onConfirm?.(inputName.trim())}
className="flex-1 min-w-0 max-w-[200px] rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px] outline-none"
placeholder={
intent.type === "directory"
? t("fileManager.folderName")
: t("fileManager.fileName")
}
/>
</div>
);
}