Add Downloads ingest and drag-out from the picker.
New finished files in ~/Downloads join history, and a drag gesture on a row starts a native file drop so screenshots and downloads can be placed into other apps without a Dock stack.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
Generated
+132
-13
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ export function makeSettings(overrides: Partial<Settings> = {}): Settings {
|
||||
window_width: 420,
|
||||
window_height: 560,
|
||||
hotkey: "ctrl+`",
|
||||
watch_downloads: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user