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

14
package-lock.json generated
View File

@ -1,18 +1,19 @@
{
"name": "exbyte-depot",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "exbyte-depot",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"highlight.js": "^11.11.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"three": "^0.185.1"
@ -1820,6 +1821,15 @@
"node": ">=6.9.0"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@ -15,6 +15,7 @@
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"highlight.js": "^11.11.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"three": "^0.185.1"

View File

@ -195,7 +195,9 @@ body.resizing{cursor:col-resize!important;user-select:none}
/* right-click context menu */
.ctxmenu{position:fixed;z-index:300;min-width:210px;padding:5px;border-radius:12px;
background:var(--elevated);border:1px solid var(--border);box-shadow:0 18px 44px -12px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.03) inset}
.ctxitem{padding:8px 12px;border-radius:8px;font-size:12.5px;color:var(--txt);cursor:pointer;white-space:nowrap;transition:background .1s}
.ctxitem{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:8px;font-size:12.5px;color:var(--txt);cursor:pointer;white-space:nowrap;transition:background .1s}
.ctx-ic{width:16px;height:16px;flex:0 0 auto;display:grid;place-items:center}
.ctx-ic svg{width:16px;height:16px}
.ctxitem:hover{background:var(--hover)}
.ctxitem.danger{color:var(--del)}
.ctxitem.danger:hover{background:rgba(242,99,126,.12)}
@ -439,6 +441,36 @@ body.resizing{cursor:col-resize!important;user-select:none}
.dl.add{background:rgba(62,207,142,.10)}.dl.add .c{color:var(--diff-add-c)}.dl.add .g{background:var(--add)}
.dl.del{background:rgba(242,99,126,.10)}.dl.del .c{color:var(--diff-del-c)}.dl.del .g{background:var(--del)}
.dl.hunk{background:rgba(124,110,246,.08)}.dl.hunk .c{color:var(--accent-2)}
/* code viewer (GitHub-style content + line numbers) */
.right-body{flex:1;min-height:0;display:flex;flex-direction:column}
.codebar{flex:0 0 auto;display:flex;align-items:center;gap:10px;padding:8px 16px;border-bottom:1px solid var(--border-soft);font-size:12px}
.cb-badge{flex:0 0 auto;font-size:10.5px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);
background:var(--panel-3);border-radius:7px;padding:2px 9px}
.cb-badge.add{color:var(--add);background:rgba(62,207,142,.14)}
.cb-badge.diff{color:var(--accent-2);background:rgba(124,110,246,.14)}
.cb-name{color:var(--txt);font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cb-lines{margin-left:auto;flex:0 0 auto;color:var(--faint);font-variant-numeric:tabular-nums}
.code{flex:1;min-height:0;overflow:auto;display:flex;font-family:var(--mono);font-size:12.5px;line-height:1.7}
.code-gutter{position:sticky;left:0;z-index:1;flex:0 0 auto;display:flex;flex-direction:column;text-align:right;
padding:10px 12px 10px 16px;color:var(--faint);background:var(--stage-bg);border-right:1px solid var(--border-soft);
user-select:none;font-variant-numeric:tabular-nums}
.code.added .code-gutter{color:var(--add);background:rgba(62,207,142,.06)}
.code-body{flex:1;margin:0;padding:10px 16px;white-space:pre;overflow:visible}
.code-body code{white-space:pre;font-family:inherit;background:none;padding:0}
.code.added .code-body{background:rgba(62,207,142,.04)}
/* highlight.js tokens mapped to the app palette (theme-aware) */
.hljs{color:var(--txt);background:none}
.hljs-comment,.hljs-quote{color:var(--faint);font-style:italic}
.hljs-keyword,.hljs-selector-tag,.hljs-built_in,.hljs-name,.hljs-tag{color:var(--accent-2)}
.hljs-string,.hljs-attr,.hljs-symbol,.hljs-bullet,.hljs-addition,.hljs-meta-string{color:var(--add)}
.hljs-number,.hljs-literal,.hljs-type,.hljs-params,.hljs-template-variable,.hljs-variable{color:var(--edit)}
.hljs-title,.hljs-title.function_,.hljs-section,.hljs-class .hljs-title{color:var(--txt);font-weight:600}
.hljs-attribute,.hljs-selector-id,.hljs-selector-class,.hljs-selector-attr{color:var(--accent)}
.hljs-meta,.hljs-comment.hljs-doctag{color:var(--muted)}
.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}
.hljs-deletion{color:var(--del)}
.diff::-webkit-scrollbar{width:10px}.diff::-webkit-scrollbar-thumb{background:var(--panel-3);border-radius:8px}
/* history */

View File

