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