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

@@ -53,6 +53,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
- **Fuzzy Model Search**: Model dropdown search (`filterModels`) supports fuzzy matching so users can find models despite minor typing imperfections. Three matching strategies are applied in order (first match wins): (1) **separator-insensitive substring** — hyphens, underscores, dots, and slashes are stripped before comparison, so `gpt4o` finds `gpt-4o`; (2) **subsequence matching** (≥ 3 chars) — characters must appear in order within a single token but need not be contiguous, so `cld` finds `claude`; (3) **typo tolerance** (≥ 4 chars) — Damerau-Levenshtein edit distance ≤ 1 supports single-character insertion, deletion, substitution, and adjacent transposition, so `sonet` finds `sonnet`. Multi-term space-separated queries use AND logic. Result ordering is stable (input-array order, no score re-sorting). Exact and substring matches from the original implementation continue to work unchanged.
- **Bulk Model Editing**: Update AI model configuration for multiple tasks at once in the list view. Select tasks via checkboxes (archived tasks excluded), then use the "Bulk Edit Models" toolbar to apply executor and/or validator model changes to all selected tasks. Selection persists in localStorage across page reloads.
- **Task Details**: View full task specifications, agent logs, and attachments. The task detail modal uses a top-level tab bar with the following tabs: **Definition**, **Logs**, **Changes** (for in-progress/in-review/done tasks), **Comments**, **Model**, and **Workflow** (when workflow steps are configured or the task has previous workflow results). **Activity** and **Agent Log** are subviews within the unified **Logs** tab — click Logs, then toggle between Activity (task lifecycle events, default) and Agent Log (live agent output). The Agent Log subview expands to fill the full modal body height above the action bar, providing maximum vertical space for watching live agent output. The Agent Log header shows the effective executor, validator, and planning/triage model names resolved from task-level overrides or project/global settings fallbacks, matching the same resolution order the engine uses at runtime. A **Markdown/Plain toggle** in the Agent Log header switches between formatted markdown rendering (default) and literal plain-text display — useful for debugging raw agent output, checking escaped markdown syntax, or inspecting exactly what the agent emitted without formatting. The toggle applies to `text` and `thinking` entries only; tool entries always render as plain text. React-markdown handles sanitization in markdown mode (no raw HTML is executed); plain-text mode uses React's built-in text escaping for safe literal output. The refinement modal positions the "Create Refinement Task" button adjacent to the feedback textarea alongside the character count, creating a tight input group that connects the submit action directly to the text being edited. The **Changes** tab for done tasks loads the diff from the recorded merge commit (`mergeDetails.commitSha`) via the `/api/tasks/:id/diff` endpoint rather than requiring a live worktree — changes remain visible even after the worktree is cleaned up. Done tasks **without** a recorded `commitSha` do not attempt to fetch a detailed file diff; instead, the tab shows a safe summary fallback displaying the merge summary numbers (`filesChanged`, `insertions`, `deletions`) from `mergeDetails`. This prevents inflated file lists that would result from a repository-wide fallback diff scan. The tab shows commit metadata (short SHA, merge commit message, merged timestamp) alongside the file-level diff when a commit SHA is available. The header displays "Files Changed (N)" as the primary title with additions/deletions totals on a second line below, freeing horizontal space for navigation and action controls. In-progress and in-review tasks continue to use the worktree-based diff path. Changed-file status indicators (added, modified, deleted, unknown) use semantic CSS classes and theme-aware color variables, ensuring readable contrast across all dashboard themes and light/dark modes.
- **Documents view hidden-file toggle**: The Documents panel’s **Project Files** tab now hides dotfiles and markdown files inside hidden directories by default, keeping the list focused on user-facing docs. A `Show Hidden` / `Hide Hidden` toggle reveals those hidden markdown entries on demand. Hard-excluded system/build directories (for example `.git`, `.fusion`, and `node_modules`) remain excluded in both modes.
- **Changed Files Viewer**: Click a task card's "files changed" button to open a dedicated diff viewer showing only files changed in that task worktree, with per-file statuses and sidebar navigation. The sidebar file list uses dedicated `changed-files-entry` styling with explicit button resets (no browser-default background/border/font inheritance) and theme-variable-driven colors for text, icons, hover, active, and focus states — ensuring correct rendering across both dark and light modes and all color themes. On mobile (≤768px), the viewer switches to a single-pane flow: the file list and diff are shown one at a time with a back button for navigation between them. The viewer always opens to the file list on mobile, and only switches to the diff view when the user taps a specific file. Pressing Escape on the diff view returns to the file list first; pressing Escape again closes the modal. Loading, error, and empty states use theme-aware styling (including light mode). Diff syntax highlighting (additions, deletions, hunks) adapts to the active theme for correct contrast. Status badges in the sidebar and diff toolbar use semantic CSS classes (e.g., `changed-files-badge--added`) with theme-aware colors for consistent readability across all themes. The board card file count and the changed-files viewer always agree — both use a shared diff-base resolution strategy. When the task has a `baseCommitSha` (captured at worktree creation time) that is still a valid ancestor of the current HEAD, the diff is scoped to only files introduced by that specific task. If `baseCommitSha` is stale or unavailable, the system falls back to a branch merge-base, then to `HEAD~1`. This ensures accurate file counts in shared or recycled worktree scenarios where a broader merge-base would include files from previous tasks. For in-progress tasks, the changed-files reporting includes all git-change types: committed changes (from `baseRef..HEAD`), staged changes (from `git diff --cached`), unstaged working-tree changes (from `git diff`), and untracked files (from `git ls-files --others --exclude-standard`). Files are deduplicated across all sources, so a file that appears in multiple states (e.g., modified and staged) appears only once. This gives operators a complete picture of work-in-progress, including new files not yet staged for commit.
- **Task Detail Changes Tab**: The task detail modal's "Changes" tab (TaskChangesTab) uses a compact spacing treatment for file detail rows. The file list container has a `task-changes-file-list--compact` modifier class that tightens padding and gaps on file headers, stat badges, and list item spacing compared to the shared base `.changes-file-*` styles. This compact treatment is scoped to TaskChangesTab only and does not affect other diff surfaces.
- **GitHub Import**: Import issues directly from GitHub repositories

