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:
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user