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>
This commit is contained in:
ilia 2026-05-12 12:55:56 -04:00
commit b643f50d76
51 changed files with 11568 additions and 0 deletions

34
.gitignore vendored Normal file
View 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
View 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
View 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.
![macOS](https://img.shields.io/badge/macOS-10.15+-black?logo=apple)
![Tauri](https://img.shields.io/badge/Tauri-2-blue?logo=tauri)
![License](https://img.shields.io/badge/license-MIT-green)
## 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
View 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

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

31
src-tauri/Cargo.toml Normal file
View 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
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 B

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 830 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 872 B

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

3
src-tauri/rustfmt.toml Normal file
View File

@ -0,0 +1,3 @@
max_width = 100
tab_spaces = 4
edition = "2021"

150
src-tauri/src/clipboard.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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>
);
}

View 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();
});
});

View 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>
);
}

View 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");
});
});

View 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>
);
}

View 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();
});
});

View 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>
);
}

View 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");
});
});
});

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}"],
},
});