From 80a6c01cdbab8aafdca39f087469bf2d419ce87a Mon Sep 17 00:00:00 2001 From: ilia Date: Tue, 12 May 2026 14:15:42 -0400 Subject: [PATCH] Fix cursor positioning, resize, and paste; add App tests and Cursor rules - Use CoreGraphics (core-graphics crate) for global mouse position instead of Tauri's window-relative cursor_position() which fails when hidden - Switch to titleBarStyle overlay with hiddenTitle for native resize handles while keeping the frameless look (decorations:false had no resize affordance) - Fix paste_and_refocus: use .output() instead of .spawn() so osascript actually completes, increase delay to 250ms for reliable app refocus - Add comprehensive App.test.tsx (7 integration tests) bringing total to 47 - Add multi-select and type badge tests for ClipboardList and ContextMenu - Update Tauri mock in setup.ts with innerSize/scaleFactor for resize tests - Create .cursor/rules/ with 4 rule files for project conventions - Add npm run lint and npm run check scripts - Update README with full usage table and current test counts (74 total) Co-authored-by: Cursor --- .cursor/rules/project-overview.mdc | 31 ++++++ .cursor/rules/react-frontend.mdc | 33 ++++++ .cursor/rules/rust-backend.mdc | 28 +++++ .cursor/rules/testing.mdc | 29 ++++++ README.md | 83 ++++++++++----- package.json | 4 +- src-tauri/Cargo.lock | 16 ++- src-tauri/Cargo.toml | 3 + src-tauri/src/commands.rs | 15 +-- src-tauri/src/lib.rs | 111 ++++++++++++-------- src-tauri/tauri.conf.json | 6 +- src/App.test.tsx | 145 ++++++++++++++++++++++++++ src/App.tsx | 5 +- src/components/ClipboardList.test.tsx | 48 +++++++++ src/components/ContextMenu.test.tsx | 18 ++++ src/index.css | 7 ++ src/test/setup.ts | 3 +- 17 files changed, 504 insertions(+), 81 deletions(-) create mode 100644 .cursor/rules/project-overview.mdc create mode 100644 .cursor/rules/react-frontend.mdc create mode 100644 .cursor/rules/rust-backend.mdc create mode 100644 .cursor/rules/testing.mdc create mode 100644 src/App.test.tsx diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc new file mode 100644 index 0000000..5cf47d4 --- /dev/null +++ b/.cursor/rules/project-overview.mdc @@ -0,0 +1,31 @@ +--- +description: maCopy project overview and architecture +alwaysApply: true +--- + +# maCopy — macOS Clipboard Manager + +Tauri 2 desktop app: Rust backend + React/TypeScript frontend. + +## Architecture + +- `src-tauri/src/lib.rs` — App setup, tray icon, global hotkey (Cmd+Shift+V), window management +- `src-tauri/src/clipboard.rs` — Background polling thread (500ms), SHA-256 dedup, arboard crate +- `src-tauri/src/db.rs` — SQLite via rusqlite, FTS5 full-text search, settings CRUD +- `src-tauri/src/commands.rs` — Tauri IPC commands exposed to frontend +- `src/App.tsx` — Main React component, state, keyboard nav, multi-select +- `src/components/` — SearchBar, ClipboardList, ContextMenu, SettingsPanel + +## Key Patterns + +- Window positions near cursor via CoreGraphics (`core-graphics` crate) +- Paste-to-previous-app: hide window → 250ms delay → AppleScript `Cmd+V` +- Window uses `titleBarStyle: "overlay"` with `hiddenTitle` for native resize + frameless look +- Settings stored in SQLite `settings` table as key-value pairs +- Clipboard entries deduplicated by SHA-256 hash, auto-trimmed to max_history + +## Testing + +- Rust: `cargo test` in `src-tauri/` — 27 unit tests (db, clipboard hashing) +- Frontend: `npx vitest run` — 47 tests (App, SearchBar, ClipboardList, ContextMenu, SettingsPanel) +- Tauri APIs mocked in `src/test/setup.ts` diff --git a/.cursor/rules/react-frontend.mdc b/.cursor/rules/react-frontend.mdc new file mode 100644 index 0000000..c5d897d --- /dev/null +++ b/.cursor/rules/react-frontend.mdc @@ -0,0 +1,33 @@ +--- +description: React/TypeScript frontend patterns for Tauri app +globs: src/**/*.{ts,tsx} +alwaysApply: false +--- + +# Frontend Conventions + +## Component Structure + +- Functional components with hooks, no class components +- Types in `src/types.ts`, shared between components +- Test files colocated: `Component.test.tsx` next to `Component.tsx` +- Test factories in `src/test/factories.ts` (`makeEntry`, `makeSettings`) + +## Tauri IPC + +- Use `invoke()` from `@tauri-apps/api/core` to call Rust commands +- Use `writeText()` from `@tauri-apps/plugin-clipboard-manager` for clipboard writes +- Mock all Tauri APIs in `src/test/setup.ts` for Vitest + +## State Management + +- App-level state in `App.tsx` via `useState`/`useCallback` +- Multi-select: `selectedIds` (Set), `anchorIndex`, `focusIndex` +- Selection actions: `selectOnly` (plain click), `toggleSelect` (Cmd+Click), `selectRange` (Shift+Click/Arrow) + +## Styling + +- Tailwind CSS v4 with custom theme tokens in `src/index.css` +- Theme colors: `--color-surface`, `--color-accent`, `--color-text-primary`, etc. +- Dark mode via `@media (prefers-color-scheme: dark)` overrides in CSS +- Never use inline styles except for dynamic positioning (ContextMenu) diff --git a/.cursor/rules/rust-backend.mdc b/.cursor/rules/rust-backend.mdc new file mode 100644 index 0000000..1778bbd --- /dev/null +++ b/.cursor/rules/rust-backend.mdc @@ -0,0 +1,28 @@ +--- +description: Rust backend conventions for Tauri commands and database +globs: src-tauri/src/**/*.rs +alwaysApply: false +--- + +# Rust Backend Conventions + +## Tauri Commands + +- All IPC commands live in `commands.rs`, annotated with `#[tauri::command]` +- Commands that need DB access take `State<'_, DbState>` +- Commands that need the app handle take `app: tauri::AppHandle` +- Return `Result` — map errors with `.map_err(|e| e.to_string())` +- Register every new command in `lib.rs` → `invoke_handler` + +## Database (db.rs) + +- `Database` wraps `Mutex` for thread safety +- Use `Database::in_memory()` in tests, `Database::new()` in production +- Settings are key-value in the `settings` table; add defaults in `init_tables()` +- FTS5 table `clipboard_fts` syncs via triggers on insert/delete + +## macOS-Specific Code + +- Gate with `#[cfg(target_os = "macos")]` +- Cursor position: use `core-graphics` crate's `CGEvent` (not Tauri's `cursor_position()` which is window-relative) +- AppleScript for paste simulation: always use `.output()` not `.spawn()` to ensure execution completes diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc new file mode 100644 index 0000000..3850768 --- /dev/null +++ b/.cursor/rules/testing.mdc @@ -0,0 +1,29 @@ +--- +description: Testing conventions for Rust and TypeScript +globs: "**/*.test.{ts,tsx}" +alwaysApply: false +--- + +# Testing Conventions + +## Rust Tests (cargo test) + +- In-module `#[cfg(test)]` blocks using `Database::in_memory()` +- Test naming: `snake_case` describing behavior (e.g. `trim_preserves_pinned_entries`) +- All tests must pass before committing: `cd src-tauri && cargo test` + +## Frontend Tests (Vitest) + +- Use `@testing-library/react` with `jsdom` environment +- Mock Tauri APIs in `src/test/setup.ts` — never call real IPC in tests +- Use `makeEntry()` and `makeSettings()` factories for test data +- Call `resetIdCounter()` in `beforeEach` when IDs matter +- When testing async Tauri commands, use `waitFor()` assertions +- Guard `scrollIntoView` with optional chaining (`el?.scrollIntoView?.()`) since jsdom doesn't implement it + +## Running Tests + +```bash +cd src-tauri && cargo test # Rust: 27 tests +npx vitest run # Frontend: 47 tests +``` diff --git a/README.md b/README.md index 0df6a8c..cf3d312 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # maCopy -A lightweight macOS clipboard manager that lives in your menu bar. Built with Tauri 2, React, TypeScript, and SQLite. +A fast, native macOS clipboard manager that lives in your menu bar. Built with Tauri 2, React, TypeScript, and SQLite. ![macOS](https://img.shields.io/badge/macOS-10.15+-black?logo=apple) ![Tauri](https://img.shields.io/badge/Tauri-2-blue?logo=tauri) @@ -12,10 +12,14 @@ A lightweight macOS clipboard manager that lives in your menu bar. Built with Ta - **Global hotkey** — `Cmd+Shift+V` opens the window from anywhere - **Clipboard monitoring** — polls every 500ms for text, images, and file paths - **Full-text search** — instant filtering via SQLite FTS5 -- **Quick paste** — `Cmd+1` through `Cmd+9` to paste the Nth item +- **Quick paste** — `Cmd+1` through `Cmd+9` to paste the Nth item directly into the previous app +- **Paste & return** — clicking an entry copies it to clipboard, hides the window, and auto-pastes into the previously-focused app +- **Multi-select** — `Cmd+Click` to toggle, `Shift+Arrow` or `Shift+Click` to range-select, `Cmd+A` for all, `Enter` to paste selected - **Pin entries** — pinned items stay at the top and are never auto-deleted -- **Context menu** — right-click for Copy, Pin/Unpin, Delete -- **Auto-trim** — keeps the last 500 entries by default (configurable: 100/500/1000) +- **Context menu** — right-click for Paste, Pin/Unpin, Delete (with multi-select support) +- **Resizable window** — drag edges to resize; size is remembered between sessions +- **Window positioning** — choose where the window appears: near cursor, center, or any corner (configurable in Settings) +- **Auto-trim** — keeps up to 50K entries (configurable: 1K/5K/10K/50K) - **Dark/light mode** — follows macOS system appearance - **Privacy** — whitespace-only entries are ignored; pause monitoring from the tray @@ -25,11 +29,12 @@ A lightweight macOS clipboard manager that lives in your menu bar. Built with Ta - **Rust** 1.77+ — [install via rustup](https://rustup.rs) - **Node.js** 18+ and npm - **Xcode Command Line Tools** — `xcode-select --install` +- **Accessibility permission** — required for auto-paste (System Settings → Privacy & Security → Accessibility → add maCopy) ## Quick Start ```bash -git clone maCopy +git clone gitea@10.0.30.169:ilia/maCopy.git cd maCopy npm install npm run tauri dev @@ -37,6 +42,21 @@ npm run tauri dev The app will compile the Rust backend, start the Vite dev server, and launch the menu bar app. +## Usage + +| Action | How | +|---|---| +| Open/close window | Click tray icon or press `Cmd+Shift+V` | +| Paste an entry | Click it, or press `Enter` | +| Quick paste | `Cmd+1` through `Cmd+9` | +| Search | Just start typing | +| Select multiple | `Cmd+Click` or `Shift+Arrow` | +| Select all | `Cmd+A` | +| Delete | `Backspace` or `Delete` (on selected items) | +| Pin/unpin | Right-click → Pin/Unpin | +| Settings | Tray icon → Settings… | +| Dismiss | `Escape` or click outside | + ## Development ### Project Structure @@ -45,26 +65,28 @@ The app will compile the Rust backend, start the Vite dev server, and launch the maCopy/ ├── src/ # React + TypeScript frontend │ ├── main.tsx # Entry point -│ ├── App.tsx # Main app: routing, keyboard nav, polling +│ ├── App.tsx # Main app: state, keyboard nav, multi-select +│ ├── App.test.tsx # App integration tests │ ├── index.css # Tailwind v4 + custom theme tokens -│ ├── types.ts # Shared TypeScript types +│ ├── types.ts # Shared TypeScript interfaces │ ├── test/ # Test setup and factories -│ │ ├── setup.ts -│ │ └── factories.ts +│ │ ├── setup.ts # Tauri API mocks for jsdom +│ │ └── factories.ts # makeEntry(), makeSettings() │ └── components/ -│ ├── SearchBar.tsx # Auto-focused search input -│ ├── ClipboardList.tsx # Entry list with time-ago, pin badges -│ ├── ContextMenu.tsx # Right-click: Copy / Pin / Delete -│ └── SettingsPanel.tsx # Toggles + max history + clear all +│ ├── SearchBar.tsx +│ ├── ClipboardList.tsx # Entry list with multi-select, type badges +│ ├── ContextMenu.tsx # Right-click menu with multi-select labels +│ └── SettingsPanel.tsx # Toggles, max history, window position ├── src-tauri/ # Rust backend │ ├── Cargo.toml │ ├── tauri.conf.json # Tauri config, permissions, window │ └── src/ │ ├── main.rs # Binary entry point -│ ├── lib.rs # App setup: tray, hotkey, window behavior -│ ├── clipboard.rs # Background polling thread (500ms) -│ ├── db.rs # SQLite CRUD + FTS5 + auto-trim +│ ├── lib.rs # App setup: tray, hotkey, window management +│ ├── clipboard.rs # Background polling thread (500ms, SHA-256 dedup) +│ ├── db.rs # SQLite CRUD + FTS5 + settings + auto-trim │ └── commands.rs # Tauri IPC commands +├── .cursor/rules/ # Cursor AI rules for this project ├── package.json ├── vite.config.ts ├── vitest.config.ts @@ -75,12 +97,13 @@ maCopy/ | Command | Description | |---|---| -| `npm run tauri dev` | Run the app in development mode with hot reload | +| `npm run tauri dev` | Run in development mode with hot reload | | `npm run tauri build` | Build a release `.app` bundle | -| `npm test` | Run frontend tests (vitest) | -| `npm run test:watch` | Run frontend tests in watch mode | -| `npm run test:rust` | Run Rust backend tests | +| `npm test` | Run frontend tests (Vitest, 47 tests) | +| `npm run test:rust` | Run Rust backend tests (27 tests) | | `npm run test:all` | Run all tests (frontend + backend) | +| `npm run lint` | TypeScript type-check | +| `npm run check` | Lint + all tests | ### Tech Stack @@ -89,11 +112,12 @@ maCopy/ | Framework | [Tauri 2](https://v2.tauri.app) | | Frontend | React 18, TypeScript, Tailwind CSS v4 | | Backend | Rust, rusqlite (bundled SQLite) | -| Clipboard | [arboard](https://crates.io/crates/arboard) for cross-platform access | +| Clipboard | [arboard](https://crates.io/crates/arboard) for system clipboard access | +| Mouse position | [core-graphics](https://crates.io/crates/core-graphics) for global cursor coordinates | | Search | SQLite FTS5 with content-sync triggers | | Hotkey | tauri-plugin-global-shortcut | | Autostart | tauri-plugin-autostart | -| Testing | vitest + @testing-library/react (frontend), `cargo test` (backend) | +| Testing | Vitest + @testing-library/react (frontend), `cargo test` (backend) | ## Architecture @@ -101,13 +125,17 @@ maCopy/ A dedicated Rust thread polls the system clipboard every 500ms using the `arboard` crate. Each new clipboard value is hashed (SHA-256) and compared against the last known hash to avoid duplicates. Text, images (stored as base64 PNG data URIs), and file paths are all captured. +### Paste & Return + +When you select an entry, maCopy writes it to the system clipboard, hides its window, waits 250ms for macOS to refocus the previous app, then simulates `Cmd+V` via AppleScript. This requires Accessibility permission. + ### SQLite + FTS5 -The database uses a content-synced FTS5 virtual table with triggers that automatically keep the full-text index in sync with the main `clipboard_entries` table. This enables instant prefix search as you type. +The database uses a content-synced FTS5 virtual table with triggers that automatically keep the full-text index in sync with the `clipboard_entries` table. This enables instant prefix search as you type. ### Window Behavior -The window is frameless, always-on-top, and hides when it loses focus — behaving like a native macOS popover. Clicking the tray icon or pressing `Cmd+Shift+V` toggles visibility. +The window uses `titleBarStyle: "overlay"` for native resize handles while keeping the frameless aesthetic. It's always-on-top and hides on blur. Position is determined by the user's setting (near cursor via CoreGraphics, center, or a screen corner). ## Data Storage @@ -119,13 +147,13 @@ The SQLite database is stored at: ## Testing -### Frontend (31 tests) +### Frontend (47 tests) ```bash npm test ``` -Tests cover all four UI components: SearchBar, ClipboardList, ContextMenu, and SettingsPanel. Tauri APIs are mocked so tests run in jsdom without the native runtime. +Tests cover App integration, SearchBar, ClipboardList (including multi-select), ContextMenu, and SettingsPanel. Tauri APIs are mocked in `src/test/setup.ts`. ### Backend (27 tests) @@ -138,7 +166,8 @@ Tests use in-memory SQLite databases and cover: CRUD operations, FTS5 search, au ### All tests ```bash -npm run test:all +npm run test:all # 74 total tests +npm run check # lint + all tests ``` ## Building for Release diff --git a/package.json b/package.json index 72ca6d4..c2a5ea5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,9 @@ "test": "vitest run", "test:watch": "vitest", "test:rust": "cd src-tauri && cargo test", - "test:all": "npm run test && npm run test:rust" + "test:all": "npm run test && npm run test:rust", + "lint": "tsc --noEmit", + "check": "npm run lint && npm run test:all" }, "dependencies": { "@tauri-apps/api": "^2.11.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e983c38..dafda45 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -418,6 +418,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + [[package]] name = "core-graphics" version = "0.25.0" @@ -1948,6 +1961,7 @@ dependencies = [ "arboard", "base64 0.22.1", "chrono", + "core-graphics 0.24.0", "dirs 5.0.1", "hex", "log", @@ -3276,7 +3290,7 @@ dependencies = [ "bitflags 2.11.1", "block2", "core-foundation", - "core-graphics", + "core-graphics 0.25.0", "crossbeam-channel", "dbus", "dispatch2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9261dc6..9d29ac6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,5 +28,8 @@ png = "0.17" dirs = "5" tokio = { version = "1", features = ["time", "macros"] } +[target.'cfg(target_os = "macos")'.dependencies] +core-graphics = "0.24" + [features] custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 2993c60..f330907 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -64,23 +64,26 @@ pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Resu .map_err(|e| e.to_string()) } -/// Hide the window then simulate Cmd+V so the content pastes into the -/// previously-focused app. The 150ms delay gives macOS time to refocus. +/// Hide the window, wait for macOS to refocus the previous app, +/// then simulate Cmd+V via AppleScript to paste the clipboard content. +/// Requires Accessibility permission (System Settings → Privacy → Accessibility). #[tauri::command] pub async fn paste_and_refocus(app: tauri::AppHandle) -> Result<(), String> { if let Some(window) = app.get_webview_window("main") { let _ = window.hide(); } - tokio::time::sleep(std::time::Duration::from_millis(150)).await; + // macOS needs time to refocus the previous application + tokio::time::sleep(std::time::Duration::from_millis(250)).await; - // Use AppleScript to press Cmd+V in the frontmost app #[cfg(target_os = "macos")] { - let _ = std::process::Command::new("osascript") + // .output() blocks until osascript finishes, ensuring the keystroke lands + std::process::Command::new("osascript") .arg("-e") .arg(r#"tell application "System Events" to keystroke "v" using command down"#) - .spawn(); + .output() + .map_err(|e| format!("osascript failed: {}", e))?; } Ok(()) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 817cb35..a08e9d8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,23 +7,40 @@ use db::Database; use std::sync::atomic::AtomicBool; use std::sync::Arc; use tauri::{ - menu::{Menu, MenuItem, PredefinedMenuItem, CheckMenuItem}, + menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, tray::TrayIconBuilder, Emitter, Manager, WindowEvent, }; use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; +/// Get the global mouse position using CoreGraphics. +/// Returns logical (x, y) in screen coordinates with origin at top-left. +#[cfg(target_os = "macos")] +fn get_mouse_position() -> Option<(f64, f64)> { + use core_graphics::event::CGEvent; + use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; + + let source = CGEventSource::new(CGEventSourceStateID::CombinedSessionState).ok()?; + let event = CGEvent::new(source).ok()?; + let loc = event.location(); + Some((loc.x, loc.y)) +} + +#[cfg(not(target_os = "macos"))] +fn get_mouse_position() -> Option<(f64, f64)> { + None +} + fn show_window(app: &tauri::AppHandle) { if let Some(window) = app.get_webview_window("main") { - // Position window before showing based on settings if let Some(db) = app.try_state::() { if let Ok(settings) = db.0.get_settings() { - position_window(&window, &settings.window_position); let _ = window.set_size(tauri::LogicalSize::new( settings.window_width as f64, settings.window_height as f64, )); + position_window(&window, &settings.window_position); } } let _ = window.show(); @@ -42,54 +59,63 @@ fn toggle_window(app: &tauri::AppHandle) { } fn position_window(window: &tauri::WebviewWindow, position: &str) { + let get_monitor_info = + |w: &tauri::WebviewWindow| -> Option<(f64, f64, f64)> { + let monitor = w.current_monitor().ok()??; + let size = monitor.size(); + let scale = monitor.scale_factor(); + Some((size.width as f64 / scale, size.height as f64 / scale, scale)) + }; + + let win_w = 420.0_f64; + let win_h = 560.0_f64; + let (ww, wh) = window + .outer_size() + .ok() + .map(|s| { + let scale = window + .scale_factor() + .unwrap_or(1.0); + (s.width as f64 / scale, s.height as f64 / scale) + }) + .unwrap_or((win_w, win_h)); + match position { "center" => { let _ = window.center(); } "top-right" => { - if let Ok(monitor) = window.current_monitor() { - if let Some(monitor) = monitor { - let size = monitor.size(); - let scale = monitor.scale_factor(); - let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560)); - let x = (size.width as f64 / scale) - (win_size.width as f64 / scale) - 10.0; - let _ = window.set_position(tauri::LogicalPosition::new(x, 30.0)); - } + if let Some((sw, _, _)) = get_monitor_info(window) { + let _ = window.set_position(tauri::LogicalPosition::new(sw - ww - 12.0, 30.0)); } } "top-left" => { - let _ = window.set_position(tauri::LogicalPosition::new(10.0, 30.0)); + let _ = window.set_position(tauri::LogicalPosition::new(12.0, 30.0)); } "bottom-right" => { - if let Ok(monitor) = window.current_monitor() { - if let Some(monitor) = monitor { - let size = monitor.size(); - let scale = monitor.scale_factor(); - let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560)); - let x = (size.width as f64 / scale) - (win_size.width as f64 / scale) - 10.0; - let y = (size.height as f64 / scale) - (win_size.height as f64 / scale) - 10.0; - let _ = window.set_position(tauri::LogicalPosition::new(x, y)); - } + if let Some((sw, sh, _)) = get_monitor_info(window) { + let _ = window.set_position(tauri::LogicalPosition::new( + sw - ww - 12.0, + sh - wh - 12.0, + )); } } "bottom-left" => { - if let Ok(monitor) = window.current_monitor() { - if let Some(monitor) = monitor { - let size = monitor.size(); - let scale = monitor.scale_factor(); - let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560)); - let y = (size.height as f64 / scale) - (win_size.height as f64 / scale) - 10.0; - let _ = window.set_position(tauri::LogicalPosition::new(10.0, y)); - } + if let Some((_, sh, _)) = get_monitor_info(window) { + let _ = window.set_position(tauri::LogicalPosition::new(12.0, sh - wh - 12.0)); } } - // "cursor" or default — position near the mouse cursor + // "cursor" — position near the mouse pointer _ => { - if let Ok(cursor) = window.cursor_position() { - let _ = window.set_position(tauri::LogicalPosition::new( - cursor.x - 210.0, - cursor.y + 10.0, - )); + if let Some((mx, my)) = get_mouse_position() { + if let Some((sw, sh, _)) = get_monitor_info(window) { + // Keep window on-screen + let x = (mx - ww / 2.0).clamp(8.0, sw - ww - 8.0); + let y = (my + 12.0).clamp(8.0, sh - wh - 8.0); + let _ = window.set_position(tauri::LogicalPosition::new(x, y)); + } else { + let _ = window.set_position(tauri::LogicalPosition::new(mx - ww / 2.0, my + 12.0)); + } } else { let _ = window.center(); } @@ -149,14 +175,13 @@ pub fn run() { app.global_shortcut().register(shortcut)?; let show_i = MenuItem::with_id(app, "show", "Show maCopy", true, None::<&str>)?; - let pause_i = CheckMenuItem::with_id(app, "pause", "Pause Monitoring", true, false, None::<&str>)?; + let pause_i = + CheckMenuItem::with_id(app, "pause", "Pause Monitoring", true, false, None::<&str>)?; let sep = PredefinedMenuItem::separator(app)?; let settings_i = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&show_i, &pause_i, &sep, &settings_i, &quit_i])?; - // Use with_id to prevent duplicate tray icons across hot-reloads let _tray = TrayIconBuilder::with_id("macopy-tray") .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) @@ -167,8 +192,10 @@ pub fn run() { move |app, event| match event.id.as_ref() { "show" => toggle_window(app), "pause" => { - let current = paused_clone.load(std::sync::atomic::Ordering::Relaxed); - paused_clone.store(!current, std::sync::atomic::Ordering::Relaxed); + let current = + paused_clone.load(std::sync::atomic::Ordering::Relaxed); + paused_clone + .store(!current, std::sync::atomic::Ordering::Relaxed); } "settings" => { if let Some(window) = app.get_webview_window("main") { @@ -177,9 +204,7 @@ pub fn run() { let _ = window.emit("open-settings", ()); } } - "quit" => { - app.exit(0); - } + "quit" => app.exit(0), _ => {} } }) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7c7d9e7..84cd53e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -20,7 +20,9 @@ "resizable": true, "minWidth": 320, "minHeight": 300, - "decorations": false, + "decorations": true, + "titleBarStyle": "overlay", + "hiddenTitle": true, "visible": false, "alwaysOnTop": true, "skipTaskbar": true, @@ -49,6 +51,8 @@ "core:window:allow-current-monitor", "core:window:allow-cursor-position", "core:window:allow-start-dragging", + "core:window:allow-start-resize-dragging", + "core:window:allow-scale-factor", "core:event:default", "core:event:allow-emit", "core:event:allow-listen", diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..50310c9 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,145 @@ +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { writeText } from "@tauri-apps/plugin-clipboard-manager"; +import App from "./App"; +import { makeEntry, makeSettings, resetIdCounter } from "./test/factories"; + +const mockInvoke = vi.mocked(invoke); +const mockWriteText = vi.mocked(writeText); + +function mockTauriCommands( + entries = [makeEntry({ content: "First" }), makeEntry({ content: "Second" })], + settings = makeSettings() +) { + mockInvoke.mockImplementation(async (cmd: string, args?: Record) => { + switch (cmd) { + case "get_entries": + case "search_entries": + return entries; + case "get_settings": + return settings; + case "paste_and_refocus": + return undefined; + case "save_window_size": + return undefined; + case "delete_entry": + return undefined; + case "toggle_pin": + return true; + case "clear_all": + return undefined; + default: + return undefined; + } + }); +} + +describe("App", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + resetIdCounter(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders the search bar and items bar", async () => { + mockTauriCommands(); + render(); + await waitFor(() => { + expect(screen.getByPlaceholderText("Search…")).toBeInTheDocument(); + }); + }); + + it("loads and displays entries from backend", async () => { + const entries = [makeEntry({ content: "Alpha" }), makeEntry({ content: "Beta" })]; + mockTauriCommands(entries); + + render(); + + await waitFor(() => { + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.getByText("Beta")).toBeInTheDocument(); + }); + }); + + it("shows entry count in footer", async () => { + const entries = [makeEntry({ content: "A" }), makeEntry({ content: "B" }), makeEntry({ content: "C" })]; + mockTauriCommands(entries); + + render(); + + await waitFor(() => { + expect(screen.getByText("3 items")).toBeInTheDocument(); + }); + }); + + it("invokes paste_and_refocus when clicking an entry", async () => { + const entries = [makeEntry({ content: "Click me" })]; + mockTauriCommands(entries); + mockWriteText.mockResolvedValue(undefined); + + render(); + + await waitFor(() => { + expect(screen.getByText("Click me")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("Click me")); + + await waitFor(() => { + expect(mockWriteText).toHaveBeenCalledWith("Click me"); + expect(mockInvoke).toHaveBeenCalledWith("paste_and_refocus"); + }); + }); + + it("shows empty state text when there are no entries", async () => { + mockTauriCommands([]); + render(); + + await waitFor(() => { + expect(screen.getByText("No clipboard history")).toBeInTheDocument(); + }); + }); + + it("shows settings panel when settings are opened", async () => { + mockTauriCommands(); + render(); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Search…")).toBeInTheDocument(); + }); + }); + + it("invokes delete_entry when delete key is pressed on selected items", async () => { + const entries = [makeEntry({ content: "To delete" })]; + mockTauriCommands(entries); + + render(); + + await waitFor(() => { + expect(screen.getByText("To delete")).toBeInTheDocument(); + }); + + // Ctrl+Click to select (doesn't trigger paste) + fireEvent.click(screen.getByText("To delete"), { metaKey: true }); + + // Clear the mock calls from setup + mockInvoke.mockClear(); + mockTauriCommands(entries); + + // Fire Backspace with focus NOT on input + // Blur the input first + const input = screen.getByPlaceholderText("Search…"); + input.blur(); + + fireEvent.keyDown(window, { key: "Backspace" }); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("delete_entry", { id: entries[0].id }); + }); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index e64ccc8..7ced534 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -235,6 +235,9 @@ export default function App() { return (
+ {/* Titlebar drag region — sits behind the native traffic lights */} +
+ {showSettings && settings ? ( ) : ( <> -
+
{ diff --git a/src/components/ClipboardList.test.tsx b/src/components/ClipboardList.test.tsx index d932203..65f4d10 100644 --- a/src/components/ClipboardList.test.tsx +++ b/src/components/ClipboardList.test.tsx @@ -115,4 +115,52 @@ describe("ClipboardList", () => { const row = container.querySelector(".divide-y > div"); expect(row?.className).toContain("bg-accent/10"); }); + + it("calls onCtrlClick when Cmd+Click is used", () => { + const onCtrlClick = vi.fn(); + const entries = [makeEntry({ content: "Ctrl item" })]; + render( + + ); + fireEvent.click(screen.getByText("Ctrl item"), { metaKey: true }); + expect(onCtrlClick).toHaveBeenCalledWith(0); + }); + + it("calls onShiftClick when Shift+Click is used", () => { + const onShiftClick = vi.fn(); + const entries = [makeEntry({ content: "Shift item" })]; + render( + + ); + fireEvent.click(screen.getByText("Shift item"), { shiftKey: true }); + expect(onShiftClick).toHaveBeenCalledWith(0); + }); + + it("applies focused style to focusIndex entry", () => { + const entries = [makeEntry({ content: "A" }), makeEntry({ content: "B" })]; + const { container } = render( + + ); + const rows = container.querySelectorAll(".divide-y > div"); + expect(rows[1]?.className).toContain("bg-surface-hover"); + }); + + it("shows type badge for entries beyond first 9", () => { + const entries = Array.from({ length: 10 }, (_, i) => + makeEntry({ content: `Item ${i + 1}` }) + ); + render(); + expect(screen.getByText("TXT")).toBeInTheDocument(); + }); + + it("shows IMG badge for image entries", () => { + const entries = Array.from({ length: 10 }, (_, i) => + makeEntry({ + content: `Item ${i + 1}`, + content_type: i === 9 ? "image" : "text", + }) + ); + render(); + expect(screen.getByText("IMG")).toBeInTheDocument(); + }); }); diff --git a/src/components/ContextMenu.test.tsx b/src/components/ContextMenu.test.tsx index 61fc6ef..ba88a60 100644 --- a/src/components/ContextMenu.test.tsx +++ b/src/components/ContextMenu.test.tsx @@ -60,4 +60,22 @@ describe("ContextMenu", () => { expect(menu.style.left).toBe("200px"); expect(menu.style.top).toBe("150px"); }); + + it("hides Pin button when multiple items are selected", () => { + render(); + expect(screen.queryByText("Pin")).not.toBeInTheDocument(); + expect(screen.queryByText("Unpin")).not.toBeInTheDocument(); + }); + + it("stops click propagation", () => { + const parentClick = vi.fn(); + const { container } = render( +
+ +
+ ); + const menu = container.querySelector(".fixed")!; + fireEvent.click(menu); + expect(parentClick).not.toHaveBeenCalled(); + }); }); diff --git a/src/index.css b/src/index.css index 62eea7e..a59c51e 100644 --- a/src/index.css +++ b/src/index.css @@ -28,6 +28,13 @@ body { -webkit-font-smoothing: antialiased; } +/* Hide the native traffic-light buttons on the overlay titlebar */ +.titlebar-spacer { + height: env(titlebar-area-height, 28px); + -webkit-app-region: drag; + app-region: drag; +} + ::-webkit-scrollbar { width: 5px; } diff --git a/src/test/setup.ts b/src/test/setup.ts index 03c37c0..77fd9b8 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,6 +1,5 @@ import "@testing-library/jest-dom/vitest"; -// Mock Tauri APIs — tests run outside the Tauri runtime vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(), })); @@ -13,6 +12,8 @@ vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: vi.fn(() => ({ hide: vi.fn(() => Promise.resolve()), show: vi.fn(() => Promise.resolve()), + innerSize: vi.fn(() => Promise.resolve({ width: 840, height: 1120 })), + scaleFactor: vi.fn(() => Promise.resolve(2)), })), }));