mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-10 02:31:27 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e618fd65c8 | ||
|
|
21472986ed | ||
|
|
0bb483ec03 |
No files matched your search
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user