feat: add search functionality (#243)

This commit is contained in:
Carlos Florêncio
2026-02-17 22:40:42 +00:00
committed by GitHub
parent 77cac23395
commit 1f4170da8b
3 changed files with 113 additions and 174 deletions
+112 -138
View File
@@ -22,6 +22,7 @@ import {
} from '@/lib/local-storage'; } from '@/lib/local-storage';
import { useToast } from '@/components/toast-provider'; import { useToast } from '@/components/toast-provider';
import { InlineFileInput } from './inline-file-input'; import { InlineFileInput } from './inline-file-input';
import { PanelHeaderBar, PanelHeaderBarSplit } from './panel-primitives';
/** /**
* Merge a freshly-fetched tree node into an existing one, preserving * Merge a freshly-fetched tree node into an existing one, preserving
@@ -49,13 +50,16 @@ function mergeTreeNodes(existing: FileTreeNode, incoming: FileTreeNode): FileTre
return { ...existing, ...incoming, children: mergedChildren }; return { ...existing, ...incoming, children: mergedChildren };
} }
/** Sort comparator: directories first, then alphabetical by name. */
const compareTreeNodes = (a: FileTreeNode, b: FileTreeNode): number => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return a.name.localeCompare(b.name);
};
/** Insert a file node into a parent folder, keeping children sorted (dirs first, then alpha). */ /** Insert a file node into a parent folder, keeping children sorted (dirs first, then alpha). */
function insertNodeInTree(root: FileTreeNode, parentPath: string, node: FileTreeNode): FileTreeNode { function insertNodeInTree(root: FileTreeNode, parentPath: string, node: FileTreeNode): FileTreeNode {
if (root.path === parentPath || (parentPath === '' && root.path === '')) { if (root.path === parentPath || (parentPath === '' && root.path === '')) {
const children = [...(root.children ?? []), node].sort((a, b) => { const children = [...(root.children ?? []), node].sort(compareTreeNodes);
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { ...root, children }; return { ...root, children };
} }
if (!root.children) return root; if (!root.children) return root;
@@ -77,12 +81,10 @@ type FileBrowserProps = {
onOpenFile: (file: OpenFileTab) => void; onOpenFile: (file: OpenFileTab) => void;
onCreateFile?: (path: string) => Promise<boolean>; onCreateFile?: (path: string) => Promise<boolean>;
onDeleteFile?: (path: string) => Promise<boolean>; onDeleteFile?: (path: string) => Promise<boolean>;
isSearchOpen?: boolean;
onCloseSearch?: () => void;
activeFilePath?: string | null; activeFilePath?: string | null;
}; };
export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile, isSearchOpen = false, onCloseSearch, activeFilePath }: FileBrowserProps) { export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile, activeFilePath }: FileBrowserProps) {
const { toast } = useToast(); const { toast } = useToast();
const [tree, setTree] = useState<FileTreeNode | null>(null); const [tree, setTree] = useState<FileTreeNode | null>(null);
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set()); const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
@@ -99,6 +101,7 @@ export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile,
const { copied, copy: copyPath } = useCopyToClipboard(1000); const { copied, copy: copyPath } = useCopyToClipboard(1000);
// Search state // Search state
const [isSearchActive, setIsSearchActive] = useState(false);
const [localSearchQuery, setLocalSearchQuery] = useState(''); const [localSearchQuery, setLocalSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<string[] | null>(null); const [searchResults, setSearchResults] = useState<string[] | null>(null);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
@@ -176,14 +179,14 @@ export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile,
// Focus search input when search opens // Focus search input when search opens
useEffect(() => { useEffect(() => {
if (isSearchOpen && searchInputRef.current) { if (isSearchActive && searchInputRef.current) {
searchInputRef.current.focus(); searchInputRef.current.focus();
} }
}, [isSearchOpen]); }, [isSearchActive]);
// Clear search when closing // Clear search when closing
useEffect(() => { useEffect(() => {
if (!isSearchOpen) { if (!isSearchActive) {
setLocalSearchQuery(''); setLocalSearchQuery('');
setSearchResults(null); setSearchResults(null);
setIsSearching(false); setIsSearching(false);
@@ -191,7 +194,7 @@ export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile,
clearTimeout(searchTimeoutRef.current); clearTimeout(searchTimeoutRef.current);
} }
} }
}, [isSearchOpen]); }, [isSearchActive]);
const clearRetryTimer = useCallback(() => { const clearRetryTimer = useCallback(() => {
if (retryTimerRef.current) { if (retryTimerRef.current) {
@@ -474,16 +477,16 @@ export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile,
}, 300); }, 300);
}, [sessionId]); }, [sessionId]);
// Close search (clears and notifies parent) // Close search and clear state
const handleCloseSearch = useCallback(() => { const handleCloseSearch = useCallback(() => {
setIsSearchActive(false);
setLocalSearchQuery(''); setLocalSearchQuery('');
setSearchResults(null); setSearchResults(null);
setIsSearching(false); setIsSearching(false);
if (searchTimeoutRef.current) { if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current); clearTimeout(searchTimeoutRef.current);
} }
onCloseSearch?.(); }, []);
}, [onCloseSearch]);
// Cleanup search timeout on unmount // Cleanup search timeout on unmount
useEffect(() => { useEffect(() => {
@@ -547,39 +550,6 @@ export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile,
} }
}, [expandedPaths, sessionId, tree, showLoading, hideLoading]); }, [expandedPaths, sessionId, tree, showLoading, hideLoading]);
const openFile = useCallback(async (node: FileTreeNode) => {
if (node.is_dir) return;
try {
const client = getWebSocketClient();
if (!client) return;
const response: FileContentResponse = await requestFileContent(client, sessionId, node.path);
// Calculate hash for the file content
const { calculateHash } = await import('@/lib/utils/file-diff');
const hash = await calculateHash(response.content);
onOpenFile({
path: node.path,
name: node.name,
content: response.content,
originalContent: response.content,
originalHash: hash,
isDirty: false,
isBinary: response.is_binary,
});
} catch (error) {
const reason = error instanceof Error ? error.message : 'Unknown error';
toast({
title: 'Failed to open file',
description: reason,
variant: 'error',
});
}
}, [sessionId, onOpenFile, toast]);
// Open file from search result (path only)
const openFileByPath = useCallback(async (path: string) => { const openFileByPath = useCallback(async (path: string) => {
try { try {
const client = getWebSocketClient(); const client = getWebSocketClient();
@@ -587,11 +557,9 @@ export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile,
const response: FileContentResponse = await requestFileContent(client, sessionId, path); const response: FileContentResponse = await requestFileContent(client, sessionId, path);
// Calculate hash for the file content
const { calculateHash } = await import('@/lib/utils/file-diff'); const { calculateHash } = await import('@/lib/utils/file-diff');
const hash = await calculateHash(response.content); const hash = await calculateHash(response.content);
// Extract filename from path
const name = path.split('/').pop() || path; const name = path.split('/').pop() || path;
onOpenFile({ onOpenFile({
@@ -629,7 +597,7 @@ export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile,
isActiveFolder && "bg-muted/50" isActiveFolder && "bg-muted/50"
)} )}
style={{ paddingLeft: `${depth * 12 + 8 + (node.is_dir ? 0 : 20)}px` }} style={{ paddingLeft: `${depth * 12 + 8 + (node.is_dir ? 0 : 20)}px` }}
onClick={() => (node.is_dir ? toggleExpand(node) : openFile(node))} onClick={() => (node.is_dir ? toggleExpand(node) : openFileByPath(node.path))}
> >
{node.is_dir && ( {node.is_dir && (
<span className="flex-shrink-0"> <span className="flex-shrink-0">
@@ -698,12 +666,7 @@ export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile,
/> />
)} )}
{node.children && [...node.children] {node.children && [...node.children]
.sort((a, b) => { .sort(compareTreeNodes)
// Folders first, then alphabetically
if (a.is_dir && !b.is_dir) return -1;
if (!a.is_dir && b.is_dir) return 1;
return a.name.localeCompare(b.name);
})
.map((child) => renderTreeNode(child, depth + 1))} .map((child) => renderTreeNode(child, depth + 1))}
</div> </div>
)} )}
@@ -755,102 +718,117 @@ export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile,
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Search input - only shown when search is open */} {/* Header toolbar */}
{isSearchOpen && ( {tree && loadState === 'loaded' && (
<div className="px-2 pb-2"> isSearchActive ? (
<div className="relative"> <PanelHeaderBar className="group/header">
{isSearching ? ( {isSearching ? (
<IconLoader2 className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none animate-spin" /> <IconLoader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin shrink-0" />
) : ( ) : (
<IconSearch className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground pointer-events-none" /> <IconSearch className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)} )}
<Input <Input
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
value={localSearchQuery} value={localSearchQuery}
onChange={handleSearchChange} onChange={handleSearchChange}
onKeyDown={(e) => { if (e.key === 'Escape') handleCloseSearch(); }}
placeholder="Search files..." placeholder="Search files..."
className="pl-7 pr-7 h-7 text-xs" className="flex-1 min-w-0 h-5 text-xs border-none bg-transparent shadow-none focus-visible:ring-0 px-2"
/> />
<button <button
type="button" className="text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
onClick={handleCloseSearch} onClick={handleCloseSearch}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
> >
<IconX className="h-3.5 w-3.5" /> <IconX className="h-3.5 w-3.5" />
</button> </button>
</div> </PanelHeaderBar>
</div> ) : (
)} <PanelHeaderBarSplit
className="group/header"
{/* Header toolbar */} left={
{tree && loadState === 'loaded' && ( <>
<div className="group/header flex items-center gap-1 border-b border-border/50 px-2 py-1"> <Tooltip>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger asChild> <button
<button className="relative shrink-0 cursor-pointer"
className="relative shrink-0 cursor-pointer" onClick={() => {
onClick={() => { if (fullPath) void copyPath(fullPath);
if (fullPath) void copyPath(fullPath); }}
}} >
> <IconFolderOpen className={cn("h-3.5 w-3.5 text-muted-foreground transition-opacity", copied ? "opacity-0" : "group-hover/header:opacity-0")} />
<IconFolderOpen className={cn("h-3.5 w-3.5 text-muted-foreground transition-opacity", copied ? "opacity-0" : "group-hover/header:opacity-0")} /> {copied ? (
{copied ? ( <IconCheck className="absolute inset-0 h-3.5 w-3.5 text-green-600/70" />
<IconCheck className="absolute inset-0 h-3.5 w-3.5 text-green-600/70" /> ) : (
) : ( <IconCopy className="absolute inset-0 h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover/header:opacity-100 hover:text-foreground transition-opacity" />
<IconCopy className="absolute inset-0 h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover/header:opacity-100 hover:text-foreground transition-opacity" /> )}
</button>
</TooltipTrigger>
<TooltipContent>Copy workspace path</TooltipContent>
</Tooltip>
<span className="min-w-0 truncate text-xs font-medium text-muted-foreground">
{displayPath}
</span>
</>
}
right={
<>
{onCreateFile && (
<Tooltip>
<TooltipTrigger asChild>
<button
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
onClick={handleStartCreate}
>
<IconPlus className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>New file</TooltipContent>
</Tooltip>
)} )}
</button> <Tooltip>
</TooltipTrigger> <TooltipTrigger asChild>
<TooltipContent>Copy workspace path</TooltipContent> <button
</Tooltip> className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
<span className="min-w-0 flex-1 truncate text-xs font-medium text-muted-foreground"> onClick={() => openFolder()}
{displayPath} >
</span> <IconFolderShare className="h-3.5 w-3.5" />
<div className="flex shrink-0 items-center gap-0.5"> </button>
{onCreateFile && ( </TooltipTrigger>
<Tooltip> <TooltipContent>Open workspace folder</TooltipContent>
<TooltipTrigger asChild> </Tooltip>
<button <Tooltip>
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer" <TooltipTrigger asChild>
onClick={handleStartCreate} <button
> className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
<IconPlus className="h-3.5 w-3.5" /> onClick={() => setIsSearchActive(true)}
</button> >
</TooltipTrigger> <IconSearch className="h-3.5 w-3.5" />
<TooltipContent>New file</TooltipContent> </button>
</Tooltip> </TooltipTrigger>
)} <TooltipContent>Search files</TooltipContent>
<Tooltip> </Tooltip>
<TooltipTrigger asChild> {expandedPaths.size > 0 && (
<button <Tooltip>
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer" <TooltipTrigger asChild>
onClick={() => openFolder()} <button
> className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer"
<IconFolderShare className="h-3.5 w-3.5" /> onClick={collapseAll}
</button> >
</TooltipTrigger> <IconListTree className="h-3.5 w-3.5" />
<TooltipContent>Open workspace folder</TooltipContent> </button>
</Tooltip> </TooltipTrigger>
{expandedPaths.size > 0 && ( <TooltipContent>Collapse all</TooltipContent>
<Tooltip> </Tooltip>
<TooltipTrigger asChild> )}
<button </>
className="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1 cursor-pointer" }
onClick={collapseAll} />
> )
<IconListTree className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Collapse all</TooltipContent>
</Tooltip>
)}
</div>
</div>
)} )}
<ScrollArea className="flex-1" ref={scrollAreaRef}> <ScrollArea className="flex-1" ref={scrollAreaRef}>
{isSearchOpen && searchResults !== null ? ( {isSearchActive && searchResults !== null ? (
renderSearchResults() renderSearchResults()
) : isSessionFailed ? ( ) : isSessionFailed ? (
<div className="p-4 text-sm text-destructive/80 space-y-2"> <div className="p-4 text-sm text-destructive/80 space-y-2">
@@ -887,11 +865,7 @@ export function FileBrowser({ sessionId, onOpenFile, onCreateFile, onDeleteFile,
/> />
)} )}
{tree.children && [...tree.children] {tree.children && [...tree.children]
.sort((a, b) => { .sort(compareTreeNodes)
if (a.is_dir && !b.is_dir) return -1;
if (!a.is_dir && b.is_dir) return 1;
return a.name.localeCompare(b.name);
})
.map((child) => renderTreeNode(child, 0))} .map((child) => renderTreeNode(child, 0))}
</div> </div>
) : ( ) : (
+1 -4
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { memo, useState, useCallback } from 'react'; import { memo, useCallback } from 'react';
import { PanelRoot, PanelBody } from './panel-primitives'; import { PanelRoot, PanelBody } from './panel-primitives';
import { useAppStore } from '@/components/state-provider'; import { useAppStore } from '@/components/state-provider';
import { useFileOperations } from '@/hooks/use-file-operations'; import { useFileOperations } from '@/hooks/use-file-operations';
@@ -13,7 +13,6 @@ type FilesPanelProps = {
}; };
const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) { const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const isArchived = useIsTaskArchived(); const isArchived = useIsTaskArchived();
const { createFile, deleteFile } = useFileOperations(activeSessionId ?? null); const { createFile, deleteFile } = useFileOperations(activeSessionId ?? null);
@@ -40,8 +39,6 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) {
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
onCreateFile={handleCreateFile} onCreateFile={handleCreateFile}
onDeleteFile={deleteFile} onDeleteFile={deleteFile}
isSearchOpen={isSearchOpen}
onCloseSearch={() => setIsSearchOpen(false)}
/> />
) : ( ) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-xs"> <div className="flex items-center justify-center h-full text-muted-foreground text-xs">
@@ -10,7 +10,6 @@ import {
IconChevronRight, IconChevronRight,
IconGitCommit, IconGitCommit,
IconLoader2, IconLoader2,
IconSearch,
IconColumns, IconColumns,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
@@ -75,7 +74,6 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
const [topTab, setTopTab] = useState<'diff' | 'files'>('diff'); const [topTab, setTopTab] = useState<'diff' | 'files'>('diff');
const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [showDiscardDialog, setShowDiscardDialog] = useState(false);
const [fileToDiscard, setFileToDiscard] = useState<string | null>(null); const [fileToDiscard, setFileToDiscard] = useState<string | null>(null);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const gitStatus = useSessionGitStatus(activeSessionId); const gitStatus = useSessionGitStatus(activeSessionId);
const { commits } = useSessionCommits(activeSessionId ?? null); const { commits } = useSessionCommits(activeSessionId ?? null);
@@ -93,11 +91,6 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
// Track previous changed files count for auto-switch detection // Track previous changed files count for auto-switch detection
const prevChangedCountRef = useRef(0); const prevChangedCountRef = useRef(0);
// Close search when switching tabs
useEffect(() => {
setIsSearchOpen(false);
}, [topTab]);
// State for commit diffs // State for commit diffs
const [expandedCommit, setExpandedCommit] = useState<string | null>(null); const [expandedCommit, setExpandedCommit] = useState<string | null>(null);
const [commitDiffs, setCommitDiffs] = useState<Record<string, Record<string, FileInfo>>>({}); const [commitDiffs, setCommitDiffs] = useState<Record<string, Record<string, FileInfo>>>({});
@@ -344,28 +337,6 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
}, },
]; ];
// Contextual buttons based on active tab
const tabRightContent = useMemo(() => {
if (topTab === 'files') {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => setIsSearchOpen(true)}
>
<IconSearch className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Search files</TooltipContent>
</Tooltip>
);
}
// No contextual buttons for diff tab (for now)
return null;
}, [topTab]);
return ( return (
<SessionPanel borderSide="left"> <SessionPanel borderSide="left">
<SessionTabs <SessionTabs
@@ -373,7 +344,6 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
activeTab={topTab} activeTab={topTab}
onTabChange={(value) => handleTabChange(value as 'diff' | 'files')} onTabChange={(value) => handleTabChange(value as 'diff' | 'files')}
className="flex-1 min-h-0" className="flex-1 min-h-0"
rightContent={tabRightContent}
> >
<TabsContent value="diff" className="flex-1 min-h-0"> <TabsContent value="diff" className="flex-1 min-h-0">
<SessionPanelContent className="flex flex-col"> <SessionPanelContent className="flex flex-col">
@@ -604,8 +574,6 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onSelectDiff, onOpenFile,
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
onCreateFile={handleCreateFile} onCreateFile={handleCreateFile}
onDeleteFile={hookDeleteFile} onDeleteFile={hookDeleteFile}
isSearchOpen={isSearchOpen}
onCloseSearch={() => setIsSearchOpen(false)}
activeFilePath={activeFilePath} activeFilePath={activeFilePath}
/> />
) : ( ) : (