Skip to content

Commit 0721763

Browse files
committed
feat(repo): Add file search functionality to repository file tree
1 parent e31f224 commit 0721763

File tree

3 files changed

+171
-23
lines changed

3 files changed

+171
-23
lines changed

templates/repo/view_content.tmpl

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -42,26 +42,6 @@
4242
<a href="{{.Repository.Link}}/find/{{.RefTypeNameSubURL}}" class="ui compact basic button">{{ctx.Locale.Tr "repo.find_file.go_to_file"}}</a>
4343
{{end}}
4444

45-
{{if and .RefFullName.IsBranch (not .IsViewFile)}}
46-
<button class="ui dropdown basic compact jump button repo-add-file" {{if not .Repository.CanEnableEditor}}disabled{{end}}>
47-
{{ctx.Locale.Tr "repo.editor.add_file"}}
48-
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
49-
<div class="menu">
50-
<a class="item" href="{{.RepoLink}}/_new/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
51-
{{ctx.Locale.Tr "repo.editor.new_file"}}
52-
</a>
53-
{{if .RepositoryUploadEnabled}}
54-
<a class="item" href="{{.RepoLink}}/_upload/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
55-
{{ctx.Locale.Tr "repo.editor.upload_file"}}
56-
</a>
57-
{{end}}
58-
<a class="item" href="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
59-
{{ctx.Locale.Tr "repo.editor.patch"}}
60-
</a>
61-
</div>
62-
</button>
63-
{{end}}
64-
6545
{{if and $isTreePathRoot .Repository.IsTemplate}}
6646
<a role="button" class="ui primary compact button" href="{{AppSubUrl}}/repo/create?template_id={{.Repository.ID}}">
6747
{{ctx.Locale.Tr "repo.use_template"}}
@@ -86,6 +66,25 @@
8666
</div>
8767

8868
<div class="repo-button-row-right">
69+
{{if and .RefFullName.IsBranch (not .IsViewFile)}}
70+
<button class="ui dropdown basic compact jump button repo-add-file" {{if not .Repository.CanEnableEditor}}disabled{{end}}>
71+
{{ctx.Locale.Tr "repo.editor.add_file"}}
72+
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
73+
<div class="menu">
74+
<a class="item" href="{{.RepoLink}}/_new/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
75+
{{svg "octicon-file-added" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.editor.new_file"}}
76+
</a>
77+
{{if .RepositoryUploadEnabled}}
78+
<a class="item" href="{{.RepoLink}}/_upload/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
79+
{{svg "octicon-upload" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.editor.upload_file"}}
80+
</a>
81+
{{end}}
82+
<a class="item" href="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">
83+
{{svg "octicon-diff" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.editor.patch"}}
84+
</a>
85+
</div>
86+
</button>
87+
{{end}}
8988
<!-- Only show clone panel in repository home page -->
9089
{{if $isTreePathRoot}}
9190
{{template "repo/clone_panel" .}}

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: 147 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
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';
58
69
const elRoot = useTemplateRef('elRoot');
10+
const searchQuery = ref('');
11+
const allFiles = ref<string[]>([]);
12+
const selectedIndex = ref(0);
713
814
const props = defineProps({
915
repoLink: {type: String, required: true},
@@ -12,19 +18,106 @@ const props = defineProps({
1218
});
1319
1420
const store = createViewFileTreeStore(props);
21+
22+
const filteredFiles = computed(() => {
23+
if (!searchQuery.value) return [];
24+
return filterRepoFilesWeighted(allFiles.value, searchQuery.value);
25+
});
26+
27+
const treeLink = computed(() => `${props.repoLink}/src/${props.currentRefNameSubURL}`);
28+
29+
let searchInputElement: HTMLInputElement | null = null;
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 (!searchQuery.value || filteredFiles.value.length === 0) return;
38+
39+
if (e.key === 'ArrowDown') {
40+
e.preventDefault();
41+
selectedIndex.value = Math.min(selectedIndex.value + 1, filteredFiles.value.length - 1);
42+
} else if (e.key === 'ArrowUp') {
43+
e.preventDefault();
44+
selectedIndex.value = Math.max(selectedIndex.value - 1, 0);
45+
} else if (e.key === 'Enter') {
46+
e.preventDefault();
47+
const selectedFile = filteredFiles.value[selectedIndex.value];
48+
if (selectedFile) {
49+
handleSearchResultClick(selectedFile.matchResult.join(''));
50+
}
51+
} else if (e.key === 'Escape') {
52+
searchQuery.value = '';
53+
if (searchInputElement) searchInputElement.value = '';
54+
}
55+
};
56+
1557
onMounted(async () => {
1658
store.rootFiles = await store.loadChildren('', props.treePath);
1759
elRoot.value.closest('.is-loading')?.classList?.remove('is-loading');
60+
61+
// Load all files for search
62+
const treeListUrl = elRoot.value.closest('#view-file-tree')?.getAttribute('data-tree-list-url');
63+
if (treeListUrl) {
64+
const response = await GET(treeListUrl);
65+
allFiles.value = await response.json();
66+
}
67+
68+
// Setup search input listener
69+
searchInputElement = document.querySelector('#file-tree-search');
70+
if (searchInputElement) {
71+
searchInputElement.addEventListener('input', handleSearchInput);
72+
searchInputElement.addEventListener('keydown', handleKeyDown);
73+
}
74+
1875
window.addEventListener('popstate', (e) => {
1976
store.selectedItem = e.state?.treePath || '';
2077
if (e.state?.url) store.loadViewContent(e.state.url);
2178
});
2279
});
80+
81+
onUnmounted(() => {
82+
if (searchInputElement) {
83+
searchInputElement.removeEventListener('input', handleSearchInput);
84+
searchInputElement.removeEventListener('keydown', handleKeyDown);
85+
}
86+
});
87+
88+
function handleSearchResultClick(filePath: string) {
89+
searchQuery.value = '';
90+
if (searchInputElement) searchInputElement.value = '';
91+
window.location.href = `${treeLink.value}/${pathEscapeSegments(filePath)}`;
92+
}
2393
</script>
2494

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

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

0 commit comments

Comments
 (0)