feat(FN-1980): add project markdown files to Documents view

- 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
This commit is contained in:
Fusion
2026-04-18 14:32:42 -07:00
committed by gsxdsm
parent 5499405fc9
commit 39f38c0abf
8 changed files with 941 additions and 209 deletions

View File

@@ -11,6 +11,7 @@ import {
readWorkspaceFile,
writeWorkspaceFile,
searchWorkspaceFiles,
scanMarkdownFiles,
copyWorkspaceFile,
moveWorkspaceFile,
deleteWorkspaceFile,
@@ -1430,3 +1431,235 @@ describe("searchWorkspaceFiles", () => {
expect(result.files).toContainEqual({ path: "MyComponent.tsx", name: "MyComponent.tsx" });
});
});
describe("scanMarkdownFiles", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
function directoryEntry(name: string) {
return { name, isDirectory: () => true, isFile: () => false };
}
function fileEntry(name: string) {
return { name, isDirectory: () => false, isFile: () => true };
}
beforeEach(() => {
mockGetRootDir.mockReset();
mockReaddir.mockReset();
mockStat.mockReset();
mockReadFile.mockReset();
});
it("finds markdown files in project root and nested directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
fileEntry("README.md"),
fileEntry("notes.txt"),
directoryEntry("docs"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("CONTRIBUTING.md")];
}
return [];
});
mockStat.mockImplementation(async (targetPath: string) => {
if (targetPath.endsWith("README.md") || targetPath.endsWith("CONTRIBUTING.md")) {
return {
isFile: () => true,
isDirectory: () => false,
size: 128,
mtime: new Date("2024-01-01T00:00:00.000Z"),
};
}
throw { code: "ENOENT" };
});
mockReadFile.mockImplementation(async (targetPath: string) => {
if (targetPath.endsWith("README.md")) {
return "Root readme";
}
if (targetPath.endsWith("CONTRIBUTING.md")) {
return "Contribution guide";
}
throw { code: "ENOENT" };
});
const result = await scanMarkdownFiles(mockStore);
expect(result).toEqual([
{
path: "docs/CONTRIBUTING.md",
name: "CONTRIBUTING.md",
size: 128,
mtime: "2024-01-01T00:00:00.000Z",
contentPreview: "Contribution guide",
},
{
path: "README.md",
name: "README.md",
size: 128,
mtime: "2024-01-01T00:00:00.000Z",
contentPreview: "Root readme",
},
]);
});
it("excludes markdown files in blocked directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
directoryEntry(".git"),
directoryEntry("node_modules"),
directoryEntry(".fusion"),
directoryEntry("dist"),
directoryEntry("build"),
directoryEntry("docs"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("README.md")];
}
throw { code: "ENOENT" };
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 32,
mtime: new Date("2024-01-02T00:00:00.000Z"),
});
mockReadFile.mockResolvedValue("Allowed file");
const result = await scanMarkdownFiles(mockStore);
expect(result).toHaveLength(1);
expect(result[0].path).toBe("docs/README.md");
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.git", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/node_modules", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.fusion", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/dist", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/build", { withFileTypes: true });
});
it("respects maxDepth when scanning nested directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [directoryEntry("level-1")];
}
if (targetPath === "/project/level-1") {
return [directoryEntry("level-2")];
}
if (targetPath === "/project/level-1/level-2") {
return [fileEntry("deep.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 42,
mtime: new Date("2024-01-03T00:00:00.000Z"),
});
mockReadFile.mockResolvedValue("Deep file");
const shallowResult = await scanMarkdownFiles(mockStore, { maxDepth: 1 });
expect(shallowResult).toEqual([]);
const deepResult = await scanMarkdownFiles(mockStore, { maxDepth: 2 });
expect(deepResult).toHaveLength(1);
expect(deepResult[0].path).toBe("level-1/level-2/deep.md");
});
it("skips files that exceed max file size", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockResolvedValue([fileEntry("LARGE.md")]);
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 2 * 1024 * 1024,
mtime: new Date("2024-01-04T00:00:00.000Z"),
});
const result = await scanMarkdownFiles(mockStore, { maxFileSize: 1024 * 1024 });
expect(result).toEqual([]);
expect(mockReadFile).not.toHaveBeenCalled();
});
it("caps content preview to 200 characters", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockResolvedValue([fileEntry("README.md")]);
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 200,
mtime: new Date("2024-01-05T00:00:00.000Z"),
});
mockReadFile.mockResolvedValue("a".repeat(250));
const result = await scanMarkdownFiles(mockStore);
expect(result).toHaveLength(1);
expect(result[0].contentPreview).toBe("a".repeat(200));
expect(result[0].contentPreview.length).toBe(200);
});
it("returns files sorted by relative path", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
fileEntry("z-last.md"),
directoryEntry("docs"),
fileEntry("a-first.md"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("middle.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 90,
mtime: new Date("2024-01-06T00:00:00.000Z"),
});
mockReadFile.mockResolvedValue("content");
const result = await scanMarkdownFiles(mockStore);
expect(result.map((entry) => entry.path)).toEqual([
"a-first.md",
"docs/middle.md",
"z-last.md",
]);
});
});

