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 1851469575
commit 72f3d04d09
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",
]);
});
});