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>
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 712 B After Width: | Height: | Size: 597 B |
|
Before Width: | Height: | Size: 830 B After Width: | Height: | Size: 804 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 713 B After Width: | Height: | Size: 458 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 567 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 908 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 890 B |
|
Before Width: | Height: | Size: 872 B After Width: | Height: | Size: 674 B |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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",
|
||||
|
||||