Files
Termix/src/ui/desktop/apps/features/file-manager/FileManagerGrid.tsx
T
+1 ad86c2040b v1.10.0 (#471)
* fix select edit host but not update view (#438)

* fix: Checksum issue with chocolatey

* fix: Remove homebrew old stuff

* Add Korean translation (#439)

Co-authored-by: 송준우 <2484@coreit.co.kr>

* feat: Automate flatpak

* fix: Add imagemagik to electron builder to resolve build error

* fix: Build error with runtime repo flag

* fix: Flatpak runtime error and install freedesktop ver warning

* fix: Flatpak runtime error and install freedesktop ver warning

* feat: Re-add homebrew cask and move scripts to backend

* fix: No sandbox flag issue

* fix: Change name for electron macos cask output

* fix: Sandbox error with Linux

* fix: Remove comming soon for app stores in readme

* Adding Comment at the end of the public_key on the host on deploy (#440)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* -Add New Interface for Credential DB
-Add Credential Name as a comment into the server authorized_key file

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Sudo auto fill password (#441)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Feature Sudo password auto-fill;

* Fix locale json shema;

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Added Italian Language; (#445)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Added Italian Language;

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Auto collapse snippet folders (#448)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* feat: Add collapsable snippets (customizable in user profile)

* Translations (#447)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Added Italian Language;

* Fix translations;

Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations.

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* Remove PTY-level keepalive (#449)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation

* fix: finalize adding docker to db

* feat: Add docker management support (local squash)

* Fix RBAC role system bugs and improve UX (#446)

* Fix RBAC role system bugs and improve UX

- Fix user list dropdown selection in host sharing
- Fix role sharing permissions to include role-based access
- Fix translation template interpolation for success messages
- Standardize system roles to admin and user only
- Auto-assign user role to new registrations
- Remove blocking confirmation dialogs in modal contexts
- Add missing i18n keys for common actions
- Fix button type to prevent unintended form submissions

* Enhance RBAC system with UI improvements and security fixes

- Move role assignment to Users tab with per-user role management
- Protect system roles (admin/user) from editing and manual assignment
- Simplify permission system: remove Use level, keep View and Manage
- Hide Update button and Sharing tab for view-only/shared hosts
- Prevent users from sharing hosts with themselves
- Unify table and modal styling across admin panels
- Auto-assign system roles on user registration
- Add permission metadata to host interface

* Add empty state message for role assignment

- Display helpful message when no custom roles available
- Clarify that system roles are auto-assigned
- Add noCustomRolesToAssign translation in English and Chinese

* fix: Prevent credential sharing errors for shared hosts

- Skip credential resolution for shared hosts with credential authentication
  to prevent decryption errors (credentials are encrypted per-user)
- Add warning alert in sharing tab when host uses credential authentication
- Inform users that shared users cannot connect to credential-based hosts
- Add translations for credential sharing warning (EN/ZH)

This prevents authentication failures when sharing hosts configured
with credential authentication while maintaining security by keeping
credentials isolated per user.

* feat: Improve rbac UI and fixes some bugs

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* SOCKS5 support (#452)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* SOCKS5 support

Adding single and chain socks5 proxy support

* fix: cleanup files

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* Notes and Expiry fields add (#453)

* Add termix.rb Cask file

* Update Termix to version 1.9.0 with new checksum

* Update README to remove 'coming soon' notes

* Notes and Expiry add

* fix: cleanup files

---------

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* fix: ssh host types

* fix: sudo incorrect styling and remove expiration date

* feat: add sudo password and add diagonal bg's

* fix: snippet running on enter key

* fix: base64 decoding

* fix: improve server stats / rbac

* fix: wrap ssh host json export in hosts array

* feat: auto trim host inputs, fix file manager jump hosts, dashboard prevent duplicates, file manager terminal not size updating, improve left sidebar sorting, hide/show tags, add apperance user profile tab, add new host manager tabs.

* feat: improve terminal connection speed

* fix: sqlite constriant errors and support non-root user (nginx perm issue)

* feat: add beta syntax highlighing to terminal

* feat: update imports and improve admin settings user management

* chore: update translations

* chore: update translations

* feat: Complete light mode implementation with semantic theme system (#450)

- Add comprehensive light/dark mode CSS variables with semantic naming
- Implement theme-aware scrollbars using CSS variables
- Add light mode backgrounds: --bg-base, --bg-elevated, --bg-surface, etc.
- Add theme-aware borders: --border-base, --border-panel, --border-subtle
- Add semantic text colors: --foreground-secondary, --foreground-subtle
- Convert oklch colors to hex for better compatibility
- Add theme awareness to CodeMirror editors
- Update dark mode colors for consistency (background, sidebar, card, muted, input)
- Add Tailwind color mappings for semantic classes

Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com>

* fix: syntax errors

* chore: updating/match themes and split admin settings

* feat: add translation workflow and remove old translation.json

* fix: translation workflow error

* fix: translation workflow error

* feat: improve translation system and update workflow

* fix: wrong path for translations

* fix: change translation to flat files

* fix: gh rule error

* chore: auto-translate to multiple languages (#458)

* chore: improve organization and made a few styling changes in host manager

* feat: improve terminal stability and split out the host manager

* fix: add unnversiioned files

* chore: migrate all to use the new theme system

* fix: wrong animation line colors

* fix: rbac implementation general issues (local squash)

* fix: remove unneeded files

* feat: add 10 new langs

* chore: update gitnore

* chore: auto-translate to multiple languages (#459)

* fix: improve tunnel system

* fix: properly split tabs, still need to fix up the host manager

* chore: cleanup files (possible RC)

* feat: add norwegian

* chore: auto-translate to multiple languages (#461)

* fix: small qol fixes and began readme update

* fix: run cleanup script

* feat: add docker docs button

* feat: general bug fixes and readme updates

* fix: translations

* chore: auto-translate to multiple languages (#462)

* fix: cleanup files

* fix: test new translation issue and add better server-stats support

* fix: fix translate error

* chore: auto-translate to multiple languages (#463)

* fix: fix translate mismatching text

* chore: auto-translate to multiple languages (#465)

* fix: fix translate mismatching text

* fix: fix translate mismatching text

* chore: auto-translate to multiple languages (#466)

* fix: fix translate mismatching text

* fix: fix translate mismatching text

* fix: fix translate mismatching text

* chore: auto-translate to multiple languages (#467)

* fix: fix translate mismatching text

* chore: auto-translate to multiple languages (#468)

* feat: add to readme, a few qol changes, and improve server stats in general

* chore: auto-translate to multiple languages (#469)

* feat: turned disk uage into graph and fixed issue with termina console

* fix: electron build error and hide icons when shared

* chore: run clean

* fix: general server stats issues, file manager decoding, ui qol

* fix: add dashboard line breaks

* fix: docker console error

* fix: docker console not loading and mismatched stripped background for electron

* fix: docker console not loading

* chore: docker console not loading in docker

* chore: translate readme to chinese

* chore: match package lock to package json

* chore: nginx config issue for dokcer console

* chore: auto-translate to multiple languages (#470)

---------

Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com>
Co-authored-by: junu <bigdwarf_@naver.com>
Co-authored-by: 송준우 <2484@coreit.co.kr>
Co-authored-by: SlimGary <trash.slim@gmail.com>
Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com>
Co-authored-by: Wesley Reid <starhound@lostsouls.org>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Denis <38875137+Medvedinca@users.noreply.github.com>
Co-authored-by: Peet McKinney <68706879+PeetMcK@users.noreply.github.com>
2025-12-31 22:20:12 -06:00

1439 lines
45 KiB
TypeScript

import React, { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils.ts";
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";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
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;
onNewFile?: () => void;
onNewFolder?: () => void;
}
const getFileTypeColor = (file: FileItem): string => {
const colorEnabled = localStorage.getItem("fileColorCoding") !== "false";
if (!colorEnabled) {
return "text-muted-foreground";
}
if (file.type === "directory") {
return "text-red-400";
}
if (file.type === "link") {
return "text-green-400";
}
return "text-blue-400";
};
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
const colorClass = getFileTypeColor(file);
if (file.type === "directory") {
return <Folder className={`${iconClass} ${colorClass}`} />;
}
if (file.type === "link") {
return <FileSymlink className={`${iconClass} ${colorClass}`} />;
}
const ext = file.name.split(".").pop()?.toLowerCase();
switch (ext) {
case "txt":
case "md":
case "readme":
return <FileText className={`${iconClass} ${colorClass}`} />;
case "png":
case "jpg":
case "jpeg":
case "gif":
case "bmp":
case "svg":
return <FileImage className={`${iconClass} ${colorClass}`} />;
case "mp4":
case "avi":
case "mkv":
case "mov":
return <FileVideo className={`${iconClass} ${colorClass}`} />;
case "mp3":
case "wav":
case "flac":
case "ogg":
return <FileAudio className={`${iconClass} ${colorClass}`} />;
case "zip":
case "tar":
case "gz":
case "rar":
case "7z":
return <Archive className={`${iconClass} ${colorClass}`} />;
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} ${colorClass}`} />;
case "json":
case "xml":
case "yaml":
case "yml":
case "toml":
case "ini":
case "conf":
case "config":
return <Settings className={`${iconClass} ${colorClass}`} />;
default:
return <File className={`${iconClass} ${colorClass}`} />;
}
};
export function FileManagerGrid({
files,
selectedFiles,
onFileOpen,
onSelectionChange,
currentPath,
isLoading,
onPathChange,
onRefresh,
onUpload,
onDownload,
onContextMenu,
viewMode = "grid",
onRename,
editingFile,
onStartEdit,
onCancelEdit,
onDelete,
onCopy,
onCut,
onPaste,
onUndo,
onFileDrop,
onFileDiff,
onSystemDragEnd,
hasClipboard,
createIntent,
onConfirmCreate,
onCancelCreate,
onNewFile,
onNewFolder,
}: 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);
};
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 = () => {
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 "d":
case "D":
if (
(event.ctrlKey || event.metaKey) &&
selectedFiles.length > 0 &&
onDownload
) {
event.preventDefault();
onDownload(selectedFiles);
}
break;
case "n":
case "N":
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
if (event.shiftKey && onNewFolder) {
onNewFolder();
} else if (!event.shiftKey && onNewFile) {
onNewFile();
}
}
break;
case "u":
case "U":
if ((event.ctrlKey || event.metaKey) && onUpload) {
event.preventDefault();
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
if (files) onUpload(files);
};
input.click();
}
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 "Enter":
if (selectedFiles.length === 1) {
event.preventDefault();
onFileOpen(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,
]);
return (
<div className="h-full flex flex-col bg-canvas overflow-hidden relative">
<div className="flex-shrink-0 border-b border-edge">
<div className="flex items-center gap-1 p-2 border-b border-edge">
<button
onClick={goBack}
disabled={historyIndex <= 0}
className={cn(
"p-1 rounded hover:bg-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-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-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-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-hover border border-edge 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-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">
<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 hover:scale-[1.02] border-2 border-transparent",
isSelected &&
"bg-primary/20 border-primary ring-2 ring-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",
)}
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-edge bg-elevated 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>
) : (
<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 ring-2 ring-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-edge bg-elevated 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-edge 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,
)}
<SimpleLoader visible={isLoading} message={t("common.connecting")} />
</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-edge bg-elevated 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-edge bg-elevated 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>
);
}