Add GitHub-style code preview + UE icon in context menu

- New CodeView: syntax-highlighted source (highlight.js, bundled offline)
  with line numbers for new files, colored unified diff for edits. Covers
  cpp/c/h, cs, py, js/ts, json/uproject, xml, ini, glsl. hljs tokens mapped
  to the app palette so it's theme-aware. Replaces the bare DiffView.
- Context-menu items now support icons; the Launch Unreal Engine item shows
  the UE mark.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 15:45:03 +03:00
parent 2feb7ca5e1
commit e9868c5b2e
6 changed files with 181 additions and 36 deletions

120
src/CodeView.tsx Normal file
View File

@ -0,0 +1,120 @@
import { useEffect, useMemo, useState } from "react";
import hljs from "highlight.js/lib/core";
import cpp from "highlight.js/lib/languages/cpp";
import csharp from "highlight.js/lib/languages/csharp";
import python from "highlight.js/lib/languages/python";
import javascript from "highlight.js/lib/languages/javascript";
import typescript from "highlight.js/lib/languages/typescript";
import json from "highlight.js/lib/languages/json";
import xml from "highlight.js/lib/languages/xml";
import ini from "highlight.js/lib/languages/ini";
import glsl from "highlight.js/lib/languages/glsl";
import { p4, OpenedFile, splitPath } from "./p4";
import { t } from "./i18n";
hljs.registerLanguage("cpp", cpp);
hljs.registerLanguage("csharp", csharp);
hljs.registerLanguage("python", python);
hljs.registerLanguage("javascript", javascript);
hljs.registerLanguage("typescript", typescript);
hljs.registerLanguage("json", json);
hljs.registerLanguage("xml", xml);
hljs.registerLanguage("ini", ini);
hljs.registerLanguage("glsl", glsl);
// file extension -> highlight.js language (empty = no highlighting, plain text)
function langOf(name: string): string {
const e = (name.split(".").pop() || "").toLowerCase();
if (/^(cpp|cc|cxx|c\+\+|hpp|hxx|h|hh|inl|c|ino)$/.test(e)) return "cpp";
if (e === "cs") return "csharp";
if (e === "py") return "python";
if (/^(js|jsx|mjs|cjs)$/.test(e)) return "javascript";
if (/^(ts|tsx)$/.test(e)) return "typescript";
if (/^(json|uproject|uplugin)$/.test(e)) return "json";
if (/^(xml|html|htm|svg|xaml)$/.test(e)) return "xml";
if (/^(ini|cfg|conf|config|toml|editorconfig)$/.test(e)) return "ini";
if (/^(usf|ush|hlsl|glsl|shader|frag|vert)$/.test(e)) return "glsl";
return "";
}
const LANG_LABEL: Record<string, string> = { cpp: "C++", csharp: "C#", python: "Python", javascript: "JavaScript", typescript: "TypeScript", json: "JSON", xml: "XML", ini: "INI", glsl: "GLSL" };
function esc(s: string): string {
return s.replace(/[&<>]/g, (c) => (c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"));
}
const MAX = 800_000; // don't try to render/highlight enormous files
// Preview source files like GitHub: syntax-highlighted content with line numbers
// for new files, or a colored unified diff for edits.
export default function CodeView({ file }: { file: OpenedFile }) {
const dp = file.depotFile || "";
const { name } = splitPath(dp);
const action = (file.action || "").toLowerCase();
const [diff, setDiff] = useState<string | null>(null);
const [code, setCode] = useState<string | null>(null);
const [err, setErr] = useState("");
const [tooBig, setTooBig] = useState(false);
useEffect(() => {
let live = true;
setDiff(null); setCode(null); setErr(""); setTooBig(false);
// edits show a diff; adds have none, so fetch it but don't depend on it
p4.diff(dp).then((d) => live && setDiff(d)).catch(() => live && setDiff(""));
p4.readDepot(dp).then((buf) => {
if (!live) return;
if (buf.byteLength > MAX) { setTooBig(true); setCode(""); return; }
const bytes = new Uint8Array(buf);
// crude binary sniff: a NUL in the first chunk
if (bytes.subarray(0, 8000).includes(0)) { setCode(null); setErr("binary"); return; }
setCode(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
}).catch((e) => live && setErr(String(e)));
return () => { live = false; };
}, [dp]);
const lang = langOf(name);
const highlighted = useMemo(() => {
if (code == null) return "";
try { return lang ? hljs.highlight(code, { language: lang, ignoreIllegals: true }).value : esc(code); }
catch { return esc(code); }
}, [code, lang]);
if (err === "binary") return <div className="nofile">{t("Binary file — no code preview.")}</div>;
if (err) return <div className="nofile" style={{ color: "var(--del)" }}>{err}</div>;
if (tooBig) return <div className="nofile">{t("File is too large to preview.")}</div>;
// an edited file with a real diff → show the diff
if (diff && diff.trim() && action.includes("edit")) {
const lines = diff.split("\n");
return (
<div className="right-body">
<div className="codebar"><span className="cb-badge diff">{t("Diff")}</span><span className="cb-name">{name}</span></div>
<div className="diff">
{lines.map((ln, i) => {
let cls = "";
if (ln.startsWith("@@")) cls = "hunk";
else if (ln.startsWith("+") && !ln.startsWith("+++")) cls = "add";
else if (ln.startsWith("-") && !ln.startsWith("---")) cls = "del";
return <div key={i} className={"dl " + cls}><span className="g" /><span className="c">{ln || " "}</span></div>;
})}
</div>
</div>
);
}
// otherwise (new file, or edit with no text diff) → full content with line numbers
if (code == null) return <div className="nofile">{t("Loading…")}</div>;
const total = code.length ? code.replace(/\n$/, "").split("\n").length : 0;
return (
<div className="right-body">
<div className="codebar">
<span className={"cb-badge" + (action.includes("add") ? " add" : "")}>{action.includes("add") ? t("New file") : lang ? (LANG_LABEL[lang] || "") : t("Text")}</span>
<span className="cb-name">{name}</span>
<span className="cb-lines">{t("{n} lines", { n: total })}</span>
</div>
<div className={"code" + (action.includes("add") ? " added" : "")}>
<div className="code-gutter">{Array.from({ length: total }, (_, i) => <span key={i}>{i + 1}</span>)}</div>
<pre className="code-body"><code className="hljs" dangerouslySetInnerHTML={{ __html: highlighted }} /></pre>
</div>
</div>
);
}