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:
parent
55ee81fc6d
commit
80a6c01cdb
31
.cursor/rules/project-overview.mdc
Normal file
31
.cursor/rules/project-overview.mdc
Normal 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`
|
||||
33
.cursor/rules/react-frontend.mdc
Normal file
33
.cursor/rules/react-frontend.mdc
Normal 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)
|
||||
28
.cursor/rules/rust-backend.mdc
Normal file
28
.cursor/rules/rust-backend.mdc
Normal 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
29
.cursor/rules/testing.mdc
Normal 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
|
||||
```
|
||||
83
README.md
83
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.
|
||||
|
||||

|
||||

|
||||
@ -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
|
||||
|
||||
@ -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
16
src-tauri/Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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"]
|
||||
|
||||
@ -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(())
|
||||
|
||||
@ -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),
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
145
src/App.test.tsx
Normal 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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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) => {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user