feat(filesystem): introduce support for local openable text file extensions and enhance folder expansion persistence in the UI

This commit is contained in:
Anish Sarkar 2026-04-28 01:12:15 +05:30
parent 7134b0feae
commit b85b7cbae0
5 changed files with 202 additions and 28 deletions

View file

@ -21,6 +21,51 @@ const MAX_LOCAL_ROOTS = 10;
const DEFAULT_SPACE_KEY = "default"; const DEFAULT_SPACE_KEY = "default";
let cachedSettingsStore: AgentFilesystemSettingsStore | null = null; let cachedSettingsStore: AgentFilesystemSettingsStore | null = null;
const LOCAL_OPENABLE_TEXT_EXTENSIONS = new Set<string>([
".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 { function getSettingsPath(): string {
return join(app.getPath("userData"), SETTINGS_FILENAME); return join(app.getPath("userData"), SETTINGS_FILENAME);
} }
@ -229,6 +274,16 @@ function toVirtualPath(rootPath: string, absolutePath: string): string {
return `/${rel.replace(/\\/g, "/")}`; 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 = { export type LocalRootMount = {
mount: string; mount: string;
rootPath: string; rootPath: string;
@ -441,6 +496,7 @@ export async function readAgentLocalFileText(
); );
} }
const absolutePath = resolveVirtualPath(rootMount.rootPath, subPath); const absolutePath = resolveVirtualPath(rootMount.rootPath, subPath);
assertLocalOpenableTextFile(absolutePath);
const content = await readFile(absolutePath, "utf8"); const content = await readFile(absolutePath, "utf8");
return { return {
path: toMountedVirtualPath(rootMount.mount, rootMount.rootPath, absolutePath), path: toMountedVirtualPath(rootMount.mount, rootMount.rootPath, absolutePath),

View file

@ -12,6 +12,15 @@ export const expandedFolderIdsAtom = atomWithStorage<Record<number, number[]>>(
{} {}
); );
/**
* Expanded folder keys for Local filesystem tree, keyed by search space ID.
* Persisted so local tree expansion survives remounts/reloads.
*/
export const localExpandedFolderKeysAtom = atomWithStorage<Record<number, string[]>>(
"surfsense:localExpandedFolderKeys",
{}
);
/** /**
* Folder currently being renamed (inline edit mode). * Folder currently being renamed (inline edit mode).
* null means no folder is being renamed. * null means no folder is being renamed.

View file

@ -143,6 +143,11 @@ export function SourceCodeEditor({
fontFamily: fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace", "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
renderWhitespace: "selection", renderWhitespace: "selection",
unicodeHighlight: {
ambiguousCharacters: false,
invisibleCharacters: false,
nonBasicASCII: false,
},
smoothScrolling: true, smoothScrolling: true,
readOnly, readOnly,
}} }}

View file

@ -1,7 +1,9 @@
"use client"; "use client";
import { Folder, FolderPlus, Search, X } from "lucide-react"; 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 { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import {
@ -45,6 +47,20 @@ export function DesktopLocalTabContent({
const [localSearch, setLocalSearch] = useState(""); const [localSearch, setLocalSearch] = useState("");
const debouncedLocalSearch = useDebouncedValue(localSearch, 250); const debouncedLocalSearch = useDebouncedValue(localSearch, 250);
const localSearchInputRef = useRef<HTMLInputElement>(null); const localSearchInputRef = useRef<HTMLInputElement>(null);
const [expandedFolderKeyMap, setExpandedFolderKeyMap] = useAtom(localExpandedFolderKeysAtom);
const expandedFolderKeys = useMemo(
() => new Set(expandedFolderKeyMap[searchSpaceId] ?? []),
[expandedFolderKeyMap, searchSpaceId]
);
const handleExpandedFolderKeysChange = useCallback(
(nextExpandedKeys: Set<string>) => {
setExpandedFolderKeyMap((prev) => ({
...prev,
[searchSpaceId]: Array.from(nextExpandedKeys),
}));
},
[searchSpaceId, setExpandedFolderKeyMap]
);
return ( return (
<div className="flex min-h-0 flex-1 flex-col select-none"> <div className="flex min-h-0 flex-1 flex-col select-none">
@ -181,6 +197,8 @@ export function DesktopLocalTabContent({
active active
searchQuery={debouncedLocalSearch.trim() || undefined} searchQuery={debouncedLocalSearch.trim() || undefined}
onOpenFile={onOpenLocalFile} onOpenFile={onOpenLocalFile}
expandedFolderKeys={expandedFolderKeys}
onExpandedFolderKeysChange={handleExpandedFolderKeysChange}
/> />
</div> </div>
); );

View file

@ -6,7 +6,6 @@ import { DEFAULT_EXCLUDE_PATTERNS } from "@/components/sources/FolderWatchDialog
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform"; import { useElectronAPI } from "@/hooks/use-platform";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
interface LocalFilesystemBrowserProps { interface LocalFilesystemBrowserProps {
rootPaths: string[]; rootPaths: string[];
@ -14,6 +13,8 @@ interface LocalFilesystemBrowserProps {
active?: boolean; active?: boolean;
searchQuery?: string; searchQuery?: string;
onOpenFile: (fullPath: string) => void; onOpenFile: (fullPath: string) => void;
expandedFolderKeys?: Set<string>;
onExpandedFolderKeysChange?: (nextExpandedKeys: Set<string>) => void;
} }
interface LocalFolderFileEntry { interface LocalFolderFileEntry {
@ -43,6 +44,51 @@ type LocalRootMount = {
type MountLoadStatus = "idle" | "loading" | "complete" | "error"; 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 => const getFolderDisplayName = (rootPath: string): string =>
rootPath.split(/[\\/]/).at(-1) || rootPath; rootPath.split(/[\\/]/).at(-1) || rootPath;
@ -73,16 +119,29 @@ function toMountedVirtualPath(mount: string, relativePath: string): string {
return `/${mount}${toVirtualPath(relativePath)}`; 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({ export function LocalFilesystemBrowser({
rootPaths, rootPaths,
searchSpaceId, searchSpaceId,
active = true, active = true,
searchQuery, searchQuery,
onOpenFile, onOpenFile,
expandedFolderKeys,
onExpandedFolderKeysChange,
}: LocalFilesystemBrowserProps) { }: LocalFilesystemBrowserProps) {
const electronAPI = useElectronAPI(); const electronAPI = useElectronAPI();
const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({}); const [rootStateMap, setRootStateMap] = useState<Record<string, RootLoadState>>({});
const [expandedFolderKeys, setExpandedFolderKeys] = useState<Set<string>>(new Set()); const [internalExpandedFolderKeys, setInternalExpandedFolderKeys] = useState<Set<string>>(new Set());
const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map()); const [mountByRootKey, setMountByRootKey] = useState<Map<string, string>>(new Map());
const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle"); const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle");
const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false); const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false);
@ -90,8 +149,9 @@ export function LocalFilesystemBrowser({
const lastLoadedSignatureByRootRef = useRef<Map<string, string>>(new Map()); const lastLoadedSignatureByRootRef = useRef<Map<string, string>>(new Map());
const hasLoadedMountsOnceRef = useRef(false); const hasLoadedMountsOnceRef = useRef(false);
const hasResolvedAtLeastOneRootRef = 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 isWindowsPlatform = electronAPI?.versions.platform === "win32";
const effectiveExpandedFolderKeys = expandedFolderKeys ?? internalExpandedFolderKeys;
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
@ -153,7 +213,6 @@ export function LocalFilesystemBrowser({
rootPath, rootPath,
searchSpaceId, searchSpaceId,
excludePatterns: DEFAULT_EXCLUDE_PATTERNS, excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: supportedExtensions,
})) as LocalFolderFileEntry[]; })) as LocalFolderFileEntry[];
if (cancelled) return; if (cancelled) return;
setRootStateMap((prev) => ({ setRootStateMap((prev) => ({
@ -181,7 +240,7 @@ export function LocalFilesystemBrowser({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId, supportedExtensions]); }, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId]);
useEffect(() => { useEffect(() => {
if (active) return; if (active) return;
@ -198,7 +257,13 @@ export function LocalFilesystemBrowser({
return; 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)) { if ((event.searchSpaceId ?? null) !== (searchSpaceId ?? null)) {
return; return;
} }
@ -225,14 +290,13 @@ export function LocalFilesystemBrowser({
searchSpaceId, searchSpaceId,
rootPaths, rootPaths,
excludePatterns: DEFAULT_EXCLUDE_PATTERNS, excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: supportedExtensions,
}); });
return () => { return () => {
unsubscribe(); unsubscribe();
void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId); void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId);
}; };
}, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId, supportedExtensions]); }, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId]);
useEffect(() => { useEffect(() => {
if (!electronAPI?.getAgentFilesystemMounts) { if (!electronAPI?.getAgentFilesystemMounts) {
@ -315,7 +379,7 @@ export function LocalFilesystemBrowser({
}, [rootPaths, rootStateMap, searchQuery]); }, [rootPaths, rootStateMap, searchQuery]);
const toggleFolder = useCallback((folderKey: string) => { const toggleFolder = useCallback((folderKey: string) => {
setExpandedFolderKeys((prev) => { const update = (prev: Set<string>) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(folderKey)) { if (next.has(folderKey)) {
next.delete(folderKey); next.delete(folderKey);
@ -323,12 +387,17 @@ export function LocalFilesystemBrowser({
next.add(folderKey); next.add(folderKey);
} }
return next; return next;
}); };
}, []); if (onExpandedFolderKeysChange) {
onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys));
return;
}
setInternalExpandedFolderKeys(update);
}, [effectiveExpandedFolderKeys, onExpandedFolderKeysChange]);
const renderFolder = useCallback( const renderFolder = useCallback(
(folder: LocalFolderNode, depth: number, mount: string) => { (folder: LocalFolderNode, depth: number, mount: string) => {
const isExpanded = expandedFolderKeys.has(folder.key); const isExpanded = effectiveExpandedFolderKeys.has(folder.key);
const FolderIcon = isExpanded ? FolderOpen : Folder; const FolderIcon = isExpanded ? FolderOpen : Folder;
const childFolders = Array.from(folder.folders.values()).sort((a, b) => const childFolders = Array.from(folder.folders.values()).sort((a, b) =>
a.name.localeCompare(b.name) a.name.localeCompare(b.name)
@ -354,26 +423,43 @@ export function LocalFilesystemBrowser({
{isExpanded && ( {isExpanded && (
<> <>
{childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))} {childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))}
{files.map((file) => ( {files.map((file) => {
<button const extension = getNormalizedExtension(file.relativePath);
key={file.fullPath} const isOpenable = openableExtensions.has(extension);
type="button" return (
onClick={() => onOpenFile(toMountedVirtualPath(mount, file.relativePath))} <button
className="flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors hover:bg-muted/60" key={file.fullPath}
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }} type="button"
title={file.fullPath} onClick={
draggable={false} isOpenable
> ? () => onOpenFile(toMountedVirtualPath(mount, file.relativePath))
<FileText className="size-3.5 shrink-0 text-muted-foreground" /> : undefined
<span className="truncate">{getFileName(file.relativePath)}</span> }
</button> className={`flex h-8 w-full items-center gap-1.5 rounded-md px-2 text-left text-sm transition-colors ${
))} isOpenable
? "hover:bg-muted/60"
: "cursor-not-allowed opacity-60"
}`}
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
title={
isOpenable
? file.fullPath
: `${file.fullPath}\nThis file type cannot be opened in the editor.`
}
draggable={false}
disabled={!isOpenable}
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{getFileName(file.relativePath)}</span>
</button>
);
})}
</> </>
)} )}
</div> </div>
); );
}, },
[expandedFolderKeys, onOpenFile, toggleFolder] [effectiveExpandedFolderKeys, onOpenFile, openableExtensions, toggleFolder]
); );
if (rootPaths.length === 0) { if (rootPaths.length === 0) {