From e9868c5b2e0c47513edd6ed4a8f49ea743181514 Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Tue, 7 Jul 2026 15:45:03 +0300 Subject: [PATCH] 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 --- package-lock.json | 14 +++++- package.json | 1 + src/App.css | 34 ++++++++++++- src/App.tsx | 42 ++++------------ src/CodeView.tsx | 120 ++++++++++++++++++++++++++++++++++++++++++++++ src/i18n.ts | 6 +++ 6 files changed, 181 insertions(+), 36 deletions(-) create mode 100644 src/CodeView.tsx 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" },