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 <noreply@anthropic.com>
This commit is contained in:
@ -1867,6 +1867,31 @@ async fn p4_latest_change(state: State<'_, AppState>, scope: String) -> Result<V
|
||||
Ok(v.into_iter().next().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
/// Preview `p4 clean` — files that would be deleted (local extras), restored
|
||||
/// (locally deleted) or refreshed (locally modified but not opened) to make the
|
||||
/// workspace exactly match the depot. Non-destructive (`-n`).
|
||||
#[tauri::command]
|
||||
async fn p4_clean_preview(state: State<'_, AppState>, scope: String) -> Result<Vec<Value>, 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<String, String> {
|
||||
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,
|
||||
|
||||
54
src/App.tsx
54
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<string, { label: string; kb?: string; ext?: boolean; icon?: ReactNode; act?: () => 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" && <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 === "jobs" && <JobsModal onClose={() => setModal(null)} onFlash={flash} />}
|
||||
{modal?.kind === "clean" && <CleanModal scope={activePath} onClose={() => setModal(null)} onFlash={flash} onDone={() => { setModal(null); refresh(); }} />}
|
||||
{modal?.kind === "filelog" && <FilelogModal depot={modal.depot} name={modal.name} onClose={() => setModal(null)} onFlash={flash} onShowDiff={(title, text) => setModal({ kind: "diff", title, text })} />}
|
||||
{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} />}
|
||||
@ -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<OpenedFile[]>([]);
|
||||
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 (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="picker wide" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="picker-head">{I.revert}<h3>{t("Clean workspace")}</h3><span className="ph-sub">{scope || "//…"} · {t("{n} changes", { n: files.length })}</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" style={{ color: "var(--del)" }}>{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.")}</div>
|
||||
<div className="srch-list">
|
||||
{busy && <div className="ur-empty"><span className="ldr sm" /> {t("Scanning…")}</div>}
|
||||
{!busy && files.length === 0 && <div className="ur-empty">{t("Workspace already matches the depot — nothing to clean.")}</div>}
|
||||
{files.map((f) => {
|
||||
const dp = f.depotFile || f.clientFile || "";
|
||||
const sp = splitPath(dp);
|
||||
const st = statusOf(f.action);
|
||||
return (
|
||||
<div key={dp} className="srch-row">
|
||||
<span className={"stat " + st.cls} style={{ marginRight: 4 }}>{st.ch}</span>
|
||||
<span className="srch-body"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
|
||||
<span className="lk-who">{f.action}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="modal-actions" style={{ padding: "12px 16px", borderTop: "1px solid var(--border-soft)" }}>
|
||||
<button className="mbtn ghost" onClick={onClose} disabled={applying}>{t("Cancel")}</button>
|
||||
<button className="mbtn danger" onClick={apply} disabled={applying || busy || files.length === 0}>{applying ? <span className="ldr sm" /> : null}{t("Clean {n} file(s)", { n: files.length })}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 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<FileRev[]>([]);
|
||||
|
||||
@ -469,6 +469,13 @@ const D: Record<string, Tr> = {
|
||||
"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…" },
|
||||
};
|
||||
|
||||
@ -72,6 +72,8 @@ export const p4 = {
|
||||
switchStream: (stream: string) => invoke<P4Info>("p4_switch_stream", { stream }),
|
||||
streamInteg: (stream: string, direction: "down" | "up") => invoke<string>("p4_stream_integ", { stream, direction }),
|
||||
filelog: (depot: string) => invoke<Record<string, unknown>>("p4_filelog", { depot }),
|
||||
cleanPreview: (scope = "") => invoke<OpenedFile[]>("p4_clean_preview", { scope }),
|
||||
cleanApply: (scope = "") => invoke<string>("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<string>("p4_fix", { job, change }),
|
||||
jobSpec: (job: string) => invoke<string>("p4_job_spec", { job }),
|
||||
|
||||
Reference in New Issue
Block a user