"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<FormState>;
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<Tab>("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<string | undefined>();
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 (
<form id={formId} action={formAction}>
<input type="hidden" name="originalPath" value={originalPath} />
<input type="hidden" name="content" value={content} />
<div className="mb-4 flex flex-wrap items-center gap-3">
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="type-title text-ink">{repoName}</span>
<span className="text-ink3">/</span>
<MuField size="regular" className="min-w-0 flex-1 sm:max-w-md">
<MuField.Icon>
<FileIcon />
</MuField.Icon>
<MuField.Input
name="path"
required
value={path}
onChange={(e) => 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]"
/>
</MuField>
<span className="hidden items-center gap-1.5 type-meta text-ink2 sm:inline-flex">
in <RefChip>{branch}</RefChip>
</span>
</div>
<div className="flex gap-2">
<ButtonLink href={cancelHref}>Cancel</ButtonLink>
<Button type="button" variant="primary" icon={<CommitIcon />} onClick={() => setDialogOpen(true)} disabled={!path.trim()}>
Commit changes…
</Button>
</div>
</div>
<Tabs<Tab> value={activeTab} onValueChange={setTab}>
<Box className="overflow-hidden">
<div className="rule-b flex min-h-12 flex-wrap items-center gap-3 px-3 py-2">
<TabList<Tab>
aria-label="Editor view"
size="compact"
items={[
{ value: "edit", label: "Edit", icon: <CodeIcon /> },
{ value: "preview", label: "Preview", icon: <EyeIcon />, disabled: !isMarkdown },
]}
/>
{!isMarkdown && <span className="hidden type-meta text-ink3 md:inline">Preview is available for Markdown files</span>}
<span className="ml-auto type-readout text-ink2">{lang?.name ?? "Plain text"}</span>
<label className="flex cursor-pointer items-center gap-2 type-meta text-ink2">
<Switch size="small" checked={wrap} onCheckedChange={setWrap} /> Soft wrap
</label>
</div>
<TabPanel<Tab> value="edit" keepMounted>
<CodeMirror
value={content}
onChange={setContent}
theme={dark ? vectrumDark : vectrumLight}
extensions={extensions}
minHeight="520px"
basicSetup={{ highlightActiveLine: true, foldGutter: true, tabSize: 2 }}
aria-label="File contents"
className="py-2"
/>
</TabPanel>
<TabPanel<Tab> value="preview">
<div className="min-h-[520px] px-6 py-8 md:px-10">
<MarkdownPreview>{content}</MarkdownPreview>
</div>
</TabPanel>
</Box>
</Tabs>
<p className="mt-3 flex flex-wrap items-center gap-1 type-meta text-ink2">
Tip: press <Kbd>Ctrl</Kbd> <Kbd>S</Kbd> to commit.
{dirty && (
<span className="ml-2 inline-flex items-center gap-1.5 text-ink">
<Led kind="waiting" /> Unsaved changes
</span>
)}
</p>
<CommitDialog
formId={formId}
branch={branch}
baseSha={baseSha}
defaultMessage={`${verb} ${filename || "file"}`}
pending={pending}
error={state?.error}
open={dialogOpen}
onOpenChange={setDialogOpen}
/>
</form>
);
}