feat(FN-801): show hidden files in workspace file listings
- Remove dotfile filtering from file-service so hidden files appear in workspace browser - Update FileBrowserModal and TaskDetailModal UI to display hidden file entries - Add UI/hook tests for hidden file entries in FileBrowserModal and useWorkspaceFileBrowser - Add file-service unit tests for listing, reading, and filtering hidden files - Update dashboard styles for hidden file display - Document Files feature section in README
This commit is contained in:
@@ -165,4 +165,27 @@ describe("FileBrowserModal", () => {
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
expect(mockSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders hidden files and directories from the file listing", () => {
|
||||
mockUseWorkspaceFileBrowser.mockReturnValue({
|
||||
...defaultBrowserState,
|
||||
entries: [
|
||||
{ name: ".env.example", type: "file", size: 42, mtime: "2024-01-01" },
|
||||
{ name: ".github", type: "directory", mtime: "2024-01-01" },
|
||||
{ name: "src", type: "directory", mtime: "2024-01-01" },
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<FileBrowserModal
|
||||
initialWorkspace="project"
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(".env.example")).toBeInTheDocument();
|
||||
expect(screen.getByText(".github")).toBeInTheDocument();
|
||||
expect(screen.getByText("src")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,4 +71,26 @@ describe("useWorkspaceFileBrowser", () => {
|
||||
expect(result.current.error).toBe("Failed to load files");
|
||||
expect(result.current.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns hidden files and directories from the API response", async () => {
|
||||
const mockResponse: FileListResponse = {
|
||||
path: ".",
|
||||
entries: [
|
||||
{ name: ".env.example", type: "file", size: 42, mtime: "2024-01-01T00:00:00Z" },
|
||||
{ name: ".github", type: "directory", mtime: "2024-01-01T00:00:00Z" },
|
||||
{ name: "src", type: "directory", mtime: "2024-01-01T00:00:00Z" },
|
||||
],
|
||||
};
|
||||
mockFetchWorkspaceFileList.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceFileBrowser("project", true));
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.entries).toEqual(mockResponse.entries);
|
||||
const names = result.current.entries.map((e) => e.name);
|
||||
expect(names).toContain(".env.example");
|
||||
expect(names).toContain(".github");
|
||||
expect(names).toContain("src");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -668,6 +668,135 @@ describe("workspace operations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hidden files visibility", () => {
|
||||
const mockGetTask = vi.fn();
|
||||
const mockGetRootDir = vi.fn();
|
||||
const mockStore = {
|
||||
getTask: mockGetTask,
|
||||
getRootDir: mockGetRootDir,
|
||||
} as unknown as TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetTask.mockReset();
|
||||
mockGetRootDir.mockReset();
|
||||
mockStat.mockReset();
|
||||
mockReaddir.mockReset();
|
||||
mockExistsSync.mockReset();
|
||||
});
|
||||
|
||||
describe("listWorkspaceFiles", () => {
|
||||
it("includes hidden files (dotfiles) in workspace listing", async () => {
|
||||
mockGetRootDir.mockReturnValue("/project");
|
||||
// stat for the directory itself, then for each entry
|
||||
mockStat
|
||||
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() })
|
||||
.mockResolvedValueOnce({ isDirectory: () => false, isFile: () => true, size: 42, mtime: new Date() })
|
||||
.mockResolvedValueOnce({ isDirectory: () => false, isFile: () => true, size: 200, mtime: new Date() })
|
||||
.mockResolvedValueOnce({ isDirectory: () => false, isFile: () => true, size: 15, mtime: new Date() });
|
||||
|
||||
mockReaddir.mockResolvedValue([
|
||||
{ name: ".env.example", isDirectory: () => false, isFile: () => true },
|
||||
{ name: ".gitignore", isDirectory: () => false, isFile: () => true },
|
||||
{ name: "README.md", isDirectory: () => false, isFile: () => true },
|
||||
]);
|
||||
|
||||
const result = await listWorkspaceFiles(mockStore, "project");
|
||||
|
||||
expect(result.entries).toHaveLength(3);
|
||||
const names = result.entries.map((e) => e.name);
|
||||
expect(names).toContain(".env.example");
|
||||
expect(names).toContain(".gitignore");
|
||||
expect(names).toContain("README.md");
|
||||
});
|
||||
|
||||
it("includes hidden directories (dot-directories) in workspace listing", async () => {
|
||||
mockGetRootDir.mockReturnValue("/project");
|
||||
mockStat
|
||||
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() })
|
||||
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() })
|
||||
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() })
|
||||
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() });
|
||||
|
||||
mockReaddir.mockResolvedValue([
|
||||
{ name: ".changeset", isDirectory: () => true, isFile: () => false },
|
||||
{ name: ".github", isDirectory: () => true, isFile: () => false },
|
||||
{ name: "src", isDirectory: () => true, isFile: () => false },
|
||||
]);
|
||||
|
||||
const result = await listWorkspaceFiles(mockStore, "project");
|
||||
|
||||
expect(result.entries).toHaveLength(3);
|
||||
const names = result.entries.map((e) => e.name);
|
||||
expect(names).toContain(".changeset");
|
||||
expect(names).toContain(".github");
|
||||
expect(names).toContain("src");
|
||||
// All should be type directory
|
||||
expect(result.entries.every((e) => e.type === "directory")).toBe(true);
|
||||
});
|
||||
|
||||
it("includes mixed hidden and non-hidden entries with correct sort order", async () => {
|
||||
mockGetRootDir.mockReturnValue("/project");
|
||||
// Directory stat + 6 entry stats = 7 mock returns
|
||||
const dirStat = { isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() };
|
||||
const fileStat = (size: number) => ({ isDirectory: () => false, isFile: () => true, size, mtime: new Date() });
|
||||
mockStat
|
||||
.mockResolvedValueOnce(dirStat) // directory itself
|
||||
.mockResolvedValueOnce(dirStat) // .github (dir)
|
||||
.mockResolvedValueOnce(dirStat) // .changeset (dir)
|
||||
.mockResolvedValueOnce(dirStat) // src (dir)
|
||||
.mockResolvedValueOnce(fileStat(10)) // .env (file)
|
||||
.mockResolvedValueOnce(fileStat(300)) // README.md (file)
|
||||
.mockResolvedValueOnce(fileStat(50)); // package.json (file)
|
||||
|
||||
mockReaddir.mockResolvedValue([
|
||||
{ name: ".env", isDirectory: () => false, isFile: () => true },
|
||||
{ name: ".github", isDirectory: () => true, isFile: () => false },
|
||||
{ name: "README.md", isDirectory: () => false, isFile: () => true },
|
||||
{ name: "src", isDirectory: () => true, isFile: () => false },
|
||||
{ name: ".changeset", isDirectory: () => true, isFile: () => false },
|
||||
{ name: "package.json", isDirectory: () => false, isFile: () => true },
|
||||
]);
|
||||
|
||||
const result = await listWorkspaceFiles(mockStore, "project");
|
||||
|
||||
expect(result.entries).toHaveLength(6);
|
||||
// Sort order: directories first, then files, both alphabetically
|
||||
const types = result.entries.map((e) => e.type);
|
||||
const lastDirIdx = types.lastIndexOf("directory");
|
||||
const firstFileIdx = types.indexOf("file");
|
||||
expect(lastDirIdx).toBeLessThan(firstFileIdx);
|
||||
|
||||
// Hidden entries are present
|
||||
const names = result.entries.map((e) => e.name);
|
||||
expect(names).toContain(".env");
|
||||
expect(names).toContain(".github");
|
||||
expect(names).toContain(".changeset");
|
||||
});
|
||||
});
|
||||
|
||||
describe("listProjectFiles", () => {
|
||||
it("includes hidden files and directories in project listing", async () => {
|
||||
mockGetRootDir.mockReturnValue("/project");
|
||||
mockStat
|
||||
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() })
|
||||
.mockResolvedValueOnce({ isDirectory: () => false, isFile: () => true, size: 18, mtime: new Date() })
|
||||
.mockResolvedValueOnce({ isDirectory: () => true, isFile: () => false, size: 0, mtime: new Date() });
|
||||
|
||||
mockReaddir.mockResolvedValue([
|
||||
{ name: ".editorconfig", isDirectory: () => false, isFile: () => true },
|
||||
{ name: ".husky", isDirectory: () => true, isFile: () => false },
|
||||
]);
|
||||
|
||||
const result = await listProjectFiles(mockStore);
|
||||
|
||||
expect(result.entries).toHaveLength(2);
|
||||
const names = result.entries.map((e) => e.name);
|
||||
expect(names).toContain(".editorconfig");
|
||||
expect(names).toContain(".husky");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL-encoded characters handling", () => {
|
||||
const mockGetRootDir = vi.fn();
|
||||
const mockStore = {
|
||||
|
||||
@@ -187,11 +187,6 @@ async function listFilesForBasePath(basePath: string, subPath?: string): Promise
|
||||
const fileNodes: FileNode[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files and directories
|
||||
if (entry.name.startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entryPath = join(targetPath, entry.name);
|
||||
const entryStats = await stat(entryPath);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user