View File

@@ -896,6 +896,122 @@ export interface FileSearchResult {
files: Array<{ path: string; name: string }>;
}
/**
* Markdown file metadata discovered in the project root.
*/
export interface MarkdownFileEntry {
path: string;
name: string;
size: number;
mtime: string;
contentPreview: string;
}
const MARKDOWN_SCAN_EXCLUDED_DIRS = new Set([
".git",
"node_modules",
".fusion",
"dist",
"build",
".next",
"coverage",
"__pycache__",
".cache",
".turbo",
".vercel",
]);
/**
* Recursively scan the project directory for Markdown files.
*/
export async function scanMarkdownFiles(
store: TaskStore,
options?: { maxDepth?: number; maxFileSize?: number },
): Promise<MarkdownFileEntry[]> {
const projectBasePath = getProjectBasePath(store);
const maxDepth = options?.maxDepth ?? 5;
const maxFileSize = options?.maxFileSize ?? MAX_FILE_SIZE;
const markdownFiles: MarkdownFileEntry[] = [];
async function walkDirectory(relativeDir: string, depth: number): Promise<void> {
if (depth > maxDepth) {
return;
}
const directoryPath = relativeDir
? validatePath(projectBasePath, relativeDir)
: projectBasePath;
let entries: Dirent[];
try {
entries = await readdir(directoryPath, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const entryRelativePath = relativeDir
? join(relativeDir, entry.name)
: entry.name;
if (entry.isDirectory()) {
if (MARKDOWN_SCAN_EXCLUDED_DIRS.has(entry.name)) {
continue;
}
if (depth < maxDepth) {
await walkDirectory(entryRelativePath, depth + 1);
}
continue;
}
if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) {
continue;
}
let resolvedPath: string;
try {
resolvedPath = validatePath(projectBasePath, entryRelativePath);
} catch {
continue;
}
let entryStats;
try {
entryStats = await stat(resolvedPath);
} catch {
continue;
}
if (!entryStats.isFile() || entryStats.size > maxFileSize) {
continue;
}
let contentPreview = "";
try {
const content = await fsReadFile(resolvedPath, "utf-8");
contentPreview = content.slice(0, 200);
} catch {
contentPreview = "";
}
markdownFiles.push({
path: entryRelativePath.replace(/\\/g, "/"),
name: entry.name,
size: entryStats.size,
mtime: entryStats.mtime.toISOString(),
contentPreview,
});
}
}
await walkDirectory("", 0);
markdownFiles.sort((a, b) => a.path.localeCompare(b.path));
return markdownFiles;
}
/**
* Search for files matching a query in a workspace.
*

View File

@@ -24,7 +24,7 @@ import { GitHubClient, parseBadgeUrl } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
import { terminalSessionManager } from "./terminal.js";
import { getTerminalService } from "./terminal-service.js";
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, searchWorkspaceFiles, copyWorkspaceFile, moveWorkspaceFile, deleteWorkspaceFile, renameWorkspaceFile, getWorkspaceFileForDownload, getWorkspaceFolderForZip, FileServiceError } from "./file-service.js";
import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, searchWorkspaceFiles, copyWorkspaceFile, moveWorkspaceFile, deleteWorkspaceFile, renameWorkspaceFile, getWorkspaceFileForDownload, getWorkspaceFolderForZip, scanMarkdownFiles, FileServiceError } from "./file-service.js";
import { clearUsageCache, fetchAllProviderUsage } from "./usage.js";
import {
getGitHubAppConfig,
@@ -4833,6 +4833,26 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// GET /project-files/md — List Markdown files in the project directory
router.get("/project-files/md", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const files = await scanMarkdownFiles(scopedStore);
const query = typeof req.query.q === "string" ? req.query.q.trim().toLowerCase() : "";
const filteredFiles = query.length > 0
? files.filter((file) => file.name.toLowerCase().includes(query) || file.contentPreview.toLowerCase().includes(query))
: files;
res.json(filteredFiles);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Internal server error");
}
});
// Add steering comment to task
router.post("/tasks/:id/steer", async (req, res) => {
try {