import { useState, useCallback, useRef, useEffect, useMemo } from "preact/hooks"; import { ToastProvider, useToast } from "./Toast"; import { BreadcrumbNav } from "./Breadcrumb"; import { FileTable } from "./FileTable"; import { FilterBar } from "./FilterBar"; import { GlobalSearch } from "./GlobalSearch"; import { Upload } from "./Upload"; import { Clipboard } from "./Clipboard"; import { OperationsPanel } from "./OperationsPanel"; import { useTheme } from "../hooks/useTheme"; import { useWebSocket } from "../hooks/useWebSocket"; import { useKeyboard } from "../hooks/useKeyboard"; import { OperationsContext, useOperationsProvider, useOperations } from "../hooks/useOperations"; import type { FileEntry, Breadcrumb, ListResponse } from "../types"; export function App() { const opsProvider = useOperationsProvider(); return ( ); } function AppInner() { const [entries, setEntries] = useState([]); const [currentPath, setCurrentPath] = useState("/"); const [breadcrumbs, setBreadcrumbs] = useState([{ name: "~", path: "/" }]); const [focusIdx, setFocusIdx] = useState(-1); const [clipboardText, setClipboardText] = useState(""); const [loading, setLoading] = useState(false); const [deleteConfirmIdx, setDeleteConfirmIdx] = useState(-1); const [renameIdx, setRenameIdx] = useState(-1); const [filterQuery, setFilterQuery] = useState(""); const [filterVisible, setFilterVisible] = useState(false); const [globalSearchOpen, setGlobalSearchOpen] = useState(false); const filterInputRef = useRef(null); const clipboardRef = useRef<{ focus: () => void } | null>(null); const { pref, cycleTheme } = useTheme(); const { showToast } = useToast(); const { operations, addClientOperation, updateClientOperation, completeClientOperation, handleServerOperation, handleOperationsSync } = useOperations(); // Fetch directory listing with loading state const fetchList = useCallback(async (path: string, resetFocus = true) => { setLoading(true); try { const res = await fetch(`/__api/list?path=${encodeURIComponent(path)}`); if (!res.ok) throw new Error("Failed to fetch"); const data: ListResponse = await res.json(); setEntries(data.entries); setCurrentPath(data.path); setBreadcrumbs(data.breadcrumbs); if (resetFocus) setFocusIdx(-1); setDeleteConfirmIdx(-1); setRenameIdx(-1); } catch (err) { console.error("Failed to fetch file list:", err); } finally { setLoading(false); } }, []); // Initial load - derive path from URL useEffect(() => { const path = decodeURIComponent(window.location.pathname); fetchList(path); }, [fetchList]); // Navigate to a directory const navigate = useCallback((path: string) => { window.history.pushState(null, "", path); setFilterQuery(""); fetchList(decodeURIComponent(path)); }, [fetchList]); // Handle browser back/forward useEffect(() => { const handler = () => { const path = decodeURIComponent(window.location.pathname); fetchList(path); }; window.addEventListener("popstate", handler); return () => window.removeEventListener("popstate", handler); }, [fetchList]); // WebSocket const onClipboardReceived = useCallback((text: string) => { setClipboardText(text); }, []); // Filtered entries for local filter const filteredEntries = useMemo(() => { if (!filterQuery.trim()) return entries; const q = filterQuery.toLowerCase(); return entries.filter((e) => e.name.toLowerCase().includes(q)); }, [entries, filterQuery]); const onFsChange = useCallback(() => { const path = decodeURIComponent(window.location.pathname); fetchList(path, false); }, [fetchList]); const { connected, sendClipboard } = useWebSocket({ onClipboard: onClipboardReceived, onFsChange, onOperation: handleServerOperation, onOperationsSync: handleOperationsSync, }); // Actions const handleShare = useCallback((resourcePath: string) => { const url = location.origin + resourcePath; navigator.clipboard.writeText(url).then(() => { showToast("Link copied"); }).catch(() => { const tmp = document.createElement("input"); tmp.value = url; document.body.appendChild(tmp); tmp.select(); document.execCommand("copy"); document.body.removeChild(tmp); showToast("Link copied"); }); }, [showToast]); const handleDelete = useCallback((resourcePath: string, name: string) => { // Capture the index before deletion for focus restore on undo const deletedIdx = entries.findIndex((e) => { const entryPath = currentPath + encodeURIComponent(e.name) + (e.isDirectory ? "/" : ""); return entryPath === resourcePath; }); fetch("/__api/delete", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: resourcePath }), }) .then((res) => { if (!res.ok) return res.json().then((d) => { throw new Error(d.error || "Delete failed"); }); return res.json(); }) .then((data) => { // Optimistically remove from list setEntries((prev) => prev.filter((e) => { const entryPath = currentPath + encodeURIComponent(e.name) + (e.isDirectory ? "/" : ""); return entryPath !== resourcePath; })); showToast(`Deleted ${name}`, { duration: 8000, danger: true, action: { label: "Undo", shortcutKey: "z", onClick: () => { fetch("/__api/restore", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ trashToken: data.trashToken }), }) .then((res) => { if (!res.ok) return res.json().then((d) => { throw new Error(d.error || "Restore failed"); }); return res.json(); }) .then(() => { const path = decodeURIComponent(window.location.pathname); fetchList(path, false).then(() => { if (deletedIdx >= 0) setFocusIdx(deletedIdx); }); }) .catch((err) => { showToast(`Error restoring: ${err.message}`); }); }, }, }); }) .catch((err) => { showToast(`Error: ${err.message}`); }); }, [currentPath, entries, showToast, fetchList]); const handleUploadComplete = useCallback(() => { const path = decodeURIComponent(window.location.pathname); fetchList(path); }, [fetchList]); // Clipboard const handleClipboardChange = useCallback((text: string) => { setClipboardText(text); }, []); // Keyboard navigation const getRowCount = useCallback(() => filteredEntries.length, [filteredEntries]); const onOpen = useCallback((idx: number) => { const entry = filteredEntries[idx]; if (!entry) return; if (entry.isDirectory) { navigate(`${currentPath}${encodeURIComponent(entry.name)}/`); } else { window.location.href = `${currentPath}${encodeURIComponent(entry.name)}`; } }, [filteredEntries, currentPath, navigate]); const onDownload = useCallback((idx: number) => { const entry = filteredEntries[idx]; if (!entry) return; const downloadPath = `/__download${currentPath}${encodeURIComponent(entry.name)}${entry.isDirectory ? "/" : ""}`; window.location.href = downloadPath; }, [filteredEntries, currentPath]); const onShareKb = useCallback((idx: number) => { const entry = filteredEntries[idx]; if (!entry) return; const resourcePath = `${currentPath}${encodeURIComponent(entry.name)}${entry.isDirectory ? "/" : ""}`; handleShare(resourcePath); }, [filteredEntries, currentPath, handleShare]); const onDeleteKb = useCallback((idx: number) => { // Show inline confirmation instead of immediate delete setDeleteConfirmIdx(idx); }, []); const handleDeleteConfirm = useCallback((idx: number) => { setDeleteConfirmIdx(idx); }, []); const handleDeleteCancel = useCallback(() => { setDeleteConfirmIdx(-1); }, []); const handleConfirmedDelete = useCallback((resourcePath: string, name: string) => { const deletedIdx = deleteConfirmIdx; setDeleteConfirmIdx(-1); handleDelete(resourcePath, name); // Adjust focus: move to previous item, or next, or clear setFocusIdx((prev) => { const newLen = entries.length - 1; if (newLen <= 0) return -1; if (deletedIdx >= newLen) return newLen - 1; return deletedIdx > 0 ? deletedIdx - 1 : 0; }); }, [handleDelete, deleteConfirmIdx, entries.length]); // Rename const handleRenameStart = useCallback((idx: number) => { setDeleteConfirmIdx(-1); setRenameIdx(idx); }, []); const handleRenameCancel = useCallback(() => { setRenameIdx(-1); }, []); const handleRenameSubmit = useCallback((idx: number, newName: string) => { const entry = entries[idx]; if (!entry) { setRenameIdx(-1); return; } const resourcePath = `${currentPath}${encodeURIComponent(entry.name)}${entry.isDirectory ? "/" : ""}`; fetch("/__api/rename", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: resourcePath, newName }), }) .then((res) => { if (!res.ok) return res.json().then((d) => { throw new Error(d.error || "Rename failed"); }); return res.json(); }) .then(() => { setRenameIdx(-1); const path = decodeURIComponent(window.location.pathname); fetchList(path, false); setFocusIdx(idx); showToast(`Renamed to ${newName}`); }) .catch((err) => { showToast(`Error: ${err.message}`); setRenameIdx(-1); }); }, [entries, currentPath, fetchList, showToast]); const onRenameKb = useCallback((idx: number) => { setDeleteConfirmIdx(-1); setRenameIdx(idx); }, []); // Clone const handleClone = useCallback((resourcePath: string) => { fetch("/__api/clone", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: resourcePath }), }) .then((res) => { if (!res.ok) return res.json().then((d) => { throw new Error(d.error || "Clone failed"); }); return res.json(); }) .then((data) => { if (!data.async) { // Instant clone - refresh immediately const path = decodeURIComponent(window.location.pathname); fetchList(path, false); } showToast(`Created ${data.newName}`); }) .catch((err) => { showToast(`Error: ${err.message}`); }); }, [fetchList, showToast]); const onCloneKb = useCallback((idx: number) => { const entry = filteredEntries[idx]; if (!entry) return; const resourcePath = `${currentPath}${encodeURIComponent(entry.name)}${entry.isDirectory ? "/" : ""}`; handleClone(resourcePath); }, [filteredEntries, currentPath, handleClone]); const uploadRef = useRef<{ triggerUpload: () => void } | null>(null); const onUpload = useCallback(() => { // Trigger upload via the hidden file input const input = document.querySelector('input[type="file"]') as HTMLInputElement; input?.click(); }, []); const [clipboardCollapsed, setClipboardCollapsed] = useState(false); const onFocusClipboard = useCallback(() => { setClipboardCollapsed(false); setTimeout(() => { const ta = document.querySelector(".clipboard-body textarea") as HTMLTextAreaElement; ta?.focus(); }, 0); }, []); const onToggleClipboard = useCallback(() => { setClipboardCollapsed((prev) => !prev); }, []); const onNavigateUp = useCallback(() => { if (breadcrumbs.length > 1) { const parent = breadcrumbs[breadcrumbs.length - 2]; navigate(parent.path); } }, [breadcrumbs, navigate]); const onFilter = useCallback(() => { setFilterVisible(true); // Focus after render setTimeout(() => filterInputRef.current?.focus(), 0); }, []); const onGlobalSearch = useCallback(() => { setGlobalSearchOpen(true); }, []); const { hintRef, showHintPanel } = useKeyboard({ getRowCount, focusIdx, setFocusIdx, onOpen, onDownload, onShare: onShareKb, onDelete: onDeleteKb, onRename: onRenameKb, onClone: onCloneKb, onUpload, onFocusClipboard, onToggleClipboard, onCycleTheme: cycleTheme, onNavigateUp, onFilter, onGlobalSearch, deleteConfirmIdx, renameIdx, globalSearchOpen, onDeleteConfirmYes: () => { const entry = filteredEntries[deleteConfirmIdx]; if (!entry) return; const resourcePath = `${currentPath}${encodeURIComponent(entry.name)}${entry.isDirectory ? "/" : ""}`; handleConfirmedDelete(resourcePath, entry.name); }, onDeleteConfirmNo: handleDeleteCancel, }); // Theme label const themeLabels: Record = { dark: "Dark", light: "Light", system: "Auto" }; return (
{themeLabels[pref] || ""}
setFilterVisible(false)} totalCount={entries.length} matchCount={filteredEntries.length} inputRef={(el) => { filterInputRef.current = el; }} />
served by folderex
{/* Keyboard hint panel */}
↑↓ or jk navigate Enter open / filter Ctrl+/ search r rename p duplicate d download c copy link x delete u upload i clipboard s toggle clipboard Esc exit
setGlobalSearchOpen(false)} onNavigate={navigate} />
); }