From 6cfeb27bdd875aefdaa25b9bc809b8d718fadb68 Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Wed, 8 Jul 2026 11:46:47 +0300 Subject: [PATCH] Workspace (client) spec create + edit - File -> New workspace... (name -> template -> edit -> create & switch) - File -> Edit current workspace... (edit View/Root/Options as spec text) - Backend p4 client -o/-i Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/lib.rs | 35 +++++++++++++++++++++++++++++++++ src/App.tsx | 47 +++++++++++++++++++++++++++++++++++++++++++- src/i18n.ts | 9 +++++++++ src/p4.ts | 2 ++ 4 files changed, 92 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 21d6eab..f175215 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1821,6 +1821,39 @@ async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result, client: String) -> Result { + let conn = current(&state)?; + if client.trim().is_empty() { + return Err("No workspace name".into()); + } + run_text(&conn, &["client", "-o", client.trim()]) +} + +/// Save a workspace (client) spec (`p4 client -i`). Creates it if new. +#[tauri::command] +async fn p4_client_save(state: State<'_, AppState>, spec: String) -> Result { + let conn = current(&state)?; + let mut child = p4_cmd(&conn, false) + .args(["client", "-i"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to start p4: {e}"))?; + if let Some(si) = child.stdin.as_mut() { + si.write_all(spec.as_bytes()).map_err(|e| e.to_string())?; + } + let out = child.wait_with_output().map_err(|e| e.to_string())?; + if !out.status.success() { + let err = decode(&out.stderr); + return Err(if err.trim().is_empty() { decode(&out.stdout).trim().to_string() } else { err.trim().to_string() }); + } + Ok(decode(&out.stdout).trim().to_string()) +} + /// Switch the active workspace (client) without re-authenticating. #[tauri::command] async fn p4_switch_client(state: State<'_, AppState>, client: String) -> Result { @@ -2025,6 +2058,8 @@ pub fn run() { p4_typemap_set, p4_reopen_type, p4_sync_to, + p4_client_spec, + p4_client_save, uasset_thumbnail, uasset_export_3d, p4_shelve, diff --git a/src/App.tsx b/src/App.tsx index bac81d0..e05e945 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -310,6 +310,7 @@ type ModalState = | { kind: "search" } | { kind: "locks" } | { kind: "typemap" } + | { kind: "client"; name: string; isNew: boolean } | { kind: "blame"; spec: string; name: string } | { kind: "diff"; title: string; text: string } | { kind: "about" }; @@ -770,6 +771,10 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set function promptSyncTo() { setPrompt({ title: t("Get Revision"), label: t("Changelist number, label name, or date (YYYY/MM/DD)"), value: "", onSave: (v) => { setPrompt(null); syncTo(v); } }); } + // create a new workspace: ask for a name, then open the spec editor with a template + function newWorkspace() { + setPrompt({ title: t("New workspace"), label: t("Workspace (client) name"), value: `${session?.user || info?.userName || "user"}_new`, onSave: (v) => { setPrompt(null); const n = v.trim(); if (n) setModal({ kind: "client", name: n, isNew: true }); } }); + } // Manual re-scan (same background reconcile as auto, on demand). function rescan() { refresh(activePath, true); } @@ -1045,7 +1050,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set } const menus: Record void }[]> = { - File: [{ label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces }, { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }, { label: t("Disconnect"), icon: I.power, act: onDisconnect }], + File: [{ label: t("Switch workspace…"), icon: I.monitor, act: openWorkspaces }, { label: t("New workspace…"), icon: I.monitor, act: newWorkspace }, ...(info?.clientName ? [{ label: t("Edit current workspace…"), icon: I.gear, act: () => setModal({ kind: "client", name: info.clientName as string, isNew: false }) }] : []), { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }, { label: t("Disconnect"), icon: I.power, act: onDisconnect }], Connection: [{ label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }, { label: t("Choose working folder…"), icon: I.folder, act: () => browseTo("") }], Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, act: rescan }, { label: t("Get Latest"), kb: "Ctrl+G", icon: I.sync, act: getLatest }, { label: t("Get Revision…"), icon: I.clock, act: promptSyncTo }, { label: t("Commit (local)"), icon: I.check, act: doCommit }, { label: t("Submit to server"), kb: "Ctrl+S", icon: I.up, act: pushAll }, ...(needResolve.length ? [{ label: t("Resolve conflicts ({n})", { n: needResolve.length }), icon: I.hex, act: () => setResolveOpen(true) }] : []), { label: t("Revert All…"), icon: I.revert, act: revertAll }, { label: t("Refresh"), kb: "F5", icon: I.sync, act: () => refresh() }], Window: [ @@ -1308,6 +1313,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {modal?.kind === "search" && setModal(null)} onOpenEditor={(dp) => p4.openInEditor(dp, effEditorId).catch((e) => flash(String(e), true))} onReveal={reveal} onCopy={(dp) => copyText(dp, t("Path"))} />} {modal?.kind === "locks" && setModal(null)} onReveal={reveal} onFlash={flash} onUnlock={(dp) => lockFiles([dp], false)} />} {modal?.kind === "typemap" && setModal(null)} onFlash={flash} />} + {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)} />} {resolveOpen && setResolveOpen(false)} />} @@ -1989,6 +1995,45 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; ); } +/* ---------------- workspace (client) spec editor ---------------- */ +function ClientSpecModal({ name, isNew, onClose, onFlash, onSaved }: { name: string; isNew: boolean; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onSaved: (client: string) => void }) { + const [spec, setSpec] = useState(""); + const [busy, setBusy] = useState(true); + const [saving, setSaving] = useState(false); + useEffect(() => { + let live = true; + p4.clientSpec(name).then((s) => { if (live) { setSpec(s); setBusy(false); } }).catch((e) => { if (live) { onFlash(String(e), true); setBusy(false); } }); + const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", k); + return () => { live = false; document.removeEventListener("keydown", k); }; + }, [name]); // eslint-disable-line + async function save() { + setSaving(true); + try { + await p4.clientSave(spec); + onFlash(isNew ? t("Workspace “{c}” created.", { c: name }) : t("Workspace “{c}” updated.", { c: name })); + onSaved(name); + } catch (e) { onFlash(String(e), true); } + finally { setSaving(false); } + } + return ( +
+
e.stopPropagation()}> +
{I.monitor}

{isNew ? t("New workspace") : t("Edit workspace")}

{name} + +
+
{t("Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.")}
+ {busy ?
{t("Loading…")}
+ :