fix(FN-2058): handle symlinked markdown directory scanning

- Follow symlink entries that resolve to directories so linked markdown trees are discovered
- Add guarded scan diagnostics for non-ENOENT directory read failures without noisy missing-dir logs
- Expand markdown scanner regression tests for symlinks, empty roots, deep nesting, and .md-like directory names
- Clarify the Documents view empty-state copy to say no Markdown files were found in the project
This commit is contained in:
Fusion
2026-04-18 20:37:38 -07:00
committed by gsxdsm
parent 15d6314225
commit 9d6f81ce0d
3 changed files with 187 additions and 5 deletions

View File

@@ -345,7 +345,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
{projectFilesExpanded && (
projectFiles.length === 0 ? (
<p className="documents-project-files-empty">
No Markdown files found in the project directory.
No Markdown files found in the project.
</p>
) : (
<div className="documents-project-files-list">

View File

@@ -1439,11 +1439,30 @@ describe("scanMarkdownFiles", () => {
} as unknown as TaskStore;
function directoryEntry(name: string) {
return { name, isDirectory: () => true, isFile: () => false };
return {
name,
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
};
}
function fileEntry(name: string) {
return { name, isDirectory: () => false, isFile: () => true };
return {
name,
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
};
}
function symlinkEntry(name: string) {
return {
name,
isDirectory: () => false,
isFile: () => false,
isSymbolicLink: () => true,
};
}
beforeEach(() => {
@@ -1628,6 +1647,145 @@ describe("scanMarkdownFiles", () => {
expect(result[0].contentPreview.length).toBe(200);
});
it("follows symlinked directories when they point to markdown files", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [symlinkEntry("docs-link")];
}
if (targetPath === "/project/docs-link") {
return [fileEntry("linked.md")];
}
return [];
});
mockStat.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project/docs-link") {
return {
isDirectory: () => true,
isFile: () => false,
size: 0,
mtime: new Date("2024-01-06T00:00:00.000Z"),
};
}
if (targetPath === "/project/docs-link/linked.md") {
return {
isDirectory: () => false,
isFile: () => true,
size: 77,
mtime: new Date("2024-01-06T00:00:00.000Z"),
};
}
throw { code: "ENOENT" };
});
mockReadFile.mockResolvedValue("Linked markdown content");
const result = await scanMarkdownFiles(mockStore);
expect(result).toEqual([
{
path: "docs-link/linked.md",
name: "linked.md",
size: 77,
mtime: "2024-01-06T00:00:00.000Z",
contentPreview: "Linked markdown content",
},
]);
expect(mockReaddir).toHaveBeenCalledWith("/project/docs-link", { withFileTypes: true });
});
it("returns an empty list when root directory has no entries", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockResolvedValue([]);
const result = await scanMarkdownFiles(mockStore);
expect(result).toEqual([]);
expect(mockStat).not.toHaveBeenCalled();
expect(mockReadFile).not.toHaveBeenCalled();
});
it("finds markdown files at depth 4 and deeper within maxDepth", 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 [directoryEntry("level-3")];
if (targetPath === "/project/level-1/level-2/level-3") return [directoryEntry("level-4")];
if (targetPath === "/project/level-1/level-2/level-3/level-4") return [fileEntry("deep.md")];
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 55,
mtime: new Date("2024-01-07T00:00:00.000Z"),
});
mockReadFile.mockResolvedValue("deep markdown");
const result = await scanMarkdownFiles(mockStore);
expect(result).toHaveLength(1);
expect(result[0].path).toBe("level-1/level-2/level-3/level-4/deep.md");
});
it("does not treat directories with .md in the name as markdown files", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
directoryEntry("readme.md-backup"),
fileEntry("actual.md"),
];
}
if (targetPath === "/project/readme.md-backup") {
return [fileEntry("notes.txt")];
}
return [];
});
mockStat.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project/actual.md") {
return {
isFile: () => true,
isDirectory: () => false,
size: 18,
mtime: new Date("2024-01-08T00:00:00.000Z"),
};
}
if (targetPath === "/project/readme.md-backup/notes.txt") {
return {
isFile: () => true,
isDirectory: () => false,
size: 18,
mtime: new Date("2024-01-08T00:00:00.000Z"),
};
}
throw { code: "ENOENT" };
});
mockReadFile.mockResolvedValue("Actual markdown file");
const result = await scanMarkdownFiles(mockStore);
expect(result).toHaveLength(1);
expect(result[0].path).toBe("actual.md");
expect(result[0].name).toBe("actual.md");
});
it("returns files sorted by relative path", async () => {
mockGetRootDir.mockReturnValue("/project");

View File

@@ -945,7 +945,13 @@ export async function scanMarkdownFiles(
let entries: Dirent[];
try {
entries = await readdir(directoryPath, { withFileTypes: true });
} catch {
} catch (err: unknown) {
const error = err as NodeJS.ErrnoException;
if (error.code !== "ENOENT") {
console.warn(
`[scanMarkdownFiles] failed to read directory ${directoryPath}: ${error.message ?? String(err)}`,
);
}
return;
}
@@ -954,7 +960,25 @@ export async function scanMarkdownFiles(
? join(relativeDir, entry.name)
: entry.name;
if (entry.isDirectory()) {
let shouldRecurse = entry.isDirectory();
if (!shouldRecurse && typeof entry.isSymbolicLink === "function" && entry.isSymbolicLink()) {
let symlinkPath: string;
try {
symlinkPath = validatePath(projectBasePath, entryRelativePath);
} catch {
continue;
}
try {
const symlinkStats = await stat(symlinkPath);
shouldRecurse = symlinkStats.isDirectory();
} catch {
continue;
}
}
if (shouldRecurse) {
if (MARKDOWN_SCAN_EXCLUDED_DIRS.has(entry.name)) {
continue;
}