- Tabbed view panel: File Viewer / File Tree / File Locks, header hidden at one tab, re-openable from Window, state persisted - Right-click Workspace/Working-folder zones -> Show in File Tree (focuses the right side) - Commit weight shown in History (p4 sizes -s //...@=CH) - Rich code editor (highlighted underlay + gutter) and clean spec-editor surfaces (no more grey notepad) - System tray (close-to-tray, tray menu Quit) + native OS notifications for teammate submits, even when hidden - 'You're behind the server' banner + native notification (p4 sync -n), rechecked only when the server advances - Audio & video preview with playback (media-src blob CSP); File Locks dockable into the view panel - Backend: depot_ls/fs_ls, p4_change_sizes, p4_behind; tauri-plugin-notification + tray-icon Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
201 lines
10 KiB
TypeScript
201 lines
10 KiB
TypeScript
import { useEffect, useMemo, useRef, 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 === "&" ? "&" : c === "<" ? "<" : ">"));
|
||
}
|
||
|
||
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. Working-copy code files can
|
||
// be edited in place (built-in editor) and saved before committing.
|
||
export default function CodeView({ file, onFlash }: { file: OpenedFile; onFlash?: (text: string, err?: boolean) => void }) {
|
||
const dp = file.depotFile || "";
|
||
const spec = file._spec || ""; // set → read this exact submitted revision
|
||
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);
|
||
const [editing, setEditing] = useState(false);
|
||
const [draft, setDraft] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [reload, setReload] = useState(0);
|
||
|
||
useEffect(() => {
|
||
let live = true;
|
||
setDiff(null); setCode(null); setErr(""); setTooBig(false); setEditing(false);
|
||
// working-copy edits show a diff against the depot; historical revisions just
|
||
// show their full content (no meaningful working diff to fetch).
|
||
if (!spec) p4.diff(dp).then((d) => live && setDiff(d)).catch(() => live && setDiff(""));
|
||
const load = spec ? p4.printDepot(spec) : p4.readDepot(dp);
|
||
load.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, spec, reload]);
|
||
|
||
// built-in editor: editable for a code file in the working copy (not history/binary/huge)
|
||
const canEdit = !spec && !tooBig && err !== "binary" && code != null;
|
||
function startEdit() { setDraft(code || ""); setEditing(true); }
|
||
function cancelEdit() { setEditing(false); }
|
||
async function save() {
|
||
setSaving(true);
|
||
try {
|
||
await p4.writeDepot(dp, draft);
|
||
setCode(draft);
|
||
setEditing(false);
|
||
onFlash?.(t("Saved {name}", { name }));
|
||
setReload((r) => r + 1); // refresh diff/status after the on-disk change
|
||
} catch (e) { onFlash?.(String(e), true); }
|
||
finally { setSaving(false); }
|
||
}
|
||
const pencil = <svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="m14.5 5.5 4 4M4 20l1-4L16.5 4.5a2 2 0 0 1 3 3L8 19l-4 1Z" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>;
|
||
|
||
// NB: all hooks must run before any early return (Rules of Hooks) — compute
|
||
// the highlighted body here, above the editing/diff/error returns below.
|
||
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]);
|
||
// live-highlighted draft for the editor underlay (same highlighter as the preview)
|
||
const draftHi = useMemo(() => {
|
||
try { return lang ? hljs.highlight(draft, { language: lang, ignoreIllegals: true }).value : esc(draft); }
|
||
catch { return esc(draft); }
|
||
}, [draft, lang]);
|
||
const preRef = useRef<HTMLPreElement>(null);
|
||
const gutRef = useRef<HTMLDivElement>(null);
|
||
const draftRows = draft.split("\n").length;
|
||
|
||
// editing mode — a real code editor: transparent textarea over a syntax-
|
||
// highlighted underlay, with a line-number gutter (looks like the preview).
|
||
if (editing) {
|
||
const syncScroll = (e: { currentTarget: HTMLTextAreaElement }) => {
|
||
const ta = e.currentTarget;
|
||
if (preRef.current) { preRef.current.scrollTop = ta.scrollTop; preRef.current.scrollLeft = ta.scrollLeft; }
|
||
if (gutRef.current) gutRef.current.scrollTop = ta.scrollTop;
|
||
};
|
||
return (
|
||
<div className="right-body">
|
||
<div className="codebar">
|
||
<span className="cb-badge editing">{t("Editing")}</span>
|
||
<span className="cb-name">{name}</span>
|
||
{lang ? <span className="cb-lines">{LANG_LABEL[lang]}</span> : null}
|
||
<span className="ce-actions">
|
||
<button className="ce-btn" onClick={cancelEdit} disabled={saving}>{t("Cancel")}</button>
|
||
<button className="ce-btn save" onClick={save} disabled={saving || draft === code}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
|
||
</span>
|
||
</div>
|
||
<div className="code editing">
|
||
<div className="code-gutter" ref={gutRef}>{Array.from({ length: draftRows }, (_, i) => <span key={i}>{i + 1}</span>)}</div>
|
||
<div className="ce-wrap">
|
||
<pre className="ce-under" aria-hidden="true" ref={preRef}><code className="hljs" dangerouslySetInnerHTML={{ __html: draftHi + (draft.endsWith("\n") ? " " : "") }} /></pre>
|
||
<textarea className="ce-ta" value={draft} spellCheck={false} autoFocus onScroll={syncScroll} onChange={(e) => setDraft(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") cancelEdit();
|
||
if (e.key === "s" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (draft !== code) save(); }
|
||
// Tab inserts a tab instead of leaving the field
|
||
if (e.key === "Tab") {
|
||
e.preventDefault();
|
||
const ta = e.currentTarget; const s = ta.selectionStart, en = ta.selectionEnd;
|
||
const nv = draft.slice(0, s) + "\t" + draft.slice(en);
|
||
setDraft(nv);
|
||
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
|
||
}
|
||
}} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
{canEdit && <button className="ce-btn edit" style={{ marginLeft: "auto" }} onClick={startEdit}>{pencil}{t("Edit")}</button>}
|
||
</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>
|
||
{canEdit && <button className="ce-btn edit" onClick={startEdit}>{pencil}{t("Edit")}</button>}
|
||
</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>
|
||
);
|
||
}
|