Files
fusion/packages/dashboard/app/hooks/useDocuments.ts
Fusion bee87329da feat(FN-2030): add project markdown explorer to Documents view
- Add recursive markdown file discovery in file-service with excluded directories and sorted metadata output
- Expose GET /api/files/markdown and wire a frontend API client plus useProjectMarkdownFiles hook
- Redesign DocumentsView with project/task tabs, search, task grouping, and desktop/mobile file preview flows
- Add coverage for markdown scanning and DocumentsView behaviors, and update useDocuments tests for new API usage
2026-04-19 10:13:33 -07:00

148 lines
4.5 KiB
TypeScript

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<void>;
}
/**
* 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<TaskDocumentWithTask[]>([]);
const [projectFiles, setProjectFiles] = useState<MarkdownFileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Track if we've completed the initial load
const initialLoadCompleteRef = useRef(false);
// Debounce timer for search
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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,
};
}