Multi-select, paste-to-app, resizable window, polished UI

- Max history default increased to 10K, options: 1K/5K/10K/50K
- Tray icon uses with_id to prevent duplicates on hot-reload
- Selecting an entry now pastes directly into the previous app
  via osascript Cmd+V simulation after hiding the window
- Multi-select: Ctrl+Click toggles, Shift+Arrow extends range,
  Cmd+A selects all, Enter pastes all selected
- Window is resizable, size persists in SQLite across opens
- Window position setting: cursor, center, or any screen corner
- UI cleanup: tighter spacing, cleaner dividers, compact search,
  type badges (TXT/IMG/FILE), PIN label, tabular timestamps
- Context menu shows multi-item labels ("Paste 3 items")
- All tests updated and passing (27 Rust + 33 TypeScript)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-12 14:01:03 -04:00
co-authored by Cursor
parent b643f50d76
commit 55ee81fc6d
36 changed files with 580 additions and 244 deletions
+13
View File
@@ -1961,6 +1961,7 @@ dependencies = [
"tauri-plugin-autostart",
"tauri-plugin-clipboard-manager",
"tauri-plugin-global-shortcut",
"tokio",
]
[[package]]
@@ -3728,9 +3729,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
+1
View File
@@ -26,6 +26,7 @@ base64 = "0.22"
log = "0.4"
png = "0.17"
dirs = "5"
tokio = { version = "1", features = ["time", "macros"] }
[features]
custom-protocol = ["tauri/custom-protocol"]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 B

After

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 B

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 713 B

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 908 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 872 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

