import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Folder, File, ArrowUp, List, LayoutGrid, ArrowUpDown, Clock, FileType, Type, ChevronRight, Info, Loader2, Download, ExternalLink, X, ChevronLeft, ZoomIn, ZoomOut, Image, Film, Music, FileCode, FileText as FileTextIcon, Archive, FileSpreadsheet, Presentation } from 'lucide-react'; import type { FileBrowserWidgetProps } from '@polymech/shared'; import { useAuth } from '@/hooks/useAuth'; // ── Types ──────────────────────────────────────────────────────── interface INode { name: string; path: string; size: number; mtime?: number; mime?: string; parent: string; type: string; } type SortKey = 'name' | 'ext' | 'date' | 'type'; type MimeCategory = 'dir' | 'image' | 'video' | 'audio' | 'code' | 'document' | 'archive' | 'spreadsheet' | 'presentation' | 'other'; // ── MIME helpers ───────────────────────────────────────────────── const IMAGE_EXTS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff', 'tif']); const VIDEO_EXTS = new Set(['mp4', 'mov', 'webm', 'mkv', 'avi', 'flv', 'wmv', 'm4v', 'ogv']); const AUDIO_EXTS = new Set(['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a', 'wma', 'opus']); const CODE_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'h', 'hpp', 'cs', 'swift', 'kt', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', 'lua', 'php', 'sql', 'r', 'html', 'htm', 'css', 'scss', 'sass', 'less', 'json', 'yaml', 'yml', 'toml', 'xml', 'vue', 'svelte']); const DOC_EXTS = new Set(['md', 'txt', 'rtf', 'pdf', 'doc', 'docx', 'odt', 'tex', 'log']); const ARCHIVE_EXTS = new Set(['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'zst', 'tgz']); const SPREADSHEET_EXTS = new Set(['xls', 'xlsx', 'csv', 'ods', 'tsv']); const PRESENTATION_EXTS = new Set(['ppt', 'pptx', 'odp', 'key']); function getExt(name: string): string { const i = name.lastIndexOf('.'); return i > 0 ? name.slice(i + 1).toLowerCase() : ''; } function getMimeCategory(node: INode): MimeCategory { if (node.mime === 'inode/directory' || node.type === 'dir') return 'dir'; const mime = node.mime || ''; if (mime.startsWith('image/')) return 'image'; if (mime.startsWith('video/')) return 'video'; if (mime.startsWith('audio/')) return 'audio'; if (mime.startsWith('text/x-') || mime.includes('javascript') || mime.includes('typescript') || mime.includes('json') || mime.includes('xml') || mime.includes('yaml')) return 'code'; const ext = getExt(node.name); if (IMAGE_EXTS.has(ext)) return 'image'; if (VIDEO_EXTS.has(ext)) return 'video'; if (AUDIO_EXTS.has(ext)) return 'audio'; if (CODE_EXTS.has(ext)) return 'code'; if (SPREADSHEET_EXTS.has(ext)) return 'spreadsheet'; if (PRESENTATION_EXTS.has(ext)) return 'presentation'; if (ARCHIVE_EXTS.has(ext)) return 'archive'; if (DOC_EXTS.has(ext)) return 'document'; return 'other'; } const CATEGORY_STYLE: Record; color: string }> = { dir: { icon: Folder, color: '#60a5fa' }, image: { icon: Image, color: '#22c55e' }, video: { icon: Film, color: '#ef4444' }, audio: { icon: Music, color: '#8b5cf6' }, code: { icon: FileCode, color: '#3b82f6' }, document: { icon: FileTextIcon, color: '#f59e0b' }, archive: { icon: Archive, color: '#eab308' }, spreadsheet: { icon: FileSpreadsheet, color: '#16a34a' }, presentation: { icon: Presentation, color: '#ec4899' }, other: { icon: File, color: '#94a3b8' }, }; function NodeIcon({ node, size = 14 }: { node: INode; size?: number }) { const cat = getMimeCategory(node); const { icon: Icon, color } = CATEGORY_STYLE[cat]; return ; } // ── Format helpers ─────────────────────────────────────────────── function formatSize(bytes: number): string { if (bytes === 0) return '—'; const units = ['B', 'KB', 'MB', 'GB']; const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); const v = bytes / Math.pow(1024, i); return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`; } function formatDate(ts?: number): string { if (!ts) return '—'; const d = new Date(ts); return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) + ' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); } // ── Sort ───────────────────────────────────────────────────────── function sortNodes(nodes: INode[], sortBy: SortKey, asc: boolean): INode[] { const dirs = nodes.filter(n => n.mime === 'inode/directory' || n.type === 'dir'); const files = nodes.filter(n => n.mime !== 'inode/directory' && n.type !== 'dir'); const cmp = (a: INode, b: INode): number => { let r = 0; switch (sortBy) { case 'name': r = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }); break; case 'ext': r = getExt(a.name).localeCompare(getExt(b.name)) || a.name.localeCompare(b.name); break; case 'date': r = (a.mtime ?? 0) - (b.mtime ?? 0); break; case 'type': r = getMimeCategory(a).localeCompare(getMimeCategory(b)) || a.name.localeCompare(b.name); break; } return asc ? r : -r; }; dirs.sort(cmp); files.sort(cmp); return [...dirs, ...files]; } // ── Thumbnail helper ───────────────────────────────────────────── function ThumbPreview({ node, mount, height = 64, tokenParam = '' }: { node: INode; mount: string; height?: number; tokenParam?: string }) { const cat = getMimeCategory(node); const baseUrl = `/api/vfs/get/${encodeURIComponent(mount)}/${node.path}`; const fileUrl = tokenParam ? `${baseUrl}?${tokenParam}` : baseUrl; if (cat === 'image') { return {node.name}; } if (cat === 'video') { return (
); } return ; } // ── Toolbar button ─────────────────────────────────────────────── const TB_BTN: React.CSSProperties = { background: 'none', border: 'none', cursor: 'pointer', padding: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--muted-foreground, #94a3b8)', borderRadius: 4, }; const TB_BTN_ACTIVE: React.CSSProperties = { ...TB_BTN, color: 'var(--foreground, #e2e8f0)' }; const TB_SEP: React.CSSProperties = { width: 1, height: 18, background: 'var(--border, #334155)', flexShrink: 0 }; // ── Main Component ─────────────────────────────────────────────── const FileBrowserWidget: React.FC = (props) => { const { mount = 'root', path: initialPath = '/', glob = '*.*', mode = 'simple', viewMode: initialViewMode = 'list', sortBy: initialSort = 'name', showToolbar = true, } = props; const { session } = useAuth(); const accessToken = session?.access_token; const [currentPath, setCurrentPath] = useState(initialPath); const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [viewMode, setViewMode] = useState<'list' | 'thumbs'>(initialViewMode); const [sortBy, setSortBy] = useState(initialSort); const [sortAsc, setSortAsc] = useState(true); const [focusIdx, setFocusIdx] = useState(-1); const [selected, setSelected] = useState(null); const listRef = useRef(null); const containerRef = useRef(null); // ── Zoom (persisted) ───────────────────────────────────────── const [thumbSize, setThumbSize] = useState(() => { const v = localStorage.getItem('fb-thumb-size'); return v ? Math.max(60, Math.min(200, Number(v))) : 100; }); const [fontSize, setFontSize] = useState(() => { const v = localStorage.getItem('fb-font-size'); return v ? Math.max(10, Math.min(18, Number(v))) : 14; }); const zoomIn = () => { if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.min(200, s + 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); else setFontSize(s => { const n = Math.min(18, s + 1); localStorage.setItem('fb-font-size', String(n)); return n; }); }; const zoomOut = () => { if (viewMode === 'thumbs') setThumbSize(s => { const n = Math.max(60, s - 20); localStorage.setItem('fb-thumb-size', String(n)); return n; }); else setFontSize(s => { const n = Math.max(10, s - 1); localStorage.setItem('fb-font-size', String(n)); return n; }); }; // ── Fetch ─────────────────────────────────────────────────── const fetchDir = useCallback(async (dirPath: string) => { setLoading(true); setError(null); setSelected(null); setFocusIdx(-1); try { const clean = dirPath.replace(/^\/+/, ''); const base = clean ? `/api/vfs/ls/${encodeURIComponent(mount)}/${clean}` : `/api/vfs/ls/${encodeURIComponent(mount)}`; const url = glob && glob !== '*.*' ? `${base}?glob=${encodeURIComponent(glob)}` : base; const headers: Record = {}; if (accessToken) headers['Authorization'] = `Bearer ${accessToken}`; const res = await fetch(url, { headers }); if (!res.ok) { const body = await res.json().catch(() => ({})); throw new Error(body.error || `HTTP ${res.status}`); } setNodes(await res.json()); } catch (e: any) { setError(e.message || 'Failed to load directory'); setNodes([]); } finally { setLoading(false); } }, [mount, glob, accessToken]); useEffect(() => { fetchDir(currentPath); }, [currentPath, fetchDir]); useEffect(() => { setCurrentPath(initialPath); }, [initialPath]); // Build a URL with optional auth token for /