Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion packages/ui/components/sidebar/FileBrowser.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down
138 changes: 132 additions & 6 deletions packages/ui/components/sidebar/FileBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -261,6 +301,7 @@ const TreeNode: React.FC<{
highlightedFiles={highlightedFiles}
editStatuses={editStatuses}
workspaceStatus={workspaceStatus}
forceExpandFolders={forceExpandFolders}
/>
))}
</>
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -397,6 +439,7 @@ const DirSection: React.FC<{
highlightedFiles={highlightedFiles}
editStatuses={editStatuses}
workspaceStatus={workspaceStatus}
forceExpandFolders={forceExpandFolders}
/>
))}
</div>
Expand All @@ -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">
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -488,6 +613,7 @@ export const FileBrowser: React.FC<FileBrowserProps> = ({
annotationCounts={annotationCounts}
highlightedFiles={highlightedFiles}
editStatuses={editStatuses}
forceExpandFolders={isFiltering}
/>
)}
</div>
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down