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-clipboard-manager",
|
||||
"tauri-plugin-global-shortcut",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3728,9 +3729,21 @@ dependencies = [
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
|
||||
@ -26,6 +26,7 @@ base64 = "0.22"
|
||||
log = "0.4"
|
||||
png = "0.17"
|
||||
dirs = "5"
|
||||
tokio = { version = "1", features = ["time", "macros"] }
|
||||
|
||||
[features]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 712 B After Width: | Height: | Size: 597 B |
|
Before Width: | Height: | Size: 830 B After Width: | Height: | Size: 804 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 713 B After Width: | Height: | Size: 458 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 567 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 908 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 890 B |
|
Before Width: | Height: | Size: 872 B After Width: | Height: | Size: 674 B |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3.0 KiB |
@ -1,14 +1,14 @@
|
||||
use crate::db::{ClipboardEntry, Database, Settings};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
use tauri::{Manager, State};
|
||||
|
||||
pub struct DbState(pub Arc<Database>);
|
||||
pub struct PausedState(pub Arc<AtomicBool>);
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
|
||||
db.0.get_entries(limit.unwrap_or(500))
|
||||
db.0.get_entries(limit.unwrap_or(10000))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@ -17,7 +17,7 @@ pub fn search_entries(db: State<'_, DbState>, query: String, limit: Option<i64>)
|
||||
if query.trim().is_empty() {
|
||||
return get_entries(db, limit);
|
||||
}
|
||||
db.0.search_entries(&query, limit.unwrap_or(500))
|
||||
db.0.search_entries(&query, limit.unwrap_or(10000))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@ -55,3 +55,33 @@ pub fn get_paused(paused: State<'_, PausedState>) -> bool {
|
||||
pub fn set_paused(paused: State<'_, PausedState>, value: bool) {
|
||||
paused.0.store(value, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Result<(), String> {
|
||||
db.0.set_setting("window_width", &width.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
db.0.set_setting("window_height", &height.to_string())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Hide the window then simulate Cmd+V so the content pastes into the
|
||||
/// previously-focused app. The 150ms delay gives macOS time to refocus.
|
||||
#[tauri::command]
|
||||
pub async fn paste_and_refocus(app: tauri::AppHandle) -> Result<(), String> {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||
|
||||
// Use AppleScript to press Cmd+V in the frontmost app
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = std::process::Command::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(r#"tell application "System Events" to keystroke "v" using command down"#)
|
||||
.spawn();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -18,6 +18,9 @@ pub struct Settings {
|
||||
pub launch_at_login: bool,
|
||||
pub show_images: bool,
|
||||
pub max_history: i64,
|
||||
pub window_position: String,
|
||||
pub window_width: i64,
|
||||
pub window_height: i64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
@ -25,7 +28,10 @@ impl Default for Settings {
|
||||
Self {
|
||||
launch_at_login: false,
|
||||
show_images: true,
|
||||
max_history: 500,
|
||||
max_history: 10000,
|
||||
window_position: "cursor".to_string(),
|
||||
window_width: 420,
|
||||
window_height: 560,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -122,6 +128,18 @@ impl Database {
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["max_history", defaults.max_history.to_string()],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["window_position", &defaults.window_position],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["window_width", defaults.window_width.to_string()],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["window_height", defaults.window_height.to_string()],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@ -255,7 +273,10 @@ impl Database {
|
||||
Ok(Settings {
|
||||
launch_at_login: get("launch_at_login", "false") == "true",
|
||||
show_images: get("show_images", "true") == "true",
|
||||
max_history: get("max_history", "500").parse().unwrap_or(500),
|
||||
max_history: get("max_history", "10000").parse().unwrap_or(10000),
|
||||
window_position: get("window_position", "cursor"),
|
||||
window_width: get("window_width", "420").parse().unwrap_or(420),
|
||||
window_height: get("window_height", "560").parse().unwrap_or(560),
|
||||
})
|
||||
}
|
||||
|
||||
@ -285,14 +306,17 @@ mod tests {
|
||||
let s = db.get_settings().unwrap();
|
||||
assert!(!s.launch_at_login);
|
||||
assert!(s.show_images);
|
||||
assert_eq!(s.max_history, 500);
|
||||
assert_eq!(s.max_history, 10000);
|
||||
assert_eq!(s.window_position, "cursor");
|
||||
assert_eq!(s.window_width, 420);
|
||||
assert_eq!(s.window_height, 560);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_init_is_idempotent() {
|
||||
let db = test_db();
|
||||
db.init_tables().unwrap();
|
||||
assert_eq!(db.get_settings().unwrap().max_history, 500);
|
||||
assert_eq!(db.get_settings().unwrap().max_history, 10000);
|
||||
}
|
||||
|
||||
// ── Insert & Get ────────────────────────────────────────────────
|
||||
|
||||
@ -14,13 +14,85 @@ use tauri::{
|
||||
use tauri_plugin_autostart::MacosLauncher;
|
||||
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
|
||||
|
||||
fn show_window(app: &tauri::AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
// Position window before showing based on settings
|
||||
if let Some(db) = app.try_state::<DbState>() {
|
||||
if let Ok(settings) = db.0.get_settings() {
|
||||
position_window(&window, &settings.window_position);
|
||||
let _ = window.set_size(tauri::LogicalSize::new(
|
||||
settings.window_width as f64,
|
||||
settings.window_height as f64,
|
||||
));
|
||||
}
|
||||
}
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
fn toggle_window(app: &tauri::AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
} else {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
show_window(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn position_window(window: &tauri::WebviewWindow, position: &str) {
|
||||
match position {
|
||||
"center" => {
|
||||
let _ = window.center();
|
||||
}
|
||||
"top-right" => {
|
||||
if let Ok(monitor) = window.current_monitor() {
|
||||
if let Some(monitor) = monitor {
|
||||
let size = monitor.size();
|
||||
let scale = monitor.scale_factor();
|
||||
let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560));
|
||||
let x = (size.width as f64 / scale) - (win_size.width as f64 / scale) - 10.0;
|
||||
let _ = window.set_position(tauri::LogicalPosition::new(x, 30.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
"top-left" => {
|
||||
let _ = window.set_position(tauri::LogicalPosition::new(10.0, 30.0));
|
||||
}
|
||||
"bottom-right" => {
|
||||
if let Ok(monitor) = window.current_monitor() {
|
||||
if let Some(monitor) = monitor {
|
||||
let size = monitor.size();
|
||||
let scale = monitor.scale_factor();
|
||||
let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560));
|
||||
let x = (size.width as f64 / scale) - (win_size.width as f64 / scale) - 10.0;
|
||||
let y = (size.height as f64 / scale) - (win_size.height as f64 / scale) - 10.0;
|
||||
let _ = window.set_position(tauri::LogicalPosition::new(x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
"bottom-left" => {
|
||||
if let Ok(monitor) = window.current_monitor() {
|
||||
if let Some(monitor) = monitor {
|
||||
let size = monitor.size();
|
||||
let scale = monitor.scale_factor();
|
||||
let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560));
|
||||
let y = (size.height as f64 / scale) - (win_size.height as f64 / scale) - 10.0;
|
||||
let _ = window.set_position(tauri::LogicalPosition::new(10.0, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
// "cursor" or default — position near the mouse cursor
|
||||
_ => {
|
||||
if let Ok(cursor) = window.cursor_position() {
|
||||
let _ = window.set_position(tauri::LogicalPosition::new(
|
||||
cursor.x - 210.0,
|
||||
cursor.y + 10.0,
|
||||
));
|
||||
} else {
|
||||
let _ = window.center();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -66,16 +138,16 @@ pub fn run() {
|
||||
commands::set_setting,
|
||||
commands::get_paused,
|
||||
commands::set_paused,
|
||||
commands::save_window_size,
|
||||
commands::paste_and_refocus,
|
||||
])
|
||||
.setup(move |app| {
|
||||
// Register the global hotkey
|
||||
let shortcut = Shortcut::new(
|
||||
Some(Modifiers::SUPER | Modifiers::SHIFT),
|
||||
Code::KeyV,
|
||||
);
|
||||
app.global_shortcut().register(shortcut)?;
|
||||
|
||||
// Build the tray menu
|
||||
let show_i = MenuItem::with_id(app, "show", "Show maCopy", true, None::<&str>)?;
|
||||
let pause_i = CheckMenuItem::with_id(app, "pause", "Pause Monitoring", true, false, None::<&str>)?;
|
||||
let sep = PredefinedMenuItem::separator(app)?;
|
||||
@ -84,7 +156,8 @@ pub fn run() {
|
||||
|
||||
let menu = Menu::with_items(app, &[&show_i, &pause_i, &sep, &settings_i, &quit_i])?;
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
// Use with_id to prevent duplicate tray icons across hot-reloads
|
||||
let _tray = TrayIconBuilder::with_id("macopy-tray")
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
@ -98,7 +171,6 @@ pub fn run() {
|
||||
paused_clone.store(!current, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
"settings" => {
|
||||
// Emit an event the frontend listens for to open settings panel
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
@ -118,11 +190,9 @@ pub fn run() {
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Hide from dock on macOS — app is menu-bar only
|
||||
#[cfg(target_os = "macos")]
|
||||
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
|
||||
|
||||
// Close window on blur for popup-like behavior
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let w = window.clone();
|
||||
window.on_window_event(move |event| {
|
||||
@ -132,7 +202,6 @@ pub fn run() {
|
||||
});
|
||||
}
|
||||
|
||||
// Start clipboard polling on a background thread
|
||||
clipboard::start_polling(db_for_polling, paused_for_polling);
|
||||
|
||||
Ok(())
|
||||
|
||||
@ -17,7 +17,9 @@
|
||||
"title": "maCopy",
|
||||
"width": 420,
|
||||
"height": 560,
|
||||
"resizable": false,
|
||||
"resizable": true,
|
||||
"minWidth": 320,
|
||||
"minHeight": 300,
|
||||
"decorations": false,
|
||||
"visible": false,
|
||||
"alwaysOnTop": true,
|
||||
@ -39,6 +41,14 @@
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-visible",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-inner-size",
|
||||
"core:window:allow-outer-size",
|
||||
"core:window:allow-center",
|
||||
"core:window:allow-current-monitor",
|
||||
"core:window:allow-cursor-position",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:event:default",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-listen",
|
||||
|
||||
197
src/App.tsx
@ -14,7 +14,9 @@ export default function App() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
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<{
|
||||
x: number;
|
||||
y: number;
|
||||
@ -22,12 +24,14 @@ export default function App() {
|
||||
} | null>(null);
|
||||
|
||||
const pollRef = useRef<ReturnType<typeof setInterval>>();
|
||||
const resizeTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
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<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
|
||||
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 (
|
||||
<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 ? (
|
||||
<SettingsPanel
|
||||
settings={settings}
|
||||
@ -153,35 +246,44 @@ export default function App() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Drag handle + search */}
|
||||
<div className="shrink-0 pt-2 px-3" data-tauri-drag-region>
|
||||
<div className="shrink-0 p-2 pb-0" data-tauri-drag-region>
|
||||
<SearchBar
|
||||
value={query}
|
||||
onChange={(v) => {
|
||||
setQuery(v);
|
||||
setSelectedIndex(0);
|
||||
setFocusIndex(0);
|
||||
setAnchorIndex(0);
|
||||
setSelectedIds(new Set());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Entry list */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<ClipboardList
|
||||
entries={entries}
|
||||
selectedIndex={selectedIndex}
|
||||
selectedIds={selectedIds}
|
||||
focusIndex={focusIndex}
|
||||
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) => {
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hint bar */}
|
||||
<div className="shrink-0 px-3 py-1.5 border-t border-border text-[11px] text-text-secondary flex justify-between">
|
||||
<span>⌘1-9 quick paste</span>
|
||||
<div className="shrink-0 px-3 py-1.5 border-t border-border text-[11px] text-text-secondary flex justify-between items-center">
|
||||
<span>
|
||||
{selectedCount > 1 ? `${selectedCount} selected` : "⌘1-9 quick paste"}
|
||||
</span>
|
||||
<span>{entries.length} items</span>
|
||||
</div>
|
||||
</>
|
||||
@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -7,16 +7,20 @@ describe("ClipboardList", () => {
|
||||
beforeEach(() => resetIdCounter());
|
||||
|
||||
const defaultProps = {
|
||||
selectedIndex: 0,
|
||||
selectedIds: new Set<number>(),
|
||||
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(<ClipboardList {...defaultProps} entries={[]} />);
|
||||
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(<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 entries = [makeEntry({ content: "Click me" })];
|
||||
render(<ClipboardList {...defaultProps} entries={entries} onSelect={onSelect} />);
|
||||
@ -51,33 +55,25 @@ describe("ClipboardList", () => {
|
||||
const onContextMenu = vi.fn();
|
||||
const entries = [makeEntry({ content: "Right click me" })];
|
||||
render(
|
||||
<ClipboardList
|
||||
{...defaultProps}
|
||||
entries={entries}
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
<ClipboardList {...defaultProps} entries={entries} onContextMenu={onContextMenu} />
|
||||
);
|
||||
|
||||
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(
|
||||
<ClipboardList
|
||||
{...defaultProps}
|
||||
entries={entries}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
/>
|
||||
<ClipboardList {...defaultProps} entries={entries} setFocusIndex={setFocusIndex} />
|
||||
);
|
||||
|
||||
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(<ClipboardList {...defaultProps} entries={entries} showImages={true} />);
|
||||
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(<ClipboardList {...defaultProps} entries={entries} showImages={false} />);
|
||||
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(<ClipboardList {...defaultProps} entries={entries} />);
|
||||
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(
|
||||
<ClipboardList {...defaultProps} entries={entries} selectedIds={selectedIds} />
|
||||
);
|
||||
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 {
|
||||
entries: ClipboardEntry[];
|
||||
selectedIndex: number;
|
||||
selectedIds: Set<number>;
|
||||
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 (
|
||||
<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="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" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<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>
|
||||
<span className={`text-[9px] font-semibold tracking-wide ${color} uppercase`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@ -54,18 +51,18 @@ function EntryPreview({
|
||||
<img
|
||||
src={entry.content}
|
||||
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 =
|
||||
entry.content.length > 200
|
||||
? entry.content.slice(0, 200) + "…"
|
||||
entry.content.length > 160
|
||||
? entry.content.slice(0, 160) + "…"
|
||||
: entry.content;
|
||||
|
||||
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}
|
||||
</span>
|
||||
);
|
||||
@ -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<HTMLDivElement>(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 (
|
||||
<div className="flex items-center justify-center h-full text-text-secondary text-sm">
|
||||
No clipboard history yet
|
||||
<div className="flex flex-col items-center justify-center h-full text-text-secondary gap-1 py-12">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={listRef} className="py-1">
|
||||
{entries.map((entry, i) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`group flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors
|
||||
${i === selectedIndex ? "bg-surface-active" : "hover:bg-surface-hover"}`}
|
||||
onClick={() => onSelect(entry)}
|
||||
onContextMenu={(e) => onContextMenu(e, entry)}
|
||||
onMouseEnter={() => setSelectedIndex(i)}
|
||||
>
|
||||
{/* Number badge for first 9 items */}
|
||||
<div className="w-5 text-center shrink-0 mt-0.5">
|
||||
{i < 9 ? (
|
||||
<span className="text-[10px] font-medium text-text-secondary bg-surface-hover rounded px-1 py-0.5">
|
||||
⌘{i + 1}
|
||||
<div ref={listRef} className="divide-y divide-border">
|
||||
{entries.map((entry, i) => {
|
||||
const isSelected = selectedIds.has(entry.id);
|
||||
const isFocused = i === focusIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className={`flex items-start gap-2 px-3 py-2 cursor-pointer transition-colors
|
||||
${isSelected ? "bg-accent/10" : ""}
|
||||
${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)}
|
||||
onMouseEnter={() => setFocusIndex(i)}
|
||||
>
|
||||
{/* Left column: shortcut badge or type */}
|
||||
<div className="w-6 text-center shrink-0 pt-0.5">
|
||||
{i < 9 ? (
|
||||
<span className="text-[10px] font-mono text-text-secondary opacity-60">
|
||||
⌘{i + 1}
|
||||
</span>
|
||||
) : (
|
||||
<TypeBadge type={entry.content_type} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<EntryPreview entry={entry} showImages={showImages} />
|
||||
</div>
|
||||
|
||||
{/* Right column: meta */}
|
||||
<div className="shrink-0 flex flex-col items-end gap-0.5 pt-0.5">
|
||||
{entry.pinned && (
|
||||
<span className="text-[9px] font-semibold text-pin tracking-wide">PIN</span>
|
||||
)}
|
||||
<span className="text-[10px] text-text-secondary tabular-nums">
|
||||
{timeAgo(entry.created_at)}
|
||||
</span>
|
||||
) : (
|
||||
<EntryIcon type={entry.content_type} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<EntryPreview entry={entry} showImages={showImages} />
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 flex items-center gap-1.5 mt-0.5">
|
||||
{entry.pinned && (
|
||||
<svg className="w-3.5 h-3.5 text-pin" fill="currentColor" viewBox="0 0 20 20">
|
||||
<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">
|
||||
{timeAgo(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(<ContextMenu {...defaultProps} />);
|
||||
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(<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();
|
||||
render(<ContextMenu {...defaultProps} onCopy={onCopy} />);
|
||||
fireEvent.click(screen.getByText("Copy"));
|
||||
fireEvent.click(screen.getByText("Paste"));
|
||||
expect(onCopy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@ -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 (
|
||||
<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 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover transition-colors"
|
||||
onClick={onPin}
|
||||
>
|
||||
{entry.pinned ? "Unpin" : "Pin"}
|
||||
{multi ? `Paste ${selectedCount} items` : "Paste"}
|
||||
</button>
|
||||
{!multi && (
|
||||
<button
|
||||
className="w-full text-left px-3 py-1.5 text-text-primary hover:bg-surface-hover transition-colors"
|
||||
onClick={onPin}
|
||||
>
|
||||
{entry.pinned ? "Unpin" : "Pin"}
|
||||
</button>
|
||||
)}
|
||||
<div className="border-t border-border my-1" />
|
||||
<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}
|
||||
>
|
||||
Delete
|
||||
{multi ? `Delete ${selectedCount} items` : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -6,9 +6,7 @@ import SearchBar from "./SearchBar";
|
||||
describe("SearchBar", () => {
|
||||
it("renders with placeholder text", () => {
|
||||
render(<SearchBar value="" onChange={() => {}} />);
|
||||
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(<SearchBar value="" onChange={onChange} />);
|
||||
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(<SearchBar value="" onChange={() => {}} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,7 +8,6 @@ interface Props {
|
||||
export default function SearchBar({ value, onChange }: Props) {
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="relative">
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@ -38,10 +37,10 @@ export default function SearchBar({ value, onChange }: Props) {
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -30,27 +30,34 @@ describe("SettingsPanel", () => {
|
||||
|
||||
it("shows show images toggle", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
expect(screen.getByText("Show images in history")).toBeInTheDocument();
|
||||
expect(screen.getByText("Show image previews")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows max history buttons", () => {
|
||||
render(<SettingsPanel {...defaultProps} />);
|
||||
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(<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", () => {
|
||||
const settings = makeSettings({ max_history: 1000 });
|
||||
const settings = makeSettings({ max_history: 50000 });
|
||||
render(<SettingsPanel {...defaultProps} settings={settings} />);
|
||||
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(<SettingsPanel {...defaultProps} onClose={onClose} />);
|
||||
// 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(<SettingsPanel {...defaultProps} />);
|
||||
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(<SettingsPanel {...defaultProps} />);
|
||||
fireEvent.click(screen.getByText("100"));
|
||||
fireEvent.click(screen.getByText("1K"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockInvoke).toHaveBeenCalledWith("set_setting", {
|
||||
key: "max_history",
|
||||
value: "100",
|
||||
value: "1000",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border" data-tauri-drag-region>
|
||||
<h2 className="text-base font-semibold text-text-primary">Settings</h2>
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b border-border"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-text-primary tracking-wide uppercase">
|
||||
Settings
|
||||
</h2>
|
||||
<button
|
||||
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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Settings body */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-5">
|
||||
{/* Launch at login */}
|
||||
<ToggleRow
|
||||
label="Launch at login"
|
||||
checked={settings.launch_at_login}
|
||||
onChange={(v) => updateSetting("launch_at_login", String(v))}
|
||||
/>
|
||||
|
||||
{/* Show images */}
|
||||
<ToggleRow
|
||||
label="Show images in history"
|
||||
label="Show image previews"
|
||||
checked={settings.show_images}
|
||||
onChange={(v) => updateSetting("show_images", String(v))}
|
||||
/>
|
||||
|
||||
{/* Max history */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
Max history size
|
||||
<label className="block text-[13px] font-medium text-text-primary mb-2">
|
||||
Max history
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{[100, 500, 1000].map((n) => (
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{HISTORY_OPTIONS.map((n) => (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => updateSetting("max_history", String(n))}
|
||||
className={`flex-1 py-1.5 rounded-lg text-sm font-medium transition-colors
|
||||
${
|
||||
settings.max_history === n
|
||||
? "bg-accent text-white"
|
||||
: "bg-surface-hover text-text-primary hover:bg-surface-active"
|
||||
className={`py-1.5 rounded text-[12px] font-medium transition-colors
|
||||
${settings.max_history === n
|
||||
? "bg-accent text-white"
|
||||
: "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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="pt-3 border-t border-border">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
@ -103,17 +133,17 @@ function ToggleRow({
|
||||
}) {
|
||||
return (
|
||||
<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
|
||||
role="switch"
|
||||
aria-checked={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"
|
||||
}`}
|
||||
>
|
||||
<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" : ""
|
||||
}`}
|
||||
/>
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-surface: light-dark(#ffffff, #1e1e2e);
|
||||
--color-surface-hover: light-dark(#f5f5f5, #2a2a3c);
|
||||
--color-surface-active: light-dark(#e8e8e8, #363649);
|
||||
--color-border: light-dark(#e0e0e0, #3a3a4c);
|
||||
--color-text-primary: light-dark(#1a1a2e, #e0e0f0);
|
||||
--color-text-secondary: light-dark(#6b7280, #9ca3af);
|
||||
--color-surface: light-dark(#ffffff, #1c1c2e);
|
||||
--color-surface-hover: light-dark(#f7f7f8, #252538);
|
||||
--color-surface-active: light-dark(#ededf0, #2e2e44);
|
||||
--color-border: light-dark(#e4e4e8, #33334a);
|
||||
--color-text-primary: light-dark(#18182b, #e2e2f0);
|
||||
--color-text-secondary: light-dark(#71717a, #a1a1aa);
|
||||
--color-accent: light-dark(#6366f1, #818cf8);
|
||||
--color-accent-hover: light-dark(#4f46e5, #6366f1);
|
||||
--color-pin: light-dark(#f59e0b, #fbbf24);
|
||||
--color-danger: light-dark(#ef4444, #f87171);
|
||||
--color-pin: light-dark(#d97706, #fbbf24);
|
||||
--color-danger: light-dark(#dc2626, #f87171);
|
||||
}
|
||||
|
||||
html {
|
||||
@ -20,16 +20,16 @@ html {
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: transparent;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
|
||||
background: var(--color-surface);
|
||||
overflow: hidden;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
width: 5px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
@ -38,3 +38,16 @@ body {
|
||||
background: var(--color-border);
|
||||
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 {
|
||||
launch_at_login: false,
|
||||
show_images: true,
|
||||
max_history: 500,
|
||||
max_history: 10000,
|
||||
window_position: "cursor",
|
||||
window_width: 420,
|
||||
window_height: 560,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@ -11,4 +11,7 @@ export interface Settings {
|
||||
launch_at_login: boolean;
|
||||
show_images: boolean;
|
||||
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
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||