mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-03 12:52:39 +02:00
Merge upstream/dev
This commit is contained in:
commit
2d962f6dd2
107 changed files with 15033 additions and 2277 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,19 +6,17 @@ import {
|
|||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderClock,
|
||||
FolderPlus,
|
||||
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,
|
||||
|
|
@ -64,16 +61,7 @@ import {
|
|||
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
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 { useElectronAPI, usePlatform } 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,14 @@ 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 +231,7 @@ function AuthenticatedDocumentsSidebar({
|
|||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [electronAPI]);
|
||||
}, [electronAPI, searchSpaceId]);
|
||||
|
||||
const hasLocalFilesystemTrust = useCallback(() => {
|
||||
try {
|
||||
|
|
@ -219,17 +247,20 @@ 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,
|
||||
});
|
||||
const updated = await electronAPI.setAgentFilesystemSettings(
|
||||
{
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: nextLocalRootPaths,
|
||||
},
|
||||
searchSpaceId
|
||||
);
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI, localRootPaths]
|
||||
[electronAPI, localRootPaths, searchSpaceId]
|
||||
);
|
||||
|
||||
const runPickLocalRoot = useCallback(async () => {
|
||||
|
|
@ -255,33 +286,42 @@ function AuthenticatedDocumentsSidebar({
|
|||
const handleRemoveFilesystemRoot = useCallback(
|
||||
async (rootPathToRemove: string) => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: localRootPaths.filter((rootPath) => rootPath !== rootPathToRemove),
|
||||
});
|
||||
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 () => {
|
||||
if (!electronAPI?.setAgentFilesystemSettings) return;
|
||||
const updated = await electronAPI.setAgentFilesystemSettings({
|
||||
mode: "desktop_local_folder",
|
||||
localRootPaths: [],
|
||||
});
|
||||
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",
|
||||
});
|
||||
const updated = await electronAPI.setAgentFilesystemSettings(
|
||||
{
|
||||
mode: tab === "cloud" ? "cloud" : "desktop_local_folder",
|
||||
},
|
||||
searchSpaceId
|
||||
);
|
||||
setFilesystemSettings(updated);
|
||||
},
|
||||
[electronAPI]
|
||||
[electronAPI, searchSpaceId]
|
||||
);
|
||||
|
||||
// AI File Sort state
|
||||
|
|
@ -407,8 +447,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(
|
||||
|
|
@ -994,6 +1034,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 = (
|
||||
<>
|
||||
|
|
@ -1106,173 +1149,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 = (
|
||||
|
|
@ -1305,16 +1248,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>
|
||||
|
|
@ -1366,7 +1309,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>
|
||||
) : (
|
||||
|
|
@ -1904,10 +1847,13 @@ function AnonymousDocumentsSidebar({
|
|||
type="button"
|
||||
onClick={handleAnonUploadClick}
|
||||
disabled={isUploading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-primary/30 px-4 py-6 text-sm text-primary transition-colors hover:border-primary/60 hover:bg-primary/5 cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
|
||||
className="relative flex w-full items-center justify-center rounded-lg border-2 border-dashed border-primary/30 px-4 py-6 text-sm text-primary transition-colors hover:border-primary/60 hover:bg-primary/5 cursor-pointer disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{isUploading ? "Uploading..." : "Upload a document"}
|
||||
<span className={`flex items-center gap-2 ${isUploading ? "opacity-0" : ""}`}>
|
||||
<Upload className="size-4" />
|
||||
Upload a document
|
||||
</span>
|
||||
{isUploading && <Spinner size="sm" className="absolute" />}
|
||||
</button>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground leading-relaxed">
|
||||
Text, code, CSV, and HTML files only. Create an account for PDFs, images, and 30+
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
Inbox,
|
||||
LayoutGrid,
|
||||
ListFilter,
|
||||
MessageSquare,
|
||||
MessageCircleReply,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
|
@ -847,7 +847,7 @@ export function InboxSidebarContent({
|
|||
<TabsList stretch showBottomBorder size="sm">
|
||||
<TabsTrigger value="comments">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<MessageCircleReply className="h-4 w-4" />
|
||||
<span>{t("comments") || "Comments"}</span>
|
||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||
{formatInboxCount(comments.unreadCount)}
|
||||
|
|
@ -1032,7 +1032,7 @@ export function InboxSidebarContent({
|
|||
) : (
|
||||
<div className="text-center py-8">
|
||||
{activeTab === "comments" ? (
|
||||
<MessageSquare className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
<MessageCircleReply className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
) : (
|
||||
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,82 @@ 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 +206,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 +239,114 @@ 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() ?? "";
|
||||
|
|
@ -193,21 +379,30 @@ export function LocalFilesystemBrowser({
|
|||
});
|
||||
}, [rootPaths, rootStateMap, searchQuery]);
|
||||
|
||||
const toggleFolder = useCallback((folderKey: string) => {
|
||||
setExpandedFolderKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderKey)) {
|
||||
next.delete(folderKey);
|
||||
} else {
|
||||
next.add(folderKey);
|
||||
const toggleFolder = useCallback(
|
||||
(folderKey: string) => {
|
||||
const update = (prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folderKey)) {
|
||||
next.delete(folderKey);
|
||||
} else {
|
||||
next.add(folderKey);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
if (onExpandedFolderKeysChange) {
|
||||
onExpandedFolderKeysChange(update(effectiveExpandedFolderKeys));
|
||||
return;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
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 +421,47 @@ 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 +475,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,12 +520,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>
|
||||
);
|
||||
}
|
||||
|
|
@ -297,11 +543,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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { Download, FileQuestionMark, FileText, Loader2, Pencil, RefreshCw } from "lucide-react";
|
||||
import { Download, FileQuestionMark, FileText, Pencil, RefreshCw } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
@ -8,6 +8,7 @@ import { PlateEditor } from "@/components/editor/plate-editor";
|
|||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
|
||||
|
|
@ -278,7 +279,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 gap-1.5"
|
||||
className="relative shrink-0"
|
||||
disabled={downloading}
|
||||
onClick={async () => {
|
||||
setDownloading(true);
|
||||
|
|
@ -307,12 +308,13 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
|
|||
}
|
||||
}}
|
||||
>
|
||||
{downloading ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<span
|
||||
className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
)}
|
||||
{downloading ? "Preparing..." : "Download .md"}
|
||||
Download .md
|
||||
</span>
|
||||
{downloading && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue