diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index 0acc1d30b..edb01d4cc 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -29,6 +29,7 @@ from app.schemas import ( DocumentTitleSearchResponse, DocumentUpdate, DocumentWithChunksRead, + FolderRead, PaginatedResponse, ) from app.services.task_dispatcher import TaskDispatcher, get_task_dispatcher @@ -953,15 +954,13 @@ async def get_document_by_chunk_id( ) from e -@router.get("/documents/watched-folders", response_model=list["FolderRead"]) +@router.get("/documents/watched-folders", response_model=list[FolderRead]) async def get_watched_folders( search_space_id: int, session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), ): """Return root folders that are marked as watched (metadata->>'watched' = 'true').""" - from app.schemas import FolderRead # noqa: F811 - await check_permission( session, user, diff --git a/surfsense_web/components/assistant-ui/document-upload-popup.tsx b/surfsense_web/components/assistant-ui/document-upload-popup.tsx index 06b0d38e7..78600be47 100644 --- a/surfsense_web/components/assistant-ui/document-upload-popup.tsx +++ b/surfsense_web/components/assistant-ui/document-upload-popup.tsx @@ -125,29 +125,23 @@ const DocumentUploadPopupContent: FC<{ onPointerDownOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()} - className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5" + className="select-none max-w-2xl w-[95vw] sm:w-[640px] h-[min(460px,75dvh)] sm:h-[min(520px,80vh)] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-6 [&>button]:top-3 sm:[&>button]:top-5 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5" > Upload Document - {/* Scrollable container for mobile */}
- {/* Header - scrolls with content on mobile */} -
- {/* Upload header */} -
-
-

- Upload Documents -

-

- Upload and sync your documents to your search space -

-
+
+
+

+ Upload Documents +

+

+ Upload and sync your documents to your search space +

