Clean up files, fix bugs in file manager, update api ports, etc.

This commit is contained in:
LukeGus
2025-09-25 01:21:15 -05:00
parent 700aa9e07d
commit 8f8ebf0c7f
49 changed files with 2497 additions and 5252 deletions
@@ -1,26 +0,0 @@
import React from "react";
import { FileManagerTabList } from "./FileManagerTabList.tsx";
interface FileManagerTopNavbarProps {
tabs: { id: string | number; title: string }[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function FIleManagerTopNavbar(
props: FileManagerTopNavbarProps,
): React.ReactElement {
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
return (
<FileManagerTabList
tabs={tabs}
activeTab={activeTab}
setActiveTab={setActiveTab}
closeTab={closeTab}
onHomeClick={onHomeClick}
/>
);
}
File diff suppressed because it is too large Load Diff
@@ -257,14 +257,14 @@ export function FileManagerContextMenu({
});
}
// Download function - unified download that uses best available method
if (hasFiles && onDragToDesktop) {
// Download function - use proper download handler
if (hasFiles && onDownload) {
menuItems.push({
icon: <Download className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.downloadFiles", { count: files.length })
: t("fileManager.downloadFile"),
action: () => onDragToDesktop(),
action: () => onDownload(files),
shortcut: "Ctrl+D",
});
}
@@ -1,348 +0,0 @@
import React, { useEffect } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import { hyperLink } from "@uiw/codemirror-extensions-hyper-link";
import { oneDark } from "@codemirror/theme-one-dark";
import { EditorView } from "@codemirror/view";
interface FileManagerCodeEditorProps {
content: string;
fileName: string;
onContentChange: (value: string) => void;
}
export function FileManagerFileEditor({
content,
fileName,
onContentChange,
}: FileManagerCodeEditorProps) {
function getLanguageName(filename: string): string {
if (!filename || typeof filename !== "string") {
return "text";
}
const lastDotIndex = filename.lastIndexOf(".");
if (lastDotIndex === -1) {
return "text";
}
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
switch (ext) {
case "ng":
return "angular";
case "apl":
return "apl";
case "asc":
return "asciiArmor";
case "ast":
return "asterisk";
case "bf":
return "brainfuck";
case "c":
return "c";
case "ceylon":
return "ceylon";
case "clj":
return "clojure";
case "cmake":
return "cmake";
case "cob":
case "cbl":
return "cobol";
case "coffee":
return "coffeescript";
case "lisp":
return "commonLisp";
case "cpp":
case "cc":
case "cxx":
return "cpp";
case "cr":
return "crystal";
case "cs":
return "csharp";
case "css":
return "css";
case "cypher":
return "cypher";
case "d":
return "d";
case "dart":
return "dart";
case "diff":
case "patch":
return "diff";
case "dockerfile":
return "dockerfile";
case "dtd":
return "dtd";
case "dylan":
return "dylan";
case "ebnf":
return "ebnf";
case "ecl":
return "ecl";
case "eiffel":
return "eiffel";
case "elm":
return "elm";
case "erl":
return "erlang";
case "factor":
return "factor";
case "fcl":
return "fcl";
case "fs":
return "forth";
case "f90":
case "for":
return "fortran";
case "s":
return "gas";
case "feature":
return "gherkin";
case "go":
return "go";
case "groovy":
return "groovy";
case "hs":
return "haskell";
case "hx":
return "haxe";
case "html":
case "htm":
return "html";
case "http":
return "http";
case "idl":
return "idl";
case "java":
return "java";
case "js":
case "mjs":
case "cjs":
return "javascript";
case "jinja2":
case "j2":
return "jinja2";
case "json":
return "json";
case "jsx":
return "jsx";
case "jl":
return "julia";
case "kt":
case "kts":
return "kotlin";
case "less":
return "less";
case "lezer":
return "lezer";
case "liquid":
return "liquid";
case "litcoffee":
return "livescript";
case "lua":
return "lua";
case "md":
return "markdown";
case "nb":
case "mat":
return "mathematica";
case "mbox":
return "mbox";
case "mmd":
return "mermaid";
case "mrc":
return "mirc";
case "moo":
return "modelica";
case "mscgen":
return "mscgen";
case "m":
return "mumps";
case "sql":
return "mysql";
case "nc":
return "nesC";
case "nginx":
return "nginx";
case "nix":
return "nix";
case "nsi":
return "nsis";
case "nt":
return "ntriples";
case "mm":
return "objectiveCpp";
case "octave":
return "octave";
case "oz":
return "oz";
case "pas":
return "pascal";
case "pl":
case "pm":
return "perl";
case "pgsql":
return "pgsql";
case "php":
return "php";
case "pig":
return "pig";
case "ps1":
return "powershell";
case "properties":
return "properties";
case "proto":
return "protobuf";
case "pp":
return "puppet";
case "py":
return "python";
case "q":
return "q";
case "r":
return "r";
case "rb":
return "ruby";
case "rs":
return "rust";
case "sas":
return "sas";
case "sass":
case "scss":
return "sass";
case "scala":
return "scala";
case "scm":
return "scheme";
case "shader":
return "shader";
case "sh":
case "bash":
return "shell";
case "siv":
return "sieve";
case "st":
return "smalltalk";
case "sol":
return "solidity";
case "solr":
return "solr";
case "rq":
return "sparql";
case "xlsx":
case "ods":
case "csv":
return "spreadsheet";
case "nut":
return "squirrel";
case "tex":
return "stex";
case "styl":
return "stylus";
case "svelte":
return "svelte";
case "swift":
return "swift";
case "tcl":
return "tcl";
case "textile":
return "textile";
case "tiddlywiki":
return "tiddlyWiki";
case "tiki":
return "tiki";
case "toml":
return "toml";
case "troff":
return "troff";
case "tsx":
return "tsx";
case "ttcn":
return "ttcn";
case "ttl":
case "turtle":
return "turtle";
case "ts":
return "typescript";
case "vb":
return "vb";
case "vbs":
return "vbscript";
case "vm":
return "velocity";
case "v":
return "verilog";
case "vhd":
case "vhdl":
return "vhdl";
case "vue":
return "vue";
case "wat":
return "wast";
case "webidl":
return "webIDL";
case "xq":
case "xquery":
return "xQuery";
case "xml":
return "xml";
case "yacas":
return "yacas";
case "yaml":
case "yml":
return "yaml";
case "z80":
return "z80";
default:
return "text";
}
}
useEffect(() => {
document.body.style.overflowX = "hidden";
return () => {
document.body.style.overflowX = "";
};
}, []);
return (
<div className="w-full h-full relative overflow-hidden flex flex-col">
<div className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper">
<CodeMirror
value={content}
extensions={[
loadLanguage(getLanguageName(fileName || "untitled.txt") as any) ||
[],
hyperLink,
oneDark,
EditorView.theme({
"&": {
backgroundColor: "var(--color-dark-bg-darkest) !important",
height: "100%",
},
".cm-gutters": {
backgroundColor: "var(--color-dark-bg) !important",
},
".cm-scroller": {
overflow: "auto",
},
".cm-editor": {
height: "100%",
},
}),
]}
onChange={(value: any) => onContentChange(value)}
theme={undefined}
height="100%"
basicSetup={{
lineNumbers: true,
scrollPastEnd: false,
}}
className="min-h-full min-w-full flex-1"
/>
</div>
</div>
);
}
@@ -1,4 +1,5 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import {
Folder,
@@ -60,6 +61,7 @@ function formatFileSize(bytes?: number): string {
interface DragState {
type: "none" | "internal" | "external";
files: FileItem[];
draggedFiles?: FileItem[];
target?: FileItem;
counter: number;
mousePosition?: { x: number; y: number };
@@ -91,7 +93,7 @@ interface FileManagerGridProps {
onFileDrop?: (draggedFiles: FileItem[], targetFile: FileItem) => void;
onFileDiff?: (file1: FileItem, file2: FileItem) => void;
onSystemDragStart?: (files: FileItem[]) => void;
onSystemDragEnd?: (e: DragEvent) => void;
onSystemDragEnd?: (e: DragEvent, files: FileItem[]) => void;
hasClipboard?: boolean;
// Linus-style creation intent props
createIntent?: CreateIntent | null;
@@ -283,6 +285,7 @@ export function FileManagerGrid({
setDragState({
type: "internal",
files: filesToDrag,
draggedFiles: filesToDrag,
counter: 0,
mousePosition: { x: e.clientX, y: e.clientY },
});
@@ -293,9 +296,6 @@ export function FileManagerGrid({
files: filesToDrag.map((f) => f.path),
};
e.dataTransfer.setData("text/plain", JSON.stringify(dragData));
// Trigger system-level drag start
onSystemDragStart?.(filesToDrag);
e.dataTransfer.effectAllowed = "move";
};
@@ -378,10 +378,11 @@ export function FileManagerGrid({
};
const handleFileDragEnd = (e: React.DragEvent) => {
const draggedFiles = dragState.draggedFiles || [];
setDragState({ type: "none", files: [], counter: 0 });
// Trigger system-level drag end detection
onSystemDragEnd?.(e.nativeEvent);
// Trigger system-level drag end detection with dragged files
onSystemDragEnd?.(e.nativeEvent, draggedFiles);
};
const [isSelecting, setIsSelecting] = useState(false);
@@ -1356,44 +1357,50 @@ export function FileManagerGrid({
</div>
</div>
{/* Drag following tooltip */}
{/* Drag following tooltip - rendered as portal to ensure highest z-index */}
{dragState.type === "internal" &&
dragState.files.length > 0 &&
dragState.mousePosition && (
(dragState.files.length > 0 || dragState.draggedFiles?.length > 0) &&
dragState.mousePosition &&
createPortal(
<div
className="fixed z-[99999] pointer-events-none"
className="fixed pointer-events-none"
style={{
left: dragState.mousePosition.x + 24,
top: dragState.mousePosition.y - 40,
left: Math.min(Math.max(dragState.mousePosition.x + 40, 10), window.innerWidth - 300),
top: Math.max(Math.min(dragState.mousePosition.y - 80, window.innerHeight - 100), 10),
zIndex: 999999,
}}
>
<div className="bg-background border border-border rounded-md shadow-md px-3 py-2 flex items-center gap-2">
{dragState.target ? (
dragState.target.type === "directory" ? (
<>
<Move className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-foreground">
Move to {dragState.target.name}
</span>
</>
{(() => {
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">
Move to {dragState.target.name}
</span>
</>
) : (
<>
<GitCompare className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-foreground">
Diff compare with {dragState.target.name}
</span>
</>
)
) : (
<>
<GitCompare className="w-4 h-4 text-purple-500" />
<Download className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-foreground">
Diff compare with {dragState.target.name}
Drag outside window to download ({files.length} files)
</span>
</>
)
) : (
<>
<Download className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-foreground">
Drag outside window to download ({dragState.files.length} files)
</span>
</>
)}
);
})()}
</div>
</div>
</div>,
document.body
)}
</div>
);
@@ -1,234 +0,0 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Trash2, Folder, File, Plus, Pin } from "lucide-react";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs.tsx";
import { Input } from "@/components/ui/input.tsx";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { FileItem, ShortcutItem } from "../../../types/index";
interface FileManagerHomeViewProps {
recent: FileItem[];
pinned: FileItem[];
shortcuts: ShortcutItem[];
onOpenFile: (file: FileItem) => void;
onRemoveRecent: (file: FileItem) => void;
onPinFile: (file: FileItem) => void;
onUnpinFile: (file: FileItem) => void;
onOpenShortcut: (shortcut: ShortcutItem) => void;
onRemoveShortcut: (shortcut: ShortcutItem) => void;
onAddShortcut: (path: string) => void;
}
export function FileManagerHomeView({
recent,
pinned,
shortcuts,
onOpenFile,
onRemoveRecent,
onPinFile,
onUnpinFile,
onOpenShortcut,
onRemoveShortcut,
onAddShortcut,
}: FileManagerHomeViewProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<"recent" | "pinned" | "shortcuts">("recent");
const [newShortcut, setNewShortcut] = useState("");
const renderFileCard = (
file: FileItem,
onRemove: () => void,
onPin?: () => void,
isPinned = false,
) => (
<div
key={file.path}
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenFile(file)}
>
{file.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{file.name}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{onPin && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={onPin}
>
<Pin
className={`w-3 h-3 ${isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
/>
</Button>
)}
{onRemove && (
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={onRemove}
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
)}
</div>
</div>
);
const renderShortcutCard = (shortcut: ShortcutItem) => (
<div
key={shortcut.path}
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenShortcut(shortcut)}
>
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white break-words leading-tight">
{shortcut.path}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
onClick={() => onRemoveShortcut(shortcut)}
>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
</div>
</div>
);
return (
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
<Tabs
value={tab}
onValueChange={(v) => setTab(v as "recent" | "pinned" | "shortcuts")}
className="w-full"
>
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
<TabsTrigger
value="recent"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.recent")}
</TabsTrigger>
<TabsTrigger
value="pinned"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.pinned")}
</TabsTrigger>
<TabsTrigger
value="shortcuts"
className="data-[state=active]:bg-dark-bg-button"
>
{t("fileManager.folderShortcuts")}
</TabsTrigger>
</TabsList>
<TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{recent.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">
{t("fileManager.noRecentFiles")}
</span>
</div>
) : (
recent.map((file) =>
renderFileCard(
file,
() => onRemoveRecent(file),
() => (file.isPinned ? onUnpinFile(file) : onPinFile(file)),
file.isPinned,
),
)
)}
</div>
</TabsContent>
<TabsContent value="pinned" className="mt-0">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{pinned.length === 0 ? (
<div className="flex items-center justify-center py-8 col-span-full">
<span className="text-sm text-muted-foreground">
{t("fileManager.noPinnedFiles")}
</span>
</div>
) : (
pinned.map((file) =>
renderFileCard(file, undefined, () => onUnpinFile(file), true),
)
)}
</div>
</TabsContent>
<TabsContent value="shortcuts" className="mt-0">
<div className="flex items-center gap-3 mb-4 p-3 bg-dark-bg border-2 border-dark-border rounded-lg">
<Input
placeholder={t("fileManager.enterFolderPath")}
value={newShortcut}
onChange={(e) => setNewShortcut(e.target.value)}
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === "Enter" && newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut("");
}
}}
/>
<Button
size="sm"
variant="ghost"
className="h-8 px-2 bg-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover rounded-md"
onClick={() => {
if (newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
setNewShortcut("");
}
}}
>
<Plus className="w-3.5 h-3.5 mr-1" />
{t("common.add")}
</Button>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{shortcuts.length === 0 ? (
<div className="flex items-center justify-center py-4 col-span-full">
<span className="text-sm text-muted-foreground">
{t("fileManager.noShortcuts")}
</span>
</div>
) : (
shortcuts.map((shortcut) => renderShortcutCard(shortcut))
)}
</div>
</TabsContent>
</Tabs>
</div>
);
}
@@ -1,709 +0,0 @@
import React, {
useEffect,
useState,
useRef,
forwardRef,
useImperativeHandle,
} from "react";
import {
Folder,
File,
FileSymlink,
ArrowUp,
Pin,
MoreVertical,
Trash2,
Edit3,
} from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
import { cn } from "@/lib/utils.ts";
import { Input } from "@/components/ui/input.tsx";
import { Button } from "@/components/ui/button.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
listSSHFiles,
renameSSHItem,
deleteSSHItem,
getFileManagerPinned,
addFileManagerPinned,
removeFileManagerPinned,
getSSHStatus,
connectSSH,
identifySSHSymlink,
} from "@/ui/main-axios.ts";
import type { SSHHost } from "../../../types/index.js";
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
{
onOpenFile,
tabs,
host,
onOperationComplete,
onPathChange,
onDeleteItem,
}: {
onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void;
tabs: any[];
host: SSHHost;
onOperationComplete?: () => void;
onError?: (error: string) => void;
onSuccess?: (message: string) => void;
onPathChange?: (path: string) => void;
onDeleteItem?: (item: any) => void;
},
ref,
) {
const { t } = useTranslation();
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<any[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null);
const [search, setSearch] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [fileSearch, setFileSearch] = useState("");
const [debouncedFileSearch, setDebouncedFileSearch] = useState("");
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
return () => clearTimeout(handler);
}, [fileSearch]);
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
const [filesLoading, setFilesLoading] = useState(false);
const [connectingSSH, setConnectingSSH] = useState(false);
const [connectionCache, setConnectionCache] = useState<
Record<
string,
{
sessionId: string;
timestamp: number;
}
>
>({});
const [fetchingFiles, setFetchingFiles] = useState(false);
const [contextMenu, setContextMenu] = useState<{
visible: boolean;
x: number;
y: number;
item: any;
}>({
visible: false,
x: 0,
y: 0,
item: null,
});
const [renamingItem, setRenamingItem] = useState<{
item: any;
newName: string;
} | null>(null);
useEffect(() => {
const nextPath = host?.defaultPath || "/";
setCurrentPath(nextPath);
onPathChange?.(nextPath);
(async () => {
await connectToSSH(host);
})();
}, [host?.id]);
async function connectToSSH(server: SSHHost): Promise<string | null> {
const sessionId = server.id.toString();
const cached = connectionCache[sessionId];
if (cached && Date.now() - cached.timestamp < 30000) {
setSshSessionId(cached.sessionId);
return cached.sessionId;
}
if (connectingSSH) {
return null;
}
setConnectingSSH(true);
try {
if (!server.password && !server.key) {
toast.error(t("common.noAuthCredentials"));
return null;
}
const connectionConfig = {
hostId: server.id,
ip: server.ip,
port: server.port,
username: server.username,
password: server.password,
sshKey: server.key,
keyPassword: server.keyPassword,
authType: server.authType,
credentialId: server.credentialId,
userId: server.userId,
};
await connectSSH(sessionId, connectionConfig);
setSshSessionId(sessionId);
setConnectionCache((prev) => ({
...prev,
[sessionId]: { sessionId, timestamp: Date.now() },
}));
return sessionId;
} catch (err: any) {
toast.error(
err?.response?.data?.error || t("fileManager.failedToConnectSSH"),
);
setSshSessionId(null);
return null;
} finally {
setConnectingSSH(false);
}
}
async function fetchFiles() {
if (fetchingFiles) {
return;
}
setFetchingFiles(true);
setFiles([]);
setFilesLoading(true);
try {
let pinnedFiles: any[] = [];
try {
if (host) {
pinnedFiles = await getFileManagerPinned(host.id);
}
} catch (err) {}
if (host && sshSessionId) {
let res: any[] = [];
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw new Error(t("fileManager.failedToReconnectSSH"));
}
} else {
res = await listSSHFiles(sshSessionId, currentPath);
}
} catch (sessionErr) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath);
} else {
throw sessionErr;
}
}
const processedFiles = (res || []).map((f: any) => {
const filePath =
currentPath + (currentPath.endsWith("/") ? "" : "/") + f.name;
const isPinned = pinnedFiles.some(
(pinned) => pinned.path === filePath,
);
return {
...f,
path: filePath,
isPinned,
isSSH: true,
sshSessionId: sshSessionId,
};
});
setFiles(processedFiles);
}
} catch (err: any) {
setFiles([]);
toast.error(
err?.response?.data?.error ||
err?.message ||
t("fileManager.failedToListFiles"),
);
} finally {
setFilesLoading(false);
setFetchingFiles(false);
}
}
useEffect(() => {
if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
const timeoutId = setTimeout(() => {
fetchFiles();
}, 100);
return () => clearTimeout(timeoutId);
}
}, [currentPath, host, sshSessionId]);
useImperativeHandle(ref, () => ({
openFolder: async (_server: SSHHost, path: string) => {
if (connectingSSH || fetchingFiles) {
return;
}
if (currentPath === path) {
setTimeout(() => fetchFiles(), 100);
return;
}
setFetchingFiles(false);
setFilesLoading(false);
setFiles([]);
setCurrentPath(path);
onPathChange?.(path);
if (!sshSessionId) {
const sessionId = await connectToSSH(host);
if (sessionId) setSshSessionId(sessionId);
}
},
fetchFiles: () => {
if (host && sshSessionId) {
fetchFiles();
}
},
getCurrentPath: () => currentPath,
}));
useEffect(() => {
if (pathInputRef.current) {
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
}
}, [currentPath]);
const filteredFiles = files.filter((file) => {
const q = debouncedFileSearch.trim().toLowerCase();
if (!q) return true;
return file.name.toLowerCase().includes(q);
});
const handleContextMenu = (e: React.MouseEvent, item: any) => {
e.preventDefault();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = 160;
const menuHeight = 80;
let x = e.clientX;
let y = e.clientY;
if (x + menuWidth > viewportWidth) {
x = e.clientX - menuWidth;
}
if (y + menuHeight > viewportHeight) {
y = e.clientY - menuHeight;
}
if (x < 0) {
x = 0;
}
if (y < 0) {
y = 0;
}
setContextMenu({
visible: true,
x,
y,
item,
});
};
const closeContextMenu = () => {
setContextMenu({ visible: false, x: 0, y: 0, item: null });
};
const handleRename = async (item: any, newName: string) => {
if (!sshSessionId || !newName.trim() || newName === item.name) {
setRenamingItem(null);
return;
}
try {
await renameSSHItem(sshSessionId, item.path, newName.trim());
toast.success(
`${item.type === "directory" ? t("common.folder") : item.type === "link" ? t("common.link") : t("common.file")} ${t("common.renamedSuccessfully")}`,
);
setRenamingItem(null);
if (onOperationComplete) {
onOperationComplete();
} else {
fetchFiles();
}
} catch (error: any) {
toast.error(
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
);
}
};
const startRename = (item: any) => {
setRenamingItem({ item, newName: item.name });
closeContextMenu();
};
const startDelete = (item: any) => {
onDeleteItem?.(item);
closeContextMenu();
};
useEffect(() => {
const handleClickOutside = () => closeContextMenu();
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}, []);
const handlePathChange = (newPath: string) => {
setCurrentPath(newPath);
onPathChange?.(newPath);
};
// Handle symlink resolution
const handleSymlinkClick = async (item: any) => {
if (!host) return;
try {
// Extract just the symlink path (before the " -> " if present)
const symlinkPath = item.path.includes(" -> ")
? item.path.split(" -> ")[0]
: item.path;
let currentSessionId = sshSessionId;
// Check SSH connection status and reconnect if needed
if (currentSessionId) {
try {
const status = await getSSHStatus(currentSessionId);
if (!status.connected) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
currentSessionId = newSessionId;
} else {
throw new Error(t("fileManager.failedToReconnectSSH"));
}
}
} catch (sessionErr) {
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
currentSessionId = newSessionId;
} else {
throw sessionErr;
}
}
} else {
// No session ID, try to connect
const newSessionId = await connectToSSH(host);
if (newSessionId) {
setSshSessionId(newSessionId);
currentSessionId = newSessionId;
} else {
throw new Error(t("fileManager.failedToConnectSSH"));
}
}
const symlinkInfo = await identifySSHSymlink(
currentSessionId,
symlinkPath,
);
if (symlinkInfo.type === "directory") {
// If symlink points to a directory, navigate to it
handlePathChange(symlinkInfo.target);
} else if (symlinkInfo.type === "file") {
// If symlink points to a file, open it as a file
onOpenFile({
name: item.name,
path: symlinkInfo.target, // Use the target path, not the symlink path
isSSH: item.isSSH,
sshSessionId: currentSessionId,
});
}
} catch (error: any) {
toast.error(
error?.response?.data?.error ||
error?.message ||
t("fileManager.failedToResolveSymlink"),
);
}
};
return (
<div className="flex flex-col h-full w-[256px] max-w-[256px]">
<div className="flex flex-col flex-grow min-h-0">
<div className="flex-1 w-full h-full flex flex-col bg-dark-bg-darkest border-r-2 border-dark-border overflow-hidden p-0 relative min-h-0">
{host && (
<div className="flex flex-col h-full w-full max-w-[260px]">
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-dark-border bg-dark-bg z-20 max-w-[260px]">
<Button
size="icon"
variant="outline"
className="h-9 w-9 bg-dark-bg border-2 border-dark-border rounded-md hover:bg-dark-hover focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => {
let path = currentPath;
if (path && path !== "/" && path !== "") {
if (path.endsWith("/")) path = path.slice(0, -1);
const lastSlash = path.lastIndexOf("/");
if (lastSlash > 0) {
handlePathChange(path.slice(0, lastSlash));
} else {
handlePathChange("/");
}
} else {
handlePathChange("/");
}
}}
>
<ArrowUp className="w-4 h-4" />
</Button>
<Input
ref={pathInputRef}
value={currentPath}
onChange={(e) => handlePathChange(e.target.value)}
className="flex-1 bg-dark-bg border-2 border-dark-border-hover text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-dark-border-light"
/>
</div>
<div className="px-2 py-2 border-b-1 border-dark-border bg-dark-bg">
<Input
placeholder={t("fileManager.searchFilesAndFolders")}
className="w-full h-7 text-sm bg-dark-bg-button border-2 border-dark-border-hover text-white placeholder:text-muted-foreground rounded-md"
autoComplete="off"
value={fileSearch}
onChange={(e) => setFileSearch(e.target.value)}
/>
</div>
<div className="flex-1 min-h-0 w-full bg-dark-bg-darkest border-t-1 border-dark-border">
<ScrollArea className="h-full w-full bg-dark-bg-darkest">
<div className="p-2 pb-0">
{connectingSSH || filesLoading ? (
<div className="text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : filteredFiles.length === 0 ? (
<div className="text-xs text-muted-foreground">
{t("fileManager.noFilesOrFoldersFound")}
</div>
) : (
<div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => {
const isOpen = (tabs || []).some(
(t: any) => t.id === item.path,
);
const isRenaming =
renamingItem?.item?.path === item.path;
const isDeleting = false;
return (
<div
key={item.path}
className={cn(
"flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded group max-w-[220px] mb-2 relative",
isOpen &&
"opacity-60 cursor-not-allowed pointer-events-none",
)}
onContextMenu={(e) =>
!isOpen && handleContextMenu(e, item)
}
>
{isRenaming ? (
<div className="flex items-center gap-2 flex-1 min-w-0">
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : item.type === "link" ? (
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<Input
value={renamingItem.newName}
onChange={(e) =>
setRenamingItem((prev) =>
prev
? {
...prev,
newName: e.target.value,
}
: null,
)
}
className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") {
handleRename(
item,
renamingItem.newName,
);
} else if (e.key === "Escape") {
setRenamingItem(null);
}
}}
onBlur={() =>
handleRename(item, renamingItem.newName)
}
/>
</div>
) : (
<>
<div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() =>
!isOpen &&
(item.type === "directory"
? handlePathChange(item.path)
: item.type === "link"
? handleSymlinkClick(item)
: onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId,
}))
}
>
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : item.type === "link" ? (
<FileSymlink className="w-4 h-4 text-blue-400 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<span className="text-sm text-white truncate flex-1 min-w-0">
{item.name}
</span>
</div>
<div className="flex items-center gap-1">
{item.type === "file" && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={isOpen}
onClick={async (e) => {
e.stopPropagation();
try {
if (item.isPinned) {
await removeFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId:
host?.id.toString(),
});
setFiles(
files.map((f) =>
f.path === item.path
? {
...f,
isPinned: false,
}
: f,
),
);
} else {
await addFileManagerPinned({
name: item.name,
path: item.path,
hostId: host?.id,
isSSH: true,
sshSessionId:
host?.id.toString(),
});
setFiles(
files.map((f) =>
f.path === item.path
? {
...f,
isPinned: true,
}
: f,
),
);
}
} catch (err) {}
}}
>
<Pin
className={`w-1 h-1 ${item.isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
/>
</Button>
)}
{!isOpen && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
handleContextMenu(e, item);
}}
>
<MoreVertical className="w-4 h-4" />
</Button>
)}
</div>
</>
)}
</div>
);
})}
</div>
)}
</div>
</ScrollArea>
</div>
</div>
)}
</div>
</div>
{contextMenu.visible && contextMenu.item && (
<div
className="fixed z-[99998] bg-dark-bg border-2 border-dark-border rounded-lg shadow-xl py-1 min-w-[160px]"
style={{
left: contextMenu.x,
top: contextMenu.y,
}}
>
<button
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-dark-hover flex items-center gap-2"
onClick={() => startRename(contextMenu.item)}
>
<Edit3 className="w-4 h-4" />
Rename
</button>
<button
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-dark-hover flex items-center gap-2"
onClick={() => startDelete(contextMenu.item)}
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
)}
</div>
);
});
export { FileManagerLeftSidebar };
@@ -1,141 +0,0 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Card } from "@/components/ui/card.tsx";
import { Folder, File, Trash2, Pin, Download } from "lucide-react";
import { useTranslation } from "react-i18next";
interface SSHConnection {
id: string;
name: string;
ip: string;
port: number;
username: string;
isPinned?: boolean;
}
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
isStarred?: boolean;
}
interface FileManagerLeftSidebarVileViewerProps {
sshConnections: SSHConnection[];
onAddSSH: () => void;
onConnectSSH: (conn: SSHConnection) => void;
onEditSSH: (conn: SSHConnection) => void;
onDeleteSSH: (conn: SSHConnection) => void;
onPinSSH: (conn: SSHConnection) => void;
currentPath: string;
files: FileItem[];
onOpenFile: (file: FileItem) => void;
onOpenFolder: (folder: FileItem) => void;
onStarFile: (file: FileItem) => void;
onDownloadFile?: (file: FileItem) => void;
onDeleteFile: (file: FileItem) => void;
isLoading?: boolean;
error?: string;
isSSHMode: boolean;
onSwitchToLocal: () => void;
onSwitchToSSH: (conn: SSHConnection) => void;
currentSSH?: SSHConnection;
}
export function FileManagerLeftSidebarFileViewer({
currentPath,
files,
onOpenFile,
onOpenFolder,
onStarFile,
onDownloadFile,
onDeleteFile,
isLoading,
error,
isSSHMode,
}: FileManagerLeftSidebarVileViewerProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col h-full">
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
<div className="mb-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground font-semibold">
{isSSHMode ? t("common.sshPath") : t("common.localPath")}
</span>
<span className="text-xs text-white truncate">{currentPath}</span>
</div>
{isLoading ? (
<div className="text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : error ? (
<div className="text-xs text-red-500">{error}</div>
) : (
<div className="flex flex-col gap-1">
{files.map((item) => (
<Card
key={item.path}
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded"
>
<div
className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() =>
item.type === "directory"
? onOpenFolder(item)
: onOpenFile(item)
}
>
{item.type === "directory" ? (
<Folder className="w-4 h-4 text-blue-400" />
) : (
<File className="w-4 h-4 text-muted-foreground" />
)}
<span className="text-sm text-white truncate">
{item.name}
</span>
</div>
<div className="flex items-center gap-1">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onStarFile(item)}
>
<Pin
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
/>
</Button>
{item.type === "file" && onDownloadFile && (
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onDownloadFile(item)}
title={t("fileManager.downloadFile")}
>
<Download className="w-4 h-4 text-blue-400" />
</Button>
)}
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={() => onDeleteFile(item)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</Card>
))}
{files.length === 0 && (
<div className="text-xs text-muted-foreground">
No files or folders found.
</div>
)}
</div>
)}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,980 +0,0 @@
import React, { useState, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Card } from "@/components/ui/card.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import {
Upload,
Download,
FilePlus,
FolderPlus,
Trash2,
Edit3,
X,
AlertCircle,
FileText,
Folder,
} from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
import type { FileManagerOperationsProps } from "../../../types/index.js";
export function FileManagerOperations({
currentPath,
sshSessionId,
onOperationComplete,
onError,
onSuccess,
}: FileManagerOperationsProps) {
const { t } = useTranslation();
const [showUpload, setShowUpload] = useState(false);
const [showDownload, setShowDownload] = useState(false);
const [showCreateFile, setShowCreateFile] = useState(false);
const [showCreateFolder, setShowCreateFolder] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showRename, setShowRename] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [downloadPath, setDownloadPath] = useState("");
const [newFileName, setNewFileName] = useState("");
const [newFolderName, setNewFolderName] = useState("");
const [deletePath, setDeletePath] = useState("");
const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
const [renamePath, setRenamePath] = useState("");
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
const [newName, setNewName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [showTextLabels, setShowTextLabels] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const checkContainerWidth = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
setShowTextLabels(width > 240);
}
};
checkContainerWidth();
const resizeObserver = new ResizeObserver(checkContainerWidth);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, []);
const handleFileUpload = async () => {
if (!uploadFile || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.uploadingFile", { name: uploadFile.name }),
);
try {
// Read file content - support text and binary files
const content = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error);
// Check file type to determine reading method
const isTextFile =
uploadFile.type.startsWith("text/") ||
uploadFile.type === "application/json" ||
uploadFile.type === "application/javascript" ||
uploadFile.type === "application/xml" ||
uploadFile.name.match(
/\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i,
);
if (isTextFile) {
reader.onload = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error("Failed to read text file content"));
}
};
reader.readAsText(uploadFile);
} else {
reader.onload = () => {
if (reader.result instanceof ArrayBuffer) {
const bytes = new Uint8Array(reader.result);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
resolve(base64);
} else {
reject(new Error("Failed to read binary file"));
}
};
reader.readAsArrayBuffer(uploadFile);
}
});
const { uploadSSHFile } = await import("@/ui/main-axios.ts");
const response = await uploadSSHFile(
sshSessionId,
currentPath,
uploadFile.name,
content,
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.fileUploadedSuccessfully", { name: uploadFile.name }),
);
}
setShowUpload(false);
setUploadFile(null);
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToUploadFile"),
);
} finally {
setIsLoading(false);
}
};
const handleCreateFile = async () => {
if (!newFileName.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.creatingFile", { name: newFileName.trim() }),
);
try {
const { createSSHFile } = await import("@/ui/main-axios.ts");
const response = await createSSHFile(
sshSessionId,
currentPath,
newFileName.trim(),
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.fileCreatedSuccessfully", {
name: newFileName.trim(),
}),
);
}
setShowCreateFile(false);
setNewFileName("");
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToCreateFile"),
);
} finally {
setIsLoading(false);
}
};
const handleDownload = async () => {
if (!downloadPath.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const fileName = downloadPath.split("/").pop() || "download";
const loadingToast = toast.loading(
t("fileManager.downloadingFile", { name: fileName }),
);
try {
const { downloadSSHFile } = await import("@/ui/main-axios.ts");
const response = await downloadSSHFile(sshSessionId, downloadPath.trim());
toast.dismiss(loadingToast);
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++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
// Create download link
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
onSuccess(
t("fileManager.fileDownloadedSuccessfully", {
name: response.fileName || fileName,
}),
);
} else {
onError(t("fileManager.noFileContent"));
}
setShowDownload(false);
setDownloadPath("");
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToDownloadFile"),
);
} finally {
setIsLoading(false);
}
};
const handleCreateFolder = async () => {
if (!newFolderName.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.creatingFolder", { name: newFolderName.trim() }),
);
try {
const { createSSHFolder } = await import("@/ui/main-axios.ts");
const response = await createSSHFolder(
sshSessionId,
currentPath,
newFolderName.trim(),
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.folderCreatedSuccessfully", {
name: newFolderName.trim(),
}),
);
}
setShowCreateFolder(false);
setNewFolderName("");
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToCreateFolder"),
);
} finally {
setIsLoading(false);
}
};
const handleDelete = async () => {
if (!deletePath || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.deletingItem", {
type: deleteIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
name: deletePath.split("/").pop(),
}),
);
try {
const { deleteSSHItem } = await import("@/ui/main-axios.ts");
const response = await deleteSSHItem(
sshSessionId,
deletePath,
deleteIsDirectory,
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.itemDeletedSuccessfully", {
type: deleteIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
}),
);
}
setShowDelete(false);
setDeletePath("");
setDeleteIsDirectory(false);
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToDeleteItem"),
);
} finally {
setIsLoading(false);
}
};
const handleRename = async () => {
if (!renamePath || !newName.trim() || !sshSessionId) return;
setIsLoading(true);
const { toast } = await import("sonner");
const loadingToast = toast.loading(
t("fileManager.renamingItem", {
type: renameIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
oldName: renamePath.split("/").pop(),
newName: newName.trim(),
}),
);
try {
const { renameSSHItem } = await import("@/ui/main-axios.ts");
const response = await renameSSHItem(
sshSessionId,
renamePath,
newName.trim(),
);
toast.dismiss(loadingToast);
if (response?.toast) {
toast[response.toast.type](response.toast.message);
} else {
onSuccess(
t("fileManager.itemRenamedSuccessfully", {
type: renameIsDirectory
? t("fileManager.folder")
: t("fileManager.file"),
}),
);
}
setShowRename(false);
setRenamePath("");
setRenameIsDirectory(false);
setNewName("");
onOperationComplete();
} catch (error: any) {
toast.dismiss(loadingToast);
onError(
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
);
} finally {
setIsLoading(false);
}
};
const openFileDialog = () => {
fileInputRef.current?.click();
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setUploadFile(file);
}
};
const resetStates = () => {
setShowUpload(false);
setShowCreateFile(false);
setShowCreateFolder(false);
setShowDelete(false);
setShowRename(false);
setUploadFile(null);
setNewFileName("");
setNewFolderName("");
setDeletePath("");
setDeleteIsDirectory(false);
setRenamePath("");
setRenameIsDirectory(false);
setNewName("");
};
if (!sshSessionId) {
return (
<div className="p-4 text-center">
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{t("fileManager.connectToSsh")}
</p>
</div>
);
}
return (
<div ref={containerRef} className="p-4 space-y-4">
<div className="grid grid-cols-3 gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowUpload(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.uploadFile")}
>
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.uploadFile")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDownload(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.downloadFile")}
>
<Download className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.downloadFile")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFile(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.newFile")}
>
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.newFile")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCreateFolder(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.newFolder")}
>
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.newFolder")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowRename(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
title={t("fileManager.rename")}
>
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.rename")}</span>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowDelete(true)}
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-3"
title={t("fileManager.deleteItem")}
>
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
{showTextLabels && (
<span className="truncate">{t("fileManager.deleteItem")}</span>
)}
</Button>
</div>
<div className="bg-dark-bg-light border-2 border-dark-border-medium rounded-md p-3">
<div className="flex items-start gap-2 text-sm">
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="text-muted-foreground block mb-1">
{t("fileManager.currentPath")}:
</span>
<span className="text-white font-mono text-xs break-all leading-relaxed">
{currentPath}
</span>
</div>
</div>
</div>
<Separator className="p-0.25 bg-dark-border" />
{showUpload && (
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.uploadFileTitle")}
</span>
</h3>
<p className="text-xs text-muted-foreground break-words">
{t("fileManager.maxFileSize")}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowUpload(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div className="border-2 border-dashed border-dark-border-hover rounded-lg p-4 text-center">
{uploadFile ? (
<div className="space-y-3">
<FileText className="w-12 h-12 text-blue-400 mx-auto" />
<p className="text-white font-medium text-sm break-words px-2">
{uploadFile.name}
</p>
<p className="text-xs text-muted-foreground">
{(uploadFile.size / 1024).toFixed(2)} KB
</p>
<Button
variant="outline"
size="sm"
onClick={() => setUploadFile(null)}
className="w-full text-sm h-8"
>
{t("fileManager.removeFile")}
</Button>
</div>
) : (
<div className="space-y-3">
<Upload className="w-12 h-12 text-muted-foreground mx-auto" />
<p className="text-white text-sm break-words px-2">
{t("fileManager.clickToSelectFile")}
</p>
<Button
variant="outline"
size="sm"
onClick={openFileDialog}
className="w-full text-sm h-8"
>
{t("fileManager.chooseFile")}
</Button>
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFileSelect}
className="hidden"
accept="*/*"
/>
<div className="flex flex-col gap-2">
<Button
onClick={handleFileUpload}
disabled={!uploadFile || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.uploading")
: t("fileManager.uploadFile")}
</Button>
<Button
variant="outline"
onClick={() => setShowUpload(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showDownload && (
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<Download className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.downloadFile")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDownload(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.filePath")}
</label>
<Input
value={downloadPath}
onChange={(e) => setDownloadPath(e.target.value)}
placeholder={t("placeholders.fullPath")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleDownload()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleDownload}
disabled={!downloadPath.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.downloading")
: t("fileManager.downloadFile")}
</Button>
<Button
variant="outline"
onClick={() => setShowDownload(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showCreateFile && (
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.createNewFile")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFile(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.fileName")}
</label>
<Input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder={t("placeholders.fileName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleCreateFile}
disabled={!newFileName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.creating")
: t("fileManager.createFile")}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFile(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showCreateFolder && (
<Card className="bg-dark-bg border-2 border-dark-border p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<FolderPlus className="w-6 h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.createNewFolder")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCreateFolder(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.folderName")}
</label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t("placeholders.folderName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
/>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.creating")
: t("fileManager.createFolder")}
</Button>
<Button
variant="outline"
onClick={() => setShowCreateFolder(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showDelete && (
<Card className="bg-dark-bg border-2 border-dark-border p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0" />
<span className="break-words">
{t("fileManager.deleteItem")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowDelete(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
<div className="flex items-start gap-2 text-red-300">
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm font-medium break-words">
{t("fileManager.warningCannotUndo")}
</span>
</div>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.itemPath")}
</label>
<Input
value={deletePath}
onChange={(e) => setDeletePath(e.target.value)}
placeholder={t("placeholders.fullPath")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
/>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="deleteIsDirectory"
checked={deleteIsDirectory}
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
/>
<label
htmlFor="deleteIsDirectory"
className="text-sm text-white break-words"
>
{t("fileManager.thisIsDirectory")}
</label>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleDelete}
disabled={!deletePath || isLoading}
variant="destructive"
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.deleting")
: t("fileManager.deleteItem")}
</Button>
<Button
variant="outline"
onClick={() => setShowDelete(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
{showRename && (
<Card className="bg-dark-bg border-2 border-dark-border p-3">
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<h3 className="text-base font-semibold text-white flex items-center gap-2">
<Edit3 className="w-6 h-6 flex-shrink-0" />
<span className="break-words">
{t("fileManager.renameItem")}
</span>
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowRename(false)}
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.currentPathLabel")}
</label>
<Input
value={renamePath}
onChange={(e) => setRenamePath(e.target.value)}
placeholder={t("placeholders.currentPath")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
/>
</div>
<div>
<label className="text-sm font-medium text-white mb-2 block">
{t("fileManager.newName")}
</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t("placeholders.newName")}
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
onKeyDown={(e) => e.key === "Enter" && handleRename()}
/>
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
id="renameIsDirectory"
checked={renameIsDirectory}
onChange={(e) => setRenameIsDirectory(e.target.checked)}
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
/>
<label
htmlFor="renameIsDirectory"
className="text-sm text-white break-words"
>
{t("fileManager.thisIsDirectoryRename")}
</label>
</div>
<div className="flex flex-col gap-2">
<Button
onClick={handleRename}
disabled={!renamePath || !newName.trim() || isLoading}
className="w-full text-sm h-9"
>
{isLoading
? t("fileManager.renaming")
: t("fileManager.renameItem")}
</Button>
<Button
variant="outline"
onClick={() => setShowRename(false)}
disabled={isLoading}
className="w-full text-sm h-9"
>
{t("common.cancel")}
</Button>
</div>
</div>
</Card>
)}
</div>
);
}
@@ -1,62 +0,0 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { X, Home } from "lucide-react";
interface FileManagerTab {
id: string | number;
title: string;
}
interface FileManagerTabList {
tabs: FileManagerTab[];
activeTab: string | number;
setActiveTab: (tab: string | number) => void;
closeTab: (tab: string | number) => void;
onHomeClick: () => void;
}
export function FileManagerTabList({
tabs,
activeTab,
setActiveTab,
closeTab,
onHomeClick,
}: FileManagerTabList) {
return (
<div className="inline-flex items-center h-full gap-2">
<Button
onClick={onHomeClick}
variant="outline"
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === "home" ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
>
<Home className="w-4 h-4" />
</Button>
{tabs.map((tab) => {
const isActive = tab.id === activeTab;
return (
<div
key={tab.id}
className="inline-flex rounded-md shadow-sm"
role="group"
>
<Button
onClick={() => setActiveTab(tab.id)}
variant="outline"
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
>
{tab.title}
</Button>
<Button
onClick={() => closeTab(tab.id)}
variant="outline"
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
>
<X className="!w-4 !h-4" strokeWidth={2} />
</Button>
</div>
);
})}
</div>
);
}
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import {
@@ -15,6 +15,7 @@ import {
Save,
RotateCcw,
Keyboard,
Search,
} from "lucide-react";
import {
SiJavascript,
@@ -49,7 +50,7 @@ import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import { EditorView, keymap } from "@codemirror/view";
import { searchKeymap, search } from "@codemirror/search";
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
import { defaultKeymap, history, historyKeymap, toggleComment } from "@codemirror/commands";
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
import { PhotoProvider, PhotoView } from "react-photo-view";
@@ -323,6 +324,7 @@ export function FileViewer({
const [pdfScale, setPdfScale] = useState(1.2);
const [pdfError, setPdfError] = useState(false);
const [markdownEditMode, setMarkdownEditMode] = useState(false);
const editorRef = useRef<any>(null);
const fileTypeInfo = getFileType(file.name);
@@ -348,7 +350,8 @@ export function FileViewer({
if (savedContent) {
setOriginalContent(savedContent);
}
setHasChanges(content !== (savedContent || content));
// 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) {
@@ -361,7 +364,8 @@ export function FileViewer({
// Handle content changes
const handleContentChange = (newContent: string) => {
setEditedContent(newContent);
setHasChanges(newContent !== originalContent);
// Fix: Compare with savedContent instead of originalContent for consistency
setHasChanges(newContent !== savedContent);
onContentChange?.(newContent);
};
@@ -373,9 +377,9 @@ export function FileViewer({
// Revert file
const handleRevert = () => {
setEditedContent(originalContent);
setEditedContent(savedContent);
setHasChanges(false);
onContentChange?.(originalContent);
onContentChange?.(savedContent);
};
// Handle save shortcut specifically
@@ -453,6 +457,26 @@ 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) {
openSearchPanel(view);
}
}
}}
className="flex items-center gap-2"
title="Search in file (Ctrl+F)"
>
<Search className="w-4 h-4" />
</Button>
)}
{/* Keyboard shortcuts help */}
{isEditable && (
<Button
@@ -557,14 +581,13 @@ export function FileViewer({
<span>{t("fileManager.redo")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+Y</kbd>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">{t("fileManager.navigation")}</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.goToLine")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+G</kbd>
<span>{t("fileManager.toggleComment")}</span>
<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>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineUp")}</span>
@@ -576,27 +599,6 @@ export function FileViewer({
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">{t("fileManager.code")}</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.toggleComment")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Ctrl+/</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.indent")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Tab</kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.outdent")}</span>
<kbd className="px-2 py-1 bg-background rounded text-xs">Shift+Tab</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>
</div>
</div>
</div>
</div>
</div>
)}
@@ -737,6 +739,7 @@ export function FileViewer({
{isEditable ? (
// Unified CodeMirror editor for all text-based files
<CodeMirror
ref={editorRef}
value={editedContent}
onChange={(value) => handleContentChange(value)}
onFocus={() => setEditorFocused(true)}
@@ -906,17 +909,7 @@ export function FileViewer({
</Button>
</div>
<div className="flex items-center gap-2">
{hasChanges && (
<Button
variant="default"
size="sm"
onClick={() => onSave?.(editedContent)}
disabled={!hasChanges}
>
<Save className="w-4 h-4 mr-1" />
{t("fileManager.save")}
</Button>
)}
{/* Save button removed - using the main header save button instead */}
</div>
</div>
</div>