import { useState, useEffect, useCallback, useRef } from "preact/hooks"; import { marked } from "marked"; interface Entry { name: string; type: "file" | "dir"; } interface FileData { name: string; content: string; format: "md" | "html" | "txt"; checkpoint: string | null; } type DiffLine = { type: "add" | "del" | "ctx"; oldLn: number | null; newLn: number | null; text: string }; function computeDiff(oldText: string, newText: string): DiffLine[] { const oldLines = oldText.split("\n"); const newLines = newText.split("\n"); const m = oldLines.length, n = newLines.length; const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); for (let i = 1; i <= m; i++) for (let j = 1; j <= n; j++) dp[i][j] = oldLines[i - 1] === newLines[j - 1] ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); const result: DiffLine[] = []; let i = m, j = n; while (i > 0 || j > 0) { if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { result.push({ type: "ctx", oldLn: i, newLn: j, text: oldLines[i - 1] }); i--; j--; } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { result.push({ type: "add", oldLn: null, newLn: j, text: newLines[j - 1] }); j--; } else { result.push({ type: "del", oldLn: i, newLn: null, text: oldLines[i - 1] }); i--; } } return result.reverse(); } export default function Notebook() { const [path, setPath] = useState(""); const [entries, setEntries] = useState([]); const [file, setFile] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [editing, setEditing] = useState(false); const [editContent, setEditContent] = useState(""); const [saving, setSaving] = useState(false); const [checkpoint, setCheckpoint] = useState(null); const [creating, setCreating] = useState<"file" | "folder" | null>(null); const [newName, setNewName] = useState(""); const nameInputRef = useRef(null); const editorRef = useRef(null); const load = useCallback(async (p: string, pushState = true) => { setLoading(true); setError(""); setFile(null); setEditing(false); setCreating(null); setCheckpoint(null); try { const res = await fetch(`/api/notebook?path=${encodeURIComponent(p)}`); if (!res.ok) throw new Error(await res.text()); const data = await res.json(); if (data.type === "dir") { setEntries(data.entries); setFile(null); } else { setFile(data); setCheckpoint(data.checkpoint ?? null); setEntries([]); } setPath(p); if (pushState) { const url = p ? `/notebook?path=${encodeURIComponent(p)}` : "/notebook"; history.pushState({ path: p }, "", url); } } catch (e: any) { setError(e.message || "Failed to load"); } finally { setLoading(false); } }, []); useEffect(() => { const params = new URLSearchParams(window.location.search); const initial = params.get("path") || ""; load(initial, false); const onPop = (e: PopStateEvent) => { const p = e.state?.path ?? ""; load(p, false); }; window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, []); useEffect(() => { if (creating && nameInputRef.current) nameInputRef.current.focus(); }, [creating]); useEffect(() => { if (editing && editorRef.current) editorRef.current.focus(); }, [editing]); const segments = path ? path.split("/") : []; const navigate = (name: string) => { const next = path ? `${path}/${name}` : name; load(next); }; const goUp = () => { const parent = segments.slice(0, -1).join("/"); load(parent); }; const goTo = (index: number) => { const target = segments.slice(0, index + 1).join("/"); load(target); }; const startEdit = () => { if (!file) return; setEditContent(file.content); setEditing(true); }; const cancelEdit = () => { setEditing(false); }; const saveFile = async () => { setSaving(true); try { const res = await fetch("/api/notebook", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "save", path, content: editContent }), }); if (!res.ok) throw new Error(await res.text()); setFile({ ...file!, content: editContent }); setEditing(false); } catch (e: any) { setError(e.message || "Failed to save"); } finally { setSaving(false); } }; const handleCreate = async () => { const name = newName.trim(); if (!name) return; const fullName = creating === "file" && !name.includes(".") ? `${name}.md` : name; const fullPath = path ? `${path}/${fullName}` : fullName; setSaving(true); try { const res = await fetch("/api/notebook", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: creating === "folder" ? "mkdir" : "create", path: fullPath, content: creating === "file" ? `# ${name}\n\n` : undefined, }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || "Failed to create"); } setCreating(null); setNewName(""); if (creating === "file") { load(fullPath); } else { load(path); } } catch (e: any) { setError(e.message || "Failed to create"); } finally { setSaving(false); } }; const deleteItem = async (deletePath: string, name: string) => { if (!confirm(`Delete "${name}"?`)) return; try { const res = await fetch("/api/notebook", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "delete", path: deletePath }), }); if (!res.ok) throw new Error(await res.text()); // If deleting current file, go back to parent if (deletePath === path) { goUp(); } else { load(path); } } catch (e: any) { setError(e.message || "Failed to delete"); } }; const hasPendingChanges = checkpoint !== null && checkpoint !== file?.content; const approveCheckpoint = async () => { setSaving(true); try { const res = await fetch("/api/notebook", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "approve", path }), }); if (!res.ok) throw new Error(await res.text()); setCheckpoint(file!.content); } catch (e: any) { setError(e.message || "Failed to approve"); } finally { setSaving(false); } }; const discardChanges = async () => { setSaving(true); try { const res = await fetch("/api/notebook", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "discard", path }), }); if (!res.ok) throw new Error(await res.text()); const data = await res.json(); setFile({ ...file!, content: data.content }); setCheckpoint(data.content); } catch (e: any) { setError(e.message || "Failed to discard"); } finally { setSaving(false); } }; const renderDiff = () => { if (!file || !checkpoint) return null; const lines = computeDiff(checkpoint, file.content); return (
{lines.map((line, i) => (
{line.oldLn ?? ""} {line.newLn ?? ""} {line.type === "add" ? "+" : line.type === "del" ? "-" : " "} {line.text}
))}
); }; const renderContent = () => { if (!file) return null; if (file.format === "md") { const html = marked.parse(file.content) as string; return
; } if (file.format === "html") { return
; } return
{file.content}
; }; return (
{/* Breadcrumb */} {loading &&

Loading...

} {error &&

{error}

} {/* FolderView */} {!loading && !error && !file && (
{/* Toolbar */}
{path && ( )}
{/* Create form */} {creating && (
setNewName((e.target as HTMLInputElement).value)} onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); if (e.key === "Escape") { setCreating(null); setNewName(""); } }} />
)} {entries.length === 0 && !creating && (

No files yet

)} {entries.map((e) => (
))}
)} {/* FileView */} {!loading && !error && file && (
{hasPendingChanges ? ( <> ) : !editing ? ( <> {file.format === "md" && ( )} ) : null}

{file.name}

{hasPendingChanges ? ( renderDiff() ) : editing ? (