@@ -38,12 +38,92 @@ const isCommitStateLoading = ref(false);
3838const commitState = ref <GithubCommitState | null >(null );
3939const currentBranch = ref (props .initialBranch );
4040const AUTO_EXPAND_SEARCH_THRESHOLD = 2 ;
41+ const isSearching = ref (false );
42+ const searchDebounceTimer = ref <number | null >(null );
43+ const filteredTreeData = ref <FileTreeNode | null >(props .treeData );
44+ const regexCache = ref <Map <string , RegExp | null >>(new Map ());
45+ const warnedPatterns = ref <Set <string >>(new Set ());
4146
4247// --- Helper Functions ---
48+
49+ const parseSearchPatterns = (query : string ): string [] => {
50+ return query .split (' ,' ).map (pattern => pattern .trim ()).filter (pattern => pattern .length > 0 );
51+ };
52+
53+ const isRegexPattern = (pattern : string ): boolean => {
54+ const regexMetacharacters = / [. +^${}()|[\]\\ ] / ;
55+ return regexMetacharacters .test (pattern ) || pattern .startsWith (' /' ) && pattern .endsWith (' /' );
56+ };
57+
58+ const isWildcardPattern = (pattern : string ): boolean => {
59+ return pattern .includes (' *' ) || pattern .includes (' ?' );
60+ };
61+
62+ const createRegexFromPattern = (pattern : string ): RegExp | null => {
63+ // Check cache first
64+ if (regexCache .value .has (pattern )) {
65+ return regexCache .value .get (pattern );
66+ }
67+
68+ try {
69+ let regex: RegExp | null = null ;
70+
71+ // Explicit regex patterns wrapped in forward slashes
72+ if (pattern .startsWith (' /' ) && pattern .endsWith (' /' )) {
73+ const regexPattern = pattern .slice (1 , - 1 );
74+ regex = new RegExp (regexPattern , ' i' );
75+ }
76+ // Wildcard patterns
77+ else if (isWildcardPattern (pattern )) {
78+ const escapedPattern = pattern
79+ .replace (/ [. +^${}()|[\]\\ ] / g , ' \\ $&' )
80+ .replace (/ \* / g , ' .*' )
81+ .replace (/ \? / g , ' .' );
82+ regex = new RegExp (` ^${escapedPattern }$ ` , ' i' );
83+ }
84+ // Explicit regex patterns (without slashes)
85+ else if (isRegexPattern (pattern )) {
86+ regex = new RegExp (pattern , ' i' );
87+ }
88+
89+ regexCache .value .set (pattern , regex );
90+ return regex ;
91+ } catch (error ) {
92+ if (! warnedPatterns .value .has (pattern )) {
93+ console .warn (` Invalid regex pattern: ${pattern } ` , error );
94+ warnedPatterns .value .add (pattern );
95+ }
96+
97+ regexCache .value .set (pattern , null ); // Prevent re-compute
98+ return null ;
99+ }
100+ };
101+
102+ const matchesPattern = (filename : string , pattern : string ): boolean => {
103+ const regex = createRegexFromPattern (pattern );
104+
105+ if (regex ) {
106+ return regex .test (filename );
107+ }
108+
109+ // Fallback to substring match
110+ return filename .toLowerCase ().includes (pattern .toLowerCase ());
111+ };
112+
113+ const matchesAnyPattern = (filename : string , patterns : string []): boolean => {
114+ return patterns .some (pattern => matchesPattern (filename , pattern ));
115+ };
116+
117+ const clearRegexCache = () => {
118+ regexCache .value .clear ();
119+ warnedPatterns .value .clear ();
120+ };
121+
122+
43123const getAllDescendantFiles = (node : FileTreeNode ): FileTreeNode [] => {
44124 if (node .type === ' file' ) {
45125 return [node ];
46- }
126+ }
47127 if (! node .children || node .children .length === 0 ) {
48128 return [];
49129 }
@@ -61,36 +141,6 @@ const getAllDirectoryPaths = (node: FileTreeNode): string[] => {
61141 return paths ;
62142};
63143
64- // --- Computed ---
65- const filteredTree = computed (() => {
66- if (! searchQuery .value ) return props .treeData ;
67-
68- const filterNodes = (node : FileTreeNode ): FileTreeNode | null => {
69- // If node matches search, include it and all its children
70- if (node .path .toLowerCase ().includes (searchQuery .value .toLowerCase ())) {
71- return { ... node };
72- }
73-
74- // If it's a directory with children, filter them
75- if (node .children && node .children .length > 0 ) {
76- const filteredChildren = node .children
77- .map (filterNodes )
78- .filter (Boolean ) as FileTreeNode [];
79-
80- if (filteredChildren .length > 0 ) {
81- return {
82- ... node ,
83- children: filteredChildren ,
84- };
85- }
86- }
87-
88- return null ;
89- };
90-
91- return filterNodes (props .treeData );
92- });
93-
94144const selectPreviewIcon = computed (() => {
95145 if (! selectPreview .value ) return ' MdiFileOutline' ;
96146 const fileIcon = getIconForFile (selectPreview .value .name );
@@ -185,6 +235,7 @@ const pullLatestChanges = async () => {
185235 ]);
186236
187237 if (fileTree && newBranches ) {
238+ filteredTreeData .value = fileTree ;
188239 const newRepoContent: RepoContent = {
189240 repo: props .repo ,
190241 currentBranch: currentBranch .value ,
@@ -226,7 +277,7 @@ watch(selectPreview, async (newPreview) => {
226277
227278watch (currentBranch , async (newBranch , oldBranch ) => {
228279 if (! newBranch || newBranch === oldBranch ) return ;
229- isPulling .value = true ; // Reuse pulling state for loading tree
280+ isPulling .value = true ;
230281 const [owner, repoName] = props .repo .full_name .split (' /' );
231282 try {
232283 const fileTree = await getRepoTree (
@@ -236,6 +287,7 @@ watch(currentBranch, async (newBranch, oldBranch) => {
236287 blockGithubSettings .value .autoPull ,
237288 );
238289 if (fileTree ) {
290+ filteredTreeData .value = fileTree ;
239291 const newRepoContent: RepoContent = {
240292 repo: props .repo ,
241293 currentBranch: newBranch ,
@@ -254,19 +306,57 @@ watch(currentBranch, async (newBranch, oldBranch) => {
254306});
255307
256308watch (searchQuery , (newQuery ) => {
257- if (newQuery .length > AUTO_EXPAND_SEARCH_THRESHOLD ) {
258- if (filteredTree .value ) {
259- const allVisibleDirPaths = getAllDirectoryPaths (filteredTree .value );
309+ if (searchDebounceTimer .value ) {
310+ clearTimeout (searchDebounceTimer .value );
311+ }
312+ isSearching .value = true ;
313+
314+ searchDebounceTimer .value = window .setTimeout (() => {
315+ if (! newQuery ) {
316+ filteredTreeData .value = props .treeData ;
317+ isSearching .value = false ;
318+ collapseAll ();
319+ clearRegexCache (); // Clear cache when search is cleared
320+ return ;
321+ }
322+
323+ const searchPatterns = parseSearchPatterns (newQuery );
324+
325+ const filterNodes = (node : FileTreeNode ): FileTreeNode | null => {
326+ if (matchesAnyPattern (node .path , searchPatterns )) {
327+ return { ... node };
328+ }
329+ if (node .children && node .children .length > 0 ) {
330+ const filteredChildren = node .children
331+ .map (filterNodes )
332+ .filter (Boolean ) as FileTreeNode [];
333+ if (filteredChildren .length > 0 ) {
334+ return { ... node , children: filteredChildren };
335+ }
336+ }
337+ return null ;
338+ };
339+
340+ filteredTreeData .value = filterNodes (props .treeData );
341+ isSearching .value = false ;
342+
343+ if (newQuery .length > AUTO_EXPAND_SEARCH_THRESHOLD && filteredTreeData .value ) {
344+ const allVisibleDirPaths = getAllDirectoryPaths (filteredTreeData .value );
260345 expandedPaths .value = new Set (allVisibleDirPaths );
261346 }
262- } else if (newQuery .length === 0 ) {
263- collapseAll ();
264- }
347+ }, 250 );
265348});
266349
267350onMounted (async () => {
268351 await getCommitState ();
269352});
353+
354+ onUnmounted (() => {
355+ if (searchDebounceTimer .value ) {
356+ clearTimeout (searchDebounceTimer .value );
357+ }
358+ clearRegexCache (); // Clear cache on component unmount
359+ });
270360 </script >
271361
272362<template >
@@ -302,7 +392,7 @@ onMounted(async () => {
302392 <input
303393 v-model =" searchQuery"
304394 type =" text"
305- placeholder =" Search files..."
395+ placeholder =" Search files... (wildcards: *.js, regex: /^test.*\.js$/i) "
306396 class =" bg-obsidian border-stone-gray/20 text-soft-silk focus:border-ember-glow w-full rounded-lg border
307397 px-10 py-2 focus:outline-none"
308398 />
@@ -379,15 +469,19 @@ onMounted(async () => {
379469 class =" bg-obsidian/50 border-stone-gray/20 dark-scrollbar flex-grow overflow-y-auto rounded-lg border"
380470 >
381471 <UiGraphNodeUtilsGithubFileTreeNode
382- v-if =" filteredTree "
383- :node =" filteredTree "
472+ v-if =" filteredTreeData "
473+ :node =" filteredTreeData "
384474 :level =" 0"
385475 :expanded-paths =" expandedPaths"
386476 :selected-paths =" Array.from(selectedPaths).map((node) => node.path)"
387477 @toggle-expand =" toggleExpand"
388478 @toggle-select =" toggleSelect"
389479 @toggle-select-preview =" (node) => (selectPreview = node)"
390480 />
481+ <!-- No results found -->
482+ <div v-else-if =" !isSearching && searchQuery" class =" p-4 text-center text-stone-gray/40" >
483+ No files found matching your search.
484+ </div >
391485 </div >
392486
393487 <!-- Actions -->
@@ -431,4 +525,4 @@ onMounted(async () => {
431525 <div class =" file-preview dark-scrollbar grow overflow-y-auto" v-html =" previewHtml" />
432526 </div >
433527 </div >
434- </template >
528+ </template >
0 commit comments