feat: initial reworked spotlight implementation
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
"cancel": "Cancel",
|
||||
"choose_file": "Choose a file",
|
||||
"clear": "Clear",
|
||||
"clear_history": "Clear All History",
|
||||
"clear_all": "Clear all",
|
||||
"close": "Close",
|
||||
"connect": "Connect",
|
||||
|
||||
@@ -18,13 +18,14 @@ declare module '@vue/runtime-core' {
|
||||
AppLogo: typeof import('./components/app/Logo.vue')['default']
|
||||
AppOptions: typeof import('./components/app/Options.vue')['default']
|
||||
AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default']
|
||||
AppPowerSearch: typeof import('./components/app/PowerSearch.vue')['default']
|
||||
AppPowerSearchEntry: typeof import('./components/app/PowerSearchEntry.vue')['default']
|
||||
AppShare: typeof import('./components/app/Share.vue')['default']
|
||||
AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default']
|
||||
AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default']
|
||||
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
|
||||
AppSidenav: typeof import('./components/app/Sidenav.vue')['default']
|
||||
AppSpotlight: typeof import('./components/app/spotlight/index.vue')['default']
|
||||
AppSpotlightEntry: typeof import('./components/app/spotlight/Entry.vue')['default']
|
||||
AppSpotlightEntryHistory: typeof import('./components/app/spotlight/entry/History.vue')['default']
|
||||
AppSupport: typeof import('./components/app/Support.vue')['default']
|
||||
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
|
||||
ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default']
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<div key="outputHash" class="flex flex-col flex-1 overflow-auto">
|
||||
<div class="flex flex-col">
|
||||
<AppPowerSearchEntry
|
||||
v-for="(shortcut, shortcutIndex) in searchResults"
|
||||
:key="`shortcut-${shortcutIndex}`"
|
||||
:active="shortcutIndex === selectedEntry"
|
||||
:shortcut="shortcut.item"
|
||||
@action="emit('action', shortcut.item.action)"
|
||||
@mouseover="selectedEntry = shortcutIndex"
|
||||
/>
|
||||
</div>
|
||||
<HoppSmartPlaceholder
|
||||
v-if="searchResults.length === 0"
|
||||
:text="`${t('state.nothing_found')} ‟${search}”`"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||
</template>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, onMounted } from "vue"
|
||||
import Fuse from "fuse.js"
|
||||
import { useArrowKeysNavigation } from "~/helpers/powerSearchNavigation"
|
||||
import { HoppAction } from "~/helpers/actions"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
input: Record<string, any>[]
|
||||
search: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action", action: HoppAction): void
|
||||
}>()
|
||||
|
||||
const options = {
|
||||
keys: ["keys", "label", "action", "tags"],
|
||||
}
|
||||
|
||||
const fuse = new Fuse(props.input, options)
|
||||
|
||||
const searchResults = computed(() => fuse.search(props.search))
|
||||
|
||||
const searchResultsItems = computed(() =>
|
||||
searchResults.value.map((searchResult) => searchResult.item)
|
||||
)
|
||||
|
||||
const emitSearchAction = (action: HoppAction) => emit("action", action)
|
||||
|
||||
const { bindArrowKeysListeners, unbindArrowKeysListeners, selectedEntry } =
|
||||
useArrowKeysNavigation(searchResultsItems, {
|
||||
onEnter: emitSearchAction,
|
||||
stopPropagation: true,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
bindArrowKeysListeners()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unbindArrowKeysListeners()
|
||||
})
|
||||
</script>
|
||||
@@ -1,122 +0,0 @@
|
||||
<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">
|
||||
<input
|
||||
id="command"
|
||||
v-model="search"
|
||||
v-focus
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
name="command"
|
||||
:placeholder="`${t('app.type_a_command_search')}`"
|
||||
class="flex flex-shrink-0 p-6 text-base bg-transparent text-secondaryDark"
|
||||
/>
|
||||
<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>
|
||||
<AppFuse
|
||||
v-if="search && show"
|
||||
:input="fuse"
|
||||
:search="search"
|
||||
@action="runAction"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col flex-1 overflow-auto space-y-4 divide-y divide-dividerLight"
|
||||
>
|
||||
<div
|
||||
v-for="(map, mapIndex) in mappings"
|
||||
:key="`map-${mapIndex}`"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<h5 class="px-6 py-2 my-2 text-secondaryLight">
|
||||
{{ t(map.section) }}
|
||||
</h5>
|
||||
<AppPowerSearchEntry
|
||||
v-for="(shortcut, shortcutIndex) in map.shortcuts"
|
||||
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
|
||||
:shortcut="shortcut"
|
||||
:active="shortcutsItems.indexOf(shortcut) === selectedEntry"
|
||||
@action="runAction"
|
||||
@mouseover="selectedEntry = shortcutsItems.indexOf(shortcut)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue"
|
||||
import { HoppAction, invokeAction } from "~/helpers/actions"
|
||||
import { spotlight as mappings, fuse } from "@helpers/shortcuts"
|
||||
import { useArrowKeysNavigation } from "~/helpers/powerSearchNavigation"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const search = ref("")
|
||||
|
||||
const hideModal = () => {
|
||||
search.value = ""
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
const runAction = (command: HoppAction) => {
|
||||
invokeAction(command)
|
||||
hideModal()
|
||||
}
|
||||
|
||||
const shortcutsItems = computed(() =>
|
||||
mappings.reduce(
|
||||
(shortcuts, section) => [...shortcuts, ...section.shortcuts],
|
||||
[]
|
||||
)
|
||||
)
|
||||
|
||||
const { bindArrowKeysListeners, unbindArrowKeysListeners, selectedEntry } =
|
||||
useArrowKeysNavigation(shortcutsItems, {
|
||||
onEnter: runAction,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) bindArrowKeysListeners()
|
||||
else unbindArrowKeysListeners()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
class="flex items-center flex-1 px-6 py-3 font-medium transition cursor-pointer search-entry focus:outline-none"
|
||||
:class="{ active: active }"
|
||||
tabindex="-1"
|
||||
@click="emit('action', shortcut.action)"
|
||||
@keydown.enter="emit('action', shortcut.action)"
|
||||
>
|
||||
<component
|
||||
:is="shortcut.icon"
|
||||
class="mr-4 transition opacity-50 svg-icons"
|
||||
:class="{ 'opacity-100 text-secondaryDark': active }"
|
||||
/>
|
||||
<span
|
||||
class="flex flex-1 mr-4 transition"
|
||||
:class="{ 'text-secondaryDark': active }"
|
||||
>
|
||||
{{ t(shortcut.label) }}
|
||||
</span>
|
||||
<kbd
|
||||
v-for="(key, keyIndex) in shortcut.keys"
|
||||
:key="`key-${String(keyIndex)}`"
|
||||
class="shortcut-key"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Component } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
defineProps<{
|
||||
shortcut: {
|
||||
label: string
|
||||
keys: string[]
|
||||
action: string
|
||||
icon: object | Component
|
||||
}
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action", action: string): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-entry {
|
||||
@apply relative;
|
||||
@apply after:absolute;
|
||||
@apply after:top-0;
|
||||
@apply after:left-0;
|
||||
@apply after:bottom-0;
|
||||
@apply after:bg-transparent;
|
||||
@apply after:z-2;
|
||||
@apply after:w-0.5;
|
||||
@apply after:content-DEFAULT;
|
||||
|
||||
&.active {
|
||||
@apply bg-primaryLight;
|
||||
@apply after:bg-accentLight;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -48,7 +48,7 @@
|
||||
{{ t("state.nothing_found") }}
|
||||
<span class="break-all">"{{ filterText }}"</span>
|
||||
</span>
|
||||
</div>
|
||||
</HoppSmartPlaceholder>
|
||||
</div>
|
||||
<div v-else class="flex flex-col divide-y divide-dividerLight">
|
||||
<details
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<button
|
||||
ref="el"
|
||||
class="flex items-center flex-1 px-6 py-3 font-medium transition cursor-pointer search-entry focus:outline-none"
|
||||
:class="{ active: active }"
|
||||
tabindex="-1"
|
||||
@click="emit('action')"
|
||||
@keydown.enter="emit('action')"
|
||||
>
|
||||
<component
|
||||
:is="entry.icon"
|
||||
class="mr-4 transition opacity-50 svg-icons"
|
||||
:class="{ 'opacity-100 text-secondaryDark': active }"
|
||||
/>
|
||||
<span
|
||||
v-if="entry.text.type === 'text' && typeof entry.text.text === 'string'"
|
||||
class="flex flex-1 mr-4 transition"
|
||||
:class="{ 'text-secondaryDark': active }"
|
||||
>
|
||||
{{ entry.text.text }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="entry.text.type === 'text' && Array.isArray(entry.text.text)"
|
||||
class="flex flex-1 mr-4 transition"
|
||||
:class="{ 'text-secondaryDark': active }"
|
||||
>
|
||||
<span
|
||||
v-for="(labelPart, labelPartIndex) in entry.text.text"
|
||||
:key="`label-${labelPart}-${labelPartIndex}`"
|
||||
>
|
||||
{{ labelPart }}
|
||||
|
||||
<icon-lucide-chevron-right
|
||||
v-if="labelPartIndex < entry.text.text.length - 1"
|
||||
class="inline"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else-if="entry.text.type === 'custom'">
|
||||
<component
|
||||
:is="entry.text.component"
|
||||
v-bind="entry.text.componentProps"
|
||||
/>
|
||||
</span>
|
||||
<span v-if="formattedShortcutKeys">
|
||||
<kbd
|
||||
v-for="(key, keyIndex) in formattedShortcutKeys"
|
||||
:key="`key-${String(keyIndex)}`"
|
||||
class="shortcut-key"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { getPlatformSpecialKey } from "~/helpers/platformutils"
|
||||
import { SpotlightSearcherResult } from "~/services/spotlight"
|
||||
|
||||
const SPECIAL_KEY_CHARS: Record<string, string> = {
|
||||
ctrl: getPlatformSpecialKey(),
|
||||
alt: getPlatformAlternateKey(),
|
||||
up: "↑",
|
||||
down: "↓",
|
||||
enter: "↩",
|
||||
}
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, ref } from "vue"
|
||||
import { capitalize } from "lodash-es"
|
||||
import { getPlatformAlternateKey } from "~/helpers/platformutils"
|
||||
|
||||
const el = ref<HTMLElement>()
|
||||
|
||||
const props = defineProps<{
|
||||
entry: SpotlightSearcherResult
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const formattedShortcutKeys = computed(() =>
|
||||
props.entry.meta?.keyboardShortcut?.map((key) => {
|
||||
return SPECIAL_KEY_CHARS[key] ?? capitalize(key)
|
||||
})
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action"): void
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(active) => {
|
||||
if (active) {
|
||||
el.value?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.search-entry {
|
||||
@apply relative;
|
||||
@apply after:absolute;
|
||||
@apply after:top-0;
|
||||
@apply after:left-0;
|
||||
@apply after:bottom-0;
|
||||
@apply after:bg-transparent;
|
||||
@apply after:z-2;
|
||||
@apply after:w-0.5;
|
||||
@apply after:content-DEFAULT;
|
||||
|
||||
&.active {
|
||||
@apply bg-primaryLight;
|
||||
@apply after:bg-accentLight;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<span class="flex flex-row space-x-2">
|
||||
<span>{{ dateTimeText }}</span>
|
||||
<icon-lucide-chevron-right class="inline" />
|
||||
<span class="truncate" :class="entryStatus.className">
|
||||
<span class="font-semibold truncate text-tiny">
|
||||
{{ historyEntry.request.method }}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
{{ historyEntry.request.endpoint }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import findStatusGroup from "~/helpers/findStatusGroup"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
import { RESTHistoryEntry } from "~/newstore/history"
|
||||
|
||||
const props = defineProps<{
|
||||
historyEntry: RESTHistoryEntry
|
||||
}>()
|
||||
|
||||
const dateTimeText = computed(() =>
|
||||
shortDateTime(props.historyEntry.updatedOn!)
|
||||
)
|
||||
|
||||
const entryStatus = computed(() => {
|
||||
const foundStatusGroup = findStatusGroup(
|
||||
props.historyEntry.responseMeta.statusCode
|
||||
)
|
||||
return (
|
||||
foundStatusGroup || {
|
||||
className: "",
|
||||
}
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,205 @@
|
||||
<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">
|
||||
<input
|
||||
id="command"
|
||||
v-model="search"
|
||||
v-focus
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
name="command"
|
||||
:placeholder="`${t('app.type_a_command_search')}`"
|
||||
class="flex flex-shrink-0 p-6 text-base bg-transparent text-secondaryDark"
|
||||
/>
|
||||
<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>
|
||||
</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"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const spotlightService = useService(SpotlightService)
|
||||
|
||||
useService(HistorySpotlightSearcherService)
|
||||
|
||||
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 = () => {
|
||||
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 = () => {
|
||||
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>
|
||||
@@ -1,55 +0,0 @@
|
||||
import { ref } from "vue"
|
||||
|
||||
const NAVIGATION_KEYS = ["ArrowDown", "ArrowUp", "Enter"]
|
||||
|
||||
export function useArrowKeysNavigation(searchItems: any, options: any = {}) {
|
||||
function handleArrowKeysNavigation(
|
||||
event: any,
|
||||
itemIndex: any,
|
||||
preventPropagation: boolean
|
||||
) {
|
||||
if (!NAVIGATION_KEYS.includes(event.key)) return
|
||||
|
||||
if (preventPropagation) event.stopImmediatePropagation()
|
||||
|
||||
const itemsLength = searchItems.value.length
|
||||
const lastItemIndex = itemsLength - 1
|
||||
const itemIndexValue = itemIndex.value
|
||||
const action = searchItems.value[itemIndexValue]?.action
|
||||
|
||||
if (action && event.key === "Enter" && options.onEnter) {
|
||||
options.onEnter(action)
|
||||
return
|
||||
}
|
||||
|
||||
if (itemsLength && event.key === "ArrowDown") {
|
||||
itemIndex.value = itemIndexValue < lastItemIndex ? itemIndexValue + 1 : 0
|
||||
} else if (itemIndexValue === 0) itemIndex.value = lastItemIndex
|
||||
else if (itemsLength && event.key === "ArrowUp")
|
||||
itemIndex.value = itemIndexValue - 1
|
||||
}
|
||||
|
||||
const preventPropagation = options && options.stopPropagation
|
||||
|
||||
const selectedEntry = ref(0)
|
||||
|
||||
const onKeyUp = (event: any) => {
|
||||
handleArrowKeysNavigation(event, selectedEntry, preventPropagation)
|
||||
}
|
||||
|
||||
function bindArrowKeysListeners() {
|
||||
window.addEventListener("keydown", onKeyUp, { capture: preventPropagation })
|
||||
}
|
||||
|
||||
function unbindArrowKeysListeners() {
|
||||
window.removeEventListener("keydown", onKeyUp, {
|
||||
capture: preventPropagation,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
bindArrowKeysListeners,
|
||||
unbindArrowKeysListeners,
|
||||
selectedEntry,
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@
|
||||
</Pane>
|
||||
</Splitpanes>
|
||||
<AppActionHandler />
|
||||
<AppPowerSearch :show="showSearch" @hide-modal="showSearch = false" />
|
||||
<AppSpotlight :show="showSearch" @hide-modal="showSearch = false" />
|
||||
<AppSupport
|
||||
v-if="mdAndLarger"
|
||||
:show="showSupport"
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import {
|
||||
SpotlightSearcher,
|
||||
SpotlightSearcherSessionState,
|
||||
SpotlightSearcherResult,
|
||||
SpotlightService,
|
||||
} from "../"
|
||||
import { Ref, computed, nextTick, ref, watch } from "vue"
|
||||
import { TestContainer } from "dioc/testing"
|
||||
|
||||
const echoSearcher: SpotlightSearcher = {
|
||||
id: "echo-searcher",
|
||||
sectionTitle: "Echo Searcher",
|
||||
createSearchSession: (query: Readonly<Ref<string>>) => {
|
||||
// A basic searcher that returns the query string as the sole result
|
||||
const loading = ref(false)
|
||||
const results = ref<SpotlightSearcherResult[]>([])
|
||||
|
||||
watch(
|
||||
query,
|
||||
(query) => {
|
||||
loading.value = true
|
||||
|
||||
results.value = [
|
||||
{
|
||||
id: "searcher-a-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: query,
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
},
|
||||
]
|
||||
|
||||
loading.value = false
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const onSessionEnd = () => {
|
||||
/* noop */
|
||||
}
|
||||
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: loading.value,
|
||||
results: results.value,
|
||||
})),
|
||||
onSessionEnd,
|
||||
]
|
||||
},
|
||||
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const emptySearcher: SpotlightSearcher = {
|
||||
id: "empty-searcher",
|
||||
sectionTitle: "Empty Searcher",
|
||||
createSearchSession: () => {
|
||||
const loading = ref(false)
|
||||
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: loading.value,
|
||||
results: [],
|
||||
})),
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
describe("SpotlightService", () => {
|
||||
describe("registerSearcher", () => {
|
||||
it("registers a searcher with a given ID", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
|
||||
const [id, searcher] = spotlight.getAllSearchers().next().value
|
||||
|
||||
expect(id).toEqual("echo-searcher")
|
||||
expect(searcher).toBe(echoSearcher)
|
||||
})
|
||||
|
||||
it("if 2 searchers are registered with the same ID, the last one overwrites the first one", () => {
|
||||
const echoSearcherFake: SpotlightSearcher = {
|
||||
id: "echo-searcher",
|
||||
sectionTitle: "Echo Searcher",
|
||||
createSearchSession: () => {
|
||||
throw new Error("not implemented")
|
||||
},
|
||||
onResultSelect: () => {
|
||||
throw new Error("not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
spotlight.registerSearcher(echoSearcherFake)
|
||||
|
||||
const [id, searcher] = spotlight.getAllSearchers().next().value
|
||||
|
||||
expect(id).toEqual("echo-searcher")
|
||||
expect(searcher).toBe(echoSearcherFake)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createSearchSession", () => {
|
||||
it("when the source query changes, the searchers are notified", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const notifiedFn = vi.fn()
|
||||
|
||||
const sampleSearcher: SpotlightSearcher = {
|
||||
id: "searcher",
|
||||
sectionTitle: "Searcher",
|
||||
createSearchSession: (query) => {
|
||||
const stop = watch(query, notifiedFn, { immediate: true })
|
||||
|
||||
return [
|
||||
ref<SpotlightSearcherSessionState>({
|
||||
loading: false,
|
||||
results: [],
|
||||
}),
|
||||
() => {
|
||||
stop()
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(sampleSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
|
||||
const [, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
query.value = "test2"
|
||||
await nextTick()
|
||||
|
||||
expect(notifiedFn).toHaveBeenCalledTimes(2)
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when a searcher returns results, they are added to the results", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
await nextTick()
|
||||
|
||||
expect(session.value.results).toHaveProperty("echo-searcher")
|
||||
expect(session.value.results["echo-searcher"]).toEqual({
|
||||
title: "Echo Searcher",
|
||||
avgScore: 1,
|
||||
results: [
|
||||
{
|
||||
id: "searcher-a-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when a searcher does not return any results, they are not added to the results", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(emptySearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
expect(session.value.results).not.toHaveProperty("empty-searcher")
|
||||
expect(session.value.results).toEqual({})
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when any of the searchers report they are loading, the search session says it is loading", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const loadingSearcher: SpotlightSearcher = {
|
||||
id: "loading-searcher",
|
||||
sectionTitle: "Loading Searcher",
|
||||
createSearchSession: () => {
|
||||
const loading = ref(true)
|
||||
const results = ref<SpotlightSearcherResult[]>([])
|
||||
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: loading.value,
|
||||
results: results.value,
|
||||
})),
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(loadingSearcher)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
expect(session.value.loading).toBe(true)
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when all of the searchers report they are not loading, the search session says it is not loading", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
spotlight.registerSearcher(emptySearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
expect(session.value.loading).toBe(false)
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when a searcher changes its loading state after a while, the search session state updates", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
const loadingSearcher: SpotlightSearcher = {
|
||||
id: "loading-searcher",
|
||||
sectionTitle: "Loading Searcher",
|
||||
createSearchSession: () => {
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: loading.value,
|
||||
results: [],
|
||||
})),
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(loadingSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
expect(session.value.loading).toBe(true)
|
||||
|
||||
loading.value = false
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(session.value.loading).toBe(false)
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when the searcher updates its results, the search session state updates", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [session, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
expect(session.value.results).toHaveProperty("echo-searcher")
|
||||
expect(session.value.results["echo-searcher"]).toEqual({
|
||||
title: "Echo Searcher",
|
||||
avgScore: 1,
|
||||
results: [
|
||||
{
|
||||
id: "searcher-a-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
query.value = "test2"
|
||||
await nextTick()
|
||||
|
||||
expect(session.value.results).toHaveProperty("echo-searcher")
|
||||
expect(session.value.results["echo-searcher"]).toEqual({
|
||||
title: "Echo Searcher",
|
||||
avgScore: 1,
|
||||
results: [
|
||||
{
|
||||
id: "searcher-a-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test2",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
dispose()
|
||||
})
|
||||
|
||||
it("when the returned dispose function is called, the searchers are notified", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const disposeFn = vi.fn()
|
||||
|
||||
const testSearcher: SpotlightSearcher = {
|
||||
id: "test-searcher",
|
||||
sectionTitle: "Test Searcher",
|
||||
createSearchSession: () => {
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: false,
|
||||
results: [],
|
||||
})),
|
||||
disposeFn,
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(testSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
dispose()
|
||||
|
||||
expect(disposeFn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("when the search session is disposed, changes to the query are not notified to the searchers", async () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const notifiedFn = vi.fn()
|
||||
|
||||
const testSearcher: SpotlightSearcher = {
|
||||
id: "test-searcher",
|
||||
sectionTitle: "Test Searcher",
|
||||
createSearchSession: (query) => {
|
||||
watch(query, notifiedFn, { immediate: true })
|
||||
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: false,
|
||||
results: [],
|
||||
})),
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: () => {
|
||||
/* noop */
|
||||
},
|
||||
}
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(testSearcher)
|
||||
|
||||
const query = ref("test")
|
||||
const [, dispose] = spotlight.createSearchSession(query)
|
||||
|
||||
query.value = "test2"
|
||||
await nextTick()
|
||||
|
||||
expect(notifiedFn).toHaveBeenCalledTimes(2)
|
||||
|
||||
dispose()
|
||||
|
||||
query.value = "test3"
|
||||
await nextTick()
|
||||
|
||||
expect(notifiedFn).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
describe("selectSearchResult", () => {
|
||||
const onResultSelectFn = vi.fn()
|
||||
|
||||
const testSearcher: SpotlightSearcher = {
|
||||
id: "test-searcher",
|
||||
sectionTitle: "Test Searcher",
|
||||
createSearchSession: () => {
|
||||
return [
|
||||
computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: false,
|
||||
results: [],
|
||||
})),
|
||||
() => {
|
||||
/* noop */
|
||||
},
|
||||
]
|
||||
},
|
||||
onResultSelect: onResultSelectFn,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onResultSelectFn.mockReset()
|
||||
})
|
||||
|
||||
it("does nothing if the searcherID is invalid", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(testSearcher)
|
||||
|
||||
spotlight.selectSearchResult("invalid-searcher-id", {
|
||||
id: "test-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
})
|
||||
|
||||
expect(onResultSelectFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("calls the correspondig searcher's onResultSelect method", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(testSearcher)
|
||||
|
||||
spotlight.selectSearchResult("test-searcher", {
|
||||
id: "test-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
})
|
||||
|
||||
expect(onResultSelectFn).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("passes the correct information to the searcher's onResultSelect method", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(testSearcher)
|
||||
|
||||
spotlight.selectSearchResult("test-searcher", {
|
||||
id: "test-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
})
|
||||
|
||||
expect(onResultSelectFn).toHaveBeenCalledWith({
|
||||
id: "test-result",
|
||||
text: {
|
||||
type: "text",
|
||||
text: "test",
|
||||
},
|
||||
icon: {},
|
||||
score: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllSearchers", () => {
|
||||
it("when no searchers are registered, it returns an empty array", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
|
||||
expect(Array.from(spotlight.getAllSearchers())).toEqual([])
|
||||
})
|
||||
|
||||
it("when a searcher is registered, it returns an array with a tuple of the searcher id and then then searcher", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
|
||||
expect(Array.from(spotlight.getAllSearchers())).toEqual([
|
||||
["echo-searcher", echoSearcher],
|
||||
])
|
||||
})
|
||||
|
||||
it("returns all registered searchers", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const spotlight = container.bind(SpotlightService)
|
||||
spotlight.registerSearcher(echoSearcher)
|
||||
spotlight.registerSearcher(emptySearcher)
|
||||
|
||||
expect(Array.from(spotlight.getAllSearchers())).toEqual([
|
||||
["echo-searcher", echoSearcher],
|
||||
["empty-searcher", emptySearcher],
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
135
packages/hoppscotch-common/src/services/spotlight/index.ts
Normal file
135
packages/hoppscotch-common/src/services/spotlight/index.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Service } from "dioc"
|
||||
import { watch, type Ref, ref, reactive, effectScope, Component } from "vue"
|
||||
|
||||
export type SpotlightResultTextType<T extends object | Component = never> =
|
||||
| { type: "text"; text: string[] | string }
|
||||
| {
|
||||
type: "custom"
|
||||
component: T
|
||||
componentProps: T extends Component<infer Props> ? Props : never
|
||||
}
|
||||
|
||||
export type SpotlightSearcherResult = {
|
||||
id: string
|
||||
text: SpotlightResultTextType<any>
|
||||
icon: object | Component
|
||||
score: number
|
||||
meta?: {
|
||||
keyboardShortcut?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export type SpotlightSearcherSessionState = {
|
||||
loading: boolean
|
||||
results: SpotlightSearcherResult[]
|
||||
}
|
||||
|
||||
export interface SpotlightSearcher {
|
||||
id: string
|
||||
sectionTitle: string
|
||||
|
||||
createSearchSession(
|
||||
query: Readonly<Ref<string>>
|
||||
): [Ref<SpotlightSearcherSessionState>, () => void]
|
||||
|
||||
onResultSelect(result: SpotlightSearcherResult): void
|
||||
}
|
||||
|
||||
export type SpotlightSearchState = {
|
||||
loading: boolean
|
||||
results: Record<
|
||||
string,
|
||||
{
|
||||
title: string
|
||||
avgScore: number
|
||||
results: SpotlightSearcherResult[]
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
export class SpotlightService extends Service {
|
||||
public static readonly ID = "SPOTLIGHT_SERVICE"
|
||||
|
||||
private searchers: Map<string, SpotlightSearcher> = new Map()
|
||||
|
||||
public registerSearcher(searcher: SpotlightSearcher) {
|
||||
this.searchers.set(searcher.id, searcher)
|
||||
}
|
||||
|
||||
public getAllSearchers(): IterableIterator<[string, SpotlightSearcher]> {
|
||||
return this.searchers.entries()
|
||||
}
|
||||
|
||||
public createSearchSession(
|
||||
query: Ref<string>
|
||||
): [Ref<SpotlightSearchState>, () => void] {
|
||||
const searchSessions = Array.from(this.searchers.values()).map(
|
||||
(x) => [x, ...x.createSearchSession(query)] as const
|
||||
)
|
||||
|
||||
const loadingSearchers = reactive(new Set())
|
||||
const onSessionEndList: Array<() => void> = []
|
||||
|
||||
const resultObj = ref<SpotlightSearchState>({
|
||||
loading: false,
|
||||
results: {},
|
||||
})
|
||||
|
||||
const scopeHandle = effectScope()
|
||||
|
||||
scopeHandle.run(() => {
|
||||
for (const [searcher, state, onSessionEnd] of searchSessions) {
|
||||
watch(
|
||||
state,
|
||||
(newState) => {
|
||||
if (newState.loading) {
|
||||
loadingSearchers.add(searcher.id)
|
||||
} else {
|
||||
loadingSearchers.delete(searcher.id)
|
||||
}
|
||||
|
||||
if (newState.results.length === 0) {
|
||||
delete resultObj.value.results[searcher.id]
|
||||
} else {
|
||||
resultObj.value.results[searcher.id] = {
|
||||
title: searcher.sectionTitle,
|
||||
avgScore:
|
||||
newState.results.reduce((acc, x) => acc + x.score, 0) /
|
||||
newState.results.length,
|
||||
results: newState.results,
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onSessionEndList.push(onSessionEnd)
|
||||
}
|
||||
|
||||
watch(
|
||||
loadingSearchers,
|
||||
(set) => {
|
||||
resultObj.value.loading = set.size > 0
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
|
||||
const onSearchEnd = () => {
|
||||
scopeHandle.stop()
|
||||
|
||||
for (const onEnd of onSessionEndList) {
|
||||
onEnd()
|
||||
}
|
||||
}
|
||||
|
||||
return [resultObj, onSearchEnd]
|
||||
}
|
||||
|
||||
public selectSearchResult(
|
||||
searcherID: string,
|
||||
result: SpotlightSearcherResult
|
||||
) {
|
||||
this.searchers.get(searcherID)?.onResultSelect(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Service } from "dioc"
|
||||
import {
|
||||
type SpotlightSearcher,
|
||||
type SpotlightSearcherResult,
|
||||
type SpotlightSearcherSessionState,
|
||||
} from "../"
|
||||
import MiniSearch, { type SearchResult } from "minisearch"
|
||||
import { Ref, computed, effectScope, ref, watch } from "vue"
|
||||
import { MaybeRef, resolveUnref } from "@vueuse/core"
|
||||
|
||||
export abstract class StaticSpotlightSearcherService<
|
||||
Doc extends Record<string, unknown> & {
|
||||
excludeFromSearch?: boolean
|
||||
} & Record<DocFields[number], string>,
|
||||
DocFields extends Array<keyof Doc>
|
||||
>
|
||||
extends Service
|
||||
implements SpotlightSearcher
|
||||
{
|
||||
public abstract readonly id: string
|
||||
public abstract readonly sectionTitle: string
|
||||
|
||||
private minisearch: MiniSearch
|
||||
|
||||
private loading = ref(false)
|
||||
|
||||
constructor(
|
||||
private documents: MaybeRef<Record<string, Doc>>,
|
||||
searchFields: Array<keyof Doc>,
|
||||
resultFields: DocFields
|
||||
) {
|
||||
super()
|
||||
|
||||
this.minisearch = new MiniSearch({
|
||||
fields: searchFields as string[],
|
||||
storeFields: resultFields as string[],
|
||||
})
|
||||
|
||||
this.addDocsToSearchIndex(resolveUnref(documents))
|
||||
}
|
||||
|
||||
private async addDocsToSearchIndex(docs: Record<string, Doc>) {
|
||||
this.loading.value = true
|
||||
|
||||
await this.minisearch.addAllAsync(
|
||||
Object.entries(docs).map(([id, doc]) => ({
|
||||
id,
|
||||
...doc,
|
||||
}))
|
||||
)
|
||||
|
||||
this.loading.value = false
|
||||
}
|
||||
|
||||
protected abstract getSearcherResultForSearchResult(
|
||||
result: Pick<Doc & SearchResult, DocFields[number] | "id" | "score">
|
||||
): SpotlightSearcherResult
|
||||
|
||||
public createSearchSession(
|
||||
query: Readonly<Ref<string>>
|
||||
): [Ref<SpotlightSearcherSessionState>, () => void] {
|
||||
const results = ref<SpotlightSearcherResult[]>([])
|
||||
|
||||
const resultObj = computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: this.loading.value,
|
||||
results: results.value,
|
||||
}))
|
||||
|
||||
const scopeHandle = effectScope()
|
||||
|
||||
scopeHandle.run(() => {
|
||||
watch(
|
||||
[query, () => resolveUnref(this.documents)],
|
||||
([query, docs]) => {
|
||||
const searchResults = this.minisearch.search(query, {
|
||||
prefix: true,
|
||||
fuzzy: 0.2,
|
||||
weights: {
|
||||
fuzzy: 0.2,
|
||||
prefix: 0.6,
|
||||
},
|
||||
})
|
||||
|
||||
results.value = searchResults
|
||||
.filter(
|
||||
(result) =>
|
||||
docs[result.id].excludeFromSearch === undefined ||
|
||||
docs[result.id].excludeFromSearch === false
|
||||
)
|
||||
.map((result) =>
|
||||
this.getSearcherResultForSearchResult(result as any)
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
|
||||
const onSessionEnd = () => {
|
||||
scopeHandle.stop()
|
||||
}
|
||||
|
||||
return [resultObj, onSessionEnd]
|
||||
}
|
||||
|
||||
public abstract onResultSelect(result: SpotlightSearcherResult): void
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { Service } from "dioc"
|
||||
import {
|
||||
SpotlightSearcher,
|
||||
SpotlightSearcherResult,
|
||||
SpotlightSearcherSessionState,
|
||||
SpotlightService,
|
||||
} from "../"
|
||||
import { Ref, computed, effectScope, markRaw, ref, watch } from "vue"
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
import MiniSearch from "minisearch"
|
||||
import { restHistoryStore } from "~/newstore/history"
|
||||
import { useTimeAgo } from "@vueuse/core"
|
||||
import IconHistory from "~icons/lucide/history"
|
||||
import IconTrash2 from "~icons/lucide/trash-2"
|
||||
import SpotlightHistoryEntry from "~/components/app/spotlight/entry/History.vue"
|
||||
import { createNewTab } from "~/helpers/rest/tab"
|
||||
import { capitalize } from "lodash-es"
|
||||
import { shortDateTime } from "~/helpers/utils/date"
|
||||
import { useStreamStatic } from "~/composables/stream"
|
||||
import { activeActions$, invokeAction } from "~/helpers/actions"
|
||||
import { map } from "rxjs/operators"
|
||||
|
||||
export class HistorySpotlightSearcherService
|
||||
extends Service
|
||||
implements SpotlightSearcher
|
||||
{
|
||||
public static readonly ID = "HISTORY_SPOTLIGHT_SEARCHER_SERVICE"
|
||||
|
||||
private t = getI18n()
|
||||
|
||||
public id = "history"
|
||||
public sectionTitle = this.t("tab.history")
|
||||
|
||||
private readonly spotlight = this.bind(SpotlightService)
|
||||
|
||||
private clearHistoryActionEnabled = useStreamStatic(
|
||||
activeActions$.pipe(map((actions) => actions.includes("history.clear"))),
|
||||
activeActions$.value.includes("history.clear"),
|
||||
() => {
|
||||
/* noop */
|
||||
}
|
||||
)[0]
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.spotlight.registerSearcher(this)
|
||||
}
|
||||
|
||||
createSearchSession(
|
||||
query: Readonly<Ref<string>>
|
||||
): [Ref<SpotlightSearcherSessionState>, () => void] {
|
||||
const loading = ref(false)
|
||||
const results = ref<SpotlightSearcherResult[]>([])
|
||||
|
||||
const minisearch = new MiniSearch({
|
||||
fields: ["url", "title", "reltime", "date"],
|
||||
storeFields: ["url"],
|
||||
})
|
||||
|
||||
const stopWatchHandle = watch(
|
||||
this.clearHistoryActionEnabled,
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
minisearch.add({
|
||||
id: "clear-history",
|
||||
title: this.t("action.clear_history"),
|
||||
})
|
||||
} else {
|
||||
minisearch.discard("clear-history")
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
minisearch.addAll(
|
||||
restHistoryStore.value.state
|
||||
.filter((x) => !!x.updatedOn)
|
||||
.map((entry, index) => {
|
||||
const relTimeString = capitalize(
|
||||
useTimeAgo(entry.updatedOn!, {
|
||||
updateInterval: 0,
|
||||
}).value
|
||||
)
|
||||
|
||||
return {
|
||||
id: index.toString(),
|
||||
url: entry.request.endpoint,
|
||||
reltime: relTimeString,
|
||||
date: shortDateTime(entry.updatedOn!),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const scopeHandle = effectScope()
|
||||
|
||||
scopeHandle.run(() => {
|
||||
watch(query, (query) => {
|
||||
results.value = minisearch
|
||||
.search(query, {
|
||||
prefix: true,
|
||||
fuzzy: true,
|
||||
boost: {
|
||||
reltime: 2,
|
||||
},
|
||||
weights: {
|
||||
fuzzy: 0.2,
|
||||
prefix: 0.8,
|
||||
},
|
||||
})
|
||||
.map((x) => {
|
||||
const entry = restHistoryStore.value.state[parseInt(x.id)]
|
||||
|
||||
if (x.id === "clear-history") {
|
||||
return {
|
||||
id: "clear-history",
|
||||
icon: markRaw(IconTrash2),
|
||||
score: x.score,
|
||||
text: {
|
||||
type: "text",
|
||||
text: this.t("action.clear_history"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: x.id,
|
||||
icon: markRaw(IconHistory),
|
||||
score: x.score,
|
||||
text: {
|
||||
type: "custom",
|
||||
component: markRaw(SpotlightHistoryEntry),
|
||||
componentProps: {
|
||||
historyEntry: entry,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const onSessionEnd = () => {
|
||||
scopeHandle.stop()
|
||||
stopWatchHandle()
|
||||
minisearch.removeAll()
|
||||
}
|
||||
|
||||
const resultObj = computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: loading.value,
|
||||
results: results.value,
|
||||
}))
|
||||
|
||||
return [resultObj, onSessionEnd]
|
||||
}
|
||||
|
||||
onResultSelect(result: SpotlightSearcherResult): void {
|
||||
if (result.id === "clear-history") {
|
||||
invokeAction("history.clear")
|
||||
return
|
||||
}
|
||||
|
||||
const req = restHistoryStore.value.state[parseInt(result.id)].request
|
||||
|
||||
createNewTab({
|
||||
request: req,
|
||||
isDirty: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user