feat(FN-2315): add hidden markdown toggle for project documents

- Extend dashboard file service and routes to support includeHidden when scanning markdown files
- Update API client, markdown hook, and Documents view UI to expose a hidden files toggle
- Add coverage for hidden-file filtering in file-service, hook, and DocumentsView tests
- Stabilize related SettingsModal and TaskForm tests and document toggle behavior in the dashboard README
This commit is contained in:
Fusion
2026-04-24 03:53:21 -07:00
committed by gsxdsm
parent 189fe189f4
commit 6453fc771e
12 changed files with 357 additions and 19 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useMemo, useCallback, useEffect, useRef, type ChangeEvent } from "react";
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X } from "lucide-react";
import { ArrowLeft, FileText, ChevronDown, ChevronUp, ChevronRight, RefreshCw, Search, X, Eye, EyeOff } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { TaskDocumentWithTask, TaskDetail } from "@fusion/core";
@@ -172,6 +172,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
const [activeTab, setActiveTab] = useState<DocumentsTab>("project");
const [searchQuery, setSearchQuery] = useState("");
const [selectedFile, setSelectedFile] = useState<MarkdownFileEntry | null>(null);
const [showHiddenProjectFiles, setShowHiddenProjectFiles] = useState(false);
const [fileContent, setFileContent] = useState<string | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const [fileError, setFileError] = useState<string | null>(null);
@@ -201,7 +202,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
loading: projectFilesLoading,
error: projectFilesError,
refresh: refreshProjectFiles,
} = useProjectMarkdownFiles(projectId);
} = useProjectMarkdownFiles(projectId, { showHidden: showHiddenProjectFiles });
useEffect(() => {
const updateMobile = () => {
@@ -220,6 +221,7 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
initialTabSetRef.current = false;
setActiveTab("project");
setSelectedFile(null);
setShowHiddenProjectFiles(false);
setFileContent(null);
setFileError(null);
setFileLoading(false);
@@ -408,6 +410,19 @@ export function DocumentsView({ projectId, addToast, onOpenDetail }: DocumentsVi
</button>
</div>
{activeTab === "project" && (
<button
className="btn btn-sm documents-hidden-toggle"
onClick={() => setShowHiddenProjectFiles((prev) => !prev)}
aria-pressed={showHiddenProjectFiles}
aria-label={showHiddenProjectFiles ? "Hide hidden project files" : "Show hidden project files"}
title={showHiddenProjectFiles ? "Hide hidden files" : "Show hidden files"}
>
{showHiddenProjectFiles ? <EyeOff size={14} /> : <Eye size={14} />}
{showHiddenProjectFiles ? "Hide Hidden" : "Show Hidden"}
</button>
)}
<div className="documents-search">
<Search size={16} className="documents-search-icon" />
<input

View File

@@ -68,6 +68,13 @@ const mockProjectFiles = [
},
];
const mockHiddenProjectFile = {
path: ".hidden/notes.md",
name: "notes.md",
size: 512,
mtime: "2026-04-19T10:00:00.000Z",
};
function setupHookDefaults(): void {
mockUseDocuments.mockReturnValue({
documents: mockTaskDocuments,
@@ -114,6 +121,31 @@ describe("DocumentsView", () => {
expect(screen.getByRole("button", { name: "Open docs/guide.md" })).toBeInTheDocument();
});
it("keeps hidden project files off by default and reveals them when toggled on", async () => {
const refreshMock = vi.fn().mockResolvedValue(undefined);
mockUseProjectMarkdownFiles.mockImplementation((_, options) => ({
files: options?.showHidden
? [...mockProjectFiles, mockHiddenProjectFile]
: mockProjectFiles,
loading: false,
error: null,
refresh: refreshMock,
}));
render(<DocumentsView addToast={addToast} onOpenDetail={onOpenDetail} />);
expect(screen.queryByRole("button", { name: "Open .hidden/notes.md" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /show hidden project files/i })).toHaveAttribute("aria-pressed", "false");
fireEvent.click(screen.getByRole("button", { name: /show hidden project files/i }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Open .hidden/notes.md" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /hide hidden project files/i })).toHaveAttribute("aria-pressed", "true");
});
});
it("renders task documents tab when there are no project files", async () => {
mockUseProjectMarkdownFiles.mockReturnValue({
files: [],

View File

@@ -246,6 +246,42 @@ async function chooseModelOption(label: string, optionName: string | RegExp): Pr
beforeEach(() => {
vi.clearAllMocks();
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...defaultSettings });
(fetchSettingsByScope as ReturnType<typeof vi.fn>).mockResolvedValue({
global: { ...defaultSettings },
project: {},
});
(updateSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...defaultSettings });
(updateGlobalSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ ...defaultSettings });
(fetchAuthStatus as ReturnType<typeof vi.fn>).mockResolvedValue({
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }],
});
(loginProvider as ReturnType<typeof vi.fn>).mockResolvedValue({ url: "https://auth.example.com/login" });
(logoutProvider as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
(saveApiKey as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
(clearApiKey as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValue({
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
],
favoriteProviders: [],
favoriteModels: [],
});
(testNtfyNotification as ReturnType<typeof vi.fn>).mockResolvedValue({ success: true });
(fetchGlobalConcurrency as ReturnType<typeof vi.fn>).mockResolvedValue({
globalMaxConcurrent: 4,
currentlyActive: 0,
queuedCount: 0,
projectsActive: {},
});
(updateGlobalConcurrency as ReturnType<typeof vi.fn>).mockResolvedValue({
globalMaxConcurrent: 4,
currentlyActive: 0,
queuedCount: 0,
projectsActive: {},
});
});
describe("SettingsModal", () => {
@@ -1834,10 +1870,12 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Models"));
fireEvent.click(screen.getAllByText("Models")[0]);
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
expect(screen.queryByLabelText("Thinking Effort")).toBeNull();
await waitFor(() => {
expect(screen.queryByLabelText("Thinking Effort")).toBeNull();
});
});
it("shows loading state during login", async () => {
@@ -2644,7 +2682,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
fireEvent.click((await screen.findAllByText("Notifications"))[0]);
expect((screen.getByLabelText("Task completed (in-review)") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Task merged") as HTMLInputElement).checked).toBe(false);
expect((screen.getByLabelText("Task failed") as HTMLInputElement).checked).toBe(false);
@@ -4096,7 +4134,6 @@ describe("Prompts section", () => {
expect(globalPayload.planningModelId).toBeUndefined();
}
}, FN1712_SCOPE_TEST_TIMEOUT_MS);
});
describe("Reset/clear null-as-delete semantics (FN-1712)", () => {

View File

@@ -674,7 +674,11 @@ describe("TaskForm preset selection (FN-819)", () => {
expect(fetchSettings).toHaveBeenCalled();
});
const presetSelect = document.getElementById("model-preset") as HTMLSelectElement;
const presetSelect = await waitFor(() => {
const element = document.getElementById("model-preset") as HTMLSelectElement | null;
expect(element).toBeTruthy();
return element as HTMLSelectElement;
});
fireEvent.change(presetSelect, { target: { value: "fast" } });
expect(onPresetModeChange).toHaveBeenCalledWith("preset");