import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react"; import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { TaskDocumentWithTask, TaskDetail } from "@fusion/core"; import type { ToastType } from "../hooks/useToast"; import { fetchTaskDetail, fetchWorkspaceFileContent, type MarkdownFileEntry } from "../api"; import { useDocuments } from "../hooks/useDocuments"; import { useProjectMarkdownFiles } from "../hooks/useProjectMarkdownFiles"; const MOBILE_BREAKPOINT = 768; type DocumentsTab = "project" | "tasks"; export interface DocumentsViewProps { projectId?: string; addToast: (message: string, type?: ToastType) => void; onOpenDetail: (task: TaskDetail) => void; } interface DocumentCardProps { document: TaskDocumentWithTask; renderMarkdown: boolean; onToggleMarkdown: () => void; } interface TaskGroupProps { taskId: string; taskTitle?: string; documents: TaskDocumentWithTask[]; onOpenTask: (taskId: string) => void; renderMarkdownStates: Map; onToggleMarkdown: (docId: string) => void; } function formatTimestamp(iso?: string): string { if (!iso) return ""; return new Date(iso).toLocaleString(); } function formatFileSize(bytes: number): string { if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(bytes >= 10 * 1024 ? 0 : 1)} KB`; } return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function getContentPreview(content: string, maxLength: number = 200): string { if (content.length <= maxLength) return content; return `${content.substring(0, maxLength)}…`; } function DocumentCard({ document, renderMarkdown, onToggleMarkdown }: DocumentCardProps) { const [expanded, setExpanded] = useState(false); const preview = getContentPreview(document.content); const showExpand = document.content.length > 200; return (
{document.key} v{document.revision}
{document.author} · {formatTimestamp(document.updatedAt)}
{expanded ? ( <>
{renderMarkdown ? (
{document.content}
) : (
{document.content}
)} ) : (

{preview}

)} {showExpand && !expanded && (

)}
); } function TaskGroup({ taskId, taskTitle, documents, onOpenTask, renderMarkdownStates, onToggleMarkdown }: TaskGroupProps) { const [expanded, setExpanded] = useState(false); return (
{documents.length} doc{documents.length !== 1 ? "s" : ""}
{expanded && (
{documents.map((doc) => ( onToggleMarkdown(doc.id)} /> ))}
)}
); } export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsViewProps) { const [activeTab, setActiveTab] = useState("project"); const [searchQuery, setSearchQuery] = useState(""); const [selectedFile, setSelectedFile] = useState(null); const [showHiddenProjectFiles, setShowHiddenProjectFiles] = useState(false); const [fileContent, setFileContent] = useState(null); const [fileLoading, setFileLoading] = useState(false); const [fileError, setFileError] = useState(null); const [isMobile, setIsMobile] = useState(false); const requestIdRef = useRef(0); const initialTabSetRef = useRef(false); // Markdown render toggle for project file preview const [renderProjectMarkdown, setRenderProjectMarkdown] = useState(false); // Markdown render toggles per task document card (scoped by doc ID) const [taskDocMarkdownStates, setTaskDocMarkdownStates] = useState>(new Map()); const taskSearchQuery = activeTab === "tasks" ? searchQuery.trim() : ""; const { documents, loading: documentsLoading, error: documentsError, refresh: refreshDocuments, } = useDocuments({ projectId, searchQuery: taskSearchQuery || undefined, includeProjectFiles: false, }); const { files: projectFiles, loading: projectFilesLoading, error: projectFilesError, refresh: refreshProjectFiles, } = useProjectMarkdownFiles(projectId, { showHidden: showHiddenProjectFiles }); useEffect(() => { const updateMobile = () => { setIsMobile(window.innerWidth <= MOBILE_BREAKPOINT); }; updateMobile(); window.addEventListener("resize", updateMobile); return () => { window.removeEventListener("resize", updateMobile); }; }, []); useEffect(() => { initialTabSetRef.current = false; setActiveTab("project"); setSelectedFile(null); setShowHiddenProjectFiles(false); setFileContent(null); setFileError(null); setFileLoading(false); setRenderProjectMarkdown(false); setTaskDocMarkdownStates(new Map()); }, [projectId]); useEffect(() => { if (initialTabSetRef.current || documentsLoading || projectFilesLoading) { return; } if (projectFiles.length > 0) { setActiveTab("project"); } else if (documents.length > 0) { setActiveTab("tasks"); } initialTabSetRef.current = true; }, [documents.length, documentsLoading, projectFiles.length, projectFilesLoading]); const groupedDocuments = useMemo(() => { const groups = new Map(); for (const doc of documents) { const existing = groups.get(doc.taskId) || []; groups.set(doc.taskId, [...existing, doc]); } return Array.from(groups.entries()) .map(([taskId, docs]) => { const sortedDocs = [...docs].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return { taskId, taskTitle: sortedDocs[0]?.taskTitle, documents: sortedDocs, latestUpdated: sortedDocs[0]?.updatedAt ?? "", }; }) .sort((a, b) => b.latestUpdated.localeCompare(a.latestUpdated)); }, [documents]); const filteredProjectFiles = useMemo(() => { const normalizedQuery = searchQuery.trim().toLowerCase(); if (!normalizedQuery) { return projectFiles; } return projectFiles.filter((file) => { const normalizedPath = file.path.toLowerCase(); const normalizedName = file.name.toLowerCase(); return normalizedPath.includes(normalizedQuery) || normalizedName.includes(normalizedQuery); }); }, [projectFiles, searchQuery]); useEffect(() => { if (!selectedFile) { return; } const selectedStillExists = projectFiles.some((file) => file.path === selectedFile.path); if (!selectedStillExists) { setSelectedFile(null); setFileContent(null); setFileError(null); setFileLoading(false); } }, [projectFiles, selectedFile]); const handleSearchChange = useCallback((e: ChangeEvent) => { setSearchQuery(e.target.value); }, []); const clearSearch = useCallback(() => { setSearchQuery(""); }, []); const handleTabChange = useCallback((tab: DocumentsTab) => { setActiveTab(tab); }, []); const handleOpenTask = useCallback(async (taskId: string) => { try { const task = await fetchTaskDetail(taskId, projectId); onOpenDetail(task); } catch { addToast(`Failed to open task ${taskId}`, "error"); } }, [projectId, onOpenDetail, addToast]); const handleSelectProjectFile = useCallback(async (file: MarkdownFileEntry) => { setSelectedFile(file); setFileLoading(true); setFileError(null); setFileContent(null); const requestId = requestIdRef.current + 1; requestIdRef.current = requestId; try { const fileResponse = await fetchWorkspaceFileContent("project", file.path, projectId); if (requestIdRef.current !== requestId) { return; } setFileContent(fileResponse.content); } catch (err) { if (requestIdRef.current !== requestId) { return; } const message = err instanceof Error ? err.message : `Failed to open ${file.path}`; setFileError(message); addToast(message, "error"); } finally { if (requestIdRef.current === requestId) { setFileLoading(false); } } }, [projectId, addToast]); const handleBackToFileList = useCallback(() => { setSelectedFile(null); setFileContent(null); setFileError(null); setFileLoading(false); }, []); const handleToggleTaskDocMarkdown = useCallback((docId: string) => { setTaskDocMarkdownStates((prev) => { const next = new Map(prev); const current = next.get(docId) ?? false; next.set(docId, !current); return next; }); }, []); const activeError = activeTab === "project" ? projectFilesError : documentsError; const handleRetry = useCallback(async () => { if (activeTab === "project") { await refreshProjectFiles(); return; } await refreshDocuments(); }, [activeTab, refreshProjectFiles, refreshDocuments]); const activeCount = activeTab === "project" ? filteredProjectFiles.length : documents.length; const searchPlaceholder = activeTab === "project" ? "Search project markdown files…" : "Search task documents…"; return (

Documents

{activeCount} result{activeCount !== 1 ? "s" : ""}
{activeTab === "project" && ( )}
{searchQuery && ( )}
{activeError ? (

Failed to load {activeTab === "project" ? "project files" : "task documents"}: {activeError}

) : activeTab === "project" ? ( projectFilesLoading && projectFiles.length === 0 ? (

Loading project markdown files…

) : filteredProjectFiles.length === 0 ? (
{searchQuery.trim() ? (

No project markdown files match "{searchQuery.trim()}".

) : ( <>

No Markdown files found in this project.

)}
) : (
{(!isMobile || !selectedFile) && ( )} {(!isMobile || selectedFile) && (
{isMobile && selectedFile && ( )} {!selectedFile ? (

Select a Markdown file to view its content.

) : (

{selectedFile.path}

{fileLoading ? (

Loading file content…

) : fileError ? (

{fileError}

) : renderProjectMarkdown ? (
{fileContent ?? ""}
) : (
{fileContent ?? ""}
)}
)}
)}
) ) : documentsLoading && documents.length === 0 ? (

Loading task documents…

) : groupedDocuments.length === 0 ? (
{searchQuery.trim() ? (

No task documents match "{searchQuery.trim()}".

) : ( <>

No task documents yet.

Documents are created in task detail tabs.

)}
) : (
{groupedDocuments.map(({ taskId, taskTitle, documents: taskDocs }) => ( ))}
)}
); }