import { useState, useEffect, useRef, useCallback } from "react"; import type { TaskDocumentWithTask } from "@fusion/core"; import { fetchAllDocuments, fetchProjectMarkdownFiles, type MarkdownFileEntry } from "../api"; export interface UseDocumentsResult { /** List of all documents across tasks */ documents: TaskDocumentWithTask[]; /** List of markdown files discovered in the project workspace */ projectFiles: MarkdownFileEntry[]; /** Loading state - true only for initial fetch, false during refresh/search */ loading: boolean; /** Error message if task document fetch failed */ error: string | null; /** Refresh documents from the server */ refresh: () => Promise; } /** * Hook for fetching all documents across tasks with optional search. * * Loading behavior: `loading` is true only during the initial fetch. * Refresh or search changes do NOT set `loading` to true, keeping * previously loaded data visible. This prevents skeleton flicker * during search filtering and manual refreshes. */ export function useDocuments(options?: { /** Project ID for project-scoped fetching */ projectId?: string; /** Search query for filtering documents */ searchQuery?: string; /** Whether to include project markdown files in the response (defaults to true) */ includeProjectFiles?: boolean; }): UseDocumentsResult { const { projectId, searchQuery, includeProjectFiles = true } = options ?? {}; const [documents, setDocuments] = useState([]); const [projectFiles, setProjectFiles] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const abortRef = useRef(null); // Track if we've completed the initial load const initialLoadCompleteRef = useRef(false); // Debounce timer for search const debounceRef = useRef | null>(null); /** * Fetch documents from the server. * Background updates (refresh, search) do NOT set loading=true. */ const refresh = useCallback(async () => { // Cancel any in-flight requests if (abortRef.current) { abortRef.current.abort(); } const requestController = new AbortController(); abortRef.current = requestController; // Only set loading on initial load const isInitial = !initialLoadCompleteRef.current; if (isInitial) { setLoading(true); } setError(null); const documentFetchPromise = fetchAllDocuments( searchQuery ? { q: searchQuery } : undefined, projectId, ); const projectFileFetchPromise = includeProjectFiles ? fetchProjectMarkdownFiles(projectId) : Promise.resolve({ files: [] as MarkdownFileEntry[] }); const [documentResult, projectFileResult] = await Promise.allSettled([ documentFetchPromise, projectFileFetchPromise, ]); if (requestController.signal.aborted) { return; } let documentError: string | null = null; if (documentResult.status === "fulfilled") { setDocuments(documentResult.value); initialLoadCompleteRef.current = true; } else { documentError = documentResult.reason instanceof Error ? documentResult.reason.message : String(documentResult.reason); } if (projectFileResult.status === "fulfilled") { const fetchedFiles = projectFileResult.value.files; const normalizedSearch = searchQuery?.trim().toLowerCase(); const filteredFiles = normalizedSearch ? fetchedFiles.filter((file) => file.name.toLowerCase().includes(normalizedSearch) || file.path.toLowerCase().includes(normalizedSearch)) : fetchedFiles; setProjectFiles(filteredFiles); } setError(documentError); if (isInitial) { setLoading(false); } }, [includeProjectFiles, projectId, searchQuery]); // Debounced search effect useEffect(() => { if (debounceRef.current) { clearTimeout(debounceRef.current); } debounceRef.current = setTimeout(() => { void refresh(); }, 300); return () => { if (debounceRef.current) { clearTimeout(debounceRef.current); } }; }, [refresh]); // Initial fetch - intentionally empty deps, only runs on mount useEffect(() => { void refresh(); return () => { if (abortRef.current) { abortRef.current.abort(); } }; }, []); return { documents, projectFiles, loading, error, refresh, }; }