0.2.3 — built-in code editor, .uasset previews, new icon

- Built-in code editor: edit working-copy source files in place (Edit → textarea
  → Save) before committing, no external IDE needed. Backend write_depot opens
  the file for edit, clears read-only, writes UTF-8; confined to the client root.
- .uasset previews: pull the embedded Unreal thumbnail (PNG) server-side for both
  working copy and history; on-demand real 3D via a headless Unreal FBX export
  (commandlet mode, no editor window), temp file deleted right after — nothing
  cached on disk. Plugin content paths mapped to their mount point.
- New app icon (brand mark on a theme tile) + theme-aware in-app logo.
- Connection pill / Settings show the actual connected server (P4PORT), not the
  server's internal self-reported address.
- Fix: the "select all" checkbox no longer renders as a giant empty square when
  not everything is selected (size pinned).
- IDE picker chooses which installed editor opens code; AI replies in UI language.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 22:55:04 +03:00
parent 6c16d4a4f4
commit 0da836b0ac
61 changed files with 528 additions and 65 deletions

View File

@ -157,6 +157,31 @@ function Select({ value, options, onChange, icon, placeholder }:
);
}
/* ---------------- brand logo (Exbyte Depot mark; theme-aware tile) ---------------- */
function Logo() {
return (
<svg className="exlogo" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<defs>
<linearGradient id="exG" x1="6" y1="24" x2="26" y2="8" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#8b6cf6" />
<stop offset="1" stopColor="#49a6ff" />
</linearGradient>
</defs>
<g stroke="url(#exG)" strokeWidth="2.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M6 16 H10 L14.5 9.5 H20" />
<path d="M10 16 L14.5 22.5 H20" />
<circle cx="22.5" cy="9.5" r="3" />
<circle cx="22.5" cy="22.5" r="3" />
</g>
<g stroke="url(#exG)" strokeWidth="1.9" strokeLinecap="round" opacity="0.5">
<path d="M15 14 H19" />
<path d="M15 16 H20" />
<path d="M15 18 H18" />
</g>
</svg>
);
}
/* ---------------- animated startup splash ---------------- */
function Splash({ light, toggleTheme }: { light: boolean; toggleTheme: () => void }) {
const steps = [t("Connecting to server…"), t("Authenticating…"), t("Loading workspace…"), t("Syncing state…")];
@ -168,7 +193,7 @@ function Splash({ light, toggleTheme }: { light: boolean; toggleTheme: () => voi
return (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
@ -225,7 +250,7 @@ function Connect({ light, toggleTheme, initial, onDone }: { light: boolean; togg
return (
<div className="win">
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
</div>
@ -1009,7 +1034,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
return (
<div className="win" style={{ "--left-w": `${leftW}px`, "--right-zone-w": `${syncW}px` } as CSSProperties}>
<div className="titlebar" data-tauri-drag-region>
<div className="brand" data-tauri-drag-region><div className="logo">{I.hex}</div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<div className="brand" data-tauri-drag-region><div className="logo"><Logo /></div><div className="app-name">Exbyte&nbsp;Depot <span> Perforce</span></div></div>
<div className="menubar" onClick={(e) => e.stopPropagation()}>
{Object.entries(menus).map(([name, items]) => (
<div key={name} className={"menu" + (openMenu === name ? " open" : "")} onClick={() => setOpenMenu(openMenu === name ? null : name)}>
@ -1024,7 +1049,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
))}
</div>
<div className="conn-pill"><span className="dot" />{info?.serverAddress || "connected"}</div>
<div className="conn-pill" title={t("Connected server")}><span className="dot" />{(session?.server || (info?.serverAddress as string) || "connected").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</div>
<button className="theme-btn" onClick={() => setModal({ kind: "users" })} title={t("People & Roles — who works on this depot")}>{I.people}</button>
<button className="theme-btn" onClick={toggleTheme} title={t("Theme")}>{light ? I.sun : I.moon}</button>
<WinControls />
@ -1148,7 +1173,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
)}
<div className="chhead">
<span className={"chk" + (allChecked ? "" : " empty")} onClick={toggleAll} title={allChecked ? t("Deselect all") : t("Select all")}>{allChecked ? I.check : null}</span>
<span className={"chk" + (allChecked ? "" : " empty")} onClick={toggleAll} title={allChecked ? t("Deselect all") : t("Select all")}
style={{ width: 16, height: 16, minWidth: 16, flex: "0 0 16px", boxSizing: "border-box", alignSelf: "center" }}>{allChecked ? I.check : null}</span>
<span>{t("{a} of {b} selected", { a: checked.size, b: view.length })}</span>
<button className={"scanbtn" + ((busy || scanning) ? " spinning" : "")} style={{ marginLeft: "auto" }} onClick={() => refresh()} disabled={busy || scanning} title={t("Re-read open files (p4 opened)")}>
{I.sync}<span>{scanning ? t("Scanning…") : t("Refresh")}</span>
@ -1193,7 +1219,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
{tab === "changes" ? (
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} />
<Preview file={selFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} uproject={uproject} />
) : (
<div className={"histsplit" + (histFile ? "" : " solo")}>
<ChangeDetail detail={detail} busy={detailBusy} onOpen={setHistFile} selected={histFile?._spec || ""} split width={histFile ? histW : undefined} />
@ -1210,7 +1236,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
document.addEventListener("mousemove", move as unknown as (e: MouseEvent) => void); document.addEventListener("mouseup", up);
window.addEventListener("blur", up); // released outside the window → end drag
}} />
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} />
<Preview file={histFile} onRevert={doRevert} onFlash={flash} editorId={effEditorId} editorName={effEditorName} onBlame={showBlame} onDiffPrev={showDiffPrev} uproject={uproject} />
</>)}
</div>
)}
@ -1247,7 +1273,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</div>
</div>
)}
{modal && modal.kind !== "users" && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
{modal && modal.kind !== "users" && <AppModal modal={modal} info={info} session={session} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
editors={editors} editorId={effEditorId} onPickEditor={chooseEditor}
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{modal?.kind === "users" && <UsersRoles me={info?.userName || ""} onClose={() => setModal(null)} onFlash={flash} />}
@ -1268,8 +1294,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
}
/* ---------------- app modal (workspaces / scope / settings / about) ---------------- */
function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
{ modal: ModalState; info: P4Info | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; editors: Editor[]; editorId: string; onPickEditor: (id: string) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
function AppModal({ modal, info, session, light, toggleTheme, lang, setLang, zoom, setZoom, editors, editorId, onPickEditor, onClose, onPickWorkspace, onBrowse, onChoose, onDisconnect }:
{ modal: ModalState; info: P4Info | null; session: Session | null; light: boolean; toggleTheme: () => void; lang: Lang; setLang: (l: Lang) => void; zoom: number; setZoom: (z: number) => void; editors: Editor[]; editorId: string; onPickEditor: (id: string) => void; onClose: () => void; onPickWorkspace: (c: string) => void; onBrowse: (path: string) => void; onChoose: (path: string) => void; onDisconnect: () => void }) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", onKey);
@ -1353,7 +1379,7 @@ function AppModal({ modal, info, light, toggleTheme, lang, setLang, zoom, setZoo
<button className="zbtn" onClick={() => setZoom(zoom + 0.1)} disabled={zoom >= 2} title="+">+</button>
</div>
</div>
<div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{info?.serverAddress || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Server")}</span><span className="rv">{(session?.server || (info?.serverAddress as string) || "—").replace(/^(ssl|tcp|ssl4|tcp4|ssl6|tcp6):/i, "")}</span></div>
<div className="info-row"><span className="rk">{t("User")}</span><span className="rv">{info?.userName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Workspace")}</span><span className="rv">{info?.clientName || "—"}</span></div>
<div className="info-row"><span className="rk">{t("Server version")}</span><span className="rv">{(info?.serverVersion as string || "").split("/")[2] || "—"}</span></div>
@ -2061,19 +2087,16 @@ function Thumb({ file }: { file: OpenedFile }) {
if (kind !== "image" && kind !== "uasset") return;
if (thumbCache.has(dp)) { setUrl(thumbCache.get(dp) ?? null); return; }
let live = true;
p4.readDepot(dp).then((buf) => {
if (!live) return;
let u: string | null = null;
if (kind === "image") {
const ext = (name.split(".").pop() || "png").toLowerCase();
u = URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }));
} else {
const png = extractPng(new Uint8Array(buf));
if (png) u = URL.createObjectURL(new Blob([png], { type: "image/png" }));
}
thumbCache.set(dp, u);
if (live) setUrl(u);
}).catch(() => { thumbCache.set(dp, null); });
// .uasset → backend pulls just the embedded thumbnail (no huge IPC transfer);
// real images → fetch the file bytes and blob them directly
const job = kind === "uasset"
? p4.uassetThumb(dp, false).then((buf) => URL.createObjectURL(new Blob([buf], { type: "image/png" })))
: p4.readDepot(dp).then((buf) => {
const ext = (name.split(".").pop() || "png").toLowerCase();
return URL.createObjectURL(new Blob([buf], { type: ext === "svg" ? "image/svg+xml" : ext === "jpg" ? "image/jpeg" : `image/${ext}` }));
});
job.then((u) => { thumbCache.set(dp, u); if (live) setUrl(u); })
.catch(() => { thumbCache.set(dp, null); });
return () => { live = false; };
}, [dp]);
@ -2121,7 +2144,7 @@ function FileList({ files, sel, selRows, checked, others, onRowClick, onToggle,
}
/* ---------------- preview panel ---------------- */
function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDiffPrev }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void }) {
function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDiffPrev, uproject }: { file?: OpenedFile; onRevert: (f: OpenedFile) => void; onFlash: (text: string, err?: boolean) => void; editorId: string; editorName: string; onBlame?: (spec: string, name: string) => void; onDiffPrev?: (f: OpenedFile) => void; uproject?: string }) {
const [explain, setExplain] = useState<string | null>(null); // AI code summary
const [explBusy, setExplBusy] = useState(false);
const explSeq = useRef(0); // drop a slow AI response if the file changed meanwhile
@ -2203,8 +2226,8 @@ function Preview({ file, onRevert, onFlash, editorId, editorName, onBlame, onDif
{kind === "model" ? <ModelViewer depot={dp} name={name} spec={spec || undefined} />
: kind === "image" ? <ImageViewer depot={dp} name={name} spec={spec || undefined} />
: kind === "uasset" ? <UassetPreview file={file} />
: <CodeView file={file} />}
: kind === "uasset" ? <UassetPreview file={file} uproject={uproject} onFlash={onFlash} />
: <CodeView file={file} onFlash={onFlash} />}
</div>
);
}
@ -2244,24 +2267,44 @@ function ImageViewer({ depot, name, spec }: { depot: string; name: string; spec?
);
}
/* ---------------- .uasset — extract embedded Unreal thumbnail (PNG) ---------------- */
function UassetPreview({ file }: { file: OpenedFile }) {
/* ---------------- .uasset — embedded thumbnail + on-demand real 3D (Unreal export) ---------------- */
function UassetPreview({ file, uproject, onFlash }: { file: OpenedFile; uproject?: string; onFlash: (text: string, err?: boolean) => void }) {
const dp = file.depotFile || "";
const { name } = splitPath(dp);
const hist = !!file._spec; // historical revision (3D export only works on the working copy)
const [thumb, setThumb] = useState<string | null>(null);
const [tried, setTried] = useState(false);
const [model, setModel] = useState<ArrayBuffer | null>(null); // exported FBX bytes
const [exporting, setExporting] = useState(false);
useEffect(() => {
let live = true; let url = "";
setThumb(null); setTried(false);
fileBytes(file).then((buf) => {
setThumb(null); setTried(false); setModel(null); setExporting(false);
// backend reads the asset and returns ONLY the embedded thumbnail PNG —
// works for the working copy and for exact submitted revisions (history)
const spec = file._spec || dp;
p4.uassetThumb(spec, !!file._spec).then((buf) => {
if (!live) return;
const png = extractPng(new Uint8Array(buf));
if (png) { url = URL.createObjectURL(new Blob([png], { type: "image/png" })); setThumb(url); }
url = URL.createObjectURL(new Blob([buf], { type: "image/png" }));
setThumb(url);
setTried(true);
}).catch(() => live && setTried(true));
return () => { live = false; if (url) URL.revokeObjectURL(url); };
}, [dp, file._spec]);
async function load3d() {
if (!uproject) { onFlash(t("Pick the Unreal project working folder first."), true); return; }
setExporting(true);
try {
const buf = await p4.exportUasset3d(dp, uproject);
setModel(buf);
} catch (e) { onFlash(String(e), true); }
finally { setExporting(false); }
}
// real, rotatable 3D once Unreal has exported the mesh
if (model) return <ModelViewer depot={dp} name={name} bytes={model} forceExt="fbx" />;
const canExport = !!uproject; // exports the version currently synced on disk
return (
<div className="asset">
<div className="stage">
@ -2272,9 +2315,14 @@ function UassetPreview({ file }: { file: OpenedFile }) {
<div className="uasset-card">
<div className="uasset-ic">{I.cube}</div>
<div className="uasset-name">{name}</div>
<div className="uasset-note">{!tried ? t("Reading asset…") : t("This asset has no embedded thumbnail. Interactive 3D works for FBX / OBJ / GLB / GLTF / STL — export the model to one of these formats.")}</div>
<div className="uasset-note">{!tried ? t("Reading asset…") : t("No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).")}</div>
</div>
)}
{/* real 3D on demand — exports the mesh from Unreal to FBX, then rotates in-viewport */}
<button className="load3d" onClick={load3d} disabled={!canExport || exporting}
title={!uproject ? t("Pick the Unreal project working folder first.") : hist ? t("Exports the version currently synced on disk.") : t("Export the mesh from Unreal and view it in 3D")}>
{exporting ? <><span className="ldr sm" />{t("Exporting from Unreal… (first time is slow)")}</> : <>{I.cube}{t("Load 3D model")}</>}
</button>
</div>
<div className="assetmeta">
<Mi k={t("Type")} v={file.type || "binary"} /><Mi k={t("Preview")} v={thumb ? t("thumbnail") : (tried ? t("none") : "…")} /><Mi k={t("Revision")} v={file.rev ? "#" + file.rev : "—"} acc /><Mi k="Change" v={file.change || "default"} />
@ -2283,18 +2331,3 @@ function UassetPreview({ file }: { file: OpenedFile }) {
);
}
// scan raw bytes for an embedded PNG and return just that PNG (sig → IEND+crc)
function extractPng(b: Uint8Array): Uint8Array | null {
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
for (let i = 0; i < b.length - 8; i++) {
let ok = true;
for (let k = 0; k < 8; k++) if (b[i + k] !== sig[k]) { ok = false; break; }
if (!ok) continue;
for (let j = i + 8; j < b.length - 8; j++) {
if (b[j] === 0x49 && b[j + 1] === 0x45 && b[j + 2] === 0x4e && b[j + 3] === 0x44) return b.slice(i, j + 8);
}
return b.slice(i);
}
return null;
}