- {/* Content */} -
+
{!isLoading && !hasDocumentSummaryLLM ? ( @@ -179,9 +173,6 @@ const DocumentUploadPopupContent: FC<{ )}
- - {/* Bottom fade shadow - hidden on very small screens */} -
); diff --git a/surfsense_web/components/documents/DocumentNode.tsx b/surfsense_web/components/documents/DocumentNode.tsx index 691a6eb0d..7a3b3e0ca 100644 --- a/surfsense_web/components/documents/DocumentNode.tsx +++ b/surfsense_web/components/documents/DocumentNode.tsx @@ -195,12 +195,14 @@ export const DocumentNode = React.memo(function DocumentNode({ {doc.title} - - {getDocumentTypeIcon( - doc.document_type as DocumentTypeEnum, - "h-3.5 w-3.5 text-muted-foreground" - )} - + {getDocumentTypeIcon(doc.document_type as DocumentTypeEnum, "h-3.5 w-3.5 text-muted-foreground") && ( + + {getDocumentTypeIcon( + doc.document_type as DocumentTypeEnum, + "h-3.5 w-3.5 text-muted-foreground" + )} + + )} diff --git a/surfsense_web/components/documents/FolderNode.tsx b/surfsense_web/components/documents/FolderNode.tsx index 6780bd1e5..41c1d8f73 100644 --- a/surfsense_web/components/documents/FolderNode.tsx +++ b/surfsense_web/components/documents/FolderNode.tsx @@ -1,6 +1,7 @@ "use client"; import { + AlertCircle, ChevronDown, ChevronRight, Eye, @@ -30,6 +31,8 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Spinner } from "@/components/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; import type { FolderSelectionState } from "./FolderTreeView"; @@ -55,6 +58,7 @@ interface FolderNodeProps { isRenaming: boolean; childCount: number; selectionState: FolderSelectionState; + processingState: "idle" | "processing" | "failed"; onToggleSelect: (folderId: number, selectAll: boolean) => void; onToggleExpand: (folderId: number) => void; onRename: (folder: FolderDisplay, newName: string) => void; @@ -100,6 +104,7 @@ export const FolderNode = React.memo(function FolderNode({ isRenaming, childCount, selectionState, + processingState, onToggleSelect, onToggleExpand, onRename, @@ -281,14 +286,41 @@ export const FolderNode = React.memo(function FolderNode({ )} - e.stopPropagation()} - className="h-3.5 w-3.5 shrink-0" - /> + {processingState !== "idle" && selectionState === "none" ? ( + <> + + + + {processingState === "processing" ? ( + + ) : ( + + )} + + + + {processingState === "processing" + ? "Syncing folder contents" + : "Some files failed to process"} + + + e.stopPropagation()} + className="h-3.5 w-3.5 shrink-0 hidden group-hover:flex" + /> + + ) : ( + e.stopPropagation()} + className="h-3.5 w-3.5 shrink-0" + /> + )} diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index f34b9a0c2..01af73edc 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -166,6 +166,35 @@ export function FolderTreeView({ return states; }, [folders, docsByFolder, foldersByParent, mentionedDocIds]); + const folderProcessingStates = useMemo(() => { + const states: Record = {}; + + function compute(folderId: number): { hasProcessing: boolean; hasFailed: boolean } { + const directDocs = docsByFolder[folderId] ?? []; + let hasProcessing = directDocs.some( + (d) => d.status?.state === "pending" || d.status?.state === "processing" + ); + let hasFailed = directDocs.some((d) => d.status?.state === "failed"); + + for (const child of foldersByParent[folderId] ?? []) { + const sub = compute(child.id); + hasProcessing = hasProcessing || sub.hasProcessing; + hasFailed = hasFailed || sub.hasFailed; + } + + if (hasProcessing) states[folderId] = "processing"; + else if (hasFailed) states[folderId] = "failed"; + else states[folderId] = "idle"; + + return { hasProcessing, hasFailed }; + } + + for (const f of folders) { + if (states[f.id] === undefined) compute(f.id); + } + return states; + }, [folders, docsByFolder, foldersByParent]); + function renderLevel(parentId: number | null, depth: number): React.ReactNode[] { const key = parentId ?? "root"; const childFolders = (foldersByParent[key] ?? []) @@ -199,6 +228,7 @@ export function FolderTreeView({ isRenaming={renamingFolderId === f.id} childCount={folderChildCounts[f.id] ?? 0} selectionState={folderSelectionStates[f.id] ?? "none"} + processingState={folderProcessingStates[f.id] ?? "idle"} onToggleSelect={onToggleFolderSelect} onToggleExpand={onToggleExpand} onRename={onRenameFolder} diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx index d5ac2770a..7176afae5 100644 --- a/surfsense_web/components/sources/DocumentUploadTab.tsx +++ b/surfsense_web/components/sources/DocumentUploadTab.tsx @@ -1,24 +1,21 @@ "use client"; import { useAtom } from "jotai"; -import { CheckCircle2, ChevronDown, File as FileIcon, FileType, FolderOpen, Info, Upload, X } from "lucide-react"; +import { CheckCircle2, ChevronDown, File as FileIcon, FileType, FolderOpen, Plus, Upload, X } from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useMemo, useRef, useState } from "react"; import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; -import { SummaryConfig } from "@/components/assistant-ui/connector-popup/components/summary-config"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; -import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { DropdownMenu, DropdownMenuContent, @@ -27,7 +24,6 @@ import { } from "@/components/ui/dropdown-menu"; import { Label } from "@/components/ui/label"; import { Progress } from "@/components/ui/progress"; -import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import { documentsApiService } from "@/lib/apis/documents-api.service"; @@ -36,7 +32,6 @@ import { trackDocumentUploadStarted, trackDocumentUploadSuccess, } from "@/lib/posthog/events"; -import { GridPattern } from "./GridPattern"; interface SelectedFolder { path: string; @@ -128,13 +123,12 @@ interface FileWithId { file: File; } -const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5"; - -// Upload limits — files are sent in batches of 5 to avoid proxy timeouts const MAX_FILES = 50; const MAX_TOTAL_SIZE_MB = 200; const MAX_TOTAL_SIZE_BYTES = MAX_TOTAL_SIZE_MB * 1024 * 1024; +const toggleRowClass = "flex items-center justify-between rounded-lg bg-slate-400/5 dark:bg-white/5 p-3"; + export function DocumentUploadTab({ searchSpaceId, onSuccess, @@ -198,7 +192,7 @@ export function DocumentUploadTab({ const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: acceptedFileTypes, - maxSize: 50 * 1024 * 1024, // 50MB per file + maxSize: 50 * 1024 * 1024, noClick: isElectron, disabled: files.length >= MAX_FILES, }); @@ -270,6 +264,8 @@ export function DocumentUploadTab({ (MAX_TOTAL_SIZE_BYTES - totalFileSize) / (1024 * 1024) ).toFixed(1); + const hasContent = files.length > 0 || selectedFolder !== null; + const handleAccordionChange = useCallback( (value: string) => { setAccordionValue(value); @@ -307,7 +303,7 @@ export function DocumentUploadTab({ }); toast.success(`Watching folder: ${selectedFolder.name}`); } else { - toast.success(`Indexing folder: ${selectedFolder.name}`); + toast.success(`Syncing folder: ${selectedFolder.name}`); } setSelectedFolder(null); @@ -355,139 +351,180 @@ export function DocumentUploadTab({ ); }; - return ( -
- - - - {t("file_size_limit")}{" "} - {t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })} - - + const renderBrowseButton = (options?: { compact?: boolean; fullWidth?: boolean }) => { + const { compact, fullWidth } = options ?? {}; + if (isFileCountLimitReached) return null; - -
- -
- -
- - {isFileCountLimitReached ? ( -
- -
-

- {t("file_limit_reached")} -

-

- {t("file_limit_reached_desc", { max: MAX_FILES })} -

+ const sizeClass = compact ? "h-7" : "h-8"; + const widthClass = fullWidth ? "w-full" : ""; + + if (isElectron) { + return ( + + e.stopPropagation()}> + + + e.stopPropagation()}> + + + Files + + + + Folder + + + + ); + } + + return ( + + ); + }; + + return ( +
+ {/* Hidden file input for mobile browse */} + + + {/* MOBILE DROP ZONE */} +
+ {hasContent ? ( + !selectedFolder && !isFileCountLimitReached && ( + isElectron ? ( +
+ {renderBrowseButton({ compact: true, fullWidth: true })}
-
- ) : isDragActive ? ( -
- -

{t("drop_files")}

-
- ) : ( -
- -
-

{t("drag_drop")}

-

{t("or_browse")}

-
- {files.length > 0 && ( -

- {t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })} -

- )} -
- )} - {!isFileCountLimitReached && ( -
- {isElectron ? ( - - e.stopPropagation()}> - - - e.stopPropagation()}> - - - Files - - - - Folder - - - ) : ( + ) + ) + ) : ( +
{ + if (!isElectron) fileInputRef.current?.click(); + }} + > + +
+

+ {isElectron ? "Select files or folder" : "Tap to select files"} +

+

+ {t("file_size_limit")}{" "} + {t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })} +

