Multi-select in the Changes list (Shift/Ctrl-click)

Rows now support Shift-click (range) and Ctrl-click (toggle) selection on
top of the single primary row used for the preview. Toggling a checkbox
while a multi-selection is active checks/unchecks the whole selection.
Accent bar marks the primary row; text selection disabled on the rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bonchellon
2026-07-07 15:55:05 +03:00
parent 72b163f592
commit 670acf5242
2 changed files with 32 additions and 10 deletions

View File

@ -273,7 +273,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const [activePath, setActivePath] = useState<string>(() => loadScope(info?.clientName || ""));
const [files, setFiles] = useState<OpenedFile[]>([]);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [sel, setSel] = useState<number | null>(null);
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 [filter, setFilter] = useState("");
const [desc, setDesc] = useState(""); // commit summary (first line)
const [descBody, setDescBody] = useState(""); // extended description (optional)
@ -331,6 +332,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
const uncommitted = f.filter((x) => (x.change || "default") === "default");
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());
try { setPending(await p4.changes()); } catch { setPending([]); }
}
@ -592,6 +594,27 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
return n;
});
}
// row click with Shift (range) / Ctrl (toggle) multi-selection
function rowClick(i: number, e: ReactMouseEvent) {
if (e.shiftKey && sel != null) {
const a = Math.min(sel, i), b = Math.max(sel, i);
const s = new Set<number>();
for (let k = a; k <= b; k++) s.add(k);
setSelRows(s);
} else if (e.ctrlKey || e.metaKey) {
setSelRows((prev) => { const n = new Set(prev); n.has(i) ? n.delete(i) : n.add(i); return n; });
} else {
setSelRows(new Set([i]));
}
setSel(i);
}
// checkbox: if the row is part of a multi-selection, toggle the whole selection together
function toggleCheck(i: number, dp: string) {
const idxs = selRows.has(i) && selRows.size > 1 ? [...selRows] : [i];
const dps = idxs.map((k) => view[k]?.depotFile || "").filter(Boolean);
const target = !checked.has(dp);
setChecked((prev) => { const n = new Set(prev); dps.forEach((d) => (target ? n.add(d) : n.delete(d))); return n; });
}
const menus: Record<string, { label: string; kb?: string; ext?: boolean; act?: () => void }[]> = {
File: [{ label: t("Switch workspace…"), act: openWorkspaces }, { label: t("Choose working folder…"), act: () => browseTo("") }, { label: t("Disconnect"), act: onDisconnect }],
@ -674,7 +697,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
{tab === "changes" ? (
<>
<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)} /></div>
<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")}>
@ -704,8 +727,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
</>}
</div>
) : (
<FileList files={view} sel={sel} checked={checked} onSelect={setSel}
onToggle={(dp) => setChecked((s) => { const n = new Set(s); n.has(dp) ? n.delete(dp) : n.add(dp); return n; })} />
<FileList files={view} sel={sel} selRows={selRows} checked={checked} onRowClick={rowClick} onToggle={toggleCheck} />
)}
<div className="commit">
<input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)}
@ -1085,8 +1107,8 @@ function Thumb({ file }: { file: OpenedFile }) {
/* ---------------- virtualized file list ---------------- */
const ROW = 52;
function FileList({ files, sel, checked, onSelect, onToggle }:
{ files: OpenedFile[]; sel: number | null; checked: Set<string>; onSelect: (i: number) => void; onToggle: (dp: string) => void }) {
function FileList({ files, sel, selRows, checked, onRowClick, onToggle }:
{ files: OpenedFile[]; sel: number | null; selRows: Set<number>; checked: Set<string>; onRowClick: (i: number, e: ReactMouseEvent) => void; onToggle: (i: number, dp: string) => void }) {
const ref = useRef<HTMLDivElement>(null);
const [scroll, setScroll] = useState(0);
const [h, setH] = useState(500);
@ -1108,8 +1130,8 @@ function FileList({ files, sel, checked, onSelect, onToggle }:
const st = statusOf(f.action);
const isCk = checked.has(dp);
rows.push(
<div key={dp + i} className={"file" + (i === sel ? " sel" : "")} style={{ top: i * ROW }} onClick={() => onSelect(i)}>
<span className={"fchk " + (isCk ? "ck" : "off")} onClick={(e) => { e.stopPropagation(); onToggle(dp); }}>{I.check}</span>
<div key={dp + i} className={"file" + (selRows.has(i) ? " sel" : "") + (i === sel ? " primary" : "")} style={{ top: i * ROW }} onClick={(e) => onRowClick(i, e)}>
<span className={"fchk " + (isCk ? "ck" : "off")} onClick={(e) => { e.stopPropagation(); onToggle(i, dp); }}>{I.check}</span>
<Thumb file={f} />
<span className="fname"><span className="n">{name}</span><span className="p">{dir}</span></span>
<span className={"stat " + st.cls}>{st.ch}</span>