Files
exbyte-depot-viewer-perforce/src/CodeView.tsx
Bonchellon 5652f489a2 v0.3.1 — view-panel tabs, tray + notifications, server-activity, media preview, dockable locks
- 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>
2026-07-08 14:26:22 +03:00

201 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 === "&" ? "&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. 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>
);
}