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

@ -140,10 +140,10 @@ body.resizing{cursor:col-resize!important;user-select:none}
.files{flex:1;overflow-y:auto;min-height:0;position:relative}
.vspace{position:relative;width:100%}
.file{position:absolute;left:7px;right:7px;height:50px;display:flex;align-items:center;gap:11px;padding:0 12px;cursor:pointer;
border-radius:11px;transition:background .13s}
border-radius:11px;transition:background .13s;user-select:none}
.file:hover{background:var(--hover)}
.file.sel{background:rgba(124,110,246,.13);box-shadow:inset 0 0 0 1px rgba(124,110,246,.28)}
.file.sel::before{content:"";position:absolute;left:4px;top:11px;bottom:11px;width:3px;border-radius:3px;
.file.primary::before{content:"";position:absolute;left:4px;top:11px;bottom:11px;width:3px;border-radius:3px;
background:linear-gradient(180deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 8px rgba(124,110,246,.6)}
.fchk{width:15px;height:15px;border-radius:6px;border:1.5px solid var(--border);display:grid;place-items:center;flex:0 0 auto;background:var(--panel)}
.fchk.ck{background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));border-color:var(--accent)}

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>