+
+ {isElectron && ( +
e.stopPropagation()}> + {renderBrowseButton({ fullWidth: true })} +
)}
)} -
- - +
- {selectedFolder && ( - - -
-
- -
- - {selectedFolder.name} - - - {selectedFolder.path} - -
+ {/* DESKTOP DROP ZONE */} +
+ {hasContent ? ( +
+ + + {isDragActive + ? t("drop_files") + : isFileCountLimitReached + ? t("file_limit_reached") + : t("remaining_capacity", { files: remainingFiles, sizeMB: remainingSizeMB })} + + {renderBrowseButton({ compact: true })} +
+ ) : isFileCountLimitReached ? ( +
+ +

{t("file_limit_reached")}

+

+ {t("file_limit_reached_desc", { max: MAX_FILES })} +

+
+ ) : isDragActive ? ( +
+ +

{t("drop_files")}

+
+ ) : ( +
+ +

{t("drag_drop")}

+

+ {t("file_size_limit")}{" "} + {t("upload_limits", { maxFiles: MAX_FILES, maxSizeMB: MAX_TOTAL_SIZE_MB })} +

+
{renderBrowseButton()}
+
+ )} +
+ + {/* FOLDER SELECTED */} + {selectedFolder && ( +
+
+ +
+

{selectedFolder.name}

+

{selectedFolder.path}

- - -
-
)} + {/* FILES SELECTED */} + {files.length > 0 && ( +
+
+

+ {t("selected_files", { count: files.length })} · {formatFileSize(totalFileSize)} +

+ +
+ +
+ {files.map((entry) => ( +
+ + {entry.file.name} + + {formatFileSize(entry.file.size)} + + +
+ ))} +
+ + {isUploading && ( +
+
+ {t("uploading_files")} + {Math.round(uploadProgress)}% +
+ +
+ )} + +
+
+

Enable AI Summary

+

+ Improves search quality but adds latency +

+
+ +
+ + +
+ )} + + {/* SUPPORTED FORMATS */} - - -
-
-
- {t("supported_file_types")} -
-
- {t("file_types_desc")} -
-
-
+ + + + {t("supported_file_types")} + - -
+ +
{supportedExtensions.map((ext) => ( - + {ext} ))} diff --git a/surfsense_web/contracts/enums/connectorIcons.tsx b/surfsense_web/contracts/enums/connectorIcons.tsx index 2e609b060..ab71d58b5 100644 --- a/surfsense_web/contracts/enums/connectorIcons.tsx +++ b/surfsense_web/contracts/enums/connectorIcons.tsx @@ -126,6 +126,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas return ; case "DEEPEST": return ; + case "LOCAL_FOLDER_FILE": + return null; default: return ; }