View File

@@ -803,8 +803,23 @@ export async function fetchAllDocuments(
return api<TaskDocumentWithTask[]>(withProjectId(path, projectId));
}
export function fetchProjectMarkdownFiles(projectId?: string): Promise<MarkdownFileListResponse> {
return api<MarkdownFileListResponse>(withProjectId("/files/markdown-list", projectId));
export interface FetchProjectMarkdownFilesOptions {
showHidden?: boolean;
}
export function fetchProjectMarkdownFiles(
projectId?: string,
options?: FetchProjectMarkdownFilesOptions,
): Promise<MarkdownFileListResponse> {
const params = new URLSearchParams();
if (options?.showHidden) {
params.set("showHidden", "1");
}
const query = params.toString();
const path = `/files/markdown-list${query ? `?${query}` : ""}`;
return api<MarkdownFileListResponse>(withProjectId(path, projectId));
}
export function putTaskDocument(

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");

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useProjectMarkdownFiles } from "../useProjectMarkdownFiles";
import { fetchProjectMarkdownFiles } from "../../api";
vi.mock("../../api", () => ({
fetchProjectMarkdownFiles: vi.fn(),
}));
const mockFetchProjectMarkdownFiles = vi.mocked(fetchProjectMarkdownFiles);
describe("useProjectMarkdownFiles", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses hidden files off by default", async () => {
mockFetchProjectMarkdownFiles.mockResolvedValue({ files: [] });
renderHook(() => useProjectMarkdownFiles("proj-1"));
await waitFor(() => {
expect(mockFetchProjectMarkdownFiles).toHaveBeenCalledWith("proj-1", { showHidden: false });
});
});
it("refetches when showHidden option changes", async () => {
mockFetchProjectMarkdownFiles.mockResolvedValue({ files: [] });
const { rerender } = renderHook(
({ showHidden }) => useProjectMarkdownFiles("proj-1", { showHidden }),
{ initialProps: { showHidden: false } },
);
await waitFor(() => {
expect(mockFetchProjectMarkdownFiles).toHaveBeenCalledWith("proj-1", { showHidden: false });
});
rerender({ showHidden: true });
await waitFor(() => {
expect(mockFetchProjectMarkdownFiles).toHaveBeenCalledWith("proj-1", { showHidden: true });
});
});
it("allows refresh override to fetch with explicit visibility", async () => {
mockFetchProjectMarkdownFiles.mockResolvedValue({ files: [] });
const { result } = renderHook(() => useProjectMarkdownFiles("proj-1", { showHidden: false }));
await waitFor(() => {
expect(mockFetchProjectMarkdownFiles).toHaveBeenCalledWith("proj-1", { showHidden: false });
});
await act(async () => {
await result.current.refresh({ showHidden: true });
});
expect(mockFetchProjectMarkdownFiles).toHaveBeenLastCalledWith("proj-1", { showHidden: true });
});
});

