import { useState, useEffect, useCallback, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; // clipboard write now happens in Rust's paste_and_refocus command import { getCurrentWindow } from "@tauri-apps/api/window"; import SearchBar from "./components/SearchBar"; import ClipboardList from "./components/ClipboardList"; import SettingsPanel from "./components/SettingsPanel"; import ContextMenu from "./components/ContextMenu"; import type { ClipboardEntry, Settings } from "./types"; export default function App() { const [entries, setEntries] = useState([]); const [query, setQuery] = useState(""); const [showSettings, setShowSettings] = useState(false); const [settings, setSettings] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [anchorIndex, setAnchorIndex] = useState(0); const [focusIndex, setFocusIndex] = useState(0); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; entry: ClipboardEntry; } | null>(null); const pollRef = useRef>(); const resizeTimer = useRef>(); const loadEntries = useCallback(async () => { try { const limit = settings?.max_history ?? 10000; const data: ClipboardEntry[] = query.trim() ? await invoke("search_entries", { query, limit }) : await invoke("get_entries", { limit }); setEntries(data); } catch (e) { console.error("Failed to load entries:", e); } }, [query, settings?.max_history]); const loadSettings = useCallback(async () => { try { const s: Settings = await invoke("get_settings"); setSettings(s); } catch (e) { console.error("Failed to load settings:", e); } }, []); useEffect(() => { loadSettings(); }, [loadSettings]); useEffect(() => { loadEntries(); pollRef.current = setInterval(loadEntries, 1000); return () => clearInterval(pollRef.current); }, [loadEntries]); useEffect(() => { const unlisten = listen("open-settings", () => setShowSettings(true)); return () => { unlisten.then((fn) => fn()); }; }, []); // 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 content = entriesToPaste .map((e) => e.content) .join("\n"); await invoke("paste_and_refocus", { content }); } catch (e) { console.error("Paste failed:", e); } }, [] ); const pasteSingle = useCallback( (entry: ClipboardEntry) => pasteEntries([entry]), [pasteEntries] ); const handleDelete = useCallback( async (ids: number[]) => { for (const id of ids) { await invoke("delete_entry", { id }); } setSelectedIds(new Set()); loadEntries(); }, [loadEntries] ); const handleTogglePin = useCallback( async (id: number) => { await invoke("toggle_pin", { id }); loadEntries(); }, [loadEntries] ); // Selection helpers const selectOnly = useCallback((index: number, entries: ClipboardEntry[]) => { const e = entries[index]; if (e) { setSelectedIds(new Set([e.id])); setAnchorIndex(index); setFocusIndex(index); } }, []); const selectRange = useCallback( (from: number, to: number, entries: ClipboardEntry[]) => { const lo = Math.min(from, to); const hi = Math.max(from, to); const ids = new Set(); 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-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) 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(); 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(); 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(); 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") { if (document.activeElement?.tagName !== "INPUT") { e.preventDefault(); 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(); } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ entries, focusIndex, anchorIndex, selectedIds, showSettings, pasteSingle, pasteEntries, handleDelete, selectOnly, selectRange, ]); useEffect(() => { const close = () => setContextMenu(null); window.addEventListener("click", close); return () => window.removeEventListener("click", close); }, []); const selectedCount = selectedIds.size; return (
{/* Titlebar drag region — sits behind the native traffic lights */}
{showSettings && settings ? ( setShowSettings(false)} onUpdate={(s) => { setSettings(s); loadSettings(); }} /> ) : ( <>
{ setQuery(v); setFocusIndex(0); setAnchorIndex(0); setSelectedIds(new Set()); }} />
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 }); }} setFocusIndex={setFocusIndex} />
{selectedCount > 1 ? `${selectedCount} selected` : "⌘1-9 quick paste"} {entries.length} items
)} {contextMenu && ( { const selected = entries.filter((e) => selectedIds.has(e.id)); pasteEntries(selected.length > 0 ? selected : [contextMenu.entry]); }} onPin={() => handleTogglePin(contextMenu.entry.id)} onDelete={() => { const ids = selectedIds.size > 0 ? Array.from(selectedIds) : [contextMenu.entry.id]; handleDelete(ids); }} /> )}
); }