- Add backend Markdown scanner and /project-files/md endpoint with directory exclusions, depth/size limits, and search filtering - Extend dashboard API and useDocuments hook to fetch task documents and project markdown files in parallel with resilient partial-failure handling - Update Documents view with a collapsible Project Files section, inline metadata previews, and on-demand file content expansion from workspace API - Add and refresh hook and file-service tests to cover markdown scanning, query propagation, project scoping, and fetch error behavior - Add token-based styles for project file cards and responsive mobile adjustments in the documents panel
401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
import { useState, useMemo, useCallback } from "react";
|
|
import { 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";
|
|
|
|
export interface DocumentsViewProps {
|
|
projectId?: string;
|
|
addToast: (message: string, type?: ToastType) => void;
|
|
onOpenDetail: (task: TaskDetail) => void;
|
|
}
|
|
|
|
interface DocumentCardProps {
|
|
document: TaskDocumentWithTask;
|
|
onOpenTask: (taskId: string) => void;
|
|
}
|
|
|
|
interface ProjectFileCardProps {
|
|
file: MarkdownFileEntry;
|
|
expanded: boolean;
|
|
loading: boolean;
|
|
error: string | null;
|
|
content: string;
|
|
onOpen: (filePath: string) => Promise<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, onOpenTask }: DocumentCardProps) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const preview = getContentPreview(document.content);
|
|
const showExpand = document.content.length > 200;
|
|
|
|
return (
|
|
<div className="document-card">
|
|
<div className="document-card-header">
|
|
<div className="document-card-key">
|
|
<FileText size={14} />
|
|
<span className="document-card-key-text">{document.key}</span>
|
|
<span className="document-card-revision-badge">v{document.revision}</span>
|
|
</div>
|
|
<button
|
|
className="btn btn-sm document-card-expand-btn"
|
|
onClick={() => setExpanded(!expanded)}
|
|
title={expanded ? "Collapse" : "Expand"}
|
|
aria-label={expanded ? "Collapse content" : "Expand content"}
|
|
>
|
|
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="document-card-meta">
|
|
<span className="document-card-author">{document.author}</span>
|
|
<span className="document-card-separator">·</span>
|
|
<span className="document-card-date">{formatTimestamp(document.updatedAt)}</span>
|
|
</div>
|
|
|
|
<div className={`document-card-content${expanded ? " document-card-content--expanded" : ""}`}>
|
|
{expanded ? (
|
|
<pre className="document-card-content-text">{document.content}</pre>
|
|
) : (
|
|
<p className="document-card-preview">{preview}</p>
|
|
)}
|
|
{showExpand && !expanded && (
|
|
<p className="document-card-preview-truncated">…</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProjectFileCard({ file, expanded, loading, error, content, onOpen }: ProjectFileCardProps) {
|
|
const preview = getContentPreview(file.contentPreview, 200);
|
|
const isExpanded = expanded;
|
|
|
|
return (
|
|
<div className="documents-project-file">
|
|
<button
|
|
className="documents-project-file-card"
|
|
onClick={() => void onOpen(file.path)}
|
|
aria-expanded={isExpanded}
|
|
aria-label={`${isExpanded ? "Collapse" : "Open"} project file ${file.path}`}
|
|
>
|
|
<div className="documents-project-file-header">
|
|
<div className="documents-project-file-title">
|
|
<FileText size={14} />
|
|
<span>{file.name}</span>
|
|
</div>
|
|
<span className="documents-project-file-toggle">
|
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
</span>
|
|
</div>
|
|
|
|
<p className="documents-project-file-path">{file.path}</p>
|
|
|
|
<div className="documents-project-file-meta">
|
|
<span>{formatFileSize(file.size)}</span>
|
|
<span>·</span>
|
|
<span>{formatTimestamp(file.mtime)}</span>
|
|
</div>
|
|
|
|
<p className="documents-project-file-preview">
|
|
{preview || "No preview available."}
|
|
{file.contentPreview.length >= 200 ? "…" : ""}
|
|
</p>
|
|
</button>
|
|
|
|
{isExpanded && (
|
|
<div className="documents-project-file-content">
|
|
{loading ? (
|
|
<p className="documents-project-file-content-state">Loading file content…</p>
|
|
) : error ? (
|
|
<p className="documents-project-file-content-state documents-project-file-content-state--error">{error}</p>
|
|
) : (
|
|
<pre className="documents-project-file-content-text">{content}</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface TaskGroupProps {
|
|
taskId: string;
|
|
taskTitle?: string;
|
|
documents: TaskDocumentWithTask[];
|
|
onOpenTask: (taskId: string) => void;
|
|
}
|
|
|
|
function TaskGroup({ taskId, taskTitle, documents, onOpenTask }: TaskGroupProps) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<div className="documents-group">
|
|
<button
|
|
className="documents-group-header"
|
|
onClick={() => setExpanded(!expanded)}
|
|
aria-expanded={expanded}
|
|
aria-label={`${expanded ? "Collapse" : "Expand"} documents for task ${taskId}`}
|
|
>
|
|
<span className="documents-group-toggle">
|
|
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
</span>
|
|
<button
|
|
className="documents-group-task-link"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onOpenTask(taskId);
|
|
}}
|
|
aria-label={`Open task ${taskId}: ${taskTitle || "Untitled"}`}
|
|
>
|
|
<span className="documents-group-task-id">{taskId}</span>
|
|
<span className="documents-group-task-title">{taskTitle || "Untitled"}</span>
|
|
</button>
|
|
<span className="documents-group-count">{documents.length} doc{documents.length !== 1 ? "s" : ""}</span>
|
|
</button>
|
|
|
|
{expanded && (
|
|
<div className="documents-group-content">
|
|
{documents.map((doc) => (
|
|
<DocumentCard
|
|
key={doc.id}
|
|
document={doc}
|
|
onOpenTask={onOpenTask}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsViewProps) {
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [projectFilesExpanded, setProjectFilesExpanded] = useState(true);
|
|
const [openProjectFilePath, setOpenProjectFilePath] = useState<string | null>(null);
|
|
const [openProjectFileContent, setOpenProjectFileContent] = useState("");
|
|
const [openProjectFileLoading, setOpenProjectFileLoading] = useState(false);
|
|
const [openProjectFileError, setOpenProjectFileError] = useState<string | null>(null);
|
|
|
|
const { documents, projectFiles, loading, error, refresh } = useDocuments({
|
|
projectId,
|
|
searchQuery: searchQuery || undefined,
|
|
});
|
|
|
|
// Group documents by task
|
|
const groupedDocuments = useMemo(() => {
|
|
const groups = new Map<string, TaskDocumentWithTask[]>();
|
|
for (const doc of documents) {
|
|
const existing = groups.get(doc.taskId) || [];
|
|
groups.set(doc.taskId, [...existing, doc]);
|
|
}
|
|
// Sort groups by the most recently updated document
|
|
return Array.from(groups.entries())
|
|
.map(([taskId, docs]) => ({
|
|
taskId,
|
|
taskTitle: docs[0].taskTitle,
|
|
documents: docs.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)),
|
|
latestUpdated: docs[0].updatedAt,
|
|
}))
|
|
.sort((a, b) => b.latestUpdated.localeCompare(a.latestUpdated));
|
|
}, [documents]);
|
|
|
|
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setSearchQuery(e.target.value);
|
|
}, []);
|
|
|
|
const clearSearch = useCallback(() => {
|
|
setSearchQuery("");
|
|
}, []);
|
|
|
|
const toggleProjectFilesExpanded = useCallback(() => {
|
|
setProjectFilesExpanded((current) => !current);
|
|
}, []);
|
|
|
|
// Wrapper to open task detail by fetching full task first
|
|
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 handleOpenProjectFile = useCallback(async (filePath: string) => {
|
|
if (openProjectFilePath === filePath) {
|
|
setOpenProjectFilePath(null);
|
|
setOpenProjectFileContent("");
|
|
setOpenProjectFileError(null);
|
|
setOpenProjectFileLoading(false);
|
|
return;
|
|
}
|
|
|
|
setOpenProjectFilePath(filePath);
|
|
setOpenProjectFileLoading(true);
|
|
setOpenProjectFileError(null);
|
|
setOpenProjectFileContent("");
|
|
|
|
try {
|
|
const file = await fetchWorkspaceFileContent("project", filePath, projectId);
|
|
setOpenProjectFileContent(file.content);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : `Failed to open ${filePath}`;
|
|
setOpenProjectFileError(message);
|
|
addToast(message, "error");
|
|
} finally {
|
|
setOpenProjectFileLoading(false);
|
|
}
|
|
}, [openProjectFilePath, projectId, addToast]);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="documents-view">
|
|
<div className="documents-view-error">
|
|
<p>Failed to load documents: {error}</p>
|
|
<button className="btn btn-primary" onClick={() => void refresh()}>
|
|
<RefreshCw size={16} />
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="documents-view">
|
|
<div className="documents-view-header">
|
|
<div className="documents-view-title-row">
|
|
<h2 className="documents-view-title">
|
|
<FileText size={20} />
|
|
Documents
|
|
</h2>
|
|
<span className="documents-view-count">
|
|
{loading ? "…" : `${documents.length + projectFiles.length} total`}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="documents-search">
|
|
<Search size={16} className="documents-search-icon" />
|
|
<input
|
|
type="text"
|
|
className="documents-search-input"
|
|
placeholder="Search documents…"
|
|
value={searchQuery}
|
|
onChange={handleSearchChange}
|
|
aria-label="Search documents"
|
|
/>
|
|
{searchQuery && (
|
|
<button
|
|
className="documents-search-clear"
|
|
onClick={clearSearch}
|
|
aria-label="Clear search"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="documents-view-content">
|
|
{loading ? (
|
|
<div className="documents-view-loading">
|
|
<p>Loading documents…</p>
|
|
</div>
|
|
) : (
|
|
<div className="documents-view-sections">
|
|
<section className="documents-project-files" aria-label="Project files section">
|
|
<button
|
|
className="documents-project-files-header"
|
|
onClick={toggleProjectFilesExpanded}
|
|
aria-expanded={projectFilesExpanded}
|
|
>
|
|
<span className="documents-project-files-toggle">
|
|
{projectFilesExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
</span>
|
|
<span className="documents-project-files-title">Project Files</span>
|
|
<span className="documents-project-files-count">
|
|
{projectFiles.length} file{projectFiles.length !== 1 ? "s" : ""}
|
|
</span>
|
|
</button>
|
|
|
|
{projectFilesExpanded && (
|
|
projectFiles.length === 0 ? (
|
|
<p className="documents-project-files-empty">
|
|
No Markdown files found in the project directory.
|
|
</p>
|
|
) : (
|
|
<div className="documents-project-files-list">
|
|
{projectFiles.map((file) => (
|
|
<ProjectFileCard
|
|
key={file.path}
|
|
file={file}
|
|
expanded={openProjectFilePath === file.path}
|
|
loading={openProjectFileLoading && openProjectFilePath === file.path}
|
|
error={openProjectFilePath === file.path ? openProjectFileError : null}
|
|
content={openProjectFilePath === file.path ? openProjectFileContent : ""}
|
|
onOpen={handleOpenProjectFile}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
)}
|
|
</section>
|
|
|
|
{groupedDocuments.length === 0 ? (
|
|
<div className="documents-view-empty">
|
|
{searchQuery ? (
|
|
<p>No task documents match "{searchQuery}".</p>
|
|
) : (
|
|
<>
|
|
<FileText size={48} className="documents-view-empty-icon" />
|
|
<p>No task documents yet.</p>
|
|
<p className="documents-view-empty-hint">
|
|
Documents are created in task detail tabs.
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="documents-view-list">
|
|
{groupedDocuments.map(({ taskId, taskTitle, documents: taskDocs }) => (
|
|
<TaskGroup
|
|
key={taskId}
|
|
taskId={taskId}
|
|
taskTitle={taskTitle}
|
|
documents={taskDocs}
|
|
onOpenTask={handleOpenTask}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|