"use client"; import { useActionState, useEffect, useId, useState } from "react"; import CodeMirror, { EditorView, type Extension } from "@uiw/react-codemirror"; import { LanguageDescription } from "@codemirror/language"; import { languages } from "@codemirror/language-data"; import { Field as MuField, Led, Switch, TabList, TabPanel, Tabs } from "@unlocalhosted/metalui"; import type { FormState } from "@/app/actions/types"; import { CommitDialog } from "./commit-dialog"; import { vectrumDark, vectrumLight } from "./editor-theme"; import { CodeIcon, CommitIcon, EyeIcon, FileIcon } from "./icons"; import { MarkdownPreview } from "./markdown-preview"; import { useDarkMode } from "./theme-toggle"; import { Box, Button, ButtonLink, Kbd, RefChip } from "./ui"; type Tab = "edit" | "preview"; /** Loads CodeMirror language support based on the file name. */ function useLanguage(filename: string) { const [lang, setLang] = useState<{ name: string; ext: Extension } | null>(null); useEffect(() => { let cancelled = false; const desc = LanguageDescription.matchFilename(languages, filename); if (!desc) { Promise.resolve().then(() => !cancelled && setLang(null)); } else { desc.load().then((support) => !cancelled && setLang({ name: desc.name, ext: support })); } return () => { cancelled = true; }; }, [filename]); return lang; } export function FileEditor({ action, repoName, branch, baseSha, originalPath, initialPath, initialContent, cancelHref, }: { action: (prev: FormState, formData: FormData) => Promise; repoName: string; branch: string; baseSha: string; originalPath: string; initialPath: string; initialContent: string; cancelHref: string; }) { const formId = useId(); const [state, formAction, pending] = useActionState(action, undefined); const [path, setPath] = useState(initialPath); const [content, setContent] = useState(initialContent); const [tab, setTab] = useState("edit"); const [wrap, setWrap] = useState(false); const [dialogOpen, setDialogOpen] = useState(false); const dark = useDarkMode(); const filename = path.split("/").pop() ?? ""; const lang = useLanguage(filename); const isMarkdown = /\.(md|markdown|mdx)$/i.test(filename); const activeTab: Tab = isMarkdown ? tab : "edit"; const dirty = content !== initialContent || path !== initialPath; // Re-open the dialog to show errors returned by the server. const [shownError, setShownError] = useState(); if (state?.error && state.error !== shownError) { setShownError(state.error); setDialogOpen(true); } // Ctrl/Cmd+S opens the commit dialog; warn before leaving with unsaved edits. useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); setDialogOpen(true); } }; const onBeforeUnload = (e: BeforeUnloadEvent) => { if (dirty && !pending) e.preventDefault(); }; window.addEventListener("keydown", onKey); window.addEventListener("beforeunload", onBeforeUnload); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("beforeunload", onBeforeUnload); }; }, [dirty, pending]); const verb = !originalPath ? "Create" : originalPath !== path ? "Rename" : "Update"; const extensions = [...(lang ? [lang.ext] : []), ...(wrap ? [EditorView.lineWrapping] : [])]; return (
{repoName} / setPath(e.target.value)} // Enter opens the commit dialog rather than committing with no message. onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); if (path.trim()) setDialogOpen(true); } }} placeholder="Name your file…" aria-label="File path" autoFocus={!initialPath} className="font-mono text-[12px]" /> in {branch}
Cancel
value={activeTab} onValueChange={setTab}>
aria-label="Editor view" size="compact" items={[ { value: "edit", label: "Edit", icon: }, { value: "preview", label: "Preview", icon: , disabled: !isMarkdown }, ]} /> {!isMarkdown && Preview is available for Markdown files} {lang?.name ?? "Plain text"}
value="edit" keepMounted> value="preview">
{content}

Tip: press Ctrl S to commit. {dirty && ( Unsaved changes )}

); }