Initial commit: macOS clipboard manager menu bar app
Tauri 2 + React 18 + TypeScript + Tailwind CSS v4 + SQLite (rusqlite) Features: - Menu bar app with tray icon (no dock icon) - Global hotkey Cmd+Shift+V - Clipboard polling every 500ms (text, images, file paths) - SQLite FTS5 full-text search - Pin/unpin entries, auto-trim, context menu - Settings panel (launch at login, show images, max history, clear all) - Dark/light mode following macOS system preference - Frameless floating window, closes on blur Testing: - 27 Rust unit tests (db, clipboard, FTS5, trim, settings) - 31 TypeScript component tests (vitest + @testing-library/react) Co-authored-by: Cursor <cursoragent@cursor.com>
34
.gitignore
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Tauri
|
||||
src-tauri/WixTools/
|
||||
src-tauri/icons/android/
|
||||
src-tauri/icons/ios/
|
||||
|
||||
# Generated icon source
|
||||
app-icon.png
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 maCopy
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
154
README.md
Normal file
@ -0,0 +1,154 @@
|
||||
# maCopy
|
||||
|
||||
A lightweight macOS clipboard manager that lives in your menu bar. Built with Tauri 2, React, TypeScript, and SQLite.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- **Menu bar app** — no dock icon, stays out of your way
|
||||
- **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
|
||||
- **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)
|
||||
- **Dark/light mode** — follows macOS system appearance
|
||||
- **Privacy** — whitespace-only entries are ignored; pause monitoring from the tray
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **macOS** 10.15+
|
||||
- **Rust** 1.77+ — [install via rustup](https://rustup.rs)
|
||||
- **Node.js** 18+ and npm
|
||||
- **Xcode Command Line Tools** — `xcode-select --install`
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone <your-repo-url> maCopy
|
||||
cd maCopy
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
The app will compile the Rust backend, start the Vite dev server, and launch the menu bar app.
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
maCopy/
|
||||
├── src/ # React + TypeScript frontend
|
||||
│ ├── main.tsx # Entry point
|
||||
│ ├── App.tsx # Main app: routing, keyboard nav, polling
|
||||
│ ├── index.css # Tailwind v4 + custom theme tokens
|
||||
│ ├── types.ts # Shared TypeScript types
|
||||
│ ├── test/ # Test setup and factories
|
||||
│ │ ├── setup.ts
|
||||
│ │ └── factories.ts
|
||||
│ └── 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
|
||||
├── 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
|
||||
│ └── commands.rs # Tauri IPC commands
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
├── vitest.config.ts
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
### Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `npm run tauri dev` | Run the app 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 run test:all` | Run all tests (frontend + backend) |
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| 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 |
|
||||
| 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) |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Clipboard Polling
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
## Data Storage
|
||||
|
||||
The SQLite database is stored at:
|
||||
|
||||
```
|
||||
~/Library/Application Support/maCopy/clipboard.db
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Frontend (31 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.
|
||||
|
||||
### Backend (27 tests)
|
||||
|
||||
```bash
|
||||
npm run test:rust
|
||||
```
|
||||
|
||||
Tests use in-memory SQLite databases and cover: CRUD operations, FTS5 search, auto-trim with pin preservation, settings persistence, SHA-256 hashing, and PNG encoding.
|
||||
|
||||
### All tests
|
||||
|
||||
```bash
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
## Building for Release
|
||||
|
||||
```bash
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
The built `.app` bundle will be in `src-tauri/target/release/bundle/macos/`.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
12
index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>maCopy</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3942
package-lock.json
generated
Normal file
39
package.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "macopy",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:rust": "cd src-tauri && cargo test",
|
||||
"test:all": "npm run test && npm run test:rust"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-autostart": "^2",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2",
|
||||
"@tauri-apps/plugin-global-shortcut": "^2",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^6",
|
||||
"vitest": "^4.1.6"
|
||||
}
|
||||
}
|
||||
5307
src-tauri/Cargo.lock
generated
Normal file
31
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,31 @@
|
||||
[package]
|
||||
name = "macopy"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "macopy_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
tauri-plugin-autostart = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
arboard = { version = "3", features = ["image-data"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
base64 = "0.22"
|
||||
log = "0.4"
|
||||
png = "0.17"
|
||||
dirs = "5"
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
3
src-tauri/build.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 712 B |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 830 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 713 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 872 B |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
3
src-tauri/rustfmt.toml
Normal file
@ -0,0 +1,3 @@
|
||||
max_width = 100
|
||||
tab_spaces = 4
|
||||
edition = "2021"
|
||||
150
src-tauri/src/clipboard.rs
Normal file
@ -0,0 +1,150 @@
|
||||
use crate::db::Database;
|
||||
use arboard::Clipboard;
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Hash arbitrary bytes for deduplication.
|
||||
fn hash_content(data: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
/// Spawns a background thread that polls the system clipboard every 500ms.
|
||||
/// When new content is detected (by comparing SHA-256 hashes), it is persisted
|
||||
/// to SQLite. The `paused` flag lets the user freeze monitoring from the tray.
|
||||
pub fn start_polling(db: Arc<Database>, paused: Arc<AtomicBool>) {
|
||||
std::thread::spawn(move || {
|
||||
// arboard::Clipboard must live on the thread that created it (macOS requirement)
|
||||
let mut clipboard = match Clipboard::new() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("Failed to open clipboard: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut last_hash = db.latest_hash().ok().flatten().unwrap_or_default();
|
||||
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
if paused.load(Ordering::Relaxed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try text first, then image
|
||||
if let Ok(text) = clipboard.get_text() {
|
||||
if !text.trim().is_empty() {
|
||||
let h = hash_content(text.as_bytes());
|
||||
if h != last_hash {
|
||||
last_hash = h.clone();
|
||||
|
||||
// Detect file paths: lines that start with / and exist on disk
|
||||
let content_type = if text.lines().all(|l| {
|
||||
let trimmed = l.trim();
|
||||
!trimmed.is_empty() && std::path::Path::new(trimmed).exists()
|
||||
}) {
|
||||
"file"
|
||||
} else {
|
||||
"text"
|
||||
};
|
||||
|
||||
if let Err(e) = db.insert_entry(&text, content_type, &h) {
|
||||
log::error!("DB insert error: {}", e);
|
||||
}
|
||||
trim_if_needed(&db);
|
||||
}
|
||||
}
|
||||
} else if let Ok(img) = clipboard.get_image() {
|
||||
// Encode RGBA pixels to PNG, then base64 for storage
|
||||
let h = hash_content(&img.bytes);
|
||||
if h != last_hash {
|
||||
last_hash = h.clone();
|
||||
|
||||
if let Ok(png_data) = encode_rgba_to_png(
|
||||
img.width as u32,
|
||||
img.height as u32,
|
||||
&img.bytes,
|
||||
) {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&png_data);
|
||||
let data_uri = format!("data:image/png;base64,{}", b64);
|
||||
if let Err(e) = db.insert_entry(&data_uri, "image", &h) {
|
||||
log::error!("DB insert error (image): {}", e);
|
||||
}
|
||||
trim_if_needed(&db);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> Result<Vec<u8>, png::EncodingError> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut buf, width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header()?;
|
||||
writer.write_image_data(rgba)?;
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn trim_if_needed(db: &Database) {
|
||||
if let Ok(settings) = db.get_settings() {
|
||||
let _ = db.trim_entries(settings.max_history);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hash_content_deterministic() {
|
||||
let a = hash_content(b"hello world");
|
||||
let b = hash_content(b"hello world");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_content_differs_for_different_input() {
|
||||
let a = hash_content(b"hello");
|
||||
let b = hash_content(b"world");
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_content_is_hex_sha256() {
|
||||
let h = hash_content(b"test");
|
||||
assert_eq!(h.len(), 64); // SHA-256 = 32 bytes = 64 hex chars
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rgba_to_png_produces_valid_png() {
|
||||
// 2x2 red pixels (RGBA)
|
||||
let rgba = vec![
|
||||
255, 0, 0, 255, 255, 0, 0, 255,
|
||||
255, 0, 0, 255, 255, 0, 0, 255,
|
||||
];
|
||||
let result = encode_rgba_to_png(2, 2, &rgba);
|
||||
assert!(result.is_ok());
|
||||
let png_data = result.unwrap();
|
||||
assert!(png_data.len() > 8);
|
||||
// PNG magic bytes
|
||||
assert_eq!(&png_data[..4], b"\x89PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_rgba_empty_image() {
|
||||
let result = encode_rgba_to_png(0, 0, &[]);
|
||||
// 0x0 image should either succeed or fail gracefully
|
||||
assert!(result.is_ok() || result.is_err());
|
||||
}
|
||||
}
|
||||
57
src-tauri/src/commands.rs
Normal file
@ -0,0 +1,57 @@
|
||||
use crate::db::{ClipboardEntry, Database, Settings};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
pub struct DbState(pub Arc<Database>);
|
||||
pub struct PausedState(pub Arc<AtomicBool>);
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
|
||||
db.0.get_entries(limit.unwrap_or(500))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_entries(db: State<'_, DbState>, query: String, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
|
||||
if query.trim().is_empty() {
|
||||
return get_entries(db, limit);
|
||||
}
|
||||
db.0.search_entries(&query, limit.unwrap_or(500))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_entry(db: State<'_, DbState>, id: i64) -> Result<(), String> {
|
||||
db.0.delete_entry(id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn toggle_pin(db: State<'_, DbState>, id: i64) -> Result<bool, String> {
|
||||
db.0.toggle_pin(id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_all(db: State<'_, DbState>) -> Result<(), String> {
|
||||
db.0.clear_all().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_settings(db: State<'_, DbState>) -> Result<Settings, String> {
|
||||
db.0.get_settings().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_setting(db: State<'_, DbState>, key: String, value: String) -> Result<(), String> {
|
||||
db.0.set_setting(&key, &value).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_paused(paused: State<'_, PausedState>) -> bool {
|
||||
paused.0.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_paused(paused: State<'_, PausedState>, value: bool) {
|
||||
paused.0.store(value, Ordering::Relaxed);
|
||||
}
|
||||
539
src-tauri/src/db.rs
Normal file
@ -0,0 +1,539 @@
|
||||
use rusqlite::{params, Connection, Result as SqlResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClipboardEntry {
|
||||
pub id: i64,
|
||||
pub content: String,
|
||||
pub content_type: String,
|
||||
pub created_at: String,
|
||||
pub pinned: bool,
|
||||
pub content_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub launch_at_login: bool,
|
||||
pub show_images: bool,
|
||||
pub max_history: i64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
launch_at_login: false,
|
||||
show_images: true,
|
||||
max_history: 500,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Database {
|
||||
pub conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub fn new() -> SqlResult<Self> {
|
||||
let db_path = Self::db_path();
|
||||
if let Some(parent) = db_path.parent() {
|
||||
std::fs::create_dir_all(parent).ok();
|
||||
}
|
||||
|
||||
let conn = Connection::open(&db_path)?;
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
db.init_tables()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// In-memory database for unit tests.
|
||||
#[cfg(test)]
|
||||
pub fn in_memory() -> SqlResult<Self> {
|
||||
let conn = Connection::open_in_memory()?;
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
};
|
||||
db.init_tables()?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
fn db_path() -> PathBuf {
|
||||
let base = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||
base.join("maCopy").join("clipboard.db")
|
||||
}
|
||||
|
||||
fn init_tables(&self) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS clipboard_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
content TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL DEFAULT 'text',
|
||||
created_at TEXT NOT NULL,
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
content_hash TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_created_at ON clipboard_entries(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard_entries(content_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_pinned ON clipboard_entries(pinned);
|
||||
|
||||
-- FTS5 virtual table for full-text search on text entries.
|
||||
-- Uses content-sync so FTS stays in lock-step with the main table.
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS clipboard_fts USING fts5(
|
||||
content,
|
||||
content=clipboard_entries,
|
||||
content_rowid=id
|
||||
);
|
||||
|
||||
-- Triggers keep the FTS index consistent with the main table.
|
||||
CREATE TRIGGER IF NOT EXISTS clipboard_ai AFTER INSERT ON clipboard_entries BEGIN
|
||||
INSERT INTO clipboard_fts(rowid, content) VALUES (new.id, new.content);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS clipboard_ad AFTER DELETE ON clipboard_entries BEGIN
|
||||
INSERT INTO clipboard_fts(clipboard_fts, rowid, content) VALUES('delete', old.id, old.content);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS clipboard_au AFTER UPDATE ON clipboard_entries BEGIN
|
||||
INSERT INTO clipboard_fts(clipboard_fts, rowid, content) VALUES('delete', old.id, old.content);
|
||||
INSERT INTO clipboard_fts(rowid, content) VALUES (new.id, new.content);
|
||||
END;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
|
||||
// Seed default settings if absent
|
||||
let defaults = Settings::default();
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["launch_at_login", defaults.launch_at_login.to_string()],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["show_images", defaults.show_images.to_string()],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["max_history", defaults.max_history.to_string()],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert_entry(
|
||||
&self,
|
||||
content: &str,
|
||||
content_type: &str,
|
||||
content_hash: &str,
|
||||
) -> SqlResult<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO clipboard_entries (content, content_type, created_at, pinned, content_hash)
|
||||
VALUES (?1, ?2, ?3, 0, ?4)",
|
||||
params![content, content_type, now, content_hash],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Check if the most recent entry already has this hash (dedup).
|
||||
pub fn latest_hash(&self) -> SqlResult<Option<String>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT content_hash FROM clipboard_entries ORDER BY id DESC LIMIT 1")?;
|
||||
let hash = stmt
|
||||
.query_row([], |row| row.get::<_, String>(0))
|
||||
.ok();
|
||||
Ok(hash)
|
||||
}
|
||||
|
||||
pub fn get_entries(&self, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// Pinned first, then by recency
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, content, content_type, created_at, pinned, content_hash
|
||||
FROM clipboard_entries
|
||||
ORDER BY pinned DESC, id DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
let entries = stmt
|
||||
.query_map(params![limit], |row| {
|
||||
Ok(ClipboardEntry {
|
||||
id: row.get(0)?,
|
||||
content: row.get(1)?,
|
||||
content_type: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
pinned: row.get::<_, i32>(4)? != 0,
|
||||
content_hash: row.get(5)?,
|
||||
})
|
||||
})?
|
||||
.collect::<SqlResult<Vec<_>>>()?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn search_entries(&self, query: &str, limit: i64) -> SqlResult<Vec<ClipboardEntry>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
// FTS5 match query — prefix search with *
|
||||
let fts_query = format!("{}*", query.replace('"', "\"\""));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT e.id, e.content, e.content_type, e.created_at, e.pinned, e.content_hash
|
||||
FROM clipboard_entries e
|
||||
JOIN clipboard_fts f ON e.id = f.rowid
|
||||
WHERE clipboard_fts MATCH ?1
|
||||
ORDER BY e.pinned DESC, e.id DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let entries = stmt
|
||||
.query_map(params![fts_query, limit], |row| {
|
||||
Ok(ClipboardEntry {
|
||||
id: row.get(0)?,
|
||||
content: row.get(1)?,
|
||||
content_type: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
pinned: row.get::<_, i32>(4)? != 0,
|
||||
content_hash: row.get(5)?,
|
||||
})
|
||||
})?
|
||||
.collect::<SqlResult<Vec<_>>>()?;
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub fn delete_entry(&self, id: i64) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM clipboard_entries WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn toggle_pin(&self, id: i64) -> SqlResult<bool> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"UPDATE clipboard_entries SET pinned = CASE WHEN pinned = 0 THEN 1 ELSE 0 END WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
let pinned: bool = conn.query_row(
|
||||
"SELECT pinned FROM clipboard_entries WHERE id = ?1",
|
||||
params![id],
|
||||
|row| Ok(row.get::<_, i32>(0)? != 0),
|
||||
)?;
|
||||
Ok(pinned)
|
||||
}
|
||||
|
||||
/// Remove oldest non-pinned entries exceeding the max limit.
|
||||
pub fn trim_entries(&self, max: i64) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"DELETE FROM clipboard_entries WHERE pinned = 0 AND id NOT IN (
|
||||
SELECT id FROM clipboard_entries WHERE pinned = 0 ORDER BY id DESC LIMIT ?1
|
||||
)",
|
||||
params![max],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_all(&self) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM clipboard_entries", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_settings(&self) -> SqlResult<Settings> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let get = |key: &str, default: &str| -> String {
|
||||
conn.query_row(
|
||||
"SELECT value FROM settings WHERE key = ?1",
|
||||
params![key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| default.to_string())
|
||||
};
|
||||
Ok(Settings {
|
||||
launch_at_login: get("launch_at_login", "false") == "true",
|
||||
show_images: get("show_images", "true") == "true",
|
||||
max_history: get("max_history", "500").parse().unwrap_or(500),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_setting(&self, key: &str, value: &str) -> SqlResult<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_db() -> Database {
|
||||
Database::in_memory().expect("in-memory DB should initialize")
|
||||
}
|
||||
|
||||
// ── Schema & Init ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn init_creates_tables_and_default_settings() {
|
||||
let db = test_db();
|
||||
let s = db.get_settings().unwrap();
|
||||
assert!(!s.launch_at_login);
|
||||
assert!(s.show_images);
|
||||
assert_eq!(s.max_history, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_init_is_idempotent() {
|
||||
let db = test_db();
|
||||
db.init_tables().unwrap();
|
||||
assert_eq!(db.get_settings().unwrap().max_history, 500);
|
||||
}
|
||||
|
||||
// ── Insert & Get ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn insert_and_get_entry() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("hello world", "text", "hash1").unwrap();
|
||||
assert!(id > 0);
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].content, "hello world");
|
||||
assert_eq!(entries[0].content_type, "text");
|
||||
assert!(!entries[0].pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_ordered_newest_first() {
|
||||
let db = test_db();
|
||||
db.insert_entry("first", "text", "h1").unwrap();
|
||||
db.insert_entry("second", "text", "h2").unwrap();
|
||||
db.insert_entry("third", "text", "h3").unwrap();
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries[0].content, "third");
|
||||
assert_eq!(entries[1].content, "second");
|
||||
assert_eq!(entries[2].content, "first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_entries_respects_limit() {
|
||||
let db = test_db();
|
||||
for i in 0..10 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
}
|
||||
assert_eq!(db.get_entries(3).unwrap().len(), 3);
|
||||
}
|
||||
|
||||
// ── Latest Hash (dedup) ─────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn latest_hash_empty_db() {
|
||||
let db = test_db();
|
||||
assert_eq!(db.latest_hash().unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_hash_returns_most_recent() {
|
||||
let db = test_db();
|
||||
db.insert_entry("a", "text", "hash_a").unwrap();
|
||||
db.insert_entry("b", "text", "hash_b").unwrap();
|
||||
assert_eq!(db.latest_hash().unwrap(), Some("hash_b".to_string()));
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn delete_entry_removes_it() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("to delete", "text", "hd").unwrap();
|
||||
assert_eq!(db.get_entries(100).unwrap().len(), 1);
|
||||
|
||||
db.delete_entry(id).unwrap();
|
||||
assert_eq!(db.get_entries(100).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_nonexistent_is_noop() {
|
||||
let db = test_db();
|
||||
db.delete_entry(9999).unwrap();
|
||||
}
|
||||
|
||||
// ── Pin / Unpin ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn toggle_pin_flips_state() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("pin me", "text", "hp").unwrap();
|
||||
|
||||
let pinned = db.toggle_pin(id).unwrap();
|
||||
assert!(pinned);
|
||||
|
||||
let pinned = db.toggle_pin(id).unwrap();
|
||||
assert!(!pinned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_entries_appear_first() {
|
||||
let db = test_db();
|
||||
let id1 = db.insert_entry("old", "text", "h1").unwrap();
|
||||
db.insert_entry("new", "text", "h2").unwrap();
|
||||
|
||||
db.toggle_pin(id1).unwrap();
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries[0].content, "old"); // pinned, even though older
|
||||
assert!(entries[0].pinned);
|
||||
assert_eq!(entries[1].content, "new");
|
||||
}
|
||||
|
||||
// ── FTS5 Search ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn search_finds_matching_text() {
|
||||
let db = test_db();
|
||||
db.insert_entry("the quick brown fox", "text", "h1").unwrap();
|
||||
db.insert_entry("lazy dog sleeps", "text", "h2").unwrap();
|
||||
db.insert_entry("quick silver", "text", "h3").unwrap();
|
||||
|
||||
let results = db.search_entries("quick", 100).unwrap();
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results.iter().all(|e| e.content.contains("quick")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_prefix_matching() {
|
||||
let db = test_db();
|
||||
db.insert_entry("programming in rust", "text", "h1").unwrap();
|
||||
db.insert_entry("rustic cabin", "text", "h2").unwrap();
|
||||
|
||||
let results = db.search_entries("rust", 100).unwrap();
|
||||
assert_eq!(results.len(), 2); // "rust" prefix matches "rust" and "rustic"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_no_match_returns_empty() {
|
||||
let db = test_db();
|
||||
db.insert_entry("hello world", "text", "h1").unwrap();
|
||||
|
||||
let results = db.search_entries("zzzzz", 100).unwrap();
|
||||
assert_eq!(results.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_fts_stays_consistent_after_delete() {
|
||||
let db = test_db();
|
||||
let id = db.insert_entry("unique searchable term", "text", "h1").unwrap();
|
||||
assert_eq!(db.search_entries("searchable", 100).unwrap().len(), 1);
|
||||
|
||||
db.delete_entry(id).unwrap();
|
||||
assert_eq!(db.search_entries("searchable", 100).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ── Trim ────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn trim_keeps_max_entries() {
|
||||
let db = test_db();
|
||||
for i in 0..10 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
}
|
||||
|
||||
db.trim_entries(5).unwrap();
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries.len(), 5);
|
||||
// Should keep the newest 5
|
||||
assert_eq!(entries[0].content, "item 9");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_preserves_pinned_entries() {
|
||||
let db = test_db();
|
||||
let pinned_id = db.insert_entry("keep me", "text", "h0").unwrap();
|
||||
db.toggle_pin(pinned_id).unwrap();
|
||||
|
||||
for i in 1..=10 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
}
|
||||
|
||||
db.trim_entries(3).unwrap();
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
// 3 non-pinned + 1 pinned = 4
|
||||
assert_eq!(entries.len(), 4);
|
||||
assert!(entries.iter().any(|e| e.content == "keep me" && e.pinned));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_noop_when_under_limit() {
|
||||
let db = test_db();
|
||||
db.insert_entry("a", "text", "h1").unwrap();
|
||||
db.insert_entry("b", "text", "h2").unwrap();
|
||||
|
||||
db.trim_entries(500).unwrap();
|
||||
assert_eq!(db.get_entries(100).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
// ── Clear All ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn clear_all_removes_everything() {
|
||||
let db = test_db();
|
||||
for i in 0..5 {
|
||||
db.insert_entry(&format!("item {}", i), "text", &format!("h{}", i)).unwrap();
|
||||
}
|
||||
db.clear_all().unwrap();
|
||||
assert_eq!(db.get_entries(100).unwrap().len(), 0);
|
||||
}
|
||||
|
||||
// ── Settings ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn set_and_get_settings() {
|
||||
let db = test_db();
|
||||
|
||||
db.set_setting("max_history", "1000").unwrap();
|
||||
let s = db.get_settings().unwrap();
|
||||
assert_eq!(s.max_history, 1000);
|
||||
|
||||
db.set_setting("show_images", "false").unwrap();
|
||||
let s = db.get_settings().unwrap();
|
||||
assert!(!s.show_images);
|
||||
|
||||
db.set_setting("launch_at_login", "true").unwrap();
|
||||
let s = db.get_settings().unwrap();
|
||||
assert!(s.launch_at_login);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_setting_overwrites() {
|
||||
let db = test_db();
|
||||
db.set_setting("max_history", "100").unwrap();
|
||||
db.set_setting("max_history", "200").unwrap();
|
||||
assert_eq!(db.get_settings().unwrap().max_history, 200);
|
||||
}
|
||||
|
||||
// ── Content types ───────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn insert_different_content_types() {
|
||||
let db = test_db();
|
||||
db.insert_entry("plain text", "text", "h1").unwrap();
|
||||
db.insert_entry("data:image/png;base64,abc", "image", "h2").unwrap();
|
||||
db.insert_entry("/usr/local/bin", "file", "h3").unwrap();
|
||||
|
||||
let entries = db.get_entries(100).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
|
||||
let types: Vec<&str> = entries.iter().map(|e| e.content_type.as_str()).collect();
|
||||
assert!(types.contains(&"text"));
|
||||
assert!(types.contains(&"image"));
|
||||
assert!(types.contains(&"file"));
|
||||
}
|
||||
}
|
||||
142
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,142 @@
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod db;
|
||||
|
||||
use commands::{DbState, PausedState};
|
||||
use db::Database;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem, CheckMenuItem},
|
||||
tray::TrayIconBuilder,
|
||||
Emitter, Manager, WindowEvent,
|
||||
};
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
||||
|
||||
fn toggle_window(app: &tauri::AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let db = Arc::new(Database::new().expect("Failed to initialize database"));
|
||||
let paused = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let db_for_polling = db.clone();
|
||||
let paused_for_polling = paused.clone();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_autostart::init(
|
||||
MacosLauncher::LaunchAgent,
|
||||
Some(vec![]),
|
||||
))
|
||||
.plugin(
|
||||
tauri_plugin_global_shortcut::Builder::new()
|
||||
.with_handler(|app, shortcut, event| {
|
||||
if event.state == ShortcutState::Pressed {
|
||||
let expected = Shortcut::new(
|
||||
Some(Modifiers::SUPER | Modifiers::SHIFT),
|
||||
Code::KeyV,
|
||||
);
|
||||
if shortcut == &expected {
|
||||
toggle_window(app);
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.manage(DbState(db.clone()))
|
||||
.manage(PausedState(paused.clone()))
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::get_entries,
|
||||
commands::search_entries,
|
||||
commands::delete_entry,
|
||||
commands::toggle_pin,
|
||||
commands::clear_all,
|
||||
commands::get_settings,
|
||||
commands::set_setting,
|
||||
commands::get_paused,
|
||||
commands::set_paused,
|
||||
])
|
||||
.setup(move |app| {
|
||||
// Register the global hotkey
|
||||
let shortcut = Shortcut::new(
|
||||
Some(Modifiers::SUPER | Modifiers::SHIFT),
|
||||
Code::KeyV,
|
||||
);
|
||||
app.global_shortcut().register(shortcut)?;
|
||||
|
||||
// Build the tray menu
|
||||
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 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])?;
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.tooltip("maCopy — Clipboard Manager")
|
||||
.on_menu_event({
|
||||
let paused_clone = paused_for_polling.clone();
|
||||
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);
|
||||
}
|
||||
"settings" => {
|
||||
// Emit an event the frontend listens for to open settings panel
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.emit("open-settings", ());
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let tauri::tray::TrayIconEvent::Click { .. } = event {
|
||||
toggle_window(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Hide from dock on macOS — app is menu-bar only
|
||||
#[cfg(target_os = "macos")]
|
||||
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||||
|
||||
// Close window on blur for popup-like behavior
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let w = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
if let WindowEvent::Focused(false) = event {
|
||||
let _ = w.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start clipboard polling on a background thread
|
||||
clipboard::start_polling(db_for_polling, paused_for_polling);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
5
src-tauri/src/main.rs
Normal file
@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
macopy_lib::run()
|
||||
}
|
||||
74
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,74 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-config-schema/schema.json",
|
||||
"productName": "maCopy",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.macopy.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "maCopy",
|
||||
"width": 420,
|
||||
"height": 560,
|
||||
"resizable": false,
|
||||
"decorations": false,
|
||||
"visible": false,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"capabilities": [
|
||||
{
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for maCopy",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-visible",
|
||||
"core:event:default",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-listen",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"clipboard-manager:allow-read-image",
|
||||
"clipboard-manager:allow-write-image",
|
||||
"clipboard-manager:allow-clear",
|
||||
"global-shortcut:allow-register",
|
||||
"global-shortcut:allow-unregister",
|
||||
"autostart:allow-enable",
|
||||
"autostart:allow-disable",
|
||||
"autostart:allow-is-enabled"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
}
|
||||
}
|
||||
}
|
||||
202
src/App.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import SearchBar from "./components/SearchBar";
|
||||
import ClipboardList from "./components/ClipboardList";
|
||||
import SettingsPanel from "./components/SettingsPanel";
|
||||
import ContextMenu from "./components/ContextMenu";
|
||||
import type { ClipboardEntry, Settings } from "./types";
|
||||
|
||||
export default function App() {
|
||||
const [entries, setEntries] = useState<ClipboardEntry[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
entry: ClipboardEntry;
|
||||
} | null>(null);
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval>>();
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
try {
|
||||
const data: ClipboardEntry[] = query.trim()
|
||||
? await invoke("search_entries", { query, limit: settings?.max_history ?? 500 })
|
||||
: await invoke("get_entries", { limit: settings?.max_history ?? 500 });
|
||||
setEntries(data);
|
||||
} catch (e) {
|
||||
console.error("Failed to load entries:", e);
|
||||
}
|
||||
}, [query, settings?.max_history]);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
try {
|
||||
const s: Settings = await invoke("get_settings");
|
||||
setSettings(s);
|
||||
} catch (e) {
|
||||
console.error("Failed to load settings:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
}, [loadSettings]);
|
||||
|
||||
// Poll for new entries while window is visible
|
||||
useEffect(() => {
|
||||
loadEntries();
|
||||
pollRef.current = setInterval(loadEntries, 1000);
|
||||
return () => clearInterval(pollRef.current);
|
||||
}, [loadEntries]);
|
||||
|
||||
// Listen for the tray "Settings…" menu click
|
||||
useEffect(() => {
|
||||
const unlisten = listen("open-settings", () => setShowSettings(true));
|
||||
return () => {
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const copyAndClose = useCallback(async (entry: ClipboardEntry) => {
|
||||
try {
|
||||
if (entry.content_type === "image") {
|
||||
// For images stored as data URIs, copy the raw base64 URI text
|
||||
await writeText(entry.content);
|
||||
} else {
|
||||
await writeText(entry.content);
|
||||
}
|
||||
await getCurrentWindow().hide();
|
||||
} catch (e) {
|
||||
console.error("Copy failed:", e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id: number) => {
|
||||
await invoke("delete_entry", { id });
|
||||
loadEntries();
|
||||
},
|
||||
[loadEntries]
|
||||
);
|
||||
|
||||
const handleTogglePin = useCallback(
|
||||
async (id: number) => {
|
||||
await invoke("toggle_pin", { id });
|
||||
loadEntries();
|
||||
},
|
||||
[loadEntries]
|
||||
);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd+1 through Cmd+9 for quick paste
|
||||
if (e.metaKey && e.key >= "1" && e.key <= "9") {
|
||||
e.preventDefault();
|
||||
const idx = parseInt(e.key) - 1;
|
||||
if (idx < entries.length) {
|
||||
copyAndClose(entries[idx]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.min(i + 1, entries.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setSelectedIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (entries[selectedIndex]) copyAndClose(entries[selectedIndex]);
|
||||
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||
// Only handle Delete/Backspace when search is not focused
|
||||
if (document.activeElement?.tagName !== "INPUT") {
|
||||
e.preventDefault();
|
||||
if (entries[selectedIndex]) handleDelete(entries[selectedIndex].id);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
if (showSettings) {
|
||||
setShowSettings(false);
|
||||
} else {
|
||||
getCurrentWindow().hide();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [entries, selectedIndex, copyAndClose, handleDelete, showSettings]);
|
||||
|
||||
// Close context menu on any click
|
||||
useEffect(() => {
|
||||
const close = () => setContextMenu(null);
|
||||
window.addEventListener("click", close);
|
||||
return () => window.removeEventListener("click", close);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen w-screen bg-surface rounded-xl border border-border overflow-hidden shadow-2xl">
|
||||
{showSettings && settings ? (
|
||||
<SettingsPanel
|
||||
settings={settings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onUpdate={(s) => {
|
||||
setSettings(s);
|
||||
loadSettings();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Drag handle + search */}
|
||||
<div className="shrink-0 pt-2 px-3" data-tauri-drag-region>
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={(v) => {
|
||||
setQuery(v);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Entry list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<ClipboardList
|
||||
entries={entries}
|
||||
selectedIndex={selectedIndex}
|
||||
showImages={settings?.show_images ?? true}
|
||||
onSelect={copyAndClose}
|
||||
onContextMenu={(e, entry) => {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, entry });
|
||||
}}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hint bar */}
|
||||
<div className="shrink-0 px-3 py-1.5 border-t border-border text-[11px] text-text-secondary flex justify-between">
|
||||
<span>⌘1-9 quick paste</span>
|
||||
<span>{entries.length} items</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{contextMenu && (
|
||||
<ContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
entry={contextMenu.entry}
|
||||
onCopy={() => copyAndClose(contextMenu.entry)}
|
||||
onPin={() => handleTogglePin(contextMenu.entry.id)}
|
||||
onDelete={() => handleDelete(contextMenu.entry.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/ClipboardList.test.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import ClipboardList from "./ClipboardList";
|
||||
import { makeEntry, resetIdCounter } from "../test/factories";
|
||||
|
||||
describe("ClipboardList", () => {
|
||||
beforeEach(() => resetIdCounter());
|
||||
|
||||
const defaultProps = {
|
||||
selectedIndex: 0,
|
||||
showImages: true,
|
||||
onSelect: vi.fn(),
|
||||
onContextMenu: vi.fn(),
|
||||
setSelectedIndex: vi.fn(),
|
||||
};
|
||||
|
||||
it("shows empty state when no entries", () => {
|
||||
render(<ClipboardList {...defaultProps} entries={[]} />);
|
||||
expect(screen.getByText("No clipboard history yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders text entries", () => {
|
||||
const entries = [makeEntry({ content: "Hello World" })];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} />);
|
||||
expect(screen.getByText("Hello World")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("truncates long content", () => {
|
||||
const long = "x".repeat(300);
|
||||
const entries = [makeEntry({ content: long })];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} />);
|
||||
expect(screen.getByText(/^x+…$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows pin indicator for pinned entries", () => {
|
||||
const entries = [makeEntry({ pinned: true, content: "Pinned item" })];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} />);
|
||||
expect(screen.getByText("Pinned item")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSelect when entry is clicked", () => {
|
||||
const onSelect = vi.fn();
|
||||
const entries = [makeEntry({ content: "Click me" })];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} onSelect={onSelect} />);
|
||||
|
||||
fireEvent.click(screen.getByText("Click me"));
|
||||
expect(onSelect).toHaveBeenCalledWith(entries[0]);
|
||||
});
|
||||
|
||||
it("calls onContextMenu on right-click", () => {
|
||||
const onContextMenu = vi.fn();
|
||||
const entries = [makeEntry({ content: "Right click me" })];
|
||||
render(
|
||||
<ClipboardList
|
||||
{...defaultProps}
|
||||
entries={entries}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByText("Right click me"));
|
||||
expect(onContextMenu).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("updates selectedIndex on mouse enter", () => {
|
||||
const setSelectedIndex = vi.fn();
|
||||
const entries = [
|
||||
makeEntry({ content: "First" }),
|
||||
makeEntry({ content: "Second" }),
|
||||
];
|
||||
render(
|
||||
<ClipboardList
|
||||
{...defaultProps}
|
||||
entries={entries}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.mouseEnter(screen.getByText("Second"));
|
||||
expect(setSelectedIndex).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("shows cmd+N badges for first 9 entries", () => {
|
||||
const entries = Array.from({ length: 3 }, (_, i) =>
|
||||
makeEntry({ content: `Item ${i + 1}` })
|
||||
);
|
||||
render(<ClipboardList {...defaultProps} entries={entries} />);
|
||||
|
||||
expect(screen.getByText("⌘1")).toBeInTheDocument();
|
||||
expect(screen.getByText("⌘2")).toBeInTheDocument();
|
||||
expect(screen.getByText("⌘3")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders image entries when showImages is true", () => {
|
||||
const entries = [
|
||||
makeEntry({
|
||||
content: "data:image/png;base64,abc",
|
||||
content_type: "image",
|
||||
}),
|
||||
];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} showImages={true} />);
|
||||
const img = screen.getByAltText("Clipboard image");
|
||||
expect(img).toBeInTheDocument();
|
||||
expect(img).toHaveAttribute("src", "data:image/png;base64,abc");
|
||||
});
|
||||
|
||||
it("renders image entries as text when showImages is false", () => {
|
||||
const entries = [
|
||||
makeEntry({
|
||||
content: "data:image/png;base64,abc",
|
||||
content_type: "image",
|
||||
}),
|
||||
];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} showImages={false} />);
|
||||
expect(screen.queryByAltText("Clipboard image")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("data:image/png;base64,abc")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows relative time for entries", () => {
|
||||
const entries = [
|
||||
makeEntry({ created_at: new Date().toISOString(), content: "Recent" }),
|
||||
];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} />);
|
||||
expect(screen.getByText("just now")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
138
src/components/ClipboardList.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { ClipboardEntry } from "../types";
|
||||
|
||||
interface Props {
|
||||
entries: ClipboardEntry[];
|
||||
selectedIndex: number;
|
||||
showImages: boolean;
|
||||
onSelect: (entry: ClipboardEntry) => void;
|
||||
onContextMenu: (e: React.MouseEvent, entry: ClipboardEntry) => void;
|
||||
setSelectedIndex: (i: number) => void;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const seconds = Math.floor(
|
||||
(Date.now() - new Date(dateStr).getTime()) / 1000
|
||||
);
|
||||
if (seconds < 60) return "just now";
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
}
|
||||
|
||||
function EntryIcon({ type }: { type: string }) {
|
||||
if (type === "image") {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-text-secondary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
if (type === "file") {
|
||||
return (
|
||||
<svg className="w-4 h-4 text-text-secondary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<svg className="w-4 h-4 text-text-secondary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function EntryPreview({
|
||||
entry,
|
||||
showImages,
|
||||
}: {
|
||||
entry: ClipboardEntry;
|
||||
showImages: boolean;
|
||||
}) {
|
||||
if (entry.content_type === "image" && showImages) {
|
||||
return (
|
||||
<img
|
||||
src={entry.content}
|
||||
alt="Clipboard image"
|
||||
className="max-h-16 max-w-full rounded object-contain mt-1"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const display =
|
||||
entry.content.length > 200
|
||||
? entry.content.slice(0, 200) + "…"
|
||||
: entry.content;
|
||||
|
||||
return (
|
||||
<span className="text-sm text-text-primary line-clamp-2 break-all">
|
||||
{display}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClipboardList({
|
||||
entries,
|
||||
selectedIndex,
|
||||
showImages,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
setSelectedIndex,
|
||||
}: Props) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Scroll selected entry into view
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
|
||||
el?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-text-secondary text-sm">
|
||||
No clipboard history yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={listRef} className="py-1">
|
||||
{entries.map((entry, i) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`group flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors
|
||||
${i === selectedIndex ? "bg-surface-active" : "hover:bg-surface-hover"}`}
|
||||
onClick={() => onSelect(entry)}
|
||||
onContextMenu={(e) => onContextMenu(e, entry)}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
>
|
||||
{/* Number badge for first 9 items */}
|
||||
<div className="w-5 text-center shrink-0 mt-0.5">
|
||||
{i < 9 ? (
|
||||
<span className="text-[10px] font-medium text-text-secondary bg-surface-hover rounded px-1 py-0.5">
|
||||
⌘{i + 1}
|
||||
</span>
|
||||
) : (
|
||||
<EntryIcon type={entry.content_type} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<EntryPreview entry={entry} showImages={showImages} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-1.5 mt-0.5">
|
||||
{entry.pinned && (
|
||||
<svg className="w-3.5 h-3.5 text-pin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.828.722a.5.5 0 01.354.146l4.95 4.95a.5.5 0 01-.707.707l-.71-.71-2.828 2.828.707 5.657a.5.5 0 01-.854.39L7.5 11.45l-3.24 3.24a.5.5 0 01-.707-.707l3.24-3.24L3.56 7.5a.5.5 0 01.39-.854l5.657.707 2.828-2.828-.71-.71a.5.5 0 01.103-.611z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className="text-[10px] text-text-secondary whitespace-nowrap">
|
||||
{timeAgo(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/components/ContextMenu.test.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import ContextMenu from "./ContextMenu";
|
||||
import { makeEntry } from "../test/factories";
|
||||
|
||||
describe("ContextMenu", () => {
|
||||
const defaultProps = {
|
||||
x: 100,
|
||||
y: 100,
|
||||
entry: makeEntry(),
|
||||
onCopy: vi.fn(),
|
||||
onPin: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
};
|
||||
|
||||
it("renders Copy, Pin, and Delete actions", () => {
|
||||
render(<ContextMenu {...defaultProps} />);
|
||||
expect(screen.getByText("Copy")).toBeInTheDocument();
|
||||
expect(screen.getByText("Pin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Unpin for pinned entries", () => {
|
||||
const entry = makeEntry({ pinned: true });
|
||||
render(<ContextMenu {...defaultProps} entry={entry} />);
|
||||
expect(screen.getByText("Unpin")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onCopy when Copy is clicked", () => {
|
||||
const onCopy = vi.fn();
|
||||
render(<ContextMenu {...defaultProps} onCopy={onCopy} />);
|
||||
fireEvent.click(screen.getByText("Copy"));
|
||||
expect(onCopy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onPin when Pin is clicked", () => {
|
||||
const onPin = vi.fn();
|
||||
render(<ContextMenu {...defaultProps} onPin={onPin} />);
|
||||
fireEvent.click(screen.getByText("Pin"));
|
||||
expect(onPin).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onDelete when Delete is clicked", () => {
|
||||
const onDelete = vi.fn();
|
||||
render(<ContextMenu {...defaultProps} onDelete={onDelete} />);
|
||||
fireEvent.click(screen.getByText("Delete"));
|
||||
expect(onDelete).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("is positioned at the given coordinates", () => {
|
||||
const { container } = render(<ContextMenu {...defaultProps} x={200} y={150} />);
|
||||
const menu = container.firstChild as HTMLElement;
|
||||
expect(menu.style.left).toBe("200px");
|
||||
expect(menu.style.top).toBe("150px");
|
||||
});
|
||||
});
|
||||
44
src/components/ContextMenu.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import type { ClipboardEntry } from "../types";
|
||||
|
||||
interface Props {
|
||||
x: number;
|
||||
y: number;
|
||||
entry: ClipboardEntry;
|
||||
onCopy: () => void;
|
||||
onPin: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export default function ContextMenu({ x, y, entry, onCopy, onPin, onDelete }: Props) {
|
||||
// Prevent the menu from going off-screen
|
||||
const adjustedX = Math.min(x, window.innerWidth - 160);
|
||||
const adjustedY = Math.min(y, window.innerHeight - 120);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl py-1 min-w-[150px]"
|
||||
style={{ left: adjustedX, top: adjustedY }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover transition-colors"
|
||||
onClick={onCopy}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover transition-colors"
|
||||
onClick={onPin}
|
||||
>
|
||||
{entry.pinned ? "Unpin" : "Pin"}
|
||||
</button>
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-danger hover:bg-surface-hover transition-colors"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/SearchBar.test.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import SearchBar from "./SearchBar";
|
||||
|
||||
describe("SearchBar", () => {
|
||||
it("renders with placeholder text", () => {
|
||||
render(<SearchBar value="" onChange={() => {}} />);
|
||||
expect(
|
||||
screen.getByPlaceholderText("Search clipboard history…")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the current value", () => {
|
||||
render(<SearchBar value="hello" onChange={() => {}} />);
|
||||
expect(screen.getByDisplayValue("hello")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onChange when user types", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SearchBar value="" onChange={onChange} />);
|
||||
const input = screen.getByPlaceholderText("Search clipboard history…");
|
||||
|
||||
await user.type(input, "test");
|
||||
expect(onChange).toHaveBeenCalledTimes(4); // one per character
|
||||
expect(onChange).toHaveBeenLastCalledWith("t");
|
||||
});
|
||||
|
||||
it("auto-focuses the input on mount", () => {
|
||||
render(<SearchBar value="" onChange={() => {}} />);
|
||||
const input = screen.getByPlaceholderText("Search clipboard history…");
|
||||
// The focus happens via setTimeout, so we check the element exists
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
48
src/components/SearchBar.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function SearchBar({ value, onChange }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Auto-focus search when window appears
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
handleFocus();
|
||||
return () => window.removeEventListener("focus", handleFocus);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-secondary pointer-events-none"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Search clipboard history…"
|
||||
className="w-full pl-9 pr-3 py-2 bg-surface-hover border border-border rounded-lg
|
||||
text-sm text-text-primary placeholder:text-text-secondary
|
||||
focus:outline-none focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
src/components/SettingsPanel.test.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import SettingsPanel from "./SettingsPanel";
|
||||
import { makeSettings } from "../test/factories";
|
||||
|
||||
const mockInvoke = vi.mocked(invoke);
|
||||
|
||||
describe("SettingsPanel", () => {
|
||||
const defaultProps = {
|
||||
settings: makeSettings(),
|
||||
onClose: vi.fn(),
|
||||
onUpdate: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockInvoke.mockResolvedValue(makeSettings());
|
||||
});
|
||||
|
||||
it("renders the settings title", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
expect(screen.getByText("Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows launch at login toggle", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
expect(screen.getByText("Launch at login")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows show images toggle", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
expect(screen.getByText("Show images in history")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows max history buttons", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
expect(screen.getByText("100")).toBeInTheDocument();
|
||||
expect(screen.getByText("500")).toBeInTheDocument();
|
||||
expect(screen.getByText("1000")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("highlights current max history value", () => {
|
||||
const settings = makeSettings({ max_history: 1000 });
|
||||
render(<SettingsPanel {...defaultProps} settings={settings} />);
|
||||
const btn = screen.getByText("1000");
|
||||
expect(btn.className).toContain("bg-accent");
|
||||
});
|
||||
|
||||
it("calls onClose when close button clicked", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<SettingsPanel {...defaultProps} onClose={onClose} />);
|
||||
// The close button is the SVG button in the header
|
||||
const closeBtn = screen.getByText("Settings").parentElement!.querySelector("button")!;
|
||||
fireEvent.click(closeBtn);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("invokes set_setting when toggling show_images", async () => {
|
||||
mockInvoke.mockResolvedValue(makeSettings({ show_images: false }));
|
||||
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
const switches = screen.getAllByRole("switch");
|
||||
// Second switch is "Show images"
|
||||
fireEvent.click(switches[1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvoke).toHaveBeenCalledWith("set_setting", {
|
||||
key: "show_images",
|
||||
value: "false",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("invokes set_setting when changing max_history", async () => {
|
||||
mockInvoke.mockResolvedValue(makeSettings({ max_history: 100 }));
|
||||
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("100"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvoke).toHaveBeenCalledWith("set_setting", {
|
||||
key: "max_history",
|
||||
value: "100",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("shows clear all button", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
expect(screen.getByText("Clear All History")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes clear_all on confirm", async () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("Clear All History"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvoke).toHaveBeenCalledWith("clear_all");
|
||||
});
|
||||
});
|
||||
});
|
||||
123
src/components/SettingsPanel.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Settings } from "../types";
|
||||
|
||||
interface Props {
|
||||
settings: Settings;
|
||||
onClose: () => void;
|
||||
onUpdate: (settings: Settings) => void;
|
||||
}
|
||||
|
||||
export default function SettingsPanel({ settings, onClose, onUpdate }: Props) {
|
||||
const updateSetting = useCallback(
|
||||
async (key: string, value: string) => {
|
||||
await invoke("set_setting", { key, value });
|
||||
const updated: Settings = await invoke("get_settings");
|
||||
onUpdate(updated);
|
||||
},
|
||||
[onUpdate]
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(async () => {
|
||||
if (confirm("Clear all clipboard history? This cannot be undone.")) {
|
||||
await invoke("clear_all");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border" data-tauri-drag-region>
|
||||
<h2 className="text-base font-semibold text-text-primary">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Settings body */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-5">
|
||||
{/* Launch at login */}
|
||||
<ToggleRow
|
||||
label="Launch at login"
|
||||
checked={settings.launch_at_login}
|
||||
onChange={(v) => updateSetting("launch_at_login", String(v))}
|
||||
/>
|
||||
|
||||
{/* Show images */}
|
||||
<ToggleRow
|
||||
label="Show images in history"
|
||||
checked={settings.show_images}
|
||||
onChange={(v) => updateSetting("show_images", String(v))}
|
||||
/>
|
||||
|
||||
{/* Max history */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
Max history size
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{[100, 500, 1000].map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => updateSetting("max_history", String(n))}
|
||||
className={`flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
${
|
||||
settings.max_history === n
|
||||
? "bg-accent text-white"
|
||||
: "bg-surface-hover text-text-primary hover:bg-surface-active"
|
||||
}`}
|
||||
>
|
||||
{n}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="pt-3 border-t border-border">
|
||||
<button
|
||||
onClick={handleClearAll}
|
||||
className="w-full py-2 rounded-lg text-sm font-medium text-white bg-danger hover:opacity-90 transition-opacity"
|
||||
>
|
||||
Clear All History
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex items-center justify-between cursor-pointer">
|
||||
<span className="text-sm text-text-primary">{label}</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors ${
|
||||
checked ? "bg-accent" : "bg-surface-active"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
||||
checked ? "translate-x-4" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
40
src/index.css
Normal file
@ -0,0 +1,40 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-surface: light-dark(#ffffff, #1e1e2e);
|
||||
--color-surface-hover: light-dark(#f5f5f5, #2a2a3c);
|
||||
--color-surface-active: light-dark(#e8e8e8, #363649);
|
||||
--color-border: light-dark(#e0e0e0, #3a3a4c);
|
||||
--color-text-primary: light-dark(#1a1a2e, #e0e0f0);
|
||||
--color-text-secondary: light-dark(#6b7280, #9ca3af);
|
||||
--color-accent: light-dark(#6366f1, #818cf8);
|
||||
--color-accent-hover: light-dark(#4f46e5, #6366f1);
|
||||
--color-pin: light-dark(#f59e0b, #fbbf24);
|
||||
--color-danger: light-dark(#ef4444, #f87171);
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
29
src/test/factories.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { ClipboardEntry, Settings } from "../types";
|
||||
|
||||
let nextId = 1;
|
||||
|
||||
export function makeEntry(overrides: Partial<ClipboardEntry> = {}): ClipboardEntry {
|
||||
const id = nextId++;
|
||||
return {
|
||||
id,
|
||||
content: `Test entry ${id}`,
|
||||
content_type: "text",
|
||||
created_at: new Date().toISOString(),
|
||||
pinned: false,
|
||||
content_hash: `hash_${id}`,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
return {
|
||||
launch_at_login: false,
|
||||
show_images: true,
|
||||
max_history: 500,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetIdCounter() {
|
||||
nextId = 1;
|
||||
}
|
||||
22
src/test/setup.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
// Mock Tauri APIs — tests run outside the Tauri runtime
|
||||
vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(() => Promise.resolve(() => {})),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/api/window", () => ({
|
||||
getCurrentWindow: vi.fn(() => ({
|
||||
hide: vi.fn(() => Promise.resolve()),
|
||||
show: vi.fn(() => Promise.resolve()),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@tauri-apps/plugin-clipboard-manager", () => ({
|
||||
writeText: vi.fn(() => Promise.resolve()),
|
||||
readText: vi.fn(() => Promise.resolve("")),
|
||||
}));
|
||||
14
src/types.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export interface ClipboardEntry {
|
||||
id: number;
|
||||
content: string;
|
||||
content_type: "text" | "image" | "file";
|
||||
created_at: string;
|
||||
pinned: boolean;
|
||||
content_hash: string;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
launch_at_login: boolean;
|
||||
show_images: boolean;
|
||||
max_history: number;
|
||||
}
|
||||
22
tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
17
vite.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host ? { protocol: "ws", host, port: 1421 } : undefined,
|
||||
watch: { ignored: ["**/src-tauri/**"] },
|
||||
},
|
||||
});
|
||||
12
vitest.config.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/test/setup.ts"],
|
||||
include: ["src/**/*.test.{ts,tsx}"],
|
||||
},
|
||||
});
|
||||