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 <cursoragent@cursor.com>
This commit is contained in:
ilia 2026-05-12 14:15:42 -04:00
parent 55ee81fc6d
commit 80a6c01cdb
17 changed files with 504 additions and 81 deletions

View File

@ -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`

View File

@ -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)

View File

@ -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<T, String>` — map errors with `.map_err(|e| e.to_string())`
- Register every new command in `lib.rs` → `invoke_handler`
## Database (db.rs)
- `Database` wraps `Mutex<Connection>` 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

29
.cursor/rules/testing.mdc Normal file
View File

@ -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
```

View File

@ -1,6 +1,6 @@
# maCopy # 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) ![macOS](https://img.shields.io/badge/macOS-10.15+-black?logo=apple)
![Tauri](https://img.shields.io/badge/Tauri-2-blue?logo=tauri) ![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 - **Global hotkey**`Cmd+Shift+V` opens the window from anywhere
- **Clipboard monitoring** — polls every 500ms for text, images, and file paths - **Clipboard monitoring** — polls every 500ms for text, images, and file paths
- **Full-text search** — instant filtering via SQLite FTS5 - **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 - **Pin entries** — pinned items stay at the top and are never auto-deleted
- **Context menu** — right-click for Copy, Pin/Unpin, Delete - **Context menu** — right-click for Paste, Pin/Unpin, Delete (with multi-select support)
- **Auto-trim** — keeps the last 500 entries by default (configurable: 100/500/1000) - **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 - **Dark/light mode** — follows macOS system appearance
- **Privacy** — whitespace-only entries are ignored; pause monitoring from the tray - **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) - **Rust** 1.77+ — [install via rustup](https://rustup.rs)
- **Node.js** 18+ and npm - **Node.js** 18+ and npm
- **Xcode Command Line Tools**`xcode-select --install` - **Xcode Command Line Tools**`xcode-select --install`
- **Accessibility permission** — required for auto-paste (System Settings → Privacy & Security → Accessibility → add maCopy)
## Quick Start ## Quick Start
```bash ```bash
git clone <your-repo-url> maCopy git clone gitea@10.0.30.169:ilia/maCopy.git
cd maCopy cd maCopy
npm install npm install
npm run tauri dev 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. 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 ## Development
### Project Structure ### Project Structure
@ -45,26 +65,28 @@ The app will compile the Rust backend, start the Vite dev server, and launch the
maCopy/ maCopy/
├── src/ # React + TypeScript frontend ├── src/ # React + TypeScript frontend
│ ├── main.tsx # Entry point │ ├── 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 │ ├── index.css # Tailwind v4 + custom theme tokens
│ ├── types.ts # Shared TypeScript types │ ├── types.ts # Shared TypeScript interfaces
│ ├── test/ # Test setup and factories │ ├── test/ # Test setup and factories
│ │ ├── setup.ts │ │ ├── setup.ts # Tauri API mocks for jsdom
│ │ └── factories.ts │ │ └── factories.ts # makeEntry(), makeSettings()
│ └── components/ │ └── components/
│ ├── SearchBar.tsx # Auto-focused search input │ ├── SearchBar.tsx
│ ├── ClipboardList.tsx # Entry list with time-ago, pin badges │ ├── ClipboardList.tsx # Entry list with multi-select, type badges
│ ├── ContextMenu.tsx # Right-click: Copy / Pin / Delete │ ├── ContextMenu.tsx # Right-click menu with multi-select labels
│ └── SettingsPanel.tsx # Toggles + max history + clear all │ └── SettingsPanel.tsx # Toggles, max history, window position
├── src-tauri/ # Rust backend ├── src-tauri/ # Rust backend
│ ├── Cargo.toml │ ├── Cargo.toml
│ ├── tauri.conf.json # Tauri config, permissions, window │ ├── tauri.conf.json # Tauri config, permissions, window
│ └── src/ │ └── src/
│ ├── main.rs # Binary entry point │ ├── main.rs # Binary entry point
│ ├── lib.rs # App setup: tray, hotkey, window behavior │ ├── lib.rs # App setup: tray, hotkey, window management
│ ├── clipboard.rs # Background polling thread (500ms) │ ├── clipboard.rs # Background polling thread (500ms, SHA-256 dedup)
│ ├── db.rs # SQLite CRUD + FTS5 + auto-trim │ ├── db.rs # SQLite CRUD + FTS5 + settings + auto-trim
│ └── commands.rs # Tauri IPC commands │ └── commands.rs # Tauri IPC commands
├── .cursor/rules/ # Cursor AI rules for this project
├── package.json ├── package.json
├── vite.config.ts ├── vite.config.ts
├── vitest.config.ts ├── vitest.config.ts
@ -75,12 +97,13 @@ maCopy/
| Command | Description | | 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 run tauri build` | Build a release `.app` bundle |
| `npm test` | Run frontend tests (vitest) | | `npm test` | Run frontend tests (Vitest, 47 tests) |
| `npm run test:watch` | Run frontend tests in watch mode | | `npm run test:rust` | Run Rust backend tests (27 tests) |
| `npm run test:rust` | Run Rust backend tests |
| `npm run test:all` | Run all tests (frontend + backend) | | `npm run test:all` | Run all tests (frontend + backend) |
| `npm run lint` | TypeScript type-check |
| `npm run check` | Lint + all tests |
### Tech Stack ### Tech Stack
@ -89,11 +112,12 @@ maCopy/
| Framework | [Tauri 2](https://v2.tauri.app) | | Framework | [Tauri 2](https://v2.tauri.app) |
| Frontend | React 18, TypeScript, Tailwind CSS v4 | | Frontend | React 18, TypeScript, Tailwind CSS v4 |
| Backend | Rust, rusqlite (bundled SQLite) | | 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 | | Search | SQLite FTS5 with content-sync triggers |
| Hotkey | tauri-plugin-global-shortcut | | Hotkey | tauri-plugin-global-shortcut |
| Autostart | tauri-plugin-autostart | | Autostart | tauri-plugin-autostart |
| Testing | vitest + @testing-library/react (frontend), `cargo test` (backend) | | Testing | Vitest + @testing-library/react (frontend), `cargo test` (backend) |
## Architecture ## 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. 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 ### 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 ### 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 ## Data Storage
@ -119,13 +147,13 @@ The SQLite database is stored at:
## Testing ## Testing
### Frontend (31 tests) ### Frontend (47 tests)
```bash ```bash
npm test 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) ### Backend (27 tests)
@ -138,7 +166,8 @@ Tests use in-memory SQLite databases and cover: CRUD operations, FTS5 search, au
### All tests ### All tests
```bash ```bash
npm run test:all npm run test:all # 74 total tests
npm run check # lint + all tests
``` ```
## Building for Release ## Building for Release

