Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src-tauri/core/src/workspace/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkspaceEntry>) -> Vec<WorkspaceEntry> {
entries.into_iter().filter(|w| w.path.is_dir()).collect()
}

#[cfg(test)]
mod tests {
use super::super::test_dir;
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 29 additions & 6 deletions src-tauri/src/tauri_api/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};

use tauri::path::BaseDirectory;
use tauri::{AppHandle, Emitter, Manager};
Expand All @@ -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";

Expand Down Expand Up @@ -35,7 +37,7 @@ pub async fn convert_workspace(
app: AppHandle,
path: String,
) -> Result<InitializedWorkspace, String> {
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), &registry).map_err(|e| e.message)
Expand All @@ -46,19 +48,40 @@ pub async fn convert_workspace(

#[tauri::command]
pub fn detect_agent_clients(app: AppHandle) -> Result<Vec<AgentClient>, 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<AgentClient, 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)?)?;
agents::connect_client(&home, &sidecar, id)
}

fn app_data_dir(app: &AppHandle) -> Result<std::path::PathBuf, String> {
#[tauri::command]
pub fn list_registry_workspaces(app: AppHandle) -> Result<Vec<WorkspaceEntry>, 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<String, String> {
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<PathBuf, String> {
app.path().home_dir().map_err(|e| e.to_string())
}

fn app_data_dir(app: &AppHandle) -> Result<PathBuf, String> {
app.path().app_local_data_dir().map_err(|e| e.to_string())
}

Expand Down
147 changes: 147 additions & 0 deletions src/hooks/useLibrary.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading