From 28a39890951b825b477ad41c6a11454b72cba7cd Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Wed, 8 Jul 2026 12:06:11 +0300 Subject: [PATCH] Clean workspace (make it match the depot) - Actions -> Clean workspace: preview extra/deleted/modified files, then apply p4 clean (opened files untouched) - Backend p4 clean -n / clean Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/lib.rs | 27 ++++++++++++++++++++++ src/App.tsx | 54 +++++++++++++++++++++++++++++++++++++++++++- src/i18n.ts | 7 ++++++ src/p4.ts | 2 ++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 81c69dc..2473e81 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1867,6 +1867,31 @@ async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result, scope: String) -> Result, String> { + let conn = current(&state)?; + run_json(&conn, &["clean", "-n", &scope_spec(&scope)]).or_else(|e| { + let l = e.to_lowercase(); + if l.contains("no file") || l.contains("up-to-date") || l.contains("no such") || l.contains("no differing") { + Ok(Vec::new()) + } else { + Err(e) + } + }) +} + +/// Apply `p4 clean`: make the workspace match the depot, discarding un-opened +/// local edits/adds/deletes. Streams progress. +#[tauri::command] +async fn p4_clean_apply(state: State<'_, AppState>, scope: String) -> Result { + let conn = current(&state)?; + let msg = run_stream(&conn, &["clean", &scope_spec(&scope)], "sync")?; + Ok(if msg.is_empty() { "Workspace already matches the depot.".into() } else { msg }) +} + /// Full revision history of a single file (`p4 filelog -l`), including /// per-revision changelist, action, user, time and description. Returns the /// tagged object with rev0/change0/action0/… arrays. @@ -2223,6 +2248,8 @@ pub fn run() { p4_switch_stream, p4_stream_integ, p4_filelog, + p4_clean_preview, + p4_clean_apply, p4_jobs, p4_fix, p4_job_spec, diff --git a/src/App.tsx b/src/App.tsx index d05202f..e0c3887 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -314,6 +314,7 @@ type ModalState = | { kind: "branch" } | { kind: "streams" } | { kind: "jobs" } + | { kind: "clean" } | { kind: "filelog"; depot: string; name: string } | { kind: "client"; name: string; isNew: boolean } | { kind: "blame"; spec: string; name: string } @@ -1074,7 +1075,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("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() }], + Actions: [{ label: t("Rescan changes"), kb: "Ctrl+R", icon: I.scan, act: rescan }, { label: t("Clean workspace…"), icon: I.revert, act: () => setModal({ kind: "clean" }) }, { 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: [ { label: (dockOpen && dockTab === "log" ? "✓ " : "") + t("Log"), kb: "Ctrl+L", icon: I.log, act: () => openDock("log") }, { label: (dockOpen && dockTab === "terminal" ? "✓ " : "") + t("Terminal"), kb: "Ctrl+`", icon: I.terminal, act: () => openDock("terminal") }, @@ -1340,6 +1341,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set {modal?.kind === "branch" && setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />} {modal?.kind === "streams" && setModal(null)} onFlash={flash} onSwitch={doSwitchStream} onInteg={doStreamInteg} />} {modal?.kind === "jobs" && setModal(null)} onFlash={flash} />} + {modal?.kind === "clean" && setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />} {modal?.kind === "filelog" && setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />} {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} />} @@ -2023,6 +2025,56 @@ function LocksModal({ me, onClose, onReveal, onFlash, onUnlock }: { me: string; ); } +/* ---------------- clean workspace (make it match the depot) ---------------- */ +function CleanModal({ scope, onClose, onFlash, onDone }: { scope: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onDone: () => void }) { + const [files, setFiles] = useState([]); + const [busy, setBusy] = useState(true); + const [applying, setApplying] = useState(false); + useEffect(() => { + let live = true; + p4.cleanPreview(scope).then((f) => live && setFiles(f)).catch((e) => { if (live) { onFlash(String(e), true); setFiles([]); } }).finally(() => live && setBusy(false)); + const k = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + document.addEventListener("keydown", k); + return () => { live = false; document.removeEventListener("keydown", k); }; + }, [scope]); // eslint-disable-line + async function apply() { + setApplying(true); + try { await p4.cleanApply(scope); onFlash(t("Workspace cleaned to match the depot.")); onDone(); } + catch (e) { onFlash(String(e), true); } + finally { setApplying(false); } + } + return ( +
+
e.stopPropagation()}> +
{I.revert}

{t("Clean workspace")}

