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} .files{flex:1;overflow-y:auto;min-height:0;position:relative}
.vspace{position:relative;width:100%} .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; .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: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{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)} 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{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)} .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 [activePath, setActivePath] = useState<string>(() => loadScope(info?.clientName || ""));
const [files, setFiles] = useState<OpenedFile[]>([]); const [files, setFiles] = useState<OpenedFile[]>([]);
const [checked, setChecked] = useState<Set<string>>(new Set()); 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 [filter, setFilter] = useState("");
const [desc, setDesc] = useState(""); // commit summary (first line) const [desc, setDesc] = useState(""); // commit summary (first line)
const [descBody, setDescBody] = useState(""); // extended description (optional) 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"); const uncommitted = f.filter((x) => (x.change || "default") === "default");
setChecked(new Set(uncommitted.map((x) => x.depotFile || "").filter(Boolean))); setChecked(new Set(uncommitted.map((x) => x.depotFile || "").filter(Boolean)));
setSel((prev) => (prev != null && prev < f.length ? prev : f.length ? 0 : null)); setSel((prev) => (prev != null && prev < f.length ? prev : f.length ? 0 : null));
setSelRows(new Set());
try { setPending(await p4.changes()); } catch { setPending([]); } try { setPending(await p4.changes()); } catch { setPending([]); }
} }
@ -592,6 +594,27 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set
return n; 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 }[]> = { 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 }], 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" ? ( {tab === "changes" ? (
<> <>
<div className="filter"> <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> </div>
{pending.length > 0 && ( {pending.length > 0 && (
<div className="pushbar" onClick={pushAll} title={t("Submit to server")}> <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> </div>
) : ( ) : (
<FileList files={view} sel={sel} checked={checked} onSelect={setSel} <FileList files={view} sel={sel} selRows={selRows} checked={checked} onRowClick={rowClick} onToggle={toggleCheck} />
onToggle={(dp) => setChecked((s) => { const n = new Set(s); n.has(dp) ? n.delete(dp) : n.add(dp); return n; })} />
)} )}
<div className="commit"> <div className="commit">
<input className="summary" placeholder={t("Summary: what changed…")} value={desc} onChange={(e) => setDesc(e.target.value)} <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 ---------------- */ /* ---------------- virtualized file list ---------------- */
const ROW = 52; const ROW = 52;
function FileList({ files, sel, checked, onSelect, onToggle }: function FileList({ files, sel, selRows, checked, onRowClick, onToggle }:
{ files: OpenedFile[]; sel: number | null; checked: Set<string>; onSelect: (i: number) => void; onToggle: (dp: string) => void }) { { 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 ref = useRef<HTMLDivElement>(null);
const [scroll, setScroll] = useState(0); const [scroll, setScroll] = useState(0);
const [h, setH] = useState(500); const [h, setH] = useState(500);
@ -1108,8 +1130,8 @@ function FileList({ files, sel, checked, onSelect, onToggle }:
const st = statusOf(f.action); const st = statusOf(f.action);
const isCk = checked.has(dp); const isCk = checked.has(dp);
rows.push( rows.push(
<div key={dp + i} className={"file" + (i === sel ? " sel" : "")} style={{ top: i * ROW }} onClick={() => onSelect(i)}> <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(dp); }}>{I.check}</span> <span className={"fchk " + (isCk ? "ck" : "off")} onClick={(e) => { e.stopPropagation(); onToggle(i, dp); }}>{I.check}</span>
<Thumb file={f} /> <Thumb file={f} />
<span className="fname"><span className="n">{name}</span><span className="p">{dir}</span></span> <span className="fname"><span className="n">{name}</span><span className="p">{dir}</span></span>
<span className={"stat " + st.cls}>{st.ch}</span> <span className={"stat " + st.cls}>{st.ch}</span>