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>
13
src-tauri/Cargo.lock
generated
@ -1961,6 +1961,7 @@ dependencies = [
|
|||||||
"tauri-plugin-autostart",
|
"tauri-plugin-autostart",
|
||||||
"tauri-plugin-clipboard-manager",
|
"tauri-plugin-clipboard-manager",
|
||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -3728,9 +3729,21 @@ dependencies = [
|
|||||||
"mio",
|
"mio",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.18"
|
version = "0.7.18"
|
||||||
|
|||||||
@ -26,6 +26,7 @@ base64 = "0.22"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
png = "0.17"
|
png = "0.17"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
tokio = { version = "1", features = ["time", "macros"] }
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
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 crate::db::{ClipboardEntry, Database, Settings};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::State;
|
use tauri::{Manager, State};
|
||||||
|
|
||||||
pub struct DbState(pub Arc<Database>);
|
pub struct DbState(pub Arc<Database>);
|
||||||
pub struct PausedState(pub Arc<AtomicBool>);
|
pub struct PausedState(pub Arc<AtomicBool>);
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
|
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())
|
.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() {
|
if query.trim().is_empty() {
|
||||||
return get_entries(db, limit);
|
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())
|
.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) {
|
pub fn set_paused(paused: State<'_, PausedState>, value: bool) {
|
||||||
paused.0.store(value, Ordering::Relaxed);
|
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 launch_at_login: bool,
|
||||||
pub show_images: bool,
|
pub show_images: bool,
|
||||||
pub max_history: i64,
|
pub max_history: i64,
|
||||||
|
pub window_position: String,
|
||||||
|
pub window_width: i64,
|
||||||
|
pub window_height: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
@ -25,7 +28,10 @@ impl Default for Settings {
|
|||||||
Self {
|
Self {
|
||||||
launch_at_login: false,
|
launch_at_login: false,
|
||||||
show_images: true,
|
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)",
|
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||||
params!["max_history", defaults.max_history.to_string()],
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@ -255,7 +273,10 @@ impl Database {
|
|||||||
Ok(Settings {
|
Ok(Settings {
|
||||||
launch_at_login: get("launch_at_login", "false") == "true",
|
launch_at_login: get("launch_at_login", "false") == "true",
|
||||||
show_images: get("show_images", "true") == "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();
|
let s = db.get_settings().unwrap();
|
||||||
assert!(!s.launch_at_login);
|
assert!(!s.launch_at_login);
|
||||||
assert!(s.show_images);
|
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]
|
#[test]
|
||||||
fn double_init_is_idempotent() {
|
fn double_init_is_idempotent() {
|
||||||
let db = test_db();
|
let db = test_db();
|
||||||
db.init_tables().unwrap();
|
db.init_tables().unwrap();
|
||||||
assert_eq!(db.get_settings().unwrap().max_history, 500);
|
assert_eq!(db.get_settings().unwrap().max_history, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Insert & Get ────────────────────────────────────────────────
|
// ── Insert & Get ────────────────────────────────────────────────
|
||||||
|
|||||||
@ -14,13 +14,85 @@ use tauri::{
|
|||||||
use tauri_plugin_autostart::MacosLauncher;
|
use tauri_plugin_autostart::MacosLauncher;
|
||||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
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) {
|
fn toggle_window(app: &tauri::AppHandle) {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
if window.is_visible().unwrap_or(false) {
|
if window.is_visible().unwrap_or(false) {
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
} else {
|
} else {
|
||||||
let _ = window.show();
|
show_window(app);
|
||||||
let _ = window.set_focus();
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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::set_setting,
|
||||||
commands::get_paused,
|
commands::get_paused,
|
||||||
commands::set_paused,
|
commands::set_paused,
|
||||||
|
commands::save_window_size,
|
||||||
|
commands::paste_and_refocus,
|
||||||
])
|
])
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
// Register the global hotkey
|
|
||||||
let shortcut = Shortcut::new(
|
let shortcut = Shortcut::new(
|
||||||
Some(Modifiers::SUPER | Modifiers::SHIFT),
|
Some(Modifiers::SUPER | Modifiers::SHIFT),
|
||||||
Code::KeyV,
|
Code::KeyV,
|
||||||
);
|
);
|
||||||
app.global_shortcut().register(shortcut)?;
|
app.global_shortcut().register(shortcut)?;
|
||||||
|
|
||||||
// Build the tray menu
|
|
||||||
let show_i = MenuItem::with_id(app, "show", "Show maCopy", true, None::<&str>)?;
|
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 pause_i = CheckMenuItem::with_id(app, "pause", "Pause Monitoring", true, false, None::<&str>)?;
|
||||||
let sep = PredefinedMenuItem::separator(app)?;
|
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 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())
|
.icon(app.default_window_icon().unwrap().clone())
|
||||||
.menu(&menu)
|
.menu(&menu)
|
||||||
.show_menu_on_left_click(false)
|
.show_menu_on_left_click(false)
|
||||||
@ -98,7 +171,6 @@ pub fn run() {
|
|||||||
paused_clone.store(!current, std::sync::atomic::Ordering::Relaxed);
|
paused_clone.store(!current, std::sync::atomic::Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
"settings" => {
|
"settings" => {
|
||||||
// Emit an event the frontend listens for to open settings panel
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
@ -118,11 +190,9 @@ pub fn run() {
|
|||||||
})
|
})
|
||||||
.build(app)?;
|
.build(app)?;
|
||||||
|
|
||||||
// Hide from dock on macOS — app is menu-bar only
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||||||
|
|
||||||
// Close window on blur for popup-like behavior
|
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let w = window.clone();
|
let w = window.clone();
|
||||||
window.on_window_event(move |event| {
|
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);
|
clipboard::start_polling(db_for_polling, paused_for_polling);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@ -17,7 +17,9 @@
|
|||||||
"title": "maCopy",
|
"title": "maCopy",
|
||||||
"width": 420,
|
"width": 420,
|
||||||
"height": 560,
|
"height": 560,
|
||||||
"resizable": false,
|
"resizable": true,
|
||||||
|
"minWidth": 320,
|
||||||
|
"minHeight": 300,
|
||||||
"decorations": false,
|
"decorations": false,
|
||||||
"visible": false,
|
"visible": false,
|
||||||
"alwaysOnTop": true,
|
"alwaysOnTop": true,
|
||||||
@ -39,6 +41,14 @@
|
|||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-is-visible",
|
"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:default",
|
||||||
"core:event:allow-emit",
|
"core:event:allow-emit",
|
||||||
"core:event:allow-listen",
|
"core:event:allow-listen",
|
||||||
|
|||||||
193
src/App.tsx
@ -14,7 +14,9 @@ export default function App() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [settings, setSettings] = useState<Settings | null>(null);
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
|
const [anchorIndex, setAnchorIndex] = useState(0);
|
||||||
|
const [focusIndex, setFocusIndex] = useState(0);
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
const [contextMenu, setContextMenu] = useState<{
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@ -22,12 +24,14 @@ export default function App() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval>>();
|
const pollRef = useRef<ReturnType<typeof setInterval>>();
|
||||||
|
const resizeTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
const loadEntries = useCallback(async () => {
|
const loadEntries = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
|
const limit = settings?.max_history ?? 10000;
|
||||||
const data: ClipboardEntry[] = query.trim()
|
const data: ClipboardEntry[] = query.trim()
|
||||||
? await invoke("search_entries", { query, limit: settings?.max_history ?? 500 })
|
? await invoke("search_entries", { query, limit })
|
||||||
: await invoke("get_entries", { limit: settings?.max_history ?? 500 });
|
: await invoke("get_entries", { limit });
|
||||||
setEntries(data);
|
setEntries(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load entries:", e);
|
console.error("Failed to load entries:", e);
|
||||||
@ -47,14 +51,12 @@ export default function App() {
|
|||||||
loadSettings();
|
loadSettings();
|
||||||
}, [loadSettings]);
|
}, [loadSettings]);
|
||||||
|
|
||||||
// Poll for new entries while window is visible
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEntries();
|
loadEntries();
|
||||||
pollRef.current = setInterval(loadEntries, 1000);
|
pollRef.current = setInterval(loadEntries, 1000);
|
||||||
return () => clearInterval(pollRef.current);
|
return () => clearInterval(pollRef.current);
|
||||||
}, [loadEntries]);
|
}, [loadEntries]);
|
||||||
|
|
||||||
// Listen for the tray "Settings…" menu click
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unlisten = listen("open-settings", () => setShowSettings(true));
|
const unlisten = listen("open-settings", () => setShowSettings(true));
|
||||||
return () => {
|
return () => {
|
||||||
@ -62,23 +64,51 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const copyAndClose = useCallback(async (entry: ClipboardEntry) => {
|
// Persist window size on resize (debounced)
|
||||||
try {
|
useEffect(() => {
|
||||||
if (entry.content_type === "image") {
|
const handleResize = () => {
|
||||||
// For images stored as data URIs, copy the raw base64 URI text
|
clearTimeout(resizeTimer.current);
|
||||||
await writeText(entry.content);
|
resizeTimer.current = setTimeout(async () => {
|
||||||
} else {
|
const win = getCurrentWindow();
|
||||||
await writeText(entry.content);
|
const size = await win.innerSize();
|
||||||
}
|
const scaleFactor = await win.scaleFactor();
|
||||||
await getCurrentWindow().hide();
|
const w = Math.round(size.width / scaleFactor);
|
||||||
} catch (e) {
|
const h = Math.round(size.height / scaleFactor);
|
||||||
console.error("Copy failed:", e);
|
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(
|
const handleDelete = useCallback(
|
||||||
async (id: number) => {
|
async (ids: number[]) => {
|
||||||
|
for (const id of ids) {
|
||||||
await invoke("delete_entry", { id });
|
await invoke("delete_entry", { id });
|
||||||
|
}
|
||||||
|
setSelectedIds(new Set());
|
||||||
loadEntries();
|
loadEntries();
|
||||||
},
|
},
|
||||||
[loadEntries]
|
[loadEntries]
|
||||||
@ -92,37 +122,96 @@ export default function App() {
|
|||||||
[loadEntries]
|
[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<number>();
|
||||||
|
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
|
// Keyboard navigation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Cmd+1 through Cmd+9 for quick paste
|
// Cmd+1-9 quick paste
|
||||||
if (e.metaKey && e.key >= "1" && e.key <= "9") {
|
if (e.metaKey && !e.shiftKey && e.key >= "1" && e.key <= "9") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const idx = parseInt(e.key) - 1;
|
const idx = parseInt(e.key) - 1;
|
||||||
if (idx < entries.length) {
|
if (idx < entries.length) pasteSingle(entries[idx]);
|
||||||
copyAndClose(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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
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") {
|
} else if (e.key === "ArrowUp") {
|
||||||
e.preventDefault();
|
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") {
|
} else if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
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") {
|
} else if (e.key === "Delete" || e.key === "Backspace") {
|
||||||
// Only handle Delete/Backspace when search is not focused
|
|
||||||
if (document.activeElement?.tagName !== "INPUT") {
|
if (document.activeElement?.tagName !== "INPUT") {
|
||||||
e.preventDefault();
|
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") {
|
} else if (e.key === "Escape") {
|
||||||
if (showSettings) {
|
if (showSettings) {
|
||||||
setShowSettings(false);
|
setShowSettings(false);
|
||||||
|
} else if (selectedIds.size > 1) {
|
||||||
|
selectOnly(focusIndex, entries);
|
||||||
} else {
|
} else {
|
||||||
getCurrentWindow().hide();
|
getCurrentWindow().hide();
|
||||||
}
|
}
|
||||||
@ -131,17 +220,21 @@ export default function App() {
|
|||||||
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
return () => window.removeEventListener("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(() => {
|
useEffect(() => {
|
||||||
const close = () => setContextMenu(null);
|
const close = () => setContextMenu(null);
|
||||||
window.addEventListener("click", close);
|
window.addEventListener("click", close);
|
||||||
return () => window.removeEventListener("click", close);
|
return () => window.removeEventListener("click", close);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const selectedCount = selectedIds.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen w-screen bg-surface rounded-xl border border-border overflow-hidden shadow-2xl">
|
<div className="flex flex-col h-screen w-screen bg-surface overflow-hidden">
|
||||||
{showSettings && settings ? (
|
{showSettings && settings ? (
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
settings={settings}
|
settings={settings}
|
||||||
@ -153,35 +246,44 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Drag handle + search */}
|
<div className="shrink-0 p-2 pb-0" data-tauri-drag-region>
|
||||||
<div className="shrink-0 pt-2 px-3" data-tauri-drag-region>
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
setQuery(v);
|
setQuery(v);
|
||||||
setSelectedIndex(0);
|
setFocusIndex(0);
|
||||||
|
setAnchorIndex(0);
|
||||||
|
setSelectedIds(new Set());
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Entry list */}
|
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
<ClipboardList
|
<ClipboardList
|
||||||
entries={entries}
|
entries={entries}
|
||||||
selectedIndex={selectedIndex}
|
selectedIds={selectedIds}
|
||||||
|
focusIndex={focusIndex}
|
||||||
showImages={settings?.show_images ?? true}
|
showImages={settings?.show_images ?? true}
|
||||||
onSelect={copyAndClose}
|
onSelect={(entry) => pasteSingle(entry)}
|
||||||
|
onCtrlClick={(index) => toggleSelect(index, entries)}
|
||||||
|
onShiftClick={(index) => selectRange(anchorIndex, index, entries)}
|
||||||
|
onPlainClick={(index) => selectOnly(index, entries)}
|
||||||
onContextMenu={(e, entry) => {
|
onContextMenu={(e, entry) => {
|
||||||
e.preventDefault();
|
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 });
|
setContextMenu({ x: e.clientX, y: e.clientY, entry });
|
||||||
}}
|
}}
|
||||||
setSelectedIndex={setSelectedIndex}
|
setFocusIndex={setFocusIndex}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hint bar */}
|
<div className="shrink-0 px-3 py-1.5 border-t border-border text-[11px] text-text-secondary flex justify-between items-center">
|
||||||
<div className="shrink-0 px-3 py-1.5 border-t border-border text-[11px] text-text-secondary flex justify-between">
|
<span>
|
||||||
<span>⌘1-9 quick paste</span>
|
{selectedCount > 1 ? `${selectedCount} selected` : "⌘1-9 quick paste"}
|
||||||
|
</span>
|
||||||
<span>{entries.length} items</span>
|
<span>{entries.length} items</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@ -192,9 +294,16 @@ export default function App() {
|
|||||||
x={contextMenu.x}
|
x={contextMenu.x}
|
||||||
y={contextMenu.y}
|
y={contextMenu.y}
|
||||||
entry={contextMenu.entry}
|
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)}
|
onPin={() => handleTogglePin(contextMenu.entry.id)}
|
||||||
onDelete={() => handleDelete(contextMenu.entry.id)}
|
onDelete={() => {
|
||||||
|
const ids = selectedIds.size > 0 ? Array.from(selectedIds) : [contextMenu.entry.id];
|
||||||
|
handleDelete(ids);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,16 +7,20 @@ describe("ClipboardList", () => {
|
|||||||
beforeEach(() => resetIdCounter());
|
beforeEach(() => resetIdCounter());
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps = {
|
||||||
selectedIndex: 0,
|
selectedIds: new Set<number>(),
|
||||||
|
focusIndex: 0,
|
||||||
showImages: true,
|
showImages: true,
|
||||||
onSelect: vi.fn(),
|
onSelect: vi.fn(),
|
||||||
|
onCtrlClick: vi.fn(),
|
||||||
|
onShiftClick: vi.fn(),
|
||||||
|
onPlainClick: vi.fn(),
|
||||||
onContextMenu: vi.fn(),
|
onContextMenu: vi.fn(),
|
||||||
setSelectedIndex: vi.fn(),
|
setFocusIndex: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
it("shows empty state when no entries", () => {
|
it("shows empty state when no entries", () => {
|
||||||
render(<ClipboardList {...defaultProps} entries={[]} />);
|
render(<ClipboardList {...defaultProps} entries={[]} />);
|
||||||
expect(screen.getByText("No clipboard history yet")).toBeInTheDocument();
|
expect(screen.getByText("No clipboard history")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders text entries", () => {
|
it("renders text entries", () => {
|
||||||
@ -35,10 +39,10 @@ describe("ClipboardList", () => {
|
|||||||
it("shows pin indicator for pinned entries", () => {
|
it("shows pin indicator for pinned entries", () => {
|
||||||
const entries = [makeEntry({ pinned: true, content: "Pinned item" })];
|
const entries = [makeEntry({ pinned: true, content: "Pinned item" })];
|
||||||
render(<ClipboardList {...defaultProps} entries={entries} />);
|
render(<ClipboardList {...defaultProps} entries={entries} />);
|
||||||
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 onSelect = vi.fn();
|
||||||
const entries = [makeEntry({ content: "Click me" })];
|
const entries = [makeEntry({ content: "Click me" })];
|
||||||
render(<ClipboardList {...defaultProps} entries={entries} onSelect={onSelect} />);
|
render(<ClipboardList {...defaultProps} entries={entries} onSelect={onSelect} />);
|
||||||
@ -51,33 +55,25 @@ describe("ClipboardList", () => {
|
|||||||
const onContextMenu = vi.fn();
|
const onContextMenu = vi.fn();
|
||||||
const entries = [makeEntry({ content: "Right click me" })];
|
const entries = [makeEntry({ content: "Right click me" })];
|
||||||
render(
|
render(
|
||||||
<ClipboardList
|
<ClipboardList {...defaultProps} entries={entries} onContextMenu={onContextMenu} />
|
||||||
{...defaultProps}
|
|
||||||
entries={entries}
|
|
||||||
onContextMenu={onContextMenu}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.contextMenu(screen.getByText("Right click me"));
|
fireEvent.contextMenu(screen.getByText("Right click me"));
|
||||||
expect(onContextMenu).toHaveBeenCalled();
|
expect(onContextMenu).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updates selectedIndex on mouse enter", () => {
|
it("updates focusIndex on mouse enter", () => {
|
||||||
const setSelectedIndex = vi.fn();
|
const setFocusIndex = vi.fn();
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({ content: "First" }),
|
makeEntry({ content: "First" }),
|
||||||
makeEntry({ content: "Second" }),
|
makeEntry({ content: "Second" }),
|
||||||
];
|
];
|
||||||
render(
|
render(
|
||||||
<ClipboardList
|
<ClipboardList {...defaultProps} entries={entries} setFocusIndex={setFocusIndex} />
|
||||||
{...defaultProps}
|
|
||||||
entries={entries}
|
|
||||||
setSelectedIndex={setSelectedIndex}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
fireEvent.mouseEnter(screen.getByText("Second"));
|
fireEvent.mouseEnter(screen.getByText("Second"));
|
||||||
expect(setSelectedIndex).toHaveBeenCalledWith(1);
|
expect(setFocusIndex).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows cmd+N badges for first 9 entries", () => {
|
it("shows cmd+N badges for first 9 entries", () => {
|
||||||
@ -93,10 +89,7 @@ describe("ClipboardList", () => {
|
|||||||
|
|
||||||
it("renders image entries when showImages is true", () => {
|
it("renders image entries when showImages is true", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({
|
makeEntry({ content: "data:image/png;base64,abc", content_type: "image" }),
|
||||||
content: "data:image/png;base64,abc",
|
|
||||||
content_type: "image",
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
render(<ClipboardList {...defaultProps} entries={entries} showImages={true} />);
|
render(<ClipboardList {...defaultProps} entries={entries} showImages={true} />);
|
||||||
const img = screen.getByAltText("Clipboard image");
|
const img = screen.getByAltText("Clipboard image");
|
||||||
@ -106,21 +99,20 @@ describe("ClipboardList", () => {
|
|||||||
|
|
||||||
it("renders image entries as text when showImages is false", () => {
|
it("renders image entries as text when showImages is false", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({
|
makeEntry({ content: "data:image/png;base64,abc", content_type: "image" }),
|
||||||
content: "data:image/png;base64,abc",
|
|
||||||
content_type: "image",
|
|
||||||
}),
|
|
||||||
];
|
];
|
||||||
render(<ClipboardList {...defaultProps} entries={entries} showImages={false} />);
|
render(<ClipboardList {...defaultProps} entries={entries} showImages={false} />);
|
||||||
expect(screen.queryByAltText("Clipboard image")).not.toBeInTheDocument();
|
expect(screen.queryByAltText("Clipboard image")).not.toBeInTheDocument();
|
||||||
expect(screen.getByText("data:image/png;base64,abc")).toBeInTheDocument();
|
expect(screen.getByText("data:image/png;base64,abc")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows relative time for entries", () => {
|
it("highlights selected entries", () => {
|
||||||
const entries = [
|
const entries = [makeEntry({ content: "Selected" })];
|
||||||
makeEntry({ created_at: new Date().toISOString(), content: "Recent" }),
|
const selectedIds = new Set([entries[0].id]);
|
||||||
];
|
const { container } = render(
|
||||||
render(<ClipboardList {...defaultProps} entries={entries} />);
|
<ClipboardList {...defaultProps} entries={entries} selectedIds={selectedIds} />
|
||||||
expect(screen.getByText("just now")).toBeInTheDocument();
|
);
|
||||||
|
const row = container.querySelector(".divide-y > div");
|
||||||
|
expect(row?.className).toContain("bg-accent/10");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,42 +3,39 @@ import type { ClipboardEntry } from "../types";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
entries: ClipboardEntry[];
|
entries: ClipboardEntry[];
|
||||||
selectedIndex: number;
|
selectedIds: Set<number>;
|
||||||
|
focusIndex: number;
|
||||||
showImages: boolean;
|
showImages: boolean;
|
||||||
onSelect: (entry: ClipboardEntry) => void;
|
onSelect: (entry: ClipboardEntry) => void;
|
||||||
|
onCtrlClick: (index: number) => void;
|
||||||
|
onShiftClick: (index: number) => void;
|
||||||
|
onPlainClick: (index: number) => void;
|
||||||
onContextMenu: (e: React.MouseEvent, entry: ClipboardEntry) => void;
|
onContextMenu: (e: React.MouseEvent, entry: ClipboardEntry) => void;
|
||||||
setSelectedIndex: (i: number) => void;
|
setFocusIndex: (i: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
const seconds = Math.floor(
|
const seconds = Math.floor(
|
||||||
(Date.now() - new Date(dateStr).getTime()) / 1000
|
(Date.now() - new Date(dateStr).getTime()) / 1000
|
||||||
);
|
);
|
||||||
if (seconds < 60) return "just now";
|
if (seconds < 60) return "now";
|
||||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
||||||
return `${Math.floor(seconds / 86400)}d ago`;
|
return `${Math.floor(seconds / 86400)}d`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntryIcon({ type }: { type: string }) {
|
function TypeBadge({ type }: { type: string }) {
|
||||||
if (type === "image") {
|
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 (
|
return (
|
||||||
<svg className="w-4 h-4 text-text-secondary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<span className={`text-[9px] font-semibold tracking-wide ${color} uppercase`}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
{label}
|
||||||
</svg>
|
</span>
|
||||||
);
|
|
||||||
}
|
|
||||||
if (type === "file") {
|
|
||||||
return (
|
|
||||||
<svg className="w-4 h-4 text-text-secondary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<svg className="w-4 h-4 text-text-secondary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
||||||
</svg>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,18 +51,18 @@ function EntryPreview({
|
|||||||
<img
|
<img
|
||||||
src={entry.content}
|
src={entry.content}
|
||||||
alt="Clipboard image"
|
alt="Clipboard image"
|
||||||
className="max-h-16 max-w-full rounded object-contain mt-1"
|
className="max-h-12 max-w-full rounded object-contain"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const display =
|
const display =
|
||||||
entry.content.length > 200
|
entry.content.length > 160
|
||||||
? entry.content.slice(0, 200) + "…"
|
? entry.content.slice(0, 160) + "…"
|
||||||
: entry.content;
|
: entry.content;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="text-sm text-text-primary line-clamp-2 break-all">
|
<span className="text-[13px] leading-snug text-text-primary line-clamp-2 break-all">
|
||||||
{display}
|
{display}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -73,66 +70,87 @@ function EntryPreview({
|
|||||||
|
|
||||||
export default function ClipboardList({
|
export default function ClipboardList({
|
||||||
entries,
|
entries,
|
||||||
selectedIndex,
|
selectedIds,
|
||||||
|
focusIndex,
|
||||||
showImages,
|
showImages,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onCtrlClick,
|
||||||
|
onShiftClick,
|
||||||
|
onPlainClick,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
setSelectedIndex,
|
setFocusIndex,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Scroll selected entry into view
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = listRef.current?.children[selectedIndex] as HTMLElement | undefined;
|
const el = listRef.current?.children[focusIndex] as HTMLElement | undefined;
|
||||||
el?.scrollIntoView?.({ block: "nearest" });
|
el?.scrollIntoView?.({ block: "nearest" });
|
||||||
}, [selectedIndex]);
|
}, [focusIndex]);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-full text-text-secondary text-sm">
|
<div className="flex flex-col items-center justify-center h-full text-text-secondary gap-1 py-12">
|
||||||
No clipboard history yet
|
<svg className="w-8 h-8 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">No clipboard history</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={listRef} className="py-1">
|
<div ref={listRef} className="divide-y divide-border">
|
||||||
{entries.map((entry, i) => (
|
{entries.map((entry, i) => {
|
||||||
|
const isSelected = selectedIds.has(entry.id);
|
||||||
|
const isFocused = i === focusIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className={`group flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors
|
className={`flex items-start gap-2 px-3 py-2 cursor-pointer transition-colors
|
||||||
${i === selectedIndex ? "bg-surface-active" : "hover:bg-surface-hover"}`}
|
${isSelected ? "bg-accent/10" : ""}
|
||||||
onClick={() => onSelect(entry)}
|
${isFocused && !isSelected ? "bg-surface-hover" : ""}
|
||||||
|
${!isSelected && !isFocused ? "hover:bg-surface-hover" : ""}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.metaKey || e.ctrlKey) {
|
||||||
|
onCtrlClick(i);
|
||||||
|
} else if (e.shiftKey) {
|
||||||
|
onShiftClick(i);
|
||||||
|
} else {
|
||||||
|
onSelect(entry);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onContextMenu={(e) => onContextMenu(e, entry)}
|
onContextMenu={(e) => onContextMenu(e, entry)}
|
||||||
onMouseEnter={() => setSelectedIndex(i)}
|
onMouseEnter={() => setFocusIndex(i)}
|
||||||
>
|
>
|
||||||
{/* Number badge for first 9 items */}
|
{/* Left column: shortcut badge or type */}
|
||||||
<div className="w-5 text-center shrink-0 mt-0.5">
|
<div className="w-6 text-center shrink-0 pt-0.5">
|
||||||
{i < 9 ? (
|
{i < 9 ? (
|
||||||
<span className="text-[10px] font-medium text-text-secondary bg-surface-hover rounded px-1 py-0.5">
|
<span className="text-[10px] font-mono text-text-secondary opacity-60">
|
||||||
⌘{i + 1}
|
⌘{i + 1}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<EntryIcon type={entry.content_type} />
|
<TypeBadge type={entry.content_type} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<EntryPreview entry={entry} showImages={showImages} />
|
<EntryPreview entry={entry} showImages={showImages} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0 flex items-center gap-1.5 mt-0.5">
|
{/* Right column: meta */}
|
||||||
|
<div className="shrink-0 flex flex-col items-end gap-0.5 pt-0.5">
|
||||||
{entry.pinned && (
|
{entry.pinned && (
|
||||||
<svg className="w-3.5 h-3.5 text-pin" fill="currentColor" viewBox="0 0 20 20">
|
<span className="text-[9px] font-semibold text-pin tracking-wide">PIN</span>
|
||||||
<path d="M9.828.722a.5.5 0 01.354.146l4.95 4.95a.5.5 0 01-.707.707l-.71-.71-2.828 2.828.707 5.657a.5.5 0 01-.854.39L7.5 11.45l-3.24 3.24a.5.5 0 01-.707-.707l3.24-3.24L3.56 7.5a.5.5 0 01.39-.854l5.657.707 2.828-2.828-.71-.71a.5.5 0 01.103-.611z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
<span className="text-[10px] text-text-secondary whitespace-nowrap">
|
<span className="text-[10px] text-text-secondary tabular-nums">
|
||||||
{timeAgo(entry.created_at)}
|
{timeAgo(entry.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,14 +8,15 @@ describe("ContextMenu", () => {
|
|||||||
x: 100,
|
x: 100,
|
||||||
y: 100,
|
y: 100,
|
||||||
entry: makeEntry(),
|
entry: makeEntry(),
|
||||||
|
selectedCount: 1,
|
||||||
onCopy: vi.fn(),
|
onCopy: vi.fn(),
|
||||||
onPin: vi.fn(),
|
onPin: vi.fn(),
|
||||||
onDelete: vi.fn(),
|
onDelete: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
it("renders Copy, Pin, and Delete actions", () => {
|
it("renders Paste, Pin, and Delete actions", () => {
|
||||||
render(<ContextMenu {...defaultProps} />);
|
render(<ContextMenu {...defaultProps} />);
|
||||||
expect(screen.getByText("Copy")).toBeInTheDocument();
|
expect(screen.getByText("Paste")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Pin")).toBeInTheDocument();
|
expect(screen.getByText("Pin")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Delete")).toBeInTheDocument();
|
expect(screen.getByText("Delete")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -26,10 +27,16 @@ describe("ContextMenu", () => {
|
|||||||
expect(screen.getByText("Unpin")).toBeInTheDocument();
|
expect(screen.getByText("Unpin")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onCopy when Copy is clicked", () => {
|
it("shows multi-select labels when count > 1", () => {
|
||||||
|
render(<ContextMenu {...defaultProps} selectedCount={3} />);
|
||||||
|
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();
|
const onCopy = vi.fn();
|
||||||
render(<ContextMenu {...defaultProps} onCopy={onCopy} />);
|
render(<ContextMenu {...defaultProps} onCopy={onCopy} />);
|
||||||
fireEvent.click(screen.getByText("Copy"));
|
fireEvent.click(screen.getByText("Paste"));
|
||||||
expect(onCopy).toHaveBeenCalledOnce();
|
expect(onCopy).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -4,40 +4,51 @@ interface Props {
|
|||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
entry: ClipboardEntry;
|
entry: ClipboardEntry;
|
||||||
|
selectedCount: number;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
onPin: () => void;
|
onPin: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContextMenu({ x, y, entry, onCopy, onPin, onDelete }: Props) {
|
export default function ContextMenu({
|
||||||
// Prevent the menu from going off-screen
|
x,
|
||||||
const adjustedX = Math.min(x, window.innerWidth - 160);
|
y,
|
||||||
const adjustedY = Math.min(y, window.innerHeight - 120);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl py-1 min-w-[150px]"
|
className="fixed z-50 bg-surface border border-border rounded-lg shadow-xl py-1 min-w-[160px] text-[13px]"
|
||||||
style={{ left: adjustedX, top: adjustedY }}
|
style={{ left: adjustedX, top: adjustedY }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
className="w-full text-left px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover transition-colors"
|
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||||
onClick={onCopy}
|
onClick={onCopy}
|
||||||
>
|
>
|
||||||
Copy
|
{multi ? `Paste ${selectedCount} items` : "Paste"}
|
||||||
</button>
|
</button>
|
||||||
|
{!multi && (
|
||||||
<button
|
<button
|
||||||
className="w-full text-left px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover transition-colors"
|
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||||
onClick={onPin}
|
onClick={onPin}
|
||||||
>
|
>
|
||||||
{entry.pinned ? "Unpin" : "Pin"}
|
{entry.pinned ? "Unpin" : "Pin"}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<div className="border-t border-border my-1" />
|
<div className="border-t border-border my-1" />
|
||||||
<button
|
<button
|
||||||
className="w-full text-left px-3 py-1.5 text-sm text-danger hover:bg-surface-hover transition-colors"
|
className="w-full text-left px-3 py-1.5 text-danger hover:bg-surface-hover transition-colors"
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
>
|
>
|
||||||
Delete
|
{multi ? `Delete ${selectedCount} items` : "Delete"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,9 +6,7 @@ import SearchBar from "./SearchBar";
|
|||||||
describe("SearchBar", () => {
|
describe("SearchBar", () => {
|
||||||
it("renders with placeholder text", () => {
|
it("renders with placeholder text", () => {
|
||||||
render(<SearchBar value="" onChange={() => {}} />);
|
render(<SearchBar value="" onChange={() => {}} />);
|
||||||
expect(
|
expect(screen.getByPlaceholderText("Search…")).toBeInTheDocument();
|
||||||
screen.getByPlaceholderText("Search clipboard history…")
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("displays the current value", () => {
|
it("displays the current value", () => {
|
||||||
@ -21,17 +19,16 @@ describe("SearchBar", () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
render(<SearchBar value="" onChange={onChange} />);
|
render(<SearchBar value="" onChange={onChange} />);
|
||||||
const input = screen.getByPlaceholderText("Search clipboard history…");
|
const input = screen.getByPlaceholderText("Search…");
|
||||||
|
|
||||||
await user.type(input, "test");
|
await user.type(input, "test");
|
||||||
expect(onChange).toHaveBeenCalledTimes(4); // one per character
|
expect(onChange).toHaveBeenCalledTimes(4);
|
||||||
expect(onChange).toHaveBeenLastCalledWith("t");
|
expect(onChange).toHaveBeenLastCalledWith("t");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto-focuses the input on mount", () => {
|
it("auto-focuses the input on mount", () => {
|
||||||
render(<SearchBar value="" onChange={() => {}} />);
|
render(<SearchBar value="" onChange={() => {}} />);
|
||||||
const input = screen.getByPlaceholderText("Search clipboard history…");
|
const input = screen.getByPlaceholderText("Search…");
|
||||||
// The focus happens via setTimeout, so we check the element exists
|
|
||||||
expect(input).toBeInTheDocument();
|
expect(input).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,7 +8,6 @@ interface Props {
|
|||||||
export default function SearchBar({ value, onChange }: Props) {
|
export default function SearchBar({ value, onChange }: Props) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Auto-focus search when window appears
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
setTimeout(() => inputRef.current?.focus(), 50);
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
@ -21,7 +20,7 @@ export default function SearchBar({ value, onChange }: Props) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<svg
|
<svg
|
||||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-secondary pointer-events-none"
|
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-secondary pointer-events-none"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -38,10 +37,10 @@ export default function SearchBar({ value, onChange }: Props) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder="Search clipboard history…"
|
placeholder="Search…"
|
||||||
className="w-full pl-9 pr-3 py-2 bg-surface-hover border border-border rounded-lg
|
className="w-full pl-8 pr-3 py-1.5 bg-surface-hover border border-border rounded-md
|
||||||
text-sm text-text-primary placeholder:text-text-secondary
|
text-[13px] text-text-primary placeholder:text-text-secondary
|
||||||
focus:outline-none focus:border-accent transition-colors"
|
focus:outline-none focus:border-accent/50 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -30,27 +30,34 @@ describe("SettingsPanel", () => {
|
|||||||
|
|
||||||
it("shows show images toggle", () => {
|
it("shows show images toggle", () => {
|
||||||
render(<SettingsPanel {...defaultProps} />);
|
render(<SettingsPanel {...defaultProps} />);
|
||||||
expect(screen.getByText("Show images in history")).toBeInTheDocument();
|
expect(screen.getByText("Show image previews")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows max history buttons", () => {
|
it("shows max history buttons", () => {
|
||||||
render(<SettingsPanel {...defaultProps} />);
|
render(<SettingsPanel {...defaultProps} />);
|
||||||
expect(screen.getByText("100")).toBeInTheDocument();
|
expect(screen.getByText("1K")).toBeInTheDocument();
|
||||||
expect(screen.getByText("500")).toBeInTheDocument();
|
expect(screen.getByText("5K")).toBeInTheDocument();
|
||||||
expect(screen.getByText("1000")).toBeInTheDocument();
|
expect(screen.getByText("10K")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("50K")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows window position buttons", () => {
|
||||||
|
render(<SettingsPanel {...defaultProps} />);
|
||||||
|
expect(screen.getByText("Near cursor")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Center")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Top right")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("highlights current max history value", () => {
|
it("highlights current max history value", () => {
|
||||||
const settings = makeSettings({ max_history: 1000 });
|
const settings = makeSettings({ max_history: 50000 });
|
||||||
render(<SettingsPanel {...defaultProps} settings={settings} />);
|
render(<SettingsPanel {...defaultProps} settings={settings} />);
|
||||||
const btn = screen.getByText("1000");
|
const btn = screen.getByText("50K");
|
||||||
expect(btn.className).toContain("bg-accent");
|
expect(btn.className).toContain("bg-accent");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onClose when close button clicked", () => {
|
it("calls onClose when close button clicked", () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
render(<SettingsPanel {...defaultProps} onClose={onClose} />);
|
render(<SettingsPanel {...defaultProps} onClose={onClose} />);
|
||||||
// The close button is the SVG button in the header
|
|
||||||
const closeBtn = screen.getByText("Settings").parentElement!.querySelector("button")!;
|
const closeBtn = screen.getByText("Settings").parentElement!.querySelector("button")!;
|
||||||
fireEvent.click(closeBtn);
|
fireEvent.click(closeBtn);
|
||||||
expect(onClose).toHaveBeenCalledOnce();
|
expect(onClose).toHaveBeenCalledOnce();
|
||||||
@ -61,7 +68,6 @@ describe("SettingsPanel", () => {
|
|||||||
|
|
||||||
render(<SettingsPanel {...defaultProps} />);
|
render(<SettingsPanel {...defaultProps} />);
|
||||||
const switches = screen.getAllByRole("switch");
|
const switches = screen.getAllByRole("switch");
|
||||||
// Second switch is "Show images"
|
|
||||||
fireEvent.click(switches[1]);
|
fireEvent.click(switches[1]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@ -73,15 +79,15 @@ describe("SettingsPanel", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("invokes set_setting when changing max_history", async () => {
|
it("invokes set_setting when changing max_history", async () => {
|
||||||
mockInvoke.mockResolvedValue(makeSettings({ max_history: 100 }));
|
mockInvoke.mockResolvedValue(makeSettings({ max_history: 1000 }));
|
||||||
|
|
||||||
render(<SettingsPanel {...defaultProps} />);
|
render(<SettingsPanel {...defaultProps} />);
|
||||||
fireEvent.click(screen.getByText("100"));
|
fireEvent.click(screen.getByText("1K"));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockInvoke).toHaveBeenCalledWith("set_setting", {
|
expect(mockInvoke).toHaveBeenCalledWith("set_setting", {
|
||||||
key: "max_history",
|
key: "max_history",
|
||||||
value: "100",
|
value: "1000",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,6 +8,17 @@ interface Props {
|
|||||||
onUpdate: (settings: Settings) => void;
|
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) {
|
export default function SettingsPanel({ settings, onClose, onUpdate }: Props) {
|
||||||
const updateSetting = useCallback(
|
const updateSetting = useCallback(
|
||||||
async (key: string, value: string) => {
|
async (key: string, value: string) => {
|
||||||
@ -26,63 +37,82 @@ export default function SettingsPanel({ settings, onClose, onUpdate }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Header */}
|
<div
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border" data-tauri-drag-region>
|
className="flex items-center justify-between px-4 py-3 border-b border-border"
|
||||||
<h2 className="text-base font-semibold text-text-primary">Settings</h2>
|
data-tauri-drag-region
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary tracking-wide uppercase">
|
||||||
|
Settings
|
||||||
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-text-secondary hover:text-text-primary transition-colors"
|
className="text-text-secondary hover:text-text-primary transition-colors p-0.5"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Settings body */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-5">
|
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-5">
|
||||||
{/* Launch at login */}
|
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Launch at login"
|
label="Launch at login"
|
||||||
checked={settings.launch_at_login}
|
checked={settings.launch_at_login}
|
||||||
onChange={(v) => updateSetting("launch_at_login", String(v))}
|
onChange={(v) => updateSetting("launch_at_login", String(v))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Show images */}
|
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Show images in history"
|
label="Show image previews"
|
||||||
checked={settings.show_images}
|
checked={settings.show_images}
|
||||||
onChange={(v) => updateSetting("show_images", String(v))}
|
onChange={(v) => updateSetting("show_images", String(v))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Max history */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
<label className="block text-[13px] font-medium text-text-primary mb-2">
|
||||||
Max history size
|
Max history
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{[100, 500, 1000].map((n) => (
|
{HISTORY_OPTIONS.map((n) => (
|
||||||
<button
|
<button
|
||||||
key={n}
|
key={n}
|
||||||
onClick={() => updateSetting("max_history", String(n))}
|
onClick={() => updateSetting("max_history", String(n))}
|
||||||
className={`flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors
|
className={`py-1.5 rounded text-[12px] font-medium transition-colors
|
||||||
${
|
${settings.max_history === n
|
||||||
settings.max_history === n
|
|
||||||
? "bg-accent text-white"
|
? "bg-accent text-white"
|
||||||
: "bg-surface-hover text-text-primary hover:bg-surface-active"
|
: "bg-surface-hover text-text-primary hover:bg-surface-active"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{n}
|
{n >= 1000 ? `${n / 1000}K` : n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[13px] font-medium text-text-primary mb-2">
|
||||||
|
Window position
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
{POSITION_OPTIONS.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => updateSetting("window_position", opt.value)}
|
||||||
|
className={`py-1.5 rounded text-[12px] font-medium transition-colors
|
||||||
|
${settings.window_position === opt.value
|
||||||
|
? "bg-accent text-white"
|
||||||
|
: "bg-surface-hover text-text-primary hover:bg-surface-active"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Danger zone */}
|
|
||||||
<div className="pt-3 border-t border-border">
|
<div className="pt-3 border-t border-border">
|
||||||
<button
|
<button
|
||||||
onClick={handleClearAll}
|
onClick={handleClearAll}
|
||||||
className="w-full py-2 rounded-lg text-sm font-medium text-white bg-danger hover:opacity-90 transition-opacity"
|
className="w-full py-2 rounded text-[13px] font-medium text-danger border border-danger/30 hover:bg-danger/10 transition-colors"
|
||||||
>
|
>
|
||||||
Clear All History
|
Clear All History
|
||||||
</button>
|
</button>
|
||||||
@ -103,17 +133,17 @@ function ToggleRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="flex items-center justify-between cursor-pointer">
|
<label className="flex items-center justify-between cursor-pointer">
|
||||||
<span className="text-sm text-text-primary">{label}</span>
|
<span className="text-[13px] text-text-primary">{label}</span>
|
||||||
<button
|
<button
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={checked}
|
aria-checked={checked}
|
||||||
onClick={() => onChange(!checked)}
|
onClick={() => onChange(!checked)}
|
||||||
className={`relative w-10 h-6 rounded-full transition-colors ${
|
className={`relative w-9 h-5 rounded-full transition-colors ${
|
||||||
checked ? "bg-accent" : "bg-surface-active"
|
checked ? "bg-accent" : "bg-surface-active"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
className={`absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
||||||
checked ? "translate-x-4" : ""
|
checked ? "translate-x-4" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-surface: light-dark(#ffffff, #1e1e2e);
|
--color-surface: light-dark(#ffffff, #1c1c2e);
|
||||||
--color-surface-hover: light-dark(#f5f5f5, #2a2a3c);
|
--color-surface-hover: light-dark(#f7f7f8, #252538);
|
||||||
--color-surface-active: light-dark(#e8e8e8, #363649);
|
--color-surface-active: light-dark(#ededf0, #2e2e44);
|
||||||
--color-border: light-dark(#e0e0e0, #3a3a4c);
|
--color-border: light-dark(#e4e4e8, #33334a);
|
||||||
--color-text-primary: light-dark(#1a1a2e, #e0e0f0);
|
--color-text-primary: light-dark(#18182b, #e2e2f0);
|
||||||
--color-text-secondary: light-dark(#6b7280, #9ca3af);
|
--color-text-secondary: light-dark(#71717a, #a1a1aa);
|
||||||
--color-accent: light-dark(#6366f1, #818cf8);
|
--color-accent: light-dark(#6366f1, #818cf8);
|
||||||
--color-accent-hover: light-dark(#4f46e5, #6366f1);
|
--color-accent-hover: light-dark(#4f46e5, #6366f1);
|
||||||
--color-pin: light-dark(#f59e0b, #fbbf24);
|
--color-pin: light-dark(#d97706, #fbbf24);
|
||||||
--color-danger: light-dark(#ef4444, #f87171);
|
--color-danger: light-dark(#dc2626, #f87171);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
@ -20,16 +20,16 @@ html {
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||||
background: transparent;
|
background: var(--color-surface);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 5px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@ -38,3 +38,16 @@ body {
|
|||||||
background: var(--color-border);
|
background: var(--color-border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Resize handle in bottom-right corner */
|
||||||
|
.resize-handle {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|||||||
@ -19,7 +19,10 @@ export function makeSettings(overrides: Partial<Settings> = {}): Settings {
|
|||||||
return {
|
return {
|
||||||
launch_at_login: false,
|
launch_at_login: false,
|
||||||
show_images: true,
|
show_images: true,
|
||||||
max_history: 500,
|
max_history: 10000,
|
||||||
|
window_position: "cursor",
|
||||||
|
window_width: 420,
|
||||||
|
window_height: 560,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,4 +11,7 @@ export interface Settings {
|
|||||||
launch_at_login: boolean;
|
launch_at_login: boolean;
|
||||||
show_images: boolean;
|
show_images: boolean;
|
||||||
max_history: number;
|
max_history: number;
|
||||||
|
window_position: "cursor" | "center" | "top-right" | "top-left" | "bottom-right" | "bottom-left";
|
||||||
|
window_width: number;
|
||||||
|
window_height: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,5 +18,6 @@
|
|||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"],
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
}
|
}
|
||||||
|
|||||||