From 329ad2c644042a866236ac0a746fdd480f1ce0ce Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:34:29 +0800 Subject: [PATCH] fix(ui): treat every second click of a burst as a double-click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../project-tree/ProjectView.test.tsx | 52 ++++++++------- src/main.tsx | 3 + src/platform/doubleClick.test.ts | 64 +++++++++++++++++++ src/platform/doubleClick.ts | 55 ++++++++++++++++ 4 files changed, 151 insertions(+), 23 deletions(-) create mode 100644 src/platform/doubleClick.test.ts create mode 100644 src/platform/doubleClick.ts diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 6c68edcf1..f916157df 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -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 () => { diff --git a/src/main.tsx b/src/main.tsx index df8199162..657b4a5d2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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-`) loads this page too. const settingsParent = settingsWindowParent(); diff --git a/src/platform/doubleClick.test.ts b/src/platform/doubleClick.test.ts new file mode 100644 index 000000000..3878909bd --- /dev/null +++ b/src/platform/doubleClick.test.ts @@ -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 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(); + }); +}); diff --git a/src/platform/doubleClick.ts b/src/platform/doubleClick.ts new file mode 100644 index 000000000..147fdd153 --- /dev/null +++ b/src/platform/doubleClick.ts @@ -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(); + +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); + }; +}