diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index b97a74d52..74eba6361 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -9,8 +9,9 @@ import { toast } from "sonner"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { Button } from "@/components/ui/button"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; -import { useDocuments, toDisplayDoc, type DocumentDisplay } from "@/hooks/use-documents"; -import { documentsApiService } from "@/lib/apis/documents-api.service"; +import { useDocuments } from "@/hooks/use-documents"; +import { useDocumentSearch } from "@/hooks/use-document-search"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; import { @@ -19,18 +20,6 @@ import { } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; -const SEARCH_INITIAL_SIZE = 20; -const SEARCH_SCROLL_SIZE = 5; - -function useDebounced(value: T, delay = 250) { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const t = setTimeout(() => setDebounced(value), delay); - return () => clearTimeout(t); - }, [value, delay]); - return debounced; -} - interface DocumentsSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -44,14 +33,15 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) const searchSpaceId = Number(params.search_space_id); const [search, setSearch] = useState(""); - const debouncedSearch = useDebounced(search, 250); + const debouncedSearch = useDebouncedValue(search, 250); const [activeTypes, setActiveTypes] = useState([]); const [sortKey, setSortKey] = useState("created_at"); const [sortDesc, setSortDesc] = useState(true); const [selectedIds, setSelectedIds] = useState>(new Set()); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); - // Paginated realtime documents from the hook (server-side sorted) + const isSearchMode = !!debouncedSearch.trim(); + const { documents: realtimeDocuments, typeCounts: realtimeTypeCounts, @@ -62,88 +52,22 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) error: realtimeError, } = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc"); - const isSearchMode = !!debouncedSearch.trim(); + const { + documents: searchDocuments, + loading: searchLoading, + loadingMore: searchLoadingMore, + hasMore: searchHasMore, + loadMore: searchLoadMore, + error: searchError, + removeItems: searchRemoveItems, + } = useDocumentSearch(searchSpaceId, debouncedSearch, activeTypes, isSearchMode && open); - // --- Search mode state --- - const searchApiLoadedRef = useRef(0); - const [searchItems, setSearchItems] = useState([]); - const [searchLoadingMore, setSearchLoadingMore] = useState(false); - const [searchInitialLoading, setSearchInitialLoading] = useState(false); - const [searchHasMore, setSearchHasMore] = useState(false); - const searchQueryRef = useRef(debouncedSearch); - - // Initial search fetch when search query changes - useEffect(() => { - if (!isSearchMode || !searchSpaceId || !open) { - setSearchItems([]); - setSearchHasMore(false); - searchApiLoadedRef.current = 0; - return; - } - - searchQueryRef.current = debouncedSearch; - setSearchInitialLoading(true); - - documentsApiService - .searchDocuments({ - queryParams: { - search_space_id: searchSpaceId, - page: 0, - page_size: SEARCH_INITIAL_SIZE, - title: debouncedSearch.trim(), - ...(activeTypes.length > 0 && { document_types: activeTypes }), - }, - }) - .then((response) => { - if (searchQueryRef.current !== debouncedSearch) return; - const mapped = response.items.map(toDisplayDoc); - setSearchItems(mapped); - setSearchHasMore(response.has_more); - searchApiLoadedRef.current = response.items.length; - }) - .catch((err) => { - console.error("[DocumentsSidebar] Search failed:", err); - }) - .finally(() => { - setSearchInitialLoading(false); - }); - }, [debouncedSearch, searchSpaceId, open, isSearchMode, activeTypes]); - - // Load more search results (uses skip for correct offset with mixed page sizes) - const loadMoreSearch = useCallback(async () => { - if (searchLoadingMore || !isSearchMode || !searchHasMore) return; - - setSearchLoadingMore(true); - try { - const response = await documentsApiService.searchDocuments({ - queryParams: { - search_space_id: searchSpaceId, - skip: searchApiLoadedRef.current, - page_size: SEARCH_SCROLL_SIZE, - title: debouncedSearch.trim(), - ...(activeTypes.length > 0 && { document_types: activeTypes }), - }, - }); - if (searchQueryRef.current !== debouncedSearch) return; - - const mapped = response.items.map(toDisplayDoc); - setSearchItems((prev) => [...prev, ...mapped]); - setSearchHasMore(response.has_more); - searchApiLoadedRef.current += response.items.length; - } catch (err) { - console.error("[DocumentsSidebar] Load more search failed:", err); - } finally { - setSearchLoadingMore(false); - } - }, [searchLoadingMore, isSearchMode, searchHasMore, searchSpaceId, debouncedSearch, activeTypes]); - - // Unified interface — pick between realtime and search mode - const displayDocs = isSearchMode ? searchItems : realtimeDocuments; - const loading = isSearchMode ? searchInitialLoading : realtimeLoading; - const error = isSearchMode ? false : realtimeError; + const displayDocs = isSearchMode ? searchDocuments : realtimeDocuments; + const loading = isSearchMode ? searchLoading : realtimeLoading; + const error = isSearchMode ? searchError : !!realtimeError; const hasMore = isSearchMode ? searchHasMore : realtimeHasMore; const loadingMore = isSearchMode ? searchLoadingMore : realtimeLoadingMore; - const onLoadMore = isSearchMode ? loadMoreSearch : realtimeLoadMore; + const onLoadMore = isSearchMode ? searchLoadMore : realtimeLoadMore; const onToggleType = (type: DocumentTypeEnum, checked: boolean) => { setActiveTypes((prev) => { @@ -161,20 +85,14 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) return; } - const allDocs = isSearchMode - ? searchItems.map((item) => ({ id: item.id, status: item.status })) - : realtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status })); - - const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id)); + const selectedDocs = displayDocs.filter((doc) => selectedIds.has(doc.id)); const deletableIds = selectedDocs .filter((doc) => doc.status?.state !== "pending" && doc.status?.state !== "processing") .map((doc) => doc.id); const inProgressCount = selectedIds.size - deletableIds.length; if (inProgressCount > 0) { - toast.warning( - `${inProgressCount} document(s) are pending or processing and cannot be deleted.` - ); + toast.warning(t("delete_in_progress_warning", { count: inProgressCount })); } if (deletableIds.length === 0) return; @@ -199,12 +117,12 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) if (okCount === deletableIds.length) { toast.success(t("delete_success_count", { count: okCount })); } else if (conflictCount > 0) { - toast.error(`${conflictCount} document(s) started processing. Please try again later.`); + toast.error(t("delete_conflict_error", { count: conflictCount })); } else { toast.error(t("delete_partial_failed")); } if (isSearchMode) { - setSearchItems((prev) => prev.filter((item) => !deletableIds.includes(item.id))); + searchRemoveItems(deletableIds); } setSelectedIds(new Set()); } catch (e) { @@ -219,7 +137,7 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) await deleteDocumentMutation({ id }); toast.success(t("delete_success") || "Document deleted"); if (isSearchMode) { - setSearchItems((prev) => prev.filter((item) => item.id !== id)); + searchRemoveItems([id]); } return true; } catch (e) { @@ -227,7 +145,7 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) return false; } }, - [deleteDocumentMutation, isSearchMode, t] + [deleteDocumentMutation, isSearchMode, t, searchRemoveItems] ); const sortKeyRef = useRef(sortKey); diff --git a/surfsense_web/hooks/use-document-search.ts b/surfsense_web/hooks/use-document-search.ts new file mode 100644 index 000000000..fdd67a2fc --- /dev/null +++ b/surfsense_web/hooks/use-document-search.ts @@ -0,0 +1,127 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import type { DocumentTypeEnum } from "@/contracts/types/document.types"; +import { documentsApiService } from "@/lib/apis/documents-api.service"; +import { type DocumentDisplay, toDisplayDoc } from "./use-documents"; + +const SEARCH_INITIAL_SIZE = 20; +const SEARCH_SCROLL_SIZE = 5; + +/** + * Paginated document search hook. + * + * Handles title-based search with server-side filtering, + * pagination via skip/page_size, and staleness detection + * so fast typing never renders stale results. + * + * @param searchSpaceId - The search space to search within + * @param query - The debounced search query + * @param activeTypes - Document types to filter by + * @param enabled - When false the hook resets and stops fetching + */ +export function useDocumentSearch( + searchSpaceId: number, + query: string, + activeTypes: DocumentTypeEnum[], + enabled: boolean +) { + const [documents, setDocuments] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [error, setError] = useState(false); + + const apiLoadedRef = useRef(0); + const queryRef = useRef(query); + + const isActive = enabled && !!query.trim(); + const activeTypesKey = activeTypes.join(","); + + // biome-ignore lint/correctness/useExhaustiveDependencies: activeTypesKey serializes activeTypes + useEffect(() => { + if (!isActive || !searchSpaceId) { + setDocuments([]); + setHasMore(false); + setError(false); + apiLoadedRef.current = 0; + return; + } + + let cancelled = false; + queryRef.current = query; + setLoading(true); + setError(false); + + documentsApiService + .searchDocuments({ + queryParams: { + search_space_id: searchSpaceId, + page: 0, + page_size: SEARCH_INITIAL_SIZE, + title: query.trim(), + ...(activeTypes.length > 0 && { document_types: activeTypes }), + }, + }) + .then((response) => { + if (cancelled || queryRef.current !== query) return; + setDocuments(response.items.map(toDisplayDoc)); + setHasMore(response.has_more); + apiLoadedRef.current = response.items.length; + }) + .catch((err) => { + if (cancelled) return; + console.error("[useDocumentSearch] Search failed:", err); + setError(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [query, searchSpaceId, isActive, activeTypesKey]); + + // biome-ignore lint/correctness/useExhaustiveDependencies: activeTypesKey serializes activeTypes + const loadMore = useCallback(async () => { + if (loadingMore || !isActive || !hasMore) return; + + setLoadingMore(true); + try { + const response = await documentsApiService.searchDocuments({ + queryParams: { + search_space_id: searchSpaceId, + skip: apiLoadedRef.current, + page_size: SEARCH_SCROLL_SIZE, + title: query.trim(), + ...(activeTypes.length > 0 && { document_types: activeTypes }), + }, + }); + if (queryRef.current !== query) return; + + setDocuments((prev) => [...prev, ...response.items.map(toDisplayDoc)]); + setHasMore(response.has_more); + apiLoadedRef.current += response.items.length; + } catch (err) { + console.error("[useDocumentSearch] Load more failed:", err); + } finally { + setLoadingMore(false); + } + }, [loadingMore, isActive, hasMore, searchSpaceId, query, activeTypesKey]); + + const removeItems = useCallback((ids: number[]) => { + const idSet = new Set(ids); + setDocuments((prev) => prev.filter((item) => !idSet.has(item.id))); + }, []); + + return { + documents, + loading, + loadingMore, + hasMore, + loadMore, + error, + removeItems, + }; +} diff --git a/surfsense_web/hooks/use-documents.ts b/surfsense_web/hooks/use-documents.ts index 92e93b6ed..40f729c7c 100644 --- a/surfsense_web/hooks/use-documents.ts +++ b/surfsense_web/hooks/use-documents.ts @@ -105,7 +105,6 @@ export function useDocuments( // Snapshot of all doc IDs from Electric's first callback after initial load. // Anything appearing in subsequent callbacks NOT in this set is genuinely new. const electricBaselineIdsRef = useRef | null>(null); - const knownApiIdsRef = useRef>(new Set()); const userCacheRef = useRef>(new Map()); const emailCacheRef = useRef>(new Map()); const syncHandleRef = useRef(null); @@ -178,7 +177,6 @@ export function useDocuments( apiLoadedCountRef.current = 0; initialLoadDoneRef.current = false; electricBaselineIdsRef.current = null; - knownApiIdsRef.current = new Set(); const fetchInitialData = async () => { try { @@ -209,9 +207,6 @@ export function useDocuments( setError(null); apiLoadedCountRef.current = docsResponse.items.length; initialLoadDoneRef.current = true; - for (const doc of docs) { - knownApiIdsRef.current.add(doc.id); - } } catch (err) { if (cancelled) return; console.error("[useDocuments] Initial load failed:", err); @@ -454,7 +449,6 @@ export function useDocuments( apiLoadedCountRef.current = 0; initialLoadDoneRef.current = false; electricBaselineIdsRef.current = null; - knownApiIdsRef.current = new Set(); userCacheRef.current.clear(); emailCacheRef.current.clear(); } @@ -481,9 +475,6 @@ export function useDocuments( populateUserCache(response.items); const newDocs = response.items.map(apiToDisplayDoc); - for (const doc of newDocs) { - knownApiIdsRef.current.add(doc.id); - } setDocuments((prev) => { const existingIds = new Set(prev.map((d) => d.id)); diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index d7b062ce1..2964cca22 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -332,7 +332,8 @@ "upload_documents": "Upload Documents", "create_shared_note": "Create Shared Note", "processing_documents": "Processing documents...", - "active_tasks_count": "{count} active task(s)" + "delete_in_progress_warning": "{count} document(s) are pending or processing and cannot be deleted.", + "delete_conflict_error": "{count} document(s) started processing. Please try again later." }, "add_connector": { "title": "Connect Your Tools", diff --git a/surfsense_web/messages/es.json b/surfsense_web/messages/es.json index d21b635b5..af9c3b34f 100644 --- a/surfsense_web/messages/es.json +++ b/surfsense_web/messages/es.json @@ -332,7 +332,8 @@ "upload_documents": "Subir documentos", "create_shared_note": "Crear nota compartida", "processing_documents": "Procesando documentos...", - "active_tasks_count": "{count} tarea(s) activa(s)" + "delete_in_progress_warning": "{count} documento(s) están pendientes o en proceso y no se pueden eliminar.", + "delete_conflict_error": "{count} documento(s) comenzaron a procesarse. Inténtelo de nuevo más tarde." }, "add_connector": { "title": "Conecta tus herramientas", diff --git a/surfsense_web/messages/hi.json b/surfsense_web/messages/hi.json index 6a3cb7ffb..ca967a8da 100644 --- a/surfsense_web/messages/hi.json +++ b/surfsense_web/messages/hi.json @@ -332,7 +332,8 @@ "upload_documents": "दस्तावेज़ अपलोड करें", "create_shared_note": "साझा नोट बनाएं", "processing_documents": "दस्तावेज़ प्रोसेस हो रहे हैं...", - "active_tasks_count": "{count} सक्रिय कार्य" + "delete_in_progress_warning": "{count} दस्तावेज़ लंबित या प्रसंस्करण में हैं और हटाए नहीं जा सकते।", + "delete_conflict_error": "{count} दस्तावेज़ प्रसंस्करण शुरू हो गया है। कृपया बाद में पुनः प्रयास करें।" }, "add_connector": { "title": "अपने टूल कनेक्ट करें", diff --git a/surfsense_web/messages/pt.json b/surfsense_web/messages/pt.json index ddaa7c0d3..5ddab2c22 100644 --- a/surfsense_web/messages/pt.json +++ b/surfsense_web/messages/pt.json @@ -332,7 +332,8 @@ "upload_documents": "Enviar documentos", "create_shared_note": "Criar nota compartilhada", "processing_documents": "Processando documentos...", - "active_tasks_count": "{count} tarefa(s) ativa(s)" + "delete_in_progress_warning": "{count} documento(s) estão pendentes ou em processamento e não podem ser excluídos.", + "delete_conflict_error": "{count} documento(s) começaram a ser processados. Tente novamente mais tarde." }, "add_connector": { "title": "Conecte suas ferramentas", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index db29eb198..6a3b00c4f 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -316,7 +316,8 @@ "upload_documents": "上传文档", "create_shared_note": "创建共享笔记", "processing_documents": "正在处理文档...", - "active_tasks_count": "{count} 个正在进行的工作项" + "delete_in_progress_warning": "{count} 个文档正在等待或处理中,无法删除。", + "delete_conflict_error": "{count} 个文档已开始处理,请稍后再试。" }, "add_connector": { "title": "连接您的工具",