0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-02-09 09:35:23 +01:00
gitea/web_src/js/components/RepoFileSearch.vue
Hypo ef529de0ac
Prevent navigation keys from triggering actions during IME composition (#36540)
Fixes  #36532 

Refined the Enter key trigger logic in the repository filter to prevent
actions during IME composition.

By checking the e.isComposing property, the filter now correctly
distinguishes between "confirming an IME candidate" and "submitting the
search." This prevents premature search triggers when users press Enter
to select Chinese/Japanese characters.

---------

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2026-02-08 14:39:09 +08:00

233 lines
7.3 KiB
Vue

<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 },
placeholder: { type: String, required: true },
});
const refElemInput = useTemplateRef('searchInput') as Readonly<ShallowRef<HTMLInputElement>>;
const refElemPopup = useTemplateRef('searchPopup') as Readonly<ShallowRef<HTMLDivElement>>;
const searchQuery = ref('');
const allFiles = ref<string[]>([]);
const selectedIndex = ref(0);
const isLoadingFileList = ref(false);
const hasLoadedFileList = ref(false);
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 = () => {
loadFileListForSearch();
applySearchQuery();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.isComposing) return;
if (e.key === 'Escape') {
e.preventDefault();
clearSearch();
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;
try {
const response = await GET(props.treeListUrl);
allFiles.value = await response.json();
hasLoadedFileList.value = true;
} finally {
isLoadingFileList.value = false;
}
};
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(() => {
const searchPopupId = generateElemId('file-search-popup-');
refElemPopup.value.setAttribute('id', searchPopupId);
refElemInput.value.setAttribute('aria-controls', searchPopupId);
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">
<input
ref="searchInput" :placeholder="placeholder" autocomplete="off"
role="combobox" aria-autocomplete="list" :aria-expanded="searchQuery ? 'true' : 'false'"
@input="handleSearchInput" @keydown="handleKeyDown"
>
</div>
<Teleport to="body">
<div v-show="showPopup" 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('')"
: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">
<div class="is-loading"/>
</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(100vw - 20px), 300px);
max-width: min(calc(100vw - 40px), 600px);
overflow-y: auto;
}
.file-search-popup .is-loading {
width: 200px;
height: 200px;
}
.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>