fix(ui): treat every second click of a burst as a double-click

WebKit fires `dblclick` only for the second click of a burst. A quick
double-click right after another arrives as `click` with `detail` 3 and 4 and
no `dblclick`, so the second double-click on a folder in the Project view (and
on any tree row, tab or list item handled by `onDoubleClick`) did nothing.

A window-level shim now dispatches a `dblclick` on the click's target for every
later even click of a burst (detail 4, 6, ...), after the page's own click
handlers. Existing `onDoubleClick` handlers and their `stopPropagation` apply
unchanged; an engine that fires its own `dblclick` for that click gets it
dropped as a duplicate.

The Project view test now delivers bursts the way WebKit does (`dblclick` only
at detail 2) and checks both expand modes over 2–6 click bursts.

Refs #724
Цей коміт міститься в:
l0ng-ai committed 2026-11-03 22:34:29 +08:00
1 джерело eefe166cf7
коміт 329ad2c644
4 files changed
+151 -23

No files matched your search

+29 -23
Переглянути файл
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { emitLocalIdeEvent } from "@/platform/ipc";
import { installRepeatedDoubleClick } from "@/platform/doubleClick";
import { getContext } from "@/platform/context";
import { provideEditorService, registerProjectViewRoot, type EditorService } from "@/workbench/contributions";
import { activate } from "./index";
@@ -211,29 +212,34 @@ describe("ProjectView", () => {
});
it("toggles folders once for each rapid double-click pair", async () => {
const { row, rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
act(() => setViewOption("singleClickExpand", true));
const content = () => row("src").querySelector("[data-project-path]")!;
const click = (detail: number) => {
fireEvent.mouseDown(row("src"), { detail });
fireEvent.click(content(), { detail });
};
click(1);
click(2);
fireEvent.doubleClick(content(), { detail: 2 });
await waitFor(() => expect(rows()).toContain("App.kt"));
click(3);
click(4);
fireEvent.doubleClick(content(), { detail: 4 });
expect(rows()).not.toContain("App.kt");
act(() => setViewOption("singleClickExpand", false));
click(5);
click(6);
expect(rows()).not.toContain("App.kt");
fireEvent.doubleClick(content(), { detail: 6 });
await waitFor(() => expect(rows()).toContain("App.kt"));
const uninstall = installRepeatedDoubleClick();
try {
const { row, rows } = setup();
await waitFor(() => expect(rows()).toContain("README.md"));
const content = () => row("src").querySelector("[data-project-path]")!;
// WebKit fires `dblclick` only for the second click of a burst, never for the 4th or 6th.
const burst = (clicks: number) => {
for (let detail = 1; detail <= clicks; detail++) {
fireEvent.mouseDown(row("src"), { detail });
fireEvent.click(content(), { detail });
if (detail === 2) fireEvent.doubleClick(content(), { detail });
}
};
// Double-click mode toggles once per pair; single-click mode once per odd click.
let expanded = false;
for (const singleClickExpand of [false, true]) {
act(() => setViewOption("singleClickExpand", singleClickExpand));
for (const clicks of [2, 4, 6, 3, 5]) {
burst(clicks);
const toggles = singleClickExpand ? Math.ceil(clicks / 2) : Math.floor(clicks / 2);
if (toggles % 2) expanded = !expanded;
await waitFor(() => expect(rows().includes("App.kt")).toBe(expanded));
}
}
} finally {
uninstall();
act(() => setViewOption("singleClickExpand", false));
}
});
it("cancels a new file when its name field loses focus", async () => {
+3
Переглянути файл
@@ -4,6 +4,7 @@ import "./styles/global.css";
import { Workbench } from "./workbench/Workbench";
import { activateSecondaryWindow, activateWorkbench } from "./workbench/activate";
import { activateFeatures, activateSettingsWindowFeatures, SettingsWindow, settingsWindowParent } from "./features";
import { installRepeatedDoubleClick } from "./platform/doubleClick";
import { installKeyboardDispatcher } from "./platform/keymap";
import { isTauri } from "./platform/ipc";
import { hideSplash } from "./platform/splash";
@@ -16,6 +17,8 @@ if (import.meta.env.DEV && isTauri()) {
(window as unknown as { __TAURI__?: unknown }).__TAURI__ ??= { core: { invoke }, event: { emit } };
}
installRepeatedDoubleClick();
// The native Settings window (`settings-<parent>`) loads this page too.
const settingsParent = settingsWindowParent();
+64
Переглянути файл
@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent } from "@testing-library/react";
import { installRepeatedDoubleClick } from "./doubleClick";
describe("installRepeatedDoubleClick", () => {
let uninstall: () => void;
let el: HTMLElement;
let dbl: ReturnType<typeof vi.fn<(detail: number, target: EventTarget | null) => void>>;
beforeEach(() => {
uninstall = installRepeatedDoubleClick();
el = document.createElement("div");
document.body.append(el);
dbl = vi.fn();
el.addEventListener("dblclick", (e) => dbl((e as MouseEvent).detail, e.target));
});
afterEach(() => {
uninstall();
el.remove();
});
/** A burst as WebKit delivers it: `dblclick` only after the second click. */
const burst = (clicks: number) => {
for (let detail = 1; detail <= clicks; detail++) {
fireEvent.click(el, { detail });
if (detail === 2) fireEvent.doubleClick(el, { detail });
}
};
it("turns each later even click of a burst into one double-click on its target", () => {
burst(6);
expect(dbl.mock.calls).toEqual([
[2, el],
[4, el],
[6, el],
]);
});
it("leaves odd clicks, other buttons and the first double-click alone", () => {
burst(3);
fireEvent.click(el, { detail: 4, button: 1 });
expect(dbl.mock.calls).toEqual([[2, el]]);
});
it("drops the engine's own double-click when it also fires one", () => {
fireEvent.click(el, { detail: 4 });
fireEvent.doubleClick(el, { detail: 4 });
expect(dbl).toHaveBeenCalledTimes(1);
});
it("keeps the click handlers ahead of the double-click", () => {
const order: string[] = [];
el.addEventListener("click", () => order.push("click"));
el.addEventListener("dblclick", () => order.push("dblclick"));
fireEvent.click(el, { detail: 4 });
expect(order).toEqual(["click", "dblclick"]);
});
it("does nothing once uninstalled", () => {
uninstall();
fireEvent.click(el, { detail: 4 });
expect(dbl).not.toHaveBeenCalled();
});
});
+55
Переглянути файл
@@ -0,0 +1,55 @@
/**
* WebKit (the macOS webview) fires `dblclick` only for the second click of a burst: a
* quick double-click right after another arrives as `click` with `detail` 3 and 4 and no
* `dblclick`, so the second double-click on a folder, tab or row did nothing. This turns
* every later even click of a burst into a `dblclick` on the same target, so React
* `onDoubleClick` handlers (and their `stopPropagation`) see each double-click. An engine
* that does fire its own `dblclick` for that click gets it suppressed as a duplicate.
*/
const synthetic = new WeakSet<Event>();
export function installRepeatedDoubleClick(win: Window = window): () => void {
let last: { target: EventTarget; detail: number } | null = null;
// Bubble phase on the window: after the page's own click handlers, like the engine's dblclick.
const onClick = (e: MouseEvent) => {
last = null;
if (e.button !== 0 || e.detail < 4 || e.detail % 2 !== 0 || !e.target) return;
const dbl = new MouseEvent("dblclick", {
bubbles: true,
cancelable: true,
composed: true,
view: e.view,
detail: e.detail,
screenX: e.screenX,
screenY: e.screenY,
clientX: e.clientX,
clientY: e.clientY,
button: e.button,
buttons: e.buttons,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
metaKey: e.metaKey,
});
synthetic.add(dbl);
last = { target: e.target, detail: e.detail };
e.target.dispatchEvent(dbl);
};
const onDoubleClick = (e: MouseEvent) => {
if (synthetic.has(e) || !last) return;
const duplicate = e.detail === last.detail && e.target === last.target;
last = null;
if (!duplicate) return;
e.stopImmediatePropagation();
e.preventDefault();
};
win.addEventListener("click", onClick);
win.addEventListener("dblclick", onDoubleClick, true);
return () => {
win.removeEventListener("click", onClick);
win.removeEventListener("dblclick", onDoubleClick, true);
};
}