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:
85
src/App.tsx
85
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" && <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);
|
||||
|
||||
Reference in New Issue
Block a user