diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 618c6ac..7fd43fd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { + 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 { @@ -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, diff --git a/src/App.tsx b/src/App.tsx index 0d75406..1bb3a67 100644 --- a/src/App.tsx +++ b/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" && setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />} {modal?.kind === "typemap" && setModal(null)} onFlash={flash} />} {modal?.kind === "labels" && setModal(null)} onFlash={flash} onSyncTo={(l) => { setModal(null); syncTo(l); }} />} + {modal?.kind === "branch" && setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />} {modal?.kind === "client" && setModal(null)} onFlash={flash} onSaved={(c) => { setModal(null); if (modal.isNew) { switchWorkspace(c); } else { refresh(); } }} />} {modal?.kind === "blame" && setModal(null)} onFlash={flash} />} {modal?.kind === "diff" && 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 = { + 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 ( +
+
e.stopPropagation()}> +
{I.branch}

{t("Integrate / Merge / Copy")}

+ +
+
+
+ {(["merge", "copy", "integrate"] as const).map((o) => ( + + ))} +
+
{DESC[op]}
+
{t("Source")} setSource(e.target.value)} placeholder="//depot/dev/..." />
+
{t("Target")} setTarget(e.target.value)} placeholder="//depot/main/..." />
+
+ + +
+
+
+
+ ); +} + /* ---------------- 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 }[]>([]); diff --git a/src/i18n.ts b/src/i18n.ts index 7e59083..c15636b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -430,6 +430,16 @@ const D: Record = { "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…" }, }; diff --git a/src/p4.ts b/src/p4.ts index 48e5f3c..9b8dcd9 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -67,6 +67,7 @@ export const p4 = { syncTo: (scope: string, rev: string) => invoke("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("p4_label_tag", { label, scope, rev }), + branchOp: (op: "merge" | "copy" | "integrate", source: string, target: string) => invoke("p4_branch_op", { op, source, target }), diff: (file: string) => invoke("p4_diff", { file }), revert: (files: string[]) => invoke("p4_revert", { files }), submit: (description: string, files: string[]) =>