{scope || "//…"} · {t("{n} changes", { n: files.length })} + +
+
{t("Clean makes the workspace exactly match the depot: extra local files are deleted, locally-deleted files restored, and un-opened local edits reverted. Opened (checked-out) files are NOT touched. This cannot be undone.")}
+
+ {busy &&
{t("Scanning…")}
} + {!busy && files.length === 0 &&
{t("Workspace already matches the depot — nothing to clean.")}
} + {files.map((f) => { + const dp = f.depotFile || f.clientFile || ""; + const sp = splitPath(dp); + const st = statusOf(f.action); + return ( +
+ {st.ch} + {sp.name}{sp.dir} + {f.action} +
+ ); + })} +
+
+ + +
+
+
+ ); +} + /* ---------------- file history timeline (filelog) ---------------- */ function FilelogModal({ depot, name, onClose, onFlash, onShowDiff }: { depot: string; name: string; onClose: () => void; onFlash: (t: string, e?: boolean) => void; onShowDiff: (title: string, text: string) => void }) { const [revs, setRevs] = useState([]); diff --git a/src/i18n.ts b/src/i18n.ts index 53e475c..fe13e76 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -469,6 +469,13 @@ const D: Record = { "File history": { ru: "История файла", de: "Dateiverlauf", fr: "Historique du fichier", es: "Historial del archivo" }, "No history.": { ru: "Истории нет.", de: "Kein Verlauf.", fr: "Aucun historique.", es: "Sin historial." }, "Diff ↔ prev": { ru: "Diff ↔ пред.", de: "Diff ↔ vorher", fr: "Diff ↔ préc.", es: "Diff ↔ ant." }, + "Clean workspace…": { ru: "Очистить воркспейс…", de: "Arbeitsbereich bereinigen…", fr: "Nettoyer l'espace de travail…", es: "Limpiar espacio de trabajo…" }, + "Clean workspace": { ru: "Очистка воркспейса", de: "Arbeitsbereich bereinigen", fr: "Nettoyer l'espace de travail", es: "Limpiar espacio de trabajo" }, + "{n} changes": { ru: "изменений: {n}", de: "{n} Änderungen", fr: "{n} changements", es: "{n} cambios" }, + "Clean makes the workspace exactly match the depot: extra local files are deleted, locally-deleted files restored, and un-opened local edits reverted. Opened (checked-out) files are NOT touched. This cannot be undone.": { ru: "Clean приводит воркспейс в точное соответствие депо: лишние локальные файлы удаляются, удалённые локально — восстанавливаются, неоткрытые локальные правки откатываются. Открытые (взятые на редактирование) файлы НЕ трогаются. Отменить нельзя.", de: "Clean bringt den Arbeitsbereich exakt in Übereinstimmung mit dem Depot: zusätzliche lokale Dateien werden gelöscht, lokal gelöschte wiederhergestellt und nicht geöffnete lokale Änderungen zurückgesetzt. Geöffnete (ausgecheckte) Dateien bleiben unberührt. Nicht widerrufbar.", fr: "Clean aligne exactement l'espace de travail sur le dépôt : les fichiers locaux supplémentaires sont supprimés, les fichiers supprimés localement restaurés et les modifications locales non ouvertes annulées. Les fichiers ouverts (extraits) ne sont PAS touchés. Irréversible.", es: "Clean hace que el espacio de trabajo coincida exactamente con el depósito: los archivos locales extra se eliminan, los borrados localmente se restauran y las ediciones locales no abiertas se revierten. Los archivos abiertos (extraídos) NO se tocan. No se puede deshacer." }, + "Workspace already matches the depot — nothing to clean.": { ru: "Воркспейс уже соответствует депо — чистить нечего.", de: "Arbeitsbereich stimmt bereits mit dem Depot überein — nichts zu bereinigen.", fr: "L'espace de travail correspond déjà au dépôt — rien à nettoyer.", es: "El espacio de trabajo ya coincide con el depósito — nada que limpiar." }, + "Workspace cleaned to match the depot.": { ru: "Воркспейс очищен под депо.", de: "Arbeitsbereich an das Depot angeglichen.", fr: "Espace de travail nettoyé pour correspondre au dépôt.", es: "Espacio de trabajo limpiado para coincidir con el depósito." }, + "Clean {n} file(s)": { ru: "Очистить файлов: {n}", de: "{n} Datei(en) bereinigen", fr: "Nettoyer {n} fichier(s)", es: "Limpiar {n} archivo(s)" }, "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 9b30252..45b1a8c 100644 --- a/src/p4.ts +++ b/src/p4.ts @@ -72,6 +72,8 @@ export const p4 = { switchStream: (stream: string) => invoke("p4_switch_stream", { stream }), streamInteg: (stream: string, direction: "down" | "up") => invoke("p4_stream_integ", { stream, direction }), filelog: (depot: string) => invoke>("p4_filelog", { depot }), + cleanPreview: (scope = "") => invoke("p4_clean_preview", { scope }), + cleanApply: (scope = "") => invoke("p4_clean_apply", { scope }), jobs: () => invoke<{ Job?: string; Status?: string; Description?: string; User?: string; [k: string]: unknown }[]>("p4_jobs"), fix: (job: string, change: string) => invoke("p4_fix", { job, change }), jobSpec: (job: string) => invoke("p4_job_spec", { job }),