diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7fd43fd..cfa8d1d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1867,6 +1867,52 @@ async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result) -> Result, String> { + let conn = current(&state)?; + run_json(&conn, &["streams", "-m", "300"]) +} + +/// Switch the current workspace to a stream (`p4 client -s -S `), then +/// return the refreshed `p4 info`. The workspace View is rebuilt from the stream. +#[tauri::command] +async fn p4_switch_stream(state: State<'_, AppState>, stream: String) -> Result { + let conn = current(&state)?; + let stream = stream.trim(); + if stream.is_empty() { + return Err("No stream".into()); + } + run_text(&conn, &["client", "-s", "-S", stream])?; + let info = run_json(&conn, &["info"])?; + let info = info.into_iter().next().unwrap_or(Value::Null); + let root = info.get("clientRoot").and_then(|r| r.as_str()).unwrap_or("").to_string(); + state.0.lock().unwrap_or_else(|e| e.into_inner()).root = root; + Ok(info) +} + +/// Stream integration: direction "down" merges from the parent into the stream +/// (merge down), "up" copies the stream to its parent (copy up). Opens the work +/// into a fresh changelist; resolve (if needed) and submit as usual. +#[tauri::command] +async fn p4_stream_integ(state: State<'_, AppState>, stream: String, direction: String) -> Result { + let conn = current(&state)?; + let stream = stream.trim(); + if stream.is_empty() { + return Err("No stream".into()); + } + let (verb, extra, label) = if direction == "up" { + ("copy", vec!["-r"], "copy up") + } else { + ("merge", vec![], "merge down") + }; + let cl = create_changelist(&conn, &format!("{label} {stream}"))?; + let mut args: Vec<&str> = vec![verb, "-S", stream, "-c", &cl]; + args.extend(extra); + let out = run_text(&conn, &args).unwrap_or_default(); + Ok(format!("CL {cl}\n{}", out.trim())) +} + /// Get a workspace (client) spec as editable text. An unknown name returns a /// fresh template — used to create a new workspace. #[tauri::command] @@ -2107,6 +2153,9 @@ pub fn run() { p4_labels, p4_label_tag, p4_branch_op, + p4_streams, + p4_switch_stream, + p4_stream_integ, p4_client_spec, p4_client_save, uasset_thumbnail, diff --git a/src/App.css b/src/App.css index 02f9fba..c4e0031 100644 --- a/src/App.css +++ b/src/App.css @@ -780,6 +780,7 @@ body.resizing-v{cursor:row-resize!important;user-select:none} .srch-list{flex:1;overflow-y:auto;padding:6px 10px 12px} .srch-row{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:9px;transition:background .12s} .srch-row:hover{background:var(--hover)} +.srch-row.on{background:rgba(124,110,246,.12);box-shadow:inset 0 0 0 1px rgba(124,110,246,.25)} .srch-body{flex:1;display:flex;flex-direction:column;gap:1px;min-width:0} .srch-body .n{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .srch-body .p{font-size:11px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} diff --git a/src/App.tsx b/src/App.tsx index 1bb3a67..7dbe410 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -312,6 +312,7 @@ type ModalState = | { kind: "typemap" } | { kind: "labels" } | { kind: "branch" } + | { kind: "streams" } | { kind: "client"; name: string; isNew: boolean } | { kind: "blame"; spec: string; name: string } | { kind: "diff"; title: string; text: string } @@ -733,6 +734,16 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set try { setModal({ kind: "scope", path, dirs: await p4.dirs(path) }); } catch (e) { flash(String(e), true); } } + async function doSwitchStream(stream: string) { + setModal(null); setBusy(true); + try { const i = await p4.switchStream(stream); onInfo(i); flash(t("Switched to stream {s}", { s: stream })); await refresh(); await loadHistory(); } + catch (e) { flash(String(e), true); setBusy(false); } + } + async function doStreamInteg(stream: string, dir: "down" | "up") { + setBusy(true); + try { const out = await p4.streamInteg(stream, dir); flash(t("{d} opened into a pending changelist — resolve & submit. {out}", { d: dir === "up" ? t("Copy up") : t("Merge down"), out: out.split("\n")[0] })); await refresh(); } + catch (e) { flash(String(e), true); } finally { setBusy(false); } + } function chooseScope(path: string) { setModal(null); setActivePath(path); @@ -1061,7 +1072,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: 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: 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" }) }], }; @@ -1317,6 +1328,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {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 === "streams" && setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />} {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)} />} @@ -1999,6 +2011,54 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; ); } +/* ---------------- streams (list / switch / merge down / copy up) ---------------- */ +function StreamsModal({ current, onClose, onFlash, onSwitch, onInteg }: { current: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSwitch: (s: string) => void; onInteg: (s: string, dir: "down" | "up") => void }) { + const [streams, setStreams] = useState<{ Stream?: string; Name?: string; Type?: string; Parent?: string }[]>([]); + const [busy, setBusy] = useState(true); + const [q, setQ] = useState(""); + useEffect(() => { + let live = true; + p4.streams().then((s) => live && setStreams(s)).catch((e) => { if (live) { onFlash(String(e), true); setStreams([]); } }).finally(() => live && setBusy(false)); + const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", k); + return () => { live = false; document.removeEventListener("keydown", k); }; + }, []); // eslint-disable-line + const s = q.trim().toLowerCase(); + const rows = streams.filter((x) => !s || (x.Stream || "").toLowerCase().includes(s) || (x.Name || "").toLowerCase().includes(s)); + return ( +
+
e.stopPropagation()}> +
{I.branch}

