diff --git a/src-tauri/core/src/workspace/registry.rs b/src-tauri/core/src/workspace/registry.rs index 0ca1f25..5281be8 100644 --- a/src-tauri/core/src/workspace/registry.rs +++ b/src-tauri/core/src/workspace/registry.rs @@ -60,6 +60,12 @@ pub fn upsert_workspace(file: &Path, entry: WorkspaceEntry) -> Result<(), CoreEr save_registry(file, &workspaces) } +/// Drops entries whose directory is gone, so the GUI never surfaces a broken +/// empty root for a workspace deleted out from under the registry. +pub fn existing_workspaces(entries: Vec) -> Vec { + entries.into_iter().filter(|w| w.path.is_dir()).collect() +} + #[cfg(test)] mod tests { use super::super::test_dir; @@ -99,6 +105,25 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn existing_workspaces_drops_missing_dirs() { + let dir = test_dir("reg_existing"); + let present = dir.join("present"); + std::fs::create_dir_all(&present).unwrap(); + let entries = vec![ + WorkspaceEntry { + slug: "here".into(), + path: present.clone(), + scope: WorkspaceScope::User, + }, + entry("gone", "/no/such/workspace/dir", WorkspaceScope::Project), + ]; + let kept = existing_workspaces(entries); + assert_eq!(kept.len(), 1); + assert_eq!(kept[0].path, present); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn malformed_registry_is_typed_error() { let dir = test_dir("reg_bad"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1a8f8b3..9aa5390 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,8 @@ pub fn run() { tauri_api::convert_workspace, tauri_api::detect_agent_clients, tauri_api::connect_agent_client, + tauri_api::list_registry_workspaces, + tauri_api::registry_dir, tauri_api::install_welcome_workspace, tauri_api::git_status, tauri_api::git_show_head diff --git a/src-tauri/src/tauri_api/mod.rs b/src-tauri/src/tauri_api/mod.rs index 2e97993..30dcad0 100644 --- a/src-tauri/src/tauri_api/mod.rs +++ b/src-tauri/src/tauri_api/mod.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use tauri::path::BaseDirectory; use tauri::{AppHandle, Emitter, Manager}; @@ -7,7 +7,9 @@ use crate::agents::{self, AgentClient, ClientId}; use docsreader_core::git::{git_show_head_core, git_status_core, GitStatus}; use docsreader_core::scan::{run_scan, ScanProgress, ScanProgressSink, ScanResult}; use docsreader_core::workspace::init::{convert_workspace_core, InitializedWorkspace}; -use docsreader_core::workspace::registry::default_registry_path; +use docsreader_core::workspace::registry::{ + default_registry_path, existing_workspaces, load_registry, WorkspaceEntry, +}; const PROGRESS_EVENT: &str = "scan-progress"; @@ -35,7 +37,7 @@ pub async fn convert_workspace( app: AppHandle, path: String, ) -> Result { - let home = app.path().home_dir().map_err(|e| e.to_string())?; + let home = home_dir(&app)?; let registry = default_registry_path(&home); tauri::async_runtime::spawn_blocking(move || { convert_workspace_core(Path::new(&path), ®istry).map_err(|e| e.message) @@ -46,19 +48,40 @@ pub async fn convert_workspace( #[tauri::command] pub fn detect_agent_clients(app: AppHandle) -> Result, String> { - let home = app.path().home_dir().map_err(|e| e.to_string())?; + let home = home_dir(&app)?; let sidecar = agents::sidecar_path(&app_data_dir(&app)?)?; Ok(agents::detect_clients(&home, &sidecar)) } #[tauri::command] pub fn connect_agent_client(app: AppHandle, id: ClientId) -> Result { - let home = app.path().home_dir().map_err(|e| e.to_string())?; + let home = home_dir(&app)?; let sidecar = agents::sidecar_path(&app_data_dir(&app)?)?; agents::connect_client(&home, &sidecar, id) } -fn app_data_dir(app: &AppHandle) -> Result { +#[tauri::command] +pub fn list_registry_workspaces(app: AppHandle) -> Result, String> { + let home = home_dir(&app)?; + let entries = load_registry(&default_registry_path(&home)).map_err(|e| e.message)?; + Ok(existing_workspaces(entries)) +} + +#[tauri::command] +pub fn registry_dir(app: AppHandle) -> Result { + let home = home_dir(&app)?; + let dir = default_registry_path(&home) + .parent() + .ok_or("registry path has no parent")? + .to_path_buf(); + Ok(dir.to_string_lossy().into_owned()) +} + +fn home_dir(app: &AppHandle) -> Result { + app.path().home_dir().map_err(|e| e.to_string()) +} + +fn app_data_dir(app: &AppHandle) -> Result { app.path().app_local_data_dir().map_err(|e| e.to_string()) } diff --git a/src/hooks/useLibrary.test.ts b/src/hooks/useLibrary.test.ts new file mode 100644 index 0000000..3a1f2d9 --- /dev/null +++ b/src/hooks/useLibrary.test.ts @@ -0,0 +1,147 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { vi, describe, it, expect, beforeEach } from "vitest"; + +import type { RegistryWorkspace } from "@/lib/workspaces"; + +let registry: RegistryWorkspace[] = []; +let storedRoots: string[] = []; +let dismissed: string[] = []; +const savedRoots = vi.fn(async () => {}); +const dismissedAdded = vi.fn(async () => {}); +const dismissedRemoved = vi.fn(async () => {}); + +vi.mock("@/lib/workspaces", () => ({ + listRegistryWorkspaces: vi.fn(async () => registry), + registryDir: vi.fn(async () => "/home/u/.docsreader"), +})); + +vi.mock("@tauri-apps/plugin-fs", () => ({ + watch: vi.fn(async () => () => {}), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() })); + +vi.mock("@/lib/scan", () => ({ + scanDirectory: vi.fn(async (root: string) => ({ + root, + files: [], + truncated: false, + })), +})); + +vi.mock("@/lib/git", () => ({ fetchGitStatus: vi.fn(async () => undefined) })); + +vi.mock("@/lib/storage", () => ({ + loadRoots: vi.fn(async () => storedRoots), + saveRoots: vi.fn(async (r: string[]) => { + storedRoots = r; + await savedRoots(); + }), + loadLastSelected: vi.fn(async () => undefined), + saveLastSelected: vi.fn(async () => {}), + loadScanCache: vi.fn(async () => undefined), + saveScanCache: vi.fn(async () => {}), + deleteScanCache: vi.fn(async () => {}), + loadDismissedRegistry: vi.fn(async () => dismissed), + addDismissedRegistry: vi.fn(async (p: string) => { + dismissed = [...dismissed, p]; + await dismissedAdded(); + }), + removeDismissedRegistry: vi.fn(async (p: string) => { + dismissed = dismissed.filter((d) => d !== p); + await dismissedRemoved(); + }), +})); + +import { useLibrary } from "./useLibrary"; + +const NOTES: RegistryWorkspace = { + slug: "notes", + path: "/home/u/notes", + scope: "user", +}; +const DCS: RegistryWorkspace = { + slug: "dcs", + path: "/repo/dcs", + scope: "project", +}; + +async function mount() { + const hook = renderHook(() => useLibrary()); + await waitFor(() => expect(hook.result.current.hydrated).toBe(true)); + return hook; +} + +describe("useLibrary registry sync", () => { + beforeEach(() => { + registry = []; + storedRoots = []; + dismissed = []; + vi.clearAllMocks(); + }); + + it("adds agent-created workspaces to an empty app and selects the first", async () => { + registry = [NOTES, DCS]; + const hook = await mount(); + await waitFor(() => + expect(hook.result.current.roots).toEqual([NOTES.path, DCS.path]) + ); + await waitFor(() => + expect(hook.result.current.activeRoot).toBe(NOTES.path) + ); + }); + + it("does not steal the active root when the app already has one", async () => { + storedRoots = ["/manual/folder"]; + registry = [NOTES]; + const hook = await mount(); + await waitFor(() => + expect(hook.result.current.roots).toContain(NOTES.path) + ); + expect(hook.result.current.activeRoot).toBe("/manual/folder"); + }); + + it("keeps stored roots and appends only new registry workspaces", async () => { + storedRoots = ["/manual/folder", NOTES.path]; + registry = [NOTES, DCS]; + const hook = await mount(); + await waitFor(() => + expect(hook.result.current.roots).toEqual([ + "/manual/folder", + NOTES.path, + DCS.path, + ]) + ); + }); + + it("does not re-add a dismissed registry workspace", async () => { + dismissed = [DCS.path]; + registry = [NOTES, DCS]; + const hook = await mount(); + await waitFor(() => + expect(hook.result.current.roots).toEqual([NOTES.path]) + ); + expect(hook.result.current.roots).not.toContain(DCS.path); + }); + + it("removing a synced workspace records a dismissal so it stays gone", async () => { + registry = [NOTES]; + const hook = await mount(); + await waitFor(() => + expect(hook.result.current.roots).toEqual([NOTES.path]) + ); + await hook.result.current.removeRoot(NOTES.path); + await waitFor(() => expect(hook.result.current.roots).toEqual([])); + expect(dismissed).toContain(NOTES.path); + }); + + it("removing a manually added folder does not record a dismissal", async () => { + storedRoots = ["/manual/only"]; + registry = []; + const hook = await mount(); + await waitFor(() => expect(hook.result.current.hydrated).toBe(true)); + await hook.result.current.removeRoot("/manual/only"); + await waitFor(() => expect(hook.result.current.roots).toEqual([])); + expect(dismissed).not.toContain("/manual/only"); + }); +}); diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts index ea1e191..d6198fc 100644 --- a/src/hooks/useLibrary.ts +++ b/src/hooks/useLibrary.ts @@ -9,14 +9,18 @@ import { import { fetchGitStatus, type GitStatus } from "@/lib/git"; import { describeEventKind } from "@/lib/events"; import { + addDismissedRegistry, deleteScanCache, + loadDismissedRegistry, loadLastSelected, loadRoots, loadScanCache, + removeDismissedRegistry, saveLastSelected, saveRoots, saveScanCache, } from "@/lib/storage"; +import { listRegistryWorkspaces, registryDir } from "@/lib/workspaces"; export interface RootScan { result: ScanResult; @@ -47,8 +51,10 @@ const emptyResult = (root: string): ScanResult => ({ root, files: [], truncated: const DEBOUNCE_MS = 600; // Minimum gap between two rescans regardless of how many events fire. const MIN_RESCAN_INTERVAL_MS = 2000; +// Coalescing window the fs watcher applies before delivering events. +const WATCH_DELAY_MS = 200; -// Mirror of the Rust scanner's SKIP_DIRS in src-tauri/src/lib.rs. Any +// Mirror of the Rust scanner's SKIP_DIRS in src-tauri/core/src/scan.rs. Any // directory segment in this set, OR any segment that starts with a dot // (other than "." and ".."), causes a watch event for that path to be // dropped without scheduling a rescan. The scanner already excludes @@ -119,6 +125,18 @@ export function useLibrary(): Library { const [scans, setScans] = useState>({}); const [hydrated, setHydrated] = useState(false); + // Mirror of `roots` for reads inside async callbacks (reconcile, watcher) + // that must see the latest list without re-subscribing. Every mutation + // path (addRoot/removeRoot/reconcile) writes it synchronously before its + // setRoots, so the no-await sections stay race-free against each other. + const rootsRef = useRef(roots); + rootsRef.current = roots; + + // Paths currently in the MCP registry, from the last reconcile. Used so + // removeRoot only tombstones actual registry workspaces, not manually + // added folders. + const registryPathsRef = useRef>(new Set()); + const hydrateFromCache = useCallback(async (root: string) => { const cached = await loadScanCache(root); if (!cached) return; @@ -128,19 +146,6 @@ export function useLibrary(): Library { })); }, []); - useEffect(() => { - (async () => { - const [stored, last] = await Promise.all([loadRoots(), loadLastSelected()]); - setRoots(stored); - if (stored.length > 0) { - const initial = stored.includes(last ?? "") ? (last as string) : stored[0]; - setActiveRoot(initial); - await hydrateFromCache(initial); - } - setHydrated(true); - })(); - }, [hydrateFromCache]); - const rescan = useCallback(async (root: string) => { const startedAt = performance.now(); setScans((s) => { @@ -192,14 +197,69 @@ export function useLibrary(): Library { } }, []); + // Merge workspaces the MCP server created (agents write to ~/notes and + // project-scoped folders, recorded in ~/.docsreader/workspaces.json) into + // the displayed roots, so agent-created workspaces stop being invisible. + // Skips ones already shown and ones the user explicitly removed. Never + // changes the active root or the persisted selection. + const reconcileRegistry = useCallback(async () => { + let registry: Awaited>; + try { + registry = await listRegistryWorkspaces(); + } catch (err) { + console.error("read workspace registry failed", err); + return; + } + registryPathsRef.current = new Set(registry.map((w) => w.path)); + const dismissed = new Set(await loadDismissedRegistry()); + const shown = new Set(rootsRef.current); + const additions = registry + .map((w) => w.path) + .filter((p) => !shown.has(p) && !dismissed.has(p)); + if (additions.length === 0) return; + const wasEmpty = rootsRef.current.length === 0; + const next = [...rootsRef.current, ...additions]; + rootsRef.current = next; + setRoots(next); + await saveRoots(next); + // When the app had nothing open, select the first synced workspace so + // the reported empty-app case lands on a doc instead of a blank pane. + if (wasEmpty) { + setActiveRoot(additions[0]); + await saveLastSelected(additions[0]); + } + for (const path of additions) { + void hydrateFromCache(path); + void rescan(path); + } + }, [hydrateFromCache, rescan]); + + useEffect(() => { + (async () => { + const [stored, last] = await Promise.all([loadRoots(), loadLastSelected()]); + setRoots(stored); + rootsRef.current = stored; + if (stored.length > 0) { + const initial = stored.includes(last ?? "") ? (last as string) : stored[0]; + setActiveRoot(initial); + await hydrateFromCache(initial); + } + setHydrated(true); + await reconcileRegistry(); + })(); + }, [hydrateFromCache, reconcileRegistry]); + const addRoot = useCallback( async (path: string) => { - setRoots((prev) => { - if (prev.includes(path)) return prev; - const next = [...prev, path]; + // A manual re-add of a previously removed registry workspace clears + // its dismissal, so future syncs keep showing it. + void removeDismissedRegistry(path); + if (!rootsRef.current.includes(path)) { + const next = [...rootsRef.current, path]; + rootsRef.current = next; + setRoots(next); void saveRoots(next); - return next; - }); + } setActiveRoot(path); await saveLastSelected(path); await hydrateFromCache(path); @@ -216,12 +276,15 @@ export function useLibrary(): Library { const removeRoot = useCallback( async (path: string) => { - let nextRoots: string[] = []; - setRoots((prev) => { - nextRoots = prev.filter((r) => r !== path); - void saveRoots(nextRoots); - return nextRoots; - }); + // Tombstone only actual registry workspaces, so a synced workspace + // does not reappear on the next launch while a manually added folder + // leaves no lingering suppression. addRoot clears the tombstone. The + // workspace and its files on disk are untouched either way. + if (registryPathsRef.current.has(path)) void addDismissedRegistry(path); + const nextRoots = rootsRef.current.filter((r) => r !== path); + rootsRef.current = nextRoots; + setRoots(nextRoots); + void saveRoots(nextRoots); await deleteScanCache(path); setScans((s) => { const c = { ...s }; @@ -256,7 +319,7 @@ export function useLibrary(): Library { // Two filters protect against runaway work: // 1. Events whose path lies inside a hidden or known-noisy // directory are dropped before scheduling. These mirror the - // Rust scanner's skip list (src-tauri/src/lib.rs SKIP_DIRS), + // Rust scanner's skip list (src-tauri/core/src/scan.rs SKIP_DIRS), // so the scan would have ignored those paths anyway. // 2. Rescans are rate-limited to one every MIN_RESCAN_INTERVAL_MS // regardless of debounce, so sustained churn cannot loop the @@ -351,7 +414,7 @@ export function useLibrary(): Library { paths.some((p) => !isSkippedWatchPath(p, activeRoot)); if (someRelevant) scheduleRescan(); }, - { recursive: true, delayMs: 200 } + { recursive: true, delayMs: WATCH_DELAY_MS } ); if (cancelled) { void unwatchFn(); @@ -371,6 +434,51 @@ export function useLibrary(): Library { }; }, [activeRoot]); + // Registry watcher: when an agent creates a workspace while the app is + // open, ~/.docsreader/workspaces.json changes and the new workspace + // appears without a restart. A window-focus pass is the fallback for the + // rare case the directory did not exist when the watch was set up. + useEffect(() => { + let cancelled = false; + let unwatch: UnwatchFn | undefined; + let timer: ReturnType | undefined; + + const scheduleReconcile = () => { + if (cancelled) return; + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + if (!cancelled) void reconcileRegistry(); + }, DEBOUNCE_MS); + }; + + void (async () => { + try { + const dir = await registryDir(); + const unwatchFn = await watch(dir, scheduleReconcile, { + recursive: false, + delayMs: WATCH_DELAY_MS, + }); + if (cancelled) { + void unwatchFn(); + return; + } + unwatch = unwatchFn; + } catch (err) { + // The registry dir may not exist until the first agent write; the + // focus fallback and next launch still pick new workspaces up. + console.debug("registry watch not active yet", err); + } + })(); + + window.addEventListener("focus", scheduleReconcile); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + window.removeEventListener("focus", scheduleReconcile); + if (unwatch) void unwatch(); + }; + }, [reconcileRegistry]); + const activeScan = activeRoot ? scans[activeRoot] : undefined; return { diff --git a/src/lib/storage.ts b/src/lib/storage.ts index a7f9a73..9153514 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -13,6 +13,7 @@ const PANE_LAYOUT_KEY = "paneLayout"; const SIDEBAR_STATE_KEY = "sidebarState"; const PINNED_KEY = "pinnedByRoot"; const CONVERT_DECLINED_KEY = "convertDeclined"; +const DISMISSED_REGISTRY_KEY = "dismissedRegistry"; export const TABS_KEY_PANE0 = TABS_STATE_KEY; export const TABS_KEY_PANE1 = TABS_STATE_PANE1_KEY; @@ -106,9 +107,24 @@ export async function loadRoots(): Promise { return Array.isArray(v) ? v : []; } +// Serializes root/dismissal writes so concurrent callers (launch reconcile, +// the registry watcher, and add/remove) cannot interleave a read-modify-write +// and lose an entry. +let writeChain: Promise = Promise.resolve(); +function enqueueWrite(fn: () => Promise): Promise { + const result = writeChain.then(fn, fn); + writeChain = result.then( + () => undefined, + () => undefined, + ); + return result; +} + export async function saveRoots(roots: string[]): Promise { - await store.set(ROOTS_KEY, roots); - await store.save(); + await enqueueWrite(async () => { + await store.set(ROOTS_KEY, roots); + await store.save(); + }); } export async function loadLastSelected(): Promise { @@ -134,6 +150,32 @@ export async function addConvertDeclined(root: string): Promise { await store.save(); } +export async function loadDismissedRegistry(): Promise { + const v = await store.get(DISMISSED_REGISTRY_KEY); + return Array.isArray(v) ? v : []; +} + +export async function addDismissedRegistry(path: string): Promise { + await enqueueWrite(async () => { + const current = await loadDismissedRegistry(); + if (current.includes(path)) return; + await store.set(DISMISSED_REGISTRY_KEY, [...current, path]); + await store.save(); + }); +} + +export async function removeDismissedRegistry(path: string): Promise { + await enqueueWrite(async () => { + const current = await loadDismissedRegistry(); + if (!current.includes(path)) return; + await store.set( + DISMISSED_REGISTRY_KEY, + current.filter((p) => p !== path), + ); + await store.save(); + }); +} + export async function loadScanCache(root: string): Promise { const all = await store.get>(SCAN_CACHE_KEY); if (!all || typeof all !== "object") return undefined; diff --git a/src/lib/workspaces.ts b/src/lib/workspaces.ts new file mode 100644 index 0000000..8236ccc --- /dev/null +++ b/src/lib/workspaces.ts @@ -0,0 +1,18 @@ +import { invoke } from "@tauri-apps/api/core"; + +export const WORKSPACE_SCOPES = ["user", "project"] as const; +export type WorkspaceScope = (typeof WORKSPACE_SCOPES)[number]; + +export interface RegistryWorkspace { + slug: string; + path: string; + scope: WorkspaceScope; +} + +export function listRegistryWorkspaces(): Promise { + return invoke("list_registry_workspaces"); +} + +export function registryDir(): Promise { + return invoke("registry_dir"); +}