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 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-08 11:46:47 +03:00
parent 0fc0af38da
commit 6cfeb27bdd
4 changed files with 92 additions and 1 deletions

View File

@ -1821,6 +1821,39 @@ async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result<V
Ok(v.into_iter().next().unwrap_or(Value::Null))
}
/// Get a workspace (client) spec as editable text. An unknown name returns a
/// fresh template — used to create a new workspace.
#[tauri::command]
async fn p4_client_spec(state: State<'_, AppState>, client: String) -> Result<String, String> {
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<String, String> {
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<Value, String> {
@ -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,

View File

@ -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<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; act?: () => 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" && <SearchModal scope={activePath} onClose={() => 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" && <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 === "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)} />}
{resolveOpen && <ResolveModal files={needResolve} onResolve={doResolve} onClose={() => 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 (
<div className="modal-back" onClick={onClose}>
<div className="picker wide blame" onClick={(e) => e.stopPropagation()}>
<div className="picker-head">{I.monitor}<h3>{isNew ? t("New workspace") : t("Edit workspace")}</h3><span className="ph-sub">{name}</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>
<div className="tm-hint">{t("Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.")}</div>
{busy ? <div className="ur-empty" style={{ flex: 1 }}><span className="ldr" /> {t("Loading…")}</div>
: <textarea className="code-edit" value={spec} spellCheck={false} onChange={(e) => setSpec(e.target.value)} />}
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
<button className="mbtn ghost" onClick={onClose} disabled={saving}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={save} disabled={saving || busy}>{saving ? <span className="ldr sm" /> : null}{isNew ? t("Create & switch") : t("Save")}</button>
</div>
</div>
</div>
);
}
/* ---------------- typemap / exclusive-lock rules ---------------- */
// binary asset types that should be exclusive-locked (+l) in an Unreal project —
// only one person can check them out at a time, preventing lost binary merges.

View File

@ -412,6 +412,15 @@ const D: Record<string, Tr> = {
"Your synced files change to match {rev}. Un-submitted (opened) work is not touched. You can Get Latest to return to head.": { ru: "Синкнутые файлы изменятся под {rev}. Незасабмиченные (открытые) правки не тронутся. Get Latest вернёт на голову.", de: "Deine synchronisierten Dateien werden an {rev} angepasst. Nicht übermittelte (offene) Arbeit bleibt unberührt. Mit „Get Latest“ kehrst du zum Head zurück.", fr: "Vos fichiers synchronisés passent à {rev}. Le travail non soumis (ouvert) n'est pas touché. « Get Latest » vous ramène à la tête.", es: "Tus archivos sincronizados cambian a {rev}. El trabajo no enviado (abierto) no se toca. Con «Get Latest» vuelves a la cabeza." },
"Synced to {rev}: {msg}": { ru: "Синкнуто на {rev}: {msg}", de: "Auf {rev} synchronisiert: {msg}", fr: "Synchronisé sur {rev} : {msg}", es: "Sincronizado a {rev}: {msg}" },
"Sync workspace to #{n}": { ru: "Синкнуть воркспейс на #{n}", de: "Arbeitsbereich auf #{n} synchronisieren", fr: "Synchroniser l'espace de travail sur #{n}", es: "Sincronizar el espacio de trabajo a #{n}" },
"New workspace…": { ru: "Новый воркспейс…", de: "Neuer Arbeitsbereich…", fr: "Nouvel espace de travail…", es: "Nuevo espacio de trabajo…" },
"New workspace": { ru: "Новый воркспейс", de: "Neuer Arbeitsbereich", fr: "Nouvel espace de travail", es: "Nuevo espacio de trabajo" },
"Edit current workspace…": { ru: "Редактировать текущий воркспейс…", de: "Aktuellen Arbeitsbereich bearbeiten…", fr: "Modifier l'espace de travail actuel…", es: "Editar el espacio de trabajo actual…" },
"Edit workspace": { ru: "Редактирование воркспейса", de: "Arbeitsbereich bearbeiten", fr: "Modifier l'espace de travail", es: "Editar espacio de trabajo" },
"Workspace (client) name": { ru: "Имя воркспейса (client)", de: "Arbeitsbereich- (Client-)Name", fr: "Nom de l'espace de travail (client)", es: "Nombre del espacio de trabajo (client)" },
"Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.": { ru: "Отредактируй спеку воркспейса. Укажи Root — локальную папку, и настрой View (депо → воркспейс). Строки с # — комментарии.", de: "Bearbeite die Arbeitsbereich-Spezifikation. Setze Root auf einen lokalen Ordner und passe das View-Mapping (Depot → Arbeitsbereich) an. Zeilen mit # sind Kommentare.", fr: "Modifiez la spec de l'espace de travail. Définissez Root sur un dossier local et ajustez le mappage View (dépôt → espace de travail). Les lignes commençant par # sont des commentaires.", es: "Edita la especificación del espacio de trabajo. Establece Root en una carpeta local y ajusta el mapeo View (depósito → espacio de trabajo). Las líneas que empiezan con # son comentarios." },
"Workspace “{c}” created.": { ru: "Воркспейс «{c}» создан.", de: "Arbeitsbereich „{c}“ erstellt.", fr: "Espace de travail « {c} » créé.", es: "Espacio de trabajo «{c}» creado." },
"Workspace “{c}” updated.": { ru: "Воркспейс «{c}» обновлён.", de: "Arbeitsbereich „{c}“ aktualisiert.", fr: "Espace de travail « {c} » mis à jour.", es: "Espacio de trabajo «{c}» actualizado." },
"Create & switch": { ru: "Создать и переключиться", de: "Erstellen & wechseln", fr: "Créer et basculer", es: "Crear y cambiar" },
"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…" },
};

View File

@ -106,6 +106,8 @@ export const p4 = {
// built-in editor: write text back to a depot file's working copy
writeDepot: (depot: string, content: string) => invoke<void>("write_depot", { depot, content }),
switchClient: (client: string) => invoke<P4Info>("p4_switch_client", { client }),
clientSpec: (client: string) => invoke<string>("p4_client_spec", { client }),
clientSave: (spec: string) => invoke<string>("p4_client_save", { spec }),
users: () => invoke<User[]>("p4_users"),
groups: (user = "") => invoke<{ group?: string }[]>("p4_groups", { user }),
groupMember: (group: string, user: string, add: boolean) => invoke<string>("p4_group_member", { group, user, add }),