From 43d0582804db831c06911d5f9ca00efa8b464374 Mon Sep 17 00:00:00 2001 From: ilia Date: Tue, 11 Aug 2026 09:51:43 -0400 Subject: [PATCH 1/2] Fix FTS search for path punctuation like /idobkin. Sanitize MATCH tokens so slash/dot queries no longer error and leave a stale full history list; clear the UI on search failure. --- CHANGELOG.md | 8 +++++ README.md | 8 ++--- docs/GUIDE.md | 2 +- docs/TESTING.md | 8 ++--- src-tauri/src/db.rs | 59 +++++++++++++++++++++++++++++-- src/App.test.tsx | 85 +++++++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 5 +++ 7 files changed, 164 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df2b1d5..46d384d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ All notable changes to maCopy. Format loosely follows [Keep a Changelog](https://keepachangelog.com/). +## [Unreleased] + +### Fixed +- Search queries with path punctuation (`/idobkin`, `github.com`, …) no + longer hit FTS5 syntax errors and leave the picker stuck on the full + unfiltered history — tokens are sanitized before `MATCH` +- When search IPC fails, the list clears instead of showing stale results + ## [0.2.0] — 2026-07-14 ### Added diff --git a/README.md b/README.md index b9473c1..cb0bb22 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ When you select an entry, maCopy writes it to the system clipboard (text or imag ### SQLite + FTS5 -The database uses a content-synced FTS5 virtual table with triggers that automatically keep the full-text index in sync with the `clipboard_entries` table. This enables instant prefix search as you type. Opened with `journal_mode=WAL` + `synchronous=NORMAL` so the UI can read while the background poller writes, without blocking on full fsyncs. +The database uses a content-synced FTS5 virtual table with triggers that automatically keep the full-text index in sync with the `clipboard_entries` table. This enables instant prefix search as you type. User queries are tokenized before `MATCH` so path punctuation (`/…`, `.`) does not trip FTS5 syntax. Opened with `journal_mode=WAL` + `synchronous=NORMAL` so the UI can read while the background poller writes, without blocking on full fsyncs. ### Window behavior @@ -173,9 +173,9 @@ The SQLite database is stored at: See **[docs/TESTING.md](docs/TESTING.md)** for the full map. ```bash -npm test # ~60 frontend tests -npm run test:rust # ~52 Rust tests -npm run check # lint + all tests (~112) +npm test # ~72 frontend tests +npm run test:rust # ~67 Rust tests +npm run check # lint + all tests (~139) ``` ## Building for Release diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 0ff5973..f006d5c 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -84,9 +84,9 @@ saved history entry is never modified. | Paste multiple selected items (joined with newlines) | `Enter` | | Delete | `Backspace`/`Delete` with items selected, or right-click → **Delete** | | Pin (keep forever, always on top) | Right-click → **Pin** / **Unpin** | +| Search | Just start typing — instant full-text search (paths like `/idobkin` and `github.com` work) | On macOS, **Control+click** opens the context menu (same as right-click). Use **Cmd+Click** for multi-select — Control+click is not multi-select. -| Search | Just start typing — instant full-text search | ### What you'll see in the list diff --git a/docs/TESTING.md b/docs/TESTING.md index fcbf97f..8a8fbc9 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -16,13 +16,13 @@ maCopy has an automated suite. There is no separate “live” Storybook/UI docs | `npm run lint` | TypeScript `tsc --noEmit` | | `npm run check` | Lint + all tests (CI-style gate) | -Current counts (approx.): **~60 frontend** + **~65 Rust** unit tests. +Current counts (approx.): **~72 frontend** + **~67 Rust** unit tests. ## What is covered ### Frontend (`src/**/*.test.tsx`, `transforms.test.ts`) -- App: load entries, paste invoke, delete via keyboard +- App: load entries, paste invoke, delete via keyboard, **search invoke + clear on search failure** - ClipboardList: selection, multi-select modifiers, image stubs/thumbs (including truncated thumb previews), Alt=plain paste - ContextMenu: Paste / Paste plain / transforms / Pin / Delete - SettingsPanel: toggles, history limit, hotkey recorder, **Launch at login → autostart enable** @@ -33,9 +33,9 @@ Tauri APIs are mocked in `src/test/setup.ts` (invoke, window, events, clipboard, ### Backend (`src-tauri/src/**` `#[cfg(test)]`) -- SQLite CRUD, FTS5, trim + pin preservation, settings +- SQLite CRUD, FTS5 (incl. `/path` / `github.com` punctuation), trim + pin preservation, settings - List previews: truncate text, omit image blobs, return thumbnails (including stub `[image WxH]` rows) -- Search excludes image rows (text-only FTS) +- Search excludes image rows (text-only FTS); App clears the list if search IPC fails - Sensitive redaction (card / password / banking) while `get_entry` stays full - Hotkey parser (`cmd+\`` etc.) - Image thumbnail / PNG helpers + `fit_rgba_under_bytes` against production `MAX_IMAGE_BYTES` diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 83d947a..d1015b1 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -58,6 +58,26 @@ pub struct Database { pub conn: Mutex, } +/// Turn free-form user input into a safe FTS5 MATCH expression. +/// +/// FTS5 treats `/`, `.`, `"`, and boolean keywords (`AND`/`OR`/`NOT`) as +/// syntax — raw `query + "*"` blows up on paths like `/idobkin` and leaves +/// the UI stuck on the previous unfiltered list. We keep alphanumeric +/// (plus `_`) tokens, quote them, and apply a prefix `*` to each so typing +/// mid-phrase still works. +fn build_fts_query(raw: &str) -> Option { + let tokens: Vec = raw + .split(|c: char| !(c.is_alphanumeric() || c == '_')) + .filter(|t| !t.is_empty()) + .map(|t| format!("\"{}\"*", t.replace('"', "\"\""))) + .collect(); + if tokens.is_empty() { + None + } else { + Some(tokens.join(" ")) + } +} + impl Database { pub fn new() -> SqlResult { let db_path = Self::db_path(); @@ -332,10 +352,11 @@ impl Database { } pub fn search_entries(&self, query: &str, limit: i64) -> SqlResult> { + let Some(fts_query) = build_fts_query(query) else { + return Ok(Vec::new()); + }; let conn = self.conn.lock().unwrap(); let limit = limit.clamp(1, DISPLAY_LIMIT); - // FTS5 match query — prefix search with * - let fts_query = format!("{}*", query.replace('"', "\"\"")); let mut stmt = conn.prepare( "SELECT e.id, e.content, e.content_type, e.created_at, e.pinned, e.content_hash, e.thumbnail FROM clipboard_entries e @@ -643,6 +664,40 @@ mod tests { assert_eq!(db.search_entries("searchable", 100).unwrap().len(), 0); } + #[test] + fn build_fts_query_strips_path_punctuation() { + assert_eq!(build_fts_query("/idobkin").as_deref(), Some("\"idobkin\"*")); + assert_eq!( + build_fts_query("github.com/idobkin").as_deref(), + Some("\"github\"* \"com\"* \"idobkin\"*") + ); + assert_eq!(build_fts_query(" /// ").as_deref(), None); + assert_eq!(build_fts_query("AND OR").as_deref(), Some("\"AND\"* \"OR\"*")); + } + + #[test] + fn search_accepts_leading_slash_and_dots() { + let db = test_db(); + db.insert_entry("user /idobkin on github", "text", "h1", None) + .unwrap(); + db.insert_entry("unrelated Synonyms", "text", "h2", None) + .unwrap(); + db.insert_entry("visit https://github.com/idobkin/repo", "text", "h3", None) + .unwrap(); + + let slash = db.search_entries("/idobkin", 100).unwrap(); + assert_eq!(slash.len(), 2); + assert!(slash.iter().all(|e| e.content.contains("idobkin"))); + + let dotted = db.search_entries("github.com", 100).unwrap(); + assert_eq!(dotted.len(), 1); + assert!(dotted[0].content.contains("github.com")); + + // Must not error (FTS syntax) — punctuation-only → empty. + assert!(db.search_entries("///", 100).unwrap().is_empty()); + assert!(db.search_entries("AND", 100).unwrap().is_empty()); + } + // ── Trim ──────────────────────────────────────────────────────── #[test] diff --git a/src/App.test.tsx b/src/App.test.tsx index 0e76d58..82287aa 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -202,6 +202,91 @@ describe("App", () => { }); }); + it("invokes search_entries with the typed query after debounce", async () => { + const all = [ + makeEntry({ content: "user /idobkin on github" }), + makeEntry({ content: "Synonyms" }), + ]; + const hits = [all[0]]; + + mockInvoke.mockImplementation(async (cmd: string, args?: Record) => { + switch (cmd) { + case "get_entries": + return all; + case "search_entries": + expect(args?.query).toBe("/idobkin"); + return hits; + case "get_settings": + return makeSettings(); + case "latest_entry_id": + return all[0]?.id ?? null; + default: + return undefined; + } + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Synonyms")).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByPlaceholderText("Search…"), { + target: { value: "/idobkin" }, + }); + + await act(async () => { + vi.advanceTimersByTime(150); + }); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("search_entries", { + query: "/idobkin", + limit: 150, + }); + expect(screen.getByText("user /idobkin on github")).toBeInTheDocument(); + expect(screen.queryByText("Synonyms")).not.toBeInTheDocument(); + }); + }); + + it("clears the list when search_entries fails instead of keeping stale results", async () => { + const all = [makeEntry({ content: "Synonyms" }), makeEntry({ content: "Magnet Math" })]; + + mockInvoke.mockImplementation(async (cmd: string) => { + switch (cmd) { + case "get_entries": + return all; + case "search_entries": + throw new Error("fts5: syntax error near \"/\""); + case "get_settings": + return makeSettings(); + case "latest_entry_id": + return all[0]?.id ?? null; + default: + return undefined; + } + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("Synonyms")).toBeInTheDocument(); + }); + + fireEvent.change(screen.getByPlaceholderText("Search…"), { + target: { value: "/broken" }, + }); + + await act(async () => { + vi.advanceTimersByTime(150); + }); + + await waitFor(() => { + expect(screen.getByText("No clipboard history")).toBeInTheDocument(); + expect(screen.queryByText("Synonyms")).not.toBeInTheDocument(); + }); + }); + it("shows settings panel when settings are opened", async () => { mockTauriCommands(); render(); diff --git a/src/App.tsx b/src/App.tsx index ed3d2be..b75d270 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,6 +49,11 @@ export default function App() { setEntries(data); } catch (e) { console.error("Failed to load entries:", e); + // Don't leave a stale unfiltered list when search fails (e.g. bad FTS query). + if (queryRef.current.trim()) { + fingerprintRef.current = ""; + setEntries([]); + } } }, []); -- 2.49.1 From f57ac62c3a5bd1eb2b526aab121335556b353fde Mon Sep 17 00:00:00 2001 From: ilia Date: Tue, 11 Aug 2026 09:51:50 -0400 Subject: [PATCH 2/2] Docs: align README test count table with current suite. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cb0bb22..2e029a9 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,8 @@ maCopy/ |---|---| | `npm run tauri dev` | Run in development mode with hot reload | | `npm run tauri build` | Build a release `.app` bundle | -| `npm test` | Run frontend tests (Vitest, ~60 tests) | -| `npm run test:rust` | Run Rust backend tests (~52 tests) | +| `npm test` | Run frontend tests (Vitest, ~72 tests) | +| `npm run test:rust` | Run Rust backend tests (~67 tests) | | `npm run test:all` | Run all tests (frontend + backend) | | `npm run lint` | TypeScript type-check | | `npm run check` | Lint + all tests | -- 2.49.1