import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X } from "lucide-react";
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;
}
interface TaskGroupProps {
taskId: string;
taskTitle?: string;
documents: TaskDocumentWithTask[];
onOpenTask: (taskId: 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 }: 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 ? (
{document.content}
) : (
{preview}
)}
{showExpand && !expanded && (
…
)}
);
}
function TaskGroup({ taskId, taskTitle, documents, onOpenTask }: TaskGroupProps) {
const [expanded, setExpanded] = useState(false);
return (
{documents.length} doc{documents.length !== 1 ? "s" : ""}
{expanded && (
{documents.map((doc) => (
))}
)}
);
}
export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsViewProps) {
const [activeTab, setActiveTab] = useState("project");
const [searchQuery, setSearchQuery] = useState("");
const [selectedFile, setSelectedFile] = useState(null);
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);
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);
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);
setFileContent(null);
setFileError(null);
setFileLoading(false);
}, [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 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" : ""}
{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}
) : (
{fileContent ?? ""}
)}
)}
)}
)
) : documentsLoading && documents.length === 0 ? (
) : 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 }) => (
))}
)}
);
}