View File

@@ -1,11 +1,15 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { fetchProjectMarkdownFiles, type MarkdownFileEntry } from "../api";
export interface ProjectMarkdownFilesVisibilityOptions {
showHidden?: boolean;
}
export interface UseProjectMarkdownFilesResult {
files: MarkdownFileEntry[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
refresh: (options?: ProjectMarkdownFilesVisibilityOptions) => Promise<void>;
}
/**
@@ -14,14 +18,19 @@ export interface UseProjectMarkdownFilesResult {
* Loading behavior matches useDocuments: loading is true only for the initial
* fetch, not for subsequent refreshes, to avoid content flicker.
*/
export function useProjectMarkdownFiles(projectId?: string): UseProjectMarkdownFilesResult {
export function useProjectMarkdownFiles(
projectId?: string,
options?: ProjectMarkdownFilesVisibilityOptions,
): UseProjectMarkdownFilesResult {
const [files, setFiles] = useState<MarkdownFileEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const initialLoadCompleteRef = useRef(false);
const refresh = useCallback(async () => {
const showHidden = options?.showHidden ?? false;
const refresh = useCallback(async (refreshOptions?: ProjectMarkdownFilesVisibilityOptions) => {
if (abortRef.current) {
abortRef.current.abort();
}
@@ -37,7 +46,9 @@ export function useProjectMarkdownFiles(projectId?: string): UseProjectMarkdownF
setError(null);
try {
const response = await fetchProjectMarkdownFiles(projectId);
const response = await fetchProjectMarkdownFiles(projectId, {
showHidden: refreshOptions?.showHidden ?? showHidden,
});
if (requestController.signal.aborted) {
return;
@@ -56,18 +67,18 @@ export function useProjectMarkdownFiles(projectId?: string): UseProjectMarkdownF
setLoading(false);
}
}
}, [projectId]);
}, [projectId, showHidden]);
useEffect(() => {
initialLoadCompleteRef.current = false;
void refresh();
void refresh({ showHidden });
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
}, [refresh]);
}, [refresh, showHidden]);
return {
files,

View File

@@ -28390,6 +28390,17 @@ html .column.drag-over * {
font-family: var(--font-mono);
}
.documents-hidden-toggle {
align-self: flex-start;
margin-bottom: var(--space-sm);
}
.documents-hidden-toggle[aria-pressed="true"] {
color: var(--todo);
border-color: var(--todo);
background: color-mix(in srgb, var(--todo) 12%, transparent);
}
.documents-search {
position: relative;
display: flex;
@@ -29165,6 +29176,12 @@ html .column.drag-over * {
min-height: 44px;
}
.documents-hidden-toggle {
width: 100%;
justify-content: center;
min-height: 44px;
}
.documents-search-input {
max-width: none;
width: 100%;

View File

@@ -1560,6 +1560,128 @@ describe("listProjectMarkdownFiles", () => {
expect(mockReaddir).not.toHaveBeenCalledWith("/project/dist", { withFileTypes: true });
});
it("omits hidden markdown files and markdown files inside hidden directories by default", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
fileEntry("README.md"),
fileEntry(".secret.md"),
directoryEntry("docs"),
directoryEntry(".hidden"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("guide.md")];
}
if (targetPath === "/project/.hidden") {
return [fileEntry("internal.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 64,
mtime: new Date("2024-01-02T00:00:00.000Z"),
});
const result = await listProjectMarkdownFiles(mockStore);
expect(result.files.map((entry) => entry.path)).toEqual([
"docs/guide.md",
"README.md",
]);
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.hidden", { withFileTypes: true });
});
it("includes hidden markdown files and hidden directories when showHidden is true", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
fileEntry("README.md"),
fileEntry(".secret.md"),
directoryEntry("docs"),
directoryEntry(".hidden"),
];
}
if (targetPath === "/project/docs") {
return [fileEntry("guide.md")];
}
if (targetPath === "/project/.hidden") {
return [fileEntry("internal.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 64,
mtime: new Date("2024-01-02T00:00:00.000Z"),
});
const result = await listProjectMarkdownFiles(mockStore, { showHidden: true });
expect(result.files.map((entry) => entry.path)).toEqual([
".hidden/internal.md",
".secret.md",
"docs/guide.md",
"README.md",
]);
});
it("keeps hard-excluded directories hidden even when showHidden is true", async () => {
mockGetRootDir.mockReturnValue("/project");
mockReaddir.mockImplementation(async (targetPath: string) => {
if (targetPath === "/project") {
return [
directoryEntry(".git"),
directoryEntry(".fusion"),
directoryEntry(".hidden"),
directoryEntry("docs"),
];
}
if (targetPath === "/project/.hidden") {
return [fileEntry("internal.md")];
}
if (targetPath === "/project/docs") {
return [fileEntry("guide.md")];
}
return [];
});
mockStat.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
size: 64,
mtime: new Date("2024-01-02T00:00:00.000Z"),
});
const result = await listProjectMarkdownFiles(mockStore, { showHidden: true });
expect(result.files.map((entry) => entry.path)).toEqual([
".hidden/internal.md",
"docs/guide.md",
]);
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.git", { withFileTypes: true });
expect(mockReaddir).not.toHaveBeenCalledWith("/project/.fusion", { withFileTypes: true });
});
it("returns an empty list when no markdown files exist", async () => {
mockGetRootDir.mockReturnValue("/project");

View File

@@ -932,10 +932,19 @@ const MARKDOWN_SCAN_EXCLUDED_DIRS = new Set([
".vercel",
]);
interface ListProjectMarkdownFilesOptions {
showHidden?: boolean;
}
function isHiddenPathSegment(name: string): boolean {
return name.startsWith(".");
}
async function walkDirForMarkdown(
basePath: string,
currentRelative: string,
results: MarkdownFileEntry[],
options: ListProjectMarkdownFilesOptions,
): Promise<void> {
const currentPath = currentRelative ? join(basePath, currentRelative) : basePath;
@@ -955,7 +964,12 @@ async function walkDirForMarkdown(
if (MARKDOWN_SCAN_EXCLUDED_DIRS.has(entry.name)) {
continue;
}
await walkDirForMarkdown(basePath, entryRelativePath, results);
if (!options.showHidden && isHiddenPathSegment(entry.name)) {
continue;
}
await walkDirForMarkdown(basePath, entryRelativePath, results, options);
continue;
}
@@ -963,6 +977,10 @@ async function walkDirForMarkdown(
continue;
}
if (!options.showHidden && isHiddenPathSegment(entry.name)) {
continue;
}
const fullPath = join(basePath, entryRelativePath);
let fileStats;
@@ -987,11 +1005,14 @@ async function walkDirForMarkdown(
export async function listProjectMarkdownFiles(
store: TaskStore,
options?: ListProjectMarkdownFilesOptions,
): Promise<MarkdownFileListResponse> {
const projectBasePath = getProjectBasePath(store);
const files: MarkdownFileEntry[] = [];
await walkDirForMarkdown(projectBasePath, "", files);
await walkDirForMarkdown(projectBasePath, "", files, {
showHidden: options?.showHidden ?? false,
});
files.sort((a, b) => a.path.localeCompare(b.path));

View File

@@ -8104,7 +8104,9 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
router.get("/files/markdown-list", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const result: MarkdownFileListResponse = await listProjectMarkdownFiles(scopedStore);
const showHiddenQuery = req.query.showHidden;
const showHidden = showHiddenQuery === "1" || showHiddenQuery === "true";
const result: MarkdownFileListResponse = await listProjectMarkdownFiles(scopedStore, { showHidden });
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {