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:
@ -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,
|
||||
|
||||
53
src/App.tsx
53
src/App.tsx
@ -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 }[]>([]);
|
||||
|
||||
10
src/i18n.ts
10
src/i18n.ts
@ -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…" },
|
||||
};
|
||||
|
||||
@ -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[]) =>
|
||||
|
||||
Reference in New Issue
Block a user