Merge pull request #1314 from AnishSarkar22/fix/swappable-filesystem

fix: improve swappable filesystem architecture
This commit is contained in:
Rohan Verma 2026-04-27 13:32:56 -07:00 committed by GitHub
commit 19f4668e8b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2364 additions and 407 deletions

View file

@ -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: {

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).
* null means no folder is being renamed.

View file

@ -229,6 +229,44 @@ function extractDomain(url: string): string {
// Canonical local-file virtual paths are mount-prefixed: /<mount>/<relative/path>
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"
>

View file

@ -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 (
<div className="space-y-6 p-6">
@ -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<string> => {
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,11 +176,15 @@ 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 resolvedLocalPath = await resolveLocalVirtualPath(localFilePath);
const readResult = await electronAPI.readAgentLocalFileText(
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,
@ -192,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 () => {
@ -226,7 +282,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) {
@ -236,10 +292,12 @@ 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,
contentToSave
resolvedLocalPath,
contentToSave,
searchSpaceId
);
if (!writeResult.ok) {
throw new Error(writeResult.error || "Failed to save local file");
@ -286,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" ||

View file

@ -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,17 @@ 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,
nonBasicASCII: false,
},
smoothScrolling: true,
readOnly,
}}

View file

@ -0,0 +1,205 @@
"use client";
import { Folder, FolderPlus, Search, X } from "lucide-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 {
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> | void;
onRemoveFilesystemRoot: (rootPath: string) => Promise<void> | void;
onClearFilesystemRoots: () => Promise<void> | 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<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 (
<div className="flex min-h-0 flex-1 flex-col select-none">
<div className="mx-4 mt-4 mb-3">
<div className="flex h-7 w-full items-stretch rounded-lg border bg-muted/50 text-[11px] text-muted-foreground">
{localRootPaths.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="min-w-0 flex-1 flex items-center gap-1 rounded-l-lg px-2 text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
title={localRootPaths.join("\n")}
aria-label="Manage selected folders"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">
{localRootPaths.length === 1
? "1 folder selected"
: `${localRootPaths.length} folders selected`}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 select-none p-0.5">
<DropdownMenuLabel className="px-1.5 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Selected folders
</DropdownMenuLabel>
<DropdownMenuSeparator className="mx-1 my-0.5" />
{localRootPaths.map((rootPath) => (
<DropdownMenuItem
key={rootPath}
onSelect={(event) => event.preventDefault()}
className="group h-8 gap-1.5 px-1.5 text-sm text-foreground"
>
<Folder className="size-3.5 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{getFolderDisplayName(rootPath)}
</span>
<button
type="button"
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
onClick={(event) => {
event.stopPropagation();
void onRemoveFilesystemRoot(rootPath);
}}
aria-label={`Remove ${getFolderDisplayName(rootPath)}`}
>
<X className="size-3" />
</button>
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="mx-1 my-0.5" />
<DropdownMenuItem
variant="destructive"
className="h-8 px-1.5 text-xs text-destructive focus:text-destructive"
onClick={() => {
void onClearFilesystemRoots();
}}
>
Clear all folders
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div
className="min-w-0 flex-1 flex items-center gap-1 px-2"
title="No local folders selected"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">No local folders selected</span>
</div>
)}
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-3 self-center bg-border"
/>
{electronAvailable ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<button
type="button"
className="flex w-8 items-center justify-center rounded-r-lg text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-50"
onClick={() => {
void onPickFilesystemRoot();
}}
disabled={!canAddMoreLocalRoots}
aria-label="Add folder"
>
<FolderPlus className="size-3.5" />
</button>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{canAddMoreLocalRoots
? "Add folder"
: `You can add up to ${maxLocalFilesystemRoots} folders`}
</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
<div className="mx-4 mb-2">
<div className="relative flex-1 min-w-0">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
<Search size={13} aria-hidden="true" />
</div>
<Input
ref={localSearchInputRef}
className="peer h-8 w-full pl-8 pr-8 text-sm bg-sidebar border-border/60 select-none focus:select-text"
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
placeholder="Search local files"
type="text"
aria-label="Search local files"
/>
{Boolean(localSearch) && (
<button
type="button"
className="absolute inset-y-0 right-0 flex h-full w-8 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear local search"
onClick={() => {
setLocalSearch("");
localSearchInputRef.current?.focus();
}}
>
<X size={13} strokeWidth={2} aria-hidden="true" />
</button>
)}
</div>
</div>
<LocalFilesystemBrowser
rootPaths={localRootPaths}
searchSpaceId={searchSpaceId}
active
searchQuery={debouncedLocalSearch.trim() || undefined}
onOpenFile={onOpenLocalFile}
expandedFolderKeys={expandedFolderKeys}
onExpandedFolderKeysChange={handleExpandedFolderKeysChange}
/>
</div>
);
}

View file

@ -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,17 +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 { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -83,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";
@ -92,12 +80,42 @@ 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 = 5;
const MAX_LOCAL_FILESYSTEM_ROOTS = 10;
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 (
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-1">
<div className="space-y-1">
{rows.map((row) => (
<div key={row.id} className="flex h-8 items-center gap-2 px-2">
<Skeleton className="h-4 w-4 rounded-sm" />
<Skeleton className={`h-4 ${row.widthClass}`} />
</div>
))}
</div>
</div>
);
}
type FilesystemSettings = {
mode: "cloud" | "desktop_local_folder";
@ -115,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" },
@ -143,25 +158,40 @@ interface DocumentsSidebarProps {
export function DocumentsSidebar(props: DocumentsSidebarProps) {
const isAnonymous = useIsAnonymous();
const { isDesktop } = usePlatform();
if (isAnonymous) {
return <AnonymousDocumentsSidebar {...props} />;
}
return <AuthenticatedDocumentsSidebar {...props} />;
return isDesktop ? (
<AuthenticatedDesktopDocumentsSidebar {...props} />
) : (
<AuthenticatedWebDocumentsSidebar {...props} />
);
}
function AuthenticatedDocumentsSidebar({
function AuthenticatedDesktopDocumentsSidebar(props: DocumentsSidebarProps) {
return <AuthenticatedDocumentsSidebarBase {...props} desktopFeaturesEnabled />;
}
function AuthenticatedWebDocumentsSidebar(props: DocumentsSidebarProps) {
return <AuthenticatedDocumentsSidebarBase {...props} desktopFeaturesEnabled={false} />;
}
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);
@ -171,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<HTMLInputElement>(null);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [filesystemSettings, setFilesystemSettings] = useState<FilesystemSettings | null>(null);
const [localTrustDialogOpen, setLocalTrustDialogOpen] = useState(false);
@ -181,13 +208,13 @@ function AuthenticatedDocumentsSidebar({
const [watchedFolderIds, setWatchedFolderIds] = useState<Set<number>>(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;
let mounted = true;
electronAPI
.getAgentFilesystemSettings()
.getAgentFilesystemSettings(searchSpaceId)
.then((settings: FilesystemSettings) => {
if (!mounted) return;
setFilesystemSettings(settings);
@ -203,7 +230,7 @@ function AuthenticatedDocumentsSidebar({
return () => {
mounted = false;
};
}, [electronAPI]);
}, [electronAPI, searchSpaceId]);
const hasLocalFilesystemTrust = useCallback(() => {
try {
@ -219,17 +246,17 @@ 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;
const updated = await electronAPI.setAgentFilesystemSettings({
mode: "desktop_local_folder",
localRootPaths: nextLocalRootPaths,
});
}, searchSpaceId);
setFilesystemSettings(updated);
},
[electronAPI, localRootPaths]
[electronAPI, localRootPaths, searchSpaceId]
);
const runPickLocalRoot = useCallback(async () => {
@ -258,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 () => {
@ -269,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
@ -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,173 +1131,73 @@ function AuthenticatedDocumentsSidebar({
</div>
)}
<FolderTreeView
folders={treeFolders}
documents={searchFilteredDocuments}
expandedIds={expandedIds}
onToggleExpand={toggleFolderExpand}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={handleToggleFolderSelect}
onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder}
onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => {
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 ? (
<CloudDocumentsSkeleton />
) : (
<FolderTreeView
folders={treeFolders}
documents={searchFilteredDocuments}
expandedIds={expandedIds}
onToggleExpand={toggleFolderExpand}
mentionedDocIds={mentionedDocIds}
onToggleChatMention={handleToggleChatMention}
onToggleFolderSelect={handleToggleFolderSelect}
onRenameFolder={handleRenameFolder}
onDeleteFolder={handleDeleteFolder}
onMoveFolder={handleMoveFolder}
onCreateFolder={handleCreateFolder}
searchQuery={debouncedSearch.trim() || undefined}
onPreviewDocument={(doc) => {
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}
/>
)}
</div>
</div>
</>
);
const localContent = (
<div className="flex min-h-0 flex-1 flex-col select-none">
<div className="mx-4 mt-4 mb-3">
<div className="flex h-7 w-full items-stretch rounded-lg border bg-muted/50 text-[11px] text-muted-foreground">
{localRootPaths.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="min-w-0 flex-1 flex items-center gap-1 rounded-l-lg px-2 text-left transition-colors hover:bg-muted/80 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
title={localRootPaths.join("\n")}
aria-label="Manage selected folders"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">
{localRootPaths.length === 1
? "1 folder selected"
: `${localRootPaths.length} folders selected`}
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 select-none p-0.5">
<DropdownMenuLabel className="px-1.5 pt-1.5 pb-0.5 text-xs font-medium text-muted-foreground">
Selected folders
</DropdownMenuLabel>
<DropdownMenuSeparator className="mx-1 my-0.5" />
{localRootPaths.map((rootPath) => (
<DropdownMenuItem
key={rootPath}
onClick={() => {
void handleRemoveFilesystemRoot(rootPath);
}}
className="group h-8 gap-1.5 px-1.5 text-sm text-foreground"
>
<Folder className="size-3.5 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{getFolderDisplayName(rootPath)}
</span>
<X className="size-3 text-muted-foreground transition-colors group-hover:text-foreground" />
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="mx-1 my-0.5" />
<DropdownMenuItem
variant="destructive"
className="h-8 px-1.5 text-xs text-destructive focus:text-destructive"
onClick={() => {
void handleClearFilesystemRoots();
}}
>
Clear all folders
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<div
className="min-w-0 flex-1 flex items-center gap-1 px-2"
title="No local folders selected"
>
<Folder className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">No local folders selected</span>
</div>
)}
<Separator
orientation="vertical"
className="data-[orientation=vertical]:h-3 self-center bg-border"
/>
<button
type="button"
className="flex w-8 items-center justify-center rounded-r-lg text-muted-foreground transition-colors hover:bg-muted/80 hover:text-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:opacity-50"
onClick={() => {
void handlePickFilesystemRoot();
}}
disabled={!canAddMoreLocalRoots}
aria-label="Add folder"
title="Add folder"
>
<FolderPlus className="size-3.5" />
</button>
</div>
</div>
<div className="mx-4 mb-2">
<div className="relative flex-1 min-w-0">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
<Search size={13} aria-hidden="true" />
</div>
<Input
ref={localSearchInputRef}
className="peer h-8 w-full pl-8 pr-8 text-sm bg-sidebar border-border/60 select-none focus:select-text"
value={localSearch}
onChange={(e) => setLocalSearch(e.target.value)}
placeholder="Search local files"
type="text"
aria-label="Search local files"
/>
{Boolean(localSearch) && (
<button
type="button"
className="absolute inset-y-0 right-0 flex h-full w-8 items-center justify-center rounded-r-md text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear local search"
onClick={() => {
setLocalSearch("");
localSearchInputRef.current?.focus();
}}
>
<X size={13} strokeWidth={2} aria-hidden="true" />
</button>
)}
</div>
</div>
<LocalFilesystemBrowser
rootPaths={localRootPaths}
searchSpaceId={searchSpaceId}
searchQuery={debouncedLocalSearch.trim() || undefined}
onOpenFile={(localFilePath) => {
openEditorPanel({
kind: "local_file",
localFilePath,
title: localFilePath.split("/").pop() || localFilePath,
searchSpaceId,
});
}}
/>
</div>
<DesktopLocalTabContent
localRootPaths={localRootPaths}
canAddMoreLocalRoots={canAddMoreLocalRoots}
maxLocalFilesystemRoots={MAX_LOCAL_FILESYSTEM_ROOTS}
searchSpaceId={searchSpaceId}
onPickFilesystemRoot={handlePickFilesystemRoot}
onRemoveFilesystemRoot={handleRemoveFilesystemRoot}
onClearFilesystemRoots={handleClearFilesystemRoots}
onOpenLocalFile={(localFilePath) => {
openEditorPanel({
kind: "local_file",
localFilePath,
title: localFilePath.split("/").pop() || localFilePath,
searchSpaceId,
});
}}
electronAvailable={!!electronAPI}
/>
);
const documentsContent = (
@ -1300,16 +1230,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"
>
<Server className="size-3" />
<span>Cloud</span>
<Server className="size-3 shrink-0" />
<span className="leading-none">Cloud</span>
</TabsTrigger>
<TabsTrigger
value="local"
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="Local"
>
<Laptop className="size-3" />
<span>Local</span>
<Laptop className="size-3 shrink-0" />
<span className="leading-none">Local</span>
</TabsTrigger>
</TabsList>
</Tabs>
@ -1361,7 +1291,7 @@ function AuthenticatedDocumentsSidebar({
{cloudContent}
</TabsContent>
<TabsContent value="local" className="mt-0 flex min-h-0 flex-1 flex-col">
{localContent}
{currentFilesystemTab === "local" ? localContent : null}
</TabsContent>
</Tabs>
) : (

View file

@ -1,17 +1,20 @@
"use client";
import { ChevronDown, ChevronRight, FileText, Folder } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "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";
import { Spinner } from "@/components/ui/spinner";
import { useElectronAPI } from "@/hooks/use-platform";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
interface LocalFilesystemBrowserProps {
rootPaths: string[];
searchSpaceId: number;
active?: boolean;
searchQuery?: string;
onOpenFile: (fullPath: string) => void;
expandedFolderKeys?: Set<string>;
onExpandedFolderKeysChange?: (nextExpandedKeys: Set<string>) => void;
}
interface LocalFolderFileEntry {
@ -39,6 +42,53 @@ type LocalRootMount = {
rootPath: string;
};
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;
@ -69,24 +119,83 @@ 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<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 supportedExtensions = useMemo(() => Array.from(getSupportedExtensionsSet()), []);
const [mountStatus, setMountStatus] = useState<MountLoadStatus>("idle");
const [mountRefreshInFlight, setMountRefreshInFlight] = useState(false);
const [reloadNonceByRoot, setReloadNonceByRoot] = useState<Record<string, number>>({});
const lastLoadedSignatureByRootRef = useRef<Map<string, string>>(new Map());
const hasLoadedMountsOnceRef = useRef(false);
const hasResolvedAtLeastOneRootRef = useRef(false);
const openableExtensions = useMemo(() => new Set(LOCAL_OPENABLE_EXTENSIONS), []);
const isWindowsPlatform = electronAPI?.versions.platform === "win32";
const effectiveExpandedFolderKeys = expandedFolderKeys ?? internalExpandedFolderKeys;
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 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;
}
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]: {
@ -98,16 +207,12 @@ export function LocalFilesystemBrowser({
}
void Promise.all(
rootPaths.map(async (rootPath) => {
rootsToReload.map(async ({ rootPath }) => {
try {
const files = (await electronAPI.listFolderFiles({
path: rootPath,
name: getFolderDisplayName(rootPath),
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
fileExtensions: supportedExtensions,
rootFolderId: null,
const files = (await electronAPI.listAgentFilesystemFiles({
rootPath,
searchSpaceId,
active: true,
excludePatterns: DEFAULT_EXCLUDE_PATTERNS,
})) as LocalFolderFileEntry[];
if (cancelled) return;
setRootStateMap((prev) => ({
@ -135,32 +240,112 @@ export function LocalFilesystemBrowser({
return () => {
cancelled = true;
};
}, [electronAPI, rootPaths, searchSpaceId, supportedExtensions]);
}, [active, electronAPI, isWindowsPlatform, reloadNonceByRoot, rootPaths, searchSpaceId]);
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: {
searchSpaceId: number | null;
reason: "watcher_event" | "safety_poll";
rootPath: string;
changedPath: string | null;
timestamp: number;
}) => {
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,
});
return () => {
unsubscribe();
void electronAPI.stopAgentFilesystemTreeWatch(searchSpaceId);
};
}, [active, electronAPI, isWindowsPlatform, rootPaths, searchSpaceId]);
useEffect(() => {
if (!electronAPI?.getAgentFilesystemMounts) {
setMountStatus("error");
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) {
setMountStatus("loading");
} else {
setMountRefreshInFlight(true);
}
void electronAPI
.getAgentFilesystemMounts()
.getAgentFilesystemMounts(searchSpaceId)
.then((mounts: LocalRootMount[]) => {
if (cancelled) return;
const next = new Map<string, string>();
for (const entry of mounts) {
next.set(normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform), entry.mount);
const normalizedRootKey = normalizeRootPathForLookup(entry.rootPath, isWindowsPlatform);
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;
};
}, [electronAPI, isWindowsPlatform, rootPaths]);
}, [electronAPI, isWindowsPlatform, rootPaths, searchSpaceId]);
const treeByRoot = useMemo(() => {
const query = searchQuery?.trim().toLowerCase() ?? "";
@ -194,7 +379,7 @@ export function LocalFilesystemBrowser({
}, [rootPaths, rootStateMap, searchQuery]);
const toggleFolder = useCallback((folderKey: string) => {
setExpandedFolderKeys((prev) => {
const update = (prev: Set<string>) => {
const next = new Set(prev);
if (next.has(folderKey)) {
next.delete(folderKey);
@ -202,12 +387,18 @@ 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)
);
@ -226,32 +417,49 @@ export function LocalFilesystemBrowser({
) : (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground" />
)}
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<FolderIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{folder.name}</span>
</button>
{isExpanded && (
<>
{childFolders.map((childFolder) => renderFolder(childFolder, depth + 1, mount))}
{files.map((file) => (
<button
key={file.fullPath}
type="button"
onClick={() => onOpenFile(toMountedVirtualPath(mount, file.relativePath))}
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"
style={{ paddingInlineStart: `${(depth + 1) * 12 + 22}px` }}
title={file.fullPath}
draggable={false}
>
<FileText className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{getFileName(file.relativePath)}</span>
</button>
))}
{files.map((file) => {
const extension = getNormalizedExtension(file.relativePath);
const isOpenable = openableExtensions.has(extension);
return (
<button
key={file.fullPath}
type="button"
onClick={
isOpenable
? () => onOpenFile(toMountedVirtualPath(mount, file.relativePath))
: undefined
}
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>
);
},
[expandedFolderKeys, onOpenFile, toggleFolder]
[effectiveExpandedFolderKeys, onOpenFile, openableExtensions, toggleFolder]
);
if (rootPaths.length === 0) {
@ -265,6 +473,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 (
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
<div className="space-y-1">
{rows.map((row) => (
<div key={row.id} className="flex h-8 items-center gap-2 px-2">
<Skeleton className="h-4 w-4 rounded-sm" />
<Skeleton className={`h-4 ${row.widthClass}`} />
</div>
))}
</div>
</div>
);
}
return (
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
{treeByRoot.map(({ rootPath, rootNode, matchCount, totalCount }) => {
@ -273,9 +518,11 @@ export function LocalFilesystemBrowser({
const mount = mountByRootKey.get(rootKey);
if (!state || state.loading) {
return (
<div key={rootPath} className="flex h-16 items-center gap-2 px-3 text-sm text-muted-foreground">
<Spinner size="sm" />
<span>Loading {getFolderDisplayName(rootPath)}...</span>
<div key={rootPath} className="mb-1 px-3 py-2 text-xs text-muted-foreground/80">
<div className="flex items-center gap-2">
<Spinner className="size-3.5" />
<span>Loading {getFolderDisplayName(rootPath)}...</span>
</div>
</div>
);
}
@ -291,11 +538,24 @@ export function LocalFilesystemBrowser({
return (
<div key={rootPath} className="mb-1">
{mount ? renderFolder(rootNode, 0, mount) : null}
{!mount && (
{!mount && (mountRefreshInFlight || mountStatus === "loading") && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
<div className="flex items-center gap-2">
<Spinner className="size-3.5" />
<span>Loading {getFolderDisplayName(rootPath)}...</span>
</div>
</div>
)}
{!mount && mountStatus === "complete" && !mountRefreshInFlight && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
Unable to resolve mounted root for this folder.
</div>
)}
{!mount && mountStatus === "error" && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
Failed to resolve local folder mounts.
</div>
)}
{isEmpty && (
<div className="px-3 pb-2 text-xs text-muted-foreground/80">
No supported files found in this folder.

View file

@ -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<typeof TooltipPrimitive.Root>) {
const isMobile = useIsTouchDevice();
const canHover = useCanHover();
return (
<TooltipProvider>
<TooltipPrimitive.Root
data-slot="tooltip"
open={isMobile ? false : open}
onOpenChange={isMobile ? undefined : onOpenChange}
open={canHover ? open : false}
onOpenChange={canHover ? onOpenChange : undefined}
{...props}
/>
</TooltipProvider>

View file

@ -22,15 +22,17 @@ export function getClientPlatform(): ClientPlatform {
return window.electronAPI ? "desktop" : "web";
}
export async function getAgentFilesystemSelection(): Promise<AgentFilesystemSelection> {
export async function getAgentFilesystemSelection(
searchSpaceId?: number | null
): Promise<AgentFilesystemSelection> {
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,

View file

@ -54,6 +54,28 @@ interface AgentFilesystemMount {
rootPath: string;
}
interface AgentFilesystemListOptions {
rootPath: string;
searchSpaceId?: number | null;
excludePatterns?: string[] | null;
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;
@ -114,10 +136,14 @@ interface ElectronAPI {
// Browse files/folders via native dialogs
browseFiles: () => Promise<string[] | null>;
readLocalFiles: (paths: string[]) => Promise<LocalFileData[]>;
readAgentLocalFileText: (virtualPath: string) => Promise<LocalTextFileResult>;
readAgentLocalFileText: (
virtualPath: string,
searchSpaceId?: number | null
) => Promise<LocalTextFileResult>;
writeAgentLocalFileText: (
virtualPath: string,
content: string
content: string,
searchSpaceId?: number | null
) => Promise<LocalTextFileResult>;
// Auth token sync across windows
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
@ -151,12 +177,22 @@ interface ElectronAPI {
platform: string;
}>;
// Agent filesystem mode
getAgentFilesystemSettings: () => Promise<AgentFilesystemSettings>;
getAgentFilesystemMounts: () => Promise<AgentFilesystemMount[]>;
getAgentFilesystemSettings: (searchSpaceId?: number | null) => Promise<AgentFilesystemSettings>;
getAgentFilesystemMounts: (searchSpaceId?: number | null) => Promise<AgentFilesystemMount[]>;
listAgentFilesystemFiles: (
options: AgentFilesystemListOptions
) => Promise<FolderFileEntry[]>;
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;
}) => Promise<AgentFilesystemSettings>;
}, searchSpaceId?: number | null) => Promise<AgentFilesystemSettings>;
pickAgentFilesystemRoot: () => Promise<string | null>;
}