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:
@ -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)}
|
||||
|
||||
38
src/App.tsx
38
src/App.tsx
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user