From 670acf5242321ebf5edb42b76ece19342268f2dd Mon Sep 17 00:00:00 2001 From: Bonchellon Date: Tue, 7 Jul 2026 15:55:05 +0300 Subject: [PATCH] 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 --- src/App.css | 4 ++-- src/App.tsx | 38 ++++++++++++++++++++++++++++++-------- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/App.css b/src/App.css index d87948e..d4ee776 100644 --- a/src/App.css +++ b/src/App.css @@ -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)} diff --git a/src/App.tsx b/src/App.tsx index a875be1..ad9686f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -273,7 +273,8 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set const [activePath, setActivePath] = useState(() => loadScope(info?.clientName || "")); const [files, setFiles] = useState([]); const [checked, setChecked] = useState>(new Set()); - const [sel, setSel] = useState(null); + const [sel, setSel] = useState(null); // primary row (preview + shift anchor) + const [selRows, setSelRows] = useState>(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(); + 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 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" ? ( <>
-
{I.search} setFilter(e.target.value)} />
+
{I.search} { setFilter(e.target.value); setSelRows(new Set()); }} />
{pending.length > 0 && (
@@ -704,8 +727,7 @@ function Workbench({ info, session, light, toggleTheme, lang, setLang, zoom, set }
) : ( - setChecked((s) => { const n = new Set(s); n.has(dp) ? n.delete(dp) : n.add(dp); return n; })} /> + )}
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; onSelect: (i: number) => void; onToggle: (dp: string) => void }) { +function FileList({ files, sel, selRows, checked, onRowClick, onToggle }: + { files: OpenedFile[]; sel: number | null; selRows: Set; checked: Set; onRowClick: (i: number, e: ReactMouseEvent) => void; onToggle: (i: number, dp: string) => void }) { const ref = useRef(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( -
onSelect(i)}> - { e.stopPropagation(); onToggle(dp); }}>{I.check} +
onRowClick(i, e)}> + { e.stopPropagation(); onToggle(i, dp); }}>{I.check} {name}{dir} {st.ch}