+33 -3
View File
@@ -1,14 +1,14 @@
use crate::db::{ClipboardEntry, Database, Settings};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::State;
use tauri::{Manager, State};
pub struct DbState(pub Arc<Database>);
pub struct PausedState(pub Arc<AtomicBool>);
#[tauri::command]
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
db.0.get_entries(limit.unwrap_or(500))
db.0.get_entries(limit.unwrap_or(10000))
.map_err(|e| e.to_string())
}
@@ -17,7 +17,7 @@ pub fn search_entries(db: State<'_, DbState>, query: String, limit: Option<i64>)
if query.trim().is_empty() {
return get_entries(db, limit);
}
db.0.search_entries(&query, limit.unwrap_or(500))
db.0.search_entries(&query, limit.unwrap_or(10000))
.map_err(|e| e.to_string())
}
@@ -55,3 +55,33 @@ pub fn get_paused(paused: State<'_, PausedState>) -> bool {
pub fn set_paused(paused: State<'_, PausedState>, value: bool) {
paused.0.store(value, Ordering::Relaxed);
}
#[tauri::command]
pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Result<(), String> {
db.0.set_setting("window_width", &width.to_string())
.map_err(|e| e.to_string())?;
db.0.set_setting("window_height", &height.to_string())
.map_err(|e| e.to_string())
}
/// Hide the window then simulate Cmd+V so the content pastes into the
/// previously-focused app. The 150ms delay gives macOS time to refocus.
#[tauri::command]
pub async fn paste_and_refocus(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
// Use AppleScript to press Cmd+V in the frontmost app
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("osascript")
.arg("-e")
.arg(r#"tell application "System Events" to keystroke "v" using command down"#)
.spawn();
}
Ok(())
}
+28 -4
View File
@@ -18,6 +18,9 @@ pub struct Settings {
pub launch_at_login: bool,
pub show_images: bool,
pub max_history: i64,
pub window_position: String,
pub window_width: i64,
pub window_height: i64,
}
impl Default for Settings {
@@ -25,7 +28,10 @@ impl Default for Settings {
Self {
launch_at_login: false,
show_images: true,
max_history: 500,
max_history: 10000,
window_position: "cursor".to_string(),
window_width: 420,
window_height: 560,
}
}
}
@@ -122,6 +128,18 @@ impl Database {
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["max_history", defaults.max_history.to_string()],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["window_position", &defaults.window_position],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["window_width", defaults.window_width.to_string()],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["window_height", defaults.window_height.to_string()],
)?;
Ok(())
}
@@ -255,7 +273,10 @@ impl Database {
Ok(Settings {
launch_at_login: get("launch_at_login", "false") == "true",
show_images: get("show_images", "true") == "true",
max_history: get("max_history", "500").parse().unwrap_or(500),
max_history: get("max_history", "10000").parse().unwrap_or(10000),
window_position: get("window_position", "cursor"),
window_width: get("window_width", "420").parse().unwrap_or(420),
window_height: get("window_height", "560").parse().unwrap_or(560),
})
}
@@ -285,14 +306,17 @@ mod tests {
let s = db.get_settings().unwrap();
assert!(!s.launch_at_login);
assert!(s.show_images);
assert_eq!(s.max_history, 500);
assert_eq!(s.max_history, 10000);
assert_eq!(s.window_position, "cursor");
assert_eq!(s.window_width, 420);
assert_eq!(s.window_height, 560);
}
#[test]
fn double_init_is_idempotent() {
let db = test_db();
db.init_tables().unwrap();
assert_eq!(db.get_settings().unwrap().max_history, 500);
assert_eq!(db.get_settings().unwrap().max_history, 10000);
}
// ── Insert & Get ────────────────────────────────────────────────
+78 -9
View File
@@ -14,13 +14,85 @@ use tauri::{
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
fn show_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
// Position window before showing based on settings
if let Some(db) = app.try_state::<DbState>() {
if let Ok(settings) = db.0.get_settings() {
position_window(&window, &settings.window_position);
let _ = window.set_size(tauri::LogicalSize::new(
settings.window_width as f64,
settings.window_height as f64,
));
}
}
let _ = window.show();
let _ = window.set_focus();
}
}
fn toggle_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
show_window(app);
}
}
}
fn position_window(window: &tauri::WebviewWindow, position: &str) {
match position {
"center" => {
let _ = window.center();
}
"top-right" => {
if let Ok(monitor) = window.current_monitor() {
if let Some(monitor) = monitor {
let size = monitor.size();
let scale = monitor.scale_factor();
let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560));
let x = (size.width as f64 / scale) - (win_size.width as f64 / scale) - 10.0;
let _ = window.set_position(tauri::LogicalPosition::new(x, 30.0));
}
}
}
"top-left" => {
let _ = window.set_position(tauri::LogicalPosition::new(10.0, 30.0));
}
"bottom-right" => {
if let Ok(monitor) = window.current_monitor() {
if let Some(monitor) = monitor {
let size = monitor.size();
let scale = monitor.scale_factor();
let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560));
let x = (size.width as f64 / scale) - (win_size.width as f64 / scale) - 10.0;
let y = (size.height as f64 / scale) - (win_size.height as f64 / scale) - 10.0;
let _ = window.set_position(tauri::LogicalPosition::new(x, y));
}
}
}
"bottom-left" => {
if let Ok(monitor) = window.current_monitor() {
if let Some(monitor) = monitor {
let size = monitor.size();
let scale = monitor.scale_factor();
let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560));
let y = (size.height as f64 / scale) - (win_size.height as f64 / scale) - 10.0;
let _ = window.set_position(tauri::LogicalPosition::new(10.0, y));
}
}
}
// "cursor" or default — position near the mouse cursor
_ => {
if let Ok(cursor) = window.cursor_position() {
let _ = window.set_position(tauri::LogicalPosition::new(
cursor.x - 210.0,
cursor.y + 10.0,
));
} else {
let _ = window.center();
}
}
}
}
@@ -66,16 +138,16 @@ pub fn run() {
commands::set_setting,
commands::get_paused,
commands::set_paused,
commands::save_window_size,
commands::paste_and_refocus,
])
.setup(move |app| {
// Register the global hotkey
let shortcut = Shortcut::new(
Some(Modifiers::SUPER | Modifiers::SHIFT),
Code::KeyV,
);
app.global_shortcut().register(shortcut)?;
// Build the tray menu
let show_i = MenuItem::with_id(app, "show", "Show maCopy", true, None::<&str>)?;
let pause_i = CheckMenuItem::with_id(app, "pause", "Pause Monitoring", true, false, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
@@ -84,7 +156,8 @@ pub fn run() {
let menu = Menu::with_items(app, &[&show_i, &pause_i, &sep, &settings_i, &quit_i])?;
let _tray = TrayIconBuilder::new()
// Use with_id to prevent duplicate tray icons across hot-reloads
let _tray = TrayIconBuilder::with_id("macopy-tray")
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
@@ -98,7 +171,6 @@ pub fn run() {
paused_clone.store(!current, std::sync::atomic::Ordering::Relaxed);
}
"settings" => {
// Emit an event the frontend listens for to open settings panel
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
@@ -118,11 +190,9 @@ pub fn run() {
})
.build(app)?;
// Hide from dock on macOS — app is menu-bar only
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
// Close window on blur for popup-like behavior
if let Some(window) = app.get_webview_window("main") {
let w = window.clone();
window.on_window_event(move |event| {
@@ -132,7 +202,6 @@ pub fn run() {
});
}
// Start clipboard polling on a background thread
clipboard::start_polling(db_for_polling, paused_for_polling);
Ok(())
+11 -1
View File
@@ -17,7 +17,9 @@
"title": "maCopy",
"width": 420,
"height": 560,
"resizable": false,
"resizable": true,
"minWidth": 320,
"minHeight": 300,
"decorations": false,
"visible": false,
"alwaysOnTop": true,
@@ -39,6 +41,14 @@
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-is-visible",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-inner-size",
"core:window:allow-outer-size",
"core:window:allow-center",
"core:window:allow-current-monitor",
"core:window:allow-cursor-position",
"core:window:allow-start-dragging",
"core:event:default",
"core:event:allow-emit",
"core:event:allow-listen",