diff --git a/package-lock.json b/package-lock.json
index fa1e626..9c0302a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 6216c61..a2236e3 100644
--- a/package.json
+++ b/package.json
@@ -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"
diff --git a/src/App.css b/src/App.css
index ac8ab71..ad19652 100644
--- a/src/App.css
+++ b/src/App.css
@@ -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 */
diff --git a/src/App.tsx b/src/App.tsx
index 2dc7ab0..339c1de 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -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
startResize(e, "sync")} title={t("Drag to resize")} />
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 (
e.stopPropagation()} onContextMenu={(e) => e.preventDefault()}>
{items.map((it, i) => (
-
{ onClose(); it.act(); }}>{it.label}
+
{ onClose(); it.act(); }}>
+ {hasIcon && {it.icon}}
+ {it.label}
+
))}
);
@@ -1138,7 +1143,7 @@ function Preview({ file, onRevert }: { file?: OpenedFile; onRevert: (f: OpenedFi
{kind === "model" ?
: kind === "image" ?
: kind === "uasset" ?
- :
}
+ :
}
);
}
@@ -1146,35 +1151,6 @@ function Mi({ k, v, acc }: { k: string; v: string; acc?: boolean }) {
return
{k}{v}
;
}
-/* ---------------- text diff ---------------- */
-function DiffView({ file }: { file: OpenedFile }) {
- const [text, setText] = useState
(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 {err}
;
- if (text === null) return {t("Loading diff…")}
;
- if (!text.trim()) return {I.check}{t("No text differences (or binary file).")}
;
-
- const lines = text.split("\n");
- return (
-
- {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
{ln || " "}
;
- })}
-
- );
-}
-
/* ---------------- real image viewer ---------------- */
function ImageViewer({ depot, name }: { depot: string; name: string }) {
const [url, setUrl] = useState(null);
diff --git a/src/CodeView.tsx b/src/CodeView.tsx
new file mode 100644
index 0000000..ae17528
--- /dev/null
+++ b/src/CodeView.tsx
@@ -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 = { 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.
+export default function CodeView({ file }: { file: OpenedFile }) {
+ const dp = file.depotFile || "";
+ const { name } = splitPath(dp);
+ const action = (file.action || "").toLowerCase();
+ const [diff, setDiff] = useState(null);
+ const [code, setCode] = useState(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 {t("Binary file — no code preview.")}
;
+ if (err) return {err}
;
+ if (tooBig) return {t("File is too large to preview.")}
;
+
+ // an edited file with a real diff → show the diff
+ if (diff && diff.trim() && action.includes("edit")) {
+ const lines = diff.split("\n");
+ return (
+
+
{t("Diff")}{name}
+
+ {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
{ln || " "}
;
+ })}
+
+
+ );
+ }
+
+ // otherwise (new file, or edit with no text diff) → full content with line numbers
+ if (code == null) return {t("Loading…")}
;
+ const total = code.length ? code.replace(/\n$/, "").split("\n").length : 0;
+ return (
+
+
+ {action.includes("add") ? t("New file") : lang ? (LANG_LABEL[lang] || "") : t("Text")}
+ {name}
+ {t("{n} lines", { n: total })}
+
+
+
{Array.from({ length: total }, (_, i) => {i + 1})}
+
+
+
+ );
+}
diff --git a/src/i18n.ts b/src/i18n.ts
index 900d1ba..8e92176 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -202,6 +202,12 @@ const D: Record = {
"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 d’aperç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 l’aperç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" },