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

@ -600,6 +600,57 @@ async fn p4_submit_change(state: State<'_, AppState>, change: String) -> Result<
run_stream(&conn, &["submit", "-c", &change], "submit")
}
/// Submit a shelved changelist directly from the server (`submit -e`), without
/// needing the files in the workspace.
#[tauri::command]
async fn p4_submit_shelved(state: State<'_, AppState>, change: String) -> Result<String, String> {
let conn = current(&state)?;
run_stream(&conn, &["submit", "-e", change.trim()], "submit")
}
/// Submit a numbered changelist with options: `reopen` keeps the files checked
/// out after submit (`-r`); `revert_unchanged` drops files with no real change
/// (`-f revertunchanged`).
#[tauri::command]
async fn p4_submit_opts(state: State<'_, AppState>, change: String, reopen: bool, revert_unchanged: bool) -> Result<String, String> {
let conn = current(&state)?;
let ch = change.trim().to_string();
let mut args: Vec<String> = vec!["submit".into(), "-c".into(), ch];
if reopen {
args.push("-r".into());
}
if revert_unchanged {
args.push("-f".into());
args.push("revertunchanged".into());
}
let refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
run_stream(&conn, &refs, "submit")
}
/// Path of the workspace `.p4ignore` file (at the client root).
fn ignore_path(conn: &Conn) -> Option<std::path::PathBuf> {
if conn.root.is_empty() {
return None;
}
Some(std::path::Path::new(&conn.root).join(".p4ignore"))
}
/// Read the workspace `.p4ignore` (empty string if none yet).
#[tauri::command]
async fn p4_ignore_read(state: State<'_, AppState>) -> Result<String, String> {
let conn = current(&state)?;
let p = ignore_path(&conn).ok_or("No workspace root known")?;
Ok(std::fs::read_to_string(&p).unwrap_or_default())
}
/// Write the workspace `.p4ignore`.
#[tauri::command]
async fn p4_ignore_write(state: State<'_, AppState>, content: String) -> Result<(), String> {
let conn = current(&state)?;
let p = ignore_path(&conn).ok_or("No workspace root known")?;
std::fs::write(&p, content).map_err(|e| format!("Could not write .p4ignore: {e}"))
}
/// "Uncommit": move files back into the default changelist (like `git reset`).
/// The now-empty numbered changelist is cleaned up on the next `p4_changes`.
#[tauri::command]
@ -2250,6 +2301,10 @@ pub fn run() {
p4_filelog,
p4_clean_preview,
p4_clean_apply,
p4_submit_shelved,
p4_submit_opts,
p4_ignore_read,
p4_ignore_write,
p4_jobs,
p4_fix,
p4_job_spec,

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[]>([]);

View File

@ -476,6 +476,17 @@ const D: Record<string, Tr> = {
"Workspace already matches the depot — nothing to clean.": { ru: "Воркспейс уже соответствует депо — чистить нечего.", de: "Arbeitsbereich stimmt bereits mit dem Depot überein — nichts zu bereinigen.", fr: "L'espace de travail correspond déjà au dépôt — rien à nettoyer.", es: "El espacio de trabajo ya coincide con el depósito — nada que limpiar." },
"Workspace cleaned to match the depot.": { ru: "Воркспейс очищен под депо.", de: "Arbeitsbereich an das Depot angeglichen.", fr: "Espace de travail nettoyé pour correspondre au dépôt.", es: "Espacio de trabajo limpiado para coincidir con el depósito." },
"Clean {n} file(s)": { ru: "Очистить файлов: {n}", de: "{n} Datei(en) bereinigen", fr: "Nettoyer {n} fichier(s)", es: "Limpiar {n} archivo(s)" },
"Submit & keep checked out (-r)": { ru: "Сабмит и оставить открытым (-r)", de: "Übermitteln & ausgecheckt lassen (-r)", fr: "Soumettre et garder extrait (-r)", es: "Enviar y mantener extraído (-r)" },
"Submit, reverting unchanged": { ru: "Сабмит, откатив неизменённые", de: "Übermitteln, Unveränderte zurücksetzen", fr: "Soumettre, annuler les inchangés", es: "Enviar, revirtiendo los sin cambios" },
"Submit shelved (-e)": { ru: "Сабмит из shelve (-e)", de: "Geshelvtes übermitteln (-e)", fr: "Soumettre le remisé (-e)", es: "Enviar lo archivado (-e)" },
"Submit shelved #{n}?": { ru: "Сабмитить отложенное #{n}?", de: "Geshelvtes #{n} übermitteln?", fr: "Soumettre le remisé #{n} ?", es: "¿Enviar lo archivado #{n}?" },
"The shelved files of #{n} are submitted directly from the server.": { ru: "Отложенные файлы #{n} сабмитятся напрямую с сервера.", de: "Die geshelvten Dateien von #{n} werden direkt vom Server übermittelt.", fr: "Les fichiers remisés de #{n} sont soumis directement depuis le serveur.", es: "Los archivos archivados de #{n} se envían directamente desde el servidor." },
"Submitted shelved changelist #{n}.": { ru: "Отправлено отложенное #{n}.", de: "Geshelvter Changelist #{n} übermittelt.", fr: "Changelist remisé #{n} soumis.", es: "Changelist archivado #{n} enviado." },
"Edit .p4ignore…": { ru: "Редактировать .p4ignore…", de: ".p4ignore bearbeiten…", fr: "Modifier .p4ignore…", es: "Editar .p4ignore…" },
"at the workspace root": { ru: "в корне воркспейса", de: "im Arbeitsbereich-Root", fr: "à la racine de l'espace de travail", es: "en la raíz del espacio de trabajo" },
"Files matching these patterns are ignored by reconcile / add. One pattern per line. Requires P4IGNORE to be configured (usually .p4ignore).": { ru: "Файлы под этими паттернами игнорируются при reconcile / add. По одному паттерну на строку. Нужна настроенная переменная P4IGNORE (обычно .p4ignore).", de: "Dateien, die diesen Mustern entsprechen, werden von reconcile / add ignoriert. Ein Muster pro Zeile. Erfordert konfiguriertes P4IGNORE (meist .p4ignore).", fr: "Les fichiers correspondant à ces motifs sont ignorés par reconcile / add. Un motif par ligne. Nécessite P4IGNORE configuré (généralement .p4ignore).", es: "Los archivos que coinciden con estos patrones se ignoran en reconcile / add. Un patrón por línea. Requiere P4IGNORE configurado (normalmente .p4ignore)." },
"Insert Unreal template": { ru: "Вставить шаблон Unreal", de: "Unreal-Vorlage einfügen", fr: "Insérer le modèle Unreal", es: "Insertar plantilla de Unreal" },
".p4ignore saved.": { ru: ".p4ignore сохранён.", de: ".p4ignore gespeichert.", fr: ".p4ignore enregistré.", es: ".p4ignore guardado." },
"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

@ -85,6 +85,10 @@ export const p4 = {
commit: (description: string, files: string[]) =>
invoke<string>("p4_commit", { description, files }),
submitChange: (change: string) => invoke<string>("p4_submit_change", { change }),
submitShelved: (change: string) => invoke<string>("p4_submit_shelved", { change }),
submitOpts: (change: string, reopen: boolean, revertUnchanged: boolean) => invoke<string>("p4_submit_opts", { change, reopen, revertUnchanged }),
ignoreRead: () => invoke<string>("p4_ignore_read"),
ignoreWrite: (content: string) => invoke<void>("p4_ignore_write", { content }),
reopenDefault: (files: string[]) => invoke<string>("p4_reopen_default", { files }),
setDesc: (change: string, description: string) => invoke<string>("p4_set_desc", { change, description }),
add: (files: string[]) => invoke<OpenedFile[]>("p4_add", { files }),