feat: Add file search functionality to OpenClaw filesystem with highlighting and match counter.

This commit is contained in:
Bruno Charest 2026-03-14 14:15:14 -04:00
parent 72e408a3aa
commit de3a50c0f1

View File

@ -230,17 +230,39 @@ function MetricCard({ label, value, accent, icon }: { label: string; value: stri
}
function TreeNode({ node, depth, onSelect }: { node: FileTreeNode; depth: number, onSelect?: (n: FileTreeNode) => void }) {
const [open, setOpen] = useState(depth < 1);
function TreeNode({ node, depth, onSelect, searchQuery }: { node: FileTreeNode; depth: number; onSelect?: (n: FileTreeNode) => void; searchQuery?: string }) {
const [open, setOpen] = useState(depth < 1 || !!searchQuery);
const isDir = node.type === 'directory';
const indent = depth * 20;
// Check if this node matches the search query
const matchesSearch = searchQuery && node.name.toLowerCase().includes(searchQuery.toLowerCase());
// Highlight matching text in file names
const renderHighlightedName = (name: string) => {
if (!searchQuery) return <span>{name}</span>;
const lowerQuery = searchQuery.toLowerCase();
const lowerName = name.toLowerCase();
const index = lowerName.indexOf(lowerQuery);
if (index === -1) return <span>{name}</span>;
return (
<span>
{name.slice(0, index)}
<span className="bg-purple-500/40 text-white px-0.5 rounded">{name.slice(index, index + searchQuery.length)}</span>
{name.slice(index + searchQuery.length)}
</span>
);
};
return (
<div>
<div
className={`flex items-center gap-2 py-1 px-2 rounded-lg transition-colors cursor-pointer ${
isDir ? 'hover:bg-surface-700/50' : 'hover:bg-surface-700/50 text-gray-400'
}`}
} ${matchesSearch && !isDir ? 'bg-purple-500/10 border border-purple-500/20' : ''}`}
style={{ paddingLeft: `${indent + 8}px` }}
onClick={() => {
if (isDir) setOpen(!open);
@ -250,15 +272,17 @@ function TreeNode({ node, depth, onSelect }: { node: FileTreeNode; depth: number
{isDir ? (
<span className="text-yellow-400 w-4 text-center">{open ? '📂' : '📁'}</span>
) : (
<span className="text-gray-500 w-4 text-center">📄</span>
<span className={`w-4 text-center ${matchesSearch ? 'text-purple-400' : 'text-gray-500'}`}>{matchesSearch ? '🔍' : '📄'}</span>
)}
<span className={isDir ? 'text-white font-medium' : 'text-gray-400'}>{node.name}</span>
<span className={`${isDir ? 'text-white font-medium' : matchesSearch ? 'text-purple-300 font-medium' : 'text-gray-400'}`}>
{renderHighlightedName(node.name)}
</span>
{node.size != null && !isDir && (
<span className="text-gray-600 ml-auto text-[10px]">{formatSize(node.size)}</span>
)}
</div>
{isDir && open && node.children?.map(child => (
<TreeNode key={child.path} node={child} depth={depth + 1} onSelect={onSelect} />
<TreeNode key={child.path} node={child} depth={depth + 1} onSelect={onSelect} searchQuery={searchQuery} />
))}
</div>
);
@ -1298,6 +1322,7 @@ function FilesystemTab() {
const [editContent, setEditContent] = useState('');
const [saving, setSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
loadTree();
@ -1340,6 +1365,52 @@ function FilesystemTab() {
}
};
const clearSearch = () => setSearchQuery('');
// Filter tree based on search query
const getFilteredTree = useCallback((nodes: FileTreeNode[], query: string): FileTreeNode[] => {
if (!query.trim()) return nodes;
const lowerQuery = query.toLowerCase();
const filterNode = (node: FileTreeNode): FileTreeNode | null => {
const matchesName = node.name.toLowerCase().includes(lowerQuery);
if (node.type === 'directory' && node.children) {
const filteredChildren = node.children
.map(filterNode)
.filter((n): n is FileTreeNode => n !== null);
if (matchesName || filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
return null;
}
return matchesName ? node : null;
};
return nodes.map(filterNode).filter((n): n is FileTreeNode => n !== null);
}, []);
const filteredTree = searchQuery ? getFilteredTree(tree, searchQuery) : tree;
// Count matching files
const countMatches = useCallback((nodes: FileTreeNode[]): number => {
let count = 0;
const countNode = (node: FileTreeNode) => {
if (node.type === 'file' && node.name.toLowerCase().includes(searchQuery.toLowerCase())) {
count++;
}
if (node.children) {
node.children.forEach(countNode);
}
};
nodes.forEach(countNode);
return count;
}, [searchQuery]);
const matchCount = searchQuery ? countMatches(filteredTree) : 0;
const getExtensions = () => {
if (!selectedFile) return [];
const lang = selectedFile.language;
@ -1436,21 +1507,60 @@ function FilesystemTab() {
<div className="flex gap-6 h-[calc(100vh-250px)] min-h-[600px]">
{/* Sidebar Tree */}
<div className="glass-card w-1/3 p-4 flex flex-col border border-glass-border">
<div className="flex items-center justify-between mb-4 px-2">
<div className="flex items-center justify-between mb-3 px-2">
<h2 className="text-lg font-bold text-white flex items-center gap-2">
<span>📂</span> Arborescence
</h2>
<button onClick={loadTree} className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-surface-700 transition-colors" title="Actualiser">🔄</button>
</div>
{/* Search Bar */}
<div className="relative mb-3 px-2">
<div className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
</div>
<input
type="text"
placeholder="Rechercher des fichiers..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-surface-800/80 border border-surface-700 rounded-xl py-2 pl-9 pr-9 text-sm text-gray-300 placeholder-gray-600 focus:outline-none focus:border-purple-500/50 focus:ring-2 focus:ring-purple-500/20 transition-all"
/>
{searchQuery && (
<button
onClick={clearSearch}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors p-0.5 rounded"
title="Effacer la recherche"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
)}
</div>
{/* Search Results Counter */}
{searchQuery && (
<div className="px-2 mb-2">
<span className={`text-xs font-medium ${matchCount > 0 ? 'text-purple-400' : 'text-gray-500'}`}>
{matchCount > 0 ? `${matchCount} fichier${matchCount > 1 ? 's' : ''} trouvé${matchCount > 1 ? 's' : ''}` : 'Aucun fichier trouvé'}
</span>
</div>
)}
<div className="flex-1 overflow-y-auto font-mono text-[13px] pr-2 custom-scrollbar">
{treeLoading ? (
<div className="text-gray-500 animate-pulse px-2">Chargement de l'arborescence</div>
) : tree.length === 0 ? (
<p className="text-gray-500 px-2">Aucun fichier trouvé</p>
) : filteredTree.length === 0 ? (
<p className="text-gray-500 px-2">{searchQuery ? 'Aucun fichier correspondant' : 'Aucun fichier trouvé'}</p>
) : (
<div className="space-y-0.5">
{tree.map(node => (
<TreeNode key={node.path} node={node} depth={0} onSelect={handleSelectFile} />
{filteredTree.map(node => (
<TreeNode key={node.path} node={node} depth={0} onSelect={handleSelectFile} searchQuery={searchQuery} />
))}
</div>
)}