diff --git a/src/stores/persist.ts b/src/stores/persist.ts new file mode 100644 index 0000000..031304f --- /dev/null +++ b/src/stores/persist.ts @@ -0,0 +1,57 @@ +/** + * Persistence scheduler for PodTUI + * Per-domain trailing-edge debounced writes + */ + +/** Debounced writer: rapid schedules collapse into one write per domain. */ +export interface PersistScheduler { + /** Mark a domain dirty and (re)arm its trailing-edge write timer. */ + schedule(domain: string): void; + /** Write the domain immediately if dirty; cancels any pending timer. */ + flush(domain: string): void; + /** Write every dirty domain immediately. */ + flushAll(): void; +} + +/** Timer handle as returned by setTimeout in this runtime. */ +type TimerHandle = ReturnType; + +/** Build a per-domain trailing-edge debouncer. `write` is invoked with the + * domain name; callers read current state inside it, so a flush always + * lands the latest value. Rapid schedule() calls share one timer. */ +export function createPersistScheduler( + write: (domain: string) => void, + debounceMs = 250, +): PersistScheduler { + const dirty = new Set(); + const timers = new Map(); + + const flush = (domain: string): void => { + const timer = timers.get(domain); + if (timer) { + clearTimeout(timer); + timers.delete(domain); + } + if (!dirty.has(domain)) return; + dirty.delete(domain); + write(domain); + }; + + const schedule = (domain: string): void => { + dirty.add(domain); + clearTimeout(timers.get(domain)); + timers.set( + domain, + setTimeout(() => { + timers.delete(domain); + flush(domain); + }, debounceMs), + ); + }; + + const flushAll = (): void => { + for (const domain of [...dirty]) flush(domain); + }; + + return { schedule, flush, flushAll }; +} diff --git a/src/stores/search.ts b/src/stores/search.ts index 763e831..dd4386b 100644 --- a/src/stores/search.ts +++ b/src/stores/search.ts @@ -11,6 +11,7 @@ import { } from "../utils/app-persistence"; import { useFeedStore } from "./feed"; import type { SearchResult, SearchScope } from "../types/source"; +import { createPersistScheduler } from "./persist"; const STORAGE_SCOPE_KEY = "podtui_search_scope"; const MAX_HISTORY = 10; @@ -70,6 +71,15 @@ export function createSearchStore() { const [selectedSources, setSelectedSources] = createSignal([]); const [scope, setScopeState] = createSignal(loadScope()); + /** History persistence: rapid mutations collapse into one debounced + * write; the closure reads the live signal so a flush lands the + * latest list. */ + const persistHistory = createPersistScheduler((domain: string) => { + if (domain === "search-history") { + saveSearchHistoryToFile(history()); + } + }); + /** Load search history from file (fire-and-forget; recents appear as * soon as the file is read). */ async function init(): Promise { @@ -167,24 +177,18 @@ export function createSearchStore() { }; const addToHistory = (q: string) => { - setHistory((prev) => { - const updated = sanitizeHistory([q, ...prev]); - saveSearchHistoryToFile(updated); - return updated; - }); + setHistory((prev) => sanitizeHistory([q, ...prev])); + persistHistory.schedule("search-history"); }; const clearHistory = () => { setHistory([]); - saveSearchHistoryToFile([]); + persistHistory.schedule("search-history"); }; const removeFromHistory = (q: string) => { - setHistory((prev) => { - const updated = prev.filter((h) => h !== q); - saveSearchHistoryToFile(updated); - return updated; - }); + setHistory((prev) => prev.filter((h) => h !== q)); + persistHistory.schedule("search-history"); }; const clearResults = () => {