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:
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user