feat(FileManager): enhance file viewing capabilities with markdown and syntax highlighting support
- Added support for viewing markdown files using ReactMarkdown and remark-gfm. - Integrated syntax highlighting for code snippets using react-syntax-highlighter. - Introduced new viewer modes: markdown, json, xml, and hex. - Updated file categorization logic to include markdown files. - Enhanced the viewer interface with icons representing different file types. - Improved user experience with loading states and error handling during file operations. - Refactored code for better readability and maintainability.
This commit is contained in:
parent
b18f9c685b
commit
0e684077b2
|
|
@ -39,6 +39,7 @@
|
||||||
"@radix-ui/react-toggle": "1.1.2",
|
"@radix-ui/react-toggle": "1.1.2",
|
||||||
"@radix-ui/react-toggle-group": "1.1.2",
|
"@radix-ui/react-toggle-group": "1.1.2",
|
||||||
"@radix-ui/react-tooltip": "1.1.8",
|
"@radix-ui/react-tooltip": "1.1.8",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"canvas-confetti": "1.9.4",
|
"canvas-confetti": "1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
|
@ -53,12 +54,15 @@
|
||||||
"react-dnd": "16.0.1",
|
"react-dnd": "16.0.1",
|
||||||
"react-dnd-html5-backend": "16.0.1",
|
"react-dnd-html5-backend": "16.0.1",
|
||||||
"react-hook-form": "7.55.0",
|
"react-hook-form": "7.55.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-popper": "2.3.0",
|
"react-popper": "2.3.0",
|
||||||
"react-resizable-panels": "2.1.7",
|
"react-resizable-panels": "2.1.7",
|
||||||
"react-responsive-masonry": "2.7.1",
|
"react-responsive-masonry": "2.7.1",
|
||||||
"react-router": "^7.17.0",
|
"react-router": "^7.17.0",
|
||||||
"react-slick": "0.31.0",
|
"react-slick": "0.31.0",
|
||||||
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
"recharts": "2.15.2",
|
"recharts": "2.15.2",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "2.0.3",
|
"sonner": "2.0.3",
|
||||||
"tailwind-merge": "3.2.0",
|
"tailwind-merge": "3.2.0",
|
||||||
"tw-animate-css": "1.3.8",
|
"tw-animate-css": "1.3.8",
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
|
AlignLeft,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
BookOpen,
|
||||||
|
Braces,
|
||||||
Check,
|
Check,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Code2,
|
||||||
Copy,
|
Copy,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
|
|
@ -12,6 +16,7 @@ import {
|
||||||
Folder,
|
Folder,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
|
Hash,
|
||||||
Home,
|
Home,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
|
@ -24,6 +29,10 @@ import {
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
import {
|
import {
|
||||||
copyPath,
|
copyPath,
|
||||||
createFolder,
|
createFolder,
|
||||||
|
|
@ -47,32 +56,184 @@ import {
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from './ui/dialog';
|
} from './ui/dialog';
|
||||||
|
|
||||||
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type SortKey = 'name' | 'size' | 'date';
|
type SortKey = 'name' | 'size' | 'date';
|
||||||
type Clipboard = { op: 'copy' | 'move'; paths: string[] };
|
type Clipboard = { op: 'copy' | 'move'; paths: string[] };
|
||||||
type FileCategory = 'text' | 'image' | 'disk' | 'binary';
|
type FileCategory = 'text' | 'image' | 'disk' | 'binary';
|
||||||
|
type ViewMode = 'text' | 'markdown' | 'json' | 'xml' | 'hex' | 'image';
|
||||||
|
|
||||||
const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'json', 'bas', 'asm', 'seq', 'rel', 'prg', 'md', 'log', 'csv']);
|
// ─── Extension sets ──────────────────────────────────────────────────────────
|
||||||
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp']);
|
|
||||||
|
const TEXT_EXTS = new Set(['txt', 'cfg', 'ini', 'bas', 'asm', 'seq', 'rel', 'prg', 'log', 'csv', 's', 'lst']);
|
||||||
|
const MD_EXTS = new Set(['md', 'markdown']);
|
||||||
|
const JSON_EXTS = new Set(['json']);
|
||||||
|
const XML_EXTS = new Set(['xml', 'svg', 'html', 'htm', 'rss', 'atom', 'xsl']);
|
||||||
|
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp']);
|
||||||
const DISK_EXTS = new Set(['d64', 'd71', 'd81', 'd82', 'g64', 'g71', 't64', 'tap', 'crt', 'nib']);
|
const DISK_EXTS = new Set(['d64', 'd71', 'd81', 'd82', 'g64', 'g71', 't64', 'tap', 'crt', 'nib']);
|
||||||
|
|
||||||
function fileCategory(entry: EntryInfo): FileCategory {
|
function fileCategory(entry: EntryInfo): FileCategory {
|
||||||
if (entry.type === 'folder') return 'binary';
|
if (entry.type === 'folder') return 'binary';
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
if (TEXT_EXTS.has(ext)) return 'text';
|
|
||||||
if (IMAGE_EXTS.has(ext)) return 'image';
|
if (IMAGE_EXTS.has(ext)) return 'image';
|
||||||
if (DISK_EXTS.has(ext)) return 'disk';
|
if (DISK_EXTS.has(ext)) return 'disk';
|
||||||
|
if (TEXT_EXTS.has(ext) || MD_EXTS.has(ext) || JSON_EXTS.has(ext) || XML_EXTS.has(ext)) return 'text';
|
||||||
return 'binary';
|
return 'binary';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function defaultViewMode(entry: EntryInfo): ViewMode {
|
||||||
|
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (IMAGE_EXTS.has(ext)) return 'image';
|
||||||
|
if (MD_EXTS.has(ext)) return 'markdown';
|
||||||
|
if (JSON_EXTS.has(ext)) return 'json';
|
||||||
|
if (XML_EXTS.has(ext)) return 'xml';
|
||||||
|
if (TEXT_EXTS.has(ext)) return 'text';
|
||||||
|
return 'hex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableViewers(entry: EntryInfo): ViewMode[] {
|
||||||
|
const def = defaultViewMode(entry);
|
||||||
|
const map: Record<ViewMode, ViewMode[]> = {
|
||||||
|
image: ['image', 'hex'],
|
||||||
|
markdown: ['markdown', 'text', 'hex'],
|
||||||
|
json: ['json', 'text', 'hex'],
|
||||||
|
xml: ['xml', 'text', 'hex'],
|
||||||
|
text: ['text', 'hex'],
|
||||||
|
hex: ['hex', 'text'],
|
||||||
|
};
|
||||||
|
return map[def];
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIEWER_LABEL: Record<ViewMode, string> = {
|
||||||
|
text: 'Text', markdown: 'Markdown', json: 'JSON', xml: 'XML', hex: 'Hex', image: 'Image',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SYNTAX_LANG: Partial<Record<ViewMode, string>> = {
|
||||||
|
json: 'json', xml: 'xml', text: 'text',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Viewer components ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ViewerModeIcon({ mode, className }: { mode: ViewMode; className?: string }) {
|
||||||
|
const cls = className ?? 'w-4 h-4';
|
||||||
|
switch (mode) {
|
||||||
|
case 'text': return <AlignLeft className={cls} />;
|
||||||
|
case 'markdown': return <BookOpen className={cls} />;
|
||||||
|
case 'json': return <Braces className={cls} />;
|
||||||
|
case 'xml': return <Code2 className={cls} />;
|
||||||
|
case 'hex': return <Hash className={cls} />;
|
||||||
|
case 'image': return <ImageIcon className={cls} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function HexViewer({ data }: { data: Uint8Array }) {
|
||||||
|
const MAX = 65536;
|
||||||
|
const view = data.length > MAX ? data.slice(0, MAX) : data;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (let i = 0; i < view.length; i += 16) {
|
||||||
|
const row = view.slice(i, i + 16);
|
||||||
|
const addr = i.toString(16).padStart(8, '0').toUpperCase();
|
||||||
|
const hex = Array.from({ length: 16 }, (_, j) =>
|
||||||
|
j < row.length ? row[j].toString(16).padStart(2, '0').toUpperCase() : ' ',
|
||||||
|
);
|
||||||
|
const hexStr = hex.slice(0, 8).join(' ') + ' ' + hex.slice(8).join(' ');
|
||||||
|
const ascii = Array.from(row).map(b => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join('');
|
||||||
|
lines.push(`${addr} ${hexStr} |${ascii}|`);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="p-4 overflow-auto h-full">
|
||||||
|
{data.length > MAX && (
|
||||||
|
<div className="text-amber-400 text-xs mb-2 font-sans">
|
||||||
|
Showing first {MAX.toLocaleString()} of {data.length.toLocaleString()} bytes
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<pre className="text-green-400 text-xs font-mono whitespace-pre leading-5">{lines.join('\n')}</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CodeViewer({ text, mode }: { text: string; mode: ViewMode }) {
|
||||||
|
const lang = SYNTAX_LANG[mode] ?? 'text';
|
||||||
|
const source = mode === 'json' ? (() => {
|
||||||
|
try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text; }
|
||||||
|
})() : text;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto text-xs">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={lang}
|
||||||
|
style={vscDarkPlus}
|
||||||
|
customStyle={{ margin: 0, minHeight: '100%', background: '#0a0a0a', fontSize: '12px', lineHeight: '1.5' }}
|
||||||
|
showLineNumbers
|
||||||
|
wrapLongLines
|
||||||
|
>
|
||||||
|
{source}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownViewer({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 overflow-auto h-full text-neutral-200 text-sm leading-relaxed">
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
h1: ({ children }) => <h1 className="text-2xl font-bold mt-6 mb-3 text-white border-b border-neutral-700 pb-2">{children}</h1>,
|
||||||
|
h2: ({ children }) => <h2 className="text-xl font-bold mt-5 mb-2 text-white">{children}</h2>,
|
||||||
|
h3: ({ children }) => <h3 className="text-base font-semibold mt-4 mb-1 text-white">{children}</h3>,
|
||||||
|
p: ({ children }) => <p className="my-2">{children}</p>,
|
||||||
|
a: ({ href, children }) => <a href={href} className="text-blue-400 underline" target="_blank" rel="noopener noreferrer">{children}</a>,
|
||||||
|
code: ({ className, children, ...props }) => {
|
||||||
|
const match = /language-(\w+)/.exec(className ?? '');
|
||||||
|
const inline = !match && !String(children).includes('\n');
|
||||||
|
return inline
|
||||||
|
? <code className="bg-neutral-700 text-green-300 rounded px-1 text-xs font-mono" {...props}>{children}</code>
|
||||||
|
: (
|
||||||
|
<div className="my-3 rounded overflow-hidden text-xs">
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={match?.[1] ?? 'text'}
|
||||||
|
style={vscDarkPlus}
|
||||||
|
customStyle={{ margin: 0, background: '#1a1a1a' }}
|
||||||
|
PreTag="div"
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, '')}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre: ({ children }) => <>{children}</>,
|
||||||
|
blockquote: ({ children }) => <blockquote className="border-l-4 border-neutral-500 pl-4 my-2 text-neutral-400 italic">{children}</blockquote>,
|
||||||
|
ul: ({ children }) => <ul className="list-disc pl-5 my-2 space-y-1">{children}</ul>,
|
||||||
|
ol: ({ children }) => <ol className="list-decimal pl-5 my-2 space-y-1">{children}</ol>,
|
||||||
|
li: ({ children }) => <li>{children}</li>,
|
||||||
|
hr: () => <hr className="border-neutral-600 my-4" />,
|
||||||
|
table: ({ children }) => <table className="border-collapse my-3 text-sm w-full">{children}</table>,
|
||||||
|
th: ({ children }) => <th className="border border-neutral-600 px-3 py-1 bg-neutral-800 font-semibold text-left">{children}</th>,
|
||||||
|
td: ({ children }) => <td className="border border-neutral-600 px-3 py-1">{children}</td>,
|
||||||
|
strong: ({ children }) => <strong className="text-white font-semibold">{children}</strong>,
|
||||||
|
img: ({ src, alt }) => <img src={src} alt={alt} className="max-w-full my-2 rounded" />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Entry icon ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function EntryIcon({ entry }: { entry: EntryInfo }) {
|
function EntryIcon({ entry }: { entry: EntryInfo }) {
|
||||||
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
|
if (entry.type === 'folder') return <Folder className="w-5 h-5 text-blue-500 flex-shrink-0" />;
|
||||||
const cat = fileCategory(entry);
|
const cat = fileCategory(entry);
|
||||||
if (cat === 'image') return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
|
if (cat === 'image') return <ImageIcon className="w-5 h-5 text-purple-500 flex-shrink-0" />;
|
||||||
if (cat === 'text') return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
|
||||||
if (cat === 'disk') return <HardDrive className="w-5 h-5 text-amber-500 flex-shrink-0" />;
|
if (cat === 'disk') return <HardDrive className="w-5 h-5 text-amber-500 flex-shrink-0" />;
|
||||||
|
if (cat === 'text') return <FileText className="w-5 h-5 text-green-600 flex-shrink-0" />;
|
||||||
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
return <File className="w-5 h-5 text-neutral-400 flex-shrink-0" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface FileManagerProps {
|
interface FileManagerProps {
|
||||||
initialPath?: string;
|
initialPath?: string;
|
||||||
config?: any;
|
config?: any;
|
||||||
|
|
@ -80,6 +241,8 @@ interface FileManagerProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Main component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function FileManager({ initialPath = '/', config, setConfig, onBack }: FileManagerProps) {
|
export default function FileManager({ initialPath = '/', config, setConfig, onBack }: FileManagerProps) {
|
||||||
const [path, setPath] = useState(normalizePath(initialPath));
|
const [path, setPath] = useState(normalizePath(initialPath));
|
||||||
const [entries, setEntries] = useState<EntryInfo[]>([]);
|
const [entries, setEntries] = useState<EntryInfo[]>([]);
|
||||||
|
|
@ -92,49 +255,50 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
const [clipboard, setClipboard] = useState<Clipboard | null>(null);
|
const [clipboard, setClipboard] = useState<Clipboard | null>(null);
|
||||||
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
|
const [actionEntry, setActionEntry] = useState<EntryInfo | null>(null);
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
|
||||||
|
// Viewer
|
||||||
const [viewEntry, setViewEntry] = useState<EntryInfo | null>(null);
|
const [viewEntry, setViewEntry] = useState<EntryInfo | null>(null);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode | null>(null);
|
||||||
|
const [viewBlob, setViewBlob] = useState<Blob | null>(null);
|
||||||
const [viewText, setViewText] = useState<string | null>(null);
|
const [viewText, setViewText] = useState<string | null>(null);
|
||||||
const [viewImgUrl, setViewImgUrl] = useState<string | null>(null);
|
const [viewImgUrl, setViewImgUrl] = useState<string | null>(null);
|
||||||
|
const [viewHexData, setViewHexData] = useState<Uint8Array | null>(null);
|
||||||
const [viewLoading, setViewLoading] = useState(false);
|
const [viewLoading, setViewLoading] = useState(false);
|
||||||
|
|
||||||
|
// Rename / folder
|
||||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||||
const [newFolderName, setNewFolderName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
const [renameEntry, setRenameEntry] = useState<EntryInfo | null>(null);
|
const [renameEntry, setRenameEntry] = useState<EntryInfo | null>(null);
|
||||||
const [renameName, setRenameName] = useState('');
|
const [renameName, setRenameName] = useState('');
|
||||||
const [mountEntry, setMountEntry] = useState<EntryInfo | null>(null);
|
const [mountEntry, setMountEntry] = useState<EntryInfo | null>(null);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
|
|
||||||
|
// ── Directory loading ────────────────────────────────────────────────────
|
||||||
|
|
||||||
const load = useCallback(async (p: string) => {
|
const load = useCallback(async (p: string) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
try {
|
try {
|
||||||
const items = await listDirectory(p);
|
setEntries(await listDirectory(p));
|
||||||
setEntries(items);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || 'Failed to load directory');
|
setError(e?.message ?? 'Failed to load directory');
|
||||||
setEntries([]);
|
setEntries([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { void load(path); }, [path, load]);
|
||||||
void load(path);
|
|
||||||
}, [path, load]);
|
|
||||||
|
|
||||||
const navigateTo = (p: string) => {
|
const navigateTo = (p: string) => { setPath(normalizePath(p)); setFilter(''); };
|
||||||
setPath(normalizePath(p));
|
const navigateUp = () => { if (path !== '/') navigateTo(splitPath(path).parent); };
|
||||||
setFilter('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigateUp = () => {
|
// ── Sort + filter ────────────────────────────────────────────────────────
|
||||||
if (path === '/') return;
|
|
||||||
navigateTo(splitPath(path).parent);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sorted + filtered view
|
|
||||||
const visible = entries
|
const visible = entries
|
||||||
.filter(e => !filter || e.name.toLowerCase().includes(filter.toLowerCase()))
|
.filter(e => !filter || e.name.toLowerCase().includes(filter.toLowerCase()))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
|
@ -142,7 +306,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
let cmp = 0;
|
let cmp = 0;
|
||||||
if (sortKey === 'name') cmp = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
if (sortKey === 'name') cmp = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||||
else if (sortKey === 'size') cmp = a.size - b.size;
|
else if (sortKey === 'size') cmp = a.size - b.size;
|
||||||
else if (sortKey === 'date') cmp = (a.lastModified?.getTime() ?? 0) - (b.lastModified?.getTime() ?? 0);
|
else cmp = (a.lastModified?.getTime() ?? 0) - (b.lastModified?.getTime() ?? 0);
|
||||||
return sortAsc ? cmp : -cmp;
|
return sortAsc ? cmp : -cmp;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -151,122 +315,106 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
else { setSortKey(key); setSortAsc(true); }
|
else { setSortKey(key); setSortAsc(true); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Multi-select
|
// ── Multi-select ─────────────────────────────────────────────────────────
|
||||||
const toggleSelect = (entryPath: string) => {
|
|
||||||
setSelected(prev => {
|
const toggleSelect = (p: string) =>
|
||||||
const next = new Set(prev);
|
setSelected(prev => { const next = new Set(prev); next.has(p) ? next.delete(p) : next.add(p); return next; });
|
||||||
if (next.has(entryPath)) next.delete(entryPath);
|
|
||||||
else next.add(entryPath);
|
const selectAll = () =>
|
||||||
return next;
|
setSelected(selected.size === visible.length && visible.length > 0
|
||||||
});
|
? new Set()
|
||||||
|
: new Set(visible.map(e => e.path)));
|
||||||
|
|
||||||
|
// ── File viewer ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const processBlob = async (blob: Blob, mode: ViewMode) => {
|
||||||
|
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; });
|
||||||
|
setViewText(null);
|
||||||
|
setViewHexData(null);
|
||||||
|
if (mode === 'image') setViewImgUrl(URL.createObjectURL(blob));
|
||||||
|
else if (mode === 'hex') setViewHexData(new Uint8Array(await blob.arrayBuffer()));
|
||||||
|
else setViewText(await blob.text());
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectAll = () => {
|
const openEntry = async (entry: EntryInfo, mode?: ViewMode) => {
|
||||||
setSelected(selected.size === visible.length && visible.length > 0 ? new Set() : new Set(visible.map(e => e.path)));
|
|
||||||
};
|
|
||||||
|
|
||||||
// File viewer
|
|
||||||
const openEntry = async (entry: EntryInfo) => {
|
|
||||||
if (renameEntry !== null) return;
|
if (renameEntry !== null) return;
|
||||||
if (entry.type === 'folder') {
|
if (entry.type === 'folder') { navigateTo(joinPath(path, entry.name)); return; }
|
||||||
navigateTo(joinPath(path, entry.name));
|
const targetMode = mode ?? defaultViewMode(entry);
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cat = fileCategory(entry);
|
|
||||||
if (cat === 'disk') {
|
|
||||||
setMountEntry(entry);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cat === 'binary') {
|
|
||||||
void downloadEntry(entry);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setViewEntry(entry);
|
setViewEntry(entry);
|
||||||
|
setViewMode(targetMode);
|
||||||
|
setViewBlob(null);
|
||||||
setViewText(null);
|
setViewText(null);
|
||||||
setViewImgUrl(null);
|
setViewImgUrl(null);
|
||||||
|
setViewHexData(null);
|
||||||
setViewLoading(true);
|
setViewLoading(true);
|
||||||
try {
|
try {
|
||||||
const blob = await getFileContents(entry.path);
|
const blob = await getFileContents(entry.path);
|
||||||
if (cat === 'image') {
|
setViewBlob(blob);
|
||||||
setViewImgUrl(URL.createObjectURL(blob));
|
await processBlob(blob, targetMode);
|
||||||
} else {
|
|
||||||
setViewText(await blob.text());
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to open ${entry.name}: ${e?.message || e}`);
|
toast.error(`Failed to open ${entry.name}: ${e?.message ?? e}`);
|
||||||
setViewEntry(null);
|
setViewEntry(null);
|
||||||
|
setViewMode(null);
|
||||||
} finally {
|
} finally {
|
||||||
setViewLoading(false);
|
setViewLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeViewer = () => {
|
const switchViewMode = async (mode: ViewMode) => {
|
||||||
if (viewImgUrl) URL.revokeObjectURL(viewImgUrl);
|
if (!viewBlob) return;
|
||||||
setViewEntry(null);
|
setViewMode(mode);
|
||||||
setViewText(null);
|
setViewLoading(true);
|
||||||
setViewImgUrl(null);
|
try { await processBlob(viewBlob, mode); } finally { setViewLoading(false); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Download
|
const closeViewer = () => {
|
||||||
|
setViewImgUrl(prev => { if (prev) URL.revokeObjectURL(prev); return null; });
|
||||||
|
setViewEntry(null); setViewMode(null); setViewBlob(null);
|
||||||
|
setViewText(null); setViewHexData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Download ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const triggerDownload = (blob: Blob, name: string) => {
|
const triggerDownload = (blob: Blob, name: string) => {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url; a.download = name; a.click();
|
||||||
a.download = name;
|
|
||||||
a.click();
|
|
||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadEntry = async (entry: EntryInfo) => {
|
const downloadEntry = async (entry: EntryInfo) => {
|
||||||
try {
|
try { triggerDownload(await getFileContents(entry.path), entry.name); }
|
||||||
const blob = await getFileContents(entry.path);
|
catch (e: any) { toast.error(`Download failed: ${e?.message ?? e}`); }
|
||||||
triggerDownload(blob, entry.name);
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`Download failed: ${e?.message || e}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const downloadSelected = async () => {
|
const downloadSelected = async () => {
|
||||||
const targets = entries.filter(e => selected.has(e.path) && e.type === 'file');
|
for (const e of entries.filter(e => selected.has(e.path) && e.type === 'file'))
|
||||||
for (const entry of targets) await downloadEntry(entry);
|
await downloadEntry(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Delete
|
// ── Delete ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const deleteEntry = async (entry: EntryInfo) => {
|
const deleteEntry = async (entry: EntryInfo) => {
|
||||||
const ok = window.confirm(
|
if (!window.confirm(entry.type === 'folder'
|
||||||
entry.type === 'folder'
|
|
||||||
? `Delete folder "${entry.name}" and all its contents?`
|
? `Delete folder "${entry.name}" and all its contents?`
|
||||||
: `Delete "${entry.name}"?`,
|
: `Delete "${entry.name}"?`)) return;
|
||||||
);
|
try { await deletePath(entry.path); toast.success(`Deleted ${entry.name}`); void load(path); }
|
||||||
if (!ok) return;
|
catch (e: any) { toast.error(`Delete failed: ${e?.message ?? e}`); }
|
||||||
try {
|
|
||||||
await deletePath(entry.path);
|
|
||||||
toast.success(`Deleted ${entry.name}`);
|
|
||||||
void load(path);
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`Delete failed: ${e?.message || e}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteSelected = async () => {
|
const deleteSelected = async () => {
|
||||||
const targets = entries.filter(e => selected.has(e.path));
|
const targets = entries.filter(e => selected.has(e.path));
|
||||||
const ok = window.confirm(`Delete ${targets.length} item${targets.length !== 1 ? 's' : ''}?`);
|
if (!window.confirm(`Delete ${targets.length} item${targets.length !== 1 ? 's' : ''}?`)) return;
|
||||||
if (!ok) return;
|
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const entry of targets) {
|
for (const e of targets) { try { await deletePath(e.path); } catch { failed++; } }
|
||||||
try {
|
|
||||||
await deletePath(entry.path);
|
|
||||||
} catch {
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (failed) toast.error(`${failed} item${failed !== 1 ? 's' : ''} failed to delete`);
|
if (failed) toast.error(`${failed} item${failed !== 1 ? 's' : ''} failed to delete`);
|
||||||
else toast.success(`Deleted ${targets.length} item${targets.length !== 1 ? 's' : ''}`);
|
else toast.success(`Deleted ${targets.length} item${targets.length !== 1 ? 's' : ''}`);
|
||||||
void load(path);
|
void load(path);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
// Rename
|
// ── Rename ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const startRename = (entry: EntryInfo) => {
|
const startRename = (entry: EntryInfo) => {
|
||||||
setRenameEntry(entry);
|
setRenameEntry(entry);
|
||||||
setRenameName(entry.name);
|
setRenameName(entry.name);
|
||||||
|
|
@ -277,23 +425,18 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
const commitRename = async () => {
|
const commitRename = async () => {
|
||||||
if (!renameEntry) return;
|
if (!renameEntry) return;
|
||||||
const newName = renameName.trim();
|
const newName = renameName.trim();
|
||||||
if (!newName || newName === renameEntry.name) {
|
if (!newName || newName === renameEntry.name) { setRenameEntry(null); return; }
|
||||||
setRenameEntry(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dest = joinPath(splitPath(renameEntry.path).parent, newName);
|
const dest = joinPath(splitPath(renameEntry.path).parent, newName);
|
||||||
try {
|
try {
|
||||||
await movePath(renameEntry.path, dest);
|
await movePath(renameEntry.path, dest);
|
||||||
toast.success(`Renamed to "${newName}"`);
|
toast.success(`Renamed to "${newName}"`);
|
||||||
void load(path);
|
void load(path);
|
||||||
} catch (e: any) {
|
} catch (e: any) { toast.error(`Rename failed: ${e?.message ?? e}`); }
|
||||||
toast.error(`Rename failed: ${e?.message || e}`);
|
finally { setRenameEntry(null); }
|
||||||
} finally {
|
|
||||||
setRenameEntry(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mount disk image onto a drive device
|
// ── Mount ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
|
const mountOnDevice = (deviceType: 'drive' | 'meatloaf', key: string) => {
|
||||||
if (!mountEntry || !setConfig || !config) return;
|
if (!mountEntry || !setConfig || !config) return;
|
||||||
const newConfig = JSON.parse(JSON.stringify(config));
|
const newConfig = JSON.parse(JSON.stringify(config));
|
||||||
|
|
@ -309,7 +452,8 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
setMountEntry(null);
|
setMountEntry(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Clipboard (copy / move)
|
// ── Clipboard ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const cutOrCopyEntry = (entry: EntryInfo, op: 'copy' | 'move') => {
|
const cutOrCopyEntry = (entry: EntryInfo, op: 'copy' | 'move') => {
|
||||||
setClipboard({ op, paths: [entry.path] });
|
setClipboard({ op, paths: [entry.path] });
|
||||||
toast.success(`"${entry.name}" ${op === 'copy' ? 'copied' : 'cut'} — navigate and paste`);
|
toast.success(`"${entry.name}" ${op === 'copy' ? 'copied' : 'cut'} — navigate and paste`);
|
||||||
|
|
@ -332,14 +476,9 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
if (!clipboard) return;
|
if (!clipboard) return;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
for (const src of clipboard.paths) {
|
for (const src of clipboard.paths) {
|
||||||
const name = splitPath(src).name;
|
const dest = joinPath(path, splitPath(src).name);
|
||||||
const dest = joinPath(path, name);
|
try { clipboard.op === 'copy' ? await copyPath(src, dest) : await movePath(src, dest); }
|
||||||
try {
|
catch { failed++; }
|
||||||
if (clipboard.op === 'copy') await copyPath(src, dest);
|
|
||||||
else await movePath(src, dest);
|
|
||||||
} catch {
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (failed) toast.error(`${failed} item${failed !== 1 ? 's' : ''} failed`);
|
if (failed) toast.error(`${failed} item${failed !== 1 ? 's' : ''} failed`);
|
||||||
else toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`);
|
else toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`);
|
||||||
|
|
@ -347,31 +486,27 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
void load(path);
|
void load(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
// New folder
|
// ── New folder ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleCreateFolder = async () => {
|
const handleCreateFolder = async () => {
|
||||||
const name = newFolderName.trim();
|
const name = newFolderName.trim();
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
try {
|
try {
|
||||||
await createFolder(joinPath(path, name), true);
|
await createFolder(joinPath(path, name), true);
|
||||||
toast.success(`Created folder "${name}"`);
|
toast.success(`Created folder "${name}"`);
|
||||||
setShowNewFolder(false);
|
setShowNewFolder(false); setNewFolderName('');
|
||||||
setNewFolderName('');
|
|
||||||
void load(path);
|
void load(path);
|
||||||
} catch (e: any) {
|
} catch (e: any) { toast.error(`Failed to create folder: ${e?.message ?? e}`); }
|
||||||
toast.error(`Failed to create folder: ${e?.message || e}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Upload
|
// ── Upload ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleUpload = async (file: File) => {
|
const handleUpload = async (file: File) => {
|
||||||
const target = joinPath(path, file.name);
|
|
||||||
try {
|
try {
|
||||||
await putFileContents(target, await file.arrayBuffer());
|
await putFileContents(joinPath(path, file.name), await file.arrayBuffer());
|
||||||
toast.success(`Uploaded ${file.name}`);
|
toast.success(`Uploaded ${file.name}`);
|
||||||
void load(path);
|
void load(path);
|
||||||
} catch (e: any) {
|
} catch (e: any) { toast.error(`Upload failed for ${file.name}: ${e?.message ?? e}`); }
|
||||||
toast.error(`Upload failed for ${file.name}: ${e?.message || e}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const onPickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -380,30 +515,26 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Drag & drop
|
// ── Drag & drop ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const onDragEnter = (e: React.DragEvent) => {
|
const onDragEnter = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault(); dragCounter.current++;
|
||||||
dragCounter.current++;
|
|
||||||
if (e.dataTransfer.types.includes('Files')) setDragOver(true);
|
if (e.dataTransfer.types.includes('Files')) setDragOver(true);
|
||||||
};
|
};
|
||||||
|
const onDragLeave = () => { if (--dragCounter.current <= 0) { dragCounter.current = 0; setDragOver(false); } };
|
||||||
const onDragLeave = () => {
|
|
||||||
dragCounter.current--;
|
|
||||||
if (dragCounter.current <= 0) { dragCounter.current = 0; setDragOver(false); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragOver = (e: React.DragEvent) => e.preventDefault();
|
const onDragOver = (e: React.DragEvent) => e.preventDefault();
|
||||||
|
|
||||||
const onDrop = (e: React.DragEvent) => {
|
const onDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault(); dragCounter.current = 0; setDragOver(false);
|
||||||
dragCounter.current = 0;
|
|
||||||
setDragOver(false);
|
|
||||||
Array.from(e.dataTransfer.files).forEach(f => void handleUpload(f));
|
Array.from(e.dataTransfer.files).forEach(f => void handleUpload(f));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Derived ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const pathParts = path.split('/').filter(Boolean);
|
const pathParts = path.split('/').filter(Boolean);
|
||||||
const selCount = selected.size;
|
const selCount = selected.size;
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative flex flex-col h-full"
|
className="relative flex flex-col h-full"
|
||||||
|
|
@ -412,7 +543,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* ── Header ── */}
|
||||||
<div className="bg-white border-b border-neutral-200 px-4 py-3 flex-shrink-0">
|
<div className="bg-white border-b border-neutral-200 px-4 py-3 flex-shrink-0">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
|
|
@ -471,7 +602,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter + sort bar */}
|
{/* ── Filter + sort bar ── */}
|
||||||
<div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2 flex items-center gap-2 flex-shrink-0">
|
<div className="bg-neutral-50 border-b border-neutral-200 px-4 py-2 flex items-center gap-2 flex-shrink-0">
|
||||||
<div className="relative flex-1 min-w-0">
|
<div className="relative flex-1 min-w-0">
|
||||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-400 pointer-events-none" />
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-neutral-400 pointer-events-none" />
|
||||||
|
|
@ -499,7 +630,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selection / clipboard bar */}
|
{/* ── Selection / clipboard bar ── */}
|
||||||
{(selCount > 0 || clipboard) && (
|
{(selCount > 0 || clipboard) && (
|
||||||
<div className="bg-blue-50 border-b border-blue-200 px-3 py-2 flex items-center gap-2 flex-shrink-0 flex-wrap text-sm">
|
<div className="bg-blue-50 border-b border-blue-200 px-3 py-2 flex items-center gap-2 flex-shrink-0 flex-wrap text-sm">
|
||||||
{selCount > 0 && (
|
{selCount > 0 && (
|
||||||
|
|
@ -538,12 +669,11 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File list */}
|
{/* ── File list ── */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="p-8 flex flex-col items-center gap-2 text-neutral-500 text-sm">
|
<div className="p-8 flex flex-col items-center gap-2 text-neutral-500 text-sm">
|
||||||
<Loader2 className="w-6 h-6 animate-spin" />
|
<Loader2 className="w-6 h-6 animate-spin" /> Loading…
|
||||||
Loading…
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -551,7 +681,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
<div className="p-4 text-sm">
|
<div className="p-4 text-sm">
|
||||||
<div className="text-red-600 mb-1">Failed to load directory</div>
|
<div className="text-red-600 mb-1">Failed to load directory</div>
|
||||||
<div className="text-neutral-500 text-xs break-all">{error}</div>
|
<div className="text-neutral-500 text-xs break-all">{error}</div>
|
||||||
<button onClick={() => void load(path)} className="mt-3 text-blue-600 inline-flex items-center gap-1 text-sm">
|
<button onClick={() => void load(path)} className="mt-3 text-blue-600 inline-flex items-center gap-1">
|
||||||
<RefreshCw className="w-3 h-3" /> Retry
|
<RefreshCw className="w-3 h-3" /> Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -602,6 +732,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
{renameEntry?.path === entry.path ? (
|
{renameEntry?.path === entry.path ? (
|
||||||
<input
|
<input
|
||||||
|
ref={renameInputRef}
|
||||||
value={renameName}
|
value={renameName}
|
||||||
onChange={e => setRenameName(e.target.value)}
|
onChange={e => setRenameName(e.target.value)}
|
||||||
onKeyDown={e => {
|
onKeyDown={e => {
|
||||||
|
|
@ -609,12 +740,10 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
if (e.key === 'Escape') setRenameEntry(null);
|
if (e.key === 'Escape') setRenameEntry(null);
|
||||||
}}
|
}}
|
||||||
onBlur={() => void commitRename()}
|
onBlur={() => void commitRename()}
|
||||||
ref={renameInputRef}
|
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
onFocus={e => {
|
onFocus={e => {
|
||||||
const dotIdx = renameName.lastIndexOf('.');
|
const dot = renameName.lastIndexOf('.');
|
||||||
const end = dotIdx > 0 ? dotIdx : renameName.length;
|
e.currentTarget.setSelectionRange(0, dot > 0 ? dot : renameName.length);
|
||||||
e.currentTarget.setSelectionRange(0, end);
|
|
||||||
}}
|
}}
|
||||||
className="w-full px-1 py-0 border border-blue-400 rounded text-sm"
|
className="w-full px-1 py-0 border border-blue-400 rounded text-sm"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|
@ -642,9 +771,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{visible.length === 0 && entries.length === 0 && (
|
{visible.length === 0 && entries.length === 0 && (
|
||||||
<div className="p-8 text-center text-neutral-500 text-sm">
|
<div className="p-8 text-center text-neutral-500 text-sm">Empty folder — drop files here to upload</div>
|
||||||
Empty folder — drop files here to upload
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{visible.length === 0 && entries.length > 0 && (
|
{visible.length === 0 && entries.length > 0 && (
|
||||||
<div className="p-8 text-center text-neutral-500 text-sm">No files match the filter</div>
|
<div className="p-8 text-center text-neutral-500 text-sm">No files match the filter</div>
|
||||||
|
|
@ -653,9 +780,9 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Drag overlay */}
|
{/* ── Drag overlay ── */}
|
||||||
{dragOver && (
|
{dragOver && (
|
||||||
<div className="absolute inset-0 bg-blue-200/60 flex items-center justify-center pointer-events-none z-40 rounded">
|
<div className="absolute inset-0 bg-blue-200/60 flex items-center justify-center pointer-events-none z-40">
|
||||||
<div className="bg-white rounded-xl shadow-lg px-8 py-6 flex flex-col items-center gap-3 text-blue-700">
|
<div className="bg-white rounded-xl shadow-lg px-8 py-6 flex flex-col items-center gap-3 text-blue-700">
|
||||||
<Upload className="w-10 h-10" />
|
<Upload className="w-10 h-10" />
|
||||||
<span className="font-medium">Drop to upload to {path}</span>
|
<span className="font-medium">Drop to upload to {path}</span>
|
||||||
|
|
@ -663,7 +790,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Per-entry action menu */}
|
{/* ── Per-entry action menu ── */}
|
||||||
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
|
<Dialog open={actionEntry !== null} onOpenChange={open => !open && setActionEntry(null)}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
@ -673,31 +800,57 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* Default open */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (actionEntry) void openEntry(actionEntry); setActionEntry(null); }}
|
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) void openEntry(e); }}
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
||||||
>
|
>
|
||||||
{actionEntry?.type === 'folder'
|
{actionEntry?.type === 'folder'
|
||||||
? <Folder className="w-4 h-4 text-blue-600" />
|
? <Folder className="w-4 h-4 text-blue-600" />
|
||||||
: <Eye className="w-4 h-4 text-blue-600" />}
|
: <Eye className="w-4 h-4 text-blue-600" />}
|
||||||
<span>{actionEntry?.type === 'folder' ? 'Open folder' : 'Open / View'}</span>
|
<span className="flex-1">{actionEntry?.type === 'folder' ? 'Open folder' : 'Open / View'}</span>
|
||||||
|
{actionEntry?.type === 'file' && (
|
||||||
|
<span className="text-xs text-neutral-400">{VIEWER_LABEL[defaultViewMode(actionEntry)]}</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Alternate viewers */}
|
||||||
|
{actionEntry?.type === 'file' && availableViewers(actionEntry)
|
||||||
|
.filter(m => m !== defaultViewMode(actionEntry))
|
||||||
|
.map(mode => {
|
||||||
|
const entry = actionEntry;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => { setActionEntry(null); void openEntry(entry, mode); }}
|
||||||
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<ViewerModeIcon mode={mode} className="w-4 h-4 text-neutral-500" />
|
||||||
|
<span>Open as {VIEWER_LABEL[mode]}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{actionEntry?.type === 'file' && (
|
{actionEntry?.type === 'file' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (actionEntry) void downloadEntry(actionEntry); setActionEntry(null); }}
|
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) void downloadEntry(e); }}
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" /> <span>Download</span>
|
<Download className="w-4 h-4" /> <span>Download</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{actionEntry?.type === 'file' && fileCategory(actionEntry) === 'disk' && (
|
{actionEntry?.type === 'file' && fileCategory(actionEntry) === 'disk' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (actionEntry) setMountEntry(actionEntry); setActionEntry(null); }}
|
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) setMountEntry(e); }}
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount on virtual drive</span>
|
<HardDrive className="w-4 h-4 text-amber-600" /> <span>Mount on virtual drive</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-neutral-100" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (actionEntry) startRename(actionEntry); }}
|
onClick={() => { if (actionEntry) startRename(actionEntry); }}
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-neutral-50 inline-flex items-center gap-3"
|
||||||
|
|
@ -717,7 +870,7 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
<Move className="w-4 h-4" /> <span>Move (Cut)</span>
|
<Move className="w-4 h-4" /> <span>Move (Cut)</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (actionEntry) void deleteEntry(actionEntry); setActionEntry(null); }}
|
onClick={() => { const e = actionEntry; setActionEntry(null); if (e) void deleteEntry(e); }}
|
||||||
className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded border border-red-200 hover:bg-red-50 text-red-700 inline-flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" /> <span>Delete</span>
|
<Trash2 className="w-4 h-4" /> <span>Delete</span>
|
||||||
|
|
@ -726,35 +879,62 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* File viewer overlay */}
|
{/* ── File viewer overlay ── */}
|
||||||
{viewEntry && (
|
{viewEntry && (
|
||||||
<div className="fixed inset-0 bg-black/80 z-50 flex flex-col">
|
<div className="fixed inset-0 bg-neutral-950 z-50 flex flex-col">
|
||||||
<div className="bg-white flex items-center px-4 py-3 gap-3 shadow flex-shrink-0">
|
<div className="bg-neutral-900 flex items-center px-4 py-2 gap-3 border-b border-neutral-700 flex-shrink-0">
|
||||||
<button onClick={closeViewer} className="p-1 rounded hover:bg-neutral-100">
|
<button onClick={closeViewer} className="p-1.5 rounded hover:bg-neutral-700">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5 text-white" />
|
||||||
</button>
|
</button>
|
||||||
<span className="font-medium truncate flex-1 text-sm">{viewEntry.name}</span>
|
<span className="font-medium truncate flex-1 text-sm text-white">{viewEntry.name}</span>
|
||||||
<button onClick={() => void downloadEntry(viewEntry)} className="p-1.5 rounded hover:bg-neutral-100" title="Download">
|
{/* Mode switcher */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{viewMode && availableViewers(viewEntry).map(mode => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
onClick={() => void switchViewMode(mode)}
|
||||||
|
title={VIEWER_LABEL[mode]}
|
||||||
|
className={`px-2 py-1 rounded text-xs inline-flex items-center gap-1 transition-colors ${
|
||||||
|
viewMode === mode
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-neutral-400 hover:bg-neutral-700 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ViewerModeIcon mode={mode} className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">{VIEWER_LABEL[mode]}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => void downloadEntry(viewEntry)} className="p-1.5 rounded hover:bg-neutral-700 text-neutral-300 hover:text-white" title="Download">
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto flex items-center justify-center bg-neutral-900">
|
|
||||||
|
<div className="flex-1 overflow-hidden bg-neutral-950">
|
||||||
{viewLoading && (
|
{viewLoading && (
|
||||||
<div className="flex items-center gap-2 text-neutral-400">
|
<div className="h-full flex items-center justify-center gap-2 text-neutral-400">
|
||||||
<Loader2 className="w-5 h-5 animate-spin" /> Loading…
|
<Loader2 className="w-5 h-5 animate-spin" /> Loading…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!viewLoading && viewImgUrl && (
|
{!viewLoading && viewMode === 'image' && viewImgUrl && (
|
||||||
|
<div className="h-full flex items-center justify-center overflow-auto p-4">
|
||||||
<img src={viewImgUrl} alt={viewEntry.name} className="max-w-full max-h-full object-contain" />
|
<img src={viewImgUrl} alt={viewEntry.name} className="max-w-full max-h-full object-contain" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{!viewLoading && viewText !== null && (
|
{!viewLoading && viewMode === 'hex' && viewHexData && (
|
||||||
<pre className="text-green-400 text-xs font-mono p-4 whitespace-pre-wrap break-all w-full h-full overflow-auto">{viewText}</pre>
|
<HexViewer data={viewHexData} />
|
||||||
|
)}
|
||||||
|
{!viewLoading && viewMode === 'markdown' && viewText !== null && (
|
||||||
|
<MarkdownViewer text={viewText} />
|
||||||
|
)}
|
||||||
|
{!viewLoading && (viewMode === 'text' || viewMode === 'json' || viewMode === 'xml') && viewText !== null && (
|
||||||
|
<CodeViewer text={viewText} mode={viewMode} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mount dialog */}
|
{/* ── Mount dialog ── */}
|
||||||
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
<Dialog open={mountEntry !== null} onOpenChange={open => !open && setMountEntry(null)}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
@ -762,21 +942,20 @@ export default function FileManager({ initialPath = '/', config, setConfig, onBa
|
||||||
<DialogDescription className="truncate">{mountEntry?.name}</DialogDescription>
|
<DialogDescription className="truncate">{mountEntry?.name}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{(() => {
|
{(() => {
|
||||||
const driveDevices = Object.entries(config?.iec?.devices?.drive ?? {})
|
const drives = Object.entries(config?.iec?.devices?.drive ?? {})
|
||||||
.filter(([key]) => key !== 'vdrive' && key !== 'rom')
|
.filter(([k]) => k !== 'vdrive' && k !== 'rom')
|
||||||
.map(([key, value]: [string, any]) => ({ deviceType: 'drive' as const, key, url: value?.url as string | undefined, enabled: !!value?.enabled }));
|
.map(([k, v]: [string, any]) => ({ type: 'drive' as const, key: k, url: v?.url as string | undefined, enabled: !!v?.enabled }));
|
||||||
const meatloafDevices = Object.entries(config?.iec?.devices?.meatloaf ?? {})
|
const meatloafs = Object.entries(config?.iec?.devices?.meatloaf ?? {})
|
||||||
.map(([key, value]: [string, any]) => ({ deviceType: 'meatloaf' as const, key, url: value?.url as string | undefined, enabled: !!value?.enabled }));
|
.map(([k, v]: [string, any]) => ({ type: 'meatloaf' as const, key: k, url: v?.url as string | undefined, enabled: !!v?.enabled }));
|
||||||
const devices = [...driveDevices, ...meatloafDevices];
|
const devices = [...drives, ...meatloafs];
|
||||||
if (devices.length === 0) {
|
if (!devices.length)
|
||||||
return <p className="text-sm text-neutral-500 text-center py-4">No drive devices found in config.</p>;
|
return <p className="text-sm text-neutral-500 text-center py-4">No drive devices found in config.</p>;
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{devices.map(dev => (
|
{devices.map(dev => (
|
||||||
<button
|
<button
|
||||||
key={`${dev.deviceType}-${dev.key}`}
|
key={`${dev.type}-${dev.key}`}
|
||||||
onClick={() => mountOnDevice(dev.deviceType, dev.key)}
|
onClick={() => mountOnDevice(dev.type, dev.key)}
|
||||||
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
className="w-full text-left px-4 py-3 rounded border border-neutral-200 hover:bg-blue-50 hover:border-blue-300 inline-flex items-center gap-3"
|
||||||
>
|
>
|
||||||
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
|
<HardDrive className={`w-5 h-5 flex-shrink-0 ${dev.enabled ? 'text-blue-500' : 'text-neutral-400'}`} />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user