{t("Streams")}

{current || t("(not on a stream)")} + +
+ {current && ( +
+ {t("Current stream integration")} + + +
+ )} +
{I.search} setQ(e.target.value)} />
+
+ {busy &&
{t("Loading…")}
} + {!busy && rows.length === 0 &&
{t("No streams (this depot may be classic, not stream-based).")}
} + {rows.map((x) => { + const st = x.Stream || ""; + const on = st === current; + return ( +
+ {I.branch}{x.Type || ""} + {x.Name || st}{on && " · " + t("current")}{st}{x.Parent && x.Parent !== "none" ? " ← " + x.Parent : ""} + {!on && } +
+ ); + })} +
+
+
+ ); +} + /* ---------------- 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"); diff --git a/src/i18n.ts b/src/i18n.ts index c15636b..9a77058 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -440,6 +440,18 @@ const D: Record = { "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}" }, + "Streams…": { ru: "Стримы…", de: "Streams…", fr: "Streams…", es: "Streams…" }, + "Streams": { ru: "Стримы", de: "Streams", fr: "Streams", es: "Streams" }, + "(not on a stream)": { ru: "(не на стриме)", de: "(nicht auf einem Stream)", fr: "(pas sur un stream)", es: "(sin stream)" }, + "Current stream integration": { ru: "Интеграция текущего стрима", de: "Integration des aktuellen Streams", fr: "Intégration du stream actuel", es: "Integración del stream actual" }, + "Merge down": { ru: "Merge down (из родителя)", de: "Merge down (vom Parent)", fr: "Merge down (depuis le parent)", es: "Merge down (desde el padre)" }, + "Copy up": { ru: "Copy up (в родителя)", de: "Copy up (zum Parent)", fr: "Copy up (vers le parent)", es: "Copy up (al padre)" }, + "Filter streams…": { ru: "Фильтр стримов…", de: "Streams filtern…", fr: "Filtrer les streams…", es: "Filtrar streams…" }, + "No streams (this depot may be classic, not stream-based).": { ru: "Стримов нет (депо может быть классическим, не на стримах).", de: "Keine Streams (dieses Depot ist evtl. klassisch, nicht stream-basiert).", fr: "Aucun stream (ce dépôt est peut-être classique, pas basé sur des streams).", es: "Sin streams (este depósito puede ser clásico, no basado en streams)." }, + "current": { ru: "текущий", de: "aktuell", fr: "actuel", es: "actual" }, + "Switch": { ru: "Переключить", de: "Wechseln", fr: "Basculer", es: "Cambiar" }, + "Switched to stream {s}": { ru: "Переключено на стрим {s}", de: "Zu Stream {s} gewechselt", fr: "Basculé sur le stream {s}", es: "Cambiado al stream {s}" }, + "{d} opened into a pending changelist — resolve & submit. {out}": { ru: "{d}: файлы открыты в pending-changelist — разреши и сабмить. {out}", de: "{d} in einen ausstehenden Changelist geöffnet — auflösen & übermitteln. {out}", fr: "{d} ouvert dans un changelist en attente — résolvez et soumettez. {out}", es: "{d} abierto en un changelist pendiente — resuelve y envía. {out}" }, "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 9b8dcd9..11b62bd 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -68,6 +68,9 @@ export const p4 = { 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 }), + streams: () => invoke<{ Stream?: string; Name?: string; Type?: string; Parent?: string; [k: string]: unknown }[]>("p4_streams"), + switchStream: (stream: string) => invoke("p4_switch_stream", { stream }), + streamInteg: (stream: string, direction: "down" | "up") => invoke("p4_stream_integ", { stream, direction }), diff: (file: string) => invoke("p4_diff", { file }), revert: (files: string[]) => invoke("p4_revert", { files }), submit: (description: string, files: string[]) =>