Файли
openidea/src/platform/pageSwipe.test.ts
T
Jaw 053a2c37d8 feat(macos): navigate editor history with trackpad swipes (#1934)
Closes #1836. AppKit reports page swipes (three-finger NSEventTypeSwipe and two-finger page swipes) outside the web view's key/mouse events, so the desktop shell now forwards them as SwipeLeft / SwipeRight keymap strokes that can be recorded and remapped. Every macOS preset (IntelliJ, Eclipse, VS Code) binds SwipeRight to Back and SwipeLeft to Forward; two-finger swipes over horizontally scrollable content still scroll, and turning off the system page-swipe gesture disables navigation.

Co-authored-by: jaw52 <[email protected]>
2026-11-08 01:16:31 +08:00

131 рядки
5.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerAction } from "./actions";
import { setContext } from "./context";
import {
installKeyboardDispatcher,
normalizeBinding,
PRESETS,
primaryKeyBinding,
suspendKeyboardDispatch,
useKeymap,
} from "./keymap";
import { installPageSwipeTracking, ownsHorizontalScroll, PAGE_SWIPE_EVENT } from "./pageSwipe";
const emitTo = vi.hoisted(() => vi.fn(() => Promise.resolve()));
vi.mock("@tauri-apps/api/webviewWindow", () => ({ getCurrentWebviewWindow: () => ({ label: "project-2", emitTo }) }));
describe("native page swipes", () => {
const back = vi.fn();
const forward = vi.fn();
let dispose: (() => void)[];
beforeEach(() => {
back.mockReset();
forward.mockReset();
useKeymap.setState({ preset: "intellij-macos", overrides: {} });
dispose = [
registerAction({ id: "navigate.back", title: "Back", run: back }),
registerAction({ id: "navigate.forward", title: "Forward", run: forward }),
installKeyboardDispatcher(window),
];
});
afterEach(() => {
dispose.reverse().forEach((fn) => fn());
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.restoreAllMocks();
emitTo.mockClear();
});
const swipe = (stroke: string) => window.dispatchEvent(new CustomEvent(PAGE_SWIPE_EVENT, { detail: stroke }));
it("only starts native tracking for the requesting window and a non-scrolling target", () => {
vi.stubGlobal("__TAURI_INTERNALS__", {});
document.body.innerHTML = '<div class="xterm"><span>x</span></div><button>Navigate</button>';
const hitTest = vi.fn(() => document.querySelector("span"));
Object.defineProperty(document, "elementFromPoint", { configurable: true, value: hitTest });
const stop = installPageSwipeTracking(window);
try {
const candidate = () =>
window.dispatchEvent(new CustomEvent("ide:page-swipe-candidate", { detail: { id: 42, x: 10, y: 20 } }));
candidate();
expect(emitTo).not.toHaveBeenCalled();
hitTest.mockReturnValue(document.querySelector("button"));
candidate();
expect(emitTo).toHaveBeenCalledWith({ kind: "WebviewWindow", label: "project-2" }, "ide://page-swipe-track", 42);
stop();
candidate();
expect(emitTo).toHaveBeenCalledOnce();
} finally {
stop();
}
});
it.each(Object.entries(PRESETS).filter(([preset]) => preset.endsWith("-macos")))(
"%s maps physical right to Back and left to Forward without duplicate owners",
async (preset, keymap) => {
useKeymap.setState({ preset });
for (const [stroke, action] of [
["SwipeRight", "navigate.back"],
["SwipeLeft", "navigate.forward"],
] as const) {
expect(
Object.entries(keymap)
.filter(([, keys]) => keys.includes(stroke))
.map(([id]) => id),
).toEqual([action]);
}
swipe("SwipeRight");
swipe("SwipeLeft");
await Promise.resolve();
expect(back).toHaveBeenCalledOnce();
expect(forward).toHaveBeenCalledOnce();
},
);
it("supports removing and rebinding gestures", async () => {
useKeymap.setState({ overrides: { "navigate.back": [], "navigate.forward": ["SwipeRight"] } });
swipe("SwipeLeft");
swipe("SwipeRight");
await Promise.resolve();
expect(back).not.toHaveBeenCalled();
expect(forward).toHaveBeenCalledOnce();
});
it("honors action context and recorder suspension", async () => {
dispose.push(registerAction({ id: "navigate.back", title: "Back", when: "editorFocus", run: back }));
setContext("editorFocus", false);
swipe("SwipeRight");
setContext("editorFocus", true);
const resume = suspendKeyboardDispatch();
swipe("SwipeRight");
resume();
swipe("invalid");
await Promise.resolve();
expect(back).not.toHaveBeenCalled();
setContext("editorFocus", false);
});
it("keeps gestures out of keyboard menu labels", () => {
expect(normalizeBinding("swiperight")).toBe("SwipeRight");
useKeymap.setState({ overrides: { "navigate.back": ["SwipeRight"] } });
expect(primaryKeyBinding("navigate.back")).toBeUndefined();
});
it.each(["auto", "scroll", "overlay"])("preserves horizontal %s scrolling even at the edge", (overflow) => {
document.body.innerHTML = `<div style="overflow-x:${overflow}"><span>x</span></div>`;
const el = document.body.firstElementChild!;
Object.defineProperties(el, { clientWidth: { value: 100 }, scrollWidth: { value: 500 } });
expect(ownsHorizontalScroll(el.firstElementChild)).toBe(true);
});
it("does not classify clipped content or vertical-only content as horizontal scrolling", () => {
document.body.innerHTML = '<div style="overflow-x:hidden"><span>x</span></div>';
const el = document.body.firstElementChild!;
Object.defineProperties(el, { clientWidth: { value: 100 }, scrollWidth: { value: 500 } });
expect(ownsHorizontalScroll(el)).toBe(false);
expect(ownsHorizontalScroll(document.body)).toBe(false);
});
it("preserves custom wheel handlers such as image panning and diff dividers", () => {
document.body.innerHTML = "<div data-wheel-scroll><span>canvas</span></div>";
expect(ownsHorizontalScroll(document.querySelector("span"))).toBe(true);
});
it("leaves terminal wheel handling to the terminal application", () => {
document.body.innerHTML = '<div class="xterm"><textarea></textarea></div>';
expect(ownsHorizontalScroll(document.querySelector("textarea"))).toBe(true);
});
});