- 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
148 lines
4.5 KiB
TypeScript
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,
|
|
};
|
|
}
|