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>
314 lines
9.9 KiB
TypeScript
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>
|
|
);
|
|
}
|