Add Pending changelists view (option B)

The Changes tab now shows committed-but-not-submitted changelists as cards
above the working set: expandable to their files (click to preview), each
with per-CL Submit, plus Edit description / Uncommit (reopen -c default) /
Discard in a ⋯ menu, and a Submit-all button. Backend adds p4_reopen_default
and p4_set_desc; a small text-prompt modal edits descriptions. Preview panel
now shows either the working selection or a clicked pending-CL file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 16:34:59 +03:00
parent 1675a7cd49
commit de6d55ad84
5 changed files with 232 additions and 6 deletions

View File

@ -276,6 +276,9 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [checked, setChecked] = useState<Set<string>>(new Set());
const [sel, setSel] = useState<number | null>(null); // primary row (preview + shift anchor)
const [selRows, setSelRows] = useState<Set<number>>(new Set()); // multi-selection (Shift/Ctrl-click)
const [previewCL, setPreviewCL] = useState<OpenedFile | null>(null); // preview override (pending-CL file)
const [expandedCL, setExpandedCL] = useState<Set<string>>(new Set()); // expanded pending changelist cards
const [prompt, setPrompt] = useState<null | { title: string; label: string; value: string; onSave: (v: string) => void }>(null);
const [filter, setFilter] = useState("");
const [desc, setDesc] = useState(""); // commit summary (first line)
const [descBody, setDescBody] = useState(""); // extended description (optional)
@ -340,6 +343,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
setChecked(new Set(uncommitted.map((x) => x.depotFile || "").filter(Boolean)));
setSel((prev) => (prev != null && prev < f.length ? prev : f.length ? 0 : null));
setSelRows(new Set());
setPreviewCL(null);
try { setPending(await p4.changes()); } catch { setPending([]); }
}
@ -596,6 +600,38 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
await loadHistory(); // the submit created a new submitted changelist — refresh History
}
// Submit a single pending changelist to the server
async function submitOne(change: string) {
if (!(await confirm({ title: t("Submit changelist #{n}?", { n: change }), body: t("This changelist goes to the Perforce server and becomes available to the team. This is a push — it cannot be undone."), confirm: t("Submit") }))) return;
setBusy(true);
try { await p4.submitChange(change); flash(t("Submitted changelist #{n}.", { n: change })); await refresh(); await loadHistory(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
// Uncommit: move a changelist's files back into the working set (default)
async function uncommit(fs: OpenedFile[]) {
const dps = fs.map((f) => f.depotFile || "").filter(Boolean);
if (!dps.length) return;
setBusy(true);
try { await p4.reopenDefault(dps); flash(t("Moved back to working changes.")); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
}
// Edit a pending changelist's description
function editDesc(cl: Change) {
setPrompt({
title: t("Edit description"), label: t("Changelist #{n}", { n: cl.change || "" }), value: (cl.desc || "").trim(),
onSave: async (v) => {
setPrompt(null);
if (!v.trim()) return;
setBusy(true);
try { await p4.setDesc(cl.change || "", v.trim()); flash(t("Description updated.")); await refresh(); }
catch (e) { flash(String(e), true); setBusy(false); }
},
});
}
function toggleCL(change: string) {
setExpandedCL((prev) => { const n = new Set(prev); n.has(change) ? n.delete(change) : n.add(change); return n; });
}
async function doRevert(file: OpenedFile) {
const dp = file.depotFile || "";
if (!(await confirm({ title: t("Revert file?"), body: t("{name}\n\nLocal edits to this file will be lost.", { name: splitPath(dp).name }), confirm: t("Revert"), danger: true }))) return;
@ -610,7 +646,13 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const q = filter.toLowerCase();
return uncommitted.filter((f) => (f.depotFile || "").toLowerCase().includes(q));
}, [uncommitted, filter]);
const selFile = sel != null ? view[sel] : undefined;
// committed pending changelists with their files (grouped from p4 opened)
const pendingCards = useMemo(() =>
pending.map((cl) => ({
cl,
files: files.filter((f) => String(f.change || "") === String(cl.change || "")),
})), [pending, files]);
const selFile = previewCL ?? (sel != null ? view[sel] : undefined);
const allChecked = view.length > 0 && view.every((f) => checked.has(f.depotFile || ""));
function toggleAll() {
setChecked((s) => {
@ -633,6 +675,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
setSelRows(new Set([i]));
}
setSel(i);
setPreviewCL(null); // show the working-set selection in the preview
}
// checkbox: if the row is part of a multi-selection, toggle the whole selection together
function toggleCheck(i: number, dp: string) {
@ -752,11 +795,49 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
<div className="filter">
<div className="box">{I.search}<input placeholder={t("Filter {n} files…", { n: uncommitted.length })} value={filter} onChange={(e) => { setFilter(e.target.value); setSelRows(new Set()); }} /></div>
</div>
{pending.length > 0 && (
<div className="pushbar" onClick={pushAll} title={t("Submit to server")}>
<span className="pushdot" />
<span>{t("{n} changelist(s) committed — ready to Submit", { n: pending.length })}</span>
<span className="pusharr"></span>
{pendingCards.length > 0 && (
<div className="pending">
<div className="pending-h">
<span className="pending-t"><span className="pushdot" />{t("Pending · ready to Submit")}</span>
<button className="pushall" onClick={pushAll} title={t("Submit all to the server")}>{t("Submit all")} </button>
</div>
{pendingCards.map(({ cl, files: cf }) => {
const ch = cl.change || "";
const open = expandedCL.has(ch);
return (
<div key={ch} className="clcard">
<div className="clcard-h" onClick={() => toggleCL(ch)}>
<span className={"cl-chev" + (open ? " open" : "")}>{I.chev}</span>
<span className="cl-num">#{ch}</span>
<span className="cl-desc">{(cl.desc || "").trim() || t("(no description)")}</span>
<span className="cl-count">{t("{n} files", { n: cf.length })}</span>
<span className="cl-actions" onClick={(e) => e.stopPropagation()}>
<button className="cl-submit" onClick={() => submitOne(ch)} title={t("Submit to server")}></button>
<button className="cl-more" onClick={(e) => openCtx(e, [
{ label: t("Edit description"), act: () => editDesc(cl) },
{ label: t("Uncommit (back to working)"), act: () => uncommit(cf) },
{ label: t("Discard changes · {n} files", { n: cf.length }), danger: true, act: () => discard(cf) },
])} title={t("More")}></button>
</span>
</div>
{open && (
<div className="cl-files">
{cf.length === 0 && <div className="cl-empty">{t("(files are outside the current working folder)")}</div>}
{cf.map((f) => {
const sp = splitPath(f.depotFile);
const st = statusOf(f.action);
return (
<div key={f.depotFile} className={"cl-file" + (previewCL?.depotFile === f.depotFile ? " sel" : "")} onClick={() => setPreviewCL(f)}>
<span className={"stat " + st.cls}>{st.ch}</span>
<span className="cl-fname"><span className="n">{sp.name}</span><span className="p">{sp.dir}</span></span>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
{scanning && (
@ -837,6 +918,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{modal && <AppModal modal={modal} info={info} light={light} toggleTheme={toggleTheme} lang={lang} setLang={setLang} zoom={zoom} setZoom={setZoom}
onClose={() => setModal(null)} onPickWorkspace={switchWorkspace} onBrowse={browseTo} onChoose={chooseScope} onDisconnect={onDisconnect} />}
{ask && <ConfirmModal {...ask} onClose={(v) => { ask.resolve(v); setAsk(null); }} />}
{prompt && <TextPrompt title={prompt.title} label={prompt.label} value={prompt.value} onSave={prompt.onSave} onClose={() => setPrompt(null)} />}
{showXfer && transfer && <TransferDialog transfer={transfer} />}
{buildOpen && !buildMin && <BuildOverlay lines={buildLog} building={building} ok={buildOk} sln={slnPath} onMinimize={() => setBuildMin(true)} onClose={() => setBuildOpen(false)} />}
{buildOpen && buildMin && <BuildChip building={building} ok={buildOk} onExpand={() => setBuildMin(false)} onClose={() => setBuildOpen(false)} />}
@ -1082,6 +1164,31 @@ function BuildChip({ building, ok, onExpand, onClose }: { building: boolean; ok:
);
}
/* ---------------- text prompt (edit description) ---------------- */
function TextPrompt({ title, label, value, onSave, onClose }: { title: string; label: string; value: string; onSave: (v: string) => void; onClose: () => void }) {
const [v, setV] = useState(value);
const ref = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
ref.current?.focus();
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) onSave(v); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [v]); // eslint-disable-line
return (
<div className="modal-back" onClick={onClose}>
<div className="modal-card prompt" onClick={(e) => e.stopPropagation()}>
<h3>{title}</h3>
<div className="prompt-label">{label}</div>
<textarea ref={ref} className="prompt-input" value={v} onChange={(e) => setV(e.target.value)} />
<div className="modal-actions">
<button className="mbtn ghost" onClick={onClose}>{t("Cancel")}</button>
<button className="mbtn primary" onClick={() => onSave(v)}>{t("Save")}</button>
</div>
</div>
</div>
);
}
/* ---------------- confirm modal ---------------- */
function ConfirmModal({ title, body, confirm, danger, onClose }: { title: string; body: string; confirm: string; danger?: boolean; onClose: (v: boolean) => void }) {
useEffect(() => {