From f04164d7ee420ca8efe41ff79ea0164b102b0dbf Mon Sep 17 00:00:00 2001 From: Muu Date: Sun, 8 Nov 2026 22:32:08 +0800 Subject: [PATCH] feat(vcs): reorder Git log columns by dragging headers (#1856) The Git Log column order was fixed. Column headers can now be dragged to any position, including the commit column, with an insertion line showing where the column lands. It uses pointer events because WebView2 intercepts HTML5 drag events, and the order persists across restarts. Siblings covered: Esc or losing the window cancels a drag and releases pointer capture, and keyboard column moves skip hidden columns. Co-authored-by: muutot <92625353+muutot@users.noreply.github.com> --- src/features/vcs/GitLog.module.css | 11 + src/features/vcs/GitLog.tsx | 393 ++++++++++++------ .../vcs/__tests__/logUiFeatures.test.tsx | 108 ++++- 3 files changed, 375 insertions(+), 137 deletions(-) diff --git a/src/features/vcs/GitLog.module.css b/src/features/vcs/GitLog.module.css index 1c735e1c0..c43894ab9 100644 --- a/src/features/vcs/GitLog.module.css +++ b/src/features/vcs/GitLog.module.css @@ -207,6 +207,17 @@ position: relative; padding-inline-end: 6px; cursor: grab; + touch-action: none; +} +.columnHeading.dragSource { + cursor: grabbing; + opacity: 0.6; +} +.columnHeading.dropBefore { + box-shadow: inset 2px 0 0 var(--accent); +} +.columnHeading.dropAfter { + box-shadow: inset -2px 0 0 var(--accent); } .columnResize { position: absolute; diff --git a/src/features/vcs/GitLog.tsx b/src/features/vcs/GitLog.tsx index f33e4ec67..e896d8914 100644 --- a/src/features/vcs/GitLog.tsx +++ b/src/features/vcs/GitLog.tsx @@ -1728,17 +1728,7 @@ function CommitList() { ); }} > - {opts.showColumnHeaders && ( -
- {opts.showRepository && useVcs.getState().roots.length > 1 && ( - {tx("Repository")} - )} - {tx("Commit")} - {columns.map((column) => ( - - ))} -
- )} + {opts.showColumnHeaders && } {!commits.length && !loading && finished && (
{headCommit ? tx("No commits match the filters") : tx("No commits yet")}
)} @@ -1789,102 +1779,226 @@ function CommitList() { ); } -function LogColumnHeader({ - column, +interface ColumnDrag { + id: string; + target: string | null; + after: boolean; +} +// The flexible "Commit" column shares the same order and can be dragged like the rest. +const SUBJECT_COLUMN: LogColumn = { id: "subject", title: "Commit", width: 0, render: () => "" }; +// Until the user drags the commit column somewhere, it leads the row. +const columnSequence = (columnOrder: string[], builtins: LogColumn[]): string[] => { + const ids = [...new Set([...columnOrder, ...builtins.map((c) => c.id)])]; + return columnOrder.includes(SUBJECT_COLUMN.id) ? ids : [SUBJECT_COLUMN.id, ...ids]; +}; +function ColumnHeaders({ columns, options, + minWidth, }: { - column: LogColumn; columns: LogColumn[]; options: LogOptions; + minWidth: number; }) { - const drag = useRef<{ x: number; width: number } | null>(null); + const [drag, setDrag] = useState(null); + const ids = columnSequence(options.columnOrder, columns); + const cancel = useCallback(() => setDrag(null), []); + const move = (source: string, target: string | null, after: boolean) => { + if (!target || source === target || !ids.includes(source) || !ids.includes(target)) return; + const reordered = ids.filter((id) => id !== source); + const to = reordered.indexOf(target); + reordered.splice(after ? to + 1 : to, 0, source); + updateLogOptions({ columnOrder: reordered }); + }; + const visibleIds = ids.filter((id) => id === SUBJECT_COLUMN.id || columns.some((column) => column.id === id)); + return ( +
+ {options.showRepository && useVcs.getState().roots.length > 1 && ( + {tx("Repository")} + )} + {ids.map((cid) => { + const column = cid === SUBJECT_COLUMN.id ? SUBJECT_COLUMN : columns.find((c) => c.id === cid); + if (!column) return null; + return ( + + setDrag((d) => (d?.id === id && d.target === target && d.after === after ? d : { id, target, after })) + } + onDragCancel={cancel} + /> + ); + })} +
+ ); +} + +// Column headers reorder by pointer capture: WebView2 blocks HTML5 drag events +// while dragDropEnabled is on, so dragging must not rely on them. +function LogColumnHeader({ + column, + subject, + ids, + options, + drag, + onMove, + onDragOver, + onDragCancel, +}: { + column: LogColumn; + subject?: boolean; + ids: string[]; + options: LogOptions; + drag: ColumnDrag | null; + onMove(source: string, target: string | null, after: boolean): void; + onDragOver(id: string, target: string | null, after: boolean): void; + onDragCancel(): void; +}) { + const press = useRef<{ + x: number; + y: number; + active: boolean; + element: HTMLElement; + pointerId: number; + } | null>(null); + const resizeDrag = useRef<{ x: number; width: number; element: HTMLElement; pointerId: number } | null>(null); + const cancel = useCallback(() => { + const captures = [press.current, resizeDrag.current]; + press.current = null; + resizeDrag.current = null; + for (const capture of captures) + if (capture?.element.hasPointerCapture?.(capture.pointerId)) + capture.element.releasePointerCapture(capture.pointerId); + onDragCancel(); + }, [onDragCancel]); + useEffect(() => { + const escape = (e: globalThis.KeyboardEvent) => { + if (e.key !== "Escape" || (!press.current && !resizeDrag.current)) return; + e.preventDefault(); + e.stopPropagation(); + cancel(); + }; + window.addEventListener("blur", cancel); + window.addEventListener("keydown", escape, true); + return () => { + window.removeEventListener("blur", cancel); + window.removeEventListener("keydown", escape, true); + cancel(); + }; + }, [cancel]); const width = options.columnWidths[column.id] ?? column.width ?? 120; const resize = (value: number) => updateLogOptions({ columnWidths: { ...logOptions().columnWidths, [column.id]: Math.max(40, Math.min(800, value)) }, }); - const move = (source: string, target: string) => { - const ids = columns.map((c) => c.id); - const from = ids.indexOf(source), - to = ids.indexOf(target); - if (from < 0 || to < 0 || from === to) return; - ids.splice(from, 1); - ids.splice(to, 0, source); - updateLogOptions({ columnOrder: ids }); + const index = ids.indexOf(column.id); + const hitColumn = (x: number, y: number) => { + const el = document.elementFromPoint(x, y)?.closest?.("[data-log-column]"); + if (!el || el.parentElement !== press.current?.element.parentElement) return null; + const rect = el.getBoundingClientRect(); + return { id: el.getAttribute("data-log-column")!, after: x > rect.left + rect.width / 2 }; }; - const index = columns.findIndex((c) => c.id === column.id); return ( { - e.dataTransfer.setData("application/x-openidea-log-column", column.id); - e.dataTransfer.effectAllowed = "move"; + className={clsx( + styles.columnCell, + styles.columnHeading, + subject && styles.colSubject, + drag?.id === column.id && styles.dragSource, + drag && + drag.target === column.id && + drag.id !== column.id && + (drag.after ? styles.dropAfter : styles.dropBefore), + )} + style={subject ? undefined : { width }} + data-log-column={column.id} + onPointerDown={(e) => { + if (e.button !== 0) return; + press.current = { x: e.clientX, y: e.clientY, active: false, element: e.currentTarget, pointerId: e.pointerId }; + e.currentTarget.setPointerCapture?.(e.pointerId); }} - onDragOver={(e) => { - if (e.dataTransfer.types.includes("application/x-openidea-log-column")) e.preventDefault(); + onPointerMove={(e) => { + const start = press.current; + if (!start || start.pointerId !== e.pointerId) return; + if (!start.active && Math.hypot(e.clientX - start.x, e.clientY - start.y) < 4) return; + start.active = true; + const target = hitColumn(e.clientX, e.clientY); + onDragOver(column.id, target?.id ?? null, target?.after ?? false); }} - onDrop={(e) => { - e.preventDefault(); - move(e.dataTransfer.getData("application/x-openidea-log-column"), column.id); + onPointerUp={(e) => { + const start = press.current; + if (!start || start.pointerId !== e.pointerId) return; + const target = hitColumn(e.clientX, e.clientY); + if (start.active) onMove(column.id, target?.id ?? null, target?.after ?? false); + cancel(); }} + onPointerCancel={cancel} + onLostPointerCapture={cancel} onContextMenu={(e) => { e.preventDefault(); showContextMenu(e, [ { label: "Move Left", disabled: index === 0, - run: () => move(column.id, columns[index - 1]?.id ?? column.id), + run: () => onMove(column.id, ids[index - 1] ?? null, false), }, { label: "Move Right", - disabled: index === columns.length - 1, - run: () => move(column.id, columns[index + 1]?.id ?? column.id), + disabled: index === ids.length - 1, + run: () => onMove(column.id, ids[index + 1] ?? null, true), }, - { label: "Reset Width", run: () => resize(column.width ?? 120) }, + ...(subject ? [] : [{ label: "Reset Width", run: () => resize(column.width ?? 120) }]), ]); }} > {tx(column.title)} - { - e.preventDefault(); - e.stopPropagation(); - drag.current = { x: e.clientX, width }; - e.currentTarget.setPointerCapture(e.pointerId); - }} - onPointerMove={(e) => { - if (drag.current && e.currentTarget.hasPointerCapture(e.pointerId)) - resize(drag.current.width + e.clientX - drag.current.x); - }} - onPointerUp={(e) => { - drag.current = null; - if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); - }} - onPointerCancel={() => { - drag.current = null; - }} - onLostPointerCapture={() => { - drag.current = null; - }} - onKeyDown={(e) => { - if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; - e.preventDefault(); - e.stopPropagation(); - if (e.altKey) move(column.id, columns[index + (e.key === "ArrowLeft" ? -1 : 1)]?.id ?? column.id); - else resize(width + (e.key === "ArrowLeft" ? -10 : 10)); - }} - /> + {!subject && ( + { + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + resizeDrag.current = { x: e.clientX, width, element: e.currentTarget, pointerId: e.pointerId }; + e.currentTarget.setPointerCapture(e.pointerId); + }} + onPointerMove={(e) => { + if (resizeDrag.current && e.currentTarget.hasPointerCapture(e.pointerId)) + resize(resizeDrag.current.width + e.clientX - resizeDrag.current.x); + }} + onPointerUp={(e) => { + resizeDrag.current = null; + if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); + }} + onPointerCancel={() => { + resizeDrag.current = null; + }} + onLostPointerCapture={() => { + resizeDrag.current = null; + }} + onKeyDown={(e) => { + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; + e.preventDefault(); + e.stopPropagation(); + if (e.altKey) + onMove(column.id, ids[index + (e.key === "ArrowLeft" ? -1 : 1)] ?? null, e.key === "ArrowRight"); + else resize(width + (e.key === "ArrowLeft" ? -10 : 10)); + }} + /> + )} ); } @@ -1979,6 +2093,53 @@ function CommitRowView({ left={opts.refsOnLeft} /> ); + const subjectCell = ( + + {graph && ( + onFragment(source ?? commitKey(c), target, expand)} + onNode={() => onFragment(commitKey(c), commitKey(c), false)} + interactive={interactive} + /> + )} + {bookmarked && } + {opts.refsOnLeft && refs} + { + clearTimeout(tooltipTimer.current); + setTooltip(false); + }} + > + + + {!opts.refsOnLeft && refs} + setTooltip(false)} + className={styles.commitTooltip} + > +
clearTimeout(tooltipTimer.current)} onPointerLeave={leaveTooltip}> + +
+
+
+ ); return (
)} - - {graph && ( - onFragment(source ?? commitKey(c), target, expand)} - onNode={() => onFragment(commitKey(c), commitKey(c), false)} - interactive={interactive} - /> - )} - {bookmarked && } - {opts.refsOnLeft && refs} - { - clearTimeout(tooltipTimer.current); - setTooltip(false); - }} - > - - - {!opts.refsOnLeft && refs} - setTooltip(false)} - className={styles.commitTooltip} - > -
clearTimeout(tooltipTimer.current)} onPointerLeave={leaveTooltip}> - -
-
-
- {columns.map((column) => ( - ` : undefined} - > - {column.render(c)} - - ))} + {columnSequence(opts.columnOrder, columns).map((cid) => { + if (cid === SUBJECT_COLUMN.id) return subjectCell; + const column = columns.find((col) => col.id === cid); + if (!column) return null; + return ( + ` : undefined} + > + {column.render(c)} + + ); + })}
); } diff --git a/src/features/vcs/__tests__/logUiFeatures.test.tsx b/src/features/vcs/__tests__/logUiFeatures.test.tsx index a116a8d92..e707d0182 100644 --- a/src/features/vcs/__tests__/logUiFeatures.test.tsx +++ b/src/features/vcs/__tests__/logUiFeatures.test.tsx @@ -159,7 +159,113 @@ describe("log controls", () => { fireEvent.keyDown(author, { key: "ArrowRight" }); expect(useLogOptions.getState().columnWidths.author).toBe(140); fireEvent.keyDown(author, { key: "ArrowRight", altKey: true }); - expect(useLogOptions.getState().columnOrder).toEqual(["date", "author"]); + expect(useLogOptions.getState().columnOrder).toEqual(["subject", "date", "author", "hash"]); + }); + + it.each([{ order: [] }, { order: ["date"] }, { order: ["unknown", "date", "author", "date"] }])( + "restores an older column order safely: $order", + async ({ order }) => { + useLogOptions.setState({ showColumnHeaders: true, columnOrder: order }); + render(); + await screen.findByRole("separator", { name: "Resize Author column" }); + const expected = order.includes("date") ? ["subject", "date", "author"] : ["subject", "author", "date"]; + expect( + [...document.querySelectorAll("[data-log-column]")].map((el) => el.dataset.logColumn), + ).toEqual(expected); + const author = screen.getByRole("separator", { name: "Resize Author column" }); + fireEvent.keyDown(author, { key: "ArrowLeft", altKey: true }); + expect(useLogOptions.getState().columnOrder).not.toContain(undefined); + }, + ); + + it("keeps pointer resizing separate from reordering and releases its capture on blur", async () => { + useLogOptions.setState({ showColumnHeaders: true }); + render(); + const author = await screen.findByRole("separator", { name: "Resize Author column" }); + const release = vi.fn(); + author.setPointerCapture = vi.fn(); + author.hasPointerCapture = () => true; + author.releasePointerCapture = release; + fireEvent.pointerDown(author, { button: 0, pointerId: 1, clientX: 10 }); + fireEvent.pointerMove(author, { pointerId: 1, clientX: 40 }); + expect(useLogOptions.getState().columnWidths.author).toBe(160); + fireEvent.blur(window); + expect(release).toHaveBeenCalledWith(1); + fireEvent.pointerMove(author, { pointerId: 1, clientX: 100 }); + expect(useLogOptions.getState().columnWidths.author).toBe(160); + expect(useLogOptions.getState().columnOrder).toEqual(DEFAULT_LOG_OPTIONS.columnOrder); + }); + + it.each(["Escape", "outside", "blur", "pointercancel", "lostpointercapture"])( + "cancels header dragging on %s", + async (reason) => { + useLogOptions.setState({ showColumnHeaders: true, columnOrder: ["subject", "author", "date"] }); + render(); + await screen.findByRole("separator", { name: "Resize Author column" }); + const author = document.querySelector('[data-log-column="author"]')!; + const date = document.querySelector('[data-log-column="date"]')!; + const hit = vi.fn((): Element | null => date); + const original = document.elementFromPoint; + document.elementFromPoint = hit; + const release = vi.fn(); + author.setPointerCapture = vi.fn(); + author.hasPointerCapture = () => true; + author.releasePointerCapture = release; + try { + fireEvent.pointerDown(author, { button: 0, pointerId: 1, clientX: 10, clientY: 10 }); + fireEvent.pointerMove(author, { pointerId: 1, clientX: 100, clientY: 10 }); + fireEvent.pointerMove(author, { pointerId: 1, clientX: 110, clientY: 10 }); + if (reason === "Escape") fireEvent.keyDown(window, { key: "Escape" }); + else if (reason === "blur") fireEvent.blur(window); + else if (reason === "outside") hit.mockReturnValue(null); + else fireEvent(author, new PointerEvent(reason, { bubbles: true, pointerId: 1 })); + fireEvent.pointerUp(author, { pointerId: 1, clientX: 110, clientY: 10 }); + expect(useLogOptions.getState().columnOrder).toEqual(["subject", "author", "date"]); + if (reason !== "lostpointercapture") expect(release).toHaveBeenCalledWith(1); + } finally { + document.elementFromPoint = original; + } + }, + ); + + it("moves the commit cell and graph together, persists order, and keeps hidden columns", async () => { + useLogOptions.setState({ showColumnHeaders: true, columnOrder: ["subject", "hash", "author", "unknown", "date"] }); + render(); + const list = await screen.findByRole("listbox", { name: "Commits" }); + const subject = document.querySelector('[data-log-column="subject"]')!; + const date = document.querySelector('[data-log-column="date"]')!; + const original = document.elementFromPoint; + document.elementFromPoint = () => date; + try { + fireEvent.pointerDown(subject, { button: 0, pointerId: 1, clientX: 10, clientY: 10 }); + fireEvent.pointerMove(subject, { pointerId: 1, clientX: 12, clientY: 10 }); + fireEvent.pointerUp(subject, { pointerId: 1, clientX: 12, clientY: 10 }); + expect(useLogOptions.getState().columnOrder[0]).toBe("subject"); + fireEvent.pointerDown(subject, { button: 0, pointerId: 1, clientX: 10, clientY: 10 }); + fireEvent.pointerMove(subject, { pointerId: 1, clientX: 100, clientY: 10 }); + fireEvent.pointerUp(subject, { pointerId: 1, clientX: 100, clientY: 10 }); + } finally { + document.elementFromPoint = original; + } + expect(useLogOptions.getState().columnOrder).toEqual(["hash", "author", "unknown", "date", "subject"]); + expect(JSON.parse(localStorage.getItem("openidea.vcs.logOptions")!).columnOrder).toEqual( + useLogOptions.getState().columnOrder, + ); + const row = within(list).getAllByRole("option")[0]!; + expect(row.lastElementChild).toContainElement(within(row).getByLabelText("Commit graph")); + expect(row.firstElementChild).toHaveTextContent("Author"); + act(() => useLogOptions.setState({ showHash: true })); + expect([...document.querySelectorAll("[data-log-column]")].map((el) => el.dataset.logColumn)).toEqual([ + "hash", + "author", + "date", + "subject", + ]); + const hash = screen.getByRole("separator", { name: "Resize Hash column" }); + fireEvent.keyDown(hash, { key: "ArrowLeft", altKey: true }); + expect(useLogOptions.getState().columnOrder).toEqual(["hash", "author", "unknown", "date", "subject"]); + fireEvent.keyDown(list, { key: "b" }); + expect(useGitLog.getState().selected).toBe("/repo\0base"); }); });