Streams: list / switch / merge down / copy up
- Tools -> Streams: list server streams, switch the workspace to a stream, merge down from parent / copy up to parent (into a pending changelist) - Backend p4 streams, client -s -S, merge/copy -S Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -1867,6 +1867,52 @@ async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result<V
|
||||
Ok(v.into_iter().next().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
/// List streams on the server.
|
||||
#[tauri::command]
|
||||
async fn p4_streams(state: State<'_, AppState>) -> Result<Vec<Value>, String> {
|
||||
let conn = current(&state)?;
|
||||
run_json(&conn, &["streams", "-m", "300"])
|
||||
}
|
||||
|
||||
/// Switch the current workspace to a stream (`p4 client -s -S <stream>`), 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<Value, String> {
|
||||
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<String, String> {
|
||||
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,
|
||||
|
||||
@ -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}
|
||||
|
||||
62
src/App.tsx
62
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" && <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 === "streams" && <StreamsModal current={String(info?.Stream || "")} onClose={() => setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />}
|
||||
{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)} />}
|
||||
@ -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 (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.branch}<h3>{t("Streams")}</h3><span className="ph-sub">{current || t("(not on a stream)")}</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>
|
||||
{current && (
|
||||
<div className="rslv-bar">
|
||||
<span style={{ flex: 1, fontSize: 12, color: "var(--muted)" }}>{t("Current stream integration")}</span>
|
||||
<button className="mbtn ghost" onClick={() => onInteg(current, "down")}>{t("Merge down")}</button>
|
||||
<button className="mbtn ghost" onClick={() => onInteg(current, "up")}>{t("Copy up")}</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="ur-filter" style={{ margin: "0 16px 8px" }}>{I.search}<input placeholder={t("Filter streams…")} value={q} onChange={(e) => setQ(e.target.value)} /></div>
|
||||
<div className="srch-list">
|
||||
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Loading…")}</div>}
|
||||
{!busy && rows.length === 0 && <div className="ur-empty">{t("No streams (this depot may be classic, not stream-based).")}</div>}
|
||||
{rows.map((x) => {
|
||||
const st = x.Stream || "";
|
||||
const on = st === current;
|
||||
return (
|
||||
<div key={st} className={"srch-row" + (on ? " on" : "")}>
|
||||
<span className={"held" + (on ? " locked" : "")} style={{ marginRight: 4 }}>{I.branch}<span>{x.Type || ""}</span></span>
|
||||
<span className="srch-body"><span className="n">{x.Name || st}{on && " · " + t("current")}</span><span className="p">{st}{x.Parent && x.Parent !== "none" ? " ← " + x.Parent : ""}</span></span>
|
||||
{!on && <button className="rslv-act" onClick={() => onSwitch(st)}>{t("Switch")}</button>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 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");
|
||||
|
||||
12
src/i18n.ts
12
src/i18n.ts
@ -440,6 +440,18 @@ const D: Record<string, Tr> = {
|
||||
"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…" },
|
||||
};
|
||||
|
||||
@ -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<string>("p4_label_tag", { label, scope, rev }),
|
||||
branchOp: (op: "merge" | "copy" | "integrate", source: string, target: string) => invoke<string>("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<P4Info>("p4_switch_stream", { stream }),
|
||||
streamInteg: (stream: string, direction: "down" | "up") => invoke<string>("p4_stream_integ", { stream, direction }),
|
||||
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