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
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 <your-repo-url> 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

View File

@ -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",

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"
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",

View File

@ -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"]

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

View File

@ -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::<DbState>() {
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),
_ => {}
}
})

View File

@ -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",

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 (
<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 ? (
<SettingsPanel
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
value={query}
onChange={(v) => {

View File

@ -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(
<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.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;
}
/* 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;
}

View File

@ -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)),
})),
}));