From e2ad438ed492e74d7d35a8170588711dd76a3459 Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Wed, 8 Jul 2026 11:41:24 +0300 Subject: [PATCH] Typemap / exclusive-lock (+l) manager for Unreal binaries - Tools -> Exclusive Locks (typemap): view/edit server typemap, one-click add recommended binary+l rules for UE asset types (.uasset/.umap/.fbx/textures/etc) - Per-file 'Set exclusive-lock type (+l)' in the Changes context menu - Backend p4 typemap -o/-i and reopen -t Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/lib.rs | 84 +++++++++++++++++++++++++++++++++++++++++++ src/App.css | 1 + src/App.tsx | 85 +++++++++++++++++++++++++++++++++++++++++++- src/i18n.ts | 13 +++++++ src/p4.ts | 3 ++ 5 files changed, 185 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c5b4a1f..4114cbd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1314,6 +1314,87 @@ async fn p4_unlock(state: State<'_, AppState>, files: Vec) -> Result) -> Result, String> { + let conn = current(&state)?; + let spec = run_text(&conn, &["typemap", "-o"])?; + let mut entries = Vec::new(); + let mut in_map = false; + for line in spec.lines() { + if line.starts_with("TypeMap:") { + in_map = true; + continue; + } + if in_map { + if line.starts_with('\t') || line.starts_with(' ') { + let e = line.trim(); + if !e.is_empty() && !e.starts_with('#') { + entries.push(e.to_string()); + } + } else if !line.trim().is_empty() { + break; // reached the next spec field + } + } + } + Ok(entries) +} + +/// Replace the server typemap with the given "TYPE PATTERN" entries. +/// Requires admin rights on the server. +#[tauri::command] +async fn p4_typemap_set(state: State<'_, AppState>, entries: Vec) -> Result { + let conn = current(&state)?; + let mut spec = String::from("TypeMap:\n"); + for e in &entries { + let e = e.trim(); + if !e.is_empty() { + spec.push('\t'); + spec.push_str(e); + spec.push('\n'); + } + } + let mut child = p4_cmd(&conn, false) + .args(["typemap", "-i"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start p4: {e}"))?; + if let Some(si) = child.stdin.as_mut() { + si.write_all(spec.as_bytes()).map_err(|e| e.to_string())?; + } + let out = child.wait_with_output().map_err(|e| e.to_string())?; + if !out.status.success() { + let err = decode(&out.stderr); + let msg = if err.trim().is_empty() { decode(&out.stdout) } else { err }; + let low = msg.to_lowercase(); + return Err(if low.contains("permission") || low.contains("protect") || low.contains("admin") || low.contains("not allowed") { + "You need admin rights on the server to change the typemap.".to_string() + } else { + msg.trim().to_string() + }); + } + Ok(decode(&out.stdout).trim().to_string()) +} + +/// Change the Perforce file type of opened files (e.g. add "+l" exclusive lock). +/// `filetype` is passed to `p4 reopen -t`, so "+l" adds the modifier and +/// "binary+l" sets the full type. +#[tauri::command] +async fn p4_reopen_type(state: State<'_, AppState>, files: Vec, filetype: String) -> Result { + let conn = current(&state)?; + if files.is_empty() { + return Ok(String::new()); + } + let mut args: Vec<&str> = vec!["reopen", "-t", &filetype]; + for f in &files { + args.push(f.as_str()); + } + run_text(&conn, &args) +} + /// Extract the largest embedded PNG from a byte blob — this is how an uncooked /// Unreal `.uasset` stores its Content-Browser thumbnail (PNG-compressed in the /// package thumbnail table). We scan for the PNG signature → IEND and keep the @@ -1924,6 +2005,9 @@ pub fn run() { p4_opened_all, p4_lock, p4_unlock, + p4_typemap_get, + p4_typemap_set, + p4_reopen_type, uasset_thumbnail, uasset_export_3d, p4_shelve, diff --git a/src/App.css b/src/App.css index e020242..02f9fba 100644 --- a/src/App.css +++ b/src/App.css @@ -794,6 +794,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .rslv-act svg{width:14px;height:14px;display:block} .lk-who{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end;gap:1px;font-size:11.5px;color:var(--muted);max-width:170px} .lk-client{font-size:10px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:170px} +.tm-hint{padding:10px 16px;font-size:11.5px;line-height:1.5;color:var(--faint);border-bottom:1px solid var(--border-soft)} /* blame + diff panes */ .picker.blame{width:900px;height:640px} .blame-body,.diff-body{flex:1;overflow:auto;padding:8px 0;font-family:var(--mono);font-size:12px;line-height:1.5;background:var(--stage-bg)} diff --git a/src/App.tsx b/src/App.tsx index 5e61fa4..09d19c6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -309,6 +309,7 @@ type ModalState = | { kind: "users" } | { kind: "search" } | { kind: "locks" } + | { kind: "typemap" } | { kind: "blame"; spec: string; name: string } | { kind: "diff"; title: string; text: string } | { kind: "about" }; @@ -985,6 +986,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set { label: targets.length > 1 ? t("Discard changes · {n} files", { n: targets.length }) : t("Discard changes"), danger: true, act: () => discard(targets) }, { label: t("Lock (exclusive)"), icon: I.lock, act: () => lockFiles(dps, true) }, { label: t("Unlock"), icon: I.unlock, act: () => lockFiles(dps, false) }, + { label: t("Set exclusive-lock type (+l)"), icon: I.lock, act: () => setExclusiveType(dps) }, ...pending.map((cl) => ({ label: t("Move to #{n}", { n: cl.change || "" }), act: () => moveToCL(cl.change || "", dps) })), ...(isCode ? [{ label: t("Open in {editor}", { editor: effEditorName }), icon: I.vscode, act: () => { p4.openInEditor(dp, effEditorId).then(() => flash(t("Opening in {editor}…", { editor: effEditorName }))).catch((err) => flash(String(err), true)); } }] : []), ...(isCode ? [{ label: t("Blame (annotate)"), act: () => setModal({ kind: "blame", spec: dp, name: splitPath(dp).name }) }] : []), @@ -997,6 +999,12 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set try { await (lock ? p4.lock(dps) : p4.unlock(dps)); flash(lock ? t("Locked {n} file(s).", { n: dps.length }) : t("Unlocked {n} file(s).", { n: dps.length })); await refresh(); } catch (e) { flash(String(e), true); } } + // switch opened files to an exclusive-lock file type (+l) so only one person edits them + async function setExclusiveType(dps: string[]) { + if (!dps.length) return; + try { await p4.reopenType(dps, "+l"); flash(t("{n} file(s) set to exclusive-lock type (+l).", { n: dps.length })); await refresh(); } + catch (e) { flash(String(e), true); } + } async function moveToCL(change: string, dps: string[]) { if (!change || !dps.length) return; try { await p4.reopenTo(change, dps); flash(t("Moved {n} file(s) to #{c}.", { n: dps.length, c: change })); await refresh(); } @@ -1034,7 +1042,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set ...(uproject ? [{ label: (dockOpen && dockTab === "unreal" ? "✓ " : "") + t("Unreal Log"), icon: I.hex, act: () => openDock("unreal") }] : []), { label: t("File Locks…"), icon: I.lock, act: () => setModal({ kind: "locks" }) }, ], - Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, act: startBuild }, { label: t("People & Roles…"), icon: I.people, act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, act: () => setModal({ kind: "settings" }) }], + Tools: [{ label: t("Search depot…"), icon: I.search, act: () => setModal({ kind: "search" }) }, { label: t("Exclusive Locks (typemap)…"), icon: I.lock, act: () => setModal({ kind: "typemap" }) }, { label: slnPath ? t("Build Solution (.sln)") : t("Build Solution — no .sln"), kb: "Ctrl+B", icon: I.hammer, act: startBuild }, { label: t("People & Roles…"), icon: I.people, act: () => setModal({ kind: "users" }) }, { label: t("Settings…"), icon: I.gear, act: () => setModal({ kind: "settings" }) }], Help: [{ label: t("About"), icon: I.info, act: () => setModal({ kind: "about" }) }], }; @@ -1286,6 +1294,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {modal?.kind === "users" && setModal(null)} onFlash={flash} />} {modal?.kind === "search" && setModal(null)} onOpenEditor={(dp) => p4.openInEditor(dp, effEditorId).catch((e) => flash(String(e), true))} onReveal={reveal} onCopy={(dp) => copyText(dp, t("Path"))} />} {modal?.kind === "locks" && setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />} + {modal?.kind === "typemap" && setModal(null)} onFlash={flash} />} {modal?.kind === "blame" && setModal(null)} onFlash={flash} />} {modal?.kind === "diff" && setModal(null)} />} {resolveOpen && setResolveOpen(false)} />} @@ -1967,6 +1976,80 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; ); } +/* ---------------- typemap / exclusive-lock rules ---------------- */ +// binary asset types that should be exclusive-locked (+l) in an Unreal project — +// only one person can check them out at a time, preventing lost binary merges. +const UE_LOCK_EXTS = ["uasset", "umap", "upk", "fbx", "png", "tga", "bmp", "jpg", "jpeg", "psd", "exr", "hdr", "wav", "mp3", "ogg", "ttf", "otf", "ico", "bin", "dds"]; +function TypemapModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) { + const [entries, setEntries] = useState([]); + const [busy, setBusy] = useState(true); + const [saving, setSaving] = useState(false); + const [add, setAdd] = useState(""); + const load = () => { setBusy(true); p4.typemapGet().then(setEntries).catch((e) => { onFlash(String(e), true); setEntries([]); }).finally(() => setBusy(false)); }; + useEffect(() => { load(); const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, []); // eslint-disable-line + const patternOf = (e: string) => (e.split(/\s+/)[1] || "").toLowerCase(); + function addRecommended() { + const have = new Set(entries.map(patternOf)); + const add: string[] = []; + for (const ext of UE_LOCK_EXTS) { + const pat = `//....${ext}`; + if (!have.has(pat)) add.push(`binary+l ${pat}`); + } + if (!add.length) { onFlash(t("All recommended rules are already present."), false); return; } + setEntries((cur) => [...cur, ...add]); + } + function addOne() { + const v = add.trim(); + if (!v) return; + // accept "binary+l //....ext" or just "//....ext" (defaults to binary+l) + const entry = /\s/.test(v) ? v : `binary+l ${v}`; + setEntries((cur) => [...cur, entry]); + setAdd(""); + } + async function save() { + setSaving(true); + try { await p4.typemapSet(entries); onFlash(t("Typemap saved.")); onClose(); } + catch (e) { onFlash(String(e), true); } + finally { setSaving(false); } + } + return ( +
+
e.stopPropagation()}> +
{I.lock}

