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) {