229 lines
6.4 KiB
Vue
229 lines
6.4 KiB
Vue
<template>
|
|
<HoppSmartModal
|
|
v-if="show"
|
|
styles="sm:max-w-lg"
|
|
full-width
|
|
@close="emit('hide-modal')"
|
|
>
|
|
<template #body>
|
|
<div class="flex flex-col border-b transition border-dividerLight">
|
|
<div class="flex items-center p-6 space-x-2">
|
|
<input
|
|
id="command"
|
|
v-model="search"
|
|
v-focus
|
|
type="text"
|
|
autocomplete="off"
|
|
name="command"
|
|
:placeholder="`${t('app.type_a_command_search')}`"
|
|
class="flex flex-1 text-base bg-transparent text-secondaryDark"
|
|
/>
|
|
|
|
<icon-lucide-refresh-cw
|
|
v-if="searchSession?.loading"
|
|
class="animate-spin"
|
|
/>
|
|
</div>
|
|
<div
|
|
class="flex flex-shrink-0 text-tiny text-secondaryLight px-4 pb-4 justify-between whitespace-nowrap overflow-auto <sm:hidden"
|
|
>
|
|
<div class="flex items-center">
|
|
<kbd class="shortcut-key">↑</kbd>
|
|
<kbd class="shortcut-key">↓</kbd>
|
|
<span class="ml-2 truncate">
|
|
{{ t("action.to_navigate") }}
|
|
</span>
|
|
<kbd class="shortcut-key">↩</kbd>
|
|
<span class="ml-2 truncate">
|
|
{{ t("action.to_select") }}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center">
|
|
<kbd class="shortcut-key">ESC</kbd>
|
|
<span class="ml-2 truncate">
|
|
{{ t("action.to_close") }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="searchSession"
|
|
class="flex flex-col flex-1 overflow-auto space-y-4 divide-y divide-dividerLight"
|
|
>
|
|
<div
|
|
v-for="([sectionID, sectionResult], sectionIndex) in scoredResults"
|
|
:key="`section-${sectionID}`"
|
|
class="flex flex-col"
|
|
>
|
|
<h5 class="px-6 py-2 my-2 text-secondaryLight">
|
|
{{ sectionResult.title }}
|
|
</h5>
|
|
<AppSpotlightEntry
|
|
v-for="(result, entryIndex) in sectionResult.results"
|
|
:key="`result-${result.id}`"
|
|
:entry="result"
|
|
:active="isEqual(selectedEntry, [sectionIndex, entryIndex])"
|
|
@mouseover="selectedEntry = [sectionIndex, entryIndex]"
|
|
@action="runAction(sectionID, result)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<HoppSmartPlaceholder
|
|
v-if="scoredResults.length === 0 && search.length > 0"
|
|
:text="`${t('state.nothing_found')} ‟${search}”`"
|
|
>
|
|
<template #icon>
|
|
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
|
</template>
|
|
</HoppSmartPlaceholder>
|
|
</template>
|
|
</HoppSmartModal>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch } from "vue"
|
|
import { useService } from "dioc/vue"
|
|
import { useI18n } from "@composables/i18n"
|
|
import {
|
|
SpotlightService,
|
|
SpotlightSearchState,
|
|
SpotlightSearcherResult,
|
|
} from "~/services/spotlight"
|
|
import { isEqual } from "lodash-es"
|
|
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
|
|
import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher"
|
|
|
|
const t = useI18n()
|
|
|
|
const props = defineProps<{
|
|
show: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: "hide-modal"): void
|
|
}>()
|
|
|
|
const spotlightService = useService(SpotlightService)
|
|
|
|
useService(HistorySpotlightSearcherService)
|
|
useService(UserSpotlightSearcherService)
|
|
|
|
const search = ref("")
|
|
|
|
const searchSession = ref<SpotlightSearchState>()
|
|
const stopSearchSession = ref<() => void>()
|
|
|
|
const scoredResults = computed(() =>
|
|
Object.entries(searchSession.value?.results ?? {}).sort(
|
|
([, sectionA], [, sectionB]) => sectionB.avgScore - sectionA.avgScore
|
|
)
|
|
)
|
|
|
|
const { selectedEntry } = newUseArrowKeysForNavigation()
|
|
|
|
watch(
|
|
() => props.show,
|
|
(show) => {
|
|
search.value = ""
|
|
|
|
if (show) {
|
|
const [session, onSessionEnd] =
|
|
spotlightService.createSearchSession(search)
|
|
|
|
searchSession.value = session.value
|
|
stopSearchSession.value = onSessionEnd
|
|
} else {
|
|
stopSearchSession.value?.()
|
|
stopSearchSession.value = undefined
|
|
searchSession.value = undefined
|
|
}
|
|
}
|
|
)
|
|
|
|
function runAction(searcherID: string, result: SpotlightSearcherResult) {
|
|
spotlightService.selectSearchResult(searcherID, result)
|
|
emit("hide-modal")
|
|
}
|
|
|
|
function newUseArrowKeysForNavigation() {
|
|
const selectedEntry = ref<[number, number]>([0, 0]) // [sectionIndex, entryIndex]
|
|
|
|
watch(search, () => {
|
|
selectedEntry.value = [0, 0]
|
|
})
|
|
|
|
const onArrowDown = () => {
|
|
// If no entries, do nothing
|
|
if (scoredResults.value.length === 0) return
|
|
|
|
const [sectionIndex, entryIndex] = selectedEntry.value
|
|
|
|
const [, section] = scoredResults.value[sectionIndex]
|
|
|
|
if (entryIndex < section.results.length - 1) {
|
|
selectedEntry.value = [sectionIndex, entryIndex + 1]
|
|
} else if (sectionIndex < scoredResults.value.length - 1) {
|
|
selectedEntry.value = [sectionIndex + 1, 0]
|
|
} else {
|
|
selectedEntry.value = [0, 0]
|
|
}
|
|
}
|
|
|
|
const onArrowUp = () => {
|
|
// If no entries, do nothing
|
|
if (scoredResults.value.length === 0) return
|
|
|
|
const [sectionIndex, entryIndex] = selectedEntry.value
|
|
|
|
if (entryIndex > 0) {
|
|
selectedEntry.value = [sectionIndex, entryIndex - 1]
|
|
} else if (sectionIndex > 0) {
|
|
const [, section] = scoredResults.value[sectionIndex - 1]
|
|
selectedEntry.value = [sectionIndex - 1, section.results.length - 1]
|
|
} else {
|
|
selectedEntry.value = [
|
|
scoredResults.value.length - 1,
|
|
scoredResults.value[scoredResults.value.length - 1][1].results.length -
|
|
1,
|
|
]
|
|
}
|
|
}
|
|
|
|
function handleKeyPress(e: KeyboardEvent) {
|
|
if (e.key === "ArrowUp") {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
onArrowUp()
|
|
} else if (e.key === "ArrowDown") {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
onArrowDown()
|
|
} else if (e.key === "Enter") {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
const [sectionIndex, entryIndex] = selectedEntry.value
|
|
const [sectionID, section] = scoredResults.value[sectionIndex]
|
|
const result = section.results[entryIndex]
|
|
|
|
runAction(sectionID, result)
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => props.show,
|
|
(show) => {
|
|
if (show) {
|
|
window.addEventListener("keydown", handleKeyPress)
|
|
} else {
|
|
window.removeEventListener("keydown", handleKeyPress)
|
|
}
|
|
}
|
|
)
|
|
|
|
return { selectedEntry }
|
|
}
|
|
</script>
|