Skip to content

Commit 3452673

Browse files
committed
feat(repo): Add file search functionality to repository file tree
1 parent 0ce7d66 commit 3452673

File tree

2 files changed

+153
-3
lines changed

2 files changed

+153
-3
lines changed

templates/repo/view_file_tree.tmpl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,14 @@
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>
13+
1014
{{/* TODO: Dynamically move components such as refSelector and createPR here */}}
1115
<div id="view-file-tree" class="tw-overflow-auto tw-h-full is-loading"
1216
data-repo-link="{{.RepoLink}}"
1317
data-tree-path="{{$.TreePath}}"
1418
data-current-ref-name-sub-url="{{.RefTypeNameSubURL}}"
19+
data-tree-list-url="{{.RepoLink}}/tree-list/{{.RefTypeNameSubURL}}"
1520
></div>

web_src/js/components/ViewFileTree.vue

Lines changed: 148 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
<script lang="ts" setup>
22
import ViewFileTreeItem from './ViewFileTreeItem.vue';
3-
import {onMounted, useTemplateRef} from 'vue';
3+
import {onMounted, onUnmounted, useTemplateRef, ref, computed} from 'vue';
44
import {createViewFileTreeStore} from './ViewFileTreeStore.ts';
5+
import {GET} from '../modules/fetch.ts';
6+
import {filterRepoFilesWeighted} from '../features/repo-findfile.ts';
7+
import {pathEscapeSegments} from '../utils/url.ts';
8+
import {svg} from '../svg.ts';
59
610
const elRoot = useTemplateRef('elRoot');
11+
const searchQuery = ref('');
12+
const allFiles = ref<string[]>([]);
13+
const selectedIndex = ref(0);
714
815
const props = defineProps({
916
repoLink: {type: String, required: true},
@@ -12,19 +19,106 @@ const props = defineProps({
1219
});
1320
1421
const store = createViewFileTreeStore(props);
22+
23+
const filteredFiles = computed(() => {
24+
if (!searchQuery.value) return [];
25+
return filterRepoFilesWeighted(allFiles.value, searchQuery.value);
26+
});
27+
28+
const treeLink = computed(() => `${props.repoLink}/src/${props.currentRefNameSubURL}`);
29+
30+
let searchInputElement: HTMLInputElement | null = null;
31+
32+
const handleSearchInput = (e: Event) => {
33+
searchQuery.value = (e.target as HTMLInputElement).value;
34+
selectedIndex.value = 0;
35+
};
36+
37+
const handleKeyDown = (e: KeyboardEvent) => {
38+
if (!searchQuery.value || filteredFiles.value.length === 0) return;
39+
40+
if (e.key === 'ArrowDown') {
41+
e.preventDefault();
42+
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredFiles.value.length - 1);
43+
} else if (e.key === 'ArrowUp') {
44+
e.preventDefault();
45+
selectedIndex.value = Math.max(selectedIndex.value - 1, 0);
46+
} else if (e.key === 'Enter') {
47+
e.preventDefault();
48+
const selectedFile = filteredFiles.value[selectedIndex.value];
49+
if (selectedFile) {
50+
handleSearchResultClick(selectedFile.matchResult.join(''));
51+
}
52+
} else if (e.key === 'Escape') {
53+
searchQuery.value = '';
54+
if (searchInputElement) searchInputElement.value = '';
55+
}
56+
};
57+
1558
onMounted(async () => {
1659
store.rootFiles = await store.loadChildren('', props.treePath);
1760
elRoot.value.closest('.is-loading')?.classList?.remove('is-loading');
61+
62+
// Load all files for search
63+
const treeListUrl = elRoot.value.closest('#view-file-tree')?.getAttribute('data-tree-list-url');
64+
if (treeListUrl) {
65+
const response = await GET(treeListUrl);
66+
allFiles.value = await response.json();
67+
}
68+
69+
// Setup search input listener
70+
searchInputElement = document.querySelector('#file-tree-search');
71+
if (searchInputElement) {
72+
searchInputElement.addEventListener('input', handleSearchInput);
73+
searchInputElement.addEventListener('keydown', handleKeyDown);
74+
}
75+
1876
window.addEventListener('popstate', (e) => {
1977
store.selectedItem = e.state?.treePath || '';
2078
if (e.state?.url) store.loadViewContent(e.state.url);
2179
});
2280
});
81+
82+
onUnmounted(() => {
83+
if (searchInputElement) {
84+
searchInputElement.removeEventListener('input', handleSearchInput);
85+
searchInputElement.removeEventListener('keydown', handleKeyDown);
86+
}
87+
});
88+
89+
function handleSearchResultClick(filePath: string) {
90+
searchQuery.value = '';
91+
if (searchInputElement) searchInputElement.value = '';
92+
window.location.href = `${treeLink.value}/${pathEscapeSegments(filePath)}`;
93+
}
2394
</script>
2495

2596
<template>
26-
<div class="view-file-tree-items" ref="elRoot">
27-
<ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/>
97+
<div ref="elRoot">
98+
<div v-if="searchQuery && filteredFiles.length > 0" class="file-tree-search-results">
99+
<div
100+
v-for="(result, idx) in filteredFiles"
101+
:key="result.matchResult.join('')"
102+
:class="['file-tree-search-result-item', {'selected': idx === selectedIndex}]"
103+
@click="handleSearchResultClick(result.matchResult.join(''))"
104+
@mouseenter="selectedIndex = idx"
105+
>
106+
<svg class="svg octicon-file" width="16" height="16" aria-hidden="true"><use href="#octicon-file" /></svg>
107+
<span class="file-tree-search-result-path">
108+
<span
109+
v-for="(part, index) in result.matchResult"
110+
:key="index"
111+
:class="{'search-match': index % 2 === 1}"
112+
>{{ part }}</span>
113+
</span>
114+
</div>
115+
</div>
116+
<div v-else-if="searchQuery && filteredFiles.length === 0" class="file-tree-search-no-results">
117+
No matching file found
118+
</div>
119+
<div v-else class="view-file-tree-items">
120+
<ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/>
121+
</div>
28122
</div>
29123
</template>
30124

@@ -35,4 +129,55 @@ onMounted(async () => {
35129
gap: 1px;
36130
margin-right: .5rem;
37131
}
132+
133+
.file-tree-search-results {
134+
display: flex;
135+
flex-direction: column;
136+
margin: 0 0.5rem 0.5rem 0.5rem;
137+
max-height: 400px;
138+
overflow-y: auto;
139+
background: var(--color-box-body);
140+
border: 1px solid var(--color-secondary);
141+
border-radius: 6px;
142+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
143+
}
144+
145+
.file-tree-search-result-item {
146+
display: flex;
147+
align-items: center;
148+
gap: 0.5rem;
149+
padding: 0.5rem 0.75rem;
150+
cursor: pointer;
151+
transition: background-color 0.1s;
152+
border-bottom: 1px solid var(--color-secondary);
153+
}
154+
155+
.file-tree-search-result-item:last-child {
156+
border-bottom: none;
157+
}
158+
159+
.file-tree-search-result-item:hover,
160+
.file-tree-search-result-item.selected {
161+
background-color: var(--color-hover);
162+
}
163+
164+
.file-tree-search-result-path {
165+
flex: 1;
166+
overflow: hidden;
167+
text-overflow: ellipsis;
168+
white-space: nowrap;
169+
font-size: 14px;
170+
}
171+
172+
.search-match {
173+
color: var(--color-red);
174+
font-weight: 600;
175+
}
176+
177+
.file-tree-search-no-results {
178+
padding: 1rem;
179+
text-align: center;
180+
color: var(--color-text-light-2);
181+
font-size: 14px;
182+
}
38183
</style>

0 commit comments

Comments
 (0)