diff --git a/packages/ui/components/sidebar/FileBrowser.test.ts b/packages/ui/components/sidebar/FileBrowser.test.ts index ff00a513f..c09be77d4 100644 --- a/packages/ui/components/sidebar/FileBrowser.test.ts +++ b/packages/ui/components/sidebar/FileBrowser.test.ts @@ -1,7 +1,99 @@ import { describe, expect, test } from "bun:test"; import type { VaultNode } from "../../types"; import type { WorkspaceFileChange, WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types"; -import { getAggregateWorkspaceChange, getFileEditStatus, getWorkspaceChange, isFileTreeSelectionDisabled, normalizePathForLookup } from "./FileBrowser"; +import { + filterFileTree, + getAggregateWorkspaceChange, + getFileEditStatus, + getFileTreeFilterTokens, + getWorkspaceChange, + isFileTreeSelectionDisabled, + normalizePathForLookup, +} from "./FileBrowser"; + +describe("FileBrowser filtering", () => { + const tree: VaultNode[] = [ + { + name: "docs", + path: "docs", + type: "folder", + children: [ + { name: "intro.md", path: "docs/intro.md", type: "file" }, + { + name: "auth", + path: "docs/auth", + type: "folder", + children: [ + { name: "middleware.md", path: "docs/auth/middleware.md", type: "file" }, + { name: "session.md", path: "docs/auth/session.md", type: "file" }, + ], + }, + ], + }, + { name: "changelog.txt", path: "changelog.txt", type: "file" }, + ]; + + test("keeps ancestor folders for matching descendant files", () => { + expect(filterFileTree(tree, getFileTreeFilterTokens("middleware"))).toEqual([ + { + name: "docs", + path: "docs", + type: "folder", + children: [ + { + name: "auth", + path: "docs/auth", + type: "folder", + children: [ + { name: "middleware.md", path: "docs/auth/middleware.md", type: "file" }, + ], + }, + ], + }, + ]); + }); + + test("matches multiple tokens across the full path", () => { + expect(filterFileTree(tree, getFileTreeFilterTokens("auth session"))).toEqual([ + { + name: "docs", + path: "docs", + type: "folder", + children: [ + { + name: "auth", + path: "docs/auth", + type: "folder", + children: [ + { name: "session.md", path: "docs/auth/session.md", type: "file" }, + ], + }, + ], + }, + ]); + }); + + test("keeps a matching folder subtree intact", () => { + expect(filterFileTree(tree, getFileTreeFilterTokens("auth"))).toEqual([ + { + name: "docs", + path: "docs", + type: "folder", + children: [ + { + name: "auth", + path: "docs/auth", + type: "folder", + children: [ + { name: "middleware.md", path: "docs/auth/middleware.md", type: "file" }, + { name: "session.md", path: "docs/auth/session.md", type: "file" }, + ], + }, + ], + }, + ]); + }); +}); describe("FileBrowser workspace status lookup", () => { test("matches Windows status keys when the UI path uses mixed separators", () => { diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx index 9b432c8f4..51ac9d5a0 100644 --- a/packages/ui/components/sidebar/FileBrowser.tsx +++ b/packages/ui/components/sidebar/FileBrowser.tsx @@ -6,6 +6,7 @@ */ import React from "react"; +import { Search, X } from "lucide-react"; import type { VaultNode } from "../../types"; import type { DirState } from "../../hooks/useFileBrowser"; import { CountBadge } from "./CountBadge"; @@ -42,6 +43,40 @@ interface AggregateWorkspaceChange { files: number; } +const FILE_EXTENSION_RE = /\.(mdx?|txt|html?)$/i; + +function normalizeFilterText(value: string): string { + return value.replace(/\\/g, "/").toLowerCase(); +} + +export function getFileTreeFilterTokens(query: string): string[] { + return normalizeFilterText(query) + .trim() + .split(/\s+/) + .filter(Boolean); +} + +function nodeMatchesFilter(node: VaultNode, tokens: string[]): boolean { + if (tokens.length === 0) return true; + const displayName = node.name.replace(FILE_EXTENSION_RE, ""); + const haystack = normalizeFilterText(`${node.name} ${displayName} ${node.path}`); + return tokens.every((token) => haystack.includes(token)); +} + +export function filterFileTree(nodes: VaultNode[], tokens: string[]): VaultNode[] { + if (tokens.length === 0) return nodes; + + return nodes.flatMap((node) => { + if (node.type === "file") return nodeMatchesFilter(node, tokens) ? [node] : []; + + if (nodeMatchesFilter(node, tokens)) return [node]; + + const children = filterFileTree(node.children ?? [], tokens); + if (children.length === 0) return []; + return [{ ...node, children }]; + }); +} + export function normalizePathForLookup(path: string): string { return normalizeBrowserPath(path); } @@ -207,21 +242,26 @@ const TreeNode: React.FC<{ highlightedFiles?: Set; editStatuses?: Map; workspaceStatus?: WorkspaceStatusPayload; -}> = ({ node, depth, dirPath, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles, editStatuses, workspaceStatus }) => { + forceExpandFolders?: boolean; +}> = ({ node, depth, dirPath, expandedFolders, onToggleFolder, onSelectFile, activeFile, annotationCounts, highlightedFiles, editStatuses, workspaceStatus, forceExpandFolders = false }) => { const folderKey = `${dirPath}:${node.path}`; const absolutePath = `${dirPath}/${node.path}`; - const isExpanded = expandedFolders.has(folderKey); + const isExpanded = forceExpandFolders || expandedFolders.has(folderKey); const isActive = node.type === "file" && absolutePath === activeFile; const paddingLeft = 8 + depth * 14; if (node.type === "folder") { const aggregateCount = annotationCounts ? getAggregateCount(node, dirPath, annotationCounts, workspaceStatus) : 0; const aggregateChange = getAggregateWorkspaceChange(node, dirPath, workspaceStatus); + const folderButtonClassName = forceExpandFolders + ? "file-tree-folder w-full flex items-center gap-1.5 py-1 px-2 text-[11px] text-muted-foreground transition-colors rounded-sm cursor-default disabled:opacity-100" + : "file-tree-folder w-full flex items-center gap-1.5 py-1 px-2 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors rounded-sm"; return ( <> + )} + + ) : ( + + )} + + {isFiltering && visibleDirs.length === 0 && ( +
+ No files match "{deferredFilterQuery.trim()}" +
+ )} + {visibleDirs.map((dir) => { + const isCollapsed = !isFiltering && collapsedDirs.has(dir.path); return (
diff --git a/packages/ui/theme.css b/packages/ui/theme.css index 7bb5397f2..fe4d71682 100644 --- a/packages/ui/theme.css +++ b/packages/ui/theme.css @@ -410,6 +410,22 @@ html:not(.transitions-ready) * { outline-offset: 2px; } +.file-browser-filter-input { + appearance: none; +} + +.file-browser-filter-input:focus-visible { + outline: none; +} + +.file-browser-filter-input::-webkit-search-decoration, +.file-browser-filter-input::-webkit-search-cancel-button, +.file-browser-filter-input::-webkit-search-results-button, +.file-browser-filter-input::-webkit-search-results-decoration { + appearance: none; + -webkit-appearance: none; +} + /* Flash highlight for annotated files in sidebar */ .file-annotation-flash { animation: file-flash 1.2s ease-out;