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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ilia 2026-05-12 14:01:03 -04:00
parent b643f50d76
commit 55ee81fc6d
36 changed files with 580 additions and 244 deletions

13
src-tauri/Cargo.lock generated
View File

@ -1961,6 +1961,7 @@ dependencies = [
"tauri-plugin-autostart",
"tauri-plugin-clipboard-manager",
"tauri-plugin-global-shortcut",
"tokio",
]
[[package]]
@ -3728,9 +3729,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tokio-util"
version = "0.7.18"

View File

@ -26,6 +26,7 @@ base64 = "0.22"
log = "0.4"
png = "0.17"
dirs = "5"
tokio = { version = "1", features = ["time", "macros"] }
[features]
custom-protocol = ["tauri/custom-protocol"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 B

After

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 830 B

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 713 B

After

Width:  |  Height:  |  Size: 458 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 903 B

After

Width:  |  Height:  |  Size: 567 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 908 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 872 B

After

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -1,14 +1,14 @@
use crate::db::{ClipboardEntry, Database, Settings};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tauri::State;
use tauri::{Manager, State};
pub struct DbState(pub Arc<Database>);
pub struct PausedState(pub Arc<AtomicBool>);
#[tauri::command]
pub fn get_entries(db: State<'_, DbState>, limit: Option<i64>) -> Result<Vec<ClipboardEntry>, String> {
db.0.get_entries(limit.unwrap_or(500))
db.0.get_entries(limit.unwrap_or(10000))
.map_err(|e| e.to_string())
}
@ -17,7 +17,7 @@ pub fn search_entries(db: State<'_, DbState>, query: String, limit: Option<i64>)
if query.trim().is_empty() {
return get_entries(db, limit);
}
db.0.search_entries(&query, limit.unwrap_or(500))
db.0.search_entries(&query, limit.unwrap_or(10000))
.map_err(|e| e.to_string())
}
@ -55,3 +55,33 @@ pub fn get_paused(paused: State<'_, PausedState>) -> bool {
pub fn set_paused(paused: State<'_, PausedState>, value: bool) {
paused.0.store(value, Ordering::Relaxed);
}
#[tauri::command]
pub fn save_window_size(db: State<'_, DbState>, width: i64, height: i64) -> Result<(), String> {
db.0.set_setting("window_width", &width.to_string())
.map_err(|e| e.to_string())?;
db.0.set_setting("window_height", &height.to_string())
.map_err(|e| e.to_string())
}
/// Hide the window then simulate Cmd+V so the content pastes into the
/// previously-focused app. The 150ms delay gives macOS time to refocus.
#[tauri::command]
pub async fn paste_and_refocus(app: tauri::AppHandle) -> Result<(), String> {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
// Use AppleScript to press Cmd+V in the frontmost app
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("osascript")
.arg("-e")
.arg(r#"tell application "System Events" to keystroke "v" using command down"#)
.spawn();
}
Ok(())
}

View File

@ -18,6 +18,9 @@ pub struct Settings {
pub launch_at_login: bool,
pub show_images: bool,
pub max_history: i64,
pub window_position: String,
pub window_width: i64,
pub window_height: i64,
}
impl Default for Settings {
@ -25,7 +28,10 @@ impl Default for Settings {
Self {
launch_at_login: false,
show_images: true,
max_history: 500,
max_history: 10000,
window_position: "cursor".to_string(),
window_width: 420,
window_height: 560,
}
}
}
@ -122,6 +128,18 @@ impl Database {
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["max_history", defaults.max_history.to_string()],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["window_position", &defaults.window_position],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["window_width", defaults.window_width.to_string()],
)?;
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?1, ?2)",
params!["window_height", defaults.window_height.to_string()],
)?;
Ok(())
}
@ -255,7 +273,10 @@ impl Database {
Ok(Settings {
launch_at_login: get("launch_at_login", "false") == "true",
show_images: get("show_images", "true") == "true",
max_history: get("max_history", "500").parse().unwrap_or(500),
max_history: get("max_history", "10000").parse().unwrap_or(10000),
window_position: get("window_position", "cursor"),
window_width: get("window_width", "420").parse().unwrap_or(420),
window_height: get("window_height", "560").parse().unwrap_or(560),
})
}
@ -285,14 +306,17 @@ mod tests {
let s = db.get_settings().unwrap();
assert!(!s.launch_at_login);
assert!(s.show_images);
assert_eq!(s.max_history, 500);
assert_eq!(s.max_history, 10000);
assert_eq!(s.window_position, "cursor");
assert_eq!(s.window_width, 420);
assert_eq!(s.window_height, 560);
}
#[test]
fn double_init_is_idempotent() {
let db = test_db();
db.init_tables().unwrap();
assert_eq!(db.get_settings().unwrap().max_history, 500);
assert_eq!(db.get_settings().unwrap().max_history, 10000);
}
// ── Insert & Get ────────────────────────────────────────────────

View File

