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:
Bonchellon
2026-07-08 12:06:11 +03:00
parent ecc5b8e9bf
commit 28a3989095
4 changed files with 89 additions and 1 deletions

View File

@ -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[]>([]);