From 6db662bf6bca54c6809d6f983162eebb68dc344a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 8 Jul 2026 13:12:28 -0700 Subject: [PATCH 1/3] Add file browser filtering --- .../ui/components/sidebar/FileBrowser.test.ts | 94 ++++++++++++- .../ui/components/sidebar/FileBrowser.tsx | 124 +++++++++++++++++- 2 files changed, 212 insertions(+), 6 deletions(-) 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..5469b0fec 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,10 +242,11 @@ 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; @@ -261,6 +297,7 @@ const TreeNode: React.FC<{ highlightedFiles={highlightedFiles} editStatuses={editStatuses} workspaceStatus={workspaceStatus} + forceExpandFolders={forceExpandFolders} /> ))} @@ -348,7 +385,8 @@ const DirSection: React.FC<{ annotationCounts?: Map; highlightedFiles?: Set; editStatuses?: Map; -}> = ({ dir, expandedFolders, onToggleFolder, onSelectFile, activeFile, onRetry, annotationCounts, highlightedFiles, editStatuses }) => { + forceExpandFolders?: boolean; +}> = ({ dir, expandedFolders, onToggleFolder, onSelectFile, activeFile, onRetry, annotationCounts, highlightedFiles, editStatuses, forceExpandFolders = false }) => { const workspaceStatus = React.useMemo(() => normalizeWorkspaceStatus(dir.workspaceStatus), [dir.workspaceStatus]); if (dir.isLoading) { @@ -397,6 +435,7 @@ const DirSection: React.FC<{ highlightedFiles={highlightedFiles} editStatuses={editStatuses} workspaceStatus={workspaceStatus} + forceExpandFolders={forceExpandFolders} /> ))} @@ -417,6 +456,25 @@ export const FileBrowser: React.FC = ({ highlightedFiles, editStatuses, }) => { + const [isFilterOpen, setIsFilterOpen] = React.useState(false); + const [filterQuery, setFilterQuery] = React.useState(""); + const inputRef = React.useRef(null); + const deferredFilterQuery = React.useDeferredValue(filterQuery); + const filterTokens = React.useMemo(() => getFileTreeFilterTokens(deferredFilterQuery), [deferredFilterQuery]); + const isFiltering = filterTokens.length > 0; + const showFilterInput = isFilterOpen || filterQuery.trim().length > 0; + const visibleDirs = React.useMemo(() => { + if (!isFiltering) return dirs; + return dirs + .map((dir) => ({ ...dir, tree: filterFileTree(dir.tree, filterTokens) })) + .filter((dir) => dir.isLoading || dir.error || dir.tree.length > 0); + }, [dirs, filterTokens, isFiltering]); + + React.useEffect(() => { + if (!showFilterInput) return; + inputRef.current?.focus(); + }, [showFilterInput]); + if (dirs.length === 0) { return (
@@ -454,8 +512,63 @@ export const FileBrowser: React.FC = ({ {workspaceTotals.deletions > 0 && 0 ? "" : "ml-auto"}`}>-{workspaceTotals.deletions}}
)} - {dirs.map((dir) => { - const isCollapsed = collapsedDirs.has(dir.path); +
+ {showFilterInput ? ( +
+
+ ) : ( + + )} +
+ {isFiltering && visibleDirs.length === 0 && ( +
+ No files match "{deferredFilterQuery.trim()}" +
+ )} + {visibleDirs.map((dir) => { + const isCollapsed = !isFiltering && collapsedDirs.has(dir.path); return (
From 4f170abba94ba9b9aefa294684c3679947167661 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 8 Jul 2026 22:07:16 -0700 Subject: [PATCH 2/3] Polish file browser filter UX --- .../ui/components/sidebar/FileBrowser.tsx | 32 ++++++++++++------- packages/ui/theme.css | 16 ++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx index 5469b0fec..a9a87e545 100644 --- a/packages/ui/components/sidebar/FileBrowser.tsx +++ b/packages/ui/components/sidebar/FileBrowser.tsx @@ -469,6 +469,11 @@ export const FileBrowser: React.FC = ({ .map((dir) => ({ ...dir, tree: filterFileTree(dir.tree, filterTokens) })) .filter((dir) => dir.isLoading || dir.error || dir.tree.length > 0); }, [dirs, filterTokens, isFiltering]); + const handleFilterBlur = React.useCallback((event: React.FocusEvent) => { + if (filterQuery.trim()) return; + if (event.currentTarget.contains(event.relatedTarget)) return; + setIsFilterOpen(false); + }, [filterQuery]); React.useEffect(() => { if (!showFilterInput) return; @@ -512,10 +517,13 @@ export const FileBrowser: React.FC = ({ {workspaceTotals.deletions > 0 && 0 ? "" : "ml-auto"}`}>-{workspaceTotals.deletions}} )} -
+
{showFilterInput ? ( -
-
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; From 3a63f09fc431829114ec17cf9981edb80a7d0d92 Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Wed, 8 Jul 2026 22:13:43 -0700 Subject: [PATCH 3/3] Avoid hidden folder toggles while filtering --- packages/ui/components/sidebar/FileBrowser.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/ui/components/sidebar/FileBrowser.tsx b/packages/ui/components/sidebar/FileBrowser.tsx index a9a87e545..51ac9d5a0 100644 --- a/packages/ui/components/sidebar/FileBrowser.tsx +++ b/packages/ui/components/sidebar/FileBrowser.tsx @@ -253,11 +253,15 @@ const TreeNode: React.FC<{ 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 ( <>