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