{t("Exclusive Locks (typemap)")}

+ {t("{n} rules", { n: entries.length })} + +
+
{t("Files matching a “binary+l” rule are exclusive-checkout: only one person can edit them at a time. Recommended for Unreal binary assets. Changing the typemap needs admin rights.")}
+
+ +
{I.search} setAdd(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addOne()} />
+
+
+ {busy &&
{t("Loading…")}
} + {!busy && entries.length === 0 &&
{t("No typemap rules yet.")}
} + {entries.map((e, i) => { + const parts = e.split(/\s+/); + const type = parts[0] || ""; + const pat = parts.slice(1).join(" "); + const excl = /\+l/.test(type); + return ( +
+ {excl ? I.lock : I.unlock}{type} + {pat} + +
+ ); + })} +
+
+ + +
+
+
+ ); +} + /* ---------------- blame / annotate ---------------- */ function BlameModal({ spec, name, onClose, onFlash }: { spec: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void }) { const [text, setText] = useState(null); diff --git a/src/i18n.ts b/src/i18n.ts index f901ea7..ee3c176 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -392,6 +392,19 @@ const D: Record = { "All open": { ru: "Все открытые", de: "Alle offenen", fr: "Tous ouverts", es: "Todos abiertos" }, "No locked files.": { ru: "Залоченных файлов нет.", de: "Keine gesperrten Dateien.", fr: "Aucun fichier verrouillé.", es: "No hay archivos bloqueados." }, "No files open by anyone.": { ru: "Ни у кого нет открытых файлов.", de: "Niemand hat Dateien geöffnet.", fr: "Personne n'a de fichiers ouverts.", es: "Nadie tiene archivos abiertos." }, + "Exclusive Locks (typemap)…": { ru: "Эксклюзивные локи (typemap)…", de: "Exklusive Sperren (typemap)…", fr: "Verrous exclusifs (typemap)…", es: "Bloqueos exclusivos (typemap)…" }, + "Exclusive Locks (typemap)": { ru: "Эксклюзивные локи (typemap)", de: "Exklusive Sperren (typemap)", fr: "Verrous exclusifs (typemap)", es: "Bloqueos exclusivos (typemap)" }, + "Set exclusive-lock type (+l)": { ru: "Задать тип с эксклюзивным локом (+l)", de: "Typ mit exklusiver Sperre setzen (+l)", fr: "Définir le type verrou exclusif (+l)", es: "Establecer tipo de bloqueo exclusivo (+l)" }, + "{n} file(s) set to exclusive-lock type (+l).": { ru: "Файлов с эксклюзивным локом (+l): {n}.", de: "{n} Datei(en) auf exklusive Sperre (+l) gesetzt.", fr: "{n} fichier(s) en verrou exclusif (+l).", es: "{n} archivo(s) en tipo de bloqueo exclusivo (+l)." }, + "{n} rules": { ru: "правил: {n}", de: "{n} Regeln", fr: "{n} règles", es: "{n} reglas" }, + "Files matching a “binary+l” rule are exclusive-checkout: only one person can edit them at a time. Recommended for Unreal binary assets. Changing the typemap needs admin rights.": { ru: "Файлы под правилом «binary+l» — эксклюзивный чекаут: их может править только один человек за раз. Рекомендуется для бинарных ассетов Unreal. Смена typemap требует прав администратора.", de: "Dateien mit einer „binary+l“-Regel sind Exklusiv-Checkout: nur eine Person kann sie gleichzeitig bearbeiten. Empfohlen für binäre Unreal-Assets. Das Ändern der Typemap erfordert Adminrechte.", fr: "Les fichiers correspondant à une règle « binary+l » sont en extraction exclusive : une seule personne peut les modifier à la fois. Recommandé pour les assets binaires Unreal. Modifier la typemap nécessite des droits admin.", es: "Los archivos que coinciden con una regla «binary+l» son de extracción exclusiva: solo una persona puede editarlos a la vez. Recomendado para assets binarios de Unreal. Cambiar la typemap requiere permisos de administrador." }, + "Add recommended Unreal rules": { ru: "Добавить рекомендованные правила Unreal", de: "Empfohlene Unreal-Regeln hinzufügen", fr: "Ajouter les règles Unreal recommandées", es: "Añadir reglas recomendadas de Unreal" }, + "Add rule, e.g. binary+l //....uasset": { ru: "Добавить правило, напр. binary+l //....uasset", de: "Regel hinzufügen, z. B. binary+l //....uasset", fr: "Ajouter une règle, ex. binary+l //....uasset", es: "Añadir regla, p. ej. binary+l //....uasset" }, + "No typemap rules yet.": { ru: "Правил typemap пока нет.", de: "Noch keine Typemap-Regeln.", fr: "Aucune règle de typemap pour l'instant.", es: "Aún no hay reglas de typemap." }, + "All recommended rules are already present.": { ru: "Все рекомендованные правила уже есть.", de: "Alle empfohlenen Regeln sind bereits vorhanden.", fr: "Toutes les règles recommandées sont déjà présentes.", es: "Todas las reglas recomendadas ya están presentes." }, + "Typemap saved.": { ru: "Typemap сохранён.", de: "Typemap gespeichert.", fr: "Typemap enregistrée.", es: "Typemap guardada." }, + "Save typemap": { ru: "Сохранить typemap", de: "Typemap speichern", fr: "Enregistrer la typemap", es: "Guardar typemap" }, + "Remove": { ru: "Убрать", de: "Entfernen", fr: "Retirer", es: "Quitar" }, "Pick the Unreal project working folder first.": { ru: "Сначала выбери рабочую папку Unreal-проекта.", de: "Wähle zuerst den Arbeitsordner des Unreal-Projekts.", fr: "Choisis d'abord le dossier de travail du projet Unreal.", es: "Primero elige la carpeta de trabajo del proyecto Unreal." }, "Disconnected from the server — retrying…": { ru: "Соединение с сервером потеряно — переподключаюсь…", de: "Verbindung zum Server verloren — erneuter Versuch…", fr: "Déconnecté du serveur — nouvelle tentative…", es: "Desconectado del servidor — reintentando…" }, }; diff --git a/src/p4.ts b/src/p4.ts index c14cb53..a59aadd 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -118,6 +118,9 @@ export const p4 = { openedAll: (scope = "") => invoke("p4_opened_all", { scope }), lock: (files: string[]) => invoke("p4_lock", { files }), unlock: (files: string[]) => invoke("p4_unlock", { files }), + typemapGet: () => invoke("p4_typemap_get"), + typemapSet: (entries: string[]) => invoke("p4_typemap_set", { entries }), + reopenType: (files: string[], filetype: string) => invoke("p4_reopen_type", { files, filetype }), shelve: (change: string) => invoke("p4_shelve", { change }), unshelve: (change: string) => invoke("p4_unshelve", { change }), deleteShelf: (change: string) => invoke("p4_delete_shelf", { change }),