diff --git a/packages/dashboard/src-tauri/tests/db_mutations.rs b/packages/dashboard/src-tauri/tests/db_mutations.rs index 6636fb8fb5..c171f37520 100644 --- a/packages/dashboard/src-tauri/tests/db_mutations.rs +++ b/packages/dashboard/src-tauri/tests/db_mutations.rs @@ -298,6 +298,83 @@ fn bulk_archive_memory_status_queues_one_mutation_per_memory_without_epoch_bumps ); } +#[test] +fn bulk_restore_mixed_selection_changes_and_bumps_only_actually_changed_rows() { + let mut conn = make_db(); + // a1: no-op (active -> active); a2: archived -> active (changes). + let a1 = insert_memory(&conn, "git:project-a", "active"); + let a2 = insert_memory(&conn, "git:project-a", "archived"); + // b1: permanent -> active (changes). + let b1 = insert_memory(&conn, "git:project-b", "permanent"); + // c1: no-op, and the only selected row on its path (path must not bump). + let c1 = insert_memory(&conn, "git:project-c", "active"); + seed_project_state(&conn, "git:project-a", 4, 0); + seed_project_state(&conn, "git:project-b", 6, 0); + seed_project_state(&conn, "git:project-c", 3, 0); + + let affected = + db::bulk_update_memory_status(&mut conn, &[a1, a2, b1, c1], "active").expect("restore"); + + // affected counts matched rows, not changed ones: the UPDATE has no status + // predicate, so all four selected rows are written even though two of them + // keep their status. + assert_eq!(affected, 4); + for id in [a1, a2, b1, c1] { + let status: String = conn + .query_row( + "SELECT status FROM memories WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .expect("status"); + assert_eq!(status, "active"); + } + // Only the paths whose rows actually transitioned bump the epoch; the + // all-no-op path (git:project-c) must not bust it. + assert_eq!(memory_epoch(&conn, "git:project-a"), 5); + assert_eq!(memory_epoch(&conn, "git:project-b"), 7); + assert_eq!(memory_epoch(&conn, "git:project-c"), 3); + // A restore never queues memory mutations (unlike archive). + assert!(mutation_log_rows(&conn).is_empty()); +} + +#[test] +fn bulk_make_permanent_mixed_selection_changes_and_bumps_only_actually_changed_rows() { + let mut conn = make_db(); + // a1: active -> permanent (changes); a2: permanent -> permanent (no-op). + let a1 = insert_memory(&conn, "git:project-a", "active"); + let a2 = insert_memory(&conn, "git:project-a", "permanent"); + // b1: archived -> permanent (changes). + let b1 = insert_memory(&conn, "git:project-b", "archived"); + // c1: permanent -> permanent (no-op), and the only selected row on its path. + let c1 = insert_memory(&conn, "git:project-c", "permanent"); + seed_project_state(&conn, "git:project-a", 4, 0); + seed_project_state(&conn, "git:project-b", 6, 0); + seed_project_state(&conn, "git:project-c", 3, 0); + let ids = [a1, a2, b1, c1]; + + let affected = + db::bulk_update_memory_status(&mut conn, &ids, "permanent").expect("bulk permanent"); + + assert_eq!(affected, 4); + for id in ids { + let status: String = conn + .query_row( + "SELECT status FROM memories WHERE id = ?1", + params![id], + |row| row.get(0), + ) + .expect("status"); + assert_eq!(status, "permanent"); + } + // Same no-op guard as the restore-to-active path: only the paths whose + // rows actually transitioned bump the epoch. + assert_eq!(memory_epoch(&conn, "git:project-a"), 5); + assert_eq!(memory_epoch(&conn, "git:project-b"), 7); + assert_eq!(memory_epoch(&conn, "git:project-c"), 3); + assert!(mutation_log_rows(&conn).is_empty()); +} + #[test] fn bulk_delete_memories_queues_one_delete_per_memory_without_epoch_bumps() { let mut conn = make_db(); diff --git a/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx b/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx index 9b93266793..9a4bdf9c84 100644 --- a/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx +++ b/packages/dashboard/src/components/MemoryBrowser/MemoryBrowser.tsx @@ -222,6 +222,33 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) { setSelectedIds(new Set()); }); + // A row can also leave the current list without any filter changing: the + // detail panel's status edit reloads the same scope. Once the category + // pages settle, drop selected ids they no longer contain, so bulk actions — + // including Restore's permanent-downgrade warning — never act on rows the + // user can no longer see. A reload empties the pages first, so pruning waits + // for it to settle instead of clearing the whole selection; and because the + // reloaded pages only cover each category's first page, a selection made + // deeper in a category or under a collapsed category is dropped too. That + // costs a few clicks, never an unintended write. + const listsSettled = () => !stats.loading && loadingCategories().size === 0; + const loadedRowsById = (): Map => { + const rows = new Map(); + for (const page of Object.values(pages())) { + for (const row of page.rows) rows.set(row.id, row); + } + return rows; + }; + + createEffect(() => { + if (!listsSettled()) return; + const loaded = loadedRowsById(); + setSelectedIds((prev) => { + const next = new Set([...prev].filter((id) => loaded.has(id))); + return next.size === prev.size ? prev : next; + }); + }); + const handleStatusChange = async (memoryId: number, newStatus: string) => { try { setError(null); @@ -381,6 +408,59 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) { } }; + const handleBulkRestore = async () => { + const ids = [...selectedIds()]; + if (ids.length === 0) return; + // While a reload is in flight the pages are empty, so the statuses that + // decide the permanent warning cannot be read; the buttons are disabled + // for that window, and this guard keeps a stray call from acting blind. + if (!listsSettled()) return; + // A permanent row is already injected, so restoring it would flatten it + // to active and silently drop its never-expire status. Selection is reset + // whenever a filter changes, so every selected row's status is a loaded + // row here: name the permanent rows in the confirm dialog and let the + // user decide, instead of skipping them or downgrading them unnoticed. + const loaded = loadedRowsById(); + const permanentCount = ids.filter((id) => loaded.get(id)?.status === "permanent").length; + const permanentClause = + permanentCount > 0 + ? ` ${permanentCount} permanent memor${permanentCount === 1 ? "y" : "ies"} in this selection will become active and lose never-expire.` + : ""; + const prompt = + permanentCount === ids.length + ? `Set ${ids.length} memor${ids.length === 1 ? "y" : "ies"} to active?${permanentClause}` + : `Restore ${ids.length} memor${ids.length === 1 ? "y" : "ies"}? Restored memories re-enter injection as active.${permanentClause}`; + const confirmed = await ask(prompt, { title: "Confirm Restore", kind: "warning" }); + if (!confirmed) return; + try { + setError(null); + await bulkUpdateMemoryStatus(ids, "active"); + clearSelection(); + reloadLists(); + } catch (e: unknown) { + setError(`Failed to restore memories: ${e instanceof Error ? e.message : String(e)}`); + } + }; + + const handleBulkPermanent = async () => { + const ids = [...selectedIds()]; + if (ids.length === 0) return; + if (!listsSettled()) return; + const confirmed = await ask( + `Make ${ids.length} memor${ids.length === 1 ? "y" : "ies"} permanent? Permanent memories always stay in injection and never expire.`, + { title: "Confirm Permanent", kind: "warning" }, + ); + if (!confirmed) return; + try { + setError(null); + await bulkUpdateMemoryStatus(ids, "permanent"); + clearSelection(); + reloadLists(); + } catch (e: unknown) { + setError(`Failed to make memories permanent: ${e instanceof Error ? e.message : String(e)}`); + } + }; + const handleBulkDelete = async () => { const ids = [...selectedIds()]; if (ids.length === 0) return; @@ -648,10 +728,36 @@ export default function MemoryBrowser(props: MemoryBrowserProps = {}) {
- - + +