@ -4,6 +4,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { listen } from "@tauri-apps/api/event";
import ModelViewer from "./ModelViewer";
import CodeView from "./CodeView";
import UpdateBanner from "./Updater";
import "./App.css";
import { p4, statusOf, splitPath, kindOf, fmtTime, describeFiles, leaf, saveSession, loadSession, clearSession, saveScope, loadScope, P4Info, OpenedFile, Client, Change, Session, Describe, Dir } from "./p4";
@ -253,7 +254,7 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg
/* ---------------- main workbench ---------------- */
type Tab = "changes" | "history";
type CtxItem = { label: string; danger?: boolean; act: () => void };
type CtxItem = { label: string; danger?: boolean; icon?: ReactNode; act: () => void };
type ModalState =
| { kind: "workspaces"; items: Client[]; current: string }
@ -638,7 +639,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<div className="vhandle grouph" onMouseDown={(e) => startResize(e, "sync")} title={t("Drag to resize")} />
<div className="tzone" onClick={() => browseTo("")} title={t("Choose the project working folder · right-click for more")}
onContextMenu={(e) => openCtx(e, [
...(uproject ? [{ label: t("Launch Unreal Engine"), act: launchUE }] : []),
...(uproject ? [{ label: t("Launch Unreal Engine"), icon: I.ue, act: launchUE }] : []),
{ label: t("Open in Explorer"), act: () => reveal(activePath || String(info?.clientRoot || "")) },
{ label: t("Choose another folder…"), act: () => browseTo("") },
{ label: t("Copy path"), act: () => copyText(activePath || "//…", t("Path")) },
@ -887,10 +888,14 @@ function ContextMenu({ x, y, items, onClose }: { x: number; y: number; items: Ct
return () => { document.removeEventListener("click", close); document.removeEventListener("scroll", close, true); document.removeEventListener("keydown", onKey); };
}, [onClose]);
const style: CSSProperties = { left: Math.min(x, window.innerWidth - 240), top: Math.min(y, window.innerHeight - (items.length * 36 + 14)) };
const hasIcon = items.some((it) => it.icon);
return (
<div className="ctxmenu" style={style} onClick={(e) => e.stopPropagation()} onContextMenu={(e) => e.preventDefault()}>
{items.map((it, i) => (
<div key={i} className={"ctxitem" + (it.danger ? " danger" : "")} onClick={() => { onClose(); it.act(); }}>{it.label}</div>
<div key={i} className={"ctxitem" + (it.danger ? " danger" : "")} onClick={() => { onClose(); it.act(); }}>
{hasIcon && <span className="ctx-ic">{it.icon}</span>}
<span>{it.label}</span>
</div>
))}
</div>
);
@ -1138,7 +1143,7 @@ function Preview({ file, onRevert }: { file?: OpenedFile; onRevert: (f: OpenedFi
{kind === "model" ? <ModelViewer depot={dp} name={name} />
: kind === "image" ? <ImageViewer depot={dp} name={name} />
: kind === "uasset" ? <UassetPreview file={file} />
: <DiffView file={file} />}
: <CodeView file={file} />}
</div>
);
}
@ -1146,35 +1151,6 @@ function Mi({ k, v, acc }: { k: string; v: string; acc?: boolean }) {
return <div className="mi"><span className="k">{k}</span><span className={"v" + (acc ? " acc" : "")}>{v}</span></div>;
}
/* ---------------- text diff ---------------- */
function DiffView({ file }: { file: OpenedFile }) {
const [text, setText] = useState<string | null>(null);
const [err, setErr] = useState("");
const dp = file.depotFile || "";
useEffect(() => {
let live = true; setText(null); setErr("");
p4.diff(dp).then((txt) => live && setText(txt)).catch((e) => live && setErr(String(e)));
return () => { live = false; };
}, [dp]);
if (err) return <div className="nofile" style={{ color: "var(--del)" }}>{err}</div>;
if (text === null) return <div className="nofile">{t("Loading diff…")}</div>;
if (!text.trim()) return <div className="nofile">{I.check}<span>{t("No text differences (or binary file).")}</span></div>;
const lines = text.split("\n");
return (
<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>
);
}
/* ---------------- real image viewer ---------------- */
function ImageViewer({ depot, name }: { depot: string; name: string }) {
const [url, setUrl] = useState<string | null>(null);

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>
);
}

View File

@ -202,6 +202,12 @@ const D: Record<string, Tr> = {
"Select a file on the left": { ru: "Выберите файл слева", de: "Wähle links eine Datei", fr: "Sélectionnez un fichier à gauche", es: "Selecciona un archivo a la izquierda" },
"Loading diff…": { ru: "Загрузка diff…", de: "Diff wird geladen…", fr: "Chargement du diff…", es: "Cargando diff…" },
"No text differences (or binary file).": { ru: "Нет текстовых отличий (или бинарный файл).", de: "Keine Textunterschiede (oder Binärdatei).", fr: "Aucune différence de texte (ou fichier binaire).", es: "Sin diferencias de texto (o archivo binario)." },
"Binary file — no code preview.": { ru: "Бинарный файл — превью кода нет.", de: "Binärdatei — keine Code-Vorschau.", fr: "Fichier binaire — pas daperçu du code.", es: "Archivo binario — sin vista previa de código." },
"File is too large to preview.": { ru: "Файл слишком большой для превью.", de: "Datei zu groß für die Vorschau.", fr: "Fichier trop volumineux pour laperçu.", es: "Archivo demasiado grande para previsualizar." },
"Diff": { ru: "Diff", de: "Diff", fr: "Diff", es: "Diff" },
"New file": { ru: "Новый файл", de: "Neue Datei", fr: "Nouveau fichier", es: "Archivo nuevo" },
"Text": { ru: "Текст", de: "Text", fr: "Texte", es: "Texto" },
"{n} lines": { ru: "{n} строк", de: "{n} Zeilen", fr: "{n} lignes", es: "{n} líneas" },
"Image": { ru: "Изображение", de: "Bild", fr: "Image", es: "Imagen" },
"Resolution": { ru: "Разрешение", de: "Auflösung", fr: "Résolution", es: "Resolución" },
"Format": { ru: "Формат", de: "Format", fr: "Format", es: "Formato" },