Merge pull request 'Downloads ingest and drag-out from the picker' (#14) from feature/downloads-ingest-drag into main
CI / skip-ci-check (push) Successful in 13s
CI / secret-scan (push) Successful in 13s
CI / node-ci (push) Successful in 29s

This commit was merged in pull request #14.
This commit is contained in:
2026-10-03 10:32:35 -05:00
24 changed files with 852 additions and 48 deletions
+4
View File
@@ -5,6 +5,10 @@ All notable changes to maCopy. Format loosely follows
## [Unreleased]
### Added
- Capture finished files from `~/Downloads` into history (Settings toggle, on by default). Existing files are not imported; in-progress `.crdownload` / `.download` / `.part` files are skipped. PNGs are stored as images.
- Drag any history row onto another app (screenshot → Messages, PDF → Finder, …). Click still pastes; moving the pointer starts a native file drag.
### Changed
- Menu-bar glyph is a clipboard with an infinity mark (template image so
macOS tints it). Replaces the dark app icon that collapsed into a grey pill.
+14 -3
View File
@@ -13,6 +13,8 @@ macOS clipboard manager in the menu bar. Built with Tauri 2, React, TypeScript,
| Menu bar | No dock icon; tray glyph is a clipboard with ∞ |
| Global hotkey | `` Ctrl+` `` opens the window (does not steal macOS Cmd+` window-cycle); configurable in Settings |
| Clipboard monitoring | Watches pasteboard changeCount; captures text, images, and file paths |
| Downloads | Optional watch of `~/Downloads`; finished files (and PNGs as images) join history |
| Drag out | Drag a row onto Finder, Messages, Slack, etc. Click still pastes |
| List performance | Truncated previews in list API; image blobs stay in SQLite until paste |
| Search | Full-text filtering via SQLite FTS5 |
| Quick paste | `Cmd+1` through `Cmd+9` paste the Nth item into the previous app |
@@ -66,6 +68,7 @@ The app will compile the Rust backend, start the Vite dev server, and launch the
|---|---|
| Open/close window | Click tray icon or press `` Ctrl+` `` (configurable in Settings) |
| Paste an entry | Click it, or press `Enter` |
| Drag an entry onto another app | Click and drag the row (picker hides so you can drop) |
| Paste plain (no formatting) | `⌥`/`Alt`+click, or right-click → Paste plain |
| Copy without pasting | Right-click → Copy |
| Transforms | Right-click → Transform (trim, case, JSON, …) |
@@ -107,6 +110,8 @@ maCopy/
│ ├── main.rs # Binary entry point
│ ├── lib.rs # App setup: tray, hotkey, window management
│ ├── clipboard.rs # Background polling thread (500ms, SHA-256 dedup)
│ ├── downloads.rs # ~/Downloads watcher (new files only)
│ ├── drag_out.rs # Materialize a history item as a file for OS drag
│ ├── db.rs # SQLite CRUD + FTS5 + settings + auto-trim
│ └── commands.rs # Tauri IPC commands
├── .cursor/rules/ # Cursor AI rules for this project
@@ -148,6 +153,12 @@ maCopy/
A background Rust thread watches the macOS pasteboard **changeCount** and only reads the clipboard when it changes. Text, images (full PNG + thumbnail), and file paths are captured. List IPC returns truncated / redacted previews and image thumbnails — not multi‑MB blobs.
A second thread polls `~/Downloads` for files that appear after launch. In-progress browser temps are skipped; PNGs are stored as images so they can be dragged like screenshots.
### Drag out
Click still pastes. Moving the pointer ~8px starts a native macOS file drag (images and text are written to a temp file first). The picker hides so you can drop onto another app.
### Paste and return
When you select an entry, maCopy writes it to the system clipboard (text or image), hides its window, waits briefly for macOS to refocus the previous app, then simulates `Cmd+V` via AppleScript. This requires Accessibility permission. Optional transforms (plain, trim, JSON, …) run before the write.
@@ -173,9 +184,9 @@ The SQLite database is stored at:
See **[docs/TESTING.md](docs/TESTING.md)** for the full map.
```bash
npm test # ~72 frontend tests
npm run test:rust # ~67 Rust tests
npm run check # lint + all tests (~139)
npm test # ~82 frontend tests
npm run test:rust # ~78 Rust tests
npm run check # lint + all tests (~160)
```
## Building for Release
+2 -1
View File
@@ -19,6 +19,8 @@ Living list of shipping ideas. Checked items are done (or landed in the current
- [x] **SQLite speed pragmas** — WAL + synchronous=NORMAL so reads don't block on writer fsyncs
- [x] **Memoized list rows** — selection/focus changes no longer re-render every row's preview
- [x] **Menu-bar template icon** — clipboard + ∞, tinted by macOS (not the dark app icon)
- [x] **Drag history items** onto other apps (native file drag)
- [x] **Downloads ingest** — new finished files in `~/Downloads` (toggle in Settings)
## Next up (high value)
@@ -58,7 +60,6 @@ Living list of shipping ideas. Checked items are done (or landed in the current
### UX polish
- Compact vs comfortable density
- Pin board / tabs (All · Pins · Images · Sensitive)
- Drag item onto another app
- Keyboard: Tab into transforms, `/` transform palette
- Menu bar: last N items quick-paste submenu
- Sound / haptic optional on paste
+6 -1
View File
@@ -68,6 +68,7 @@ field → press your new combo.
| Action | How |
|---|---|
| Paste an item into the app you were just using | Click it, or select + `Enter` |
| **Drag** an item onto another app | Click and drag the row — the picker hides so you can drop |
| Paste **plain** (strip formatting) | `⌥`/`Alt` + click, or right-click → **Paste plain** |
| Paste with a transform | Right-click → **Transform** → pick one |
| Paste the Nth item directly (no picker needed) | `Cmd+1` … `Cmd+9` while picker is open |
@@ -113,6 +114,7 @@ two-finger / right-click, or the gear.)
|---|---|
| Global hotkey | Re-bind the open/close shortcut |
| Show images | Toggle thumbnail rendering in the list (off = faster on huge histories) |
| Capture Downloads folder | New finished downloads appear in history (`~/Downloads`; does not import old files) |
| Max history | 1K / 5K / 10K / 50K entries kept before auto-trim (pinned items are exempt) |
| Window position | Near cursor, center, or a screen corner |
| Launch at login | Registers/unregisters a macOS Login Item — see [docs/LAUNCH-AT-LOGIN.md](LAUNCH-AT-LOGIN.md) |
@@ -130,7 +132,8 @@ two-finger / right-click, or the gear.)
```
~/Library/Application Support/maCopy/clipboard.db
```
- You can pause monitoring any time from the tray menu.
- You can pause monitoring any time from the tray menu (also pauses Downloads ingest).
- New files in `~/Downloads` are captured when **Capture Downloads folder** is on (default). Existing files are not imported.
- Full picture, including what's *not yet* implemented (encryption at
rest, per-app exclusions): [ROADMAP.md](../ROADMAP.md) → "Privacy & trust".
@@ -142,6 +145,8 @@ two-finger / right-click, or the gear.)
|---|---|
| Hotkey does nothing | Settings → check it isn't a duplicate of a macOS shortcut (the old default `Cmd+\`` conflicted with window-cycling — current default is `` Ctrl+` ``) |
| Paste doesn't happen (item copies but old app doesn't receive it) | Grant **Accessibility** permission — System Settings → Privacy & Security → Accessibility → add/enable maCopy |
| Drag onto another app does nothing | Click-hold and move ~8px; drop after the picker hides. If the file was a download you already moved, the path is stale — copy/paste instead |
| New Downloads files don't appear | Settings → **Capture Downloads folder** on; wait ~1s after the download finishes; tray **Pause monitoring** also pauses this |
| App won't open ("cannot verify developer") | Right-click → Open, or `xattr -cr /Applications/maCopy.app` — see [docs/SHARING.md](SHARING.md) |
| Launch at login doesn't stick after rebuild | You're running a dev/rebuilt binary at a new path — install the `.app` under `/Applications` and re-toggle the setting |
| App feels slow / using lots of memory | Update to the latest build — see the performance notes in [README.md](../README.md#architecture); file it as a regression if it persists |
+6 -4
View File
@@ -16,16 +16,16 @@ maCopy has an automated suite. There is no separate “live” Storybook/UI docs
| `npm run lint` | TypeScript `tsc --noEmit` |
| `npm run check` | Lint + all tests (CI-style gate) |
Current counts (approx.): **~72 frontend** + **~67 Rust** unit tests.
Current counts (approx.): **~82 frontend** + **~78 Rust** unit tests.
## What is covered
### Frontend (`src/**/*.test.tsx`, `transforms.test.ts`)
- App: load entries, paste invoke, delete via keyboard, **search invoke + clear on search failure**
- ClipboardList: selection, multi-select modifiers, image stubs/thumbs (including truncated thumb previews), Alt=plain paste
- ClipboardList: selection, multi-select modifiers, **click vs drag**, image stubs/thumbs, Alt=plain paste
- ContextMenu: Paste / Paste plain / transforms / Pin / Delete
- SettingsPanel: toggles, history limit, hotkey recorder, **Launch at login → autostart enable**
- SettingsPanel: toggles (**Downloads ingest**), history limit, hotkey recorder, **Launch at login → autostart enable**
- SearchBar basics + Settings gear
- Transforms: plain text strip, JSON pretty, collapse / oneline
@@ -41,6 +41,8 @@ Tauri APIs are mocked in `src/test/setup.ts` (invoke, window, events, clipboard,
- Image thumbnail / PNG helpers + `fit_rgba_under_bytes` against production `MAX_IMAGE_BYTES`
- **Oversized / Retina screenshot prepare** — never skip; downscale + thumb + DB list preview
- Clipboard content hashing fingerprints
- **Downloads ingest** — skip incomplete names, PNG vs file insert, hash-dedup
- **Drag-out materialize** — existing file path, image → temp PNG, text → temp txt, preview placeholder
## What is *not* covered (yet)
@@ -56,6 +58,6 @@ Tauri APIs are mocked in `src/test/setup.ts` (invoke, window, events, clipboard,
```bash
npm run check # before every PR / commit
npm run tauri dev # manual smoke: hotkey, paste, redaction, thumbs
npm run tauri dev # manual smoke: hotkey, paste, redaction, thumbs, drag-out, Downloads ingest
npm run tauri build # release .app when testing Login Items / signing
```
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "macopy",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "macopy",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-autostart": "^2",
+132 -13
View File
@@ -767,6 +767,27 @@ dependencies = [
"serde",
]
[[package]]
name = "drag"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e90b4a25ace5ce0561534b073943594cbcd21af936e64d09aec444568411f8c"
dependencies = [
"core-graphics 0.24.0",
"dunce",
"gdk",
"gdkx11",
"gtk",
"log",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"raw-window-handle",
"thiserror 2.0.18",
"windows 0.52.0",
"windows-core 0.58.0",
]
[[package]]
name = "dtoa"
version = "1.0.11"
@@ -1963,6 +1984,7 @@ dependencies = [
"chrono",
"core-graphics 0.24.0",
"dirs 5.0.1",
"drag",
"hex",
"log",
"objc2",
@@ -3351,7 +3373,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -3423,7 +3445,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -3570,7 +3592,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
]
[[package]]
@@ -3595,7 +3617,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows",
"windows 0.61.3",
"wry",
]
@@ -4478,10 +4500,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
]
[[package]]
@@ -4502,7 +4524,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
]
@@ -4558,6 +4580,18 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core 0.52.0",
"windows-implement 0.52.0",
"windows-interface 0.52.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.3"
@@ -4580,14 +4614,36 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement 0.58.0",
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -4599,8 +4655,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@@ -4617,6 +4673,28 @@ dependencies = [
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -4628,6 +4706,28 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -4661,6 +4761,15 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -4679,6 +4788,16 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
@@ -5202,7 +5321,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows",
"windows 0.61.3",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
+1
View File
@@ -34,6 +34,7 @@ core-graphics = "0.24"
objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSPasteboard"] }
objc2-foundation = { version = "0.3", features = ["NSString"] }
drag = "2.1"
[features]
custom-protocol = ["tauri/custom-protocol"]
+61 -2
View File
@@ -7,6 +7,7 @@ use tauri::{Manager, State};
pub struct DbState(pub Arc<Database>);
pub struct PausedState(pub Arc<AtomicBool>);
pub struct LastHashState(pub LastHash);
pub struct WatchDownloadsState(pub Arc<AtomicBool>);
#[tauri::command]
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
@@ -56,8 +57,66 @@ pub fn get_settings(db: State<'_, DbState>) -> Result<Settings, 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())
pub fn set_setting(
db: State<'_, DbState>,
watch: State<'_, WatchDownloadsState>,
key: String,
value: String,
) -> Result<(), String> {
db.0.set_setting(&key, &value).map_err(|e| e.to_string())?;
if key == "watch_downloads" {
watch.0.store(value == "true", Ordering::Relaxed);
}
Ok(())
}
/// Native drag-out so the user can drop a history item onto Finder, Slack, etc.
#[tauri::command]
pub fn start_item_drag(app: tauri::AppHandle, db: State<'_, DbState>, id: i64) -> Result<(), String> {
let entry = db
.0
.get_entry(id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("entry {} not found", id))?;
let path = crate::drag_out::materialize_drag_path(&entry)?;
let window = app
.get_webview_window("main")
.ok_or_else(|| "main window missing".to_string())?;
let _ = window.hide();
#[cfg(target_os = "macos")]
{
use drag::{start_drag, DragItem, Image, Options};
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
let preview = crate::drag_out::drag_preview_or_placeholder(&path);
let drag_window = window.clone();
window
.run_on_main_thread(move || {
let result = start_drag(
&drag_window,
DragItem::Files(vec![path]),
Image::Raw(preview),
|_result, _cursor| {},
Options {
skip_animatation_on_cancel_or_failure: true,
..Default::default()
},
);
let _ = tx.send(result.map_err(|e| e.to_string()));
})
.map_err(|e| e.to_string())?;
rx.recv().map_err(|e| e.to_string())??;
}
#[cfg(not(target_os = "macos"))]
{
let _ = path;
return Err("drag-out is only supported on macOS".into());
}
Ok(())
}
#[tauri::command]
+32
View File
@@ -34,6 +34,7 @@ pub struct Settings {
pub window_width: i64,
pub window_height: i64,
pub hotkey: String,
pub watch_downloads: bool,
}
pub const DEFAULT_HOTKEY: &str = "ctrl+`";
@@ -50,6 +51,7 @@ impl Default for Settings {
window_width: 420,
window_height: 560,
hotkey: DEFAULT_HOTKEY.to_string(),
watch_downloads: true,
}
}
}
@@ -201,6 +203,10 @@ impl Database {
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["hotkey", &defaults.hotkey],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["watch_downloads", defaults.watch_downloads.to_string()],
)?;
// Migrate installs that still use Cmd+` (steals macOS window-cycle).
let current_hotkey: String = conn
@@ -237,6 +243,16 @@ impl Database {
Ok(conn.last_insert_rowid())
}
pub fn has_hash(&self, content_hash: &str) -> SqlResult<bool> {
let conn = self.conn.lock().unwrap();
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM clipboard_entries WHERE content_hash = ?1",
params![content_hash],
|row| row.get(0),
)?;
Ok(n > 0)
}
/// Build missing image thumbnails from stored full PNGs (best-effort, capped).
pub fn backfill_thumbnails(&self, max: usize) -> usize {
let rows: Vec<(i64, String)> = {
@@ -428,6 +444,7 @@ impl Database {
window_width: get("window_width", "420").parse().unwrap_or(420),
window_height: get("window_height", "560").parse().unwrap_or(560),
hotkey: get("hotkey", DEFAULT_HOTKEY),
watch_downloads: get("watch_downloads", "true") == "true",
})
}
@@ -498,6 +515,7 @@ mod tests {
assert_eq!(s.window_width, 420);
assert_eq!(s.window_height, 560);
assert_eq!(s.hotkey, DEFAULT_HOTKEY);
assert!(s.watch_downloads);
}
#[test]
@@ -537,6 +555,15 @@ mod tests {
assert!(!entries[0].pinned);
}
#[test]
fn has_hash_detects_inserted_row() {
let db = test_db();
assert!(!db.has_hash("hash1").unwrap());
db.insert_entry("hello world", "text", "hash1", None).unwrap();
assert!(db.has_hash("hash1").unwrap());
assert!(!db.has_hash("hash2").unwrap());
}
#[test]
fn entries_ordered_newest_first() {
let db = test_db();
@@ -770,6 +797,11 @@ mod tests {
db.set_setting("launch_at_login", "true").unwrap();
let s = db.get_settings().unwrap();
assert!(s.launch_at_login);
assert!(s.watch_downloads);
db.set_setting("watch_downloads", "false").unwrap();
let s = db.get_settings().unwrap();
assert!(!s.watch_downloads);
}
#[test]
+249
View File
@@ -0,0 +1,249 @@
//! Watch ~/Downloads and insert finished files into clipboard history.
//!
//! Only files that appear *after* the watcher starts are ingested — existing
//! clutter in Downloads is left alone.
use crate::clipboard::{hash_content, prepare_clipboard_image, MAX_IMAGE_BYTES, MAX_STORED_PNG_BYTES};
use crate::db::Database;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tauri::{AppHandle, Emitter};
const STABLE_FOR: Duration = Duration::from_millis(900);
const POLL_EVERY: Duration = Duration::from_millis(1000);
const MAX_READ_BYTES: u64 = 40 * 1024 * 1024;
const INCOMPLETE_SUFFIXES: &[&str] = &[
".download",
".crdownload",
".part",
".partial",
".tmp",
".temp",
];
/// True for in-progress browser downloads, junk, and hidden files.
pub fn skip_download_name(name: &str) -> bool {
if name.is_empty() || name.starts_with('.') {
return true;
}
let lower = name.to_ascii_lowercase();
if lower == "desktop.ini" || lower == "thumbs.db" {
return true;
}
INCOMPLETE_SUFFIXES.iter().any(|s| lower.ends_with(s))
}
pub fn is_png_path(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.eq_ignore_ascii_case("png"))
.unwrap_or(false)
}
pub fn downloads_dir() -> Option<PathBuf> {
dirs::download_dir().or_else(|| dirs::home_dir().map(|h| h.join("Downloads")))
}
/// Content hash for a finished download (path + size + mtime).
pub fn download_hash(path: &Path, size: u64, mtime_secs: u64) -> String {
hash_content(format!("{}:{size}:{mtime_secs}", path.display()).as_bytes())
}
pub fn start_polling(
app: AppHandle,
db: Arc<Database>,
paused: Arc<AtomicBool>,
enabled: Arc<AtomicBool>,
) {
std::thread::spawn(move || {
let Some(dir) = downloads_dir() else {
log::warn!("Downloads folder not found; watcher idle");
return;
};
let mut known = snapshot_paths(&dir);
let mut pending: HashMap<PathBuf, (u64, Instant)> = HashMap::new();
loop {
std::thread::sleep(POLL_EVERY);
if paused.load(Ordering::Relaxed) || !enabled.load(Ordering::Relaxed) {
continue;
}
if !dir.is_dir() {
continue;
}
let mut alive: HashSet<PathBuf> = HashSet::new();
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for ent in entries.flatten() {
let path = ent.path();
alive.insert(path.clone());
let name = ent.file_name().to_string_lossy().into_owned();
if skip_download_name(&name) {
continue;
}
let Ok(meta) = ent.metadata() else {
continue;
};
if !meta.is_file() {
continue;
}
let size = meta.len();
if size == 0 {
continue;
}
if known.contains(&path) {
continue;
}
match pending.get(&path) {
Some((old_size, since)) if *old_size == size && since.elapsed() >= STABLE_FOR => {
pending.remove(&path);
known.insert(path.clone());
match ingest_download(&db, &path, size, mtime_secs(&meta)) {
Ok(true) => {
let _ = app.emit("clipboard-changed", ());
}
Ok(false) => {}
Err(e) => log::warn!("Downloads ingest {}: {}", path.display(), e),
}
}
Some((old_size, _)) if *old_size == size => {}
_ => {
pending.insert(path, (size, Instant::now()));
}
}
}
known.retain(|p| alive.contains(p));
pending.retain(|p, _| alive.contains(p) && !known.contains(p));
}
});
}
fn snapshot_paths(dir: &Path) -> HashSet<PathBuf> {
let mut out = HashSet::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for ent in entries.flatten() {
out.insert(ent.path());
}
}
out
}
fn mtime_secs(meta: &std::fs::Metadata) -> u64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn ingest_download(db: &Database, path: &Path, size: u64, mtime: u64) -> Result<bool, String> {
let hash = download_hash(path, size, mtime);
if db.has_hash(&hash).unwrap_or(false) {
return Ok(false);
}
if is_png_path(path) && size <= MAX_READ_BYTES {
if let Ok(bytes) = std::fs::read(path) {
if let Ok((w, h, rgba)) = crate::image_util::decode_png_rgba(&bytes) {
let prepared = prepare_clipboard_image(
w,
h,
&rgba,
MAX_IMAGE_BYTES,
MAX_STORED_PNG_BYTES,
)?;
db.insert_entry(
&prepared.content,
"image",
&hash,
prepared.thumbnail.as_deref(),
)
.map_err(|e| e.to_string())?;
return Ok(true);
}
}
}
let abs = path
.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.into_owned();
db.insert_entry(&abs, "file", &hash, None)
.map_err(|e| e.to_string())?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
#[test]
fn skips_hidden_and_incomplete() {
assert!(skip_download_name(".DS_Store"));
assert!(skip_download_name("foo.crdownload"));
assert!(skip_download_name("Safari.dmg.download"));
assert!(skip_download_name("x.part"));
assert!(skip_download_name("x.tmp"));
assert!(skip_download_name("desktop.ini"));
assert!(!skip_download_name("photo.png"));
assert!(!skip_download_name("Report.pdf"));
assert!(!skip_download_name("Screenshot 2026-10-03.png"));
}
#[test]
fn png_extension_is_case_insensitive() {
assert!(is_png_path(Path::new("/tmp/a.PNG")));
assert!(is_png_path(Path::new("/tmp/a.png")));
assert!(!is_png_path(Path::new("/tmp/a.jpg")));
assert!(!is_png_path(Path::new("/tmp/a")));
}
#[test]
fn download_hash_changes_with_mtime() {
let p = Path::new("/tmp/a.png");
assert_ne!(download_hash(p, 10, 1), download_hash(p, 10, 2));
assert_eq!(download_hash(p, 10, 1), download_hash(p, 10, 1));
}
#[test]
fn ingest_png_as_image_and_other_as_file() {
let db = Database::in_memory().unwrap();
let dir = std::env::temp_dir().join(format!("macopy-dl-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let png_path = dir.join("shot.png");
let rgba = vec![255u8, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 0, 255];
let png = crate::image_util::encode_rgba_to_png(2, 2, &rgba).unwrap();
std::fs::write(&png_path, &png).unwrap();
let pdf_path = dir.join("notes.pdf");
std::fs::write(&pdf_path, b"%PDF-fake").unwrap();
assert!(ingest_download(&db, &png_path, png.len() as u64, 1).unwrap());
assert!(ingest_download(&db, &pdf_path, 9, 1).unwrap());
// Same file+mtime is a no-op.
assert!(!ingest_download(&db, &pdf_path, 9, 1).unwrap());
let rows = db.get_entries(10).unwrap();
assert_eq!(rows.len(), 2);
let img = rows.iter().find(|e| e.content_type == "image").unwrap();
assert!(img.content.starts_with("data:image/png;base64,") || img.content.is_empty());
let file = rows.iter().find(|e| e.content_type == "file").unwrap();
assert!(file.content.contains("notes.pdf"));
let _ = std::fs::remove_dir_all(&dir);
}
}
+153
View File
@@ -0,0 +1,153 @@
//! Turn a history entry into a real file path so macOS can drag it to other apps.
use crate::db::ClipboardEntry;
use crate::image_util::{data_uri_to_rgba, encode_rgba_to_png};
use std::path::{Path, PathBuf};
const TEXT_DRAG_MAX: usize = 2 * 1024 * 1024;
/// Resolve a file we can hand to the OS drag session.
pub fn materialize_drag_path(entry: &ClipboardEntry) -> Result<PathBuf, String> {
match entry.content_type.as_str() {
"file" => existing_file_path(&entry.content),
"image" => write_image_temp(entry.id, &entry.content),
_ => write_text_temp(entry.id, &entry.content),
}
}
fn existing_file_path(content: &str) -> Result<PathBuf, String> {
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let p = PathBuf::from(trimmed);
if p.is_file() {
return Ok(p);
}
}
Err("file no longer exists".into())
}
fn write_image_temp(id: i64, content: &str) -> Result<PathBuf, String> {
if !content.starts_with("data:image/") {
return Err("image entry has no PNG payload".into());
}
let (w, h, rgba) = data_uri_to_rgba(content)?;
let png = encode_rgba_to_png(w, h, &rgba).map_err(|e| e.to_string())?;
let path = temp_dir().join(format!("macopy-{id}.png"));
std::fs::write(&path, png).map_err(|e| e.to_string())?;
Ok(path)
}
fn write_text_temp(id: i64, content: &str) -> Result<PathBuf, String> {
if content.len() > TEXT_DRAG_MAX {
return Err("text too large to drag as a file".into());
}
let path = temp_dir().join(format!("macopy-{id}.txt"));
std::fs::write(&path, content).map_err(|e| e.to_string())?;
Ok(path)
}
fn temp_dir() -> PathBuf {
let dir = std::env::temp_dir().join("macopy-drag");
let _ = std::fs::create_dir_all(&dir);
dir
}
/// Small PNG for the drag cursor. Never reads huge downloads into memory.
pub fn drag_preview_bytes(path: &Path) -> Vec<u8> {
const MAX: u64 = 1_500_000;
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
if !matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp") {
return Vec::new();
}
match std::fs::metadata(path) {
Ok(m) if m.len() > 0 && m.len() <= MAX => std::fs::read(path).unwrap_or_default(),
_ => Vec::new(),
}
}
/// NSImage rejects empty buffers — 1×1 PNG when we have no thumbnail.
pub fn drag_preview_or_placeholder(path: &Path) -> Vec<u8> {
let bytes = drag_preview_bytes(path);
if !bytes.is_empty() {
return bytes;
}
crate::image_util::encode_rgba_to_png(1, 1, &[160, 160, 160, 255]).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::image_util::encode_rgba_to_png;
use base64::Engine;
fn entry(kind: &str, content: &str) -> ClipboardEntry {
ClipboardEntry {
id: 42,
content: content.to_string(),
content_type: kind.to_string(),
created_at: "now".into(),
pinned: false,
content_hash: "h".into(),
truncated: false,
sensitive: false,
}
}
#[test]
fn file_entry_uses_existing_path() {
let dir = std::env::temp_dir().join(format!("macopy-drag-src-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("hello.pdf");
std::fs::write(&src, b"pdf").unwrap();
let path = materialize_drag_path(&entry("file", src.to_str().unwrap())).unwrap();
assert_eq!(path, src);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn missing_file_errors() {
let err = materialize_drag_path(&entry("file", "/tmp/macopy-no-such-file-xyz")).unwrap_err();
assert!(err.contains("no longer exists"));
}
#[test]
fn image_entry_writes_png() {
let rgba = vec![10u8, 20, 30, 255];
let png = encode_rgba_to_png(1, 1, &rgba).unwrap();
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
let uri = format!("data:image/png;base64,{b64}");
let path = materialize_drag_path(&entry("image", &uri)).unwrap();
assert!(path.ends_with("macopy-42.png"));
let bytes = std::fs::read(&path).unwrap();
assert_eq!(&bytes[..4], b"\x89PNG");
let _ = std::fs::remove_file(&path);
}
#[test]
fn text_entry_writes_txt() {
let path = materialize_drag_path(&entry("text", "hello drag")).unwrap();
assert!(path.ends_with("macopy-42.txt"));
assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello drag");
let _ = std::fs::remove_file(&path);
}
#[test]
fn preview_skips_non_images_and_placeholder_is_png() {
let dir = std::env::temp_dir().join(format!("macopy-prev-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let pdf = dir.join("a.pdf");
std::fs::write(&pdf, b"%PDF").unwrap();
assert!(drag_preview_bytes(&pdf).is_empty());
let ph = drag_preview_or_placeholder(&pdf);
assert!(ph.len() > 8);
assert_eq!(&ph[..4], b"\x89PNG");
let _ = std::fs::remove_dir_all(&dir);
}
}
+20 -1
View File
@@ -1,10 +1,12 @@
mod clipboard;
mod commands;
mod db;
mod downloads;
mod drag_out;
mod image_util;
mod redact;
use commands::{DbState, LastHashState, PausedState};
use commands::{DbState, LastHashState, PausedState, WatchDownloadsState};
use db::Database;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
@@ -252,9 +254,17 @@ fn position_window(window: &tauri::WebviewWindow, position: &str) {
pub fn run() {
let db = Arc::new(Database::new().expect("Failed to initialize database"));
let paused = Arc::new(AtomicBool::new(false));
let watch_downloads = Arc::new(AtomicBool::new(
db.get_settings()
.map(|s| s.watch_downloads)
.unwrap_or(true),
));
let db_for_polling = db.clone();
let paused_for_polling = paused.clone();
let db_for_downloads = db.clone();
let paused_for_downloads = paused.clone();
let watch_for_downloads = watch_downloads.clone();
tauri::Builder::default()
.plugin(tauri_plugin_clipboard_manager::init())
@@ -274,6 +284,7 @@ pub fn run() {
)
.manage(DbState(db.clone()))
.manage(PausedState(paused.clone()))
.manage(WatchDownloadsState(watch_downloads.clone()))
// LastHashState is added in .setup() after start_polling returns it
.invoke_handler(tauri::generate_handler![
commands::get_entries,
@@ -291,6 +302,7 @@ pub fn run() {
commands::paste_and_refocus,
commands::copy_to_clipboard,
commands::set_hotkey,
commands::start_item_drag,
])
.setup(move |app| {
let ignore_blur_until = Arc::new(AtomicU64::new(0));
@@ -393,6 +405,13 @@ pub fn run() {
);
app.manage(LastHashState(last_hash));
downloads::start_polling(
app.handle().clone(),
db_for_downloads,
paused_for_downloads,
watch_for_downloads,
);
Ok(())
})
.run(tauri::generate_context!())
+25 -2
View File
@@ -93,7 +93,8 @@ describe("App", () => {
expect(screen.getByText("Click me")).toBeInTheDocument();
});
fireEvent.mouseDown(screen.getByText("Click me"));
fireEvent.pointerDown(screen.getByText("Click me"));
fireEvent.pointerUp(screen.getByText("Click me"));
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith("paste_and_refocus", {
@@ -103,6 +104,28 @@ describe("App", () => {
});
});
it("starts native drag instead of paste when the pointer moves", async () => {
const entries = [makeEntry({ content: "Drag me" })];
mockTauriCommands(entries);
render(<App />);
await waitFor(() => {
expect(screen.getByText("Drag me")).toBeInTheDocument();
});
const el = screen.getByText("Drag me");
fireEvent.pointerDown(el, { button: 0, clientX: 10, clientY: 10 });
fireEvent.pointerMove(el, { clientX: 40, clientY: 10 });
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith("start_item_drag", { id: entries[0].id });
});
fireEvent.pointerUp(el);
expect(mockInvoke).not.toHaveBeenCalledWith("paste_and_refocus", expect.anything());
});
it("invokes copy_to_clipboard (not paste_and_refocus) from the Copy context menu action", async () => {
const entries = [makeEntry({ content: "Copy me" })];
mockTauriCommands(entries);
@@ -307,7 +330,7 @@ describe("App", () => {
});
// Ctrl+Click to select (doesn't trigger paste)
fireEvent.mouseDown(screen.getByText("To delete"), { metaKey: true, button: 0 });
fireEvent.pointerDown(screen.getByText("To delete"), { metaKey: true, button: 0 });
// Clear the mock calls from setup
mockInvoke.mockClear();
+6 -1
View File
@@ -389,6 +389,11 @@ export default function App() {
}
setContextMenu({ x: e.clientX, y: e.clientY, entry });
}}
onDragStart={(entry) => {
invoke("start_item_drag", { id: entry.id }).catch((err) => {
console.error("Drag failed:", err);
});
}}
setFocusIndex={setFocusIndex}
/>
</div>
@@ -397,7 +402,7 @@ export default function App() {
<span>
{selectedCount > 1
? `${selectedCount} selected`
: "⌘1-9 paste · ⌥ click plain"}
: "⌘1-9 paste · drag to drop"}
</span>
<span>{entries.length} items</span>
</div>
+31 -6
View File
@@ -15,6 +15,7 @@ describe("ClipboardList", () => {
onShiftClick: vi.fn(),
onPlainClick: vi.fn(),
onContextMenu: vi.fn(),
onDragStart: vi.fn(),
setFocusIndex: vi.fn(),
};
@@ -42,12 +43,14 @@ describe("ClipboardList", () => {
expect(screen.getByText("PIN")).toBeInTheDocument();
});
it("calls onSelect when entry is mouse-downed without modifiers", () => {
it("calls onSelect when entry is clicked without modifiers", () => {
const onSelect = vi.fn();
const entries = [makeEntry({ content: "Click me" })];
render(<ClipboardList {...defaultProps} entries={entries} onSelect={onSelect} />);
fireEvent.mouseDown(screen.getByText("Click me"));
const el = screen.getByText("Click me");
fireEvent.pointerDown(el, { button: 0, clientX: 0, clientY: 0 });
fireEvent.pointerUp(el);
expect(onSelect).toHaveBeenCalledWith(entries[0], { plain: false });
});
@@ -55,7 +58,9 @@ describe("ClipboardList", () => {
const onSelect = vi.fn();
const entries = [makeEntry({ content: "Rich stuff" })];
render(<ClipboardList {...defaultProps} entries={entries} onSelect={onSelect} />);
fireEvent.mouseDown(screen.getByText("Rich stuff"), { altKey: true, button: 0 });
const el = screen.getByText("Rich stuff");
fireEvent.pointerDown(el, { altKey: true, button: 0, clientX: 0, clientY: 0 });
fireEvent.pointerUp(el);
expect(onSelect).toHaveBeenCalledWith(entries[0], { plain: true });
});
@@ -155,7 +160,7 @@ describe("ClipboardList", () => {
render(
<ClipboardList {...defaultProps} entries={entries} onCtrlClick={onCtrlClick} />
);
fireEvent.mouseDown(screen.getByText("Ctrl item"), { metaKey: true, button: 0 });
fireEvent.pointerDown(screen.getByText("Ctrl item"), { metaKey: true, button: 0 });
expect(onCtrlClick).toHaveBeenCalledWith(0);
});
@@ -171,7 +176,7 @@ describe("ClipboardList", () => {
onSelect={onSelect}
/>
);
fireEvent.mouseDown(screen.getByText("Control item"), { ctrlKey: true, button: 0 });
fireEvent.pointerDown(screen.getByText("Control item"), { ctrlKey: true, button: 0 });
expect(onCtrlClick).not.toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
@@ -182,7 +187,7 @@ describe("ClipboardList", () => {
render(
<ClipboardList {...defaultProps} entries={entries} onShiftClick={onShiftClick} />
);
fireEvent.mouseDown(screen.getByText("Shift item"), { shiftKey: true, button: 0 });
fireEvent.pointerDown(screen.getByText("Shift item"), { shiftKey: true, button: 0 });
expect(onShiftClick).toHaveBeenCalledWith(0);
});
@@ -225,4 +230,24 @@ describe("ClipboardList", () => {
render(<ClipboardList {...defaultProps} entries={entries} showImages={false} />);
expect(screen.getByText("IMG")).toBeInTheDocument();
});
it("calls onDragStart instead of onSelect when the pointer moves", () => {
const onSelect = vi.fn();
const onDragStart = vi.fn();
const entries = [makeEntry({ content: "Drag me" })];
render(
<ClipboardList
{...defaultProps}
entries={entries}
onSelect={onSelect}
onDragStart={onDragStart}
/>
);
const el = screen.getByText("Drag me");
fireEvent.pointerDown(el, { button: 0, clientX: 10, clientY: 10 });
fireEvent.pointerMove(el, { clientX: 40, clientY: 10 });
fireEvent.pointerUp(el);
expect(onDragStart).toHaveBeenCalledWith(entries[0]);
expect(onSelect).not.toHaveBeenCalled();
});
});
+39 -11
View File
@@ -1,5 +1,6 @@
import { memo, useEffect, useRef } from "react";
import type { ClipboardEntry } from "../types";
import { isDragGesture } from "../drag";
// Cheap, anchored regex — only matches when the *whole* trimmed entry is a
// color literal, so it never lights up mid-paragraph substrings.
@@ -22,6 +23,7 @@ interface Props {
onShiftClick: (index: number) => void;
onPlainClick: (index: number) => void;
onContextMenu: (e: React.MouseEvent, entry: ClipboardEntry) => void;
onDragStart: (entry: ClipboardEntry) => void;
setFocusIndex: (i: number) => void;
}
@@ -114,11 +116,10 @@ interface RowProps {
onCtrlClick: (index: number) => void;
onShiftClick: (index: number) => void;
onContextMenu: (e: React.MouseEvent, entry: ClipboardEntry) => void;
onDragStart: (entry: ClipboardEntry) => void;
setFocusIndex: (i: number) => void;
}
// Memoized so selection/focus changes elsewhere in the list don't re-render
// every row's preview (image decode, color regex, etc.) each time.
const Row = memo(function Row({
entry,
index: i,
@@ -129,19 +130,24 @@ const Row = memo(function Row({
onCtrlClick,
onShiftClick,
onContextMenu,
onDragStart,
setFocusIndex,
}: RowProps) {
const press = useRef<{
x: number;
y: number;
plain: boolean;
dragged: boolean;
} | null>(null);
return (
<div
className={`flex items-start gap-2 px-3 py-2 cursor-pointer transition-colors
className={`flex items-start gap-2 px-3 py-2 cursor-grab transition-colors
${isSelected ? "bg-accent/10" : ""}
${isFocused && !isSelected ? "bg-surface-hover" : ""}
${!isSelected && !isFocused ? "hover:bg-surface-hover" : ""}`}
onMouseDown={(e) => {
// Use mousedown so paste wins the race against window blur/hide.
onPointerDown={(e) => {
if (e.button !== 0) return;
// Control+click is the macOS context-menu gesture — do not multi-select
// (Cmd+Click) or paste; let onContextMenu open the menu.
if (e.ctrlKey && !e.metaKey) {
e.preventDefault();
return;
@@ -156,12 +162,32 @@ const Row = memo(function Row({
onShiftClick(i);
return;
}
e.preventDefault();
// ⌥/Alt = paste without formatting
onSelect(entry, { plain: e.altKey });
press.current = {
x: e.clientX,
y: e.clientY,
plain: e.altKey,
dragged: false,
};
e.currentTarget.setPointerCapture?.(e.pointerId);
}}
onPointerMove={(e) => {
const p = press.current;
if (!p || p.dragged) return;
if (isDragGesture(p.x, p.y, e.clientX, e.clientY)) {
p.dragged = true;
onDragStart(entry);
}
}}
onPointerUp={() => {
const p = press.current;
press.current = null;
if (!p || p.dragged) return;
onSelect(entry, { plain: p.plain });
}}
onPointerCancel={() => {
press.current = null;
}}
onClick={(e) => {
// Prevent the follow-up click from doing anything extra.
e.preventDefault();
}}
onContextMenu={(e) => onContextMenu(e, entry)}
@@ -209,6 +235,7 @@ export default function ClipboardList({
onCtrlClick,
onShiftClick,
onContextMenu,
onDragStart,
setFocusIndex,
}: Props) {
const listRef = useRef<HTMLDivElement>(null);
@@ -243,6 +270,7 @@ export default function ClipboardList({
onCtrlClick={onCtrlClick}
onShiftClick={onShiftClick}
onContextMenu={onContextMenu}
onDragStart={onDragStart}
setFocusIndex={setFocusIndex}
/>
))}
+20
View File
@@ -33,6 +33,11 @@ describe("SettingsPanel", () => {
expect(screen.getByText("Show image previews")).toBeInTheDocument();
});
it("shows capture Downloads toggle", () => {
render(<SettingsPanel {...defaultProps} />);
expect(screen.getByText("Capture Downloads folder")).toBeInTheDocument();
});
it("shows max history buttons", () => {
render(<SettingsPanel {...defaultProps} />);
expect(screen.getByText("1K")).toBeInTheDocument();
@@ -78,6 +83,21 @@ describe("SettingsPanel", () => {
});
});
it("invokes set_setting when toggling watch_downloads", async () => {
mockInvoke.mockResolvedValue(makeSettings({ watch_downloads: false }));
render(<SettingsPanel {...defaultProps} />);
const switches = screen.getAllByRole("switch");
fireEvent.click(switches[2]);
await waitFor(() => {
expect(mockInvoke).toHaveBeenCalledWith("set_setting", {
key: "watch_downloads",
value: "false",
});
});
});
it("enables macOS autostart when launching at login is turned on", async () => {
const { enable } = await import("@tauri-apps/plugin-autostart");
mockInvoke.mockResolvedValue(makeSettings({ launch_at_login: true }));
+12 -1
View File
@@ -165,6 +165,16 @@ export default function SettingsPanel({ settings: initialSettings, onClose, onUp
onChange={(v) => updateSetting("show_images", String(v))}
/>
<ToggleRow
label="Capture Downloads folder"
checked={settings.watch_downloads}
onChange={(v) => updateSetting("watch_downloads", String(v))}
/>
<p className="text-[11px] text-text-secondary -mt-3">
New files that finish downloading appear in history. Existing files are not imported.
Drag any row out of the picker onto another app.
</p>
<HotkeyRow
hotkey={settings.hotkey}
onSaved={(updated) => {
@@ -229,7 +239,8 @@ export default function SettingsPanel({ settings: initialSettings, onClose, onUp
}
function parseSettingValue(key: string, value: string): unknown {
if (key === "launch_at_login" || key === "show_images") return value === "true";
if (key === "launch_at_login" || key === "show_images" || key === "watch_downloads")
return value === "true";
if (key === "max_history" || key === "window_width" || key === "window_height")
return parseInt(value, 10);
return value;
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { DRAG_THRESHOLD_PX, isDragGesture } from "./drag";
describe("isDragGesture", () => {
it("treats no movement as a click", () => {
expect(isDragGesture(10, 10, 10, 10)).toBe(false);
});
it("treats a small wobble as a click", () => {
expect(isDragGesture(0, 0, 3, 4)).toBe(false);
});
it("treats movement at the threshold as a drag", () => {
expect(isDragGesture(0, 0, DRAG_THRESHOLD_PX, 0)).toBe(true);
});
it("treats a long move as a drag", () => {
expect(isDragGesture(100, 100, 100, 140)).toBe(true);
});
});
+14
View File
@@ -0,0 +1,14 @@
export const DRAG_THRESHOLD_PX = 8;
/** True once the pointer has moved far enough that this is a drag, not a click. */
export function isDragGesture(
startX: number,
startY: number,
x: number,
y: number,
threshold = DRAG_THRESHOLD_PX
): boolean {
const dx = x - startX;
const dy = y - startY;
return dx * dx + dy * dy >= threshold * threshold;
}
+1
View File
@@ -26,6 +26,7 @@ export function makeSettings(overrides: Partial<Settings> = {}): Settings {
window_width: 420,
window_height: 560,
hotkey: "ctrl+`",
watch_downloads: true,
...overrides,
};
}
+1
View File
@@ -19,6 +19,7 @@ export interface Settings {
window_width: number;
window_height: number;
hotkey: string;
watch_downloads: boolean;
}
/** How many history rows to request for the picker UI. */
+1
View File
@@ -131,6 +131,7 @@ function VisualApp() {
e.preventDefault();
setMenu({ x: e.clientX, y: e.clientY, entry });
}}
onDragStart={(entry) => setBanner(`Drag ${entry.content_type} #${entry.id}`)}
setFocusIndex={setFocusIndex}
/>
</div>