diff --git a/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py b/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py index 9600b7e05..98996d6bc 100644 --- a/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py +++ b/surfsense_backend/tests/unit/middleware/test_filesystem_backends.py @@ -30,6 +30,7 @@ def test_backend_resolver_returns_multi_root_backend_for_single_root(tmp_path: P backend = resolver(_RuntimeStub()) assert isinstance(backend, MultiRootLocalFolderBackend) + assert backend.list_mounts() == ("tmp",) def test_backend_resolver_uses_cloud_mode_by_default(): @@ -57,3 +58,4 @@ def test_backend_resolver_returns_multi_root_backend_for_multiple_roots(tmp_path backend = resolver(_RuntimeStub()) assert isinstance(backend, MultiRootLocalFolderBackend) + assert backend.list_mounts() == ("resume", "notes") diff --git a/surfsense_backend/tests/unit/middleware/test_multi_root_local_folder_backend.py b/surfsense_backend/tests/unit/middleware/test_multi_root_local_folder_backend.py index 7afb47e26..43a671178 100644 --- a/surfsense_backend/tests/unit/middleware/test_multi_root_local_folder_backend.py +++ b/surfsense_backend/tests/unit/middleware/test_multi_root_local_folder_backend.py @@ -26,3 +26,12 @@ def test_mount_ids_preserve_client_mapping_order(tmp_path: Path) -> None: ) assert backend.list_mounts() == ("pc_backups", "pc_backups_2", "notes_2026") + + +def test_mount_id_is_authoritative_not_folder_name(tmp_path: Path) -> None: + root = tmp_path / "Resume Folder" + root.mkdir() + + backend = MultiRootLocalFolderBackend((("custom_resume_mount", str(root)),)) + + assert backend.list_mounts() == ("custom_resume_mount",) diff --git a/surfsense_desktop/src/modules/agent-filesystem.ts b/surfsense_desktop/src/modules/agent-filesystem.ts index 6db5fd6f7..eb8d385b5 100644 --- a/surfsense_desktop/src/modules/agent-filesystem.ts +++ b/surfsense_desktop/src/modules/agent-filesystem.ts @@ -1,5 +1,5 @@ import { app, dialog } from "electron"; -import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, realpath, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; export type AgentFilesystemMode = "cloud" | "desktop_local_folder"; @@ -25,16 +25,26 @@ function getDefaultSettings(): AgentFilesystemSettings { }; } -function normalizeLocalRootPaths(paths: unknown): string[] { +async function canonicalizeRootPath(pathValue: string): Promise { + const resolvedPath = resolve(pathValue); + try { + return await realpath(resolvedPath); + } catch { + return resolvedPath; + } +} + +async function normalizeLocalRootPaths(paths: unknown): Promise { if (!Array.isArray(paths)) { return []; } const uniquePaths = new Set(); - for (const path of paths) { - if (typeof path !== "string") continue; - const trimmed = path.trim(); + for (const rawPath of paths) { + if (typeof rawPath !== "string") continue; + const trimmed = rawPath.trim(); if (!trimmed) continue; - uniquePaths.add(trimmed); + const canonicalRootPath = await canonicalizeRootPath(trimmed); + uniquePaths.add(canonicalRootPath); if (uniquePaths.size >= MAX_LOCAL_ROOTS) { break; } @@ -51,7 +61,7 @@ export async function getAgentFilesystemSettings(): Promise(null); const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false); const [pendingLocalPath, setPendingLocalPath] = useState(null); + const [draggedLocalRootPath, setDraggedLocalRootPath] = useState(null); const [watchedFolderIds, setWatchedFolderIds] = useState>(new Set()); const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom); const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom); @@ -246,7 +247,7 @@ function AuthenticatedDocumentsSidebar({ const applyLocalRootPath = useCallback( async (path: string) => { if (!electronAPI?.setAgentFilesystemSettings) return; - const nextLocalRootPaths = [...localRootPaths, path] + const nextLocalRootPaths = [path, ...localRootPaths] .filter((rootPath, index, allPaths) => allPaths.indexOf(rootPath) === index) .slice(0, MAX_LOCAL_FILESYSTEM_ROOTS); if (nextLocalRootPaths.length === localRootPaths.length) return; @@ -259,6 +260,26 @@ function AuthenticatedDocumentsSidebar({ [electronAPI, localRootPaths] ); + const handleReorderFilesystemRoots = useCallback( + async (draggedPath: string, targetPath: string) => { + if (!electronAPI?.setAgentFilesystemSettings) return; + if (draggedPath === targetPath) return; + const fromIndex = localRootPaths.indexOf(draggedPath); + const toIndex = localRootPaths.indexOf(targetPath); + if (fromIndex < 0 || toIndex < 0) return; + const nextLocalRootPaths = [...localRootPaths]; + const [movedPath] = nextLocalRootPaths.splice(fromIndex, 1); + if (!movedPath) return; + nextLocalRootPaths.splice(toIndex, 0, movedPath); + const updated = await electronAPI.setAgentFilesystemSettings({ + mode: "desktop_local_folder", + localRootPaths: nextLocalRootPaths, + }); + setFilesystemSettings(updated); + }, + [electronAPI, localRootPaths] + ); + const runPickLocalRoot = useCallback(async () => { if (!electronAPI?.pickAgentFilesystemRoot) return; const picked = await electronAPI.pickAgentFilesystemRoot(); @@ -1208,16 +1229,47 @@ function AuthenticatedDocumentsSidebar({ {localRootPaths.map((rootPath) => ( { - void handleRemoveFilesystemRoot(rootPath); + onSelect={(event) => event.preventDefault()} + draggable + onDragStart={(event) => { + event.dataTransfer.setData("text/plain", rootPath); + event.dataTransfer.effectAllowed = "move"; + setDraggedLocalRootPath(rootPath); }} - className="group h-8 gap-1.5 px-1.5 text-sm text-foreground" + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }} + onDrop={(event) => { + event.preventDefault(); + const sourcePath = + event.dataTransfer.getData("text/plain") || draggedLocalRootPath; + if (!sourcePath) return; + void handleReorderFilesystemRoots(sourcePath, rootPath); + setDraggedLocalRootPath(null); + }} + onDragEnd={() => { + setDraggedLocalRootPath(null); + }} + className={`group h-8 gap-1.5 px-1.5 text-sm text-foreground ${ + draggedLocalRootPath === rootPath ? "bg-muted/60" : "" + }`} > {getFolderDisplayName(rootPath)} - + ))} @@ -1358,16 +1410,16 @@ function AuthenticatedDocumentsSidebar({ className="h-5 gap-1 px-1.5 text-[11px] select-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=active]:bg-muted-foreground/25 data-[state=active]:text-foreground data-[state=active]:shadow-none" title="Cloud" > - - Cloud + + Cloud - - Local + + Local diff --git a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx index 30e532896..39b8ee769 100644 --- a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx +++ b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx @@ -150,6 +150,13 @@ export function LocalFilesystemBrowser({ setMountByRootKey(new Map()); return; } + if (rootPaths.length === 0) { + setMountByRootKey(new Map()); + setMountStatus("complete"); + setMountRefreshInFlight(false); + hasLoadedMountsOnceRef.current = true; + return; + } let cancelled = false; const isInitialMountLoad = !hasLoadedMountsOnceRef.current; if (isInitialMountLoad) { @@ -161,13 +168,9 @@ export function LocalFilesystemBrowser({ .getAgentFilesystemMounts() .then((mounts: LocalRootMount[]) => { if (cancelled) return; - const knownRootKeys = new Set( - rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform)) - ); const next = new Map(); for (const entry of mounts) { const normalizedRootKey = normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform); - if (!knownRootKeys.has(normalizedRootKey)) continue; next.set(normalizedRootKey, entry.mount); } setMountByRootKey(next);