|
| 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