View File

@ -11,7 +11,9 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:rust": "cd src-tauri && cargo test", "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": { "dependencies": {
"@tauri-apps/api": "^2.11.0", "@tauri-apps/api": "^2.11.0",

16
src-tauri/Cargo.lock generated
View File

@ -418,6 +418,19 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 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]] [[package]]
name = "core-graphics" name = "core-graphics"
version = "0.25.0" version = "0.25.0"
@ -1948,6 +1961,7 @@ dependencies = [
"arboard", "arboard",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"core-graphics 0.24.0",
"dirs 5.0.1", "dirs 5.0.1",
"hex", "hex",
"log", "log",
@ -3276,7 +3290,7 @@ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2", "block2",
"core-foundation", "core-foundation",
"core-graphics", "core-graphics 0.25.0",
"crossbeam-channel", "crossbeam-channel",
"dbus", "dbus",
"dispatch2", "dispatch2",

View File

@ -28,5 +28,8 @@ png = "0.17"
dirs = "5" dirs = "5"
tokio = { version = "1", features = ["time", "macros"] } tokio = { version = "1", features = ["time", "macros"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-graphics = "0.24"
[features] [features]
custom-protocol = ["tauri/custom-protocol"] custom-protocol = ["tauri/custom-protocol"]

View File

@ -64,23 +64,26 @@ pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Resu
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// Hide the window then simulate Cmd+V so the content pastes into the /// Hide the window, wait for macOS to refocus the previous app,
/// previously-focused app. The 150ms delay gives macOS time to refocus. /// then simulate Cmd+V via AppleScript to paste the clipboard content.
/// Requires Accessibility permission (System Settings → Privacy → Accessibility).
#[tauri::command] #[tauri::command]
pub async fn paste_and_refocus(app: tauri::AppHandle) -> Result<(), String> { pub async fn paste_and_refocus(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
let _ = window.hide(); 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")] #[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("-e")
.arg(r#"tell application "System Events" to keystroke "v" using command down"#) .arg(r#"tell application "System Events" to keystroke "v" using command down"#)
.spawn(); .output()
.map_err(|e| format!("osascript failed: {}", e))?;
} }
Ok(()) Ok(())

View File

@ -7,23 +7,40 @@ use db::Database;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::Arc; use std::sync::Arc;
use tauri::{ use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem, CheckMenuItem}, menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem},
tray::TrayIconBuilder, tray::TrayIconBuilder,
Emitter, Manager, WindowEvent, Emitter, Manager, WindowEvent,
}; };
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; 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) { fn show_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
// Position window before showing based on settings
if let Some(db) = app.try_state::<DbState>() { if let Some(db) = app.try_state::<DbState>() {
if let Ok(settings) = db.0.get_settings() { if let Ok(settings) = db.0.get_settings() {
position_window(&window, &settings.window_position);
let _ = window.set_size(tauri::LogicalSize::new( let _ = window.set_size(tauri::LogicalSize::new(
settings.window_width as f64, settings.window_width as f64,
settings.window_height as f64, settings.window_height as f64,
)); ));
position_window(&window, &settings.window_position);
} }
} }
let _ = window.show(); let _ = window.show();
@ -42,54 +59,63 @@ fn toggle_window(app: &tauri::AppHandle) {
} }
fn position_window(window: &tauri::WebviewWindow, position: &str) { 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 { match position {
"center" => { "center" => {
let _ = window.center(); let _ = window.center();
} }
"top-right" => { "top-right" => {
if let Ok(monitor) = window.current_monitor() { if let Some((sw, _, _)) = get_monitor_info(window) {
if let Some(monitor) = monitor { let _ = window.set_position(tauri::LogicalPosition::new(sw - ww - 12.0, 30.0));
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));
}
} }
} }
"top-left" => { "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" => { "bottom-right" => {
if let Ok(monitor) = window.current_monitor() { if let Some((sw, sh, _)) = get_monitor_info(window) {
if let Some(monitor) = monitor { let _ = window.set_position(tauri::LogicalPosition::new(
let size = monitor.size(); sw - ww - 12.0,
let scale = monitor.scale_factor(); sh - wh - 12.0,
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));
}
} }
} }
"bottom-left" => { "bottom-left" => {
if let Ok(monitor) = window.current_monitor() { if let Some((_, sh, _)) = get_monitor_info(window) {
if let Some(monitor) = monitor { let _ = window.set_position(tauri::LogicalPosition::new(12.0, sh - wh - 12.0));
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));
}
} }
} }
// "cursor" or default — position near the mouse cursor // "cursor" — position near the mouse pointer
_ => { _ => {
if let Ok(cursor) = window.cursor_position() { if let Some((mx, my)) = get_mouse_position() {
let _ = window.set_position(tauri::LogicalPosition::new( if let Some((sw, sh, _)) = get_monitor_info(window) {
cursor.x - 210.0, // Keep window on-screen
cursor.y + 10.0, 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 { } else {
let _ = window.center(); let _ = window.center();
} }
@ -149,14 +175,13 @@ pub fn run() {
app.global_shortcut().register(shortcut)?; app.global_shortcut().register(shortcut)?;
let show_i = MenuItem::with_id(app, "show", "Show maCopy", true, None::<&str>)?; 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 sep = PredefinedMenuItem::separator(app)?;
let settings_i = MenuItem::with_id(app, "settings", "Settings…", true, None::<&str>)?; 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 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])?; 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") let _tray = TrayIconBuilder::with_id("macopy-tray")
.icon(app.default_window_icon().unwrap().clone()) .icon(app.default_window_icon().unwrap().clone())
.menu(&menu) .menu(&menu)
@ -167,8 +192,10 @@ pub fn run() {
move |app, event| match event.id.as_ref() { move |app, event| match event.id.as_ref() {
"show" => toggle_window(app), "show" => toggle_window(app),
"pause" => { "pause" => {
let current = paused_clone.load(std::sync::atomic::Ordering::Relaxed); let current =
paused_clone.store(!current, std::sync::atomic::Ordering::Relaxed); paused_clone.load(std::sync::atomic::Ordering::Relaxed);
paused_clone
.store(!current, std::sync::atomic::Ordering::Relaxed);
} }
"settings" => { "settings" => {
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
@ -177,9 +204,7 @@ pub fn run() {
let _ = window.emit("open-settings", ()); let _ = window.emit("open-settings", ());
} }
} }
"quit" => { "quit" => app.exit(0),
app.exit(0);
}
_ => {} _ => {}
} }
}) })

View File

@ -20,7 +20,9 @@
"resizable": true, "resizable": true,
"minWidth": 320, "minWidth": 320,
"minHeight": 300, "minHeight": 300,
"decorations": false, "decorations": true,
"titleBarStyle": "overlay",
"hiddenTitle": true,
"visible": false, "visible": false,
"alwaysOnTop": true, "alwaysOnTop": true,
"skipTaskbar": true, "skipTaskbar": true,
@ -49,6 +51,8 @@
"core:window:allow-current-monitor", "core:window:allow-current-monitor",
"core:window:allow-cursor-position", "core:window:allow-cursor-position",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-scale-factor",
"core:event:default", "core:event:default",
"core:event:allow-emit", "core:event:allow-emit",
"core:event:allow-listen", "core:event:allow-listen",

145
src/App.test.tsx Normal file
View File

@ -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<string, unknown>) => {
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(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
await waitFor(() => {
expect(screen.getByText("No clipboard history")).toBeInTheDocument();
});
});
it("shows settings panel when settings are opened", async () => {
mockTauriCommands();
render(<App />);
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(<App />);
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 });
});
});
});

