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({
)}