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; }