@ -14,13 +14,85 @@ use tauri::{
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
fn show_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
// Position window before showing based on settings
if let Some(db) = app.try_state::<DbState>() {
if let Ok(settings) = db.0.get_settings() {
position_window(&window, &settings.window_position);
let _ = window.set_size(tauri::LogicalSize::new(
settings.window_width as f64,
settings.window_height as f64,
));
}
}
let _ = window.show();
let _ = window.set_focus();
}
}
fn toggle_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
show_window(app);
}
}
}
fn position_window(window: &tauri::WebviewWindow, position: &str) {
match position {
"center" => {
let _ = window.center();
}
"top-right" => {
if let Ok(monitor) = window.current_monitor() {
if let Some(monitor) = monitor {
let size = monitor.size();
let scale = monitor.scale_factor();
let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560));
let x = (size.width as f64 / scale) - (win_size.width as f64 / scale) - 10.0;
let _ = window.set_position(tauri::LogicalPosition::new(x, 30.0));
}
}
}
"top-left" => {
let _ = window.set_position(tauri::LogicalPosition::new(10.0, 30.0));
}
"bottom-right" => {
if let Ok(monitor) = window.current_monitor() {
if let Some(monitor) = monitor {
let size = monitor.size();
let scale = monitor.scale_factor();
let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560));
let x = (size.width as f64 / scale) - (win_size.width as f64 / scale) - 10.0;
let y = (size.height as f64 / scale) - (win_size.height as f64 / scale) - 10.0;
let _ = window.set_position(tauri::LogicalPosition::new(x, y));
}
}
}
"bottom-left" => {
if let Ok(monitor) = window.current_monitor() {
if let Some(monitor) = monitor {
let size = monitor.size();
let scale = monitor.scale_factor();
let win_size = window.outer_size().unwrap_or(tauri::PhysicalSize::new(420, 560));
let y = (size.height as f64 / scale) - (win_size.height as f64 / scale) - 10.0;
let _ = window.set_position(tauri::LogicalPosition::new(10.0, y));
}
}
}
// "cursor" or default — position near the mouse cursor
_ => {
if let Ok(cursor) = window.cursor_position() {
let _ = window.set_position(tauri::LogicalPosition::new(
cursor.x - 210.0,
cursor.y + 10.0,
));
} else {
let _ = window.center();
}
}
}
}
@ -66,16 +138,16 @@ pub fn run() {
commands::set_setting,
commands::get_paused,
commands::set_paused,
commands::save_window_size,
commands::paste_and_refocus,
])
.setup(move |app| {
// Register the global hotkey
let shortcut = Shortcut::new(
Some(Modifiers::SUPER | Modifiers::SHIFT),
Code::KeyV,
);
app.global_shortcut().register(shortcut)?;
// Build the tray menu
let show_i = MenuItem::with_id(app, "show", "Show maCopy", true, None::<&str>)?;
let pause_i = CheckMenuItem::with_id(app, "pause", "Pause Monitoring", true, false, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
@ -84,7 +156,8 @@ pub fn run() {
let menu = Menu::with_items(app, &[&show_i, &pause_i, &sep, &settings_i, &quit_i])?;
let _tray = TrayIconBuilder::new()
// Use with_id to prevent duplicate tray icons across hot-reloads
let _tray = TrayIconBuilder::with_id("macopy-tray")
.icon(app.default_window_icon().unwrap().clone())
.menu(&menu)
.show_menu_on_left_click(false)
@ -98,7 +171,6 @@ pub fn run() {
paused_clone.store(!current, std::sync::atomic::Ordering::Relaxed);
}
"settings" => {
// Emit an event the frontend listens for to open settings panel
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
@ -118,11 +190,9 @@ pub fn run() {
})
.build(app)?;
// Hide from dock on macOS — app is menu-bar only
#[cfg(target_os = "macos")]
app.set_activation_policy(tauri::ActivationPolicy::Accessory);
// Close window on blur for popup-like behavior
if let Some(window) = app.get_webview_window("main") {
let w = window.clone();
window.on_window_event(move |event| {
@ -132,7 +202,6 @@ pub fn run() {
});
}
// Start clipboard polling on a background thread
clipboard::start_polling(db_for_polling, paused_for_polling);
Ok(())

View File

@ -17,7 +17,9 @@
"title": "maCopy",
"width": 420,
"height": 560,
"resizable": false,
"resizable": true,
"minWidth": 320,
"minHeight": 300,
"decorations": false,
"visible": false,
"alwaysOnTop": true,
@ -39,6 +41,14 @@
"core:window:allow-set-focus",
"core:window:allow-close",
"core:window:allow-is-visible",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-inner-size",
"core:window:allow-outer-size",
"core:window:allow-center",
"core:window:allow-current-monitor",
"core:window:allow-cursor-position",
"core:window:allow-start-dragging",
"core:event:default",
"core:event:allow-emit",
"core:event:allow-listen",

View File

@ -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) => {
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>

View File

@ -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");
});
});

View File

@ -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") {
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="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>
);
}
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 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={`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)}
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={() => setSelectedIndex(i)}
onMouseEnter={() => setFocusIndex(i)}
>
{/* Number badge for first 9 items */}
<div className="w-5 text-center shrink-0 mt-0.5">
{/* Left column: shortcut badge or type */}
<div className="w-6 text-center shrink-0 pt-0.5">
{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}
</span>
) : (
<EntryIcon type={entry.content_type} />
<TypeBadge type={entry.content_type} />
)}
</div>
{/* Content */}
<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">
{/* Right column: meta */}
<div className="shrink-0 flex flex-col items-end gap-0.5 pt-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-[9px] font-semibold text-pin tracking-wide">PIN</span>
)}
<span className="text-[10px] text-text-secondary whitespace-nowrap">
<span className="text-[10px] text-text-secondary tabular-nums">
{timeAgo(entry.created_at)}
</span>
</div>
</div>
))}
);
})}
</div>
);
}

View File

@ -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();
});

View File

@ -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
{multi ? `Paste ${selectedCount} items` : "Paste"}
</button>
{!multi && (
<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}
>
{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>
);

View File

@ -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();
});
});

View File

@ -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>
);

View File

@ -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",
});
});
});

View File

@ -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
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" : ""
}`}
/>

View File

@ -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;
}

View File

@ -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,
};
}

View File

@ -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;
}

View File

@ -18,5 +18,6 @@
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"],
"references": [{ "path": "./tsconfig.node.json" }]
}