diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3739dff..e983c38 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -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" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 48285a1..9261dc6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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"] diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 7767e38..a230305 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e459c41..156c76e 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index fedf649..f5a4629 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/64x64.png b/src-tauri/icons/64x64.png index 38f62c2..f87e95d 100644 Binary files a/src-tauri/icons/64x64.png and b/src-tauri/icons/64x64.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index edfd3d6..44fcebb 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index beef24d..68b1beb 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index ec45636..809a57a 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index b38cd47..78c0ad8 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 0620b22..f1c7419 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index 4969186..f5ce32d 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index b688bda..1347dfa 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 2cae3db..9dfdab0 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index eaf6669..59e8910 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index d54fb8e..54f5773 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index ca923a5..3193631 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 8d80885..0b15a99 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 6e2ae0f..137254e 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 38c3a53..2993c60 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -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); pub struct PausedState(pub Arc); #[tauri::command] pub fn get_entries(db: State<'_, DbState>, limit: Option) -> Result, 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) 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(()) +} diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index c8c61ce..b238a8d 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -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 ──────────────────────────────────────────────── diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f9755a7..817cb35 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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::() { + 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(()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index de64e8a..7c7d9e7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/App.tsx b/src/App.tsx index 63d5a5e..e64ccc8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,7 +14,9 @@ export default function App() { const [query, setQuery] = useState(""); const [showSettings, setShowSettings] = useState(false); const [settings, setSettings] = useState(null); - const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [anchorIndex, setAnchorIndex] = useState(0); + const [focusIndex, setFocusIndex] = useState(0); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; @@ -22,12 +24,14 @@ export default function App() { } | null>(null); const pollRef = useRef>(); + const resizeTimer = useRef>(); const loadEntries = useCallback(async () => { try { + const limit = settings?.max_history ?? 10000; const data: ClipboardEntry[] = query.trim() - ? await invoke("search_entries", { query, limit: settings?.max_history ?? 500 }) - : await invoke("get_entries", { limit: settings?.max_history ?? 500 }); + ? await invoke("search_entries", { query, limit }) + : await invoke("get_entries", { limit }); setEntries(data); } catch (e) { console.error("Failed to load entries:", e); @@ -47,14 +51,12 @@ export default function App() { loadSettings(); }, [loadSettings]); - // Poll for new entries while window is visible useEffect(() => { loadEntries(); pollRef.current = setInterval(loadEntries, 1000); return () => clearInterval(pollRef.current); }, [loadEntries]); - // Listen for the tray "Settings…" menu click useEffect(() => { const unlisten = listen("open-settings", () => setShowSettings(true)); return () => { @@ -62,23 +64,51 @@ export default function App() { }; }, []); - const copyAndClose = useCallback(async (entry: ClipboardEntry) => { - try { - if (entry.content_type === "image") { - // For images stored as data URIs, copy the raw base64 URI text - await writeText(entry.content); - } else { - await writeText(entry.content); - } - await getCurrentWindow().hide(); - } catch (e) { - console.error("Copy failed:", e); - } + // Persist window size on resize (debounced) + useEffect(() => { + const handleResize = () => { + clearTimeout(resizeTimer.current); + resizeTimer.current = setTimeout(async () => { + const win = getCurrentWindow(); + const size = await win.innerSize(); + const scaleFactor = await win.scaleFactor(); + const w = Math.round(size.width / scaleFactor); + const h = Math.round(size.height / scaleFactor); + invoke("save_window_size", { width: w, height: h }); + }, 500); + }; + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); }, []); + // Copy selected entries and paste into previous app + const pasteEntries = useCallback( + async (entriesToPaste: ClipboardEntry[]) => { + if (entriesToPaste.length === 0) return; + try { + const combined = entriesToPaste + .map((e) => e.content) + .join("\n"); + await writeText(combined); + await invoke("paste_and_refocus"); + } catch (e) { + console.error("Paste failed:", e); + } + }, + [] + ); + + const pasteSingle = useCallback( + (entry: ClipboardEntry) => pasteEntries([entry]), + [pasteEntries] + ); + const handleDelete = useCallback( - async (id: number) => { - await invoke("delete_entry", { id }); + async (ids: number[]) => { + for (const id of ids) { + await invoke("delete_entry", { id }); + } + setSelectedIds(new Set()); loadEntries(); }, [loadEntries] @@ -92,37 +122,96 @@ export default function App() { [loadEntries] ); + // Selection helpers + const selectOnly = useCallback((index: number, entries: ClipboardEntry[]) => { + const e = entries[index]; + if (e) { + setSelectedIds(new Set([e.id])); + setAnchorIndex(index); + setFocusIndex(index); + } + }, []); + + const selectRange = useCallback( + (from: number, to: number, entries: ClipboardEntry[]) => { + const lo = Math.min(from, to); + const hi = Math.max(from, to); + const ids = new Set(); + for (let i = lo; i <= hi; i++) { + if (entries[i]) ids.add(entries[i].id); + } + setSelectedIds(ids); + setFocusIndex(to); + }, + [] + ); + + const toggleSelect = useCallback( + (index: number, entries: ClipboardEntry[]) => { + const e = entries[index]; + if (!e) return; + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(e.id)) next.delete(e.id); + else next.add(e.id); + return next; + }); + setAnchorIndex(index); + setFocusIndex(index); + }, + [] + ); + // Keyboard navigation useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Cmd+1 through Cmd+9 for quick paste - if (e.metaKey && e.key >= "1" && e.key <= "9") { + // Cmd+1-9 quick paste + if (e.metaKey && !e.shiftKey && e.key >= "1" && e.key <= "9") { e.preventDefault(); const idx = parseInt(e.key) - 1; - if (idx < entries.length) { - copyAndClose(entries[idx]); - } + if (idx < entries.length) pasteSingle(entries[idx]); + return; + } + + // Cmd+A select all + if (e.metaKey && e.key === "a" && document.activeElement?.tagName !== "INPUT") { + e.preventDefault(); + setSelectedIds(new Set(entries.map((e) => e.id))); return; } if (e.key === "ArrowDown") { e.preventDefault(); - setSelectedIndex((i) => Math.min(i + 1, entries.length - 1)); + const next = Math.min(focusIndex + 1, entries.length - 1); + if (e.shiftKey) { + selectRange(anchorIndex, next, entries); + } else { + selectOnly(next, entries); + } } else if (e.key === "ArrowUp") { e.preventDefault(); - setSelectedIndex((i) => Math.max(i - 1, 0)); + const next = Math.max(focusIndex - 1, 0); + if (e.shiftKey) { + selectRange(anchorIndex, next, entries); + } else { + selectOnly(next, entries); + } } else if (e.key === "Enter") { e.preventDefault(); - if (entries[selectedIndex]) copyAndClose(entries[selectedIndex]); + const selected = entries.filter((e) => selectedIds.has(e.id)); + if (selected.length > 0) pasteEntries(selected); + else if (entries[focusIndex]) pasteSingle(entries[focusIndex]); } else if (e.key === "Delete" || e.key === "Backspace") { - // Only handle Delete/Backspace when search is not focused if (document.activeElement?.tagName !== "INPUT") { e.preventDefault(); - if (entries[selectedIndex]) handleDelete(entries[selectedIndex].id); + const ids = Array.from(selectedIds); + if (ids.length > 0) handleDelete(ids); } } else if (e.key === "Escape") { if (showSettings) { setShowSettings(false); + } else if (selectedIds.size > 1) { + selectOnly(focusIndex, entries); } else { getCurrentWindow().hide(); } @@ -131,17 +220,21 @@ export default function App() { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [entries, selectedIndex, copyAndClose, handleDelete, showSettings]); + }, [ + entries, focusIndex, anchorIndex, selectedIds, showSettings, + pasteSingle, pasteEntries, handleDelete, selectOnly, selectRange, + ]); - // Close context menu on any click useEffect(() => { const close = () => setContextMenu(null); window.addEventListener("click", close); return () => window.removeEventListener("click", close); }, []); + const selectedCount = selectedIds.size; + return ( -
+
{showSettings && settings ? ( ) : ( <> - {/* Drag handle + search */} -
+
{ setQuery(v); - setSelectedIndex(0); + setFocusIndex(0); + setAnchorIndex(0); + setSelectedIds(new Set()); }} />
- {/* Entry list */}
pasteSingle(entry)} + onCtrlClick={(index) => toggleSelect(index, entries)} + onShiftClick={(index) => selectRange(anchorIndex, index, entries)} + onPlainClick={(index) => selectOnly(index, entries)} onContextMenu={(e, entry) => { e.preventDefault(); + if (!selectedIds.has(entry.id)) { + const idx = entries.findIndex((x) => x.id === entry.id); + selectOnly(idx, entries); + } setContextMenu({ x: e.clientX, y: e.clientY, entry }); }} - setSelectedIndex={setSelectedIndex} + setFocusIndex={setFocusIndex} />
- {/* Hint bar */} -
- ⌘1-9 quick paste +
+ + {selectedCount > 1 ? `${selectedCount} selected` : "⌘1-9 quick paste"} + {entries.length} items
@@ -192,9 +294,16 @@ export default function App() { x={contextMenu.x} y={contextMenu.y} entry={contextMenu.entry} - onCopy={() => copyAndClose(contextMenu.entry)} + selectedCount={selectedCount} + onCopy={() => { + const selected = entries.filter((e) => selectedIds.has(e.id)); + pasteEntries(selected.length > 0 ? selected : [contextMenu.entry]); + }} onPin={() => handleTogglePin(contextMenu.entry.id)} - onDelete={() => handleDelete(contextMenu.entry.id)} + onDelete={() => { + const ids = selectedIds.size > 0 ? Array.from(selectedIds) : [contextMenu.entry.id]; + handleDelete(ids); + }} /> )}
diff --git a/src/components/ClipboardList.test.tsx b/src/components/ClipboardList.test.tsx index 3f011c8..d932203 100644 --- a/src/components/ClipboardList.test.tsx +++ b/src/components/ClipboardList.test.tsx @@ -7,16 +7,20 @@ describe("ClipboardList", () => { beforeEach(() => resetIdCounter()); const defaultProps = { - selectedIndex: 0, + selectedIds: new Set(), + focusIndex: 0, showImages: true, onSelect: vi.fn(), + onCtrlClick: vi.fn(), + onShiftClick: vi.fn(), + onPlainClick: vi.fn(), onContextMenu: vi.fn(), - setSelectedIndex: vi.fn(), + setFocusIndex: vi.fn(), }; it("shows empty state when no entries", () => { render(); - expect(screen.getByText("No clipboard history yet")).toBeInTheDocument(); + expect(screen.getByText("No clipboard history")).toBeInTheDocument(); }); it("renders text entries", () => { @@ -35,10 +39,10 @@ describe("ClipboardList", () => { it("shows pin indicator for pinned entries", () => { const entries = [makeEntry({ pinned: true, content: "Pinned item" })]; render(); - expect(screen.getByText("Pinned item")).toBeInTheDocument(); + expect(screen.getByText("PIN")).toBeInTheDocument(); }); - it("calls onSelect when entry is clicked", () => { + it("calls onSelect when entry is clicked without modifiers", () => { const onSelect = vi.fn(); const entries = [makeEntry({ content: "Click me" })]; render(); @@ -51,33 +55,25 @@ describe("ClipboardList", () => { const onContextMenu = vi.fn(); const entries = [makeEntry({ content: "Right click me" })]; render( - + ); fireEvent.contextMenu(screen.getByText("Right click me")); expect(onContextMenu).toHaveBeenCalled(); }); - it("updates selectedIndex on mouse enter", () => { - const setSelectedIndex = vi.fn(); + it("updates focusIndex on mouse enter", () => { + const setFocusIndex = vi.fn(); const entries = [ makeEntry({ content: "First" }), makeEntry({ content: "Second" }), ]; render( - + ); fireEvent.mouseEnter(screen.getByText("Second")); - expect(setSelectedIndex).toHaveBeenCalledWith(1); + expect(setFocusIndex).toHaveBeenCalledWith(1); }); it("shows cmd+N badges for first 9 entries", () => { @@ -93,10 +89,7 @@ describe("ClipboardList", () => { it("renders image entries when showImages is true", () => { const entries = [ - makeEntry({ - content: "data:image/png;base64,abc", - content_type: "image", - }), + makeEntry({ content: "data:image/png;base64,abc", content_type: "image" }), ]; render(); const img = screen.getByAltText("Clipboard image"); @@ -106,21 +99,20 @@ describe("ClipboardList", () => { it("renders image entries as text when showImages is false", () => { const entries = [ - makeEntry({ - content: "data:image/png;base64,abc", - content_type: "image", - }), + makeEntry({ content: "data:image/png;base64,abc", content_type: "image" }), ]; render(); expect(screen.queryByAltText("Clipboard image")).not.toBeInTheDocument(); expect(screen.getByText("data:image/png;base64,abc")).toBeInTheDocument(); }); - it("shows relative time for entries", () => { - const entries = [ - makeEntry({ created_at: new Date().toISOString(), content: "Recent" }), - ]; - render(); - expect(screen.getByText("just now")).toBeInTheDocument(); + it("highlights selected entries", () => { + const entries = [makeEntry({ content: "Selected" })]; + const selectedIds = new Set([entries[0].id]); + const { container } = render( + + ); + const row = container.querySelector(".divide-y > div"); + expect(row?.className).toContain("bg-accent/10"); }); }); diff --git a/src/components/ClipboardList.tsx b/src/components/ClipboardList.tsx index f884e90..5a5e55c 100644 --- a/src/components/ClipboardList.tsx +++ b/src/components/ClipboardList.tsx @@ -3,42 +3,39 @@ import type { ClipboardEntry } from "../types"; interface Props { entries: ClipboardEntry[]; - selectedIndex: number; + selectedIds: Set; + focusIndex: number; showImages: boolean; onSelect: (entry: ClipboardEntry) => void; + onCtrlClick: (index: number) => void; + onShiftClick: (index: number) => void; + onPlainClick: (index: number) => void; onContextMenu: (e: React.MouseEvent, entry: ClipboardEntry) => void; - setSelectedIndex: (i: number) => void; + setFocusIndex: (i: number) => void; } function timeAgo(dateStr: string): string { const seconds = Math.floor( (Date.now() - new Date(dateStr).getTime()) / 1000 ); - if (seconds < 60) return "just now"; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; - return `${Math.floor(seconds / 86400)}d ago`; + if (seconds < 60) return "now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + return `${Math.floor(seconds / 86400)}d`; } -function EntryIcon({ type }: { type: string }) { - if (type === "image") { - return ( - - - - ); - } - if (type === "file") { - return ( - - - - ); - } +function TypeBadge({ type }: { type: string }) { + const label = type === "image" ? "IMG" : type === "file" ? "FILE" : "TXT"; + const color = + type === "image" + ? "text-purple-400" + : type === "file" + ? "text-orange-400" + : "text-text-secondary"; return ( - - - + + {label} + ); } @@ -54,18 +51,18 @@ function EntryPreview({ Clipboard image ); } const display = - entry.content.length > 200 - ? entry.content.slice(0, 200) + "…" + entry.content.length > 160 + ? entry.content.slice(0, 160) + "…" : entry.content; return ( - + {display} ); @@ -73,66 +70,87 @@ function EntryPreview({ export default function ClipboardList({ entries, - selectedIndex, + selectedIds, + focusIndex, showImages, onSelect, + onCtrlClick, + onShiftClick, + onPlainClick, onContextMenu, - setSelectedIndex, + setFocusIndex, }: Props) { const listRef = useRef(null); - // Scroll selected entry into view useEffect(() => { - const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined; + const el = listRef.current?.children[focusIndex] as HTMLElement | undefined; el?.scrollIntoView?.({ block: "nearest" }); - }, [selectedIndex]); + }, [focusIndex]); if (entries.length === 0) { return ( -
- No clipboard history yet +
+ + + + No clipboard history
); } return ( -
- {entries.map((entry, i) => ( -
onSelect(entry)} - onContextMenu={(e) => onContextMenu(e, entry)} - onMouseEnter={() => setSelectedIndex(i)} - > - {/* Number badge for first 9 items */} -
- {i < 9 ? ( - - ⌘{i + 1} +
+ {entries.map((entry, i) => { + const isSelected = selectedIds.has(entry.id); + const isFocused = i === focusIndex; + + return ( +
{ + if (e.metaKey || e.ctrlKey) { + onCtrlClick(i); + } else if (e.shiftKey) { + onShiftClick(i); + } else { + onSelect(entry); + } + }} + onContextMenu={(e) => onContextMenu(e, entry)} + onMouseEnter={() => setFocusIndex(i)} + > + {/* Left column: shortcut badge or type */} +
+ {i < 9 ? ( + + ⌘{i + 1} + + ) : ( + + )} +
+ + {/* Content */} +
+ +
+ + {/* Right column: meta */} +
+ {entry.pinned && ( + PIN + )} + + {timeAgo(entry.created_at)} - ) : ( - - )} +
- -
- -
- -
- {entry.pinned && ( - - - - )} - - {timeAgo(entry.created_at)} - -
-
- ))} + ); + })}
); } diff --git a/src/components/ContextMenu.test.tsx b/src/components/ContextMenu.test.tsx index c255903..61fc6ef 100644 --- a/src/components/ContextMenu.test.tsx +++ b/src/components/ContextMenu.test.tsx @@ -8,14 +8,15 @@ describe("ContextMenu", () => { x: 100, y: 100, entry: makeEntry(), + selectedCount: 1, onCopy: vi.fn(), onPin: vi.fn(), onDelete: vi.fn(), }; - it("renders Copy, Pin, and Delete actions", () => { + it("renders Paste, Pin, and Delete actions", () => { render(); - expect(screen.getByText("Copy")).toBeInTheDocument(); + expect(screen.getByText("Paste")).toBeInTheDocument(); expect(screen.getByText("Pin")).toBeInTheDocument(); expect(screen.getByText("Delete")).toBeInTheDocument(); }); @@ -26,10 +27,16 @@ describe("ContextMenu", () => { expect(screen.getByText("Unpin")).toBeInTheDocument(); }); - it("calls onCopy when Copy is clicked", () => { + it("shows multi-select labels when count > 1", () => { + render(); + expect(screen.getByText("Paste 3 items")).toBeInTheDocument(); + expect(screen.getByText("Delete 3 items")).toBeInTheDocument(); + }); + + it("calls onCopy when Paste is clicked", () => { const onCopy = vi.fn(); render(); - fireEvent.click(screen.getByText("Copy")); + fireEvent.click(screen.getByText("Paste")); expect(onCopy).toHaveBeenCalledOnce(); }); diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 650181c..f72830a 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -4,40 +4,51 @@ interface Props { x: number; y: number; entry: ClipboardEntry; + selectedCount: number; onCopy: () => void; onPin: () => void; onDelete: () => void; } -export default function ContextMenu({ x, y, entry, onCopy, onPin, onDelete }: Props) { - // Prevent the menu from going off-screen - const adjustedX = Math.min(x, window.innerWidth - 160); - const adjustedY = Math.min(y, window.innerHeight - 120); +export default function ContextMenu({ + x, + y, + entry, + selectedCount, + onCopy, + onPin, + onDelete, +}: Props) { + const adjustedX = Math.min(x, window.innerWidth - 170); + const adjustedY = Math.min(y, window.innerHeight - 130); + const multi = selectedCount > 1; return (
e.stopPropagation()} > - + {!multi && ( + + )}
); diff --git a/src/components/SearchBar.test.tsx b/src/components/SearchBar.test.tsx index 502fece..1eb5074 100644 --- a/src/components/SearchBar.test.tsx +++ b/src/components/SearchBar.test.tsx @@ -6,9 +6,7 @@ import SearchBar from "./SearchBar"; describe("SearchBar", () => { it("renders with placeholder text", () => { render( {}} />); - expect( - screen.getByPlaceholderText("Search clipboard history…") - ).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Search…")).toBeInTheDocument(); }); it("displays the current value", () => { @@ -21,17 +19,16 @@ describe("SearchBar", () => { const user = userEvent.setup(); render(); - const input = screen.getByPlaceholderText("Search clipboard history…"); + const input = screen.getByPlaceholderText("Search…"); await user.type(input, "test"); - expect(onChange).toHaveBeenCalledTimes(4); // one per character + expect(onChange).toHaveBeenCalledTimes(4); expect(onChange).toHaveBeenLastCalledWith("t"); }); it("auto-focuses the input on mount", () => { render( {}} />); - const input = screen.getByPlaceholderText("Search clipboard history…"); - // The focus happens via setTimeout, so we check the element exists + const input = screen.getByPlaceholderText("Search…"); expect(input).toBeInTheDocument(); }); }); diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index b58259a..7f81ac4 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -8,7 +8,6 @@ interface Props { export default function SearchBar({ value, onChange }: Props) { const inputRef = useRef(null); - // Auto-focus search when window appears useEffect(() => { const handleFocus = () => { setTimeout(() => inputRef.current?.focus(), 50); @@ -21,7 +20,7 @@ export default function SearchBar({ value, onChange }: Props) { return (
onChange(e.target.value)} - placeholder="Search clipboard history…" - className="w-full pl-9 pr-3 py-2 bg-surface-hover border border-border rounded-lg - text-sm text-text-primary placeholder:text-text-secondary - focus:outline-none focus:border-accent transition-colors" + placeholder="Search…" + className="w-full pl-8 pr-3 py-1.5 bg-surface-hover border border-border rounded-md + text-[13px] text-text-primary placeholder:text-text-secondary + focus:outline-none focus:border-accent/50 transition-colors" />
); diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 4168c7c..32477ce 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -30,27 +30,34 @@ describe("SettingsPanel", () => { it("shows show images toggle", () => { render(); - expect(screen.getByText("Show images in history")).toBeInTheDocument(); + expect(screen.getByText("Show image previews")).toBeInTheDocument(); }); it("shows max history buttons", () => { render(); - expect(screen.getByText("100")).toBeInTheDocument(); - expect(screen.getByText("500")).toBeInTheDocument(); - expect(screen.getByText("1000")).toBeInTheDocument(); + expect(screen.getByText("1K")).toBeInTheDocument(); + expect(screen.getByText("5K")).toBeInTheDocument(); + expect(screen.getByText("10K")).toBeInTheDocument(); + expect(screen.getByText("50K")).toBeInTheDocument(); + }); + + it("shows window position buttons", () => { + render(); + expect(screen.getByText("Near cursor")).toBeInTheDocument(); + expect(screen.getByText("Center")).toBeInTheDocument(); + expect(screen.getByText("Top right")).toBeInTheDocument(); }); it("highlights current max history value", () => { - const settings = makeSettings({ max_history: 1000 }); + const settings = makeSettings({ max_history: 50000 }); render(); - const btn = screen.getByText("1000"); + const btn = screen.getByText("50K"); expect(btn.className).toContain("bg-accent"); }); it("calls onClose when close button clicked", () => { const onClose = vi.fn(); render(); - // The close button is the SVG button in the header const closeBtn = screen.getByText("Settings").parentElement!.querySelector("button")!; fireEvent.click(closeBtn); expect(onClose).toHaveBeenCalledOnce(); @@ -61,7 +68,6 @@ describe("SettingsPanel", () => { render(); const switches = screen.getAllByRole("switch"); - // Second switch is "Show images" fireEvent.click(switches[1]); await waitFor(() => { @@ -73,15 +79,15 @@ describe("SettingsPanel", () => { }); it("invokes set_setting when changing max_history", async () => { - mockInvoke.mockResolvedValue(makeSettings({ max_history: 100 })); + mockInvoke.mockResolvedValue(makeSettings({ max_history: 1000 })); render(); - fireEvent.click(screen.getByText("100")); + fireEvent.click(screen.getByText("1K")); await waitFor(() => { expect(mockInvoke).toHaveBeenCalledWith("set_setting", { key: "max_history", - value: "100", + value: "1000", }); }); }); diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 58a0227..c1dc315 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -8,6 +8,17 @@ interface Props { onUpdate: (settings: Settings) => void; } +const HISTORY_OPTIONS = [1000, 5000, 10000, 50000]; + +const POSITION_OPTIONS: { value: string; label: string }[] = [ + { value: "cursor", label: "Near cursor" }, + { value: "center", label: "Center" }, + { value: "top-right", label: "Top right" }, + { value: "top-left", label: "Top left" }, + { value: "bottom-right", label: "Bottom right" }, + { value: "bottom-left", label: "Bottom left" }, +]; + export default function SettingsPanel({ settings, onClose, onUpdate }: Props) { const updateSetting = useCallback( async (key: string, value: string) => { @@ -26,63 +37,82 @@ export default function SettingsPanel({ settings, onClose, onUpdate }: Props) { return (
- {/* Header */} -
-

Settings

+
+

+ Settings +

- {/* Settings body */}
- {/* Launch at login */} updateSetting("launch_at_login", String(v))} /> - {/* Show images */} updateSetting("show_images", String(v))} /> - {/* Max history */}
-