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"); }); });