maCopy/src/App.tsx
ilia 7bb3b5fc89 Fix paste creating duplicate entries in clipboard history
The clipboard polling thread was detecting programmatic writes (from
paste_and_refocus) as "new" clipboard content and re-inserting them.

Fix: share the last_hash between the polling thread and the paste
command via Arc<Mutex<String>>. When pasting, the Rust command now:
1. Computes the hash of the content being pasted
2. Updates the shared last_hash so the polling thread skips it
3. Writes to clipboard via arboard (moved from JS writeText)
4. Hides window and runs AppleScript paste

This eliminates the JS-side writeText call entirely — clipboard write
now happens in Rust where it can atomically update the hash before
the polling thread's next tick.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-12 14:33:33 -04:00

314 lines
9.9 KiB
TypeScript

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<ClipboardEntry[]>([]);
const [query, setQuery] = useState("");
const [showSettings, setShowSettings] = useState(false);
const [settings, setSettings] = useState<Settings | null>(null);
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;
entry: ClipboardEntry;
} | 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 })
: 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<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-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 (
<div className="flex flex-col h-screen w-screen bg-surface overflow-hidden">
{/* Titlebar drag region — sits behind the native traffic lights */}
<div className="titlebar-spacer shrink-0" data-tauri-drag-region />
{showSettings && settings ? (
<SettingsPanel
settings={settings}
onClose={() => setShowSettings(false)}
onUpdate={(s) => {
setSettings(s);
loadSettings();
}}
/>
) : (
<>
<div className="shrink-0 px-2 pb-0" data-tauri-drag-region>
<SearchBar
value={query}
onChange={(v) => {
setQuery(v);
setFocusIndex(0);
setAnchorIndex(0);
setSelectedIds(new Set());
}}
/>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<ClipboardList
entries={entries}
selectedIds={selectedIds}
focusIndex={focusIndex}
showImages={settings?.show_images ?? true}
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 });
}}
setFocusIndex={setFocusIndex}
/>
</div>
<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>
</>
)}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
entry={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={() => {
const ids = selectedIds.size > 0 ? Array.from(selectedIds) : [contextMenu.entry.id];
handleDelete(ids);
}}
/>
)}
</div>
);
}