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 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-08 11:41:24 +03:00
parent b40358e0f5
commit e2ad438ed4
5 changed files with 185 additions and 1 deletions

View File

@ -1314,6 +1314,87 @@ async fn p4_unlock(state: State<'_, AppState>, files: Vec<String>) -> Result<Str
run_text(&conn, &args)
}
/// Read the server typemap as a list of "TYPE PATTERN" entries.
/// (Empty list if the typemap is unset. Requires admin to *change*, not to read.)
#[tauri::command]
async fn p4_typemap_get(state: State<'_, AppState>) -> Result<Vec<String>, 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<String>) -> Result<String, String> {
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<String>, filetype: String) -> Result<String, String> {
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,

View File

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

View File

@ -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" && <UsersRoles me={info?.userName || ""} onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "search" && <SearchModal scope={activePath} onClose={() => 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" && <LocksModal me={info?.userName || ""} onClose={() => setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />}
{modal?.kind === "typemap" && <TypemapModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "blame" && <BlameModal spec={modal.spec} name={modal.name} onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "diff" && <DiffModal title={modal.title} text={modal.text} onClose={() => setModal(null)} />}
{resolveOpen && <ResolveModal files={needResolve} onResolve={doResolve} onClose={() => 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<string[]>([]);
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 (
<div className="modal-back" onClick={onClose}>
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.lock}<h3>{t("Exclusive Locks (typemap)")}</h3>
<span className="ph-sub">{t("{n} rules", { n: entries.length })}</span>
<button className="x" onClick={onClose}><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg></button>
</div>
<div className="tm-hint">{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.")}</div>
<div className="rslv-bar">
<button className="mbtn primary" onClick={addRecommended}>{I.lock}{t("Add recommended Unreal rules")}</button>
<div className="ur-filter" style={{ flex: 1, padding: 0, border: "none" }}>{I.search}<input placeholder={t("Add rule, e.g. binary+l //....uasset")} value={add} onChange={(e) => setAdd(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addOne()} /></div>
</div>
<div className="srch-list">
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
{!busy && entries.length === 0 && <div className="ur-empty">{t("No typemap rules yet.")}</div>}
{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 (
<div key={i + e} className="srch-row">
<span className={"held" + (excl ? " locked" : "")} style={{ marginRight: 4 }}>{excl ? I.lock : I.unlock}<span>{type}</span></span>
<span className="srch-body"><span className="n" style={{ fontFamily: "var(--mono)", fontSize: 12 }}>{pat}</span></span>
<button className="srch-act" title={t("Remove")} onClick={() => setEntries((cur) => cur.filter((_, j) => j !== i))}></button>
</div>
);
})}
</div>
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={save} disabled={saving}>{saving ? <span className="ldr sm" /> : null}{t("Save typemap")}</button>
</div>
</div>
</div>
);
}
/* ---------------- blame / annotate ---------------- */
function BlameModal({ spec, name, onClose, onFlash }: { spec: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
const [text, setText] = useState<string | null>(null);

View File

@ -392,6 +392,19 @@ const D: Record<string, Tr> = {
"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…" },
};

View File

@ -118,6 +118,9 @@ export const p4 = {
openedAll: (scope = "") => invoke<OpenedFile[]>("p4_opened_all", { scope }),
lock: (files: string[]) => invoke<string>("p4_lock", { files }),
unlock: (files: string[]) => invoke<string>("p4_unlock", { files }),
typemapGet: () => invoke<string[]>("p4_typemap_get"),
typemapSet: (entries: string[]) => invoke<string>("p4_typemap_set", { entries }),
reopenType: (files: string[], filetype: string) => invoke<string>("p4_reopen_type", { files, filetype }),
shelve: (change: string) => invoke<string>("p4_shelve", { change }),
unshelve: (change: string) => invoke<string>("p4_unshelve", { change }),
deleteShelf: (change: string) => invoke<string>("p4_delete_shelf", { change }),