Code cleanup

This commit is contained in:
LukeGus
2025-09-28 00:23:00 -05:00
parent d2ba934f61
commit bc8aa69099
76 changed files with 62289 additions and 6806 deletions
File diff suppressed because it is too large Load Diff
@@ -107,7 +107,6 @@ export function FileManagerContextMenu({
useEffect(() => {
if (!isVisible) return;
// Adjust menu position to avoid going off screen
const adjustPosition = () => {
const menuWidth = 200;
const menuHeight = 300;
@@ -130,13 +129,10 @@ export function FileManagerContextMenu({
adjustPosition();
// Delay adding event listeners to avoid capturing the click that triggered the menu
let cleanupFn: (() => void) | null = null;
const timeoutId = setTimeout(() => {
// Click outside to close menu
const handleClickOutside = (event: MouseEvent) => {
// Check if click is inside menu
const target = event.target as Element;
const menuElement = document.querySelector("[data-context-menu]");
@@ -145,13 +141,11 @@ export function FileManagerContextMenu({
}
};
// Right-click to close menu (Windows behavior)
const handleRightClick = (event: MouseEvent) => {
event.preventDefault();
onClose();
};
// Keyboard support
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
@@ -159,12 +153,10 @@ export function FileManagerContextMenu({
}
};
// Close menu on window blur
const handleBlur = () => {
onClose();
};
// Close menu on scroll (Windows behavior)
const handleScroll = () => {
onClose();
};
@@ -175,7 +167,6 @@ export function FileManagerContextMenu({
window.addEventListener("blur", handleBlur);
window.addEventListener("scroll", handleScroll, true);
// Set cleanup function
cleanupFn = () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("contextmenu", handleRightClick);
@@ -183,7 +174,7 @@ export function FileManagerContextMenu({
window.removeEventListener("blur", handleBlur);
window.removeEventListener("scroll", handleScroll, true);
};
}, 50); // 50ms delay to ensure we don't capture the click that triggered the menu
}, 50);
return () => {
clearTimeout(timeoutId);
@@ -204,13 +195,9 @@ export function FileManagerContextMenu({
(f) => f.type === "file" && f.executable,
);
// Build menu items
const menuItems: MenuItem[] = [];
if (isFileContext) {
// Menu when files/folders are selected
// Open terminal function - supports files and folders
if (onOpenTerminal) {
const targetPath = isSingleFile
? files[0].type === "directory"
@@ -229,7 +216,6 @@ export function FileManagerContextMenu({
});
}
// Run executable file function - only show for single executable files
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
menuItems.push({
icon: <Play className="w-4 h-4" />,
@@ -239,7 +225,6 @@ export function FileManagerContextMenu({
});
}
// Add separator (if above functions exist)
if (
onOpenTerminal ||
(isSingleFile && hasExecutableFiles && onRunExecutable)
@@ -247,7 +232,6 @@ export function FileManagerContextMenu({
menuItems.push({ separator: true } as MenuItem);
}
// Preview function
if (hasFiles && onPreview) {
menuItems.push({
icon: <Eye className="w-4 h-4" />,
@@ -257,7 +241,6 @@ export function FileManagerContextMenu({
});
}
// Download function - use proper download handler
if (hasFiles && onDownload) {
menuItems.push({
icon: <Download className="w-4 h-4" />,
@@ -269,7 +252,6 @@ export function FileManagerContextMenu({
});
}
// PIN/UNPIN function - only show for single files
if (isSingleFile && files[0].type === "file") {
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
@@ -288,7 +270,6 @@ export function FileManagerContextMenu({
}
}
// Add folder shortcut - only show for single folders
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
menuItems.push({
icon: <Bookmark className="w-4 h-4" />,
@@ -297,7 +278,6 @@ export function FileManagerContextMenu({
});
}
// Add separator (if above functions exist)
if (
(hasFiles && (onPreview || onDragToDesktop)) ||
(isSingleFile &&
@@ -308,7 +288,6 @@ export function FileManagerContextMenu({
menuItems.push({ separator: true } as MenuItem);
}
// Rename function
if (isSingleFile && onRename) {
menuItems.push({
icon: <Edit3 className="w-4 h-4" />,
@@ -318,7 +297,6 @@ export function FileManagerContextMenu({
});
}
// Copy function
if (onCopy) {
menuItems.push({
icon: <Copy className="w-4 h-4" />,
@@ -330,7 +308,6 @@ export function FileManagerContextMenu({
});
}
// Cut function
if (onCut) {
menuItems.push({
icon: <Scissors className="w-4 h-4" />,
@@ -342,12 +319,10 @@ export function FileManagerContextMenu({
});
}
// Add separator (if edit functions exist)
if ((isSingleFile && onRename) || onCopy || onCut) {
menuItems.push({ separator: true } as MenuItem);
}
// Delete function
if (onDelete) {
menuItems.push({
icon: <Trash2 className="w-4 h-4" />,
@@ -360,12 +335,10 @@ export function FileManagerContextMenu({
});
}
// Add separator (if delete function exists)
if (onDelete) {
menuItems.push({ separator: true } as MenuItem);
}
// Properties function
if (isSingleFile && onProperties) {
menuItems.push({
icon: <Info className="w-4 h-4" />,
@@ -374,9 +347,6 @@ export function FileManagerContextMenu({
});
}
} else {
// Empty area right-click menu
// Open terminal in current directory
if (onOpenTerminal && currentPath) {
menuItems.push({
icon: <Terminal className="w-4 h-4" />,
@@ -386,7 +356,6 @@ export function FileManagerContextMenu({
});
}
// Upload function
if (onUpload) {
menuItems.push({
icon: <Upload className="w-4 h-4" />,
@@ -396,12 +365,10 @@ export function FileManagerContextMenu({
});
}
// Add separator (if terminal or upload functions exist)
if ((onOpenTerminal && currentPath) || onUpload) {
menuItems.push({ separator: true } as MenuItem);
}
// New folder
if (onNewFolder) {
menuItems.push({
icon: <FolderPlus className="w-4 h-4" />,
@@ -411,7 +378,6 @@ export function FileManagerContextMenu({
});
}
// New file
if (onNewFile) {
menuItems.push({
icon: <FilePlus className="w-4 h-4" />,
@@ -421,12 +387,10 @@ export function FileManagerContextMenu({
});
}
// Add separator (if new functions exist)
if (onNewFolder || onNewFile) {
menuItems.push({ separator: true } as MenuItem);
}
// Refresh function
if (onRefresh) {
menuItems.push({
icon: <RefreshCw className="w-4 h-4" />,
@@ -436,7 +400,6 @@ export function FileManagerContextMenu({
});
}
// Paste function
if (hasClipboard && onPaste) {
menuItems.push({
icon: <Clipboard className="w-4 h-4" />,
@@ -447,15 +410,12 @@ export function FileManagerContextMenu({
}
}
// Filter out consecutive separators
const filteredMenuItems = menuItems.filter((item, index) => {
if (!item.separator) return true;
// If it's a separator, check if previous and next are also separators
const prevItem = index > 0 ? menuItems[index - 1] : null;
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
// If previous or next is a separator, filter out current separator
if (prevItem?.separator || nextItem?.separator) {
return false;
}
@@ -463,7 +423,6 @@ export function FileManagerContextMenu({
return true;
});
// Remove separators at beginning and end
const finalMenuItems = filteredMenuItems.filter((item, index) => {
if (!item.separator) return true;
return index > 0 && index < filteredMenuItems.length - 1;
@@ -471,10 +430,8 @@ export function FileManagerContextMenu({
return (
<>
{/* Transparent overlay to capture click events */}
<div className="fixed inset-0 z-[99990]" />
{/* Menu body */}
<div
data-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden"
@@ -15,7 +15,6 @@ import {
Upload,
ChevronLeft,
ChevronRight,
MoreHorizontal,
RefreshCw,
ArrowUp,
FileSymlink,
@@ -26,20 +25,16 @@ import {
import { useTranslation } from "react-i18next";
import type { FileItem } from "../../../types/index.js";
// Linus-style data structure: separate creation intent from actual files
interface CreateIntent {
id: string;
type: 'file' | 'directory';
type: "file" | "directory";
defaultName: string;
currentName: string;
}
// Format file size
function formatFileSize(bytes?: number): string {
// Handle undefined or null cases
if (bytes === undefined || bytes === null) return "-";
// Display 0-byte files as "0 B"
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -51,7 +46,6 @@ function formatFileSize(bytes?: number): string {
unitIndex++;
}
// Display one decimal place for values less than 10, integers for values greater than 10
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
@@ -95,7 +89,6 @@ interface FileManagerGridProps {
onSystemDragStart?: (files: FileItem[]) => void;
onSystemDragEnd?: (e: DragEvent, files: FileItem[]) => void;
hasClipboard?: boolean;
// Linus-style creation intent props
createIntent?: CreateIntent | null;
onConfirmCreate?: (name: string) => void;
onCancelCreate?: () => void;
@@ -206,16 +199,12 @@ export function FileManagerGrid({
const gridRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState("");
// Unified drag state management
const [dragState, setDragState] = useState<DragState>({
type: "none",
files: [],
counter: 0,
});
// Global mouse move listener - for drag tooltip following
useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => {
if (dragState.type === "internal" && dragState.files.length > 0) {
@@ -235,11 +224,9 @@ export function FileManagerGrid({
const editInputRef = useRef<HTMLInputElement>(null);
// Set initial name when starting edit
useEffect(() => {
if (editingFile) {
setEditingName(editingFile.name);
// Delay focus to ensure DOM is updated
setTimeout(() => {
editInputRef.current?.focus();
editInputRef.current?.select();
@@ -247,7 +234,6 @@ export function FileManagerGrid({
}
}, [editingFile]);
// Handle edit confirmation
const handleEditConfirm = () => {
if (
editingFile &&
@@ -260,13 +246,11 @@ export function FileManagerGrid({
onCancelEdit?.();
};
// Handle edit cancellation
const handleEditCancel = () => {
setEditingName("");
onCancelEdit?.();
};
// Handle input key events
const handleEditKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -277,9 +261,7 @@ export function FileManagerGrid({
}
};
// File drag handling function
const handleFileDragStart = (e: React.DragEvent, file: FileItem) => {
// If dragged file is selected, drag all selected files
const filesToDrag = selectedFiles.includes(file) ? selectedFiles : [file];
setDragState({
@@ -290,7 +272,6 @@ export function FileManagerGrid({
mousePosition: { x: e.clientX, y: e.clientY },
});
// Set drag data, add internal drag identifier
const dragData = {
type: "internal_files",
files: filesToDrag.map((f) => f.path),
@@ -303,7 +284,6 @@ export function FileManagerGrid({
e.preventDefault();
e.stopPropagation();
// Only set target when dragging to different file and not being dragged file
if (
dragState.type === "internal" &&
!dragState.files.some((f) => f.path === targetFile.path)
@@ -317,7 +297,6 @@ export function FileManagerGrid({
e.preventDefault();
e.stopPropagation();
// Clear drag target highlight
if (dragState.target?.path === targetFile.path) {
setDragState((prev) => ({ ...prev, target: undefined }));
}
@@ -332,46 +311,23 @@ export function FileManagerGrid({
return;
}
// Check if dragging to self
const isDroppingOnSelf = dragState.files.some(
(f) => f.path === targetFile.path,
);
if (isDroppingOnSelf) {
console.log("Ignoring drop on self");
setDragState({ type: "none", files: [], counter: 0 });
return;
}
// Determine drag behavior:
// 1. File/folder drag to folder = move operation
// 2. Single file drag to single file = diff comparison
// 3. Other cases = invalid operation
if (targetFile.type === "directory") {
// Move operation
console.log(
"Moving files to directory:",
dragState.files.map((f) => f.name),
"to",
targetFile.name,
);
onFileDrop?.(dragState.files, targetFile);
} else if (
targetFile.type === "file" &&
dragState.files.length === 1 &&
dragState.files[0].type === "file"
) {
// Diff comparison operation
console.log(
"Comparing files:",
dragState.files[0].name,
"vs",
targetFile.name,
);
onFileDiff?.(dragState.files[0], targetFile);
} else {
// Invalid operation, notify user
console.log("Invalid drag operation");
}
setDragState({ type: "none", files: [], counter: 0 });
@@ -381,7 +337,6 @@ export function FileManagerGrid({
const draggedFiles = dragState.draggedFiles || [];
setDragState({ type: "none", files: [], counter: 0 });
// Trigger system-level drag end detection with dragged files
onSystemDragEnd?.(e.nativeEvent, draggedFiles);
};
@@ -398,17 +353,14 @@ export function FileManagerGrid({
} | null>(null);
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
// Navigation history management
const [navigationHistory, setNavigationHistory] = useState<string[]>([
currentPath,
]);
const [historyIndex, setHistoryIndex] = useState(0);
// Path editing state
const [isEditingPath, setIsEditingPath] = useState(false);
const [editPathValue, setEditPathValue] = useState(currentPath);
// Update navigation history
useEffect(() => {
const lastPath = navigationHistory[historyIndex];
if (currentPath !== lastPath) {
@@ -419,7 +371,6 @@ export function FileManagerGrid({
}
}, [currentPath]);
// Navigation functions
const goBack = () => {
if (historyIndex > 0) {
const newIndex = historyIndex - 1;
@@ -447,7 +398,6 @@ export function FileManagerGrid({
}
};
// Path navigation
const pathParts = currentPath.split("/").filter(Boolean);
const navigateToPath = (index: number) => {
if (index === -1) {
@@ -458,7 +408,6 @@ export function FileManagerGrid({
}
};
// Path editing functionality
const startEditingPath = () => {
setEditPathValue(currentPath);
setIsEditingPath(true);
@@ -472,7 +421,6 @@ export function FileManagerGrid({
const confirmEditingPath = () => {
const trimmedPath = editPathValue.trim();
if (trimmedPath) {
// Ensure path starts with /
const normalizedPath = trimmedPath.startsWith("/")
? trimmedPath
: "/" + trimmedPath;
@@ -491,31 +439,26 @@ export function FileManagerGrid({
}
};
// Sync editPathValue with currentPath
useEffect(() => {
if (!isEditingPath) {
setEditPathValue(currentPath);
}
}, [currentPath, isEditingPath]);
// Drag and drop handling - distinguish internal file drag and external file upload
const handleDragEnter = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Check if it's internal file drag
const isInternalDrag = dragState.type === "internal";
if (!isInternalDrag) {
// Only show upload prompt for external file drag
setDragState((prev) => ({
...prev,
type: "external",
counter: prev.counter + 1,
}));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
// External drag detected
}
}
},
@@ -527,7 +470,6 @@ export function FileManagerGrid({
e.preventDefault();
e.stopPropagation();
// Check if it's internal file drag
const isInternalDrag = dragState.type === "internal";
if (!isInternalDrag && dragState.type === "external") {
@@ -549,11 +491,9 @@ export function FileManagerGrid({
e.preventDefault();
e.stopPropagation();
// Check if it's internal file drag
const isInternalDrag = dragState.type === "internal";
if (isInternalDrag) {
// Update mouse position
setDragState((prev) => ({
...prev,
mousePosition: { x: e.clientX, y: e.clientY },
@@ -566,15 +506,11 @@ export function FileManagerGrid({
[dragState.type],
);
// Mouse wheel event handling, ensure scrolling works normally
const handleWheel = useCallback((e: React.WheelEvent) => {
// Don't prevent default scroll behavior, let browser handle scrolling
e.stopPropagation();
}, []);
// Box selection functionality implementation
const handleMouseDown = useCallback((e: React.MouseEvent) => {
// Only start box selection in empty area, avoid interfering with file clicks
if (e.target === e.currentTarget && e.button === 0) {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
@@ -585,7 +521,6 @@ export function FileManagerGrid({
setSelectionStart({ x: startX, y: startY });
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
// Reset flag for just completed selection, prepare for new selection
setJustFinishedSelecting(false);
}
}, []);
@@ -604,7 +539,6 @@ export function FileManagerGrid({
setSelectionRect({ x, y, width, height });
// Detect intersection with file items, perform real-time selection
if (gridRef.current) {
const fileElements =
gridRef.current.querySelectorAll("[data-file-path]");
@@ -614,7 +548,6 @@ export function FileManagerGrid({
const elementRect = element.getBoundingClientRect();
const containerRect = gridRef.current!.getBoundingClientRect();
// Simplify coordinate calculation - directly use coordinates relative to container
const relativeElementRect = {
left: elementRect.left - containerRect.left,
top: elementRect.top - containerRect.top,
@@ -622,7 +555,6 @@ export function FileManagerGrid({
bottom: elementRect.bottom - containerRect.top,
};
// Selection box coordinates
const selectionBox = {
left: x,
top: y,
@@ -630,7 +562,6 @@ export function FileManagerGrid({
bottom: y + height,
};
// Check if intersecting
const intersects = !(
relativeElementRect.right < selectionBox.left ||
relativeElementRect.left > selectionBox.right ||
@@ -642,21 +573,13 @@ export function FileManagerGrid({
const filePath = element.getAttribute("data-file-path");
if (filePath) {
selectedPaths.push(filePath);
console.log("Selected file:", filePath);
}
}
});
console.log("Total selected paths:", selectedPaths.length);
// Update selected files
const newSelection = files.filter((file) =>
selectedPaths.includes(file.path),
);
console.log(
"New selection:",
newSelection.map((f) => f.name),
);
onSelectionChange(newSelection);
}
}
@@ -671,7 +594,6 @@ export function FileManagerGrid({
setSelectionStart(null);
setSelectionRect(null);
// Only consider as box selection when movement distance is large enough, otherwise it's a click
const startPos = selectionStart;
if (startPos) {
const rect = gridRef.current?.getBoundingClientRect();
@@ -683,13 +605,11 @@ export function FileManagerGrid({
);
if (distance > 5) {
// Real box selection, set flag to prevent immediate clearing
setJustFinishedSelecting(true);
setTimeout(() => {
setJustFinishedSelecting(false);
}, 50);
} else {
// Just a click, don't set flag, let handleGridClick handle normally
setJustFinishedSelecting(false);
}
}
@@ -699,7 +619,6 @@ export function FileManagerGrid({
[isSelecting, selectionStart],
);
// Global mouse event listener, ensure box selection can end outside container
useEffect(() => {
const handleGlobalMouseUp = (e: MouseEvent) => {
if (isSelecting) {
@@ -707,7 +626,6 @@ export function FileManagerGrid({
setSelectionStart(null);
setSelectionRect(null);
// Global mouseup indicates drag box selection, set flag
setJustFinishedSelecting(true);
setTimeout(() => {
setJustFinishedSelecting(false);
@@ -747,58 +665,32 @@ export function FileManagerGrid({
e.stopPropagation();
if (dragState.type === "internal") {
// Internal drag to empty area: just cancel the drag operation
console.log("Internal drag to empty area - cancelling drag operation");
// Do not trigger download here - system drag end will handle it if truly outside window
setDragState({ type: "none", files: [], counter: 0 });
} else if (dragState.type === "external") {
// External drag: handle file upload
if (onUpload && e.dataTransfer.files.length > 0) {
onUpload(e.dataTransfer.files);
}
}
// Reset drag state
setDragState({ type: "none", files: [], counter: 0 });
},
[onUpload, onDownload, dragState],
);
// File selection handling
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
event.stopPropagation();
// Ensure grid gets focus to support keyboard events
if (gridRef.current) {
gridRef.current.focus();
}
console.log(
"File clicked:",
file.name,
"Current selected:",
selectedFiles.length,
);
if (event.detail === 2) {
// Double click to open
console.log("Double click - opening file");
onFileOpen(file);
} else {
// Single click to select
const multiSelect = event.ctrlKey || event.metaKey;
const rangeSelect = event.shiftKey;
console.log(
"Single click - multiSelect:",
multiSelect,
"rangeSelect:",
rangeSelect,
);
if (rangeSelect && selectedFiles.length > 0) {
// Range selection (Shift+click)
console.log("Range selection");
const lastSelected = selectedFiles[selectedFiles.length - 1];
const currentIndex = files.findIndex((f) => f.path === file.path);
const lastIndex = files.findIndex((f) => f.path === lastSelected.path);
@@ -807,36 +699,26 @@ export function FileManagerGrid({
const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex);
const rangeFiles = files.slice(start, end + 1);
console.log("Range selection result:", rangeFiles.length, "files");
onSelectionChange(rangeFiles);
}
} else if (multiSelect) {
// Multi-selection (Ctrl+click)
console.log("Multi selection");
const isSelected = selectedFiles.some((f) => f.path === file.path);
if (isSelected) {
console.log("Removing from selection");
onSelectionChange(selectedFiles.filter((f) => f.path !== file.path));
} else {
console.log("Adding to selection");
onSelectionChange([...selectedFiles, file]);
}
} else {
// Single selection
console.log("Single selection - should select only:", file.name);
onSelectionChange([file]);
}
}
};
// Click empty area to cancel selection
const handleGridClick = (event: React.MouseEvent) => {
// Ensure grid gets focus to support keyboard events
if (gridRef.current) {
gridRef.current.focus();
}
// If just completed box selection, don't clear selection
if (
event.target === event.currentTarget &&
!isSelecting &&
@@ -846,10 +728,8 @@ export function FileManagerGrid({
}
};
// Keyboard support
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Check if input box or editable element has focus, skip if so
const activeElement = document.activeElement;
if (
activeElement &&
@@ -868,7 +748,6 @@ export function FileManagerGrid({
case "A":
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
console.log("Ctrl+A pressed - selecting all files:", files.length);
onSelectionChange([...files]);
}
break;
@@ -910,7 +789,6 @@ export function FileManagerGrid({
break;
case "Delete":
if (selectedFiles.length > 0 && onDelete) {
// Trigger delete operation
onDelete(selectedFiles);
}
break;
@@ -922,7 +800,7 @@ export function FileManagerGrid({
break;
case "y":
case "Y":
if ((event.ctrlKey || event.metaKey)) {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
onRefresh();
}
@@ -957,9 +835,7 @@ export function FileManagerGrid({
return (
<div className="h-full flex flex-col bg-dark-bg overflow-hidden">
{/* Toolbar and path navigation */}
<div className="flex-shrink-0 border-b border-dark-border">
{/* Navigation buttons */}
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
<button
onClick={goBack}
@@ -1004,10 +880,8 @@ export function FileManagerGrid({
</button>
</div>
{/* Breadcrumb navigation */}
<div className="flex items-center px-3 py-2 text-sm">
{isEditingPath ? (
// Edit mode: path input box
<div className="flex-1 flex items-center gap-2">
<input
type="text"
@@ -1038,7 +912,6 @@ export function FileManagerGrid({
</button>
</div>
) : (
// View mode: breadcrumb navigation
<>
<button
onClick={() => navigateToPath(-1)}
@@ -1071,7 +944,6 @@ export function FileManagerGrid({
</div>
</div>
{/* Main file grid - scroll area */}
<div className="flex-1 relative overflow-hidden">
<div
ref={gridRef}
@@ -1092,7 +964,6 @@ export function FileManagerGrid({
onContextMenu={(e) => onContextMenu?.(e)}
tabIndex={0}
>
{/* Drag hint overlay */}
{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">
@@ -1128,7 +999,6 @@ export function FileManagerGrid({
</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">
{/* Linus-style creation intent UI - pure separation */}
{createIntent && (
<CreateIntentGridItem
intent={createIntent}
@@ -1169,10 +1039,8 @@ export function FileManagerGrid({
onDragEnd={handleFileDragEnd}
>
<div className="flex flex-col items-center text-center">
{/* File icon */}
<div className="mb-2">{getFileIcon(file, viewMode)}</div>
{/* File name */}
<div className="w-full flex flex-col items-center">
{editingFile?.path === file.path ? (
<input
@@ -1221,7 +1089,6 @@ export function FileManagerGrid({
) : (
/* List view */
<div className="space-y-1">
{/* Linus-style creation intent UI - list view */}
{createIntent && (
<CreateIntentListItem
intent={createIntent}
@@ -1260,12 +1127,10 @@ export function FileManagerGrid({
onDrop={(e) => handleFileDrop(e, file)}
onDragEnd={handleFileDragEnd}
>
{/* File icon */}
<div className="flex-shrink-0">
{getFileIcon(file, viewMode)}
</div>
{/* File info */}
<div className="flex-1 min-w-0">
{editingFile?.path === file.path ? (
<input
@@ -1305,7 +1170,6 @@ export function FileManagerGrid({
)}
</div>
{/* File size */}
<div className="flex-shrink-0 text-right">
{file.type === "file" &&
file.size !== undefined &&
@@ -1316,7 +1180,6 @@ export function FileManagerGrid({
)}
</div>
{/* Permission info */}
<div className="flex-shrink-0 text-right w-20">
{file.permissions && (
<p className="text-xs text-muted-foreground font-mono">
@@ -1330,7 +1193,6 @@ export function FileManagerGrid({
</div>
)}
{/* Selection rectangle */}
{isSelecting && selectionRect && (
<div
className="absolute pointer-events-none border-2 border-primary bg-primary/10 z-50"
@@ -1345,7 +1207,6 @@ export function FileManagerGrid({
</div>
</div>
{/* Status bar */}
<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>
@@ -1357,7 +1218,6 @@ export function FileManagerGrid({
</div>
</div>
{/* Drag following tooltip - rendered as portal to ensure highest z-index */}
{dragState.type === "internal" &&
(dragState.files.length > 0 || dragState.draggedFiles?.length > 0) &&
dragState.mousePosition &&
@@ -1365,27 +1225,43 @@ export function FileManagerGrid({
<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),
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 || [];
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 })}
{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 })}
{t("fileManager.diffCompareWith", {
name: dragState.target.name,
})}
</span>
</>
)
@@ -1393,20 +1269,21 @@ export function FileManagerGrid({
<>
<Download className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-foreground">
{t("fileManager.dragOutsideToDownload", { count: files.length })}
{t("fileManager.dragOutsideToDownload", {
count: files.length,
})}
</span>
</>
);
})()}
</div>
</div>,
document.body
document.body,
)}
</div>
);
}
// Linus-style creation intent component: Grid view
function CreateIntentGridItem({
intent,
onConfirm,
@@ -1439,7 +1316,7 @@ function CreateIntentGridItem({
<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' ? (
{intent.type === "directory" ? (
<Folder className="w-8 h-8 text-primary" />
) : (
<File className="w-8 h-8 text-primary" />
@@ -1453,14 +1330,17 @@ function CreateIntentGridItem({
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')}
placeholder={
intent.type === "directory"
? t("fileManager.folderName")
: t("fileManager.fileName")
}
/>
</div>
</div>
);
}
// Linus-style creation intent component: List view
function CreateIntentListItem({
intent,
onConfirm,
@@ -1492,7 +1372,7 @@ function CreateIntentListItem({
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' ? (
{intent.type === "directory" ? (
<Folder className="w-6 h-6 text-primary" />
) : (
<File className="w-6 h-6 text-primary" />
@@ -1506,7 +1386,11 @@ function CreateIntentListItem({
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')}
placeholder={
intent.type === "directory"
? t("fileManager.folderName")
: t("fileManager.fileName")
}
/>
</div>
);
@@ -38,9 +38,9 @@ interface FileManagerSidebarProps {
currentPath: string;
onPathChange: (path: string) => void;
onLoadDirectory?: (path: string) => void;
onFileOpen?: (file: SidebarItem) => void; // Added: handle file opening
onFileOpen?: (file: SidebarItem) => void;
sshSessionId?: string;
refreshTrigger?: number; // Used to trigger data refresh
refreshTrigger?: number;
}
export function FileManagerSidebar({
@@ -61,7 +61,6 @@ export function FileManagerSidebar({
new Set(["root"]),
);
// Right-click menu state
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
@@ -74,12 +73,10 @@ export function FileManagerSidebar({
item: null,
});
// Load quick access data
useEffect(() => {
loadQuickAccessData();
}, [currentHost, refreshTrigger]);
// Load directory tree (depends on sshSessionId)
useEffect(() => {
if (sshSessionId) {
loadDirectoryTree();
@@ -90,7 +87,6 @@ export function FileManagerSidebar({
if (!currentHost?.id) return;
try {
// Load recent files (limit to 5)
const recentData = await getRecentFiles(currentHost.id);
const recentItems = recentData.slice(0, 5).map((item: any) => ({
id: `recent-${item.id}`,
@@ -101,7 +97,6 @@ export function FileManagerSidebar({
}));
setRecentItems(recentItems);
// Load pinned files
const pinnedData = await getPinnedFiles(currentHost.id);
const pinnedItems = pinnedData.map((item: any) => ({
id: `pinned-${item.id}`,
@@ -111,7 +106,6 @@ export function FileManagerSidebar({
}));
setPinnedItems(pinnedItems);
// Load folder shortcuts
const shortcutData = await getFolderShortcuts(currentHost.id);
const shortcutItems = shortcutData.map((item: any) => ({
id: `shortcut-${item.id}`,
@@ -122,20 +116,18 @@ export function FileManagerSidebar({
setShortcuts(shortcutItems);
} catch (error) {
console.error("Failed to load quick access data:", error);
// If loading fails, keep empty arrays
setRecentItems([]);
setPinnedItems([]);
setShortcuts([]);
}
};
// Delete functionality implementation
const handleRemoveRecentFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeRecentFile(currentHost.id, item.path);
loadQuickAccessData(); // Reload data
loadQuickAccessData();
toast.success(
t("fileManager.removedFromRecentFiles", { name: item.name }),
);
@@ -150,7 +142,7 @@ export function FileManagerSidebar({
try {
await removePinnedFile(currentHost.id, item.path);
loadQuickAccessData(); // Reload data
loadQuickAccessData();
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
} catch (error) {
console.error("Failed to unpin file:", error);
@@ -163,7 +155,7 @@ export function FileManagerSidebar({
try {
await removeFolderShortcut(currentHost.id, item.path);
loadQuickAccessData(); // Reload data
loadQuickAccessData();
toast.success(t("fileManager.removedShortcut", { name: item.name }));
} catch (error) {
console.error("Failed to remove shortcut:", error);
@@ -175,11 +167,10 @@ export function FileManagerSidebar({
if (!currentHost?.id || recentItems.length === 0) return;
try {
// Batch delete all recent files
await Promise.all(
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
);
loadQuickAccessData(); // Reload data
loadQuickAccessData();
toast.success(t("fileManager.clearedAllRecentFiles"));
} catch (error) {
console.error("Failed to clear recent files:", error);
@@ -187,7 +178,6 @@ export function FileManagerSidebar({
}
};
// Right-click menu handling
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
e.preventDefault();
e.stopPropagation();
@@ -204,7 +194,6 @@ export function FileManagerSidebar({
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
// Click outside to close menu
useEffect(() => {
if (!contextMenu.isVisible) return;
@@ -223,7 +212,6 @@ export function FileManagerSidebar({
}
};
// Delay adding listeners to avoid immediate trigger
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
@@ -240,10 +228,8 @@ export function FileManagerSidebar({
if (!sshSessionId) return;
try {
// Load root directory
const response = await listSSHFiles(sshSessionId, "/");
// listSSHFiles now always returns {files: Array, path: string} format
const rootFiles = response.files || [];
const rootFolders = rootFiles.filter(
(item: any) => item.type === "directory",
@@ -255,7 +241,7 @@ export function FileManagerSidebar({
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [], // Subdirectories will be loaded on demand
children: [],
}));
setDirectoryTree([
@@ -270,7 +256,6 @@ export function FileManagerSidebar({
]);
} catch (error) {
console.error("Failed to load directory tree:", error);
// If loading fails, show simple root directory
setDirectoryTree([
{
id: "root",
@@ -289,17 +274,14 @@ export function FileManagerSidebar({
toggleFolder(item.id, item.path);
onPathChange(item.path);
} else if (item.type === "recent" || item.type === "pinned") {
// For file types, call file open callback
if (onFileOpen) {
onFileOpen(item);
} else {
// If no file open callback, switch to file directory
const directory =
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
onPathChange(directory);
}
} else if (item.type === "shortcut") {
// Folder shortcuts directly switch to directory
onPathChange(item.path);
}
};
@@ -312,12 +294,10 @@ export function FileManagerSidebar({
} else {
newExpanded.add(folderId);
// Load subdirectories on demand
if (sshSessionId && folderPath && folderPath !== "/") {
try {
const subResponse = await listSSHFiles(sshSessionId, folderPath);
// listSSHFiles now always returns {files: Array, path: string} format
const subFiles = subResponse.files || [];
const subFolders = subFiles.filter(
(item: any) => item.type === "directory",
@@ -332,7 +312,6 @@ export function FileManagerSidebar({
children: [],
}));
// Update directory tree, add subdirectories for current folder
setDirectoryTree((prevTree) => {
const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
return items.map((item) => {
@@ -370,7 +349,6 @@ export function FileManagerSidebar({
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
onClick={() => handleItemClick(item)}
onContextMenu={(e) => {
// Only quick access items need right-click menu
if (
item.type === "recent" ||
item.type === "pinned" ||
@@ -438,7 +416,6 @@ export function FileManagerSidebar({
);
};
// Check if there are any quick access items
const hasQuickAccessItems =
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
@@ -447,7 +424,6 @@ export function FileManagerSidebar({
<div className="h-full flex flex-col bg-dark-bg border-r border-dark-border">
<div className="flex-1 relative overflow-hidden">
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
{/* Quick access area */}
{renderSection(
t("fileManager.recent"),
<Clock className="w-3 h-3" />,
@@ -464,7 +440,6 @@ export function FileManagerSidebar({
shortcuts,
)}
{/* Directory tree */}
<div
className={cn(
hasQuickAccessItems && "pt-4 border-t border-dark-border",
@@ -482,7 +457,6 @@ export function FileManagerSidebar({
</div>
</div>
{/* Right-click menu */}
{contextMenu.isVisible && contextMenu.item && (
<>
<div className="fixed inset-0 z-40" />
@@ -33,8 +33,6 @@ export function DiffViewer({
file2,
sshSessionId,
sshHost,
onDownload1,
onDownload2,
}: DiffViewerProps) {
const { t } = useTranslation();
const [content1, setContent1] = useState<string>("");
@@ -46,7 +44,6 @@ export function DiffViewer({
);
const [showLineNumbers, setShowLineNumbers] = useState(true);
// Ensure SSH connection is valid
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
@@ -70,7 +67,6 @@ export function DiffViewer({
}
};
// Load file contents
const loadFileContents = async () => {
if (file1.type !== "file" || file2.type !== "file") {
setError(t("fileManager.canOnlyCompareFiles"));
@@ -81,10 +77,8 @@ export function DiffViewer({
setIsLoading(true);
setError(null);
// Ensure SSH connection is valid
await ensureSSHConnection();
// Load both files in parallel
const [response1, response2] = await Promise.all([
readSSHFile(sshSessionId, file1.path),
readSSHFile(sshSessionId, file2.path),
@@ -106,13 +100,16 @@ export function DiffViewer({
t("fileManager.sshConnectionFailed", {
name: sshHost.name,
ip: sshHost.ip,
port: sshHost.port
port: sshHost.port,
}),
);
} else {
setError(
t("fileManager.loadFileFailed", {
error: error.message || errorData?.error || t("fileManager.unknownError")
error:
error.message ||
errorData?.error ||
t("fileManager.unknownError"),
}),
);
}
@@ -121,7 +118,6 @@ export function DiffViewer({
}
};
// Download file
const handleDownloadFile = async (file: FileItem) => {
try {
await ensureSSHConnection();
@@ -147,15 +143,20 @@ export function DiffViewer({
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(t("fileManager.downloadFileSuccess", { name: file.name }));
toast.success(
t("fileManager.downloadFileSuccess", { name: file.name }),
);
}
} catch (error: any) {
console.error("Failed to download file:", error);
toast.error(t("fileManager.downloadFileFailed") + ": " + (error.message || t("fileManager.unknownError")));
toast.error(
t("fileManager.downloadFileFailed") +
": " +
(error.message || t("fileManager.unknownError")),
);
}
};
// Get file language type
const getFileLanguage = (fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = {
@@ -190,7 +191,6 @@ export function DiffViewer({
return languageMap[ext || ""] || "plaintext";
};
// Initial load
useEffect(() => {
loadFileContents();
}, [file1, file2, sshSessionId]);
@@ -200,7 +200,9 @@ export function DiffViewer({
<div className="h-full flex items-center justify-center bg-dark-bg">
<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("fileManager.loadingFileComparison")}</p>
<p className="text-sm text-muted-foreground">
{t("fileManager.loadingFileComparison")}
</p>
</div>
</div>
);
@@ -223,12 +225,13 @@ export function DiffViewer({
return (
<div className="h-full flex flex-col bg-dark-bg">
{/* Toolbar */}
<div className="flex-shrink-0 border-b border-dark-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted-foreground">{t("fileManager.compare")}:</span>
<span className="text-muted-foreground">
{t("fileManager.compare")}:
</span>
<span className="font-medium text-green-400 mx-2">
{file1.name}
</span>
@@ -238,7 +241,6 @@ export function DiffViewer({
</div>
<div className="flex items-center gap-2">
{/* View toggle */}
<Button
variant="outline"
size="sm"
@@ -248,10 +250,11 @@ export function DiffViewer({
)
}
>
{diffMode === "side-by-side" ? t("fileManager.sideBySide") : t("fileManager.inline")}
{diffMode === "side-by-side"
? t("fileManager.sideBySide")
: t("fileManager.inline")}
</Button>
{/* Line number toggle */}
<Button
variant="outline"
size="sm"
@@ -264,7 +267,6 @@ export function DiffViewer({
)}
</Button>
{/* Download buttons */}
<Button
variant="outline"
size="sm"
@@ -285,7 +287,6 @@ export function DiffViewer({
{file2.name}
</Button>
{/* Refresh button */}
<Button variant="outline" size="sm" onClick={loadFileContents}>
<RefreshCw className="w-4 h-4" />
</Button>
@@ -293,7 +294,6 @@ export function DiffViewer({
</div>
</div>
{/* Diff editor */}
<div className="flex-1">
<DiffEditor
original={content1}
@@ -322,7 +322,9 @@ export function DiffViewer({
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">{t("fileManager.initializingEditor")}</p>
<p className="text-sm text-muted-foreground">
{t("fileManager.initializingEditor")}
</p>
</div>
</div>
}
@@ -30,7 +30,6 @@ export function DiffWindow({
const currentWindow = windows.find((w) => w.id === windowId);
// Window operation handling
const handleClose = () => {
closeWindow(windowId);
};
@@ -49,7 +48,10 @@ export function DiffWindow({
return (
<DraggableWindow
title={t("fileManager.fileComparison", { file1: file1.name, file2: file2.name })}
title={t("fileManager.fileComparison", {
file1: file1.name,
file2: file2.name,
})}
initialX={initialX}
initialY={initialY}
initialWidth={1200}
@@ -39,7 +39,6 @@ export function DraggableWindow({
targetSize,
}: DraggableWindowProps) {
const { t } = useTranslation();
// Window state
const [position, setPosition] = useState({ x: initialX, y: initialY });
const [size, setSize] = useState({
width: initialWidth,
@@ -49,7 +48,6 @@ export function DraggableWindow({
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<string>("");
// Drag and resize start positions
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
@@ -57,17 +55,14 @@ export function DraggableWindow({
const windowRef = useRef<HTMLDivElement>(null);
const titleBarRef = useRef<HTMLDivElement>(null);
// Handle target size changes for media files
useEffect(() => {
if (targetSize && !isMaximized) {
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
// Calculate appropriate window size maintaining aspect ratio
let newWidth = Math.min(targetSize.width + 50, maxWidth); // Add padding for UI
let newHeight = Math.min(targetSize.height + 150, maxHeight); // Add padding for header/footer
let newWidth = Math.min(targetSize.width + 50, maxWidth);
let newHeight = Math.min(targetSize.height + 150, maxHeight);
// If still too large, scale down maintaining aspect ratio
if (newWidth > maxWidth || newHeight > maxHeight) {
const widthRatio = maxWidth / newWidth;
const heightRatio = maxHeight / newHeight;
@@ -77,26 +72,22 @@ export function DraggableWindow({
newHeight = Math.floor(newHeight * scale);
}
// Ensure minimum size
newWidth = Math.max(newWidth, minWidth);
newHeight = Math.max(newHeight, minHeight);
setSize({ width: newWidth, height: newHeight });
// Center the window
setPosition({
x: Math.max(0, (window.innerWidth - newWidth) / 2),
y: Math.max(0, (window.innerHeight - newHeight) / 2)
y: Math.max(0, (window.innerHeight - newHeight) / 2),
});
}
}, [targetSize, isMaximized, minWidth, minHeight]);
// Handle window focus
const handleWindowClick = useCallback(() => {
onFocus?.();
}, [onFocus]);
// Drag handling
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isMaximized) return;
@@ -119,44 +110,44 @@ export function DraggableWindow({
const newX = windowStart.x + deltaX;
const newY = windowStart.y + deltaY;
// Find the positioning container by checking parent hierarchy
const windowElement = windowRef.current;
let positioningContainer = null;
let currentElement = windowElement?.parentElement;
while (currentElement && currentElement !== document.body) {
const computedStyle = window.getComputedStyle(currentElement);
const position = computedStyle.position;
const transform = computedStyle.transform;
if (position === 'relative' || position === 'absolute' || position === 'fixed' || transform !== 'none') {
if (
position === "relative" ||
position === "absolute" ||
position === "fixed" ||
transform !== "none"
) {
positioningContainer = currentElement;
break;
}
currentElement = currentElement.parentElement;
}
// Calculate boundaries based on the actual positioning context
let maxX, maxY, minX, minY;
if (positioningContainer) {
const containerRect = positioningContainer.getBoundingClientRect();
// Window is positioned relative to a positioning container
maxX = containerRect.width - size.width;
maxY = containerRect.height - size.height;
minX = 0;
minY = 0;
} else {
// Window is positioned relative to viewport
maxX = window.innerWidth - size.width;
maxY = window.innerHeight - size.height;
minX = 0;
minY = 0;
}
// Ensure window stays within boundaries
const constrainedX = Math.max(minX, Math.min(maxX, newX));
const constrainedY = Math.max(minY, Math.min(maxY, newY));
@@ -175,14 +166,12 @@ export function DraggableWindow({
let newX = windowStart.x;
let newY = windowStart.y;
// Handle horizontal resizing
if (resizeDirection.includes("right")) {
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
}
if (resizeDirection.includes("left")) {
const widthChange = -deltaX;
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
// Only move position if we're actually changing size
if (newWidth > minWidth || widthChange > 0) {
newX = windowStart.x - (newWidth - sizeStart.width);
} else {
@@ -190,14 +179,12 @@ export function DraggableWindow({
}
}
// Handle vertical resizing
if (resizeDirection.includes("bottom")) {
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
}
if (resizeDirection.includes("top")) {
const heightChange = -deltaY;
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
// Only move position if we're actually changing size
if (newHeight > minHeight || heightChange > 0) {
newY = windowStart.y - (newHeight - sizeStart.height);
} else {
@@ -205,7 +192,6 @@ export function DraggableWindow({
}
}
// Ensure window stays within viewport
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
@@ -234,7 +220,6 @@ export function DraggableWindow({
setResizeDirection("");
}, []);
// Resize handling
const handleResizeStart = useCallback(
(e: React.MouseEvent, direction: string) => {
if (isMaximized) return;
@@ -251,7 +236,6 @@ export function DraggableWindow({
[isMaximized, position, size, onFocus],
);
// Global event listeners
useEffect(() => {
if (isDragging || isResizing) {
document.addEventListener("mousemove", handleMouseMove);
@@ -268,7 +252,6 @@ export function DraggableWindow({
}
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
// Double-click title bar to maximize/restore
const handleTitleDoubleClick = useCallback(() => {
onMaximize?.();
}, [onMaximize]);
@@ -290,7 +273,6 @@ export function DraggableWindow({
}}
onClick={handleWindowClick}
>
{/* Title bar */}
<div
ref={titleBarRef}
className={cn(
@@ -349,7 +331,6 @@ export function DraggableWindow({
</div>
</div>
{/* Window content */}
<div
className="flex-1 overflow-auto"
style={{ height: "calc(100% - 40px)" }}
@@ -357,10 +338,8 @@ export function DraggableWindow({
{children}
</div>
{/* Resize borders - only show when not maximized */}
{!isMaximized && (
<>
{/* Edge resize */}
<div
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
onMouseDown={(e) => handleResizeStart(e, "top")}
@@ -378,7 +357,6 @@ export function DraggableWindow({
onMouseDown={(e) => handleResizeStart(e, "right")}
/>
{/* Corner resize */}
<div
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
onMouseDown={(e) => handleResizeStart(e, "top-left")}
@@ -51,7 +51,12 @@ import { oneDark } from "@codemirror/theme-one-dark";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import { EditorView, keymap } from "@codemirror/view";
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
import { defaultKeymap, history, historyKeymap, toggleComment } from "@codemirror/commands";
import {
defaultKeymap,
history,
historyKeymap,
toggleComment,
} from "@codemirror/commands";
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
import { PhotoProvider, PhotoView } from "react-photo-view";
import "react-photo-view/dist/react-photo-view.css";
@@ -64,8 +69,7 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
import { Document, Page, pdfjs } from "react-pdf";
// Use local PDF.js worker to avoid CDN issues
pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js';
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
interface FileItem {
name: string;
@@ -87,15 +91,16 @@ interface FileViewerProps {
onContentChange?: (content: string) => void;
onSave?: (content: string) => void;
onDownload?: () => void;
onMediaDimensionsChange?: (dimensions: { width: number; height: number }) => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
// Get official icon for programming languages
function getLanguageIcon(filename: string): React.ReactNode {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
// Special filename handling
if (["dockerfile"].includes(baseName)) {
return <SiDocker className="w-6 h-6 text-blue-400" />;
}
@@ -141,7 +146,6 @@ function getLanguageIcon(filename: string): React.ReactNode {
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
}
// Get file type and icon
function getFileType(filename: string): {
type: string;
icon: React.ReactNode;
@@ -239,17 +243,14 @@ function getFileType(filename: string): {
}
}
// Get CodeMirror language extension
function getLanguageExtension(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
// Special filename handling
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
return loadLanguage(baseName);
}
// Map by file extension
const langMap: Record<string, string> = {
js: "javascript",
jsx: "jsx",
@@ -288,7 +289,6 @@ function getLanguageExtension(filename: string) {
return language ? loadLanguage(language) : null;
}
// Format file size
function formatFileSize(bytes?: number, t?: any): string {
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
const sizes = ["B", "KB", "MB", "GB"];
@@ -328,32 +328,25 @@ export function FileViewer({
const fileTypeInfo = getFileType(file.name);
// File size limits - remove hard limits, support large file handling
const WARNING_SIZE = 50 * 1024 * 1024; // 50MB warning
const MAX_SIZE = Number.MAX_SAFE_INTEGER; // Remove hard limits
const WARNING_SIZE = 50 * 1024 * 1024;
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
// Check if should display as text
const shouldShowAsText =
fileTypeInfo.type === "text" ||
fileTypeInfo.type === "code" ||
(fileTypeInfo.type === "unknown" &&
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
// Check if file is too large
const isLargeFile = file.size && file.size > WARNING_SIZE;
const isTooLarge = file.size && file.size > MAX_SIZE;
// Sync external content changes
useEffect(() => {
setEditedContent(content);
// Only update originalContent when savedContent is updated
if (savedContent) {
setOriginalContent(savedContent);
}
// Fix: Compare current content with saved content properly
setHasChanges(content !== savedContent);
// If unknown file type and file is large, show warning
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
setShowLargeFileWarning(true);
} else {
@@ -361,59 +354,46 @@ export function FileViewer({
}
}, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
// Handle content changes
const handleContentChange = (newContent: string) => {
setEditedContent(newContent);
// Fix: Compare with savedContent instead of originalContent for consistency
setHasChanges(newContent !== savedContent);
onContentChange?.(newContent);
};
// Save file
const handleSave = () => {
onSave?.(editedContent);
// Note: Don't update originalContent here, as it will be updated via savedContent prop
};
// Revert file
const handleRevert = () => {
setEditedContent(savedContent);
setHasChanges(false);
onContentChange?.(savedContent);
};
// Handle save shortcut specifically
useEffect(() => {
if (!editorFocused || !isEditable) return;
const handleKeyDown = (e: KeyboardEvent) => {
// Only handle Ctrl+S for custom save, let CodeMirror handle everything else
const isCtrl = e.ctrlKey || e.metaKey;
if (isCtrl && e.key.toLowerCase() === 's') {
if (isCtrl && e.key.toLowerCase() === "s") {
e.preventDefault();
e.stopPropagation();
handleSave();
}
};
// Add event listener with capture for save shortcut only
document.addEventListener('keydown', handleKeyDown, true);
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener('keydown', handleKeyDown, true);
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [editorFocused, isEditable, handleSave]);
// Handle user confirmation to open large file
const handleConfirmOpenAsText = () => {
setForceShowAsText(true);
setShowLargeFileWarning(false);
};
// Handle user rejection to open large file
const handleCancelOpenAsText = () => {
setShowLargeFileWarning(false);
};
@@ -431,7 +411,6 @@ export function FileViewer({
return (
<div className="h-full flex flex-col bg-background">
{/* File info header */}
<div className="flex-shrink-0 bg-card border-b border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -442,7 +421,11 @@ export function FileViewer({
<h3 className="font-medium text-foreground">{file.name}</h3>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatFileSize(file.size, t)}</span>
{file.modified && <span>{t("fileManager.modified")}: {file.modified}</span>}
{file.modified && (
<span>
{t("fileManager.modified")}: {file.modified}
</span>
)}
<span
className={cn(
"px-2 py-1 rounded-full text-xs",
@@ -457,13 +440,11 @@ export function FileViewer({
</div>
<div className="flex items-center gap-2">
{/* Search button */}
{isEditable && (
<Button
variant="ghost"
size="sm"
onClick={() => {
// Use CodeMirror's proper API to open search panel
if (editorRef.current) {
const view = editorRef.current.view;
if (view) {
@@ -477,7 +458,6 @@ export function FileViewer({
<Search className="w-4 h-4" />
</Button>
)}
{/* Keyboard shortcuts help */}
{isEditable && (
<Button
variant="ghost"
@@ -526,11 +506,12 @@ export function FileViewer({
</div>
</div>
{/* Keyboard shortcuts help panel */}
{showKeyboardShortcuts && isEditable && (
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">{t("fileManager.keyboardShortcuts")}</h3>
<h3 className="text-sm font-semibold">
{t("fileManager.keyboardShortcuts")}
</h3>
<Button
variant="ghost"
size="sm"
@@ -542,60 +523,88 @@ export function FileViewer({
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">{t("fileManager.searchAndReplace")}</h4>
<h4 className="font-medium text-muted-foreground">
{t("fileManager.searchAndReplace")}
</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.search")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+F</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+F
</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.replace")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+H</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+H
</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.findNext")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">F3</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
F3
</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.findPrevious")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Shift+F3</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Shift+F3
</kbd>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">{t("fileManager.editing")}</h4>
<h4 className="font-medium text-muted-foreground">
{t("fileManager.editing")}
</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.save")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+S</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+S
</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.selectAll")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+A</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+A
</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.undo")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Z</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Z
</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.redo")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Y</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Y
</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.toggleComment")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+/</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+/
</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.autoComplete")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Space</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Space
</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineUp")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Alt+</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Alt+
</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineDown")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Alt+</kbd>
<kbd className="px-2 py-1 bg-background rounded text-xs">
Alt+
</kbd>
</div>
</div>
</div>
@@ -603,9 +612,7 @@ export function FileViewer({
</div>
)}
{/* File content */}
<div className="flex-1 overflow-hidden">
{/* Large file warning dialog */}
{showLargeFileWarning && (
<div className="h-full flex items-center justify-center bg-background">
<div className="bg-card border border-destructive/30 rounded-lg p-6 max-w-md mx-4 shadow-lg">
@@ -616,7 +623,9 @@ export function FileViewer({
{t("fileManager.largeFileWarning")}
</h3>
<p className="text-sm text-muted-foreground mb-3">
{t("fileManager.largeFileWarningDesc", { size: formatFileSize(file.size, t) })}
{t("fileManager.largeFileWarningDesc", {
size: formatFileSize(file.size, t),
})}
</p>
{isTooLarge ? (
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
@@ -667,19 +676,15 @@ export function FileViewer({
</div>
)}
{/* Image preview with react-photo-view */}
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full relative">
{imageLoadError ? (
// Error state
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
{t("fileManager.imageLoadError")}
</h3>
<p className="text-sm mb-4">
{file.name}
</p>
<p className="text-sm mb-4">{file.name}</p>
{onDownload && (
<Button
variant="outline"
@@ -703,12 +708,15 @@ export function FileViewer({
setImageLoading(false);
setImageLoadError(false);
// Get natural dimensions and notify parent
const img = e.currentTarget;
if (onMediaDimensionsChange && img.naturalWidth && img.naturalHeight) {
if (
onMediaDimensionsChange &&
img.naturalWidth &&
img.naturalHeight
) {
onMediaDimensionsChange({
width: img.naturalWidth,
height: img.naturalHeight
height: img.naturalHeight,
});
}
}}
@@ -721,23 +729,22 @@ export function FileViewer({
</PhotoProvider>
)}
{/* Loading state */}
{imageLoading && !imageLoadError && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<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">Loading image...</p>
<p className="text-sm text-muted-foreground">
Loading image...
</p>
</div>
</div>
)}
</div>
)}
{/* Unified text and code file editor */}
{shouldShowAsText && !showLargeFileWarning && (
<div className="h-full flex flex-col">
{isEditable ? (
// Unified CodeMirror editor for all text-based files
<CodeMirror
ref={editorRef}
value={editedContent}
@@ -756,20 +763,18 @@ export function FileViewer({
...searchKeymap,
...historyKeymap,
...completionKeymap,
// Custom keybindings
{
key: "Mod-/",
run: toggleComment,
preventDefault: true
preventDefault: true,
},
{
key: "Mod-h",
run: () => {
// Let CodeMirror search handle this, just prevent browser default
return false; // Return false to let search keymap handle it
return false;
},
preventDefault: true
}
preventDefault: true,
},
]),
EditorView.theme({
"&": {
@@ -800,7 +805,6 @@ export function FileViewer({
}}
/>
) : (
// Read-only view for non-editable files
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
{editedContent || content || t("fileManager.fileIsEmpty")}
</div>
@@ -808,22 +812,29 @@ export function FileViewer({
</div>
)}
{/* Video file preview with enhanced HTML5 support */}
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-4xl">
{(() => {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const mimeType = (() => {
switch (ext) {
case 'mp4': return 'video/mp4';
case 'webm': return 'video/webm';
case 'mkv': return 'video/x-matroska';
case 'avi': return 'video/x-msvideo';
case 'mov': return 'video/quicktime';
case 'wmv': return 'video/x-ms-wmv';
case 'flv': return 'video/x-flv';
default: return 'video/mp4';
case "mp4":
return "video/mp4";
case "webm":
return "video/webm";
case "mkv":
return "video/x-matroska";
case "avi":
return "video/x-msvideo";
case "mov":
return "video/quicktime";
case "wmv":
return "video/x-ms-wmv";
case "flv":
return "video/x-flv";
default:
return "video/mp4";
}
})();
@@ -836,35 +847,36 @@ export function FileViewer({
className="w-full rounded-lg shadow-sm"
style={{
maxHeight: "calc(100vh - 200px)",
backgroundColor: "#000"
backgroundColor: "#000",
}}
preload="metadata"
onError={(e) => {
console.error('Video playback error:', e.currentTarget.error);
}}
onLoadStart={() => {
console.log('Video loading started...');
console.error(
"Video playback error:",
e.currentTarget.error,
);
}}
onLoadedMetadata={(e) => {
const video = e.currentTarget;
console.log('Video metadata loaded, dimensions:', video.videoWidth, 'x', video.videoHeight);
// Get video dimensions and notify parent
if (onMediaDimensionsChange && video.videoWidth && video.videoHeight) {
if (
onMediaDimensionsChange &&
video.videoWidth &&
video.videoHeight
) {
onMediaDimensionsChange({
width: video.videoWidth,
height: video.videoHeight
height: video.videoHeight,
});
}
}}
onCanPlay={() => {
console.log('Video can start playing');
}}
>
<source src={videoUrl} type={mimeType} />
<div className="text-center text-muted-foreground p-4">
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
<p>Your browser does not support video playback for this format.</p>
<p>
Your browser does not support video playback for this
format.
</p>
{onDownload && (
<Button
variant="outline"
@@ -884,10 +896,8 @@ export function FileViewer({
</div>
)}
{/* Markdown file editor with live preview */}
{fileTypeInfo.type === "markdown" && !showLargeFileWarning && (
<div className="h-full flex flex-col">
{/* Markdown toolbar */}
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -908,17 +918,13 @@ export function FileViewer({
{t("fileManager.preview")}
</Button>
</div>
<div className="flex items-center gap-2">
{/* Save button removed - using the main header save button instead */}
</div>
<div className="flex items-center gap-2"></div>
</div>
</div>
{/* Markdown content area */}
<div className="flex-1 flex overflow-hidden">
{markdownEditMode ? (
<>
{/* Editor pane */}
<div className="flex-1 border-r border-border">
<div className="h-full p-4 bg-background">
<textarea
@@ -933,14 +939,21 @@ export function FileViewer({
</div>
</div>
{/* Preview pane */}
<div className="flex-1 overflow-auto bg-muted/10">
<div className="p-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
code({
node,
inline,
className,
children,
...props
}) {
const match = /language-(\w+)/.exec(
className || "",
);
return !inline && match ? (
<SyntaxHighlighter
style={syntaxTheme}
@@ -949,10 +962,13 @@ export function FileViewer({
className="rounded-lg"
{...props}
>
{String(children).replace(/\n$/, '')}
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className="bg-muted px-1 py-0.5 rounded text-sm font-mono" {...props}>
<code
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
@@ -993,9 +1009,7 @@ export function FileViewer({
</ol>
),
li: ({ children }) => (
<li className="mb-1 text-foreground">
{children}
</li>
<li className="mb-1 text-foreground">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1">
@@ -1010,15 +1024,9 @@ export function FileViewer({
</div>
),
thead: ({ children }) => (
<thead className="bg-muted">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody>
{children}
</tbody>
<thead className="bg-muted">{children}</thead>
),
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">
{children}
@@ -1059,7 +1067,7 @@ export function FileViewer({
remarkPlugins={[remarkGfm]}
components={{
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={syntaxTheme}
@@ -1068,10 +1076,13 @@ export function FileViewer({
className="rounded-lg"
{...props}
>
{String(children).replace(/\n$/, '')}
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code className="bg-muted px-1 py-0.5 rounded text-sm font-mono" {...props}>
<code
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
@@ -1112,9 +1123,7 @@ export function FileViewer({
</ol>
),
li: ({ children }) => (
<li className="mb-1 text-foreground">
{children}
</li>
<li className="mb-1 text-foreground">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2">
@@ -1129,19 +1138,11 @@ export function FileViewer({
</div>
),
thead: ({ children }) => (
<thead className="bg-muted">
{children}
</thead>
),
tbody: ({ children }) => (
<tbody>
{children}
</tbody>
<thead className="bg-muted">{children}</thead>
),
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">
{children}
</tr>
<tr className="border-b border-border">{children}</tr>
),
th: ({ children }) => (
<th className="px-4 py-2 text-left font-semibold text-foreground">
@@ -1174,10 +1175,8 @@ export function FileViewer({
</div>
)}
{/* PDF file preview with react-pdf */}
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
<div className="h-full flex flex-col bg-background">
{/* PDF Controls */}
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
@@ -1191,12 +1190,17 @@ export function FileViewer({
{t("fileManager.previous")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
{t("fileManager.pageXOfY", { current: pageNumber, total: numPages || 0 })}
{t("fileManager.pageXOfY", {
current: pageNumber,
total: numPages || 0,
})}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPageNumber(Math.min(numPages || 1, pageNumber + 1))}
onClick={() =>
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
}
disabled={!numPages || pageNumber >= numPages}
>
{t("fileManager.next")}
@@ -1236,13 +1240,14 @@ export function FileViewer({
</div>
</div>
{/* PDF Content */}
<div className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
<div className="flex justify-center">
{pdfError ? (
<div className="text-center text-muted-foreground p-8">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Cannot load PDF</h3>
<h3 className="text-lg font-medium mb-2">
Cannot load PDF
</h3>
<p className="text-sm mb-4">
There was an error loading this PDF file.
</p>
@@ -1264,22 +1269,23 @@ export function FileViewer({
setNumPages(numPages);
setPdfError(false);
// Notify parent about PDF dimensions for window sizing
if (onMediaDimensionsChange) {
onMediaDimensionsChange({
width: 800,
height: 600
height: 600,
});
}
}}
onLoadError={(error) => {
console.error('PDF load error:', error);
console.error("PDF load error:", error);
setPdfError(true);
}}
loading={
<div className="text-center p-8">
<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">Loading PDF...</p>
<p className="text-sm text-muted-foreground">
Loading PDF...
</p>
</div>
}
>
@@ -1290,7 +1296,9 @@ export function FileViewer({
loading={
<div className="text-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-xs text-muted-foreground">Loading page...</p>
<p className="text-xs text-muted-foreground">
Loading page...
</p>
</div>
}
/>
@@ -1301,21 +1309,27 @@ export function FileViewer({
</div>
)}
{/* Audio file preview with react-h5-audio-player */}
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-2xl">
{(() => {
const ext = file.name.split('.').pop()?.toLowerCase() || '';
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const mimeType = (() => {
switch (ext) {
case 'mp3': return 'audio/mpeg';
case 'wav': return 'audio/wav';
case 'flac': return 'audio/flac';
case 'ogg': return 'audio/ogg';
case 'aac': return 'audio/aac';
case 'm4a': return 'audio/mp4';
default: return 'audio/mpeg';
case "mp3":
return "audio/mpeg";
case "wav":
return "audio/wav";
case "flac":
return "audio/flac";
case "ogg":
return "audio/ogg";
case "aac":
return "audio/aac";
case "m4a":
return "audio/mp4";
default:
return "audio/mpeg";
}
})();
@@ -1323,7 +1337,6 @@ export function FileViewer({
return (
<div className="space-y-4">
{/* Album artwork placeholder */}
<div className="flex justify-center">
<div
className={cn(
@@ -1335,7 +1348,6 @@ export function FileViewer({
</div>
</div>
{/* Track info */}
<div className="text-center">
<h3 className="font-semibold text-foreground text-lg mb-1">
{file.name.replace(/\.[^/.]+$/, "")}
@@ -1345,30 +1357,20 @@ export function FileViewer({
</p>
</div>
{/* Audio Player */}
<div className="rounded-lg overflow-hidden">
<AudioPlayer
src={audioUrl}
onPlay={() => {
console.log('Audio playback started');
}}
onPause={() => {
console.log('Audio playback paused');
}}
onLoadedMetadata={(e) => {
const audio = e.currentTarget;
console.log('Audio metadata loaded, duration:', audio.duration);
// Get audio dimensions for window sizing (use a standard audio player height)
if (onMediaDimensionsChange) {
onMediaDimensionsChange({
width: 600,
height: 400
height: 400,
});
}
}}
onError={(e) => {
console.error('Audio playback error:', e);
console.error("Audio playback error:", e);
}}
showJumpControls={false}
showSkipControls={false}
@@ -1384,7 +1386,6 @@ export function FileViewer({
</div>
)}
{/* Unknown file type - only show when cannot display as text and no warning */}
{fileTypeInfo.type === "unknown" &&
!shouldShowAsText &&
!showLargeFileWarning && (
@@ -1413,7 +1414,6 @@ export function FileViewer({
)}
</div>
{/* Bottom status bar */}
<div className="flex-shrink-0 bg-muted/50 border-t border-border px-4 py-2 text-xs text-muted-foreground">
<div className="flex justify-between items-center">
<span>{file.path}</span>
@@ -44,8 +44,7 @@ interface FileWindowProps {
sshHost: SSHHost;
initialX?: number;
initialY?: number;
onFileNotFound?: (file: FileItem) => void; // Callback for when file is not found
// readOnly parameter removed, determined internally by FileViewer based on file type
onFileNotFound?: (file: FileItem) => void;
}
export function FileWindow({
@@ -57,13 +56,8 @@ export function FileWindow({
initialY = 100,
onFileNotFound,
}: FileWindowProps) {
const {
closeWindow,
maximizeWindow,
focusWindow,
updateWindow,
windows,
} = useWindowManager();
const { closeWindow, maximizeWindow, focusWindow, updateWindow, windows } =
useWindowManager();
const { t } = useTranslation();
@@ -71,22 +65,18 @@ export function FileWindow({
const [isLoading, setIsLoading] = useState(false);
const [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>("");
const [mediaDimensions, setMediaDimensions] = useState<{ width: number; height: number } | undefined>();
const [mediaDimensions, setMediaDimensions] = useState<
{ width: number; height: number } | undefined
>();
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find((w) => w.id === windowId);
// Ensure SSH connection is valid
const ensureSSHConnection = async () => {
try {
// First check SSH connection status
const status = await getSSHStatus(sshSessionId);
console.log("SSH connection status:", status);
if (!status.connected) {
console.log("SSH not connected, attempting to reconnect...");
// Re-establish connection
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
@@ -99,17 +89,13 @@ export function FileWindow({
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
console.log("SSH reconnection successful");
}
} catch (error) {
console.log("SSH connection check/reconnect failed:", error);
// Even if connection fails, try to continue and let specific API calls handle errors
console.error("SSH connection check/reconnect failed:", error);
throw error;
}
};
// Load file content
useEffect(() => {
const loadFileContent = async () => {
if (file.type !== "file") return;
@@ -117,23 +103,19 @@ export function FileWindow({
try {
setIsLoading(true);
// Ensure SSH connection is valid
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent(fileContent); // Initialize pending content
setPendingContent(fileContent);
// If file size is unknown, calculate size based on content
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
// Determine if editable based on file type: all except media files are editable
const mediaExtensions = [
// Image files
"jpg",
"jpeg",
"png",
@@ -143,7 +125,6 @@ export function FileWindow({
"webp",
"tiff",
"ico",
// Audio files
"mp3",
"wav",
"ogg",
@@ -151,7 +132,6 @@ export function FileWindow({
"flac",
"m4a",
"wma",
// Video files
"mp4",
"avi",
"mov",
@@ -160,7 +140,6 @@ export function FileWindow({
"mkv",
"webm",
"m4v",
// Archive files
"zip",
"rar",
"7z",
@@ -168,7 +147,6 @@ export function FileWindow({
"gz",
"bz2",
"xz",
// Binary files
"exe",
"dll",
"so",
@@ -178,28 +156,25 @@ export function FileWindow({
];
const extension = file.name.split(".").pop()?.toLowerCase();
// Only media files and binary files are not editable, all other files are editable
setIsEditable(!mediaExtensions.includes(extension || ""));
} catch (error: any) {
console.error("Failed to load file:", error);
// Check if it's a large file error
const errorData = error?.response?.data;
if (errorData?.tooLarge) {
toast.error(`File too large: ${errorData.error}`, {
duration: 10000, // 10 seconds for important message
duration: 10000,
});
} else if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
// If connection error, provide more specific error message
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
// Check if file not found (common error messages from cat command)
const errorMessage = errorData?.error || error.message || "Unknown error";
const errorMessage =
errorData?.error || error.message || "Unknown error";
const isFileNotFound =
(error as any).isFileNotFound ||
errorData?.fileNotFound ||
@@ -211,19 +186,21 @@ export function FileWindow({
errorMessage.includes("Resource not found");
if (isFileNotFound && onFileNotFound) {
// Notify parent component about the missing file for cleanup
onFileNotFound(file);
toast.error(t("fileManager.fileNotFoundAndRemoved", { name: file.name }));
toast.error(
t("fileManager.fileNotFoundAndRemoved", { name: file.name }),
);
// Close this window since the file doesn't exist
closeWindow(windowId);
return; // Exit early to prevent showing empty editor
return;
} else {
toast.error(t("fileManager.failedToLoadFile", {
error: errorMessage.includes("Server error occurred") ?
t("fileManager.serverErrorOccurred") :
errorMessage
}));
toast.error(
t("fileManager.failedToLoadFile", {
error: errorMessage.includes("Server error occurred")
? t("fileManager.serverErrorOccurred")
: errorMessage,
}),
);
}
}
} finally {
@@ -234,19 +211,16 @@ export function FileWindow({
loadFileContent();
}, [file, sshSessionId, sshHost]);
// Save file
const handleSave = async (newContent: string) => {
try {
setIsLoading(true);
// Ensure SSH connection is valid
await ensureSSHConnection();
await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent);
setPendingContent(""); // Clear pending content
setPendingContent("");
// Clear auto-save timer
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
@@ -256,7 +230,6 @@ export function FileWindow({
} catch (error: any) {
console.error("Failed to save file:", error);
// If it's a connection error, provide more specific error message
if (
error.message?.includes("connection") ||
error.message?.includes("established")
@@ -265,36 +238,33 @@ export function FileWindow({
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`);
toast.error(
`${t("fileManager.failedToSaveFile")}: ${error.message || t("fileManager.unknownError")}`,
);
}
} finally {
setIsLoading(false);
}
};
// Handle content changes - set 1-minute auto-save
const handleContentChange = (newContent: string) => {
setPendingContent(newContent);
// Clear previous timer
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
// Set new 1-minute auto-save timer
autoSaveTimerRef.current = setTimeout(async () => {
try {
console.log("Auto-saving file...");
await handleSave(newContent);
toast.success(t("fileManager.fileAutoSaved"));
} catch (error) {
console.error("Auto-save failed:", error);
toast.error(t("fileManager.autoSaveFailed"));
}
}, 60000); // 1 minute = 60000 milliseconds
}, 60000);
};
// Cleanup timer
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
@@ -303,16 +273,13 @@ export function FileWindow({
};
}, []);
// Download file
const handleDownload = async () => {
try {
// Ensure SSH connection is valid
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) {
// Convert base64 to blob and trigger download
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
@@ -337,7 +304,6 @@ export function FileWindow({
} catch (error: any) {
console.error("Failed to download file:", error);
// If it's a connection error, provide more specific error message
if (
error.message?.includes("connection") ||
error.message?.includes("established")
@@ -353,7 +319,6 @@ export function FileWindow({
}
};
// Window operation handling
const handleClose = () => {
closeWindow(windowId);
};
@@ -366,9 +331,10 @@ export function FileWindow({
focusWindow(windowId);
};
// Handle media dimensions change
const handleMediaDimensionsChange = (dimensions: { width: number; height: number }) => {
console.log('Media dimensions received:', dimensions);
const handleMediaDimensionsChange = (dimensions: {
width: number;
height: number;
}) => {
setMediaDimensions(dimensions);
};
@@ -397,7 +363,7 @@ export function FileWindow({
content={pendingContent || content}
savedContent={content}
isLoading={isLoading}
isEditable={isEditable} // Remove forced read-only mode, controlled internally by FileViewer
isEditable={isEditable}
onContentChange={handleContentChange}
onSave={(newContent) => handleSave(newContent)}
onDownload={handleDownload}
@@ -39,10 +39,8 @@ export function TerminalWindow({
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
// Get current window state
const currentWindow = windows.find((w) => w.id === windowId);
if (!currentWindow) {
console.warn(`Window with id ${windowId} not found`);
return null;
}
@@ -65,7 +63,10 @@ export function TerminalWindow({
const terminalTitle = executeCommand
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
: initialPath
? t("terminal.terminalWithPath", { host: hostConfig.name, path: initialPath })
? t("terminal.terminalWithPath", {
host: hostConfig.name,
path: initialPath,
})
: t("terminal.terminalTitle", { host: hostConfig.name });
return (
@@ -35,13 +35,11 @@ export function WindowManager({ children }: WindowManagerProps) {
const nextZIndex = useRef(1000);
const windowCounter = useRef(0);
// Open new window
const openWindow = useCallback(
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
const id = `window-${++windowCounter.current}`;
const zIndex = ++nextZIndex.current;
// Calculate offset position to avoid windows completely overlapping
const offset = (windows.length % 5) * 30;
const adjustedX = windowData.x + offset;
const adjustedY = windowData.y + offset;
@@ -60,12 +58,10 @@ export function WindowManager({ children }: WindowManagerProps) {
[windows.length],
);
// Close window
const closeWindow = useCallback((id: string) => {
setWindows((prev) => prev.filter((w) => w.id !== id));
}, []);
// Minimize window
const minimizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
@@ -74,7 +70,6 @@ export function WindowManager({ children }: WindowManagerProps) {
);
}, []);
// Maximize/restore window
const maximizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
@@ -83,7 +78,6 @@ export function WindowManager({ children }: WindowManagerProps) {
);
}, []);
// Focus window (bring to top)
const focusWindow = useCallback((id: string) => {
setWindows((prev) => {
const targetWindow = prev.find((w) => w.id === id);
@@ -94,7 +88,6 @@ export function WindowManager({ children }: WindowManagerProps) {
});
}, []);
// Update window properties
const updateWindow = useCallback(
(id: string, updates: Partial<WindowInstance>) => {
setWindows((prev) =>
@@ -117,7 +110,6 @@ export function WindowManager({ children }: WindowManagerProps) {
return (
<WindowManagerContext.Provider value={contextValue}>
{children}
{/* Render all windows */}
<div className="window-container">
{windows.map((window) => (
<div key={window.id}>
@@ -131,7 +123,6 @@ export function WindowManager({ children }: WindowManagerProps) {
);
}
// Hook for using window manager
export function useWindowManager() {
const context = React.useContext(WindowManagerContext);
if (!context) {