Add file browser filtering

This commit is contained in:
Michael Ramos
2026-07-08 22:32:20 -07:00
committed by GitHub
parent 381a876dac
commit 1267cd50db
3 changed files with 241 additions and 7 deletions
@@ -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", () => {
+132 -6
View File
@@ -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<string>;
editStatuses?: Map<string, FileEditStatus>;
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 (
<>
<button
disabled={forceExpandFolders}
onClick={() => onToggleFolder(folderKey)}
className="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"
className={folderButtonClassName}
style={{ paddingLeft }}
>
<svg
@@ -261,6 +301,7 @@ const TreeNode: React.FC<{
highlightedFiles={highlightedFiles}
editStatuses={editStatuses}
workspaceStatus={workspaceStatus}
forceExpandFolders={forceExpandFolders}
/>
))}
</>
@@ -348,7 +389,8 @@ const DirSection: React.FC<{
annotationCounts?: Map<string, number>;
highlightedFiles?: Set<string>;
editStatuses?: Map<string, FileEditStatus>;
}> = ({ 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 +439,7 @@ const DirSection: React.FC<{
highlightedFiles={highlightedFiles}
editStatuses={editStatuses}
workspaceStatus={workspaceStatus}
forceExpandFolders={forceExpandFolders}
/>
))}
</div>
@@ -417,6 +460,30 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
highlightedFiles,
editStatuses,
}) => {
const [isFilterOpen, setIsFilterOpen] = React.useState(false);
const [filterQuery, setFilterQuery] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(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]);
const handleFilterBlur = React.useCallback((event: React.FocusEvent<HTMLDivElement>) => {
if (filterQuery.trim()) return;
if (event.currentTarget.contains(event.relatedTarget)) return;
setIsFilterOpen(false);
}, [filterQuery]);
React.useEffect(() => {
if (!showFilterInput) return;
inputRef.current?.focus();
}, [showFilterInput]);
if (dirs.length === 0) {
return (
<div className="p-3 text-[11px] text-muted-foreground">
@@ -454,8 +521,66 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
{workspaceTotals.deletions > 0 && <span className={`deletions ${workspaceTotals.additions > 0 ? "" : "ml-auto"}`}>-{workspaceTotals.deletions}</span>}
</div>
)}
{dirs.map((dir) => {
const isCollapsed = collapsedDirs.has(dir.path);
<div className="border-b border-border/20 px-2 py-0.5">
{showFilterInput ? (
<div
onBlur={handleFilterBlur}
className="flex h-6 items-center gap-1.5 rounded-sm bg-muted/25 px-1.5 text-muted-foreground focus-within:bg-muted/40"
>
<Search size={12} className="shrink-0 text-muted-foreground/55" aria-hidden="true" />
<input
ref={inputRef}
type="search"
value={filterQuery}
onChange={(event) => setFilterQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Escape") return;
if (filterQuery) setFilterQuery("");
else setIsFilterOpen(false);
}}
placeholder="Filter"
aria-label="Filter files"
autoComplete="off"
spellCheck={false}
data-lpignore="true"
data-1p-ignore
className="file-browser-filter-input h-full min-w-0 flex-1 bg-transparent p-0 text-[16px] leading-4 text-foreground outline-none placeholder:text-muted-foreground/45 sm:text-[11px]"
/>
{filterQuery && (
<button
type="button"
onClick={() => {
setFilterQuery("");
inputRef.current?.focus();
}}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground/55 hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:bg-muted"
aria-label="Clear file filter"
title="Clear file filter"
>
<X size={12} aria-hidden="true" />
</button>
)}
</div>
) : (
<button
type="button"
onClick={() => setIsFilterOpen(true)}
className="flex h-6 w-full items-center gap-1.5 rounded-sm px-1.5 text-left text-[10px] text-muted-foreground hover:bg-muted/40 hover:text-foreground focus-visible:outline-none focus-visible:bg-muted/50 focus-visible:text-foreground"
aria-label="Filter files"
title="Filter files"
>
<Search size={12} className="shrink-0 text-muted-foreground/55" aria-hidden="true" />
<span className="truncate">Filter</span>
</button>
)}
</div>
{isFiltering && visibleDirs.length === 0 && (
<div className="px-3 py-8 text-center text-[11px] text-muted-foreground">
No files match "{deferredFilterQuery.trim()}"
</div>
)}
{visibleDirs.map((dir) => {
const isCollapsed = !isFiltering && collapsedDirs.has(dir.path);
return (
<div key={dir.path}>
<button
@@ -488,6 +613,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
annotationCounts={annotationCounts}
highlightedFiles={highlightedFiles}
editStatuses={editStatuses}
forceExpandFolders={isFiltering}
/>
)}
</div>
+16
View File
@@ -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;