Skip to content

Commit 6e4312f

Browse files
committed
refactor: Extract file search logic and UI from ViewFileTree into a new component.
1 parent d23fff3 commit 6e4312f

File tree

4 files changed

+285
-246
lines changed

4 files changed

+285
-246
lines changed

templates/repo/view_file_tree.tmpl

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,17 @@
77
<b>{{ctx.Locale.Tr "files"}}</b>
88
</div>
99

10-
<div class="ui small input tw-w-full tw-px-2 tw-pb-2">
11-
<input id="file-tree-search" type="text" placeholder="{{ctx.Locale.Tr "repo.find_file.go_to_file"}}" autocomplete="off">
12-
</div>
10+
<div id="file-tree-search-container"
11+
data-repo-link="{{.RepoLink}}"
12+
data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}"
13+
data-tree-list-url="{{.RepoLink}}/tree-list/{{.RefTypeNameSubURL}}"
14+
data-no-results-text="{{ctx.Locale.Tr "repo.find_file.no_matching"}}"
15+
data-placeholder="{{ctx.Locale.Tr "repo.find_file.go_to_file"}}"
16+
></div>
1317

1418
{{/* TODO: Dynamically move components such as refSelector and createPR here */}}
1519
<div id="view-file-tree" class="tw-overflow-y-auto tw-overflow-x-visible tw-h-full is-loading"
1620
data-repo-link="{{.RepoLink}}"
1721
data-tree-path="{{$.TreePath}}"
1822
data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}"
19-
data-tree-list-url="{{.RepoLink}}/tree-list/{{.RefTypeNameSubURL}}"
20-
data-no-results-text="{{ctx.Locale.Tr "repo.find_file.no_matching"}}"
2123
></div>
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
<script lang="ts" setup>
2+
import {ref, computed, watch, nextTick, useTemplateRef, onMounted, onUnmounted} from 'vue';
3+
import {GET} from '../modules/fetch.ts';
4+
import {filterRepoFilesWeighted} from '../features/repo-findfile.ts';
5+
import {pathEscapeSegments} from '../utils/url.ts';
6+
import {svg} from '../svg.ts';
7+
8+
const searchInput = useTemplateRef('searchInput');
9+
const searchResults = useTemplateRef('searchResults');
10+
const searchQuery = ref('');
11+
const allFiles = ref<string[]>([]);
12+
const selectedIndex = ref(0);
13+
const isLoadingFileList = ref(false);
14+
const hasLoadedFileList = ref(false);
15+
16+
const props = defineProps({
17+
repoLink: {type: String, required: true},
18+
currentRefNameSubURL: {type: String, required: true},
19+
treeListUrl: {type: String, required: true},
20+
noResultsText: {type: String, required: true},
21+
placeholder: {type: String, required: true},
22+
});
23+
24+
const filteredFiles = computed(() => {
25+
if (!searchQuery.value) return [];
26+
return filterRepoFilesWeighted(allFiles.value, searchQuery.value);
27+
});
28+
29+
const treeLink = computed(() => `${props.repoLink}/src/${pathEscapeSegments(props.currentRefNameSubURL)}`);
30+
31+
const handleSearchInput = (e: Event) => {
32+
searchQuery.value = (e.target as HTMLInputElement).value;
33+
selectedIndex.value = 0;
34+
};
35+
36+
const handleKeyDown = (e: KeyboardEvent) => {
37+
if (e.key === 'Escape' && searchQuery.value) {
38+
e.preventDefault();
39+
clearSearch();
40+
return;
41+
}
42+
43+
if (!searchQuery.value || filteredFiles.value.length === 0) return;
44+
45+
if (e.key === 'ArrowDown') {
46+
e.preventDefault();
47+
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredFiles.value.length - 1);
48+
scrollSelectedIntoView();
49+
} else if (e.key === 'ArrowUp') {
50+
e.preventDefault();
51+
selectedIndex.value = Math.max(selectedIndex.value - 1, 0);
52+
scrollSelectedIntoView();
53+
} else if (e.key === 'Enter') {
54+
e.preventDefault();
55+
const selectedFile = filteredFiles.value[selectedIndex.value];
56+
if (selectedFile) {
57+
handleSearchResultClick(selectedFile.matchResult.join(''));
58+
}
59+
}
60+
};
61+
62+
const clearSearch = () => {
63+
searchQuery.value = '';
64+
if (searchInput.value) searchInput.value.value = '';
65+
};
66+
67+
const scrollSelectedIntoView = () => {
68+
nextTick(() => {
69+
const resultsEl = searchResults.value;
70+
if (!resultsEl) return;
71+
72+
const selectedEl = resultsEl.querySelector('.file-tree-search-result-item.selected');
73+
if (selectedEl) {
74+
selectedEl.scrollIntoView({block: 'nearest', behavior: 'smooth'});
75+
}
76+
});
77+
};
78+
79+
const handleClickOutside = (e: MouseEvent) => {
80+
if (!searchQuery.value) return;
81+
82+
const target = e.target as HTMLElement;
83+
const resultsEl = searchResults.value;
84+
const inputEl = searchInput.value;
85+
86+
if (inputEl && !inputEl.contains(target) &&
87+
resultsEl && !resultsEl.contains(target)) {
88+
clearSearch();
89+
}
90+
};
91+
92+
const loadFileListForSearch = async () => {
93+
if (hasLoadedFileList.value || isLoadingFileList.value) return;
94+
95+
isLoadingFileList.value = true;
96+
try {
97+
const response = await GET(props.treeListUrl);
98+
allFiles.value = await response.json();
99+
hasLoadedFileList.value = true;
100+
} finally {
101+
isLoadingFileList.value = false;
102+
}
103+
};
104+
105+
const handleSearchFocus = () => {
106+
loadFileListForSearch();
107+
};
108+
109+
function handleSearchResultClick(filePath: string) {
110+
clearSearch();
111+
window.location.href = `${treeLink.value}/${pathEscapeSegments(filePath)}`;
112+
}
113+
114+
onMounted(() => {
115+
document.addEventListener('click', handleClickOutside);
116+
});
117+
118+
onUnmounted(() => {
119+
document.removeEventListener('click', handleClickOutside);
120+
});
121+
122+
// Position search results below the input
123+
watch(searchQuery, async () => {
124+
if (searchQuery.value && searchInput.value) {
125+
await nextTick();
126+
const resultsEl = searchResults.value;
127+
if (resultsEl) {
128+
const rect = searchInput.value.getBoundingClientRect();
129+
resultsEl.style.top = `${rect.bottom + 4}px`;
130+
resultsEl.style.left = `${rect.left}px`;
131+
}
132+
}
133+
});
134+
</script>
135+
136+
<template>
137+
<div class="repo-file-search">
138+
<div class="ui small input tw-w-full tw-px-2 tw-pb-2">
139+
<input
140+
ref="searchInput"
141+
type="text"
142+
:placeholder="placeholder"
143+
autocomplete="off"
144+
@input="handleSearchInput"
145+
@keydown="handleKeyDown"
146+
@focus="handleSearchFocus"
147+
>
148+
</div>
149+
150+
<Teleport to="body">
151+
<div v-if="searchQuery && filteredFiles.length > 0" ref="searchResults" class="file-tree-search-results">
152+
<div
153+
v-for="(result, idx) in filteredFiles"
154+
:key="result.matchResult.join('')"
155+
:class="['file-tree-search-result-item', {'selected': idx === selectedIndex}]"
156+
@click="handleSearchResultClick(result.matchResult.join(''))"
157+
@mouseenter="selectedIndex = idx"
158+
:title="result.matchResult.join('')"
159+
>
160+
<!-- eslint-disable-next-line vue/no-v-html -->
161+
<span v-html="svg('octicon-file', 16)"/>
162+
<span class="file-tree-search-result-path">
163+
<span
164+
v-for="(part, index) in result.matchResult"
165+
:key="index"
166+
:class="{'search-match': index % 2 === 1}"
167+
>{{ part }}</span>
168+
</span>
169+
</div>
170+
</div>
171+
<div v-if="searchQuery && filteredFiles.length === 0" ref="searchResults" class="file-tree-search-results file-tree-search-no-results">
172+
<div class="file-tree-no-results-content">
173+
<!-- eslint-disable-next-line vue/no-v-html -->
174+
<span v-html="svg('octicon-search', 24)"/>
175+
<span>{{ props.noResultsText }}</span>
176+
</div>
177+
</div>
178+
</Teleport>
179+
</div>
180+
</template>
181+
182+
<style scoped>
183+
.repo-file-search {
184+
position: relative;
185+
}
186+
187+
.file-tree-search-results {
188+
position: fixed;
189+
display: flex;
190+
flex-direction: column;
191+
max-height: 400px;
192+
overflow-y: auto;
193+
background: var(--color-box-body);
194+
border: 1px solid var(--color-secondary);
195+
border-radius: 6px;
196+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
197+
min-width: 300px;
198+
width: max-content;
199+
max-width: 600px;
200+
z-index: 99999;
201+
}
202+
203+
.file-tree-search-result-item {
204+
display: flex;
205+
align-items: flex-start;
206+
gap: 0.5rem;
207+
padding: 0.5rem 0.75rem;
208+
cursor: pointer;
209+
transition: background-color 0.1s;
210+
border-bottom: 1px solid var(--color-secondary);
211+
}
212+
213+
.file-tree-search-result-item > span:first-child {
214+
flex-shrink: 0;
215+
margin-top: 0.125rem;
216+
}
217+
218+
.file-tree-search-result-item:last-child {
219+
border-bottom: none;
220+
}
221+
222+
.file-tree-search-result-item:hover,
223+
.file-tree-search-result-item.selected {
224+
background-color: var(--color-hover);
225+
}
226+
227+
.file-tree-search-result-path {
228+
flex: 1;
229+
font-size: 14px;
230+
word-break: break-all;
231+
overflow-wrap: break-word;
232+
}
233+
234+
.search-match {
235+
color: var(--color-red);
236+
font-weight: var(--font-weight-semibold);
237+
}
238+
239+
.file-tree-search-no-results {
240+
padding: 0;
241+
}
242+
243+
.file-tree-no-results-content {
244+
display: flex;
245+
flex-direction: column;
246+
align-items: center;
247+
gap: 0.5rem;
248+
padding: 1.5rem;
249+
color: var(--color-text-light-2);
250+
font-size: 14px;
251+
}
252+
253+
.file-tree-no-results-content > span:first-child {
254+
opacity: 0.5;
255+
}
256+
</style>

0 commit comments

Comments
 (0)