From f9a11ca64024bd6bc6790cf097787fd9c2011e67 Mon Sep 17 00:00:00 2001 From: ilia Date: Wed, 15 Jul 2026 17:02:38 -0400 Subject: [PATCH] Fix context menu Pin/Delete and Control+click race. Control+click was treated as multi-select and a window click listener dismissed the menu before Pin/Delete could run; fire menu actions on mousedown and close only on outside pointerdown. --- docs/GUIDE.md | 4 +- docs/LAUNCH-AT-LOGIN.md | 4 ++ src/App.test.tsx | 70 ++++++++++++++++++++++++++- src/App.tsx | 21 ++++++-- src/components/ClipboardList.test.tsx | 17 +++++++ src/components/ClipboardList.tsx | 8 ++- src/components/ContextMenu.test.tsx | 21 ++++---- src/components/ContextMenu.tsx | 30 +++++++++--- 8 files changed, 151 insertions(+), 24 deletions(-) diff --git a/docs/GUIDE.md b/docs/GUIDE.md index e85e0f0..0ff5973 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -82,8 +82,10 @@ saved history entry is never modified. |---|---| | Multi-select | `Cmd+Click` to toggle one, `Shift+Click`/`Shift+Arrow` for a range, `Cmd+A` for all | | Paste multiple selected items (joined with newlines) | `Enter` | -| Delete | `Backspace`/`Delete` with items selected | +| Delete | `Backspace`/`Delete` with items selected, or right-click → **Delete** | | Pin (keep forever, always on top) | Right-click → **Pin** / **Unpin** | + +On macOS, **Control+click** opens the context menu (same as right-click). Use **Cmd+Click** for multi-select — Control+click is not multi-select. | Search | Just start typing — instant full-text search | ### What you'll see in the list diff --git a/docs/LAUNCH-AT-LOGIN.md b/docs/LAUNCH-AT-LOGIN.md index 5067fc3..78903c4 100644 --- a/docs/LAUNCH-AT-LOGIN.md +++ b/docs/LAUNCH-AT-LOGIN.md @@ -29,6 +29,10 @@ If the toggle fails: 1. System Settings → General → Login Items → **+** → choose `maCopy.app` 2. Or: right-click maCopy in the Dock (while open) → Options → **Open at Login** +3. Or install a LaunchAgent (also restarts after crash/`kill`, but not after a normal Quit): + + `~/Library/LaunchAgents/macopy.plist` → `/Applications/maCopy.app/Contents/MacOS/macopy` + with `RunAtLoad` + `KeepAlive` (`SuccessfulExit` = false). ## Requirements for it to stick diff --git a/src/App.test.tsx b/src/App.test.tsx index 521db30..0e76d58 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -114,7 +114,7 @@ describe("App", () => { }); fireEvent.contextMenu(screen.getByText("Copy me")); - fireEvent.click(await screen.findByText("Copy")); + fireEvent.mouseDown(await screen.findByText("Copy")); await waitFor(() => { expect(mockInvoke).toHaveBeenCalledWith("copy_to_clipboard", { @@ -125,6 +125,74 @@ describe("App", () => { expect(mockInvoke).not.toHaveBeenCalledWith("paste_and_refocus", expect.anything()); }); + it("invokes toggle_pin from the Pin context menu action", async () => { + const entries = [makeEntry({ content: "Pin me", pinned: false })]; + mockTauriCommands(entries); + + render(); + + await waitFor(() => { + expect(screen.getByText("Pin me")).toBeInTheDocument(); + }); + + fireEvent.contextMenu(screen.getByText("Pin me")); + // Simulate the trailing click from a Control+click open — must not kill the menu. + act(() => { + vi.advanceTimersByTime(0); + }); + fireEvent.click(document.body); + + expect(screen.getByText("Pin")).toBeInTheDocument(); + fireEvent.mouseDown(screen.getByText("Pin")); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("toggle_pin", { id: entries[0].id }); + }); + }); + + it("invokes delete_entry from the Delete context menu action", async () => { + const entries = [makeEntry({ content: "Trash me" })]; + mockTauriCommands(entries); + + render(); + + await waitFor(() => { + expect(screen.getByText("Trash me")).toBeInTheDocument(); + }); + + fireEvent.contextMenu(screen.getByText("Trash me")); + fireEvent.mouseDown(await screen.findByText("Delete")); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("delete_entry", { id: entries[0].id }); + }); + }); + + it("keeps the context menu open through the Control+click trailing click", async () => { + const entries = [makeEntry({ content: "Stay open" })]; + mockTauriCommands(entries); + + render(); + + await waitFor(() => { + expect(screen.getByText("Stay open")).toBeInTheDocument(); + }); + + fireEvent.contextMenu(screen.getByText("Stay open")); + expect(screen.getByText("Delete")).toBeInTheDocument(); + + // Same-gesture primary click that used to dismiss via window click listener + fireEvent.click(screen.getByText("Stay open")); + expect(screen.getByText("Delete")).toBeInTheDocument(); + + // Outside press after the deferred listener attaches should close + act(() => { + vi.advanceTimersByTime(0); + }); + fireEvent.pointerDown(document.body); + expect(screen.queryByText("Delete")).not.toBeInTheDocument(); + }); + it("shows empty state text when there are no entries", async () => { mockTauriCommands([]); render(); diff --git a/src/App.tsx b/src/App.tsx index 9aa03b6..ed3d2be 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -315,11 +315,24 @@ export default function App() { pasteSingle, pasteEntries, handleDelete, selectOnly, selectRange, ]); + // Close the menu on outside pointerdown. Defer listener attachment so the + // opening gesture (esp. macOS Control+click, which also fires a click) does + // not immediately dismiss the menu before Pin/Delete can be used. useEffect(() => { - const close = () => setContextMenu(null); - window.addEventListener("click", close); - return () => window.removeEventListener("click", close); - }, []); + if (!contextMenu) return; + const onPointerDown = (e: PointerEvent) => { + const t = e.target; + if (t instanceof Element && t.closest("[data-context-menu]")) return; + setContextMenu(null); + }; + const timer = window.setTimeout(() => { + document.addEventListener("pointerdown", onPointerDown, true); + }, 0); + return () => { + window.clearTimeout(timer); + document.removeEventListener("pointerdown", onPointerDown, true); + }; + }, [contextMenu]); const selectedCount = selectedIds.size; diff --git a/src/components/ClipboardList.test.tsx b/src/components/ClipboardList.test.tsx index 34ad63b..ed5e18b 100644 --- a/src/components/ClipboardList.test.tsx +++ b/src/components/ClipboardList.test.tsx @@ -143,6 +143,23 @@ describe("ClipboardList", () => { expect(onCtrlClick).toHaveBeenCalledWith(0); }); + it("does not multi-select on Control+Click (macOS context-menu gesture)", () => { + const onCtrlClick = vi.fn(); + const onSelect = vi.fn(); + const entries = [makeEntry({ content: "Control item" })]; + render( + + ); + fireEvent.mouseDown(screen.getByText("Control item"), { ctrlKey: true, button: 0 }); + expect(onCtrlClick).not.toHaveBeenCalled(); + expect(onSelect).not.toHaveBeenCalled(); + }); + it("calls onShiftClick when Shift+Click is used", () => { const onShiftClick = vi.fn(); const entries = [makeEntry({ content: "Shift item" })]; diff --git a/src/components/ClipboardList.tsx b/src/components/ClipboardList.tsx index f076352..9e450e5 100644 --- a/src/components/ClipboardList.tsx +++ b/src/components/ClipboardList.tsx @@ -140,7 +140,13 @@ const Row = memo(function Row({ onMouseDown={(e) => { // Use mousedown so paste wins the race against window blur/hide. if (e.button !== 0) return; - if (e.metaKey || e.ctrlKey) { + // Control+click is the macOS context-menu gesture — do not multi-select + // (Cmd+Click) or paste; let onContextMenu open the menu. + if (e.ctrlKey && !e.metaKey) { + e.preventDefault(); + return; + } + if (e.metaKey) { e.preventDefault(); onCtrlClick(i); return; diff --git a/src/components/ContextMenu.test.tsx b/src/components/ContextMenu.test.tsx index 30de2bf..dfa7579 100644 --- a/src/components/ContextMenu.test.tsx +++ b/src/components/ContextMenu.test.tsx @@ -24,10 +24,10 @@ describe("ContextMenu", () => { expect(screen.getByText("Delete")).toBeInTheDocument(); }); - it("calls onCopyOnly when Copy is clicked", () => { + it("calls onCopyOnly when Copy is pressed", () => { const onCopyOnly = vi.fn(); render(); - fireEvent.click(screen.getByText("Copy")); + fireEvent.mouseDown(screen.getByText("Copy")); expect(onCopyOnly).toHaveBeenCalledOnce(); }); @@ -43,41 +43,40 @@ describe("ContextMenu", () => { expect(screen.getByText("Delete 3 items")).toBeInTheDocument(); }); - it("calls onPaste when Paste is clicked", () => { + it("calls onPaste when Paste is pressed", () => { const onPaste = vi.fn(); render(); - fireEvent.click(screen.getByText("Paste")); + fireEvent.mouseDown(screen.getByText("Paste")); expect(onPaste).toHaveBeenCalledWith("none"); }); it("calls onPaste(plain) for Paste plain", () => { const onPaste = vi.fn(); render(); - fireEvent.click(screen.getByText("Paste plain")); + fireEvent.mouseDown(screen.getByText("Paste plain")); expect(onPaste).toHaveBeenCalledWith("plain"); }); it("calls onPaste with transform id", () => { const onPaste = vi.fn(); render(); - fireEvent.click(screen.getByText("Pretty JSON")); + fireEvent.mouseDown(screen.getByText("Pretty JSON")); expect(onPaste).toHaveBeenCalledWith("json"); }); - it("calls onPin when Pin is clicked", () => { + it("calls onPin when Pin is pressed", () => { const onPin = vi.fn(); render(); - fireEvent.click(screen.getByText("Pin")); + fireEvent.mouseDown(screen.getByText("Pin")); expect(onPin).toHaveBeenCalledOnce(); }); - it("calls onDelete when Delete is clicked", () => { + it("calls onDelete when Delete is pressed", () => { const onDelete = vi.fn(); render(); - fireEvent.click(screen.getByText("Delete")); + fireEvent.mouseDown(screen.getByText("Delete")); expect(onDelete).toHaveBeenCalledOnce(); }); - it("is positioned at the given coordinates", () => { const { container } = render(); const menu = container.firstChild as HTMLElement; diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 8fe5f66..0564ebb 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -26,29 +26,44 @@ export default function ContextMenu({ const adjustedY = Math.min(y, window.innerHeight - 280); const multi = selectedCount > 1; + // Fire actions on mousedown (same race as list paste) so blur/hide cannot + // cancel the click before onClick runs. + const act = + (fn: () => void) => + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + fn(); + }; + return (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} >
{TRANSFORMS.filter((t) => t.id !== "none" && t.id !== "plain").map((t) => ( ))}
@@ -80,8 +97,9 @@ export default function ContextMenu({ )}
-- 2.49.1