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 <[email protected]>
Цей коміт міститься в:
Muuтаmuutot authored and GitHub committed 2026-11-08 22:32:08 +08:00
1 джерело fff2c35181
коміт f04164d7ee
3 files changed
+375 -137

No files matched your search

+11
Переглянути файл
@@ -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;
+257 -136
Переглянути файл
@@ -1728,17 +1728,7 @@ function CommitList() {
);
}}
>
{opts.showColumnHeaders && (
<div className={clsx(styles.row, styles.columnHeader)} style={{ minWidth }}>
{opts.showRepository && useVcs.getState().roots.length > 1 && (
<span className={styles.colRepository}>{tx("Repository")}</span>
)}
<span className={styles.colSubject}>{tx("Commit")}</span>
{columns.map((column) => (
<LogColumnHeader key={column.id} column={column} columns={columns} options={opts} />
))}
</div>
)}
{opts.showColumnHeaders && <ColumnHeaders columns={columns} options={opts} minWidth={minWidth} />}
{!commits.length && !loading && finished && (
<div className={styles.message}>{headCommit ? tx("No commits match the filters") : tx("No commits yet")}</div>
)}
@@ -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<ColumnDrag | null>(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 (
<div className={clsx(styles.row, styles.columnHeader)} style={{ minWidth }}>
{options.showRepository && useVcs.getState().roots.length > 1 && (
<span className={styles.colRepository}>{tx("Repository")}</span>
)}
{ids.map((cid) => {
const column = cid === SUBJECT_COLUMN.id ? SUBJECT_COLUMN : columns.find((c) => c.id === cid);
if (!column) return null;
return (
<LogColumnHeader
key={cid}
column={column}
subject={cid === SUBJECT_COLUMN.id}
ids={visibleIds}
options={options}
drag={drag}
onMove={move}
onDragOver={(id, target, after) =>
setDrag((d) => (d?.id === id && d.target === target && d.after === after ? d : { id, target, after }))
}
onDragCancel={cancel}
/>
);
})}
</div>
);
}
// 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 (
<span
className={clsx(styles.columnCell, styles.columnHeading)}
style={{ width }}
draggable
onDragStart={(e) => {
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)}
<span
className={styles.columnResize}
role="separator"
aria-orientation="vertical"
aria-label={tx`Resize ${tx(column.title)} column`}
aria-valuemin={40}
aria-valuemax={800}
aria-valuenow={width}
tabIndex={0}
onPointerDown={(e) => {
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 && (
<span
className={styles.columnResize}
role="separator"
aria-orientation="vertical"
aria-label={tx`Resize ${tx(column.title)} column`}
aria-valuemin={40}
aria-valuemax={800}
aria-valuenow={width}
tabIndex={0}
onPointerDown={(e) => {
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));
}}
/>
)}
</span>
);
}
@@ -1979,6 +2093,53 @@ function CommitRowView({
left={opts.refsOnLeft}
/>
);
const subjectCell = (
<span key={SUBJECT_COLUMN.id} className={styles.colSubject}>
{graph && (
<GraphCell
row={graph}
head={head}
activeColor={hoverColor}
highlighted={highlighted}
highlightedNodes={highlightedNodes}
onHover={onHover}
onNavigate={onNavigate}
targetTitle={targetTitle}
onFragment={(target, expand, source) => onFragment(source ?? commitKey(c), target, expand)}
onNode={() => onFragment(commitKey(c), commitKey(c), false)}
interactive={interactive}
/>
)}
{bookmarked && <Icon name="bookmark" size={12} />}
{opts.refsOnLeft && refs}
<span
ref={subjectRef}
className={clsx(styles.subject, head && styles.headSubject, merge && styles.mergeSubject)}
onPointerEnter={enterTooltip}
onPointerLeave={leaveTooltip}
onPointerDown={() => {
clearTimeout(tooltipTimer.current);
setTooltip(false);
}}
>
<IssueText text={c.summary} />
</span>
{!opts.refsOnLeft && refs}
<Popup
open={tooltip}
anchor={subjectRef}
role="tooltip"
autoFocus={false}
restoreFocus={false}
onClose={() => setTooltip(false)}
className={styles.commitTooltip}
>
<div onPointerEnter={() => clearTimeout(tooltipTimer.current)} onPointerLeave={leaveTooltip}>
<CommitMessage commit={c} />
</div>
</Popup>
</span>
);
return (
<div
id={id}
@@ -2000,66 +2161,26 @@ function CommitRowView({
{repoName(commitRepo(c)!)}
</span>
)}
<span className={styles.colSubject}>
{graph && (
<GraphCell
row={graph}
head={head}
activeColor={hoverColor}
highlighted={highlighted}
highlightedNodes={highlightedNodes}
onHover={onHover}
onNavigate={onNavigate}
targetTitle={targetTitle}
onFragment={(target, expand, source) => onFragment(source ?? commitKey(c), target, expand)}
onNode={() => onFragment(commitKey(c), commitKey(c), false)}
interactive={interactive}
/>
)}
{bookmarked && <Icon name="bookmark" size={12} />}
{opts.refsOnLeft && refs}
<span
ref={subjectRef}
className={clsx(styles.subject, head && styles.headSubject, merge && styles.mergeSubject)}
onPointerEnter={enterTooltip}
onPointerLeave={leaveTooltip}
onPointerDown={() => {
clearTimeout(tooltipTimer.current);
setTooltip(false);
}}
>
<IssueText text={c.summary} />
</span>
{!opts.refsOnLeft && refs}
<Popup
open={tooltip}
anchor={subjectRef}
role="tooltip"
autoFocus={false}
restoreFocus={false}
onClose={() => setTooltip(false)}
className={styles.commitTooltip}
>
<div onPointerEnter={() => clearTimeout(tooltipTimer.current)} onPointerLeave={leaveTooltip}>
<CommitMessage commit={c} />
</div>
</Popup>
</span>
{columns.map((column) => (
<span
key={column.id}
className={clsx(
styles.columnCell,
styles.muted,
column.id === "author" && mine && styles.mine,
column.id === "hash" && styles.hash,
)}
style={{ width: opts.columnWidths[column.id] ?? column.width ?? 120 }}
title={column.id === "author" ? `${c.author} <${c.email}>` : undefined}
>
{column.render(c)}
</span>
))}
{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 (
<span
key={cid}
className={clsx(
styles.columnCell,
styles.muted,
column.id === "author" && mine && styles.mine,
column.id === "hash" && styles.hash,
)}
style={{ width: opts.columnWidths[column.id] ?? column.width ?? 120 }}
title={column.id === "author" ? `${c.author} <${c.email}>` : undefined}
>
{column.render(c)}
</span>
);
})}
</div>
);
}
+107 -1
Переглянути файл
@@ -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(<LogView />);
await screen.findByRole("separator", { name: "Resize Author column" });
const expected = order.includes("date") ? ["subject", "date", "author"] : ["subject", "author", "date"];
expect(
[...document.querySelectorAll<HTMLElement>("[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(<LogView />);
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(<LogView />);
await screen.findByRole("separator", { name: "Resize Author column" });
const author = document.querySelector<HTMLElement>('[data-log-column="author"]')!;
const date = document.querySelector<HTMLElement>('[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(<LogView />);
const list = await screen.findByRole("listbox", { name: "Commits" });
const subject = document.querySelector<HTMLElement>('[data-log-column="subject"]')!;
const date = document.querySelector<HTMLElement>('[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<HTMLElement>("[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");
});
});