Compare commits

...
Author SHA1 Message Date
arookieofc e618fd65c8 chore: fix README formatting (pre-existing oxfmt violation blocking CI) 2026-11-10 10:36:56 +08:00
arookieofc 21472986ed test(vcs): cover diff divider drag, keys and reset 2026-11-10 10:20:07 +08:00
arookieofc 0bb483ec03 fix(vcs): make side-by-side diff divider draggable
Allow dragging the middle band between unaligned panes to resize
left/right share (10-90%), with keyboard arrows, double-click
reset and localStorage persistence. Fixes #2437.
2026-11-10 10:03:26 +08:00
4 changed files with 214 additions and 2 deletions

No files matched your search

-2
View File
@@ -76,6 +76,4 @@ the [official plugins](plugins/official). The plugin API is still a preview.
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
+9
View File
@@ -239,6 +239,15 @@
background: var(--bg-app);
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
cursor: ew-resize;
touch-action: none;
}
.host :global(.cm-diffDivider:hover) {
background: var(--bg-hover);
}
.host :global(.cm-diffDivider:focus-visible) {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
/* Only the band layer: the chevrons inside the arrow buttons are centred by the button's flex box. */
.host :global(.cm-diffDivider > svg) {
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import type { MergeView } from "@codemirror/merge";
import { Text } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { UnalignedPanes, DIFF_SPLIT_STORAGE_KEY } from "../diffScroll";
const doc = (s: string) => Text.of(s.split("\n"));
function panes(initialRatio = 50) {
try {
localStorage.removeItem(DIFF_SPLIT_STORAGE_KEY);
} catch {
/* ignore */
}
const root = document.createElement("div");
const text = doc(Array.from({ length: 50 }, () => "line").join("\n"));
const view = () => {
const wrapper = root.appendChild(document.createElement("div"));
const dom = wrapper.appendChild(document.createElement("div"));
const scrollDOM = dom.appendChild(document.createElement("div"));
Object.defineProperty(scrollDOM, "scrollHeight", { value: 1000 });
return {
dom,
scrollDOM,
state: { doc: text },
get documentTop() {
return -scrollDOM.scrollTop;
},
lineBlockAt: (pos: number) => ({ bottom: text.lineAt(pos).number * 20 }),
} as unknown as EditorView;
};
const a = view();
const b = view();
const controller = new UnalignedPanes({ sync: true, initialRatio });
controller.attach({ a, b, chunks: [] } as unknown as MergeView);
const divider = root.querySelector(".cm-diffDivider") as HTMLElement;
return { a, b, controller, divider };
}
const down = (el: HTMLElement, clientX: number) =>
el.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX }));
const move = (el: HTMLElement, clientX: number) =>
el.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, clientX }));
const up = (el: HTMLElement) => el.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
describe("diff divider dragging (issue #2437)", () => {
it("starts at an even split", () => {
const p = panes();
try {
expect((p.a.dom.parentElement as HTMLElement).style.flex).toBe("50 1 0px");
expect((p.b.dom.parentElement as HTMLElement).style.flex).toBe("50 1 0px");
} finally {
p.controller.destroy();
}
});
it("drags the divider to resize the panes and persists the ratio", () => {
const p = panes();
try {
down(p.divider, 100);
// jsdom has no layout (total falls back to 1px), so a small move clamps to the 90% edge.
move(p.divider, 100.6);
expect((p.a.dom.parentElement as HTMLElement).style.flex).toBe("90 1 0px");
expect((p.b.dom.parentElement as HTMLElement).style.flex).toBe("10 1 0px");
up(p.divider);
expect(localStorage.getItem(DIFF_SPLIT_STORAGE_KEY)).toBe("90");
} finally {
p.controller.destroy();
}
});
it("ignores drags started on arrow buttons", () => {
const p = panes();
try {
const btn = document.createElement("button");
p.divider.appendChild(btn);
btn.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }));
move(p.divider, 200);
expect((p.a.dom.parentElement as HTMLElement).style.flex).toBe("50 1 0px");
} finally {
p.controller.destroy();
}
});
it("nudges with arrow keys and resets on double-click", () => {
const p = panes();
try {
p.divider.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }));
expect((p.a.dom.parentElement as HTMLElement).style.flex).toBe("52 1 0px");
p.divider.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
expect((p.a.dom.parentElement as HTMLElement).style.flex).toBe("50 1 0px");
} finally {
p.controller.destroy();
}
});
});
+109
View File
@@ -146,6 +146,27 @@ export interface UnalignedPanesOptions {
sync: boolean;
/** Divider arrows toward the editable sides; `apply` copies chunk `index` from `source`. */
arrows?: { toA: boolean; toB: boolean; apply(index: number, source: Side): void };
/** Initial left-pane share (percent); defaults to the persisted value, then 50. */
initialRatio?: number;
}
/** localStorage key for the side-by-side pane split, shared by every diff viewer. */
export const DIFF_SPLIT_STORAGE_KEY = "openidea:diff-pane-ratio";
const MIN_RATIO = 10;
const MAX_RATIO = 90;
function loadRatio(fallback = 50): number {
try {
const raw = localStorage.getItem(DIFF_SPLIT_STORAGE_KEY);
if (raw != null) {
const n = Number(raw);
if (Number.isFinite(n)) return Math.min(MAX_RATIO, Math.max(MIN_RATIO, n));
}
} catch {
/* Storage may be unavailable (private mode, SSR). */
}
return fallback;
}
/**
@@ -170,14 +191,25 @@ export class UnalignedPanes {
private frame = -1;
private resize: ResizeObserver | null = null;
private readonly cleanup: (() => void)[] = [];
/** Left pane share (percent) of the side-by-side split; dragging the divider updates it. */
private ratio: number;
constructor(private readonly opts: UnalignedPanesOptions) {
this.sync = opts.sync;
this.ratio =
opts.initialRatio != null && Number.isFinite(opts.initialRatio)
? Math.min(MAX_RATIO, Math.max(MIN_RATIO, opts.initialRatio))
: loadRatio();
this.extension = [gapMarks, changedLineGutters, EditorView.updateListener.of(() => this.schedule())];
this.divider.className = "cm-diffDivider";
// A lone arrow is centred in the divider; a pair keeps to the two edges.
this.divider.classList.toggle("cm-diffDivider-lone", !!opts.arrows && opts.arrows.toA !== opts.arrows.toB);
this.divider.setAttribute("data-wheel-scroll", "");
this.divider.setAttribute("role", "separator");
this.divider.setAttribute("aria-orientation", "vertical");
this.divider.setAttribute("aria-label", "Resize diff panes");
this.divider.setAttribute("title", "Drag to resize panes (double-click to reset)");
this.divider.tabIndex = 0;
this.svg.setAttribute("aria-hidden", "true");
this.svg.appendChild(this.bandLayer);
this.divider.appendChild(this.svg);
@@ -201,6 +233,71 @@ export class UnalignedPanes {
el.removeEventListener("pointerdown", onPointer);
});
}
this.applyRatio();
// Dragging the divider resizes the panes like IDEA's splitter (double-click resets).
let dragX: number | null = null;
let dragRatio = this.ratio;
const persist = () => {
try {
localStorage.setItem(DIFF_SPLIT_STORAGE_KEY, String(this.ratio));
} catch {
/* Storage may be unavailable. */
}
};
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0 || (e.target as HTMLElement | null)?.closest?.("button")) return;
e.preventDefault();
dragX = e.clientX;
dragRatio = this.ratio;
try {
this.divider.setPointerCapture(e.pointerId);
} catch {
/* jsdom / touch without capture support. */
}
};
const onPointerMove = (e: PointerEvent) => {
if (dragX === null || !this.mv) return;
const editors = this.divider.parentElement;
const total = (editors?.clientWidth ?? 0) - this.divider.offsetWidth || 1;
const next = dragRatio + ((e.clientX - dragX) / total) * 100;
this.ratio = Math.min(MAX_RATIO, Math.max(MIN_RATIO, next));
this.applyRatio();
};
const endDrag = () => {
if (dragX === null) return;
dragX = null;
persist();
this.schedule();
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
this.ratio = Math.min(MAX_RATIO, Math.max(MIN_RATIO, this.ratio + (e.key === "ArrowRight" ? 2 : -2)));
this.applyRatio();
persist();
this.schedule();
};
const onDblClick = (e: MouseEvent) => {
if ((e.target as HTMLElement | null)?.closest?.("button")) return;
this.ratio = 50;
this.applyRatio();
persist();
this.schedule();
};
this.divider.addEventListener("pointerdown", onPointerDown);
this.divider.addEventListener("pointermove", onPointerMove);
this.divider.addEventListener("pointerup", endDrag);
this.divider.addEventListener("pointercancel", endDrag);
this.divider.addEventListener("keydown", onKeyDown);
this.divider.addEventListener("dblclick", onDblClick);
this.cleanup.push(() => {
this.divider.removeEventListener("pointerdown", onPointerDown);
this.divider.removeEventListener("pointermove", onPointerMove);
this.divider.removeEventListener("pointerup", endDrag);
this.divider.removeEventListener("pointercancel", endDrag);
this.divider.removeEventListener("keydown", onKeyDown);
this.divider.removeEventListener("dblclick", onDblClick);
});
// The divider scrolls the right pane (and, when synchronized, through it the left one).
const onWheel = (e: WheelEvent) => {
e.preventDefault();
@@ -261,6 +358,18 @@ export class UnalignedPanes {
this.mv = null;
}
/** Applies the current split to the two pane wrappers (flex-grow shares of the free width). */
private applyRatio() {
const mv = this.mv;
if (!mv) return;
const left = mv.a.dom.parentElement;
const right = mv.b.dom.parentElement;
if (!left || !right) return;
left.style.flex = `${this.ratio} 1 0`;
right.style.flex = `${100 - this.ratio} 1 0`;
this.divider.setAttribute("aria-valuenow", String(Math.round(this.ratio)));
}
private view(side: Side): EditorView {
return side === "a" ? this.mv!.a : this.mv!.b;
}