Skip to content

Commit d240218

Browse files
Merge pull request #204 from dandan2611/feat/github-search-wildcards
feat: enhance file search functionality with wildcard and regex support
2 parents 74cba7e + 4e329fc commit d240218

File tree

1 file changed

+136
-42
lines changed

1 file changed

+136
-42
lines changed

ui/app/components/ui/graph/node/utils/github/fileTreeSelector.vue

Lines changed: 136 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,92 @@ const isCommitStateLoading = ref(false);
3838
const commitState = ref<GithubCommitState | null>(null);
3939
const currentBranch = ref(props.initialBranch);
4040
const 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+
43123
const 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-
94144
const 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
227278
watch(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
256308
watch(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
267350
onMounted(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

Comments
 (0)