View File

@ -235,6 +235,9 @@ export default function App() {
return ( return (
<div className="flex flex-col h-screen w-screen bg-surface overflow-hidden"> <div className="flex flex-col h-screen w-screen bg-surface overflow-hidden">
{/* Titlebar drag region — sits behind the native traffic lights */}
<div className="titlebar-spacer shrink-0" data-tauri-drag-region />
{showSettings && settings ? ( {showSettings && settings ? (
<SettingsPanel <SettingsPanel
settings={settings} settings={settings}
@ -246,7 +249,7 @@ export default function App() {
/> />
) : ( ) : (
<> <>
<div className="shrink-0 p-2 pb-0" data-tauri-drag-region> <div className="shrink-0 px-2 pb-0" data-tauri-drag-region>
<SearchBar <SearchBar
value={query} value={query}
onChange={(v) => { onChange={(v) => {

View File

@ -115,4 +115,52 @@ describe("ClipboardList", () => {
const row = container.querySelector(".divide-y > div"); const row = container.querySelector(".divide-y > div");
expect(row?.className).toContain("bg-accent/10"); 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(
<ClipboardList {...defaultProps} entries={entries} onCtrlClick={onCtrlClick} />
);
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(
<ClipboardList {...defaultProps} entries={entries} onShiftClick={onShiftClick} />
);
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(
<ClipboardList {...defaultProps} entries={entries} focusIndex={1} />
);
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(<ClipboardList {...defaultProps} entries={entries} />);
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(<ClipboardList {...defaultProps} entries={entries} showImages={false} />);
expect(screen.getByText("IMG")).toBeInTheDocument();
});
}); });

View File

@ -60,4 +60,22 @@ describe("ContextMenu", () => {
expect(menu.style.left).toBe("200px"); expect(menu.style.left).toBe("200px");
expect(menu.style.top).toBe("150px"); expect(menu.style.top).toBe("150px");
}); });
it("hides Pin button when multiple items are selected", () => {
render(<ContextMenu {...defaultProps} selectedCount={3} />);
expect(screen.queryByText("Pin")).not.toBeInTheDocument();
expect(screen.queryByText("Unpin")).not.toBeInTheDocument();
});
it("stops click propagation", () => {
const parentClick = vi.fn();
const { container } = render(
<div onClick={parentClick}>
<ContextMenu {...defaultProps} />
</div>
);
const menu = container.querySelector(".fixed")!;
fireEvent.click(menu);
expect(parentClick).not.toHaveBeenCalled();
});
}); });

View File

@ -28,6 +28,13 @@ body {
-webkit-font-smoothing: antialiased; -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 { ::-webkit-scrollbar {
width: 5px; width: 5px;
} }

View File

@ -1,6 +1,5 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
// Mock Tauri APIs — tests run outside the Tauri runtime
vi.mock("@tauri-apps/api/core", () => ({ vi.mock("@tauri-apps/api/core", () => ({
invoke: vi.fn(), invoke: vi.fn(),
})); }));
@ -13,6 +12,8 @@ vi.mock("@tauri-apps/api/window", () => ({
getCurrentWindow: vi.fn(() => ({ getCurrentWindow: vi.fn(() => ({
hide: vi.fn(() => Promise.resolve()), hide: vi.fn(() => Promise.resolve()),
show: vi.fn(() => Promise.resolve()), show: vi.fn(() => Promise.resolve()),
innerSize: vi.fn(() => Promise.resolve({ width: 840, height: 1120 })),
scaleFactor: vi.fn(() => Promise.resolve(2)),
})), })),
})); }));