Integrate / Merge / Copy between branches

- Tools -> Integrate / Merge / Copy: pick op + source/target paths, runs into a
  new pending changelist; resolve (if needed) via the Resolve UI and submit
- Backend p4 merge/copy/integrate -c

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-08 11:53:12 +03:00
parent 2811aec6e8
commit fdaf126c00
4 changed files with 86 additions and 1 deletions

View File

@ -1783,6 +1783,28 @@ async fn p4_resolve_file(state: State<'_, AppState>, file: String, mode: String)
}
}
/// Propagate changes between branches/streams: op is "merge" (with resolve),
/// "copy" (overwrite target), or "integrate" (classic). Opens the affected files
/// into a fresh numbered changelist; the user then resolves (if needed) and
/// submits via the normal pending-changelist flow. Returns the changelist number.
#[tauri::command]
async fn p4_branch_op(state: State<'_, AppState>, op: String, source: String, target: String) -> Result<String, String> {
let conn = current(&state)?;
let cmd = match op.as_str() {
"merge" => "merge",
"copy" => "copy",
_ => "integrate",
};
let src = source.trim();
let tgt = target.trim();
if src.is_empty() || tgt.is_empty() {
return Err("Source and target paths are required".into());
}
let cl = create_changelist(&conn, &format!("{cmd} {src} -> {tgt}"))?;
let out = run_text(&conn, &[cmd, "-c", &cl, src, tgt]).unwrap_or_default();
Ok(format!("CL {cl}\n{}", out.trim()))
}
/// Unified diff between two exact revisions (`p4 diff2 -du`).
#[tauri::command]
async fn p4_diff2(state: State<'_, AppState>, spec1: String, spec2: String) -> Result<String, String> {
@ -2084,6 +2106,7 @@ pub fn run() {
p4_sync_to,
p4_labels,
p4_label_tag,
p4_branch_op,
p4_client_spec,
p4_client_save,
uasset_thumbnail,

View File

@ -311,6 +311,7 @@ type ModalState =
| { kind: "locks" }
| { kind: "typemap" }
| { kind: "labels" }
| { kind: "branch" }
| { kind: "client"; name: string; isNew: boolean }
| { kind: "blame"; spec: string; name: string }
| { kind: "diff"; title: string; text: string }
@ -1060,7 +1061,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: 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: 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" }) }],
};
@ -1315,6 +1316,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{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 === "labels" && <LabelsModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />}
{modal?.kind === "branch" && <BranchModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
{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} />}
{modal?.kind === "diff" && <DiffModal title={modal.title} text={modal.text} onClose={() => setModal(null)} />}
@ -1997,6 +1999,55 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string;
);
}
/* ---------------- integrate / merge / copy between branches ---------------- */
function BranchModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) {
const [op, setOp] = useState<"merge" | "copy" | "integrate">("merge");
const [source, setSource] = useState(scope ? scope + "/..." : "//depot/…/...");
const [target, setTarget] = useState("//depot/…/...");
const [busy, setBusy] = useState(false);
useEffect(() => { const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); document.addEventListener("keydown", k); return () => document.removeEventListener("keydown", k); }, [onClose]);
const DESC: Record<string, string> = {
merge: t("Merge changes from source into target — you resolve conflicts, then submit."),
copy: t("Copy source over target verbatim (no merge). Target becomes identical to source."),
integrate: t("Classic integrate — open target files for integration from source."),
};
async function run() {
if (!source.trim() || !target.trim()) { onFlash(t("Source and target are required."), true); return; }
setBusy(true);
try {
const out = await p4.branchOp(op, source.trim(), target.trim());
onFlash(t("{op} opened into a pending changelist. Resolve (if needed) and Submit. {out}", { op, out: out.split("\n")[0] }));
onDone();
} catch (e) { onFlash(String(e), true); }
finally { setBusy(false); }
}
return (
<div className="modal-back" onClick={onClose}>
<div className="picker" style={{ width: 560, maxWidth: "94%" }} onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.branch}<h3>{t("Integrate / Merge / Copy")}</h3>
<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="info-body">
<div className="langsel" style={{ marginBottom: 12 }}>
{(["merge", "copy", "integrate"] as const).map((o) => (
<button key={o} className={"langbtn" + (op === o ? " on" : "")} onClick={() => setOp(o)}>
<span className="lname">{o === "merge" ? t("Merge") : o === "copy" ? t("Copy") : t("Integrate")}</span>
</button>
))}
</div>
<div className="tm-hint" style={{ border: "none", padding: "0 0 12px" }}>{DESC[op]}</div>
<div className="info-row"><span className="rk">{t("Source")}</span><input className="kf-in mono" value={source} onChange={(e) => setSource(e.target.value)} placeholder="//depot/dev/..." /></div>
<div className="info-row"><span className="rk">{t("Target")}</span><input className="kf-in mono" value={target} onChange={(e) => setTarget(e.target.value)} placeholder="//depot/main/..." /></div>
<div className="modal-actions" style={{ marginTop: 16 }}>
<button className="mbtn ghost" onClick={onClose} disabled={busy}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={run} disabled={busy}>{busy ? <span className="ldr sm" /> : null}{t("Run {op}", { op: op === "merge" ? t("Merge") : op === "copy" ? t("Copy") : t("Integrate") })}</button>
</div>
</div>
</div>
</div>
);
}
/* ---------------- labels (list / create / sync-to) ---------------- */
function LabelsModal({ scope, onClose, onFlash, onSyncTo }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSyncTo: (label: string) => void }) {
const [labels, setLabels] = useState<{ label?: string; Update?: string; Owner?: string; Description?: string }[]>([]);

View File

@ -430,6 +430,16 @@ const D: Record<string, Tr> = {
"Label “{c}” tagged at current head.": { ru: "Метка «{c}» проставлена на текущую голову.", de: "Label „{c}“ am aktuellen Head getaggt.", fr: "Label « {c} » posé sur la tête actuelle.", es: "Etiqueta «{c}» marcada en la cabeza actual." },
"No labels.": { ru: "Меток нет.", de: "Keine Labels.", fr: "Aucun label.", es: "Sin etiquetas." },
"Sync to": { ru: "Синкнуть на", de: "Sync auf", fr: "Synchroniser sur", es: "Sincronizar a" },
"Integrate / Merge / Copy…": { ru: "Интеграция / Merge / Copy…", de: "Integrate / Merge / Copy…", fr: "Intégrer / Fusionner / Copier…", es: "Integrar / Fusionar / Copiar…" },
"Integrate / Merge / Copy": { ru: "Интеграция / Merge / Copy", de: "Integrate / Merge / Copy", fr: "Intégrer / Fusionner / Copier", es: "Integrar / Fusionar / Copiar" },
"Integrate": { ru: "Integrate", de: "Integrieren", fr: "Intégrer", es: "Integrar" },
"Merge changes from source into target — you resolve conflicts, then submit.": { ru: "Слить изменения из source в target — ты разрешаешь конфликты и сабмитишь.", de: "Änderungen von Quelle in Ziel zusammenführen — du löst Konflikte und übermittelst.", fr: "Fusionner les changements de la source vers la cible — vous résolvez les conflits puis soumettez.", es: "Fusiona los cambios del origen al destino — resuelves conflictos y envías." },
"Copy source over target verbatim (no merge). Target becomes identical to source.": { ru: "Скопировать source поверх target как есть (без merge). Target станет идентичен source.", de: "Quelle unverändert über Ziel kopieren (kein Merge). Ziel wird identisch zur Quelle.", fr: "Copier la source sur la cible telle quelle (sans fusion). La cible devient identique à la source.", es: "Copia el origen sobre el destino tal cual (sin fusión). El destino queda idéntico al origen." },
"Classic integrate — open target files for integration from source.": { ru: "Классический integrate — открыть файлы target для интеграции из source.", de: "Klassisches Integrate — Zieldateien zur Integration aus der Quelle öffnen.", fr: "Intégration classique — ouvrir les fichiers cibles pour intégration depuis la source.", es: "Integración clásica — abrir los archivos de destino para integrar desde el origen." },
"Target": { ru: "Цель", de: "Ziel", fr: "Cible", es: "Destino" },
"Source and target are required.": { ru: "Нужны источник и цель.", de: "Quelle und Ziel erforderlich.", fr: "Source et cible requises.", es: "Se requieren origen y destino." },
"{op} opened into a pending changelist. Resolve (if needed) and Submit. {out}": { ru: "{op}: файлы открыты в pending-changelist. Разреши (если нужно) и сабмить. {out}", de: "{op} in einen ausstehenden Changelist geöffnet. Auflösen (falls nötig) und übermitteln. {out}", fr: "{op} ouvert dans un changelist en attente. Résolvez (si besoin) et soumettez. {out}", es: "{op} abierto en un changelist pendiente. Resuelve (si hace falta) y envía. {out}" },
"Run {op}": { ru: "Запустить {op}", de: "{op} ausführen", fr: "Lancer {op}", es: "Ejecutar {op}" },
"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

@ -67,6 +67,7 @@ export const p4 = {
syncTo: (scope: string, rev: string) => invoke<string>("p4_sync_to", { scope, rev }),
labels: () => invoke<{ label?: string; Update?: string; Owner?: string; Description?: string; [k: string]: unknown }[]>("p4_labels"),
labelTag: (label: string, scope: string, rev = "") => invoke<string>("p4_label_tag", { label, scope, rev }),
branchOp: (op: "merge" | "copy" | "integrate", source: string, target: string) => invoke<string>("p4_branch_op", { op, source, target }),
diff: (file: string) => invoke<string>("p4_diff", { file }),
revert: (files: string[]) => invoke<string>("p4_revert", { files }),
submit: (description: string, files: string[]) =>