Submit options (-r / revert-unchanged / -e shelved) + .p4ignore editor

- Pending changelist menu: Submit & keep checked out (-r), Submit reverting
  unchanged, Submit shelved (-e)
- Tools -> Edit .p4ignore: read/write workspace .p4ignore with an Unreal template
- Backend p4 submit -r/-f revertunchanged/-e, .p4ignore read/write

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-08 12:09:12 +03:00
parent 28a3989095
commit 5d647f8e92
4 changed files with 127 additions and 1 deletions

View File

@ -315,6 +315,7 @@ type ModalState =
| { kind: "streams" }
| { kind: "jobs" }
| { kind: "clean" }
| { kind: "ignore" }
| { kind: "filelog"; depot: string; name: string }
| { kind: "client"; name: string; isNew: boolean }
| { kind: "blame"; spec: string; name: string }
@ -914,6 +915,19 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
try { await p4.shelve(change); flash(t("Shelved #{n} on the server.", { n: change })); }
catch (e) { flash(String(e), true); } finally { setBusy(false); }
}
// submit variants: reopen after submit (-r), revert-unchanged, or submit a shelf (-e)
async function submitWithOpts(change: string, reopen: boolean, revertUnchanged: boolean) {
if (!(await confirm({ title: t("Submit changelist #{n}?", { n: change }), body: t("This changelist goes to the Perforce server and becomes available to the team. This is a push — it cannot be undone."), confirm: t("Submit") }))) return;
setBusy(true);
try { await p4.submitOpts(change, reopen, revertUnchanged); flash(t("Submitted changelist #{n}.", { n: change })); clearDraftIfCommitted(); await refresh(); await loadHistory(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
async function submitShelved(change: string) {
if (!(await confirm({ title: t("Submit shelved #{n}?", { n: change }), body: t("The shelved files of #{n} are submitted directly from the server.", { n: change }), confirm: t("Submit") }))) return;
setBusy(true);
try { await p4.submitShelved(change); flash(t("Submitted shelved changelist #{n}.", { n: change })); await refresh(); await loadHistory(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
async function unshelveCL(change: string) {
setBusy(true);
try { await p4.unshelve(change); flash(t("Unshelved #{n} into the workspace.", { n: change })); await refresh(); }
@ -1082,7 +1096,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: t("Exclusive Locks (typemap)…"), icon: I.lock, act: () => setModal({ kind: "typemap" }) }, { label: t("Labels…"), icon: I.clock, act: () => setModal({ kind: "labels" }) }, { label: t("Integrate / Merge / Copy…"), icon: I.branch, act: () => setModal({ kind: "branch" }) }, { label: t("Streams…"), icon: I.branch, act: () => setModal({ kind: "streams" }) }, { label: t("Jobs…"), icon: I.log, act: () => setModal({ kind: "jobs" }) }, { 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: t("Labels…"), icon: I.clock, act: () => setModal({ kind: "labels" }) }, { label: t("Integrate / Merge / Copy…"), icon: I.branch, act: () => setModal({ kind: "branch" }) }, { label: t("Streams…"), icon: I.branch, act: () => setModal({ kind: "streams" }) }, { label: t("Jobs…"), icon: I.log, act: () => setModal({ kind: "jobs" }) }, { label: t("Edit .p4ignore…"), icon: I.gear, act: () => setModal({ kind: "ignore" }) }, { 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" }) }],
};
@ -1195,8 +1209,11 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<button className="cl-more" onClick={(e) => openCtx(e, [
{ label: t("Edit description"), act: () => editDesc(cl) },
{ label: t("Attach job…"), icon: I.log, act: () => attachJob(ch) },
{ label: t("Submit & keep checked out (-r)"), icon: I.up, act: () => submitWithOpts(ch, true, false) },
{ label: t("Submit, reverting unchanged"), icon: I.up, act: () => submitWithOpts(ch, false, true) },
{ label: t("Shelve (share on server)"), icon: I.shelf, act: () => shelveCL(ch) },
{ label: t("Unshelve into workspace"), act: () => unshelveCL(ch) },
{ label: t("Submit shelved (-e)"), icon: I.up, act: () => submitShelved(ch) },
{ label: t("Delete shelved files"), danger: true, act: () => deleteShelfCL(ch) },
{ label: t("Uncommit (back to working)"), act: () => uncommit(cf) },
{ label: t("Discard changes · {n} files", { n: cf.length }), danger: true, act: () => discard(cf) },
@ -1342,6 +1359,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{modal?.kind === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
{modal?.kind === "jobs" && <JobsModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "clean" && <CleanModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
{modal?.kind === "ignore" && <IgnoreModal onClose={() => setModal(null)} onFlash={flash} />}
{modal?.kind === "filelog" && <FilelogModal depot={modal.depot} name={modal.name} onClose={() => setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />}
{modal?.kind === "client" && <ClientSpecModal name={modal.name} isNew={modal.isNew} onClose={() => setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />}
{modal?.kind === "blame" && <BlameModal spec={modal.spec} name={modal.name} onClose={() => setModal(null)} onFlash={flash} />}
@ -2025,6 +2043,44 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string;
);
}
/* ---------------- .p4ignore editor ---------------- */
const P4IGNORE_TEMPLATE = "# Unreal Engine — keep generated / local files out of Perforce\nBinaries/\nBuild/\nDerivedDataCache/\nIntermediate/\nSaved/\n.vs/\n*.sln\n*.suo\n*.opensdf\n*.sdf\n*.VC.db\n*.VC.opendb\n";
function IgnoreModal({ onClose, onFlash }: { onClose: () => void; onFlash: (t: string, e?: boolean) => void }) {
const [text, setText] = useState("");
const [busy, setBusy] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
let live = true;
p4.ignoreRead().then((s) => { if (live) { setText(s); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } });
const k = (e: KeyboardEvent) => e.key === "Escape" && onClose();
document.addEventListener("keydown", k);
return () => { live = false; document.removeEventListener("keydown", k); };
}, []); // eslint-disable-line
async function save() {
setSaving(true);
try { await p4.ignoreWrite(text); onFlash(t(".p4ignore saved.")); onClose(); }
catch (e) { onFlash(String(e), true); }
finally { setSaving(false); }
}
return (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.gear}<h3>.p4ignore</h3><span className="ph-sub">{t("at the workspace root")}</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 these patterns are ignored by reconcile / add. One pattern per line. Requires P4IGNORE to be configured (usually .p4ignore).")}
{!busy && !text.trim() && <> <button className="ce-btn" style={{ display: "inline-flex", marginLeft: 6 }} onClick={() => setText(P4IGNORE_TEMPLATE)}>{t("Insert Unreal template")}</button></>}</div>
{busy ? <div className="ur-empty" style={{ flex: 1 }}><span className="ldr" /> {t("Loading…")}</div>
: <textarea className="code-edit" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} placeholder={"Binaries/\nIntermediate/\nSaved/\n*.tmp"} />}
<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 || busy}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
</div>
</div>
</div>
);
}
/* ---------------- clean workspace (make it match the depot) ---------------- */
function CleanModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
const [files, setFiles] = useState<OpenedFile[]>([]);