feat(FN-2030): add project markdown explorer to Documents view

- Add recursive markdown file discovery in file-service with excluded directories and sorted metadata output
- Expose GET /api/files/markdown and wire a frontend API client plus useProjectMarkdownFiles hook
- Redesign DocumentsView with project/task tabs, search, task grouping, and desktop/mobile file preview flows
- Add coverage for markdown scanning and DocumentsView behaviors, and update useDocuments tests for new API usage
This commit is contained in:
Fusion
2026-04-19 02:32:05 -07:00
committed by gsxdsm
parent 9bc570d58c
commit bee87329da
10 changed files with 1254 additions and 409 deletions

View File

@@ -11,6 +11,7 @@ import {
readWorkspaceFile,
writeWorkspaceFile,
searchWorkspaceFiles,
listProjectMarkdownFiles,
scanMarkdownFiles,
copyWorkspaceFile,
moveWorkspaceFile,
@@ -1432,6 +1433,183 @@ describe("searchWorkspaceFiles", () => {
});
});
describe("listProjectMarkdownFiles", () => {
const mockGetRootDir = vi.fn();
const mockStore = {
getRootDir: mockGetRootDir,
} as unknown as TaskStore;
function directoryEntry(name: string) {
return {
name,
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
};
}
function fileEntry(name: string) {
return {
name,
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
};
}
beforeEach(() => {
mockGetRootDir.mockReset();
mockReaddir.mockReset();
mockStat.mockReset();
});
it("returns markdown files from 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("guide.md")];
}
return [];
});
mockStat.mockImplementation(async (targetPath: string) => {
if (targetPath.endsWith("README.md") || targetPath.endsWith("guide.md")) {
return {
isFile: () => true,
isDirectory: () => false,
size: 128,
mtime: new Date("2024-01-01T00:00:00.000Z"),
};
}
throw { code: "ENOENT" };
});
const result = await listProjectMarkdownFiles(mockStore);
expect(result).toEqual({
files: [
{
path: "docs/guide.md",
name: "guide.md",
size: 128,
mtime: "2024-01-01T00:00:00.000Z",
},
{
path: "README.md",
name: "README.md",
size: 128,
mtime: "2024-01-01T00:00:00.000Z",
},
],
});
});
it("skips node_modules, .git, .fusion, and dist directories", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
directoryEntry("node_modules"),
directoryEntry(".git"),
directoryEntry(".fusion"),
directoryEntry("dist"),
directoryEntry("docs"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("allowed.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 64,
mtime: new Date("2024-01-02T00:00:00.000Z"),
});
const result = await listProjectMarkdownFiles(mockStore);
expect(result.files).toEqual([
{
path: "docs/allowed.md",
name: "allowed.md",
size: 64,
mtime: "2024-01-02T00:00:00.000Z",
},
]);
expect(mockReaddir).not.toHaveBeenCalledWith("/project/node_modules", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.git", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.fusion", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/dist", { withFileTypes: true });
});
it("returns an empty list when no markdown files exist", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockResolvedValue([
fileEntry("package.json"),
fileEntry("notes.txt"),
] as Awaited<ReturnType<typeof import("node:fs/promises").readdir>>);
const result = await listProjectMarkdownFiles(mockStore);
expect(result).toEqual({ files: [] });
expect(mockStat).not.toHaveBeenCalled();
});
it("sorts markdown results alphabetically by path", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
fileEntry("zeta.md"),
directoryEntry("docs"),
fileEntry("alpha.md"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("middle.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 33,
mtime: new Date("2024-01-03T00:00:00.000Z"),
});
const result = await listProjectMarkdownFiles(mockStore);
expect(result.files.map((entry) => entry.path)).toEqual([
"alpha.md",
"docs/middle.md",
"zeta.md",
]);
});
});
describe("scanMarkdownFiles", () => {
const mockGetRootDir = vi.fn();
const mockStore = {