/
uzer_007
/
Rixea
Обзор
Документация
Войти
/
uzer_007
/
Rixea
Код
Запросы
0
Задачи
Вики
Пакеты
5
Релизы
2
CI/CD
Аналитика
Безопасность
master
web_src/js/components/RepoFileSearch.vue
275 строк
9 KB
uzer_007
feat: первая версия Rixea
02 авг 2026, 22:16
02 авг 2026, 22:16
d4f7504
Код
Авторство
О чём код?
<script lang="ts" setup> import {ref, computed, watch, nextTick, useTemplateRef, onMounted, onUnmounted, type ShallowRef} from 'vue'; import {generateElemId} from '../utils/dom.ts'; import {GET} from '../modules/fetch.ts'; import {filterRepoFilesWeighted} from '../features/repo-findfile.ts'; import {pathEscapeSegments} from '../utils/url.ts'; import {SvgIcon} from '../svg.ts'; import {throttle} from 'throttle-debounce'; const props = defineProps({ repoLink: { type: String, required: true }, currentRefNameSubURL: { type: String, required: true }, treeListUrl: { type: String, required: true }, noResultsText: { type: String, required: true }, loadErrorText: { type: String, required: true }, loadingText: { type: String, required: true }, placeholder: { type: String, required: true }, retryText: { type: String, required: true }, }); const refElemInput = useTemplateRef('searchInput') as Readonly<ShallowRef<HTMLInputElement>>; const refElemPopup = useTemplateRef('searchPopup') as Readonly<ShallowRef<HTMLDivElement>>; const refElemRetry = useTemplateRef('retryButton') as Readonly<ShallowRef<HTMLButtonElement | null>>; const searchQuery = ref(''); const allFiles = ref<string[]>([]); const selectedIndex = ref(0); const isLoadingFileList = ref(false); const hasLoadedFileList = ref(false); const hasFileListLoadError = ref(false); const popupId = generateElemId('file-search-popup-'); const showPopup = computed(() => searchQuery.value.length > 0); const filteredFiles = computed(() => { if (!searchQuery.value) return []; return filterRepoFilesWeighted(allFiles.value, searchQuery.value); }); const applySearchQuery = throttle(300, () => { searchQuery.value = refElemInput.value.value; selectedIndex.value = 0; }); const handleSearchInput = () => { if (!hasFileListLoadError.value) void loadFileListForSearch(); applySearchQuery(); }; const handleKeyDown = (e: KeyboardEvent) => { if (e.isComposing) return; if (e.key === 'Escape') { clearSearch(); nextTick(() => refElemInput.value.blur()); return; } if (!searchQuery.value || filteredFiles.value.length === 0) return; const handleSelectedItem = (idx: number) => { e.preventDefault(); selectedIndex.value = idx; const el = refElemPopup.value.querySelector(`.file-search-results > :nth-child(${idx+1} of .item)`); el?.scrollIntoView({ block: 'nearest', behavior: 'instant' }); }; if (e.key === 'ArrowDown') { handleSelectedItem(Math.min(selectedIndex.value + 1, filteredFiles.value.length - 1)); } else if (e.key === 'ArrowUp') { handleSelectedItem(Math.max(selectedIndex.value - 1, 0)) } else if (e.key === 'Enter') { e.preventDefault(); const selectedFile = filteredFiles.value[selectedIndex.value]; if (selectedFile) { handleSearchResultClick(selectedFile.matchResult.join('')); } } }; const clearSearch = () => { searchQuery.value = ''; refElemInput.value.value = ''; }; const handleClickOutside = (e: MouseEvent) => { if (!searchQuery.value) return; const target = e.target as HTMLElement; const clickInside = refElemInput.value.contains(target) || refElemPopup.value.contains(target); if (!clickInside) clearSearch(); }; const loadFileListForSearch = async () => { if (hasLoadedFileList.value || isLoadingFileList.value) return; isLoadingFileList.value = true; hasFileListLoadError.value = false; try { const response = await GET(props.treeListUrl); if (!response.ok) throw new Error(`Unexpected response status: ${response.status}`); const files: unknown = await response.json(); if (!Array.isArray(files) || !files.every((file) => typeof file === 'string')) { throw new TypeError('Unexpected file list response'); } allFiles.value = files; hasLoadedFileList.value = true; } catch { hasFileListLoadError.value = true; } finally { isLoadingFileList.value = false; } }; const retryFileListLoad = async () => { await loadFileListForSearch(); if (hasLoadedFileList.value) { await nextTick(); refElemInput.value.focus(); } else { await nextTick(); refElemRetry.value?.focus(); } }; function handleSearchResultClick(filePath: string) { clearSearch(); window.location.href = `${props.repoLink}/src/${pathEscapeSegments(props.currentRefNameSubURL)}/${pathEscapeSegments(filePath)}`; } const updatePosition = () => { if (!showPopup.value) return; const rectInput = refElemInput.value.getBoundingClientRect(); const rectPopup = refElemPopup.value.getBoundingClientRect(); const docElem = document.documentElement; const style = refElemPopup.value.style; style.top = `${docElem.scrollTop + rectInput.bottom + 4}px`; if (rectInput.x + rectPopup.width < docElem.clientWidth) { // enough space to align left with the input style.left = `${docElem.scrollLeft + rectInput.x}px`; } else { // no enough space, align right from the viewport right edge minus page margin const leftPos = docElem.scrollLeft + docElem.getBoundingClientRect().width - rectPopup.width; style.left = `calc(${leftPos}px - var(--page-margin-x))`; } }; onMounted(() => { document.addEventListener('click', handleClickOutside); window.addEventListener('resize', updatePosition); }); onUnmounted(() => { document.removeEventListener('click', handleClickOutside); window.removeEventListener('resize', updatePosition); }); // Position search results below the input watch([searchQuery, filteredFiles], async () => { if (searchQuery.value) { await nextTick(); updatePosition(); } }); </script> <template> <div> <div class="ui small input global-shortcut-wrapper"> <input ref="searchInput" :placeholder="placeholder" autocomplete="off" role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'" :aria-controls="popupId" :aria-busy="isLoadingFileList ? 'true' : 'false'" :aria-activedescendant="filteredFiles.length ? `${popupId}-option-${selectedIndex}` : undefined" @input="handleSearchInput" @keydown="handleKeyDown" > <kbd data-global-init="onGlobalShortcut" data-shortcut-keys="t">T</kbd> </div> <Teleport to="body"> <div v-show="showPopup" :id="popupId" ref="searchPopup" class="file-search-popup"> <!-- always create the popup by v-show above to avoid null ref, only create the popup content if the popup should be displayed to save memory --> <template v-if="showPopup"> <div v-if="filteredFiles.length" role="listbox" class="file-search-results flex-items-block"> <div v-for="(result, idx) in filteredFiles" :key="result.matchResult.join('')" :id="`${popupId}-option-${idx}`" :class="['item', { 'selected': idx === selectedIndex }]" role="option" :aria-selected="idx === selectedIndex" @click="handleSearchResultClick(result.matchResult.join(''))" @mouseenter="selectedIndex = idx" :title="result.matchResult.join('')" > <SvgIcon name="octicon-file" class="file-icon"/> <span class="full-path"> <span v-for="(part, index) in result.matchResult" :key="index">{{ part }}</span> </span> </div> </div> <div v-else-if="isLoadingFileList" class="file-search-state" role="status" aria-live="polite"> <div class="is-loading"/> <span>{{ props.loadingText }}</span> </div> <div v-else-if="hasFileListLoadError" class="file-search-state tw-p-4" role="alert"> <span>{{ props.loadErrorText }}</span> <button ref="retryButton" type="button" class="ui compact basic button" @click.stop="retryFileListLoad"> {{ props.retryText }} </button> </div> <div v-else class="tw-p-4"> {{ props.noResultsText }} </div> </template> </div> </Teleport> </div> </template> <style scoped> .file-search-popup { position: absolute; background: var(--color-box-body); border: 1px solid var(--color-secondary); border-radius: var(--border-radius); width: max-content; max-height: min(calc(100vh - 5rem), 300px); max-width: min(calc(100vw - 40px), 600px); overflow-y: auto; } .file-search-popup .is-loading { width: 200px; height: 200px; } .file-search-state { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; text-align: center; } .file-search-results .item { align-items: flex-start; padding: 0.5rem 0.75rem; cursor: pointer; border-bottom: 1px solid var(--color-secondary); } .file-search-results .item:last-child { border-bottom: none; } .file-search-results .item:hover, .file-search-results .item.selected { background-color: var(--color-hover); } .file-search-results .item .file-icon { flex-shrink: 0; margin-top: 0.125rem; } .file-search-results .item .full-path { flex: 1; overflow-wrap: anywhere; } .file-search-results .item .full-path :nth-child(even) { color: var(--color-red); font-weight: var(--font-weight-semibold); } </style>