From f7fa96ccd01bcd2df89087c4e92151af5bdc5b25 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 27 Apr 2026 02:53:26 +0530 Subject: [PATCH 01/14] feat(sidebar): enhance DocumentsSidebar with tooltip support for folder addition, improving user feedback on folder limits --- .../layout/ui/sidebar/DocumentsSidebar.tsx | 48 ++++++++++++++----- surfsense_web/components/ui/tooltip.tsx | 19 ++++---- 2 files changed, 45 insertions(+), 22 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 5819dcef4..ce9b80d49 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -1211,18 +1211,42 @@ function AuthenticatedDocumentsSidebar({ orientation="vertical" className="data-[orientation=vertical]:h-3 self-center bg-border" /> - + {electronAPI ? ( + + + + + + + + {canAddMoreLocalRoots + ? "Add folder" + : `You can add up to ${MAX_LOCAL_FILESYSTEM_ROOTS} folders`} + + + ) : ( + + )}
diff --git a/surfsense_web/components/ui/tooltip.tsx b/surfsense_web/components/ui/tooltip.tsx index bcf1c72f8..c1469156d 100644 --- a/surfsense_web/components/ui/tooltip.tsx +++ b/surfsense_web/components/ui/tooltip.tsx @@ -6,20 +6,19 @@ import { useEffect, useState } from "react"; import { cn } from "@/lib/utils"; -const MOBILE_BREAKPOINT = 768; - -function useIsTouchDevice() { - const [isTouch, setIsTouch] = useState(false); +function useCanHover() { + const [canHover, setCanHover] = useState(false); useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); - const update = () => setIsTouch(mql.matches); + // Hover-capable pointers are a better cross-platform signal than viewport width. + const mql = window.matchMedia("(hover: hover) and (pointer: fine)"); + const update = () => setCanHover(mql.matches); update(); mql.addEventListener("change", update); return () => mql.removeEventListener("change", update); }, []); - return isTouch; + return canHover; } function TooltipProvider({ @@ -42,14 +41,14 @@ function Tooltip({ onOpenChange, ...props }: React.ComponentProps) { - const isMobile = useIsTouchDevice(); + const canHover = useCanHover(); return ( From dbdeaa1bcffa4dd8ca60df2e1f706c656f76279e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 27 Apr 2026 04:03:53 +0530 Subject: [PATCH 02/14] feat(sidebar): add loading skeletons to DocumentsSidebar and LocalFilesystemBrowser during data fetching --- .../layout/ui/sidebar/DocumentsSidebar.tsx | 116 +++++++++++------- .../ui/sidebar/LocalFilesystemBrowser.tsx | 94 ++++++++++++-- 2 files changed, 162 insertions(+), 48 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index ce9b80d49..f4c5c03ac 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -73,6 +73,7 @@ import { } from "@/components/ui/dropdown-menu"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; import { Input } from "@/components/ui/input"; +import { Skeleton } from "@/components/ui/skeleton"; import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -99,6 +100,32 @@ const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"]; const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1"; const MAX_LOCAL_FILESYSTEM_ROOTS = 5; +function CloudDocumentsSkeleton() { + const rows = [ + { id: "row-1", widthClass: "w-44" }, + { id: "row-2", widthClass: "w-32" }, + { id: "row-3", widthClass: "w-32" }, + { id: "row-4", widthClass: "w-44" }, + { id: "row-5", widthClass: "w-32" }, + { id: "row-6", widthClass: "w-32" }, + { id: "row-7", widthClass: "w-44" }, + { id: "row-8", widthClass: "w-32" }, + ]; + + return ( +
+
+ {rows.map((row) => ( +
+ + +
+ ))} +
+
+ ); +} + type FilesystemSettings = { mode: "cloud" | "desktop_local_folder"; localRootPaths: string[]; @@ -407,8 +434,8 @@ function AuthenticatedDocumentsSidebar({ ); // Zero queries for tree data - const [zeroFolders] = useQuery(queries.folders.bySpace({ searchSpaceId })); - const [zeroAllDocs] = useQuery(queries.documents.bySpace({ searchSpaceId })); + const [zeroFolders, zeroFoldersResult] = useQuery(queries.folders.bySpace({ searchSpaceId })); + const [zeroAllDocs, zeroAllDocsResult] = useQuery(queries.documents.bySpace({ searchSpaceId })); const [agentCreatedDocs, setAgentCreatedDocs] = useAtom(agentCreatedDocumentsAtom); const treeFolders: FolderDisplay[] = useMemo( @@ -989,6 +1016,9 @@ function AuthenticatedDocumentsSidebar({ const showFilesystemTabs = !isMobile && !!electronAPI && !!filesystemSettings; const currentFilesystemTab = filesystemSettings?.mode === "desktop_local_folder" ? "local" : "cloud"; + const showCloudSkeleton = + currentFilesystemTab === "cloud" && + (zeroFoldersResult.type !== "complete" || zeroAllDocsResult.type !== "complete"); const cloudContent = ( <> @@ -1101,45 +1131,49 @@ function AuthenticatedDocumentsSidebar({
)} - { - openEditorPanel({ - documentId: doc.id, - searchSpaceId, - title: doc.title, - }); - }} - onEditDocument={(doc) => { - openEditorPanel({ - documentId: doc.id, - searchSpaceId, - title: doc.title, - }); - }} - onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} - onMoveDocument={handleMoveDocument} - onExportDocument={handleExportDocument} - onVersionHistory={(doc) => setVersionDocId(doc.id)} - activeTypes={activeTypes} - onDropIntoFolder={handleDropIntoFolder} - onReorderFolder={handleReorderFolder} - watchedFolderIds={watchedFolderIds} - onRescanFolder={handleRescanFolder} - onStopWatchingFolder={handleStopWatching} - onExportFolder={handleExportFolder} - /> + {showCloudSkeleton ? ( + + ) : ( + { + openEditorPanel({ + documentId: doc.id, + searchSpaceId, + title: doc.title, + }); + }} + onEditDocument={(doc) => { + openEditorPanel({ + documentId: doc.id, + searchSpaceId, + title: doc.title, + }); + }} + onDeleteDocument={(doc) => handleDeleteDocument(doc.id)} + onMoveDocument={handleMoveDocument} + onExportDocument={handleExportDocument} + onVersionHistory={(doc) => setVersionDocId(doc.id)} + activeTypes={activeTypes} + onDropIntoFolder={handleDropIntoFolder} + onReorderFolder={handleReorderFolder} + watchedFolderIds={watchedFolderIds} + onRescanFolder={handleRescanFolder} + onStopWatchingFolder={handleStopWatching} + onExportFolder={handleExportFolder} + /> + )} diff --git a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx index 5b08f2e37..30e532896 100644 --- a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx +++ b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx @@ -1,8 +1,9 @@ "use client"; import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog"; +import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { useElectronAPI } from "@/hooks/use-platform"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; @@ -39,6 +40,8 @@ type LocalRootMount = { rootPath: string; }; +type MountLoadStatus = "idle" | "loading" | "complete" | "error"; + const getFolderDisplayName = (rootPath: string): string => rootPath.split(/[\\/]/).at(-1) || rootPath; @@ -79,6 +82,10 @@ export function LocalFilesystemBrowser({ const [rootStateMap, setRootStateMap] = useState>({}); const [expandedFolderKeys, setExpandedFolderKeys] = useState>(new Set()); const [mountByRootKey, setMountByRootKey] = useState>(new Map()); + const [mountStatus, setMountStatus] = useState("idle"); + const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false); + const hasLoadedMountsOnceRef = useRef(false); + const hasResolvedAtLeastOneRootRef = useRef(false); const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []); const isWindowsPlatform = electronAPI?.versions.platform === "win32"; @@ -139,23 +146,44 @@ export function LocalFilesystemBrowser({ useEffect(() => { if (!electronAPI?.getAgentFilesystemMounts) { + setMountStatus("error"); setMountByRootKey(new Map()); return; } let cancelled = false; + const isInitialMountLoad = !hasLoadedMountsOnceRef.current; + if (isInitialMountLoad) { + setMountStatus("loading"); + } else { + setMountRefreshInFlight(true); + } void electronAPI .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) { - next.set(normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform), entry.mount); + const normalizedRootKey = normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform); + if (!knownRootKeys.has(normalizedRootKey)) continue; + next.set(normalizedRootKey, entry.mount); } setMountByRootKey(next); + setMountStatus("complete"); + hasLoadedMountsOnceRef.current = true; }) .catch(() => { if (cancelled) return; - setMountByRootKey(new Map()); + if (isInitialMountLoad) { + setMountByRootKey(new Map()); + setMountStatus("error"); + } + }) + .finally(() => { + if (cancelled) return; + setMountRefreshInFlight(false); }); return () => { cancelled = true; @@ -265,6 +293,43 @@ export function LocalFilesystemBrowser({ ); } + const allRootsLoaded = rootPaths.every((rootPath) => { + const state = rootStateMap[rootPath]; + return !!state && !state.loading; + }); + const mountsSettled = mountStatus === "complete" || mountStatus === "error"; + if (allRootsLoaded && mountsSettled && rootPaths.length > 0) { + hasResolvedAtLeastOneRootRef.current = true; + } + const showInitialLoading = + !hasResolvedAtLeastOneRootRef.current && (!allRootsLoaded || !mountsSettled); + + if (showInitialLoading) { + const rows = [ + { id: "local-row-1", widthClass: "w-44" }, + { id: "local-row-2", widthClass: "w-32" }, + { id: "local-row-3", widthClass: "w-32" }, + { id: "local-row-4", widthClass: "w-44" }, + { id: "local-row-5", widthClass: "w-32" }, + { id: "local-row-6", widthClass: "w-32" }, + { id: "local-row-7", widthClass: "w-44" }, + { id: "local-row-8", widthClass: "w-32" }, + ]; + + return ( +
+
+ {rows.map((row) => ( +
+ + +
+ ))} +
+
+ ); + } + return (
{treeByRoot.map(({ rootPath, rootNode, matchCount, totalCount }) => { @@ -273,9 +338,11 @@ export function LocalFilesystemBrowser({ const mount = mountByRootKey.get(rootKey); if (!state || state.loading) { return ( -
- - Loading {getFolderDisplayName(rootPath)}... +
+
+ + Loading {getFolderDisplayName(rootPath)}... +
); } @@ -291,11 +358,24 @@ export function LocalFilesystemBrowser({ return (
{mount ? renderFolder(rootNode, 0, mount) : null} - {!mount && ( + {!mount && (mountRefreshInFlight || mountStatus === "loading") && ( +
+
+ + Loading {getFolderDisplayName(rootPath)}... +
+
+ )} + {!mount && mountStatus === "complete" && !mountRefreshInFlight && (
Unable to resolve mounted root for this folder.
)} + {!mount && mountStatus === "error" && ( +
+ Failed to resolve local folder mounts. +
+ )} {isEmpty && (
No supported files found in this folder. From 95511f0915afa3cb36af3e23af217416215c075d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:58:12 +0530 Subject: [PATCH 03/14] feat(sidebar): implement canonicalize roots, authoritative mount handling & preserved incremental UX for local folder mode --- .../middleware/test_filesystem_backends.py | 2 + .../test_multi_root_local_folder_backend.py | 9 +++ .../src/modules/agent-filesystem.ts | 26 ++++--- .../layout/ui/sidebar/DocumentsSidebar.tsx | 72 ++++++++++++++++--- .../ui/sidebar/LocalFilesystemBrowser.tsx | 11 +-- 5 files changed, 98 insertions(+), 22 deletions(-) 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); From 6aa172a7306adfdd892285e30a2ca949fb41eec4 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:07:02 +0530 Subject: [PATCH 04/14] feat(filesystem): increase max local roots to 10, optimize path normalization, and implement caching for filesystem settings --- .../src/modules/agent-filesystem.ts | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/surfsense_desktop/src/modules/agent-filesystem.ts b/surfsense_desktop/src/modules/agent-filesystem.ts index eb8d385b5..a62b84f70 100644 --- a/surfsense_desktop/src/modules/agent-filesystem.ts +++ b/surfsense_desktop/src/modules/agent-filesystem.ts @@ -11,7 +11,8 @@ export interface AgentFilesystemSettings { } const SETTINGS_FILENAME = "agent-filesystem-settings.json"; -const MAX_LOCAL_ROOTS = 5; +const MAX_LOCAL_ROOTS = 10; +let cachedSettings: AgentFilesystemSettings | null = null; function getSettingsPath(): string { return join(app.getPath("userData"), SETTINGS_FILENAME); @@ -34,7 +35,7 @@ async function canonicalizeRootPath(pathValue: string): Promise { } } -async function normalizeLocalRootPaths(paths: unknown): Promise { +function normalizeLocalRootPaths(paths: unknown): string[] { if (!Array.isArray(paths)) { return []; } @@ -43,8 +44,22 @@ async function normalizeLocalRootPaths(paths: unknown): Promise { if (typeof rawPath !== "string") continue; const trimmed = rawPath.trim(); if (!trimmed) continue; - const canonicalRootPath = await canonicalizeRootPath(trimmed); - uniquePaths.add(canonicalRootPath); + uniquePaths.add(trimmed); + if (uniquePaths.size >= MAX_LOCAL_ROOTS) { + break; + } + } + return [...uniquePaths]; +} + +async function normalizeLocalRootPathsCanonical(paths: unknown): Promise { + const normalizedPaths = normalizeLocalRootPaths(paths); + const canonicalizedPaths = await Promise.all( + normalizedPaths.map((pathValue) => canonicalizeRootPath(pathValue)) + ); + const uniquePaths = new Set(); + for (const canonicalPath of canonicalizedPaths) { + uniquePaths.add(canonicalPath); if (uniquePaths.size >= MAX_LOCAL_ROOTS) { break; } @@ -53,19 +68,26 @@ async function normalizeLocalRootPaths(paths: unknown): Promise { } export async function getAgentFilesystemSettings(): Promise { + if (cachedSettings) { + return cachedSettings; + } try { const raw = await readFile(getSettingsPath(), "utf8"); const parsed = JSON.parse(raw) as Partial; if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") { - return getDefaultSettings(); + cachedSettings = getDefaultSettings(); + return cachedSettings; } - return { + cachedSettings = { mode: parsed.mode, - localRootPaths: await normalizeLocalRootPaths(parsed.localRootPaths), + // Avoid filesystem I/O during reads; canonicalize paths on write. + localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths), updatedAt: parsed.updatedAt ?? new Date().toISOString(), }; + return cachedSettings; } catch { - return getDefaultSettings(); + cachedSettings = getDefaultSettings(); + return cachedSettings; } } @@ -85,13 +107,14 @@ export async function setAgentFilesystemSettings( localRootPaths: settings.localRootPaths === undefined ? current.localRootPaths - : await normalizeLocalRootPaths(settings.localRootPaths ?? []), + : await normalizeLocalRootPathsCanonical(settings.localRootPaths ?? []), updatedAt: new Date().toISOString(), }; const settingsPath = getSettingsPath(); await mkdir(dirname(settingsPath), { recursive: true }); await writeFile(settingsPath, JSON.stringify(next, null, 2), "utf8"); + cachedSettings = next; return next; } From 86e2dc8a5dc7cde161e748c6df2ee42c315caf9e Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:20:14 +0530 Subject: [PATCH 05/14] refactor(filesystem): remove unused drag-and-drop functionality in DocumentsSidebar --- .../layout/ui/sidebar/DocumentsSidebar.tsx | 46 +------------------ 1 file changed, 1 insertion(+), 45 deletions(-) diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index fc8db0386..990a4eb99 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -205,7 +205,6 @@ function AuthenticatedDocumentsSidebar({ const [filesystemSettings, setFilesystemSettings] = useState(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); @@ -260,26 +259,6 @@ 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(); @@ -1230,30 +1209,7 @@ function AuthenticatedDocumentsSidebar({ event.preventDefault()} - draggable - onDragStart={(event) => { - event.dataTransfer.setData("text/plain", rootPath); - event.dataTransfer.effectAllowed = "move"; - setDraggedLocalRootPath(rootPath); - }} - 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" : "" - }`} + className="group h-8 gap-1.5 px-1.5 text-sm text-foreground" > From 27e16231c1a548471624724c22c6001d8d91fc2d Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:00:40 +0530 Subject: [PATCH 06/14] feat(filesystem): enhance agent filesystem API with searchSpaceId support for improved context handling --- surfsense_desktop/src/ipc/channels.ts | 1 + surfsense_desktop/src/ipc/handlers.ts | 45 +++- .../src/modules/agent-filesystem.ts | 252 +++++++++++++++--- surfsense_desktop/src/preload.ts | 25 +- .../new-chat/[[...chat_id]]/page.tsx | 6 +- .../components/editor-panel/editor-panel.tsx | 10 +- .../layout/ui/sidebar/DocumentsSidebar.tsx | 21 +- .../ui/sidebar/LocalFilesystemBrowser.tsx | 42 ++- surfsense_web/lib/agent-filesystem.ts | 8 +- surfsense_web/types/window.d.ts | 24 +- 10 files changed, 349 insertions(+), 85 deletions(-) diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index ccd166899..ec676fba8 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -56,6 +56,7 @@ export const IPC_CHANNELS = { // Agent filesystem mode AGENT_FILESYSTEM_GET_SETTINGS: 'agent-filesystem:get-settings', AGENT_FILESYSTEM_GET_MOUNTS: 'agent-filesystem:get-mounts', + AGENT_FILESYSTEM_LIST_FILES: 'agent-filesystem:list-files', AGENT_FILESYSTEM_SET_SETTINGS: 'agent-filesystem:set-settings', AGENT_FILESYSTEM_PICK_ROOT: 'agent-filesystem:pick-root', } as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index 54882f4ee..4054255f4 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -37,6 +37,7 @@ import { trackEvent, } from '../modules/analytics'; import { + listAgentFilesystemFiles, readAgentLocalFileText, writeAgentLocalFileText, getAgentFilesystemMounts, @@ -126,21 +127,24 @@ export function registerIpcHandlers(): void { readLocalFiles(paths) ); - ipcMain.handle(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, async (_event, virtualPath: string) => { + ipcMain.handle( + IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, + async (_event, virtualPath: string, searchSpaceId?: number | null) => { try { - const result = await readAgentLocalFileText(virtualPath); + const result = await readAgentLocalFileText(virtualPath, searchSpaceId); return { ok: true, path: result.path, content: result.content }; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to read local file'; return { ok: false, path: virtualPath, error: message }; } - }); + } + ); ipcMain.handle( IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, - async (_event, virtualPath: string, content: string) => { + async (_event, virtualPath: string, content: string, searchSpaceId?: number | null) => { try { - const result = await writeAgentLocalFileText(virtualPath, content); + const result = await writeAgentLocalFileText(virtualPath, content, searchSpaceId); return { ok: true, path: result.path }; } catch (error) { const message = error instanceof Error ? error.message : 'Failed to write local file'; @@ -223,18 +227,37 @@ export function registerIpcHandlers(): void { }; }); - ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, () => - getAgentFilesystemSettings() + ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, (_event, searchSpaceId?: number | null) => + getAgentFilesystemSettings(searchSpaceId) ); - ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, () => - getAgentFilesystemMounts() + ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, (_event, searchSpaceId?: number | null) => + getAgentFilesystemMounts(searchSpaceId) + ); + + ipcMain.handle( + IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES, + ( + _event, + options: { + rootPath: string; + searchSpaceId?: number | null; + excludePatterns?: string[] | null; + fileExtensions?: string[] | null; + } + ) => + listAgentFilesystemFiles(options) ); ipcMain.handle( IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, - (_event, settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | null }) => - setAgentFilesystemSettings(settings) + ( + _event, + payload: { + searchSpaceId?: number | null; + settings: { mode?: 'cloud' | 'desktop_local_folder'; localRootPaths?: string[] | null }; + } + ) => setAgentFilesystemSettings(payload?.searchSpaceId, payload?.settings ?? {}) ); ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT, () => diff --git a/surfsense_desktop/src/modules/agent-filesystem.ts b/surfsense_desktop/src/modules/agent-filesystem.ts index a62b84f70..d8c64b79a 100644 --- a/surfsense_desktop/src/modules/agent-filesystem.ts +++ b/surfsense_desktop/src/modules/agent-filesystem.ts @@ -1,6 +1,7 @@ import { app, dialog } from "electron"; -import { access, mkdir, readFile, realpath, writeFile } from "node:fs/promises"; -import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import type { Dirent } from "node:fs"; +import { access, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises"; +import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path"; export type AgentFilesystemMode = "cloud" | "desktop_local_folder"; @@ -10,9 +11,15 @@ export interface AgentFilesystemSettings { updatedAt: string; } +type AgentFilesystemSettingsStore = { + version: 2; + spaces: Record; +}; + const SETTINGS_FILENAME = "agent-filesystem-settings.json"; const MAX_LOCAL_ROOTS = 10; -let cachedSettings: AgentFilesystemSettings | null = null; +const DEFAULT_SPACE_KEY = "default"; +let cachedSettingsStore: AgentFilesystemSettingsStore | null = null; function getSettingsPath(): string { return join(app.getPath("userData"), SETTINGS_FILENAME); @@ -67,37 +74,97 @@ async function normalizeLocalRootPathsCanonical(paths: unknown): Promise { - if (cachedSettings) { - return cachedSettings; +function normalizeSearchSpaceKey(searchSpaceId?: number | null): string { + if (typeof searchSpaceId === "number" && Number.isFinite(searchSpaceId) && searchSpaceId > 0) { + return String(searchSpaceId); } + return DEFAULT_SPACE_KEY; +} + +function toSettingsFromUnknown(value: unknown): AgentFilesystemSettings | null { + if (!value || typeof value !== "object") { + return null; + } + const parsed = value as Partial; + if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") { + return null; + } + return { + mode: parsed.mode, + localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths), + updatedAt: parsed.updatedAt ?? new Date().toISOString(), + }; +} + +function getDefaultStore(): AgentFilesystemSettingsStore { + return { version: 2, spaces: {} }; +} + +function getSettingsFromStore( + store: AgentFilesystemSettingsStore, + searchSpaceId?: number | null +): AgentFilesystemSettings { + const key = normalizeSearchSpaceKey(searchSpaceId); + return store.spaces[key] ?? getDefaultSettings(); +} + +async function loadAgentFilesystemSettingsStore(): Promise { + if (cachedSettingsStore) { + return cachedSettingsStore; + } + const settingsPath = getSettingsPath(); try { - const raw = await readFile(getSettingsPath(), "utf8"); - const parsed = JSON.parse(raw) as Partial; - if (parsed.mode !== "cloud" && parsed.mode !== "desktop_local_folder") { - cachedSettings = getDefaultSettings(); - return cachedSettings; + const raw = await readFile(settingsPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + const nextStore = getDefaultStore(); + if ( + parsed && + typeof parsed === "object" && + "version" in parsed && + "spaces" in parsed && + (parsed as { version?: unknown }).version === 2 + ) { + const parsedStore = parsed as { spaces?: Record; version: 2 }; + if (parsedStore.spaces && typeof parsedStore.spaces === "object") { + for (const [spaceKey, rawSettings] of Object.entries(parsedStore.spaces)) { + const normalizedSettings = toSettingsFromUnknown(rawSettings); + if (normalizedSettings) { + nextStore.spaces[String(spaceKey)] = normalizedSettings; + } + } + } + } else { + // Strict migration: reject legacy/non-scoped settings and reset. + await mkdir(dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8"); } - cachedSettings = { - mode: parsed.mode, - // Avoid filesystem I/O during reads; canonicalize paths on write. - localRootPaths: normalizeLocalRootPaths(parsed.localRootPaths), - updatedAt: parsed.updatedAt ?? new Date().toISOString(), - }; - return cachedSettings; + cachedSettingsStore = nextStore; + return nextStore; } catch { - cachedSettings = getDefaultSettings(); - return cachedSettings; + cachedSettingsStore = getDefaultStore(); + await mkdir(dirname(settingsPath), { recursive: true }); + await writeFile(settingsPath, JSON.stringify(cachedSettingsStore, null, 2), "utf8"); + return cachedSettingsStore; } } +export async function getAgentFilesystemSettings( + searchSpaceId?: number | null +): Promise { + const store = await loadAgentFilesystemSettingsStore(); + return getSettingsFromStore(store, searchSpaceId); +} + export async function setAgentFilesystemSettings( + searchSpaceId: number | null | undefined, settings: { mode?: AgentFilesystemMode; localRootPaths?: string[] | null; } ): Promise { - const current = await getAgentFilesystemSettings(); + const store = await loadAgentFilesystemSettingsStore(); + const key = normalizeSearchSpaceKey(searchSpaceId); + const current = getSettingsFromStore(store, searchSpaceId); const nextMode = settings.mode === "cloud" || settings.mode === "desktop_local_folder" ? settings.mode @@ -113,8 +180,15 @@ export async function setAgentFilesystemSettings( const settingsPath = getSettingsPath(); await mkdir(dirname(settingsPath), { recursive: true }); - await writeFile(settingsPath, JSON.stringify(next, null, 2), "utf8"); - cachedSettings = next; + const nextStore: AgentFilesystemSettingsStore = { + version: 2, + spaces: { + ...store.spaces, + [key]: next, + }, + }; + await writeFile(settingsPath, JSON.stringify(nextStore, null, 2), "utf8"); + cachedSettingsStore = nextStore; return next; } @@ -160,6 +234,20 @@ export type LocalRootMount = { rootPath: string; }; +export type AgentFilesystemListOptions = { + rootPath: string; + searchSpaceId?: number | null; + excludePatterns?: string[] | null; + fileExtensions?: string[] | null; +}; + +export type AgentFilesystemFileEntry = { + relativePath: string; + fullPath: string; + size: number; + mtimeMs: number; +}; + function sanitizeMountName(rawMount: string): string { const normalized = rawMount .trim() @@ -188,11 +276,111 @@ function buildRootMounts(rootPaths: string[]): LocalRootMount[] { return mounts; } -export async function getAgentFilesystemMounts(): Promise { - const rootPaths = await resolveCurrentRootPaths(); +export async function getAgentFilesystemMounts( + searchSpaceId?: number | null +): Promise { + const rootPaths = await resolveCurrentRootPaths(searchSpaceId); return buildRootMounts(rootPaths); } +function normalizeComparablePath(pathValue: string): string { + const normalized = resolve(pathValue); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + +function normalizeExtensionSet(fileExtensions: string[] | null | undefined): Set | null { + if (!fileExtensions || fileExtensions.length === 0) { + return null; + } + const set = new Set(); + for (const extension of fileExtensions) { + if (typeof extension !== "string") continue; + const trimmed = extension.trim().toLowerCase(); + if (!trimmed) continue; + set.add(trimmed.startsWith(".") ? trimmed : `.${trimmed}`); + } + return set.size > 0 ? set : null; +} + +function normalizeExcludeSet(excludePatterns: string[] | null | undefined): Set { + const set = new Set(); + for (const pattern of excludePatterns ?? []) { + if (typeof pattern !== "string") continue; + const trimmed = pattern.trim(); + if (!trimmed) continue; + set.add(trimmed); + } + return set; +} + +export async function listAgentFilesystemFiles( + options: AgentFilesystemListOptions +): Promise { + const allowedRootPaths = await resolveCurrentRootPaths(options.searchSpaceId); + const requestedRootPath = await canonicalizeRootPath(options.rootPath); + const normalizedRequestedRoot = normalizeComparablePath(requestedRootPath); + const allowedRoots = new Set( + ( + await Promise.all(allowedRootPaths.map((rootPath) => canonicalizeRootPath(rootPath))) + ).map((rootPath) => normalizeComparablePath(rootPath)) + ); + if (!allowedRoots.has(normalizedRequestedRoot)) { + throw new Error("Selected path is not an allowed local root"); + } + + const excludePatterns = normalizeExcludeSet(options.excludePatterns); + const extensionSet = normalizeExtensionSet(options.fileExtensions); + const files: AgentFilesystemFileEntry[] = []; + const stack: string[] = [requestedRootPath]; + + while (stack.length > 0) { + const currentDir = stack.pop(); + if (!currentDir) continue; + let entries: Dirent[]; + try { + entries = await readdir(currentDir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (entry.name.startsWith(".") || excludePatterns.has(entry.name)) { + continue; + } + const absolutePath = join(currentDir, entry.name); + if (entry.isDirectory()) { + stack.push(absolutePath); + continue; + } + if (!entry.isFile()) { + continue; + } + if (extensionSet) { + const extension = extname(entry.name).toLowerCase(); + if (!extensionSet.has(extension)) { + continue; + } + } + try { + const fileStat = await stat(absolutePath); + if (!fileStat.isFile()) { + continue; + } + files.push({ + relativePath: relative(requestedRootPath, absolutePath).replace(/\\/g, "/"), + fullPath: absolutePath, + size: fileStat.size, + mtimeMs: fileStat.mtimeMs, + }); + } catch { + // Files can disappear while scanning. + } + } + } + + return files; +} + function parseMountedVirtualPath( virtualPath: string, mounts: LocalRootMount[] @@ -231,8 +419,8 @@ function toMountedVirtualPath(mount: string, rootPath: string, absolutePath: str return `/${mount}${relativePath}`; } -async function resolveCurrentRootPaths(): Promise { - const settings = await getAgentFilesystemSettings(); +async function resolveCurrentRootPaths(searchSpaceId?: number | null): Promise { + const settings = await getAgentFilesystemSettings(searchSpaceId); if (settings.localRootPaths.length === 0) { throw new Error("No local filesystem roots selected"); } @@ -240,9 +428,10 @@ async function resolveCurrentRootPaths(): Promise { } export async function readAgentLocalFileText( - virtualPath: string + virtualPath: string, + searchSpaceId?: number | null ): Promise<{ path: string; content: string }> { - const rootPaths = await resolveCurrentRootPaths(); + const rootPaths = await resolveCurrentRootPaths(searchSpaceId); const mounts = buildRootMounts(rootPaths); const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts); const rootMount = findMountByName(mounts, mount); @@ -261,9 +450,10 @@ export async function readAgentLocalFileText( export async function writeAgentLocalFileText( virtualPath: string, - content: string + content: string, + searchSpaceId?: number | null ): Promise<{ path: string }> { - const rootPaths = await resolveCurrentRootPaths(); + const rootPaths = await resolveCurrentRootPaths(searchSpaceId); const mounts = buildRootMounts(rootPaths); const { mount, subPath } = parseMountedVirtualPath(virtualPath, mounts); const rootMount = findMountByName(mounts, mount); diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 9c538f691..8e5c2f56b 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -71,10 +71,10 @@ contextBridge.exposeInMainWorld('electronAPI', { // Browse files via native dialog browseFiles: () => ipcRenderer.invoke(IPC_CHANNELS.BROWSE_FILES), readLocalFiles: (paths: string[]) => ipcRenderer.invoke(IPC_CHANNELS.READ_LOCAL_FILES, paths), - readAgentLocalFileText: (virtualPath: string) => - ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath), - writeAgentLocalFileText: (virtualPath: string, content: string) => - ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content), + readAgentLocalFileText: (virtualPath: string, searchSpaceId?: number | null) => + ipcRenderer.invoke(IPC_CHANNELS.READ_AGENT_LOCAL_FILE_TEXT, virtualPath, searchSpaceId), + writeAgentLocalFileText: (virtualPath: string, content: string, searchSpaceId?: number | null) => + ipcRenderer.invoke(IPC_CHANNELS.WRITE_AGENT_LOCAL_FILE_TEXT, virtualPath, content, searchSpaceId), // Auth token sync across windows getAuthTokens: () => ipcRenderer.invoke(IPC_CHANNELS.GET_AUTH_TOKENS), @@ -106,13 +106,20 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_CAPTURE, { event, properties }), getAnalyticsContext: () => ipcRenderer.invoke(IPC_CHANNELS.ANALYTICS_GET_CONTEXT), // Agent filesystem mode - getAgentFilesystemSettings: () => - ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS), - getAgentFilesystemMounts: () => - ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS), + getAgentFilesystemSettings: (searchSpaceId?: number | null) => + ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_SETTINGS, searchSpaceId), + getAgentFilesystemMounts: (searchSpaceId?: number | null) => + ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_GET_MOUNTS, searchSpaceId), + listAgentFilesystemFiles: (options: { + rootPath: string; + searchSpaceId?: number | null; + excludePatterns?: string[] | null; + fileExtensions?: string[] | null; + }) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES, options), setAgentFilesystemSettings: (settings: { mode?: "cloud" | "desktop_local_folder"; localRootPaths?: string[] | null; - }) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, settings), + }, searchSpaceId?: number | null) => + ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_SET_SETTINGS, { searchSpaceId, settings }), pickAgentFilesystemRoot: () => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT), }); diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 62332d2c4..06f3bf79f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -658,7 +658,7 @@ export default function NewChatPage() { try { const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; - const selection = await getAgentFilesystemSelection(); + const selection = await getAgentFilesystemSelection(searchSpaceId); if ( selection.filesystem_mode === "desktop_local_folder" && (!selection.local_filesystem_mounts || @@ -1088,7 +1088,7 @@ export default function NewChatPage() { try { const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; - const selection = await getAgentFilesystemSelection(); + const selection = await getAgentFilesystemSelection(searchSpaceId); const response = await fetch(`${backendUrl}/api/v1/threads/${resumeThreadId}/resume`, { method: "POST", headers: { @@ -1424,7 +1424,7 @@ export default function NewChatPage() { ]); try { - const selection = await getAgentFilesystemSelection(); + const selection = await getAgentFilesystemSelection(searchSpaceId); const response = await fetch(getRegenerateUrl(threadId), { method: "POST", headers: { diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx index 1f1b41c3e..a9fe886e1 100644 --- a/surfsense_web/components/editor-panel/editor-panel.tsx +++ b/surfsense_web/components/editor-panel/editor-panel.tsx @@ -124,7 +124,10 @@ export function EditorPanelContent({ if (!electronAPI?.readAgentLocalFileText) { throw new Error("Local file editor is available only in desktop mode."); } - const readResult = await electronAPI.readAgentLocalFileText(localFilePath); + const readResult = await electronAPI.readAgentLocalFileText( + localFilePath, + searchSpaceId + ); if (!readResult.ok) { throw new Error(readResult.error || "Failed to read local file"); } @@ -226,7 +229,7 @@ export function EditorPanelContent({ } }, [editorDoc?.source_markdown]); - const handleSave = useCallback(async (options?: { silent?: boolean }) => { + const handleSave = useCallback(async (_options?: { silent?: boolean }) => { setSaving(true); try { if (isLocalFileMode) { @@ -239,7 +242,8 @@ export function EditorPanelContent({ const contentToSave = markdownRef.current; const writeResult = await electronAPI.writeAgentLocalFileText( localFilePath, - contentToSave + contentToSave, + searchSpaceId ); if (!writeResult.ok) { throw new Error(writeResult.error || "Failed to save local file"); diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 990a4eb99..3b747b15a 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -214,7 +214,7 @@ function AuthenticatedDocumentsSidebar({ if (!electronAPI?.getAgentFilesystemSettings) return; let mounted = true; electronAPI - .getAgentFilesystemSettings() + .getAgentFilesystemSettings(searchSpaceId) .then((settings: FilesystemSettings) => { if (!mounted) return; setFilesystemSettings(settings); @@ -230,7 +230,7 @@ function AuthenticatedDocumentsSidebar({ return () => { mounted = false; }; - }, [electronAPI]); + }, [electronAPI, searchSpaceId]); const hasLocalFilesystemTrust = useCallback(() => { try { @@ -253,10 +253,10 @@ function AuthenticatedDocumentsSidebar({ const updated = await electronAPI.setAgentFilesystemSettings({ mode: "desktop_local_folder", localRootPaths: nextLocalRootPaths, - }); + }, searchSpaceId); setFilesystemSettings(updated); }, - [electronAPI, localRootPaths] + [electronAPI, localRootPaths, searchSpaceId] ); const runPickLocalRoot = useCallback(async () => { @@ -285,10 +285,10 @@ function AuthenticatedDocumentsSidebar({ const updated = await electronAPI.setAgentFilesystemSettings({ mode: "desktop_local_folder", localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove), - }); + }, searchSpaceId); setFilesystemSettings(updated); }, - [electronAPI, localRootPaths] + [electronAPI, localRootPaths, searchSpaceId] ); const handleClearFilesystemRoots = useCallback(async () => { @@ -296,19 +296,19 @@ function AuthenticatedDocumentsSidebar({ const updated = await electronAPI.setAgentFilesystemSettings({ mode: "desktop_local_folder", localRootPaths: [], - }); + }, searchSpaceId); setFilesystemSettings(updated); - }, [electronAPI]); + }, [electronAPI, searchSpaceId]); const handleFilesystemTabChange = useCallback( async (tab: "cloud" | "local") => { if (!electronAPI?.setAgentFilesystemSettings) return; const updated = await electronAPI.setAgentFilesystemSettings({ mode: tab === "cloud" ? "cloud" : "desktop_local_folder", - }); + }, searchSpaceId); setFilesystemSettings(updated); }, - [electronAPI] + [electronAPI, searchSpaceId] ); // AI File Sort state @@ -1323,6 +1323,7 @@ function AuthenticatedDocumentsSidebar({ { openEditorPanel({ diff --git a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx index 39b8ee769..add7cd2d9 100644 --- a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx +++ b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx @@ -11,6 +11,7 @@ import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; interface LocalFilesystemBrowserProps { rootPaths: string[]; searchSpaceId: number; + active?: boolean; searchQuery?: string; onOpenFile: (fullPath: string) => void; } @@ -75,6 +76,7 @@ function toMountedVirtualPath(mount: string, relativePath: string): string { export function LocalFilesystemBrowser({ rootPaths, searchSpaceId, + active = true, searchQuery, onOpenFile, }: LocalFilesystemBrowserProps) { @@ -84,13 +86,36 @@ export function LocalFilesystemBrowser({ const [mountByRootKey, setMountByRootKey] = useState>(new Map()); const [mountStatus, setMountStatus] = useState("idle"); const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false); + const lastLoadedRootsSignatureRef = useRef(""); const hasLoadedMountsOnceRef = useRef(false); const hasResolvedAtLeastOneRootRef = useRef(false); const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []); const isWindowsPlatform = electronAPI?.versions.platform === "win32"; useEffect(() => { - if (!electronAPI?.listFolderFiles) return; + if (!active) return; + if (!electronAPI?.listAgentFilesystemFiles) { + for (const rootPath of rootPaths) { + setRootStateMap((prev) => ({ + ...prev, + [rootPath]: { + loading: false, + error: "Desktop app update required for local mode browsing.", + files: [], + }, + })); + } + return; + } + const rootsSignature = rootPaths + .map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform)) + .sort() + .join("|"); + const settingsSignature = `${searchSpaceId}:${rootsSignature}`; + if (settingsSignature === lastLoadedRootsSignatureRef.current) { + return; + } + lastLoadedRootsSignatureRef.current = settingsSignature; let cancelled = false; for (const rootPath of rootPaths) { @@ -107,14 +132,11 @@ export function LocalFilesystemBrowser({ void Promise.all( rootPaths.map(async (rootPath) => { try { - const files = (await electronAPI.listFolderFiles({ - path: rootPath, - name: getFolderDisplayName(rootPath), + const files = (await electronAPI.listAgentFilesystemFiles({ + rootPath, + searchSpaceId, excludePatterns: DEFAULT_EXCLUDE_PATTERNS, fileExtensions: supportedExtensions, - rootFolderId: null, - searchSpaceId, - active: true, })) as LocalFolderFileEntry[]; if (cancelled) return; setRootStateMap((prev) => ({ @@ -142,7 +164,7 @@ export function LocalFilesystemBrowser({ return () => { cancelled = true; }; - }, [electronAPI, rootPaths, searchSpaceId, supportedExtensions]); + }, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId, supportedExtensions]); useEffect(() => { if (!electronAPI?.getAgentFilesystemMounts) { @@ -165,7 +187,7 @@ export function LocalFilesystemBrowser({ setMountRefreshInFlight(true); } void electronAPI - .getAgentFilesystemMounts() + .getAgentFilesystemMounts(searchSpaceId) .then((mounts: LocalRootMount[]) => { if (cancelled) return; const next = new Map(); @@ -191,7 +213,7 @@ export function LocalFilesystemBrowser({ return () => { cancelled = true; }; - }, [electronAPI, isWindowsPlatform, rootPaths]); + }, [electronAPI, isWindowsPlatform, rootPaths, searchSpaceId]); const treeByRoot = useMemo(() => { const query = searchQuery?.trim().toLowerCase() ?? ""; diff --git a/surfsense_web/lib/agent-filesystem.ts b/surfsense_web/lib/agent-filesystem.ts index 91c366d43..da5fc1b1d 100644 --- a/surfsense_web/lib/agent-filesystem.ts +++ b/surfsense_web/lib/agent-filesystem.ts @@ -22,15 +22,17 @@ export function getClientPlatform(): ClientPlatform { return window.electronAPI ? "desktop" : "web"; } -export async function getAgentFilesystemSelection(): Promise { +export async function getAgentFilesystemSelection( + searchSpaceId?: number | null +): Promise { const platform = getClientPlatform(); if (platform !== "desktop" || !window.electronAPI?.getAgentFilesystemSettings) { return { ...DEFAULT_SELECTION, client_platform: platform }; } try { - const settings = await window.electronAPI.getAgentFilesystemSettings(); + const settings = await window.electronAPI.getAgentFilesystemSettings(searchSpaceId); if (settings.mode === "desktop_local_folder") { - const mounts = await window.electronAPI.getAgentFilesystemMounts?.(); + const mounts = await window.electronAPI.getAgentFilesystemMounts?.(searchSpaceId); const localFilesystemMounts = mounts?.map((entry) => ({ mount_id: entry.mount, diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index e9f29a8f3..d3356d4d1 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -54,6 +54,13 @@ interface AgentFilesystemMount { rootPath: string; } +interface AgentFilesystemListOptions { + rootPath: string; + searchSpaceId?: number | null; + excludePatterns?: string[] | null; + fileExtensions?: string[] | null; +} + interface LocalTextFileResult { ok: boolean; path: string; @@ -114,10 +121,14 @@ interface ElectronAPI { // Browse files/folders via native dialogs browseFiles: () => Promise; readLocalFiles: (paths: string[]) => Promise; - readAgentLocalFileText: (virtualPath: string) => Promise; + readAgentLocalFileText: ( + virtualPath: string, + searchSpaceId?: number | null + ) => Promise; writeAgentLocalFileText: ( virtualPath: string, - content: string + content: string, + searchSpaceId?: number | null ) => Promise; // Auth token sync across windows getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>; @@ -151,12 +162,15 @@ interface ElectronAPI { platform: string; }>; // Agent filesystem mode - getAgentFilesystemSettings: () => Promise; - getAgentFilesystemMounts: () => Promise; + getAgentFilesystemSettings: (searchSpaceId?: number | null) => Promise; + getAgentFilesystemMounts: (searchSpaceId?: number | null) => Promise; + listAgentFilesystemFiles: ( + options: AgentFilesystemListOptions + ) => Promise; setAgentFilesystemSettings: (settings: { mode?: AgentFilesystemMode; localRootPaths?: string[] | null; - }) => Promise; + }, searchSpaceId?: number | null) => Promise; pickAgentFilesystemRoot: () => Promise; } From 1190ee9449626f921af250ce2d88969cca7f6a9a Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:17:47 +0530 Subject: [PATCH 07/14] feat(sidebar): separate DesktopLocalTabContent component for reducing bundle size in web --- .../ui/sidebar/DesktopLocalTabContent.tsx | 187 ++++++++++++++ .../layout/ui/sidebar/DocumentsSidebar.tsx | 229 ++++-------------- 2 files changed, 233 insertions(+), 183 deletions(-) create mode 100644 surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx diff --git a/surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx b/surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx new file mode 100644 index 000000000..6fd4e48f8 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { Folder, FolderPlus, Search, X } from "lucide-react"; +import { useRef, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser"; + +const getFolderDisplayName = (rootPath: string): string => + rootPath.split(/[\\/]/).at(-1) || rootPath; + +interface DesktopLocalTabContentProps { + localRootPaths: string[]; + canAddMoreLocalRoots: boolean; + maxLocalFilesystemRoots: number; + searchSpaceId: number; + onPickFilesystemRoot: () => Promise | void; + onRemoveFilesystemRoot: (rootPath: string) => Promise | void; + onClearFilesystemRoots: () => Promise | void; + onOpenLocalFile: (localFilePath: string) => void; + electronAvailable: boolean; +} + +export function DesktopLocalTabContent({ + localRootPaths, + canAddMoreLocalRoots, + maxLocalFilesystemRoots, + searchSpaceId, + onPickFilesystemRoot, + onRemoveFilesystemRoot, + onClearFilesystemRoots, + onOpenLocalFile, + electronAvailable, +}: DesktopLocalTabContentProps) { + const [localSearch, setLocalSearch] = useState(""); + const debouncedLocalSearch = useDebouncedValue(localSearch, 250); + const localSearchInputRef = useRef(null); + + return ( +
+
+
+ {localRootPaths.length > 0 ? ( + + + + + + + Selected folders + + + {localRootPaths.map((rootPath) => ( + event.preventDefault()} + className="group h-8 gap-1.5 px-1.5 text-sm text-foreground" + > + + + {getFolderDisplayName(rootPath)} + + + + ))} + + { + void onClearFilesystemRoots(); + }} + > + Clear all folders + + + + ) : ( +
+ + No local folders selected +
+ )} + + {electronAvailable ? ( + + + + + + + + {canAddMoreLocalRoots + ? "Add folder" + : `You can add up to ${maxLocalFilesystemRoots} folders`} + + + ) : null} +
+
+
+
+
+
+ setLocalSearch(e.target.value)} + placeholder="Search local files" + type="text" + aria-label="Search local files" + /> + {Boolean(localSearch) && ( + + )} +
+
+ +
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 3b747b15a..5b9157b28 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -6,19 +6,17 @@ import { ChevronLeft, ChevronRight, FileText, - Folder, - FolderPlus, FolderClock, Laptop, Lock, Paperclip, - Search, Server, Trash2, Unplug, Upload, X, } from "lucide-react"; +import dynamic from "next/dynamic"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -49,7 +47,6 @@ import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { DEFAULT_EXCLUDE_PATTERNS, FolderWatchDialog, - type SelectedFolder, } from "@/components/sources/FolderWatchDialog"; import { AlertDialog, @@ -63,18 +60,8 @@ import { } from "@/components/ui/alert-dialog"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; -import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; -import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -84,7 +71,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; -import { useElectronAPI } from "@/hooks/use-platform"; +import { usePlatform, useElectronAPI } from "@/hooks/use-platform"; import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { foldersApiService } from "@/lib/apis/folders-api.service"; @@ -93,9 +80,13 @@ import { authenticatedFetch } from "@/lib/auth-utils"; import { uploadFolderScan } from "@/lib/folder-sync-upload"; import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; import { queries } from "@/zero/queries/index"; -import { LocalFilesystemBrowser } from "./LocalFilesystemBrowser"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; +const DesktopLocalTabContent = dynamic( + () => import("./DesktopLocalTabContent").then((mod) => mod.DesktopLocalTabContent), + { ssr: false } +); + const NON_DELETABLE_DOCUMENT_TYPES: readonly string[] = ["SURFSENSE_DOCS"]; const LOCAL_FILESYSTEM_TRUST_KEY = "surfsense.local-filesystem-trust.v1"; const MAX_LOCAL_FILESYSTEM_ROOTS = 10; @@ -142,9 +133,6 @@ interface WatchedFolderEntry { active: boolean; } -const getFolderDisplayName = (rootPath: string): string => - rootPath.split(/[\\/]/).at(-1) || rootPath; - const SHOWCASE_CONNECTORS = [ { type: "GOOGLE_DRIVE_CONNECTOR", label: "Google Drive" }, { type: "GOOGLE_GMAIL_CONNECTOR", label: "Gmail" }, @@ -170,25 +158,40 @@ interface DocumentsSidebarProps { export function DocumentsSidebar(props: DocumentsSidebarProps) { const isAnonymous = useIsAnonymous(); + const { isDesktop } = usePlatform(); if (isAnonymous) { return ; } - return ; + return isDesktop ? ( + + ) : ( + + ); } -function AuthenticatedDocumentsSidebar({ +function AuthenticatedDesktopDocumentsSidebar(props: DocumentsSidebarProps) { + return ; +} + +function AuthenticatedWebDocumentsSidebar(props: DocumentsSidebarProps) { + return ; +} + +function AuthenticatedDocumentsSidebarBase({ open, onOpenChange, isDocked = false, onDockedChange, embedded = false, headerAction, -}: DocumentsSidebarProps) { + desktopFeaturesEnabled, +}: DocumentsSidebarProps & { desktopFeaturesEnabled: boolean }) { const t = useTranslations("documents"); const tSidebar = useTranslations("sidebar"); const params = useParams(); const isMobile = !useMediaQuery("(min-width: 640px)"); - const electronAPI = useElectronAPI(); + const platformElectronAPI = useElectronAPI(); + const electronAPI = desktopFeaturesEnabled ? platformElectronAPI : null; const searchSpaceId = Number(params.search_space_id); const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const setRightPanelCollapsed = useSetAtom(rightPanelCollapsedAtom); @@ -198,9 +201,6 @@ function AuthenticatedDocumentsSidebar({ const [search, setSearch] = useState(""); const debouncedSearch = useDebouncedValue(search, 250); - const [localSearch, setLocalSearch] = useState(""); - const debouncedLocalSearch = useDebouncedValue(localSearch, 250); - const localSearchInputRef = useRef(null); const [activeTypes, setActiveTypes] = useState([]); const [filesystemSettings, setFilesystemSettings] = useState(null); const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false); @@ -208,7 +208,7 @@ function AuthenticatedDocumentsSidebar({ const [watchedFolderIds, setWatchedFolderIds] = useState>(new Set()); const [folderWatchOpen, setFolderWatchOpen] = useAtom(folderWatchDialogOpenAtom); const [watchInitialFolder, setWatchInitialFolder] = useAtom(folderWatchInitialFolderAtom); - const isElectron = typeof window !== "undefined" && !!window.electronAPI; + const isElectron = desktopFeaturesEnabled && typeof window !== "undefined" && !!window.electronAPI; useEffect(() => { if (!electronAPI?.getAgentFilesystemSettings) return; @@ -1180,161 +1180,24 @@ function AuthenticatedDocumentsSidebar({ ); const localContent = ( -
-
-
- {localRootPaths.length > 0 ? ( - - - - - - - Selected folders - - - {localRootPaths.map((rootPath) => ( - event.preventDefault()} - className="group h-8 gap-1.5 px-1.5 text-sm text-foreground" - > - - - {getFolderDisplayName(rootPath)} - - - - ))} - - { - void handleClearFilesystemRoots(); - }} - > - Clear all folders - - - - ) : ( -
- - No local folders selected -
- )} - - {electronAPI ? ( - - - - - - - - {canAddMoreLocalRoots - ? "Add folder" - : `You can add up to ${MAX_LOCAL_FILESYSTEM_ROOTS} folders`} - - - ) : ( - - )} -
-
-
-
-
-
- setLocalSearch(e.target.value)} - placeholder="Search local files" - type="text" - aria-label="Search local files" - /> - {Boolean(localSearch) && ( - - )} -
-
- { - openEditorPanel({ - kind: "local_file", - localFilePath, - title: localFilePath.split("/").pop() || localFilePath, - searchSpaceId, - }); - }} - /> -
+ { + openEditorPanel({ + kind: "local_file", + localFilePath, + title: localFilePath.split("/").pop() || localFilePath, + searchSpaceId, + }); + }} + electronAvailable={!!electronAPI} + /> ); const documentsContent = ( @@ -1428,7 +1291,7 @@ function AuthenticatedDocumentsSidebar({ {cloudContent} - {localContent} + {currentFilesystemTab === "local" ? localContent : null} ) : ( From 3fa8c790f5cf7432a7413614b6bd7b92ddf482b8 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 27 Apr 2026 22:32:37 +0530 Subject: [PATCH 08/14] feat(filesystem): add move and list_tree functionalities to enhance local folder operations --- .../agents/new_chat/middleware/filesystem.py | 314 ++++++++++++++++++ .../middleware/local_folder_backend.py | 281 ++++++++++++++++ .../multi_root_local_folder_backend.py | 163 +++++++++ 3 files changed, 758 insertions(+) diff --git a/surfsense_backend/app/agents/new_chat/middleware/filesystem.py b/surfsense_backend/app/agents/new_chat/middleware/filesystem.py index 1706e3705..d7bb339bd 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/filesystem.py +++ b/surfsense_backend/app/agents/new_chat/middleware/filesystem.py @@ -7,6 +7,7 @@ This middleware customizes prompts and persists write/edit operations for from __future__ import annotations import asyncio +import json import logging import re import secrets @@ -141,6 +142,33 @@ IMPORTANT: content. """ +SURFSENSE_MOVE_FILE_TOOL_DESCRIPTION = """Moves or renames a file or folder. + +Use absolute paths for both source and destination. + +Notes: +- In local-folder mode, paths should use mount prefixes (e.g., //foo.txt). +- Rename is a special case of move (same folder, different filename). +- Cross-mount moves are not supported. +""" + +SURFSENSE_LIST_TREE_TOOL_DESCRIPTION = """Lists files/folders recursively with cursor pagination. + +Use this in desktop local-folder mode to discover nested files at scale. + +Args: +- path: absolute mount-prefixed path (e.g., //src) or "/" for mount roots. +- max_depth: recursion depth limit (default 8). +- page_size: number of entries to return per page (max 1000). +- cursor: opaque continuation token from a previous call. +- include_files/include_dirs: filter returned entry types. + +Returns JSON with: +- entries: [{path, is_dir, size, modified_at, depth}] +- next_cursor: continuation token or null +- has_more: whether additional pages exist +""" + SURFSENSE_GLOB_TOOL_DESCRIPTION = """Find files matching a glob pattern. Supports standard glob patterns: `*`, `**`, `?`. @@ -222,11 +250,14 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): ) if filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: system_prompt += ( + "\n- move_file: move or rename files/folders in local-folder mode." + "\n- list_tree: recursively list nested local paths with cursor pagination." "\n\n## Local Folder Mode" "\n\nThis chat is running in desktop local-folder mode." " Keep all file operations local. Do not use save_document." " Always use mount-prefixed absolute paths like //file.ext." " If you are unsure which mounts are available, call ls('/') first." + " For big trees: use list_tree pages, then grep, then read_file." ) super().__init__( @@ -237,6 +268,8 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): "read_file": SURFSENSE_READ_FILE_TOOL_DESCRIPTION, "write_file": SURFSENSE_WRITE_FILE_TOOL_DESCRIPTION, "edit_file": SURFSENSE_EDIT_FILE_TOOL_DESCRIPTION, + "move_file": SURFSENSE_MOVE_FILE_TOOL_DESCRIPTION, + "list_tree": SURFSENSE_LIST_TREE_TOOL_DESCRIPTION, "glob": SURFSENSE_GLOB_TOOL_DESCRIPTION, "grep": SURFSENSE_GREP_TOOL_DESCRIPTION, }, @@ -244,6 +277,9 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): max_execute_timeout=self._MAX_EXECUTE_TIMEOUT, ) self.tools = [t for t in self.tools if t.name != "execute"] + if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: + self.tools.append(self._create_move_file_tool()) + self.tools.append(self._create_list_tree_tool()) if self._should_persist_documents(): self.tools.append(self._create_save_document_tool()) if self._sandbox_available: @@ -836,6 +872,34 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): return f"/{candidate.lstrip('/')}" return candidate + def _resolve_move_target_path( + self, + file_path: str, + runtime: ToolRuntime[None, FilesystemState], + ) -> str: + candidate = file_path.strip() + if not candidate: + return "" + if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: + return self._normalize_local_mount_path(candidate, runtime) + if not candidate.startswith("/"): + return f"/{candidate.lstrip('/')}" + return candidate + + def _resolve_list_target_path( + self, + path: str, + runtime: ToolRuntime[None, FilesystemState], + ) -> str: + candidate = path.strip() or "/" + if candidate == "/": + return "/" + if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: + return self._normalize_local_mount_path(candidate, runtime) + if not candidate.startswith("/"): + return f"/{candidate.lstrip('/')}" + return candidate + @staticmethod def _is_error_text(value: str) -> bool: return value.startswith("Error:") @@ -930,6 +994,256 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): ) return None, updated_content + def _create_move_file_tool(self) -> BaseTool: + """Create move_file for desktop local-folder mode.""" + tool_description = ( + self._custom_tool_descriptions.get("move_file") + or SURFSENSE_MOVE_FILE_TOOL_DESCRIPTION + ) + + def sync_move_file( + source_path: Annotated[ + str, + "Absolute source path to move from.", + ], + destination_path: Annotated[ + str, + "Absolute destination path to move to.", + ], + runtime: ToolRuntime[None, FilesystemState], + *, + overwrite: Annotated[ + bool, + "If True, replace an existing destination file. Defaults to False.", + ] = False, + ) -> Command | str: + if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER: + return "Error: move_file is only available in desktop local-folder mode." + + if not source_path.strip() or not destination_path.strip(): + return "Error: source_path and destination_path are required." + + resolved_backend = self._get_backend(runtime) + source_target = self._resolve_move_target_path(source_path, runtime) + destination_target = self._resolve_move_target_path(destination_path, runtime) + try: + validated_source = validate_path(source_target) + validated_destination = validate_path(destination_target) + except ValueError as exc: + return f"Error: {exc}" + res: WriteResult = resolved_backend.move( + validated_source, + validated_destination, + overwrite=overwrite, + ) + if res.error: + return res.error + if res.files_update is not None: + return Command( + update={ + "files": res.files_update, + "messages": [ + ToolMessage( + content=( + f"Moved '{validated_source}' to " + f"'{res.path or validated_destination}'" + ), + tool_call_id=runtime.tool_call_id, + ) + ], + } + ) + return f"Moved '{validated_source}' to '{res.path or validated_destination}'" + + async def async_move_file( + source_path: Annotated[ + str, + "Absolute source path to move from.", + ], + destination_path: Annotated[ + str, + "Absolute destination path to move to.", + ], + runtime: ToolRuntime[None, FilesystemState], + *, + overwrite: Annotated[ + bool, + "If True, replace an existing destination file. Defaults to False.", + ] = False, + ) -> Command | str: + if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER: + return "Error: move_file is only available in desktop local-folder mode." + + if not source_path.strip() or not destination_path.strip(): + return "Error: source_path and destination_path are required." + + resolved_backend = self._get_backend(runtime) + source_target = self._resolve_move_target_path(source_path, runtime) + destination_target = self._resolve_move_target_path(destination_path, runtime) + try: + validated_source = validate_path(source_target) + validated_destination = validate_path(destination_target) + except ValueError as exc: + return f"Error: {exc}" + res: WriteResult = await resolved_backend.amove( + validated_source, + validated_destination, + overwrite=overwrite, + ) + if res.error: + return res.error + if res.files_update is not None: + return Command( + update={ + "files": res.files_update, + "messages": [ + ToolMessage( + content=( + f"Moved '{validated_source}' to " + f"'{res.path or validated_destination}'" + ), + tool_call_id=runtime.tool_call_id, + ) + ], + } + ) + return f"Moved '{validated_source}' to '{res.path or validated_destination}'" + + return StructuredTool.from_function( + name="move_file", + description=tool_description, + func=sync_move_file, + coroutine=async_move_file, + ) + + def _create_list_tree_tool(self) -> BaseTool: + """Create list_tree for desktop local-folder mode.""" + tool_description = ( + self._custom_tool_descriptions.get("list_tree") + or SURFSENSE_LIST_TREE_TOOL_DESCRIPTION + ) + + def sync_list_tree( + runtime: ToolRuntime[None, FilesystemState], + *, + path: Annotated[ + str, + "Absolute path to list from. Use '/' for mount roots.", + ] = "/", + max_depth: Annotated[ + int, + "Maximum recursion depth to traverse. Defaults to 8.", + ] = 8, + page_size: Annotated[ + int, + "Number of entries to return per page. Defaults to 500 (max 1000).", + ] = 500, + cursor: Annotated[ + str | None, + "Opaque cursor from a previous list_tree call.", + ] = None, + include_files: Annotated[ + bool, + "Whether file entries should be included.", + ] = True, + include_dirs: Annotated[ + bool, + "Whether directory entries should be included.", + ] = True, + ) -> str: + if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER: + return "Error: list_tree is only available in desktop local-folder mode." + if max_depth < 0: + return "Error: max_depth must be >= 0." + if page_size < 1: + return "Error: page_size must be >= 1." + if not include_files and not include_dirs: + return "Error: include_files and include_dirs cannot both be false." + + resolved_backend = self._get_backend(runtime) + target_path = self._resolve_list_target_path(path, runtime) + try: + validated_path = validate_path(target_path) + except ValueError as exc: + return f"Error: {exc}" + + result = resolved_backend.list_tree( + validated_path, + max_depth=max_depth, + page_size=page_size, + cursor=cursor, + include_files=include_files, + include_dirs=include_dirs, + ) + error = result.get("error") if isinstance(result, dict) else None + if isinstance(error, str) and error: + return error + return json.dumps(result, ensure_ascii=True) + + async def async_list_tree( + runtime: ToolRuntime[None, FilesystemState], + *, + path: Annotated[ + str, + "Absolute path to list from. Use '/' for mount roots.", + ] = "/", + max_depth: Annotated[ + int, + "Maximum recursion depth to traverse. Defaults to 8.", + ] = 8, + page_size: Annotated[ + int, + "Number of entries to return per page. Defaults to 500 (max 1000).", + ] = 500, + cursor: Annotated[ + str | None, + "Opaque cursor from a previous list_tree call.", + ] = None, + include_files: Annotated[ + bool, + "Whether file entries should be included.", + ] = True, + include_dirs: Annotated[ + bool, + "Whether directory entries should be included.", + ] = True, + ) -> str: + if self._filesystem_mode != FilesystemMode.DESKTOP_LOCAL_FOLDER: + return "Error: list_tree is only available in desktop local-folder mode." + if max_depth < 0: + return "Error: max_depth must be >= 0." + if page_size < 1: + return "Error: page_size must be >= 1." + if not include_files and not include_dirs: + return "Error: include_files and include_dirs cannot both be false." + + resolved_backend = self._get_backend(runtime) + target_path = self._resolve_list_target_path(path, runtime) + try: + validated_path = validate_path(target_path) + except ValueError as exc: + return f"Error: {exc}" + + result = await resolved_backend.alist_tree( + validated_path, + max_depth=max_depth, + page_size=page_size, + cursor=cursor, + include_files=include_files, + include_dirs=include_dirs, + ) + error = result.get("error") if isinstance(result, dict) else None + if isinstance(error, str) and error: + return error + return json.dumps(result, ensure_ascii=True) + + return StructuredTool.from_function( + name="list_tree", + description=tool_description, + func=sync_list_tree, + coroutine=async_list_tree, + ) + def _create_edit_file_tool(self) -> BaseTool: """Create edit_file with DB persistence (skipped for KB documents).""" tool_description = ( diff --git a/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py b/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py index 60d967053..ef6a1657d 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py +++ b/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py @@ -6,7 +6,12 @@ import asyncio import fnmatch import os import threading +from collections import deque +from contextlib import ExitStack from pathlib import Path +from time import time +from typing import Any +from uuid import uuid4 from deepagents.backends.protocol import ( EditResult, @@ -38,6 +43,8 @@ class LocalFolderBackend: self._root = root self._locks: dict[str, threading.Lock] = {} self._locks_mu = threading.Lock() + self._tree_sessions: dict[str, dict[str, Any]] = {} + self._tree_sessions_ttl_s = 900 def _lock_for(self, path: str) -> threading.Lock: with self._locks_mu: @@ -71,6 +78,54 @@ class LocalFolderBackend: temp_path.write_text(content, encoding="utf-8") os.replace(temp_path, path) + def _acquire_path_locks(self, *paths: str) -> ExitStack: + ordered_paths = sorted(set(paths)) + stack = ExitStack() + for path in ordered_paths: + stack.enter_context(self._lock_for(path)) + return stack + + @staticmethod + def _clamp_page_size(page_size: int) -> int: + return max(1, min(page_size, 1000)) + + def _prune_expired_tree_sessions(self) -> None: + now = time() + expired = [ + cursor + for cursor, session in self._tree_sessions.items() + if now - float(session.get("last_accessed_at", now)) > self._tree_sessions_ttl_s + ] + for cursor in expired: + self._tree_sessions.pop(cursor, None) + + def _read_dir_entries(self, directory_path: str) -> list[dict[str, Any]]: + directory = Path(directory_path) + try: + children = sorted( + directory.iterdir(), + key=lambda p: (not p.is_dir(), p.name.lower()), + ) + except OSError: + return [] + + entries: list[dict[str, Any]] = [] + for child in children: + try: + stat_result = child.stat() + except OSError: + continue + entries.append( + { + "path": self._to_virtual(child, self._root), + "is_dir": child.is_dir(), + "size": stat_result.st_size if child.is_file() else 0, + "modified_at": str(stat_result.st_mtime), + "absolute_path": str(child), + } + ) + return entries + def ls_info(self, path: str) -> list[FileInfo]: try: target = self._resolve_virtual(path, allow_root=True) @@ -145,6 +200,232 @@ class LocalFolderBackend: async def awrite(self, file_path: str, content: str) -> WriteResult: return await asyncio.to_thread(self.write, file_path, content) + def list_tree( + self, + path: str = "/", + *, + max_depth: int | None = 8, + page_size: int = 500, + cursor: str | None = None, + include_files: bool = True, + include_dirs: bool = True, + ) -> dict[str, Any]: + self._prune_expired_tree_sessions() + if not include_files and not include_dirs: + return { + "entries": [], + "next_cursor": None, + "has_more": False, + "truncated": False, + } + + normalized_depth = None if max_depth is None else max(0, int(max_depth)) + page_limit = self._clamp_page_size(int(page_size)) + now = time() + + if cursor: + session = self._tree_sessions.get(cursor) + if not session: + return {"error": "Invalid or expired cursor"} + if ( + session.get("path") != path + or session.get("max_depth") != normalized_depth + or session.get("include_files") != include_files + or session.get("include_dirs") != include_dirs + ): + return {"error": "Cursor options do not match request options"} + state = session + else: + try: + start = self._resolve_virtual(path, allow_root=True) + except ValueError: + return {"error": f"Error: invalid path '{path}'"} + if not start.exists(): + return {"error": f"Error: path '{path}' not found"} + if start.is_file(): + stat_result = start.stat() + if include_files: + return { + "entries": [ + { + "path": self._to_virtual(start, self._root), + "is_dir": False, + "size": stat_result.st_size, + "modified_at": str(stat_result.st_mtime), + "depth": 0, + } + ], + "next_cursor": None, + "has_more": False, + "truncated": False, + } + return { + "entries": [], + "next_cursor": None, + "has_more": False, + "truncated": False, + } + state = { + "path": path, + "max_depth": normalized_depth, + "include_files": include_files, + "include_dirs": include_dirs, + "pending_dirs": deque([(str(start), 0)]), + "active_dir": None, + "active_depth": 0, + "active_entries": [], + "active_index": 0, + } + + entries: list[dict[str, Any]] = [] + truncated = False + while len(entries) < page_limit: + active_entries = state.get("active_entries", []) + active_index = int(state.get("active_index", 0)) + if active_index >= len(active_entries): + pending_dirs = state.get("pending_dirs", []) + if not pending_dirs: + state["active_entries"] = [] + state["active_index"] = 0 + break + next_dir_path, next_depth = pending_dirs.popleft() + state["active_dir"] = next_dir_path + state["active_depth"] = next_depth + state["active_entries"] = self._read_dir_entries(next_dir_path) + state["active_index"] = 0 + active_entries = state["active_entries"] + active_index = 0 + + if active_index >= len(active_entries): + continue + + item = active_entries[active_index] + state["active_index"] = active_index + 1 + item_depth = int(state.get("active_depth", 0)) + 1 + if normalized_depth is not None and item_depth > normalized_depth: + continue + if item["is_dir"]: + if normalized_depth is None or item_depth <= normalized_depth: + state["pending_dirs"].append((item["absolute_path"], item_depth)) + if include_dirs: + entries.append( + { + "path": item["path"], + "is_dir": True, + "size": 0, + "modified_at": item["modified_at"], + "depth": item_depth, + } + ) + elif include_files: + entries.append( + { + "path": item["path"], + "is_dir": False, + "size": item["size"], + "modified_at": item["modified_at"], + "depth": item_depth, + } + ) + + if len(entries) >= page_limit: + truncated = True + break + + has_more = bool(state.get("pending_dirs")) or ( + int(state.get("active_index", 0)) < len(state.get("active_entries", [])) + ) + if has_more: + next_cursor = cursor or uuid4().hex + state["last_accessed_at"] = now + self._tree_sessions[next_cursor] = state + else: + next_cursor = None + if cursor: + self._tree_sessions.pop(cursor, None) + + return { + "entries": entries, + "next_cursor": next_cursor, + "has_more": has_more, + "truncated": truncated, + } + + async def alist_tree( + self, + path: str = "/", + *, + max_depth: int | None = 8, + page_size: int = 500, + cursor: str | None = None, + include_files: bool = True, + include_dirs: bool = True, + ) -> dict[str, Any]: + return await asyncio.to_thread( + self.list_tree, + path, + max_depth=max_depth, + page_size=page_size, + cursor=cursor, + include_files=include_files, + include_dirs=include_dirs, + ) + + def move( + self, + source_path: str, + destination_path: str, + overwrite: bool = False, + ) -> WriteResult: + try: + source = self._resolve_virtual(source_path) + destination = self._resolve_virtual(destination_path) + except ValueError: + return WriteResult( + error=( + f"Error: invalid source '{source_path}' or destination " + f"'{destination_path}' path" + ) + ) + if source == destination: + return WriteResult(error="Error: source and destination paths are the same") + with self._acquire_path_locks(source_path, destination_path): + if not source.exists(): + return WriteResult(error=f"Error: source path '{source_path}' not found") + if destination.exists(): + if not overwrite: + return WriteResult( + error=( + f"Error: destination path '{destination_path}' already exists. " + "Set overwrite=True to replace files." + ) + ) + if source.is_dir() or destination.is_dir(): + return WriteResult( + error=( + "Error: overwrite=True is only supported for file-to-file moves." + ) + ) + destination.parent.mkdir(parents=True, exist_ok=True) + try: + if overwrite: + os.replace(source, destination) + else: + source.rename(destination) + except OSError as exc: + return WriteResult(error=f"Error: failed to move '{source_path}': {exc}") + return WriteResult(path=self._to_virtual(destination, self._root), files_update=None) + + async def amove( + self, + source_path: str, + destination_path: str, + overwrite: bool = False, + ) -> WriteResult: + return await asyncio.to_thread( + self.move, source_path, destination_path, overwrite + ) + def edit( self, file_path: str, diff --git a/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py b/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py index 12632f00f..6760d76f0 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py +++ b/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio +import base64 +import json from pathlib import Path from typing import Any @@ -107,6 +109,28 @@ class MultiRootLocalFolderBackend: for mount in self._mount_order ] + @staticmethod + def _encode_tree_cursor(mount: str, local_cursor: str) -> str: + payload = json.dumps( + {"mount": mount, "cursor": local_cursor}, + separators=(",", ":"), + ).encode("utf-8") + return base64.urlsafe_b64encode(payload).decode("ascii") + + @staticmethod + def _decode_tree_cursor(cursor: str) -> tuple[str, str]: + try: + padded = cursor + "=" * ((4 - len(cursor) % 4) % 4) + data = base64.urlsafe_b64decode(padded.encode("ascii")) + parsed = json.loads(data.decode("utf-8")) + except Exception as exc: + raise ValueError("Invalid cursor") from exc + mount = parsed.get("mount") + local_cursor = parsed.get("cursor") + if not isinstance(mount, str) or not isinstance(local_cursor, str): + raise ValueError("Invalid cursor") + return mount, local_cursor + def _transform_infos(self, mount: str, infos: list[FileInfo]) -> list[FileInfo]: transformed: list[FileInfo] = [] for info in infos: @@ -132,6 +156,103 @@ class MultiRootLocalFolderBackend: async def als_info(self, path: str) -> list[FileInfo]: return await asyncio.to_thread(self.ls_info, path) + def list_tree( + self, + path: str = "/", + *, + max_depth: int | None = 8, + page_size: int = 500, + cursor: str | None = None, + include_files: bool = True, + include_dirs: bool = True, + ) -> dict[str, Any]: + if path == "/" and not cursor: + entries = [ + { + "path": f"/{mount}", + "is_dir": True, + "size": 0, + "modified_at": "0", + "depth": 0, + } + for mount in self._mount_order + ] + return { + "entries": entries if include_dirs else [], + "next_cursor": None, + "has_more": False, + "truncated": False, + } + + try: + if cursor: + mount, local_cursor = self._decode_tree_cursor(cursor) + if mount not in self._mount_to_backend: + return {"error": "Invalid or expired cursor"} + local_path = "/" + else: + mount, local_path = self._split_mount_path(path) + local_cursor = None + except ValueError as exc: + return {"error": f"Error: {exc}"} + + result = self._mount_to_backend[mount].list_tree( + local_path, + max_depth=max_depth, + page_size=page_size, + cursor=local_cursor, + include_files=include_files, + include_dirs=include_dirs, + ) + if result.get("error"): + return result + + entries: list[dict[str, Any]] = [] + for entry in result.get("entries", []): + raw_path = self._get_str(entry, "path") + entries.append( + { + "path": self._prefix_mount_path(mount, raw_path), + "is_dir": self._get_bool(entry, "is_dir"), + "size": self._get_int(entry, "size"), + "modified_at": self._get_str(entry, "modified_at"), + "depth": self._get_int(entry, "depth"), + } + ) + + local_next_cursor = self._get_str(result, "next_cursor") + next_cursor = ( + self._encode_tree_cursor(mount, local_next_cursor) + if local_next_cursor + else None + ) + return { + "entries": entries, + "next_cursor": next_cursor, + "has_more": self._get_bool(result, "has_more"), + "truncated": self._get_bool(result, "truncated"), + } + + async def alist_tree( + self, + path: str = "/", + *, + max_depth: int | None = 8, + page_size: int = 500, + cursor: str | None = None, + include_files: bool = True, + include_dirs: bool = True, + ) -> dict[str, Any]: + return await asyncio.to_thread( + self.list_tree, + path, + max_depth=max_depth, + page_size=page_size, + cursor=cursor, + include_files=include_files, + include_dirs=include_dirs, + ) + def read(self, file_path: str, offset: int = 0, limit: int = 2000) -> str: try: mount, local_path = self._split_mount_path(file_path) @@ -165,6 +286,48 @@ class MultiRootLocalFolderBackend: async def awrite(self, file_path: str, content: str) -> WriteResult: return await asyncio.to_thread(self.write, file_path, content) + def move( + self, + source_path: str, + destination_path: str, + overwrite: bool = False, + ) -> WriteResult: + try: + source_mount, source_local_path = self._split_mount_path(source_path) + destination_mount, destination_local_path = self._split_mount_path( + destination_path + ) + except ValueError as exc: + return WriteResult(error=f"Error: {exc}") + if source_mount != destination_mount: + return WriteResult( + error=( + "Error: cross-mount moves are not supported. " + "Source and destination must be under the same mounted root." + ) + ) + result = self._mount_to_backend[source_mount].move( + source_local_path, + destination_local_path, + overwrite=overwrite, + ) + if result.path: + result.path = self._prefix_mount_path(source_mount, result.path) + return result + + async def amove( + self, + source_path: str, + destination_path: str, + overwrite: bool = False, + ) -> WriteResult: + return await asyncio.to_thread( + self.move, + source_path, + destination_path, + overwrite, + ) + def edit( self, file_path: str, From f330d1431cb8476f066424651d5f6f6afa1c5d37 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Mon, 27 Apr 2026 23:08:32 +0530 Subject: [PATCH 09/14] feat(filesystem): implement filesystem tree watch functionality using chokidar for real-time updates on local folder changes --- surfsense_desktop/src/ipc/channels.ts | 3 + surfsense_desktop/src/ipc/handlers.ts | 17 + .../modules/agent-filesystem-tree-watcher.ts | 302 ++++++++++++++++++ surfsense_desktop/src/preload.ts | 32 ++ .../components/assistant-ui/markdown-text.tsx | 78 ++++- .../components/editor-panel/editor-panel.tsx | 64 +++- .../ui/sidebar/LocalFilesystemBrowser.tsx | 88 ++++- surfsense_web/types/window.d.ts | 22 ++ 8 files changed, 583 insertions(+), 23 deletions(-) create mode 100644 surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index ec676fba8..ed4b49fad 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -57,6 +57,9 @@ export const IPC_CHANNELS = { AGENT_FILESYSTEM_GET_SETTINGS: 'agent-filesystem:get-settings', AGENT_FILESYSTEM_GET_MOUNTS: 'agent-filesystem:get-mounts', AGENT_FILESYSTEM_LIST_FILES: 'agent-filesystem:list-files', + AGENT_FILESYSTEM_TREE_WATCH_START: 'agent-filesystem:tree-watch-start', + AGENT_FILESYSTEM_TREE_WATCH_STOP: 'agent-filesystem:tree-watch-stop', + AGENT_FILESYSTEM_TREE_DIRTY: 'agent-filesystem:tree-dirty', AGENT_FILESYSTEM_SET_SETTINGS: 'agent-filesystem:set-settings', AGENT_FILESYSTEM_PICK_ROOT: 'agent-filesystem:pick-root', } as const; diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index 4054255f4..2b06c7fb0 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -45,6 +45,11 @@ import { pickAgentFilesystemRoot, setAgentFilesystemSettings, } from '../modules/agent-filesystem'; +import { + startAgentFilesystemTreeWatch, + stopAgentFilesystemTreeWatch, + type AgentFilesystemTreeWatchOptions, +} from '../modules/agent-filesystem-tree-watcher'; let authTokens: { bearer: string; refresh: string } | null = null; @@ -263,4 +268,16 @@ export function registerIpcHandlers(): void { ipcMain.handle(IPC_CHANNELS.AGENT_FILESYSTEM_PICK_ROOT, () => pickAgentFilesystemRoot() ); + + ipcMain.handle( + IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START, + (_event, options: AgentFilesystemTreeWatchOptions) => + startAgentFilesystemTreeWatch(options) + ); + + ipcMain.handle( + IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, + (_event, searchSpaceId?: number | null) => + stopAgentFilesystemTreeWatch(searchSpaceId) + ); } diff --git a/surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts b/surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts new file mode 100644 index 000000000..600f84fd5 --- /dev/null +++ b/surfsense_desktop/src/modules/agent-filesystem-tree-watcher.ts @@ -0,0 +1,302 @@ +import { BrowserWindow } from 'electron'; +import chokidar, { type FSWatcher } from 'chokidar'; +import { resolve } from 'node:path'; +import { IPC_CHANNELS } from '../ipc/channels'; +import { listAgentFilesystemFiles } from './agent-filesystem'; + +const SAFETY_POLL_MS = 60_000; +const EVENT_DEBOUNCE_MS = 700; + +export type AgentFilesystemTreeWatchOptions = { + searchSpaceId?: number | null; + rootPaths: string[]; + excludePatterns?: string[] | null; + fileExtensions?: string[] | null; +}; + +type TreeDirtyReason = 'watcher_event' | 'safety_poll'; + +type TreeDirtyEvent = { + searchSpaceId: number | null; + reason: TreeDirtyReason; + rootPath: string; + changedPath: string | null; + timestamp: number; +}; + +type WatchSession = { + searchSpaceId: number | null; + optionsSignature: string; + rootPaths: string[]; + excludePatterns: string[]; + fileExtensions: string[] | null; + watchers: FSWatcher[]; + pollTimer: NodeJS.Timeout | null; + emitTimer: NodeJS.Timeout | null; + rootSnapshotByPath: Map; + pendingDirtyByRoot: Map; + disposed: boolean; +}; + +const sessions = new Map(); + +function normalizeSearchSpaceId(searchSpaceId?: number | null): number | null { + if (typeof searchSpaceId === 'number' && Number.isFinite(searchSpaceId) && searchSpaceId > 0) { + return searchSpaceId; + } + return null; +} + +function getSessionKey(searchSpaceId?: number | null): string { + const normalized = normalizeSearchSpaceId(searchSpaceId); + return normalized === null ? 'default' : String(normalized); +} + +function normalizeRootPath(pathValue: string): string { + const normalized = resolve(pathValue.trim()); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function normalizeList(value: string[] | null | undefined): string[] { + if (!value || value.length === 0) return []; + return value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function normalizeExtensions(value: string[] | null | undefined): string[] | null { + const normalized = normalizeList(value).map((entry) => entry.toLowerCase()); + return normalized.length > 0 ? normalized : null; +} + +function buildOptionsSignature( + searchSpaceId: number | null, + rootPaths: string[], + excludePatterns: string[], + fileExtensions: string[] | null +): string { + return JSON.stringify({ + searchSpaceId, + rootPaths: [...rootPaths].sort(), + excludePatterns: [...excludePatterns].sort(), + fileExtensions: fileExtensions ? [...fileExtensions].sort() : null, + }); +} + +function hashText(input: string, seed: number): number { + let hash = seed >>> 0; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 16777619); + hash >>>= 0; + } + return hash; +} + +async function buildRootSnapshotSignature( + session: WatchSession, + rootPath: string +): Promise { + let hash = 2166136261; + hash = hashText(`space:${session.searchSpaceId ?? 'default'}|root:${rootPath}`, hash); + const files = await listAgentFilesystemFiles({ + rootPath, + searchSpaceId: session.searchSpaceId, + excludePatterns: session.excludePatterns, + fileExtensions: session.fileExtensions, + }); + const sortedFiles = [...files].sort((a, b) => a.relativePath.localeCompare(b.relativePath)); + hash = hashText(`count:${sortedFiles.length}`, hash); + for (const file of sortedFiles) { + hash = hashText( + `${file.relativePath}|${Math.round(file.mtimeMs)}|${file.size}`, + hash + ); + } + return hash.toString(16); +} + +function sendTreeDirtyEvent( + searchSpaceId: number | null, + reason: TreeDirtyReason, + rootPath: string, + changedPath: string | null +): void { + const payload: TreeDirtyEvent = { + searchSpaceId, + reason, + rootPath, + changedPath, + timestamp: Date.now(), + }; + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, payload); + } + } +} + +function scheduleDirtyEmit( + session: WatchSession, + reason: TreeDirtyReason, + rootPath: string, + changedPath: string | null = null +): void { + if (session.disposed) return; + const existing = session.pendingDirtyByRoot.get(rootPath); + if (!existing || existing.reason === 'safety_poll') { + session.pendingDirtyByRoot.set(rootPath, { reason, changedPath }); + } + if (session.emitTimer) { + clearTimeout(session.emitTimer); + } + session.emitTimer = setTimeout(() => { + session.emitTimer = null; + if (session.disposed) return; + const pending = Array.from(session.pendingDirtyByRoot.entries()); + session.pendingDirtyByRoot.clear(); + for (const [pendingRootPath, payload] of pending) { + sendTreeDirtyEvent( + session.searchSpaceId, + payload.reason, + pendingRootPath, + payload.changedPath + ); + } + }, EVENT_DEBOUNCE_MS); +} + +async function closeSession(session: WatchSession): Promise { + session.disposed = true; + if (session.emitTimer) { + clearTimeout(session.emitTimer); + session.emitTimer = null; + } + if (session.pollTimer) { + clearInterval(session.pollTimer); + session.pollTimer = null; + } + await Promise.allSettled(session.watchers.map((watcher) => watcher.close())); +} + +export async function startAgentFilesystemTreeWatch( + options: AgentFilesystemTreeWatchOptions +): Promise<{ ok: true }> { + const searchSpaceId = normalizeSearchSpaceId(options.searchSpaceId); + const rootPaths = Array.from( + new Set(normalizeList(options.rootPaths).map((rootPath) => normalizeRootPath(rootPath))) + ); + const excludePatterns = Array.from(new Set(normalizeList(options.excludePatterns))); + const fileExtensions = normalizeExtensions(options.fileExtensions); + const sessionKey = getSessionKey(searchSpaceId); + + if (rootPaths.length === 0) { + await stopAgentFilesystemTreeWatch(searchSpaceId); + return { ok: true }; + } + + const optionsSignature = buildOptionsSignature( + searchSpaceId, + rootPaths, + excludePatterns, + fileExtensions + ); + const existing = sessions.get(sessionKey); + if (existing && existing.optionsSignature === optionsSignature) { + return { ok: true }; + } + if (existing) { + await closeSession(existing); + sessions.delete(sessionKey); + } + + const ignored = [ + /(^|[/\\])\../, + ...excludePatterns.map((pattern) => `**/${pattern}/**`), + ]; + const watchers = rootPaths.map((rootPath) => + chokidar.watch(rootPath, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 500, + pollInterval: 100, + }, + ignored, + }) + ); + + const session: WatchSession = { + searchSpaceId, + optionsSignature, + rootPaths, + excludePatterns, + fileExtensions, + watchers, + pollTimer: null, + emitTimer: null, + rootSnapshotByPath: new Map(), + pendingDirtyByRoot: new Map(), + disposed: false, + }; + + for (let index = 0; index < watchers.length; index += 1) { + const watcher = watchers[index]; + const rootPath = rootPaths[index]; + watcher.on('add', (filePath) => scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath)); + watcher.on('change', (filePath) => + scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath) + ); + watcher.on('unlink', (filePath) => + scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath) + ); + watcher.on('addDir', (filePath) => + scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath) + ); + watcher.on('unlinkDir', (filePath) => + scheduleDirtyEmit(session, 'watcher_event', rootPath, filePath) + ); + } + + for (const rootPath of rootPaths) { + try { + const signature = await buildRootSnapshotSignature(session, rootPath); + session.rootSnapshotByPath.set(rootPath, signature); + } catch { + session.rootSnapshotByPath.set(rootPath, ''); + } + } + + session.pollTimer = setInterval(() => { + void (async () => { + if (session.disposed) return; + for (const rootPath of session.rootPaths) { + try { + const nextSignature = await buildRootSnapshotSignature(session, rootPath); + const previousSignature = session.rootSnapshotByPath.get(rootPath) ?? ''; + if (nextSignature !== previousSignature) { + session.rootSnapshotByPath.set(rootPath, nextSignature); + scheduleDirtyEmit(session, 'safety_poll', rootPath, null); + } + } catch { + // Keep watcher resilient on transient IO errors. + } + } + })(); + }, SAFETY_POLL_MS); + + sessions.set(sessionKey, session); + return { ok: true }; +} + +export async function stopAgentFilesystemTreeWatch( + searchSpaceId?: number | null +): Promise<{ ok: true }> { + const sessionKey = getSessionKey(searchSpaceId); + const session = sessions.get(sessionKey); + if (!session) return { ok: true }; + sessions.delete(sessionKey); + await closeSession(session); + return { ok: true }; +} diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 8e5c2f56b..100825c0f 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -116,6 +116,38 @@ contextBridge.exposeInMainWorld('electronAPI', { excludePatterns?: string[] | null; fileExtensions?: string[] | null; }) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_LIST_FILES, options), + startAgentFilesystemTreeWatch: (options: { + searchSpaceId?: number | null; + rootPaths: string[]; + excludePatterns?: string[] | null; + fileExtensions?: string[] | null; + }) => ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_START, options), + stopAgentFilesystemTreeWatch: (searchSpaceId?: number | null) => + ipcRenderer.invoke(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_WATCH_STOP, searchSpaceId), + onAgentFilesystemTreeDirty: ( + callback: (data: { + searchSpaceId: number | null; + reason: 'watcher_event' | 'safety_poll'; + rootPath: string; + changedPath: string | null; + timestamp: number; + }) => void + ) => { + const listener = ( + _event: unknown, + data: { + searchSpaceId: number | null; + reason: 'watcher_event' | 'safety_poll'; + rootPath: string; + changedPath: string | null; + timestamp: number; + } + ) => callback(data); + ipcRenderer.on(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, listener); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.AGENT_FILESYSTEM_TREE_DIRTY, listener); + }; + }, setAgentFilesystemSettings: (settings: { mode?: "cloud" | "desktop_local_folder"; localRootPaths?: string[] | null; diff --git a/surfsense_web/components/assistant-ui/markdown-text.tsx b/surfsense_web/components/assistant-ui/markdown-text.tsx index a15ff1cd7..2707e8956 100644 --- a/surfsense_web/components/assistant-ui/markdown-text.tsx +++ b/surfsense_web/components/assistant-ui/markdown-text.tsx @@ -229,6 +229,44 @@ function extractDomain(url: string): string { // Canonical local-file virtual paths are mount-prefixed: // const LOCAL_FILE_PATH_REGEX = /^\/[a-z0-9_-]+\/[^\s`]+(?:\/[^\s`]+)*$/; +type AgentFilesystemMount = { + mount: string; + rootPath: string; +}; + +function normalizeLocalVirtualPathForEditor( + candidatePath: string, + mounts: AgentFilesystemMount[] +): string { + const normalizedCandidate = candidatePath.trim().replace(/\\/g, "/").replace(/\/+/g, "/"); + if (!normalizedCandidate) { + return candidatePath; + } + const defaultMount = mounts[0]?.mount; + if (!defaultMount) { + return normalizedCandidate.startsWith("/") + ? normalizedCandidate + : `/${normalizedCandidate.replace(/^\/+/, "")}`; + } + + const mountNames = new Set(mounts.map((entry) => entry.mount)); + if (normalizedCandidate.startsWith("/")) { + const relative = normalizedCandidate.replace(/^\/+/, ""); + const [firstSegment] = relative.split("/", 1); + if (mountNames.has(firstSegment)) { + return `/${relative}`; + } + return `/${defaultMount}/${relative}`; + } + + const relative = normalizedCandidate.replace(/^\/+/, ""); + const [firstSegment] = relative.split("/", 1); + if (mountNames.has(firstSegment)) { + return `/${relative}`; + } + return `/${defaultMount}/${relative}`; +} + function isVirtualFilePathToken(value: string): boolean { if (!LOCAL_FILE_PATH_REGEX.test(value) || value.startsWith("//")) { return false; @@ -421,8 +459,15 @@ const defaultComponents = memoizeMarkdownComponents({ !codeString.includes("\n"); if (!isCodeBlock) { const inlineValue = String(children ?? "").trim(); + const normalizedInlinePath = inlineValue.replace(/\/+$/, ""); + const leafSegment = normalizedInlinePath.split("/").filter(Boolean).at(-1) ?? ""; + const isLikelyFolder = + inlineValue.endsWith("/") || !leafSegment || !leafSegment.includes("."); const isLocalPath = - !!electronAPI && isVirtualFilePathToken(inlineValue) && !inlineValue.startsWith("//"); + !!electronAPI && + isVirtualFilePathToken(inlineValue) && + !inlineValue.startsWith("//") && + !isLikelyFolder; const displayLocalPath = inlineValue.replace(/^\/+/, ""); const searchSpaceIdParam = params?.search_space_id; const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam) @@ -438,14 +483,31 @@ const defaultComponents = memoizeMarkdownComponents({ onClick={(event) => { event.preventDefault(); event.stopPropagation(); - openEditorPanel({ - kind: "local_file", - localFilePath: inlineValue, - title: inlineValue.split("/").pop() || inlineValue, - searchSpaceId: Number.isFinite(parsedSearchSpaceId) + void (async () => { + let resolvedLocalPath = inlineValue; + const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId) ? parsedSearchSpaceId - : undefined, - }); + : undefined; + if (electronAPI?.getAgentFilesystemMounts) { + try { + const mounts = (await electronAPI.getAgentFilesystemMounts( + resolvedSearchSpaceId + )) as AgentFilesystemMount[]; + resolvedLocalPath = normalizeLocalVirtualPathForEditor( + inlineValue, + mounts + ); + } catch { + // Fall back to the raw inline path if mount lookup fails. + } + } + openEditorPanel({ + kind: "local_file", + localFilePath: resolvedLocalPath, + title: resolvedLocalPath.split("/").pop() || resolvedLocalPath, + searchSpaceId: resolvedSearchSpaceId, + }); + })(); }} title="Open in editor panel" > diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx index a9fe886e1..9b1383d7f 100644 --- a/surfsense_web/components/editor-panel/editor-panel.tsx +++ b/surfsense_web/components/editor-panel/editor-panel.tsx @@ -47,6 +47,42 @@ interface EditorContent { const EDITABLE_DOCUMENT_TYPES = new Set(["FILE", "NOTE"]); type EditorRenderMode = "rich_markdown" | "source_code"; +type AgentFilesystemMount = { + mount: string; + rootPath: string; +}; + +function normalizeLocalVirtualPathForEditor( + candidatePath: string, + mounts: AgentFilesystemMount[] +): string { + const normalizedCandidate = candidatePath.trim().replace(/\\/g, "/").replace(/\/+/g, "/"); + if (!normalizedCandidate) return candidatePath; + const defaultMount = mounts[0]?.mount; + if (!defaultMount) { + return normalizedCandidate.startsWith("/") + ? normalizedCandidate + : `/${normalizedCandidate.replace(/^\/+/, "")}`; + } + + const mountNames = new Set(mounts.map((entry) => entry.mount)); + if (normalizedCandidate.startsWith("/")) { + const relative = normalizedCandidate.replace(/^\/+/, ""); + const [firstSegment] = relative.split("/", 1); + if (mountNames.has(firstSegment)) { + return `/${relative}`; + } + return `/${defaultMount}/${relative}`; + } + + const relative = normalizedCandidate.replace(/^\/+/, ""); + const [firstSegment] = relative.split("/", 1); + if (mountNames.has(firstSegment)) { + return `/${relative}`; + } + return `/${defaultMount}/${relative}`; +} + function EditorPanelSkeleton() { return (
@@ -100,6 +136,22 @@ export function EditorPanelContent({ const [displayTitle, setDisplayTitle] = useState(title || "Untitled"); const isLocalFileMode = kind === "local_file"; const editorRenderMode: EditorRenderMode = isLocalFileMode ? "source_code" : "rich_markdown"; + const resolveLocalVirtualPath = useCallback( + async (candidatePath: string): Promise => { + if (!electronAPI?.getAgentFilesystemMounts) { + return candidatePath; + } + try { + const mounts = (await electronAPI.getAgentFilesystemMounts( + searchSpaceId + )) as AgentFilesystemMount[]; + return normalizeLocalVirtualPathForEditor(candidatePath, mounts); + } catch { + return candidatePath; + } + }, + [electronAPI, searchSpaceId] + ); const isLargeDocument = (editorDoc?.content_size_bytes ?? 0) > LARGE_DOCUMENT_THRESHOLD; @@ -124,14 +176,15 @@ export function EditorPanelContent({ if (!electronAPI?.readAgentLocalFileText) { throw new Error("Local file editor is available only in desktop mode."); } + const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath); const readResult = await electronAPI.readAgentLocalFileText( - localFilePath, + resolvedLocalPath, searchSpaceId ); if (!readResult.ok) { throw new Error(readResult.error || "Failed to read local file"); } - const inferredTitle = localFilePath.split("/").pop() || localFilePath; + const inferredTitle = resolvedLocalPath.split("/").pop() || resolvedLocalPath; const content: EditorContent = { document_id: -1, title: inferredTitle, @@ -195,7 +248,7 @@ export function EditorPanelContent({ doFetch().catch(() => {}); return () => controller.abort(); - }, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId, title]); + }, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId, title]); useEffect(() => { return () => { @@ -239,9 +292,10 @@ export function EditorPanelContent({ if (!electronAPI?.writeAgentLocalFileText) { throw new Error("Local file editor is available only in desktop mode."); } + const resolvedLocalPath = await resolveLocalVirtualPath(localFilePath); const contentToSave = markdownRef.current; const writeResult = await electronAPI.writeAgentLocalFileText( - localFilePath, + resolvedLocalPath, contentToSave, searchSpaceId ); @@ -290,7 +344,7 @@ export function EditorPanelContent({ } finally { setSaving(false); } - }, [documentId, electronAPI, isLocalFileMode, localFilePath, searchSpaceId]); + }, [documentId, electronAPI, isLocalFileMode, localFilePath, resolveLocalVirtualPath, searchSpaceId]); const isEditableType = editorDoc ? (editorRenderMode === "source_code" || diff --git a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx index add7cd2d9..d1146338d 100644 --- a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx +++ b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx @@ -86,7 +86,8 @@ export function LocalFilesystemBrowser({ const [mountByRootKey, setMountByRootKey] = useState>(new Map()); const [mountStatus, setMountStatus] = useState("idle"); const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false); - const lastLoadedRootsSignatureRef = useRef(""); + const [reloadNonceByRoot, setReloadNonceByRoot] = useState>({}); + const lastLoadedSignatureByRootRef = useRef>(new Map()); const hasLoadedMountsOnceRef = useRef(false); const hasResolvedAtLeastOneRootRef = useRef(false); const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []); @@ -107,18 +108,34 @@ export function LocalFilesystemBrowser({ } return; } - const rootsSignature = rootPaths - .map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform)) - .sort() - .join("|"); - const settingsSignature = `${searchSpaceId}:${rootsSignature}`; - if (settingsSignature === lastLoadedRootsSignatureRef.current) { + const rootEntries = rootPaths.map((rootPath) => ({ + rootPath, + rootKey: normalizeRootPathForLookup(rootPath, isWindowsPlatform), + })); + const activeRootKeys = new Set(rootEntries.map((entry) => entry.rootKey)); + for (const key of Array.from(lastLoadedSignatureByRootRef.current.keys())) { + if (!activeRootKeys.has(key)) { + lastLoadedSignatureByRootRef.current.delete(key); + } + } + const rootsToReload = rootEntries.filter(({ rootKey }) => { + const nonce = reloadNonceByRoot[rootKey] ?? 0; + const signature = `${searchSpaceId}:${rootKey}:${nonce}`; + return lastLoadedSignatureByRootRef.current.get(rootKey) !== signature; + }); + if (rootsToReload.length === 0) { return; } - lastLoadedRootsSignatureRef.current = settingsSignature; + for (const { rootKey } of rootsToReload) { + const nonce = reloadNonceByRoot[rootKey] ?? 0; + lastLoadedSignatureByRootRef.current.set( + rootKey, + `${searchSpaceId}:${rootKey}:${nonce}` + ); + } let cancelled = false; - for (const rootPath of rootPaths) { + for (const { rootPath } of rootsToReload) { setRootStateMap((prev) => ({ ...prev, [rootPath]: { @@ -130,7 +147,7 @@ export function LocalFilesystemBrowser({ } void Promise.all( - rootPaths.map(async (rootPath) => { + rootsToReload.map(async ({ rootPath }) => { try { const files = (await electronAPI.listAgentFilesystemFiles({ rootPath, @@ -164,6 +181,57 @@ export function LocalFilesystemBrowser({ return () => { cancelled = true; }; + }, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId, supportedExtensions]); + + useEffect(() => { + if (active) return; + lastLoadedSignatureByRootRef.current.clear(); + }, [active]); + + useEffect(() => { + if (!electronAPI?.startAgentFilesystemTreeWatch) return; + if (!electronAPI?.stopAgentFilesystemTreeWatch) return; + if (!electronAPI?.onAgentFilesystemTreeDirty) return; + if (!active) return; + if (rootPaths.length === 0) { + void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId); + return; + } + + const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event) => { + if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) { + return; + } + const eventRootKey = normalizeRootPathForLookup(event.rootPath, isWindowsPlatform); + const knownRootKeys = new Set( + rootPaths.map((rootPath) => normalizeRootPathForLookup(rootPath, isWindowsPlatform)) + ); + if (!knownRootKeys.has(eventRootKey)) { + setReloadNonceByRoot((prev) => { + const next = { ...prev }; + for (const rootKey of knownRootKeys) { + next[rootKey] = (prev[rootKey] ?? 0) + 1; + } + return next; + }); + return; + } + setReloadNonceByRoot((prev) => ({ + ...prev, + [eventRootKey]: (prev[eventRootKey] ?? 0) + 1, + })); + }); + void electronAPI.startAgentFilesystemTreeWatch({ + searchSpaceId, + rootPaths, + excludePatterns: DEFAULT_EXCLUDE_PATTERNS, + fileExtensions: supportedExtensions, + }); + + return () => { + unsubscribe(); + void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId); + }; }, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId, supportedExtensions]); useEffect(() => { diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index d3356d4d1..5840d7a04 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -61,6 +61,21 @@ interface AgentFilesystemListOptions { fileExtensions?: string[] | null; } +interface AgentFilesystemTreeWatchOptions { + searchSpaceId?: number | null; + rootPaths: string[]; + excludePatterns?: string[] | null; + fileExtensions?: string[] | null; +} + +interface AgentFilesystemTreeDirtyEvent { + searchSpaceId: number | null; + reason: "watcher_event" | "safety_poll"; + rootPath: string; + changedPath: string | null; + timestamp: number; +} + interface LocalTextFileResult { ok: boolean; path: string; @@ -167,6 +182,13 @@ interface ElectronAPI { listAgentFilesystemFiles: ( options: AgentFilesystemListOptions ) => Promise; + startAgentFilesystemTreeWatch: ( + options: AgentFilesystemTreeWatchOptions + ) => Promise<{ ok: true }>; + stopAgentFilesystemTreeWatch: (searchSpaceId?: number | null) => Promise<{ ok: true }>; + onAgentFilesystemTreeDirty: ( + callback: (data: AgentFilesystemTreeDirtyEvent) => void + ) => () => void; setAgentFilesystemSettings: (settings: { mode?: AgentFilesystemMode; localRootPaths?: string[] | null; From 7bcb6306c5be26da5b7a094340c001e8fd759303 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:45:07 +0530 Subject: [PATCH 10/14] refactor(filesystem): streamline filesystem operations by removing cursor-based pagination and enhancing path normalization methods --- .../agents/new_chat/middleware/filesystem.py | 81 ++------ .../middleware/local_folder_backend.py | 178 +++++------------- .../multi_root_local_folder_backend.py | 49 +---- 3 files changed, 69 insertions(+), 239 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/middleware/filesystem.py b/surfsense_backend/app/agents/new_chat/middleware/filesystem.py index d7bb339bd..3622bbcdf 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/filesystem.py +++ b/surfsense_backend/app/agents/new_chat/middleware/filesystem.py @@ -28,9 +28,6 @@ from langgraph.types import Command from sqlalchemy import delete, select from app.agents.new_chat.filesystem_selection import FilesystemMode -from app.agents.new_chat.middleware.multi_root_local_folder_backend import ( - MultiRootLocalFolderBackend, -) from app.agents.new_chat.sandbox import ( _evict_sandbox_cache, delete_sandbox, @@ -152,21 +149,19 @@ Notes: - Cross-mount moves are not supported. """ -SURFSENSE_LIST_TREE_TOOL_DESCRIPTION = """Lists files/folders recursively with cursor pagination. +SURFSENSE_LIST_TREE_TOOL_DESCRIPTION = """Lists files/folders recursively in a single bounded call. Use this in desktop local-folder mode to discover nested files at scale. Args: - path: absolute mount-prefixed path (e.g., //src) or "/" for mount roots. - max_depth: recursion depth limit (default 8). -- page_size: number of entries to return per page (max 1000). -- cursor: opaque continuation token from a previous call. +- page_size: maximum number of entries returned (max 1000). - include_files/include_dirs: filter returned entry types. Returns JSON with: - entries: [{path, is_dir, size, modified_at, depth}] -- next_cursor: continuation token or null -- has_more: whether additional pages exist +- truncated: true when additional entries were omitted due to page_size """ SURFSENSE_GLOB_TOOL_DESCRIPTION = """Find files matching a glob pattern. @@ -251,13 +246,13 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): if filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: system_prompt += ( "\n- move_file: move or rename files/folders in local-folder mode." - "\n- list_tree: recursively list nested local paths with cursor pagination." + "\n- list_tree: recursively list nested local paths in one bounded response." "\n\n## Local Folder Mode" "\n\nThis chat is running in desktop local-folder mode." " Keep all file operations local. Do not use save_document." " Always use mount-prefixed absolute paths like //file.ext." " If you are unsure which mounts are available, call ls('/') first." - " For big trees: use list_tree pages, then grep, then read_file." + " For big trees: use list_tree, then grep, then read_file." ) super().__init__( @@ -812,35 +807,14 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): """Only cloud mode persists file content to Document/Chunk tables.""" return self._filesystem_mode == FilesystemMode.CLOUD - def _default_mount_prefix(self, runtime: ToolRuntime[None, FilesystemState]) -> str: - backend = self._get_backend(runtime) - if isinstance(backend, MultiRootLocalFolderBackend): - return f"/{backend.default_mount()}" - return "" - - def _normalize_local_mount_path( - self, candidate: str, runtime: ToolRuntime[None, FilesystemState] - ) -> str: - backend = self._get_backend(runtime) - mount_prefix = self._default_mount_prefix(runtime) - normalized_candidate = re.sub(r"/+", "/", candidate.strip().replace("\\", "/")) - if not mount_prefix or not isinstance(backend, MultiRootLocalFolderBackend): - if normalized_candidate.startswith("/"): - return normalized_candidate - return f"/{normalized_candidate.lstrip('/')}" - - mount_names = set(backend.list_mounts()) - if normalized_candidate.startswith("/"): - first_segment = normalized_candidate.lstrip("/").split("/", 1)[0] - if first_segment in mount_names: - return normalized_candidate - return f"{mount_prefix}{normalized_candidate}" - - relative = normalized_candidate.lstrip("/") - first_segment = relative.split("/", 1)[0] - if first_segment in mount_names: - return f"/{relative}" - return f"{mount_prefix}/{relative}" + @staticmethod + def _normalize_absolute_path(candidate: str) -> str: + normalized = re.sub(r"/+", "/", candidate.strip().replace("\\", "/")) + if not normalized: + return "/" + if normalized.startswith("/"): + return normalized + return f"/{normalized.lstrip('/')}" def _get_contract_suggested_path( self, runtime: ToolRuntime[None, FilesystemState] @@ -848,14 +822,7 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): contract = runtime.state.get("file_operation_contract") or {} suggested = contract.get("suggested_path") if isinstance(suggested, str) and suggested.strip(): - cleaned = suggested.strip() - if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: - return self._normalize_local_mount_path(cleaned, runtime) - return cleaned - if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: - mount_prefix = self._default_mount_prefix(runtime) - if mount_prefix: - return f"{mount_prefix}/notes.md" + return self._normalize_absolute_path(suggested) return "/notes.md" def _resolve_write_target_path( @@ -867,7 +834,7 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): if not candidate: return self._get_contract_suggested_path(runtime) if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: - return self._normalize_local_mount_path(candidate, runtime) + return self._normalize_absolute_path(candidate) if not candidate.startswith("/"): return f"/{candidate.lstrip('/')}" return candidate @@ -881,7 +848,7 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): if not candidate: return "" if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: - return self._normalize_local_mount_path(candidate, runtime) + return self._normalize_absolute_path(candidate) if not candidate.startswith("/"): return f"/{candidate.lstrip('/')}" return candidate @@ -895,7 +862,7 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): if candidate == "/": return "/" if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: - return self._normalize_local_mount_path(candidate, runtime) + return self._normalize_absolute_path(candidate) if not candidate.startswith("/"): return f"/{candidate.lstrip('/')}" return candidate @@ -1136,12 +1103,8 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): ] = 8, page_size: Annotated[ int, - "Number of entries to return per page. Defaults to 500 (max 1000).", + "Maximum number of entries to return. Defaults to 500 (max 1000).", ] = 500, - cursor: Annotated[ - str | None, - "Opaque cursor from a previous list_tree call.", - ] = None, include_files: Annotated[ bool, "Whether file entries should be included.", @@ -1171,7 +1134,6 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): validated_path, max_depth=max_depth, page_size=page_size, - cursor=cursor, include_files=include_files, include_dirs=include_dirs, ) @@ -1193,12 +1155,8 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): ] = 8, page_size: Annotated[ int, - "Number of entries to return per page. Defaults to 500 (max 1000).", + "Maximum number of entries to return. Defaults to 500 (max 1000).", ] = 500, - cursor: Annotated[ - str | None, - "Opaque cursor from a previous list_tree call.", - ] = None, include_files: Annotated[ bool, "Whether file entries should be included.", @@ -1228,7 +1186,6 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): validated_path, max_depth=max_depth, page_size=page_size, - cursor=cursor, include_files=include_files, include_dirs=include_dirs, ) diff --git a/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py b/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py index ef6a1657d..4f149a756 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py +++ b/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py @@ -9,9 +9,7 @@ import threading from collections import deque from contextlib import ExitStack from pathlib import Path -from time import time from typing import Any -from uuid import uuid4 from deepagents.backends.protocol import ( EditResult, @@ -43,8 +41,6 @@ class LocalFolderBackend: self._root = root self._locks: dict[str, threading.Lock] = {} self._locks_mu = threading.Lock() - self._tree_sessions: dict[str, dict[str, Any]] = {} - self._tree_sessions_ttl_s = 900 def _lock_for(self, path: str) -> threading.Lock: with self._locks_mu: @@ -89,16 +85,6 @@ class LocalFolderBackend: def _clamp_page_size(page_size: int) -> int: return max(1, min(page_size, 1000)) - def _prune_expired_tree_sessions(self) -> None: - now = time() - expired = [ - cursor - for cursor, session in self._tree_sessions.items() - if now - float(session.get("last_accessed_at", now)) > self._tree_sessions_ttl_s - ] - for cursor in expired: - self._tree_sessions.pop(cursor, None) - def _read_dir_entries(self, directory_path: str) -> list[dict[str, Any]]: directory = Path(directory_path) try: @@ -206,148 +192,82 @@ class LocalFolderBackend: *, max_depth: int | None = 8, page_size: int = 500, - cursor: str | None = None, include_files: bool = True, include_dirs: bool = True, ) -> dict[str, Any]: - self._prune_expired_tree_sessions() if not include_files and not include_dirs: return { "entries": [], - "next_cursor": None, - "has_more": False, "truncated": False, } normalized_depth = None if max_depth is None else max(0, int(max_depth)) page_limit = self._clamp_page_size(int(page_size)) - now = time() - - if cursor: - session = self._tree_sessions.get(cursor) - if not session: - return {"error": "Invalid or expired cursor"} - if ( - session.get("path") != path - or session.get("max_depth") != normalized_depth - or session.get("include_files") != include_files - or session.get("include_dirs") != include_dirs - ): - return {"error": "Cursor options do not match request options"} - state = session - else: - try: - start = self._resolve_virtual(path, allow_root=True) - except ValueError: - return {"error": f"Error: invalid path '{path}'"} - if not start.exists(): - return {"error": f"Error: path '{path}' not found"} - if start.is_file(): - stat_result = start.stat() - if include_files: - return { - "entries": [ - { - "path": self._to_virtual(start, self._root), - "is_dir": False, - "size": stat_result.st_size, - "modified_at": str(stat_result.st_mtime), - "depth": 0, - } - ], - "next_cursor": None, - "has_more": False, - "truncated": False, - } + try: + start = self._resolve_virtual(path, allow_root=True) + except ValueError: + return {"error": f"Error: invalid path '{path}'"} + if not start.exists(): + return {"error": f"Error: path '{path}' not found"} + if start.is_file(): + stat_result = start.stat() + if include_files: return { - "entries": [], - "next_cursor": None, - "has_more": False, + "entries": [ + { + "path": self._to_virtual(start, self._root), + "is_dir": False, + "size": stat_result.st_size, + "modified_at": str(stat_result.st_mtime), + "depth": 0, + } + ], "truncated": False, } - state = { - "path": path, - "max_depth": normalized_depth, - "include_files": include_files, - "include_dirs": include_dirs, - "pending_dirs": deque([(str(start), 0)]), - "active_dir": None, - "active_depth": 0, - "active_entries": [], - "active_index": 0, + return { + "entries": [], + "truncated": False, } + pending_dirs: deque[tuple[str, int]] = deque([(str(start), 0)]) entries: list[dict[str, Any]] = [] truncated = False - while len(entries) < page_limit: - active_entries = state.get("active_entries", []) - active_index = int(state.get("active_index", 0)) - if active_index >= len(active_entries): - pending_dirs = state.get("pending_dirs", []) - if not pending_dirs: - state["active_entries"] = [] - state["active_index"] = 0 - break - next_dir_path, next_depth = pending_dirs.popleft() - state["active_dir"] = next_dir_path - state["active_depth"] = next_depth - state["active_entries"] = self._read_dir_entries(next_dir_path) - state["active_index"] = 0 - active_entries = state["active_entries"] - active_index = 0 - - if active_index >= len(active_entries): - continue - - item = active_entries[active_index] - state["active_index"] = active_index + 1 - item_depth = int(state.get("active_depth", 0)) + 1 - if normalized_depth is not None and item_depth > normalized_depth: - continue - if item["is_dir"]: - if normalized_depth is None or item_depth <= normalized_depth: - state["pending_dirs"].append((item["absolute_path"], item_depth)) - if include_dirs: + while pending_dirs and not truncated: + next_dir_path, next_depth = pending_dirs.popleft() + active_entries = self._read_dir_entries(next_dir_path) + for item in active_entries: + item_depth = next_depth + 1 + if normalized_depth is not None and item_depth > normalized_depth: + continue + if item["is_dir"]: + if normalized_depth is None or item_depth <= normalized_depth: + pending_dirs.append((item["absolute_path"], item_depth)) + if include_dirs: + entries.append( + { + "path": item["path"], + "is_dir": True, + "size": 0, + "modified_at": item["modified_at"], + "depth": item_depth, + } + ) + elif include_files: entries.append( { "path": item["path"], - "is_dir": True, - "size": 0, + "is_dir": False, + "size": item["size"], "modified_at": item["modified_at"], "depth": item_depth, } ) - elif include_files: - entries.append( - { - "path": item["path"], - "is_dir": False, - "size": item["size"], - "modified_at": item["modified_at"], - "depth": item_depth, - } - ) - - if len(entries) >= page_limit: - truncated = True - break - - has_more = bool(state.get("pending_dirs")) or ( - int(state.get("active_index", 0)) < len(state.get("active_entries", [])) - ) - if has_more: - next_cursor = cursor or uuid4().hex - state["last_accessed_at"] = now - self._tree_sessions[next_cursor] = state - else: - next_cursor = None - if cursor: - self._tree_sessions.pop(cursor, None) + if len(entries) >= page_limit: + truncated = True + break return { "entries": entries, - "next_cursor": next_cursor, - "has_more": has_more, "truncated": truncated, } @@ -357,7 +277,6 @@ class LocalFolderBackend: *, max_depth: int | None = 8, page_size: int = 500, - cursor: str | None = None, include_files: bool = True, include_dirs: bool = True, ) -> dict[str, Any]: @@ -366,7 +285,6 @@ class LocalFolderBackend: path, max_depth=max_depth, page_size=page_size, - cursor=cursor, include_files=include_files, include_dirs=include_dirs, ) diff --git a/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py b/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py index 6760d76f0..82914f9ce 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py +++ b/surfsense_backend/app/agents/new_chat/middleware/multi_root_local_folder_backend.py @@ -3,8 +3,6 @@ from __future__ import annotations import asyncio -import base64 -import json from pathlib import Path from typing import Any @@ -109,28 +107,6 @@ class MultiRootLocalFolderBackend: for mount in self._mount_order ] - @staticmethod - def _encode_tree_cursor(mount: str, local_cursor: str) -> str: - payload = json.dumps( - {"mount": mount, "cursor": local_cursor}, - separators=(",", ":"), - ).encode("utf-8") - return base64.urlsafe_b64encode(payload).decode("ascii") - - @staticmethod - def _decode_tree_cursor(cursor: str) -> tuple[str, str]: - try: - padded = cursor + "=" * ((4 - len(cursor) % 4) % 4) - data = base64.urlsafe_b64decode(padded.encode("ascii")) - parsed = json.loads(data.decode("utf-8")) - except Exception as exc: - raise ValueError("Invalid cursor") from exc - mount = parsed.get("mount") - local_cursor = parsed.get("cursor") - if not isinstance(mount, str) or not isinstance(local_cursor, str): - raise ValueError("Invalid cursor") - return mount, local_cursor - def _transform_infos(self, mount: str, infos: list[FileInfo]) -> list[FileInfo]: transformed: list[FileInfo] = [] for info in infos: @@ -162,11 +138,10 @@ class MultiRootLocalFolderBackend: *, max_depth: int | None = 8, page_size: int = 500, - cursor: str | None = None, include_files: bool = True, include_dirs: bool = True, ) -> dict[str, Any]: - if path == "/" and not cursor: + if path == "/": entries = [ { "path": f"/{mount}", @@ -179,20 +154,11 @@ class MultiRootLocalFolderBackend: ] return { "entries": entries if include_dirs else [], - "next_cursor": None, - "has_more": False, "truncated": False, } try: - if cursor: - mount, local_cursor = self._decode_tree_cursor(cursor) - if mount not in self._mount_to_backend: - return {"error": "Invalid or expired cursor"} - local_path = "/" - else: - mount, local_path = self._split_mount_path(path) - local_cursor = None + mount, local_path = self._split_mount_path(path) except ValueError as exc: return {"error": f"Error: {exc}"} @@ -200,7 +166,6 @@ class MultiRootLocalFolderBackend: local_path, max_depth=max_depth, page_size=page_size, - cursor=local_cursor, include_files=include_files, include_dirs=include_dirs, ) @@ -220,16 +185,8 @@ class MultiRootLocalFolderBackend: } ) - local_next_cursor = self._get_str(result, "next_cursor") - next_cursor = ( - self._encode_tree_cursor(mount, local_next_cursor) - if local_next_cursor - else None - ) return { "entries": entries, - "next_cursor": next_cursor, - "has_more": self._get_bool(result, "has_more"), "truncated": self._get_bool(result, "truncated"), } @@ -239,7 +196,6 @@ class MultiRootLocalFolderBackend: *, max_depth: int | None = 8, page_size: int = 500, - cursor: str | None = None, include_files: bool = True, include_dirs: bool = True, ) -> dict[str, Any]: @@ -248,7 +204,6 @@ class MultiRootLocalFolderBackend: path, max_depth=max_depth, page_size=page_size, - cursor=cursor, include_files=include_files, include_dirs=include_dirs, ) From 7134b0feae70fb7a6eec4172d6cdccf5a9780bad Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:57:07 +0530 Subject: [PATCH 11/14] refactor(file_intent): remove _infer_text_file_extension function and standardize fallback filename to 'notes.md' --- .../agents/new_chat/middleware/file_intent.py | 36 ++----------------- .../ui/sidebar/LocalFilesystemBrowser.tsx | 5 +-- 2 files changed, 5 insertions(+), 36 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/middleware/file_intent.py b/surfsense_backend/app/agents/new_chat/middleware/file_intent.py index 1e5fd0ede..4bf5dcfe4 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/file_intent.py +++ b/surfsense_backend/app/agents/new_chat/middleware/file_intent.py @@ -109,37 +109,6 @@ def _sanitize_path_segment(value: str) -> str: return segment -def _infer_text_file_extension(user_text: str) -> str: - lowered = user_text.lower() - if any(token in lowered for token in ("json", ".json")): - return ".json" - if any(token in lowered for token in ("yaml", "yml", ".yaml", ".yml")): - return ".yaml" - if any(token in lowered for token in ("csv", ".csv")): - return ".csv" - if any(token in lowered for token in ("python", ".py")): - return ".py" - if any(token in lowered for token in ("typescript", ".ts", ".tsx")): - return ".ts" - if any(token in lowered for token in ("javascript", ".js", ".mjs", ".cjs")): - return ".js" - if any(token in lowered for token in ("html", ".html")): - return ".html" - if any(token in lowered for token in ("css", ".css")): - return ".css" - if any(token in lowered for token in ("sql", ".sql")): - return ".sql" - if any(token in lowered for token in ("toml", ".toml")): - return ".toml" - if any(token in lowered for token in ("ini", ".ini")): - return ".ini" - if any(token in lowered for token in ("xml", ".xml")): - return ".xml" - if any(token in lowered for token in ("markdown", ".md", "readme")): - return ".md" - return ".md" - - def _normalize_directory(value: str) -> str: raw = value.strip().replace("\\", "/") raw = raw.strip("/") @@ -193,7 +162,6 @@ def _fallback_path( suggested_path: str | None = None, user_text: str, ) -> str: - default_extension = _infer_text_file_extension(user_text) inferred_dir = _infer_directory_from_user_text(user_text) sanitized_filename = "" @@ -202,9 +170,9 @@ def _fallback_path( if sanitized_filename.lower().endswith(".txt"): sanitized_filename = f"{sanitized_filename[:-4]}.md" if not sanitized_filename: - sanitized_filename = f"notes{default_extension}" + sanitized_filename = "notes.md" elif "." not in sanitized_filename: - sanitized_filename = f"{sanitized_filename}{default_extension}" + sanitized_filename = f"{sanitized_filename}.md" normalized_suggested_path = ( _normalize_file_path(suggested_path) if suggested_path else "" diff --git a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx index d1146338d..a808d5a31 100644 --- a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx +++ b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react"; +import { ChevronDown, ChevronRight, FileText, Folder, FolderOpen } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog"; import { Skeleton } from "@/components/ui/skeleton"; @@ -329,6 +329,7 @@ export function LocalFilesystemBrowser({ const renderFolder = useCallback( (folder: LocalFolderNode, depth: number, mount: string) => { const isExpanded = expandedFolderKeys.has(folder.key); + const FolderIcon = isExpanded ? FolderOpen : Folder; const childFolders = Array.from(folder.folders.values()).sort((a, b) => a.name.localeCompare(b.name) ); @@ -347,7 +348,7 @@ export function LocalFilesystemBrowser({ ) : ( )} - + {folder.name} {isExpanded && ( From b85b7cbae0d6a97bd1a0a437a8fdb58f2b250bc4 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:12:15 +0530 Subject: [PATCH 12/14] feat(filesystem): introduce support for local openable text file extensions and enhance folder expansion persistence in the UI --- .../src/modules/agent-filesystem.ts | 56 +++++++ surfsense_web/atoms/documents/folder.atoms.ts | 9 ++ .../components/editor/source-code-editor.tsx | 5 + .../ui/sidebar/DesktopLocalTabContent.tsx | 20 ++- .../ui/sidebar/LocalFilesystemBrowser.tsx | 140 ++++++++++++++---- 5 files changed, 202 insertions(+), 28 deletions(-) diff --git a/surfsense_desktop/src/modules/agent-filesystem.ts b/surfsense_desktop/src/modules/agent-filesystem.ts index d8c64b79a..608f8c4a4 100644 --- a/surfsense_desktop/src/modules/agent-filesystem.ts +++ b/surfsense_desktop/src/modules/agent-filesystem.ts @@ -21,6 +21,51 @@ const MAX_LOCAL_ROOTS = 10; const DEFAULT_SPACE_KEY = "default"; let cachedSettingsStore: AgentFilesystemSettingsStore | null = null; +const LOCAL_OPENABLE_TEXT_EXTENSIONS = new Set([ + ".md", + ".markdown", + ".txt", + ".json", + ".yaml", + ".yml", + ".csv", + ".tsv", + ".xml", + ".html", + ".htm", + ".css", + ".scss", + ".sass", + ".sql", + ".toml", + ".ini", + ".conf", + ".log", + ".py", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".java", + ".kt", + ".kts", + ".go", + ".rs", + ".rb", + ".php", + ".swift", + ".r", + ".lua", + ".sh", + ".bash", + ".zsh", + ".fish", + ".env", + ".mk", +]); + function getSettingsPath(): string { return join(app.getPath("userData"), SETTINGS_FILENAME); } @@ -229,6 +274,16 @@ function toVirtualPath(rootPath: string, absolutePath: string): string { return `/${rel.replace(/\\/g, "/")}`; } +function assertLocalOpenableTextFile(absolutePath: string): void { + const extension = extname(absolutePath).toLowerCase(); + if (!LOCAL_OPENABLE_TEXT_EXTENSIONS.has(extension)) { + throw new Error( + `Unsupported local file type '${extension || "(no extension)"}'. ` + + "Only text/code files can be opened in local mode." + ); + } +} + export type LocalRootMount = { mount: string; rootPath: string; @@ -441,6 +496,7 @@ export async function readAgentLocalFileText( ); } const absolutePath = resolveVirtualPath(rootMount.rootPath, subPath); + assertLocalOpenableTextFile(absolutePath); const content = await readFile(absolutePath, "utf8"); return { path: toMountedVirtualPath(rootMount.mount, rootMount.rootPath, absolutePath), diff --git a/surfsense_web/atoms/documents/folder.atoms.ts b/surfsense_web/atoms/documents/folder.atoms.ts index fe7d556eb..bbdc58e4e 100644 --- a/surfsense_web/atoms/documents/folder.atoms.ts +++ b/surfsense_web/atoms/documents/folder.atoms.ts @@ -12,6 +12,15 @@ export const expandedFolderIdsAtom = atomWithStorage>( {} ); +/** + * Expanded folder keys for Local filesystem tree, keyed by search space ID. + * Persisted so local tree expansion survives remounts/reloads. + */ +export const localExpandedFolderKeysAtom = atomWithStorage>( + "surfsense:localExpandedFolderKeys", + {} +); + /** * Folder currently being renamed (inline edit mode). * null means no folder is being renamed. diff --git a/surfsense_web/components/editor/source-code-editor.tsx b/surfsense_web/components/editor/source-code-editor.tsx index 5cab8e5b1..27734005e 100644 --- a/surfsense_web/components/editor/source-code-editor.tsx +++ b/surfsense_web/components/editor/source-code-editor.tsx @@ -143,6 +143,11 @@ export function SourceCodeEditor({ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace", renderWhitespace: "selection", + unicodeHighlight: { + ambiguousCharacters: false, + invisibleCharacters: false, + nonBasicASCII: false, + }, smoothScrolling: true, readOnly, }} diff --git a/surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx b/surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx index 6fd4e48f8..dd7520d24 100644 --- a/surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DesktopLocalTabContent.tsx @@ -1,7 +1,9 @@ "use client"; import { Folder, FolderPlus, Search, X } from "lucide-react"; -import { useRef, useState } from "react"; +import { useAtom } from "jotai"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { localExpandedFolderKeysAtom } from "@/atoms/documents/folder.atoms"; import { Input } from "@/components/ui/input"; import { Separator } from "@/components/ui/separator"; import { @@ -45,6 +47,20 @@ export function DesktopLocalTabContent({ const [localSearch, setLocalSearch] = useState(""); const debouncedLocalSearch = useDebouncedValue(localSearch, 250); const localSearchInputRef = useRef(null); + const [expandedFolderKeyMap, setExpandedFolderKeyMap] = useAtom(localExpandedFolderKeysAtom); + const expandedFolderKeys = useMemo( + () => new Set(expandedFolderKeyMap[searchSpaceId] ?? []), + [expandedFolderKeyMap, searchSpaceId] + ); + const handleExpandedFolderKeysChange = useCallback( + (nextExpandedKeys: Set) => { + setExpandedFolderKeyMap((prev) => ({ + ...prev, + [searchSpaceId]: Array.from(nextExpandedKeys), + })); + }, + [searchSpaceId, setExpandedFolderKeyMap] + ); return (
@@ -181,6 +197,8 @@ export function DesktopLocalTabContent({ active searchQuery={debouncedLocalSearch.trim() || undefined} onOpenFile={onOpenLocalFile} + expandedFolderKeys={expandedFolderKeys} + onExpandedFolderKeysChange={handleExpandedFolderKeysChange} />
); diff --git a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx index a808d5a31..6bfb1d3f1 100644 --- a/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx +++ b/surfsense_web/components/layout/ui/sidebar/LocalFilesystemBrowser.tsx @@ -6,7 +6,6 @@ import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { useElectronAPI } from "@/hooks/use-platform"; -import { getSupportedExtensionsSet } from "@/lib/supported-extensions"; interface LocalFilesystemBrowserProps { rootPaths: string[]; @@ -14,6 +13,8 @@ interface LocalFilesystemBrowserProps { active?: boolean; searchQuery?: string; onOpenFile: (fullPath: string) => void; + expandedFolderKeys?: Set; + onExpandedFolderKeysChange?: (nextExpandedKeys: Set) => void; } interface LocalFolderFileEntry { @@ -43,6 +44,51 @@ type LocalRootMount = { type MountLoadStatus = "idle" | "loading" | "complete" | "error"; +const LOCAL_OPENABLE_EXTENSIONS = [ + ".md", + ".markdown", + ".txt", + ".json", + ".yaml", + ".yml", + ".csv", + ".tsv", + ".xml", + ".html", + ".htm", + ".css", + ".scss", + ".sass", + ".sql", + ".toml", + ".ini", + ".conf", + ".log", + ".py", + ".js", + ".jsx", + ".mjs", + ".cjs", + ".ts", + ".tsx", + ".java", + ".kt", + ".kts", + ".go", + ".rs", + ".rb", + ".php", + ".swift", + ".r", + ".lua", + ".sh", + ".bash", + ".zsh", + ".fish", + ".env", + ".mk", +]; + const getFolderDisplayName = (rootPath: string): string => rootPath.split(/[\\/]/).at(-1) || rootPath; @@ -73,16 +119,29 @@ function toMountedVirtualPath(mount: string, relativePath: string): string { return `/${mount}${toVirtualPath(relativePath)}`; } +function getNormalizedExtension(pathValue: string): string { + const fileName = getFileName(pathValue).toLowerCase(); + if (!fileName) return ""; + if (fileName === "dockerfile" || fileName === "makefile") { + return `.${fileName}`; + } + const dotIndex = fileName.lastIndexOf("."); + if (dotIndex <= 0) return ""; + return fileName.slice(dotIndex); +} + export function LocalFilesystemBrowser({ rootPaths, searchSpaceId, active = true, searchQuery, onOpenFile, + expandedFolderKeys, + onExpandedFolderKeysChange, }: LocalFilesystemBrowserProps) { const electronAPI = useElectronAPI(); const [rootStateMap, setRootStateMap] = useState>({}); - const [expandedFolderKeys, setExpandedFolderKeys] = useState>(new Set()); + const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState>(new Set()); const [mountByRootKey, setMountByRootKey] = useState>(new Map()); const [mountStatus, setMountStatus] = useState("idle"); const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false); @@ -90,8 +149,9 @@ export function LocalFilesystemBrowser({ const lastLoadedSignatureByRootRef = useRef>(new Map()); const hasLoadedMountsOnceRef = useRef(false); const hasResolvedAtLeastOneRootRef = useRef(false); - const supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []); + const openableExtensions = useMemo(() => new Set(LOCAL_OPENABLE_EXTENSIONS), []); const isWindowsPlatform = electronAPI?.versions.platform === "win32"; + const effectiveExpandedFolderKeys = expandedFolderKeys ?? internalExpandedFolderKeys; useEffect(() => { if (!active) return; @@ -153,7 +213,6 @@ export function LocalFilesystemBrowser({ rootPath, searchSpaceId, excludePatterns: DEFAULT_EXCLUDE_PATTERNS, - fileExtensions: supportedExtensions, })) as LocalFolderFileEntry[]; if (cancelled) return; setRootStateMap((prev) => ({ @@ -181,7 +240,7 @@ export function LocalFilesystemBrowser({ return () => { cancelled = true; }; - }, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId, supportedExtensions]); + }, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId]); useEffect(() => { if (active) return; @@ -198,7 +257,13 @@ export function LocalFilesystemBrowser({ return; } - const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event) => { + const unsubscribe = electronAPI.onAgentFilesystemTreeDirty((event: { + searchSpaceId: number | null; + reason: "watcher_event" | "safety_poll"; + rootPath: string; + changedPath: string | null; + timestamp: number; + }) => { if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) { return; } @@ -225,14 +290,13 @@ export function LocalFilesystemBrowser({ searchSpaceId, rootPaths, excludePatterns: DEFAULT_EXCLUDE_PATTERNS, - fileExtensions: supportedExtensions, }); return () => { unsubscribe(); void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId); }; - }, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId, supportedExtensions]); + }, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId]); useEffect(() => { if (!electronAPI?.getAgentFilesystemMounts) { @@ -315,7 +379,7 @@ export function LocalFilesystemBrowser({ }, [rootPaths, rootStateMap, searchQuery]); const toggleFolder = useCallback((folderKey: string) => { - setExpandedFolderKeys((prev) => { + const update = (prev: Set) => { const next = new Set(prev); if (next.has(folderKey)) { next.delete(folderKey); @@ -323,12 +387,17 @@ export function LocalFilesystemBrowser({ next.add(folderKey); } return next; - }); - }, []); + }; + if (onExpandedFolderKeysChange) { + onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys)); + return; + } + setInternalExpandedFolderKeys(update); + }, [effectiveExpandedFolderKeys, onExpandedFolderKeysChange]); const renderFolder = useCallback( (folder: LocalFolderNode, depth: number, mount: string) => { - const isExpanded = expandedFolderKeys.has(folder.key); + const isExpanded = effectiveExpandedFolderKeys.has(folder.key); const FolderIcon = isExpanded ? FolderOpen : Folder; const childFolders = Array.from(folder.folders.values()).sort((a, b) => a.name.localeCompare(b.name) @@ -354,26 +423,43 @@ export function LocalFilesystemBrowser({ {isExpanded && ( <> {childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))} - {files.map((file) => ( - - ))} + {files.map((file) => { + const extension = getNormalizedExtension(file.relativePath); + const isOpenable = openableExtensions.has(extension); + return ( + + ); + })} )}
); }, - [expandedFolderKeys, onOpenFile, toggleFolder] + [effectiveExpandedFolderKeys, onOpenFile, openableExtensions, toggleFolder] ); if (rootPaths.length === 0) { From 8c0670929595ce45c087fa2ef2da8dcdc578f037 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:26:33 +0530 Subject: [PATCH 13/14] refactor(source-code-editor): update editor settings by adjusting line number display, disabling folding, and modifying whitespace rendering options --- .../components/editor/source-code-editor.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/surfsense_web/components/editor/source-code-editor.tsx b/surfsense_web/components/editor/source-code-editor.tsx index 27734005e..dd4b3bd8e 100644 --- a/surfsense_web/components/editor/source-code-editor.tsx +++ b/surfsense_web/components/editor/source-code-editor.tsx @@ -114,10 +114,10 @@ export function SourceCodeEditor({ automaticLayout: true, minimap: { enabled: false }, lineNumbers: "on", - lineNumbersMinChars: 3, - lineDecorationsWidth: 12, + lineNumbersMinChars: 4, + lineDecorationsWidth: 20, glyphMargin: false, - folding: true, + folding: false, overviewRulerLanes: 0, hideCursorInOverviewRuler: true, scrollBeyondLastLine: false, @@ -142,7 +142,12 @@ export function SourceCodeEditor({ fontSize, fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace", - renderWhitespace: "selection", + renderWhitespace: "none", + renderValidationDecorations: "off", + colorDecorators: false, + codeLens: false, + hover: { enabled: false }, + stickyScroll: { enabled: false }, unicodeHighlight: { ambiguousCharacters: false, invisibleCharacters: false, From c238a671c8ec315348965009a2d6334874dbcd61 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:54:26 +0530 Subject: [PATCH 14/14] feat(filesystem): enhance local mount path normalization and add error handling for missing parent directories --- .../agents/new_chat/middleware/filesystem.py | 92 ++++++++++++++++++- .../middleware/local_folder_backend.py | 8 ++ .../middleware/test_file_intent_middleware.py | 4 +- .../test_filesystem_verification.py | 49 ++++++++++ .../middleware/test_local_folder_backend.py | 12 +++ 5 files changed, 160 insertions(+), 5 deletions(-) diff --git a/surfsense_backend/app/agents/new_chat/middleware/filesystem.py b/surfsense_backend/app/agents/new_chat/middleware/filesystem.py index 3622bbcdf..8dfa89ef2 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/filesystem.py +++ b/surfsense_backend/app/agents/new_chat/middleware/filesystem.py @@ -28,6 +28,9 @@ from langgraph.types import Command from sqlalchemy import delete, select from app.agents.new_chat.filesystem_selection import FilesystemMode +from app.agents.new_chat.middleware.multi_root_local_folder_backend import ( + MultiRootLocalFolderBackend, +) from app.agents.new_chat.sandbox import ( _evict_sandbox_cache, delete_sandbox, @@ -816,6 +819,89 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): return normalized return f"/{normalized.lstrip('/')}" + @staticmethod + def _extract_mount_from_path(path: str, mounts: tuple[str, ...]) -> str | None: + rel = path.lstrip("/") + if not rel: + return None + mount, _, _ = rel.partition("/") + if mount in mounts: + return mount + return None + + @staticmethod + def _local_parent_path(path: str) -> str: + rel = path.lstrip("/") + if "/" not in rel: + return "/" + parent = rel.rsplit("/", 1)[0].strip("/") + if not parent: + return "/" + return f"/{parent}" + + @staticmethod + def _path_exists_under_mount( + backend: MultiRootLocalFolderBackend, + mount: str, + local_path: str, + ) -> bool: + result = backend.list_tree( + f"/{mount}{local_path}", + max_depth=0, + page_size=1, + include_files=True, + include_dirs=True, + ) + return not bool(result.get("error")) + + def _normalize_local_mount_path( + self, + candidate: str, + runtime: ToolRuntime[None, FilesystemState], + ) -> str: + normalized = self._normalize_absolute_path(candidate) + backend = self._get_backend(runtime) + if not isinstance(backend, MultiRootLocalFolderBackend): + return normalized + + mounts = backend.list_mounts() + explicit_mount = self._extract_mount_from_path(normalized, mounts) + if explicit_mount: + return normalized + + if len(mounts) == 1: + return f"/{mounts[0]}{normalized}" + + suggested_mount: str | None = None + contract = runtime.state.get("file_operation_contract") or {} + suggested_path = contract.get("suggested_path") + if isinstance(suggested_path, str) and suggested_path.strip(): + normalized_suggested = self._normalize_absolute_path(suggested_path) + suggested_mount = self._extract_mount_from_path(normalized_suggested, mounts) + + matching_mounts = [ + mount + for mount in mounts + if self._path_exists_under_mount(backend, mount, normalized) + ] + if len(matching_mounts) == 1: + return f"/{matching_mounts[0]}{normalized}" + + parent_path = self._local_parent_path(normalized) + if parent_path != "/": + parent_matching_mounts = [ + mount + for mount in mounts + if self._path_exists_under_mount(backend, mount, parent_path) + ] + if len(parent_matching_mounts) == 1: + return f"/{parent_matching_mounts[0]}{normalized}" + + if suggested_mount: + return f"/{suggested_mount}{normalized}" + + return f"/{backend.default_mount()}{normalized}" + def _get_contract_suggested_path( self, runtime: ToolRuntime[None, FilesystemState] ) -> str: @@ -834,7 +920,7 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): if not candidate: return self._get_contract_suggested_path(runtime) if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: - return self._normalize_absolute_path(candidate) + return self._normalize_local_mount_path(candidate, runtime) if not candidate.startswith("/"): return f"/{candidate.lstrip('/')}" return candidate @@ -848,7 +934,7 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): if not candidate: return "" if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: - return self._normalize_absolute_path(candidate) + return self._normalize_local_mount_path(candidate, runtime) if not candidate.startswith("/"): return f"/{candidate.lstrip('/')}" return candidate @@ -862,7 +948,7 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): if candidate == "/": return "/" if self._filesystem_mode == FilesystemMode.DESKTOP_LOCAL_FOLDER: - return self._normalize_absolute_path(candidate) + return self._normalize_local_mount_path(candidate, runtime) if not candidate.startswith("/"): return f"/{candidate.lstrip('/')}" return candidate diff --git a/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py b/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py index 4f149a756..0cee3e007 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py +++ b/surfsense_backend/app/agents/new_chat/middleware/local_folder_backend.py @@ -180,6 +180,14 @@ class LocalFolderBackend: "Read and then make an edit, or write to a new path." ) ) + parent = path.parent + if not parent.exists() or not parent.is_dir(): + return WriteResult( + error=( + f"Error: parent directory for '{file_path}' does not exist. " + "Create the folder first or write to an existing directory." + ) + ) self._write_text_atomic(path, content) return WriteResult(path=file_path, files_update=None) diff --git a/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py b/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py index c0281fa29..673331b0a 100644 --- a/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py +++ b/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py @@ -79,7 +79,7 @@ async def test_file_write_null_filename_uses_semantic_default_path(): @pytest.mark.asyncio -async def test_file_write_null_filename_infers_json_extension(): +async def test_file_write_null_filename_defaults_to_markdown_path(): llm = _FakeLLM( '{"intent":"file_write","confidence":0.71,"suggested_filename":null}' ) @@ -94,7 +94,7 @@ async def test_file_write_null_filename_infers_json_extension(): assert result is not None contract = result["file_operation_contract"] assert contract["intent"] == FileOperationIntent.FILE_WRITE.value - assert contract["suggested_path"] == "/notes.json" + assert contract["suggested_path"] == "/notes.md" @pytest.mark.asyncio diff --git a/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py b/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py index 7b4119bb5..d00365032 100644 --- a/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py +++ b/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py @@ -34,6 +34,11 @@ class _RuntimeNoSuggestedPath: state = {"file_operation_contract": {}} +class _RuntimeWithSuggestedPath: + def __init__(self, suggested_path: str) -> None: + self.state = {"file_operation_contract": {"suggested_path": suggested_path}} + + def test_verify_written_content_prefers_raw_sync() -> None: middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware) expected = "line1\nline2" @@ -162,3 +167,47 @@ def test_normalize_local_mount_path_prefixes_posix_absolute_path_for_linux_and_m resolved = middleware._normalize_local_mount_path("/var/log/app.log", runtime) # type: ignore[arg-type] assert resolved == "/pc_backups/var/log/app.log" + + +def test_normalize_local_mount_path_prefers_unique_existing_parent_mount( + tmp_path: Path, +) -> None: + root_a = tmp_path / "RootA" + root_b = tmp_path / "RootB" + (root_a / "other").mkdir(parents=True) + (root_b / "nested" / "deep").mkdir(parents=True) + backend = MultiRootLocalFolderBackend( + (("root_a", str(root_a)), ("root_b", str(root_b))) + ) + runtime = _RuntimeNoSuggestedPath() + middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware) + middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign] + + resolved = middleware._normalize_local_mount_path( # type: ignore[arg-type] + "/nested/deep/new-note.md", + runtime, + ) + + assert resolved == "/root_b/nested/deep/new-note.md" + + +def test_normalize_local_mount_path_uses_suggested_mount_when_ambiguous( + tmp_path: Path, +) -> None: + root_a = tmp_path / "RootA" + root_b = tmp_path / "RootB" + root_a.mkdir(parents=True) + root_b.mkdir(parents=True) + backend = MultiRootLocalFolderBackend( + (("root_a", str(root_a)), ("root_b", str(root_b))) + ) + runtime = _RuntimeWithSuggestedPath("/root_b/notes/context.md") + middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware) + middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign] + + resolved = middleware._normalize_local_mount_path( # type: ignore[arg-type] + "/brand-new-note.md", + runtime, + ) + + assert resolved == "/root_b/brand-new-note.md" diff --git a/surfsense_backend/tests/unit/middleware/test_local_folder_backend.py b/surfsense_backend/tests/unit/middleware/test_local_folder_backend.py index 3484a2cc4..7dfc68402 100644 --- a/surfsense_backend/tests/unit/middleware/test_local_folder_backend.py +++ b/surfsense_backend/tests/unit/middleware/test_local_folder_backend.py @@ -9,6 +9,7 @@ pytestmark = pytest.mark.unit def test_local_backend_write_read_edit_roundtrip(tmp_path: Path): backend = LocalFolderBackend(str(tmp_path)) + (tmp_path / "notes").mkdir() write = backend.write("/notes/test.md", "line1\nline2") assert write.error is None @@ -51,9 +52,20 @@ def test_local_backend_glob_and_grep(tmp_path: Path): def test_local_backend_read_raw_returns_exact_content(tmp_path: Path): backend = LocalFolderBackend(str(tmp_path)) + (tmp_path / "notes").mkdir() expected = "# Title\n\nline 1\nline 2\n" write = backend.write("/notes/raw.md", expected) assert write.error is None raw = backend.read_raw("/notes/raw.md") assert raw == expected + + +def test_local_backend_write_rejects_missing_parent_directory(tmp_path: Path): + backend = LocalFolderBackend(str(tmp_path)) + + write = backend.write("/tempoo/new-note.md", "# New note") + + assert write.error is not None + assert "parent directory" in write.error + assert not (tmp_path / "tempoo").exists()