Vectrum
Sign In
JjohannSoAdd wikisHistory
199 lines · 7.3 KB · TypeScript
"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. */
export 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>
  );
}