Compare commits

...

18 Commits

Author SHA1 Message Date
Nivedin
a60303ef7c chore: add empty result placeholder 2023-07-10 15:29:52 +05:30
Andrew Bastin
53e4863a80 fix: error out when arrow key navigation done on spotlight when no results 2023-07-09 21:56:08 +05:30
Andrew Bastin
3340d0813c chore: remove fuse.js dependency 2023-07-05 11:52:36 +05:30
Andrew Bastin
7c5722586c refactor: move shortcuts to use minisearch 2023-07-05 11:51:15 +05:30
Andrew Bastin
6b38363fab chore: add test case to user searcher to register with spotlight on init 2023-07-04 15:03:50 +05:30
Andrew Bastin
14202a3eed chore: introduce tests for history searcher 2023-07-04 14:55:44 +05:30
Andrew Bastin
ea03223b8e fix: history returning no entries until query update and minisearch conflicts 2023-07-04 14:54:30 +05:30
Andrew Bastin
235deb113c chore: update vitest to be able to parse Vue components 2023-07-04 14:51:31 +05:30
Andrew Bastin
833e11ab0b feat: general spotlight improvements and introducing user searcher 2023-07-04 12:54:57 +05:30
Andrew Bastin
0d101673d2 chore: update vitest config to support loading icons 2023-07-04 12:52:26 +05:30
Andrew Bastin
6fe565c30f feat: add action handler to login and logout components 2023-07-03 23:13:36 +05:30
Andrew Bastin
4164de5a9e refactor: ability for defineActionHandler to be able to control binding 2023-07-03 23:10:27 +05:30
Andrew Bastin
1ff35f45ee feat: introduce debug service 2023-07-03 23:01:46 +05:30
Andrew Bastin
3bf8288de3 feat: initial reworked spotlight implementation 2023-07-03 12:03:17 +05:30
Andrew Bastin
8c48d41eed refactor: expose function to get a service instance from dioc through module 2023-07-03 11:41:05 +05:30
Andrew Bastin
38215be3bd refactor: provide global i18n function through the module 2023-07-03 11:39:00 +05:30
Andrew Bastin
edf57da9be chore: add minisearch and bump @vueuse/core dep 2023-07-03 11:36:55 +05:30
Andrew Bastin
be61b62825 feat: add clear history action 2023-07-03 11:23:02 +05:30
31 changed files with 3536 additions and 1020 deletions

View File

@@ -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",
@@ -582,6 +583,11 @@
"log": "Log",
"url": "URL"
},
"spotlight": {
"section": {
"user": "User"
}
},
"sse": {
"event_type": "Event type",
"log": "Log",

View File

@@ -46,7 +46,7 @@
"@urql/exchange-auth": "^0.1.7",
"@urql/exchange-graphcache": "^4.4.3",
"@vitejs/plugin-legacy": "^2.3.0",
"@vueuse/core": "^8.7.5",
"@vueuse/core": "^8.9.4",
"@vueuse/head": "^0.7.9",
"acorn-walk": "^8.2.0",
"axios": "^0.21.4",
@@ -67,6 +67,7 @@
"jsonpath-plus": "^7.0.0",
"lodash-es": "^4.17.21",
"lossless-json": "^2.0.8",
"minisearch": "^6.1.0",
"nprogress": "^0.2.0",
"paho-mqtt": "^1.1.0",
"path": "^0.12.7",

View File

@@ -11,20 +11,20 @@ declare module '@vue/runtime-core' {
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
AppFooter: typeof import('./components/app/Footer.vue')['default']
AppFuse: typeof import('./components/app/Fuse.vue')['default']
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
AppHeader: typeof import('./components/app/Header.vue')['default']
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
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']
@@ -131,6 +131,7 @@ declare module '@vue/runtime-core' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']

View File

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

View File

@@ -242,7 +242,7 @@ import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
import { platform } from "~/platform"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { invokeAction } from "@helpers/actions"
import { defineActionHandler, invokeAction } from "@helpers/actions"
import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
import { onLoggedIn } from "~/composables/auth"
@@ -374,4 +374,12 @@ const profile = ref<any | null>(null)
const settings = ref<any | null>(null)
const logout = ref<any | null>(null)
const accountActions = ref<any | null>(null)
defineActionHandler(
"user.login",
() => {
invokeAction("modals.login.toggle")
},
computed(() => !currentUser.value)
)
</script>

View File

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

View File

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

View File

@@ -14,46 +14,18 @@
/>
</div>
</div>
<div v-if="filterText" class="flex flex-col divide-y divide-dividerLight">
<details
v-for="(map, mapIndex) in searchResults"
:key="`map-${mapIndex}`"
class="flex flex-col"
open
>
<summary
class="flex items-center flex-1 min-w-0 px-6 py-4 font-semibold transition cursor-pointer focus:outline-none text-secondaryLight hover:text-secondaryDark"
>
<icon-lucide-chevron-right class="mr-2 indicator" />
<span
class="font-semibold truncate capitalize-first text-secondaryDark"
>
{{ t(map.item.section) }}
</span>
</summary>
<div class="flex flex-col px-6 pb-4 space-y-2">
<AppShortcutsEntry
v-for="(shortcut, index) in map.item.shortcuts"
:key="`shortcut-${index}`"
:shortcut="shortcut"
/>
</div>
</details>
<HoppSmartPlaceholder
v-if="searchResults.length === 0"
:text="`${t('state.nothing_found')} ‟${filterText}”`"
>
<div class="flex flex-col divide-y divide-dividerLight">
<HoppSmartPlaceholder v-if="isEmpty(shortcutsResults)">
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
<span class="my-2 text-center flex flex-col">
{{ t("state.nothing_found") }}
<span class="break-all">"{{ filterText }}"</span>
</span>
</div>
</div>
<div v-else class="flex flex-col divide-y divide-dividerLight">
</HoppSmartPlaceholder>
<details
v-for="(map, mapIndex) in mappings"
:key="`map-${mapIndex}`"
v-for="(sectionResults, sectionTitle) in shortcutsResults"
v-else
:key="`section-${sectionTitle}`"
class="flex flex-col"
open
>
@@ -64,13 +36,13 @@
<span
class="font-semibold truncate capitalize-first text-secondaryDark"
>
{{ t(map.section) }}
{{ sectionTitle }}
</span>
</summary>
<div class="flex flex-col px-6 pb-4 space-y-2">
<AppShortcutsEntry
v-for="(shortcut, shortcutIndex) in map.shortcuts"
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
v-for="(shortcut, index) in sectionResults"
:key="`shortcut-${index}`"
:shortcut="shortcut"
/>
</div>
@@ -81,10 +53,11 @@
</template>
<script setup lang="ts">
import { computed, ref } from "vue"
import Fuse from "fuse.js"
import mappings from "~/helpers/shortcuts"
import { computed, onBeforeMount, ref } from "vue"
import { ShortcutDef, getShortcuts } from "~/helpers/shortcuts"
import MiniSearch from "minisearch"
import { useI18n } from "@composables/i18n"
import { groupBy, isEmpty } from "lodash-es"
const t = useI18n()
@@ -92,15 +65,33 @@ defineProps<{
show: boolean
}>()
const options = {
keys: ["shortcuts.label"],
}
const minisearch = new MiniSearch({
fields: ["label", "keys", "section"],
idField: "label",
storeFields: ["label", "keys", "section"],
searchOptions: {
fuzzy: true,
prefix: true,
},
})
const fuse = new Fuse(mappings, options)
const shortcuts = getShortcuts(t)
onBeforeMount(() => {
minisearch.addAllAsync(shortcuts)
})
const filterText = ref("")
const searchResults = computed(() => fuse.search(filterText.value))
const shortcutsResults = computed(() => {
// If there are no search text, return all the shortcuts
const results =
filterText.value.length > 0
? minisearch.search(filterText.value)
: shortcuts
return groupBy(results, "section") as Record<string, ShortcutDef[]>
})
const emit = defineEmits<{
(e: "close"): void

View File

@@ -1,7 +1,7 @@
<template>
<div class="flex items-center py-1">
<span class="flex flex-1 mr-4">
{{ t(shortcut.label) }}
{{ shortcut.label }}
</span>
<kbd
v-for="(key, index) in shortcut.keys"
@@ -14,14 +14,9 @@
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
const t = useI18n()
import { ShortcutDef } from "~/helpers/shortcuts"
defineProps<{
shortcut: {
label: string
keys: string[]
}
shortcut: ShortcutDef
}>()
</script>

View File

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

View File

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

View File

@@ -0,0 +1,228 @@
<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>

View File

@@ -1,12 +1,12 @@
<template>
<div class="flex" @click="OpenLogoutModal()">
<div class="flex" @click="openLogoutModal()">
<HoppSmartItem
ref="logoutItem"
:icon="IconLogOut"
:label="`${t('auth.logout')}`"
:outline="outline"
:shortcut="shortcut"
@click="OpenLogoutModal()"
@click="openLogoutModal()"
/>
<HoppSmartConfirmModal
:show="confirmLogout"
@@ -23,6 +23,7 @@ import IconLogOut from "~icons/lucide/log-out"
import { useToast } from "@composables/toast"
import { useI18n } from "@composables/i18n"
import { platform } from "~/platform"
import { defineActionHandler } from "~/helpers/actions"
defineProps({
outline: {
@@ -55,8 +56,12 @@ const logout = async () => {
}
}
const OpenLogoutModal = () => {
const openLogoutModal = () => {
emit("confirm-logout")
confirmLogout.value = true
}
defineActionHandler("user.logout", () => {
openLogoutModal()
})
</script>

View File

@@ -176,6 +176,7 @@ import {
import HistoryRestCard from "./rest/Card.vue"
import HistoryGraphqlCard from "./graphql/Card.vue"
import { createNewTab } from "~/helpers/rest/tab"
import { defineActionHandler } from "~/helpers/actions"
type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
@@ -329,4 +330,8 @@ const toggleStar = (entry: HistoryEntry) => {
toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
}
defineActionHandler("history.clear", () => {
confirmRemove.value = true
})
</script>

View File

@@ -2,7 +2,7 @@
* For example, sending a request.
*/
import { onBeforeUnmount, onMounted } from "vue"
import { Ref, onBeforeUnmount, onMounted, watch } from "vue"
import { BehaviorSubject } from "rxjs"
export type HoppAction =
@@ -38,6 +38,9 @@ export type HoppAction =
| "response.file.download" // Download response as file
| "response.copy" // Copy response to clipboard
| "modals.login.toggle" // Login to Hoppscotch
| "history.clear" // Clear REST History
| "user.login" // Login to Hoppscotch
| "user.logout" // Log out of Hoppscotch
/**
* Defines the arguments, if present for a given type that is required to be passed on
@@ -142,15 +145,50 @@ export function unbindAction<A extends HoppAction>(
activeActions$.next(Object.keys(boundActions) as HoppAction[])
}
/**
* A composable function that defines a component can handle a given
* HoppAction. The handler will be bound when the component is mounted
* and unbound when the component is unmounted.
* @param action The action to be bound
* @param handler The function to be called when the action is invoked
* @param isActive A ref that indicates whether the action is active
*/
export function defineActionHandler<A extends HoppAction>(
action: A,
handler: ActionFunc<A>
handler: ActionFunc<A>,
isActive: Ref<boolean> | undefined = undefined
) {
let mounted = false
let bound = true
onMounted(() => {
mounted = true
bound = true
bindAction(action, handler)
})
onBeforeUnmount(() => {
mounted = false
bound = false
unbindAction(action, handler)
})
if (isActive) {
watch(isActive, (active) => {
if (mounted) {
if (active) {
if (!bound) {
bound = true
bindAction(action, handler)
}
} else if (bound) {
bound = false
unbindAction(action, handler)
}
}
})
}
}

View File

@@ -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,
}
}

View File

@@ -1,315 +1,146 @@
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconZap from "~icons/lucide/zap"
import IconArrowRight from "~icons/lucide/arrow-right"
import IconGift from "~icons/lucide/gift"
import IconMonitor from "~icons/lucide/monitor"
import IconSun from "~icons/lucide/sun"
import IconCloud from "~icons/lucide/cloud"
import IconMoon from "~icons/lucide/moon"
import { getPlatformAlternateKey, getPlatformSpecialKey } from "./platformutils"
export default [
{
section: "shortcut.general.title",
shortcuts: [
{
keys: ["?"],
label: "shortcut.general.help_menu",
},
{
keys: ["/"],
label: "shortcut.general.command_menu",
},
{
keys: [getPlatformSpecialKey(), "K"],
label: "shortcut.general.show_all",
},
{
keys: ["ESC"],
label: "shortcut.general.close_current_menu",
},
],
},
{
section: "shortcut.request.title",
shortcuts: [
{
keys: [getPlatformSpecialKey(), "↩"],
label: "shortcut.request.send_request",
},
{
keys: [getPlatformSpecialKey(), "S"],
label: "shortcut.request.save_to_collections",
},
{
keys: [getPlatformSpecialKey(), "U"],
label: "shortcut.request.copy_request_link",
},
{
keys: [getPlatformSpecialKey(), "I"],
label: "shortcut.request.reset_request",
},
{
keys: [getPlatformAlternateKey(), "↑"],
label: "shortcut.request.next_method",
},
{
keys: [getPlatformAlternateKey(), "↓"],
label: "shortcut.request.previous_method",
},
{
keys: [getPlatformAlternateKey(), "G"],
label: "shortcut.request.get_method",
},
{
keys: [getPlatformAlternateKey(), "H"],
label: "shortcut.request.head_method",
},
{
keys: [getPlatformAlternateKey(), "P"],
label: "shortcut.request.post_method",
},
{
keys: [getPlatformAlternateKey(), "U"],
label: "shortcut.request.put_method",
},
{
keys: [getPlatformAlternateKey(), "X"],
label: "shortcut.request.delete_method",
},
],
},
{
section: "shortcut.response.title",
shortcuts: [
{
keys: [getPlatformSpecialKey(), "J"],
label: "shortcut.response.download",
},
{
keys: [getPlatformSpecialKey(), "."],
label: "shortcut.response.copy",
},
],
},
{
section: "shortcut.navigation.title",
shortcuts: [
{
keys: [getPlatformSpecialKey(), "←"],
label: "shortcut.navigation.back",
},
{
keys: [getPlatformSpecialKey(), "→"],
label: "shortcut.navigation.forward",
},
{
keys: [getPlatformAlternateKey(), "R"],
label: "shortcut.navigation.rest",
},
{
keys: [getPlatformAlternateKey(), "Q"],
label: "shortcut.navigation.graphql",
},
{
keys: [getPlatformAlternateKey(), "W"],
label: "shortcut.navigation.realtime",
},
{
keys: [getPlatformAlternateKey(), "S"],
label: "shortcut.navigation.settings",
},
{
keys: [getPlatformAlternateKey(), "M"],
label: "shortcut.navigation.profile",
},
],
},
{
section: "shortcut.miscellaneous.title",
shortcuts: [
{
keys: [getPlatformSpecialKey(), "M"],
label: "shortcut.miscellaneous.invite",
},
],
},
]
export type ShortcutDef = {
label: string
keys: string[]
section: string
}
export const spotlight = [
{
section: "app.spotlight",
shortcuts: [
{
keys: ["?"],
label: "shortcut.general.help_menu",
action: "modals.support.toggle",
icon: IconLifeBuoy,
},
{
keys: [getPlatformSpecialKey(), "K"],
label: "shortcut.general.show_all",
action: "flyouts.keybinds.toggle",
icon: IconZap,
},
],
},
{
section: "shortcut.navigation.title",
shortcuts: [
{
keys: [getPlatformAlternateKey(), "R"],
label: "shortcut.navigation.rest",
action: "navigation.jump.rest",
icon: IconArrowRight,
},
{
keys: [getPlatformAlternateKey(), "Q"],
label: "shortcut.navigation.graphql",
action: "navigation.jump.graphql",
icon: IconArrowRight,
},
{
keys: [getPlatformAlternateKey(), "W"],
label: "shortcut.navigation.realtime",
action: "navigation.jump.realtime",
icon: IconArrowRight,
},
{
keys: [getPlatformAlternateKey(), "S"],
label: "shortcut.navigation.settings",
action: "navigation.jump.settings",
icon: IconArrowRight,
},
{
keys: [getPlatformAlternateKey(), "M"],
label: "shortcut.navigation.profile",
action: "navigation.jump.profile",
icon: IconArrowRight,
},
],
},
{
section: "shortcut.miscellaneous.title",
shortcuts: [
{
keys: [getPlatformSpecialKey(), "M"],
label: "shortcut.miscellaneous.invite",
action: "modals.share.toggle",
icon: IconGift,
},
],
},
]
export function getShortcuts(t: (x: string) => string): ShortcutDef[] {
// General
return [
{
label: t("shortcut.general.help_menu"),
keys: ["?"],
section: t("shortcut.general.title"),
},
{
label: t("shortcut.general.command_menu"),
keys: ["/"],
section: t("shortcut.general.title"),
},
{
label: t("shortcut.general.show_all"),
keys: [getPlatformSpecialKey(), "K"],
section: t("shortcut.general.title"),
},
{
label: t("shortcut.general.close_current_menu"),
keys: ["ESC"],
section: t("shortcut.general.title"),
},
export const fuse = [
{
keys: ["?"],
label: "shortcut.general.help_menu",
action: "modals.support.toggle",
icon: IconLifeBuoy,
tags: [
"help",
"support",
"menu",
"discord",
"twitter",
"documentation",
"troubleshooting",
"chat",
"community",
"feedback",
"report",
"bug",
"issue",
"ticket",
],
},
{
keys: [getPlatformSpecialKey(), "K"],
label: "shortcut.general.show_all",
action: "flyouts.keybinds.toggle",
icon: IconZap,
tags: ["keyboard", "shortcuts"],
},
{
keys: [getPlatformAlternateKey(), "R"],
label: "shortcut.navigation.rest",
action: "navigation.jump.rest",
icon: IconArrowRight,
tags: ["rest", "jump", "page", "navigation", "go"],
},
{
keys: [getPlatformAlternateKey(), "Q"],
label: "shortcut.navigation.graphql",
action: "navigation.jump.graphql",
icon: IconArrowRight,
tags: ["graphql", "jump", "page", "navigation", "go"],
},
{
keys: [getPlatformAlternateKey(), "W"],
label: "shortcut.navigation.realtime",
action: "navigation.jump.realtime",
icon: IconArrowRight,
tags: [
"realtime",
"jump",
"page",
"navigation",
"websocket",
"socket",
"mqtt",
"sse",
"go",
],
},
{
keys: [getPlatformAlternateKey(), "S"],
label: "shortcut.navigation.settings",
action: "navigation.jump.settings",
icon: IconArrowRight,
tags: ["settings", "jump", "page", "navigation", "account", "theme", "go"],
},
{
keys: [getPlatformAlternateKey(), "M"],
label: "shortcut.navigation.profile",
action: "navigation.jump.profile",
icon: IconArrowRight,
tags: ["profile", "jump", "page", "navigation", "account", "theme", "go"],
},
{
keys: [getPlatformSpecialKey(), "M"],
label: "shortcut.miscellaneous.invite",
action: "modals.share.toggle",
icon: IconGift,
tags: ["invite", "share", "app", "friends", "people", "social"],
},
{
keys: [getPlatformAlternateKey(), "0"],
label: "shortcut.theme.system",
action: "settings.theme.system",
icon: IconMonitor,
tags: ["theme", "system"],
},
{
keys: [getPlatformAlternateKey(), "1"],
label: "shortcut.theme.light",
action: "settings.theme.light",
icon: IconSun,
tags: ["theme", "light"],
},
{
keys: [getPlatformAlternateKey(), "2"],
label: "shortcut.theme.dark",
action: "settings.theme.dark",
icon: IconCloud,
tags: ["theme", "dark"],
},
{
keys: [getPlatformAlternateKey(), "3"],
label: "shortcut.theme.black",
action: "settings.theme.black",
icon: IconMoon,
tags: ["theme", "black"],
},
]
// Request
{
label: t("shortcut.request.send_request"),
keys: [getPlatformSpecialKey(), "↩"],
section: t("shortcut.request.title"),
},
{
keys: [getPlatformSpecialKey(), "S"],
label: t("shortcut.request.save_to_collections"),
section: t("shortcut.request.title"),
},
{
keys: [getPlatformSpecialKey(), "U"],
label: t("shortcut.request.copy_request_link"),
section: t("shortcut.request.title"),
},
{
keys: [getPlatformSpecialKey(), "I"],
label: t("shortcut.request.reset_request"),
section: t("shortcut.request.title"),
},
{
keys: [getPlatformAlternateKey(), "↑"],
label: t("shortcut.request.next_method"),
section: t("shortcut.request.title"),
},
{
keys: [getPlatformAlternateKey(), "↓"],
label: t("shortcut.request.previous_method"),
section: t("shortcut.request.title"),
},
{
keys: [getPlatformAlternateKey(), "G"],
label: t("shortcut.request.get_method"),
section: t("shortcut.request.title"),
},
{
keys: [getPlatformAlternateKey(), "H"],
label: t("shortcut.request.head_method"),
section: t("shortcut.request.title"),
},
{
keys: [getPlatformAlternateKey(), "P"],
label: t("shortcut.request.post_method"),
section: t("shortcut.request.title"),
},
{
keys: [getPlatformAlternateKey(), "U"],
label: t("shortcut.request.put_method"),
section: t("shortcut.request.title"),
},
{
keys: [getPlatformAlternateKey(), "X"],
label: t("shortcut.request.delete_method"),
section: t("shortcut.request.title"),
},
// Response
{
keys: [getPlatformSpecialKey(), "J"],
label: t("shortcut.response.download"),
section: t("shortcut.response.title"),
},
{
keys: [getPlatformSpecialKey(), "."],
label: t("shortcut.response.copy"),
section: t("shortcut.response.title"),
},
// Navigation
{
keys: [getPlatformSpecialKey(), "←"],
label: t("shortcut.navigation.back"),
section: t("shortcut.navigation.title"),
},
{
keys: [getPlatformSpecialKey(), ""],
label: t("shortcut.navigation.forward"),
section: t("shortcut.navigation.title"),
},
{
keys: [getPlatformAlternateKey(), "R"],
label: t("shortcut.navigation.rest"),
section: t("shortcut.navigation.title"),
},
{
keys: [getPlatformAlternateKey(), "Q"],
label: t("shortcut.navigation.graphql"),
section: t("shortcut.navigation.title"),
},
{
keys: [getPlatformAlternateKey(), "W"],
label: t("shortcut.navigation.realtime"),
section: t("shortcut.navigation.title"),
},
{
keys: [getPlatformAlternateKey(), "S"],
label: t("shortcut.navigation.settings"),
section: t("shortcut.navigation.title"),
},
{
keys: [getPlatformAlternateKey(), "M"],
label: t("shortcut.navigation.profile"),
section: t("shortcut.navigation.title"),
},
// Miscellaneous
{
keys: [getPlatformSpecialKey(), "M"],
label: t("shortcut.miscellaneous.invite"),
section: t("shortcut.miscellaneous.title"),
},
]
}

View File

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

View File

@@ -1,13 +1,38 @@
import { HoppModule } from "."
import { Container } from "dioc"
import { Container, Service } from "dioc"
import { diocPlugin } from "dioc/vue"
import { DebugService } from "~/services/debug.service"
const serviceContainer = new Container()
if (import.meta.env.DEV) {
serviceContainer.bind(DebugService)
}
/**
* Gets a service from the app service container. You can use this function
* to get a service if you have no access to the container or if you are not
* in a component (if you are, you can use `useService`) or if you are not in a
* service.
* @param service The class of the service to get
* @returns The service instance
*
* @deprecated This is a temporary escape hatch for legacy code to access
* services. Please use `useService` if within components or try to convert your
* legacy subsystem into a service if possible.
*/
export function getService<T extends typeof Service<any> & { ID: string }>(
service: T
): InstanceType<T> {
return serviceContainer.bind(service)
}
export default <HoppModule>{
onVueAppInit(app) {
// TODO: look into this
// @ts-expect-error Something weird with Vue versions
app.use(diocPlugin, {
container: new Container(),
container: serviceContainer,
})
},
}

View File

@@ -115,6 +115,14 @@ export const changeAppLanguage = async (locale: string) => {
setLocalConfig("locale", locale)
}
/**
* Returns the i18n instance
*/
export function getI18n() {
// @ts-expect-error Something weird with the i18n errors
return i18nInstance!.global.t
}
export default <HoppModule>{
onVueAppInit(app) {
const i18n = createI18n(<I18nOptions>{

View File

@@ -0,0 +1,64 @@
import { Service } from "dioc"
/**
* This service provice debug utilities for the application and is
* supposed to be used only in development.
*
* This service logs events from the container and also events
* from all the services that are bound to the container.
*
* This service injects couple of utilities into the global scope:
* - `_getService(id: string): Service | undefined` - Returns the service instance with the given ID or undefined.
* - `_getBoundServiceIDs(): string[]` - Returns the IDs of all the bound services.
*/
export class DebugService extends Service {
public static readonly ID = "DEBUG_SERVICE"
constructor() {
super()
console.log("DebugService is initialized...")
const container = this.getContainer()
// Log container events
container.getEventStream().subscribe((event) => {
if (event.type === "SERVICE_BIND") {
console.log(
"[CONTAINER] Service Bind:",
event.bounderID ?? "<CONTAINER>",
"->",
event.boundeeID
)
} else if (event.type === "SERVICE_INIT") {
console.log("[CONTAINER] Service Init:", event.serviceID)
// Subscribe to event stream of the newly initialized service
const service = container.getBoundServiceWithID(event.serviceID)
service?.getEventStream().subscribe((ev: any) => {
console.log(`[${event.serviceID}] Event:`, ev)
})
}
})
// Subscribe to event stream of all already bound services (if any)
for (const [id, service] of container.getBoundServices()) {
service.getEventStream().subscribe((event: any) => {
console.log(`[${id}]`, event)
})
}
// Inject debug utilities into the global scope
;(window as any)._getService = this.getService.bind(this)
;(window as any)._getBoundServiceIDs = this.getBoundServiceIDs.bind(this)
}
private getBoundServiceIDs() {
return Array.from(this.getContainer().getBoundServices()).map(([id]) => id)
}
private getService(id: string) {
return this.getContainer().getBoundServiceWithID(id)
}
}

View File

@@ -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 = {
searcherID: "echo-searcher",
searcherSectionTitle: "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 = {
searcherID: "empty-searcher",
searcherSectionTitle: "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 = {
searcherID: "echo-searcher",
searcherSectionTitle: "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 = {
searcherID: "searcher",
searcherSectionTitle: "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 = {
searcherID: "loading-searcher",
searcherSectionTitle: "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 = {
searcherID: "loading-searcher",
searcherSectionTitle: "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 = {
searcherID: "test-searcher",
searcherSectionTitle: "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 = {
searcherID: "test-searcher",
searcherSectionTitle: "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 = {
searcherID: "test-searcher",
searcherSectionTitle: "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],
])
})
})
})

View File

@@ -0,0 +1,216 @@
import { Service } from "dioc"
import { watch, type Ref, ref, reactive, effectScope, Component } from "vue"
/**
* Defines how to render the entry text in a Spotlight Search Result
*/
export type SpotlightResultTextType<T extends object | Component = never> =
| {
type: "text"
/**
* The text to render. Passing an array of strings will render each string separated by a chevron
*/
text: string[] | string
}
| {
type: "custom"
/**
* The component to render in place of the text
*/
component: T
/**
* The props to pass to the component
*/
componentProps: T extends Component<infer Props> ? Props : never
}
/**
* Defines info about a spotlight light so the UI can render it
*/
export type SpotlightSearcherResult = {
/**
* The unique ID of the result
*/
id: string
/**
* The text to render in the result
*/
text: SpotlightResultTextType<any>
/**
* The icon to render as the signifier of the result
*/
icon: object | Component
/**
* The score of the result, the UI should sort the results by this
*/
score: number
/**
* Additional metadata about the result
*/
meta?: {
/**
* The keyboard shortcut to trigger the result
*/
keyboardShortcut?: string[]
}
}
/**
* Defines the state of a searcher during a spotlight search session
*/
export type SpotlightSearcherSessionState = {
/**
* Whether the searcher is currently loading results
*/
loading: boolean
/**
* The results presented by the corresponding searcher in a session
*/
results: SpotlightSearcherResult[]
}
export interface SpotlightSearcher {
searcherID: string
searcherSectionTitle: string
createSearchSession(
query: Readonly<Ref<string>>
): [Ref<SpotlightSearcherSessionState>, () => void]
onResultSelect(result: SpotlightSearcherResult): void
}
/**
* Defines the state of a searcher during a search session that
* is exposed to through the spotlight service
*/
export type SpotlightSearchSearcherState = {
title: string
avgScore: number
results: SpotlightSearcherResult[]
}
/**
* Defines the state of a spotlight search session
*/
export type SpotlightSearchState = {
/**
* Whether any of the searchers are currently loading results
*/
loading: boolean
/**
* The results presented by the corresponding searcher in a session
*/
results: Record<string, SpotlightSearchSearcherState>
}
export class SpotlightService extends Service {
public static readonly ID = "SPOTLIGHT_SERVICE"
private searchers: Map<string, SpotlightSearcher> = new Map()
/**
* Registers a searcher with the spotlight service
* @param searcher The searcher instance to register
*/
public registerSearcher(searcher: SpotlightSearcher) {
this.searchers.set(searcher.searcherID, searcher)
}
/**
* Gets an iterator over all registered searchers and their IDs
*/
public getAllSearchers(): IterableIterator<[string, SpotlightSearcher]> {
return this.searchers.entries()
}
/**
* Creates a new search session
* @param query A ref to the query to search for, updating this ref will notify the searchers about the change
* @returns A ref to the state of the search session and a function to end the session
*/
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.searcherID)
} else {
loadingSearchers.delete(searcher.searcherID)
}
if (newState.results.length === 0) {
delete resultObj.value.results[searcher.searcherID]
} else {
resultObj.value.results[searcher.searcherID] = {
title: searcher.searcherSectionTitle,
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]
}
/**
* Selects a search result. To be called when the user selects a result
* @param searcherID The ID of the searcher that the result belongs to
* @param result The resuklt to look at
*/
public selectSearchResult(
searcherID: string,
result: SpotlightSearcherResult
) {
this.searchers.get(searcherID)?.onResultSelect(result)
}
}

View File

@@ -0,0 +1,211 @@
import { TestContainer } from "dioc/testing"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { HistorySpotlightSearcherService } from "../history.searcher"
import { nextTick, ref } from "vue"
import { SpotlightService } from "../.."
import { RESTHistoryEntry } from "~/newstore/history"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
async function flushPromises() {
return await new Promise((r) => setTimeout(r))
}
const tabMock = vi.hoisted(() => ({
createNewTab: vi.fn(),
}))
vi.mock("~/helpers/rest/tab", () => ({
__esModule: true,
createNewTab: tabMock.createNewTab,
}))
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
const actionsMock = vi.hoisted(() => ({
value: [] as string[],
invokeAction: vi.fn(),
}))
vi.mock("~/helpers/actions", async () => {
const { BehaviorSubject }: any = await vi.importActual("rxjs")
return {
__esModule: true,
activeActions$: new BehaviorSubject(actionsMock.value),
invokeAction: actionsMock.invokeAction,
}
})
const historyMock = vi.hoisted(() => ({
entries: [] as RESTHistoryEntry[],
}))
vi.mock("~/newstore/history", () => ({
__esModule: true,
restHistoryStore: {
value: {
state: historyMock.entries,
},
},
}))
describe("HistorySpotlightSearcherService", () => {
beforeEach(() => {
let x = actionsMock.value.pop()
while (x) {
x = actionsMock.value.pop()
}
let y = historyMock.entries.pop()
while (y) {
y = historyMock.entries.pop()
}
actionsMock.invokeAction.mockReset()
tabMock.createNewTab.mockReset()
})
it("registers with the spotlight service upon initialization", async () => {
const container = new TestContainer()
const registerSearcherFn = vi.fn()
container.bindMock(SpotlightService, {
registerSearcher: registerSearcherFn,
})
const history = container.bind(HistorySpotlightSearcherService)
await flushPromises()
expect(registerSearcherFn).toHaveBeenCalledOnce()
expect(registerSearcherFn).toHaveBeenCalledWith(history)
})
it("returns a clear history result if the action is available", async () => {
const container = new TestContainer()
actionsMock.value.push("history.clear")
const history = container.bind(HistorySpotlightSearcherService)
await flushPromises()
const query = ref("his")
const [result] = history.createSearchSession(query)
await nextTick()
expect(result.value.results).toContainEqual(
expect.objectContaining({
id: "clear-history",
})
)
})
it("doesn't return a clear history result if the action is not available", async () => {
const container = new TestContainer()
const history = container.bind(HistorySpotlightSearcherService)
await flushPromises()
const query = ref("his")
const [result] = history.createSearchSession(query)
await nextTick()
expect(result.value.results).not.toContainEqual(
expect.objectContaining({
id: "clear-history",
})
)
})
it("selecting a clear history entry invokes the clear history action", async () => {
const container = new TestContainer()
actionsMock.value.push("history.clear")
const history = container.bind(HistorySpotlightSearcherService)
await flushPromises()
const query = ref("his")
const [result] = history.createSearchSession(query)
await nextTick()
history.onResultSelect(result.value.results[0])
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
expect(actionsMock.invokeAction).toHaveBeenCalledWith("history.clear")
})
it("returns all the valid history entries for the search term", async () => {
historyMock.entries.push({
request: {
...getDefaultRESTRequest(),
endpoint: "bla.com",
},
responseMeta: {
duration: null,
statusCode: null,
},
star: false,
v: 1,
updatedOn: new Date(),
})
const container = new TestContainer()
const history = container.bind(HistorySpotlightSearcherService)
await flushPromises()
const query = ref("bla")
const [result] = history.createSearchSession(query)
await nextTick()
expect(result.value.results).toContainEqual(
expect.objectContaining({
id: "0",
text: {
type: "custom",
component: expect.anything(),
componentProps: expect.objectContaining({
historyEntry: historyMock.entries[0],
}),
},
})
)
})
it("selecting a history entry asks the tab system to open a new tab", async () => {
historyMock.entries.push({
request: {
...getDefaultRESTRequest(),
endpoint: "bla.com",
},
responseMeta: {
duration: null,
statusCode: null,
},
star: false,
v: 1,
updatedOn: new Date(),
})
const container = new TestContainer()
const history = container.bind(HistorySpotlightSearcherService)
await flushPromises()
const query = ref("bla")
const [result] = history.createSearchSession(query)
await nextTick()
const doc = result.value.results[0]
history.onResultSelect(doc)
expect(tabMock.createNewTab).toHaveBeenCalledOnce()
expect(tabMock.createNewTab).toHaveBeenCalledWith({
request: historyMock.entries[0].request,
isDirty: false,
})
})
})

View File

@@ -0,0 +1,192 @@
import { beforeEach, describe, it, expect, vi } from "vitest"
import { TestContainer } from "dioc/testing"
import { UserSpotlightSearcherService } from "../user.searcher"
import { nextTick, ref } from "vue"
import { SpotlightService } from "../.."
async function flushPromises() {
return await new Promise((r) => setTimeout(r))
}
vi.mock("~/modules/i18n", () => ({
__esModule: true,
getI18n: () => (x: string) => x,
}))
const actionsMock = vi.hoisted(() => ({
value: ["user.login"],
invokeAction: vi.fn(),
}))
vi.mock("~/helpers/actions", async () => {
const { BehaviorSubject }: any = await vi.importActual("rxjs")
return {
__esModule: true,
activeActions$: new BehaviorSubject(actionsMock.value),
invokeAction: actionsMock.invokeAction,
}
})
describe("UserSearcher", () => {
beforeEach(() => {
let x = actionsMock.value.pop()
while (x) {
x = actionsMock.value.pop()
}
actionsMock.invokeAction.mockReset()
})
it("registers with the spotlight service upon initialization", async () => {
const container = new TestContainer()
const registerSearcherFn = vi.fn()
container.bindMock(SpotlightService, {
registerSearcher: registerSearcherFn,
})
const user = container.bind(UserSpotlightSearcherService)
await flushPromises()
expect(registerSearcherFn).toHaveBeenCalledOnce()
expect(registerSearcherFn).toHaveBeenCalledWith(user)
})
it("if login action is available, the search result should have the login result", async () => {
const container = new TestContainer()
actionsMock.value.push("user.login")
const user = container.bind(UserSpotlightSearcherService)
await flushPromises()
const query = ref("log")
const result = user.createSearchSession(query)[0]
await nextTick()
expect(result.value.results).toContainEqual(
expect.objectContaining({
id: "login",
})
)
})
it("if login action is not available, the search result should not have the login result", async () => {
const container = new TestContainer()
const user = container.bind(UserSpotlightSearcherService)
await flushPromises()
const query = ref("log")
const result = user.createSearchSession(query)[0]
await nextTick()
expect(result.value.results).not.toContainEqual(
expect.objectContaining({
id: "login",
})
)
})
it("if logout action is available, the search result should have the logout result", async () => {
const container = new TestContainer()
actionsMock.value.push("user.logout")
const user = container.bind(UserSpotlightSearcherService)
await flushPromises()
const query = ref("log")
const result = user.createSearchSession(query)[0]
await nextTick()
expect(result.value.results).toContainEqual(
expect.objectContaining({
id: "logout",
})
)
})
it("if logout action is not available, the search result should not have the logout result", async () => {
const container = new TestContainer()
const user = container.bind(UserSpotlightSearcherService)
await flushPromises()
const query = ref("log")
const result = user.createSearchSession(query)[0]
await nextTick()
expect(result.value.results).not.toContainEqual(
expect.objectContaining({
id: "logout",
})
)
})
it("if login action and logout action are available, the search result should have both results", async () => {
const container = new TestContainer()
actionsMock.value.push("user.login", "user.logout")
const user = container.bind(UserSpotlightSearcherService)
await flushPromises()
const query = ref("log")
const result = user.createSearchSession(query)[0]
await nextTick()
expect(result.value.results).toContainEqual(
expect.objectContaining({
id: "logout",
})
)
expect(result.value.results).toContainEqual(
expect.objectContaining({
id: "login",
})
)
})
it("selecting the login event should invoke the login action", () => {
const container = new TestContainer()
actionsMock.value.push("user.login")
const user = container.bind(UserSpotlightSearcherService)
const query = ref("log")
user.createSearchSession(query)[0]
user.onDocSelected("login")
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
expect(actionsMock.invokeAction).toHaveBeenCalledWith("user.login")
})
it("selecting the logout event should invoke the logout action", () => {
const container = new TestContainer()
actionsMock.value.push("user.logout")
const user = container.bind(UserSpotlightSearcherService)
const query = ref("log")
user.createSearchSession(query)[0]
user.onDocSelected("logout")
expect(actionsMock.invokeAction).toHaveBeenCalledOnce()
expect(actionsMock.invokeAction).toHaveBeenCalledWith("user.logout")
})
it("selecting an invalid event should not invoke any action", () => {
const container = new TestContainer()
actionsMock.value.push("user.logout")
const user = container.bind(UserSpotlightSearcherService)
const query = ref("log")
user.createSearchSession(query)[0]
user.onDocSelected("bla")
expect(actionsMock.invokeAction).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,403 @@
import { describe, it, expect, vi } from "vitest"
import {
SearchResult,
StaticSpotlightSearcherService,
} from "../static.searcher"
import { nextTick, reactive, ref } from "vue"
import { SpotlightSearcherResult } from "../../.."
import { TestContainer } from "dioc/testing"
async function flushPromises() {
return await new Promise((r) => setTimeout(r))
}
describe("StaticSpotlightSearcherService", () => {
it("returns docs that have excludeFromSearch set to false", async () => {
class TestSearcherService extends StaticSpotlightSearcherService<
Record<string, any>
> {
public static readonly ID = "TEST_SEARCHER_SERVICE"
public readonly searcherID = "test"
public searcherSectionTitle = "test"
private documents: Record<string, any> = reactive({
login: {
text: "Login",
excludeFromSearch: false,
},
logout: {
text: "Logout",
excludeFromSearch: false,
},
})
constructor() {
super({
searchFields: ["text"],
fieldWeights: {},
})
this.setDocuments(this.documents)
}
protected getSearcherResultForSearchResult(
result: SearchResult<Record<string, any>>
): SpotlightSearcherResult {
return {
id: result.id,
icon: {},
text: { type: "text", text: result.doc.text },
score: result.score,
}
}
public onDocSelected(): void {
// noop
}
}
const container = new TestContainer()
const service = container.bind(TestSearcherService)
await flushPromises()
const query = ref("log")
const [results] = service.createSearchSession(query)
await nextTick()
expect(results.value.results).toContainEqual(
expect.objectContaining({
id: "login",
})
)
})
it("doesn't return docs that have excludeFromSearch set to true", async () => {
class TestSearcherServiceB extends StaticSpotlightSearcherService<
Record<string, any>
> {
public static readonly ID = "TEST_SEARCHER_SERVICE_B"
public readonly searcherID = "test"
public searcherSectionTitle = "test"
private documents: Record<string, any> = reactive({
login: {
text: "Login",
excludeFromSearch: true,
},
logout: {
text: "Logout",
excludeFromSearch: false,
},
})
constructor() {
super({
searchFields: ["text"],
fieldWeights: {},
})
this.setDocuments(this.documents)
}
protected getSearcherResultForSearchResult(
result: SearchResult<Record<string, any>>
): SpotlightSearcherResult {
return {
id: result.id,
icon: {},
text: { type: "text", text: result.doc.text },
score: result.score,
}
}
public onDocSelected(): void {
// noop
}
}
const container = new TestContainer()
const service = container.bind(TestSearcherServiceB)
await flushPromises()
const query = ref("log")
const [results] = service.createSearchSession(query)
await nextTick()
expect(results.value.results).not.toContainEqual(
expect.objectContaining({
id: "login",
})
)
expect(results.value.results).toContainEqual(
expect.objectContaining({
id: "logout",
})
)
})
it("returns docs that have excludeFromSearch set to undefined", async () => {
class TestSearcherServiceC extends StaticSpotlightSearcherService<
Record<string, any>
> {
public static readonly ID = "TEST_SEARCHER_SERVICE_C"
public readonly searcherID = "test"
public searcherSectionTitle = "test"
private documents: Record<string, any> = reactive({
login: {
text: "Login",
},
logout: {
text: "Logout",
},
})
constructor() {
super({
searchFields: ["text"],
fieldWeights: {},
})
this.setDocuments(this.documents)
}
protected getSearcherResultForSearchResult(
result: SearchResult<Record<string, any>>
): SpotlightSearcherResult {
return {
id: result.id,
icon: {},
text: { type: "text", text: result.doc.text },
score: result.score,
}
}
public onDocSelected(): void {
// noop
}
}
const container = new TestContainer()
const service = container.bind(TestSearcherServiceC)
await flushPromises()
const query = ref("log")
const [results] = service.createSearchSession(query)
await nextTick()
expect(results.value.results).toContainEqual(
expect.objectContaining({
id: "login",
})
)
expect(results.value.results).toContainEqual(
expect.objectContaining({
id: "logout",
})
)
})
it("onDocSelected is called with a valid doc id and doc when onResultSelect is called", async () => {
class TestSearcherServiceD extends StaticSpotlightSearcherService<
Record<string, any>
> {
public static readonly ID = "TEST_SEARCHER_SERVICE_D"
public readonly searcherID = "test"
public searcherSectionTitle = "test"
public documents: Record<string, any> = reactive({
login: {
text: "Login",
},
logout: {
text: "Logout",
},
})
constructor() {
super({
searchFields: ["text"],
fieldWeights: {},
})
this.setDocuments(this.documents)
}
protected getSearcherResultForSearchResult(
result: SearchResult<Record<string, any>>
): SpotlightSearcherResult {
return {
id: result.id,
icon: {},
text: { type: "text", text: result.doc.text },
score: result.score,
}
}
public onDocSelected = vi.fn()
}
const container = new TestContainer()
const service = container.bind(TestSearcherServiceD)
await flushPromises()
const query = ref("log")
const [results] = service.createSearchSession(query)
await nextTick()
const doc = results.value.results[0]
service.onResultSelect(doc)
expect(service.onDocSelected).toHaveBeenCalledOnce()
expect(service.onDocSelected).toHaveBeenCalledWith(
doc.id,
service.documents["login"]
)
})
it("returns search results from entries as specified by getSearcherResultForSearchResult", async () => {
class TestSearcherServiceE extends StaticSpotlightSearcherService<
Record<string, any>
> {
public static readonly ID = "TEST_SEARCHER_SERVICE_E"
public readonly searcherID = "test"
public searcherSectionTitle = "test"
public documents: Record<string, any> = reactive({
login: {
text: "Login",
},
logout: {
text: "Logout",
},
})
constructor() {
super({
searchFields: ["text"],
fieldWeights: {},
})
this.setDocuments(this.documents)
}
protected getSearcherResultForSearchResult(
result: SearchResult<Record<string, any>>
): SpotlightSearcherResult {
return {
id: result.id,
icon: {},
text: { type: "text", text: result.doc.text.toUpperCase() },
score: result.score,
}
}
public onDocSelected(): void {
// noop
}
}
const container = new TestContainer()
const service = container.bind(TestSearcherServiceE)
await flushPromises()
const query = ref("log")
const [results] = service.createSearchSession(query)
await nextTick()
expect(results.value.results).toContainEqual(
expect.objectContaining({
id: "login",
text: { type: "text", text: "LOGIN" },
})
)
expect(results.value.results).toContainEqual(
expect.objectContaining({
id: "logout",
text: { type: "text", text: "LOGOUT" },
})
)
})
it("indexes the documents by the 'searchFields' property and obeys multiple index fields", async () => {
class TestSearcherServiceF extends StaticSpotlightSearcherService<
Record<string, any>
> {
public static readonly ID = "TEST_SEARCHER_SERVICE_F"
public readonly searcherID = "test"
public searcherSectionTitle = "test"
public documents: Record<string, any> = reactive({
login: {
text: "Login",
alternate: ["sign in"],
},
logout: {
text: "Logout",
alternate: ["sign out"],
},
})
constructor() {
super({
searchFields: ["text", "alternate"],
fieldWeights: {},
})
this.setDocuments(this.documents)
}
protected getSearcherResultForSearchResult(
result: SearchResult<Record<string, any>>
): SpotlightSearcherResult {
return {
id: result.id,
icon: {},
text: { type: "text", text: result.doc.text },
score: result.score,
}
}
public onDocSelected(): void {
// noop
}
}
const container = new TestContainer()
const service = container.bind(TestSearcherServiceF)
await flushPromises()
const query = ref("sign")
const [results] = service.createSearchSession(query)
await nextTick()
expect(results.value.results).toContainEqual(
expect.objectContaining({
id: "login",
})
)
expect(results.value.results).toContainEqual(
expect.objectContaining({
id: "logout",
})
)
})
})

View File

@@ -0,0 +1,175 @@
import { Service } from "dioc"
import {
type SpotlightSearcher,
type SpotlightSearcherResult,
type SpotlightSearcherSessionState,
} from "../.."
import MiniSearch from "minisearch"
import { Ref, computed, effectScope, ref, watch } from "vue"
import { resolveUnref } from "@vueuse/core"
/**
* Defines a search result and additional metadata returned by a StaticSpotlightSearcher
*/
export type SearchResult<Doc extends object & { excludeFromSearch?: boolean }> =
{
id: string
score: number
doc: Doc
}
/**
* Options for StaticSpotlightSearcher initialization
*/
export type StaticSpotlightSearcherOptions<
Doc extends object & { excludeFromSearch?: boolean }
> = {
/**
* The array of field names in the given documents to search against
*/
searchFields: Array<keyof Doc>
/**
* The weights to apply to each field in the search, this allows for certain
* fields to have more priority than others in the search and update the score
*/
fieldWeights?: Partial<Record<keyof Doc, number>>
/**
* How much the score should be boosted if the search matched fuzzily.
* Increasing this value generally makes the search ignore typos, but reduces performance
*/
fuzzyWeight?: number
/**
* How much the score should be boosted if the search matched by prefix.
* For e.g, when searching for "hop", "hoppscotch" would match by prefix.
*/
prefixWeight?: number
}
/**
* A base class for SpotlightSearcherServices that have a static set of documents
* that can optionally be toggled against (via the `excludeFromSearch` property in the Doc)
*/
export abstract class StaticSpotlightSearcherService<
Doc extends object & { excludeFromSearch?: boolean }
>
extends Service
implements SpotlightSearcher
{
public abstract readonly searcherID: string
public abstract readonly searcherSectionTitle: string
private minisearch: MiniSearch
private loading = ref(false)
private _documents: Record<string, Doc> = {}
constructor(private opts: StaticSpotlightSearcherOptions<Doc>) {
super()
this.minisearch = new MiniSearch({
fields: opts.searchFields as string[],
})
}
/**
* Sets the documents to search against.
* NOTE: We generally expect this function to only be called once and we expect
* the documents to not change generally. You can pass a reactive object, if you want to toggle
* states if you want to.
* @param docs The documents to search against, this is an object, with the key being the document ID
*/
protected setDocuments(docs: Record<string, Doc>) {
this._documents = docs
this.addDocsToSearchIndex()
}
private async addDocsToSearchIndex() {
this.loading.value = true
this.minisearch.removeAll()
this.minisearch.vacuum()
await this.minisearch.addAllAsync(
Object.entries(this._documents).map(([id, doc]) => ({
id,
...doc,
}))
)
this.loading.value = false
}
/**
* Specifies how to convert a document into the Spotlight entry format
* @param result The search result to convert
*/
protected abstract getSearcherResultForSearchResult(
result: SearchResult<Doc>
): 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, () => this._documents],
([query, docs]) => {
const searchResults = this.minisearch.search(query, {
prefix: true,
boost: (this.opts.fieldWeights as any) ?? {},
weights: {
fuzzy: this.opts.fuzzyWeight ?? 0.2,
prefix: this.opts.prefixWeight ?? 0.6,
},
})
results.value = searchResults
.filter(
(result) =>
this._documents[result.id].excludeFromSearch === undefined ||
this._documents[result.id].excludeFromSearch === false
)
.map((result) => {
return this.getSearcherResultForSearchResult({
id: result.id,
score: result.score,
doc: docs[result.id],
})
})
},
{ immediate: true }
)
})
const onSessionEnd = () => {
scopeHandle.stop()
}
return [resultObj, onSessionEnd]
}
/**
* Called when a document is selected from the search results
* @param id The ID of the document selected
* @param doc The document information of the document selected
*/
public abstract onDocSelected(id: string, doc: Doc): void
public onResultSelect(result: SpotlightSearcherResult): void {
this.onDocSelected(result.id, resolveUnref(this._documents)[result.id])
}
}

View File

@@ -0,0 +1,183 @@
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"
/**
* This searcher is responsible for searching through the history.
* It also provides actions to clear the history.
*
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
*/
export class HistorySpotlightSearcherService
extends Service
implements SpotlightSearcher
{
public static readonly ID = "HISTORY_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public searcherID = "history"
public searcherSectionTitle = 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) {
if (minisearch.has("clear-history")) return
minisearch.add({
id: "clear-history",
title: this.t("action.clear_history"),
})
} else {
if (!minisearch.has("clear-history")) return
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, this.clearHistoryActionEnabled],
([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,
},
},
}
})
},
{ immediate: true }
)
})
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,
})
}
}

View File

@@ -0,0 +1,96 @@
import { SpotlightSearcherResult, SpotlightService } from ".."
import {
SearchResult,
StaticSpotlightSearcherService,
} from "./base/static.searcher"
import { getI18n } from "~/modules/i18n"
import { Component, computed, markRaw, reactive } from "vue"
import { useStreamStatic } from "~/composables/stream"
import IconLogin from "~icons/lucide/log-in"
import IconLogOut from "~icons/lucide/log-out"
import { activeActions$, invokeAction } from "~/helpers/actions"
type Doc = {
text: string
excludeFromSearch?: boolean
alternates: string[]
icon: object | Component
}
/**
* This searcher is responsible for providing user related actions on the spotlight results.
*
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
*/
export class UserSpotlightSearcherService extends StaticSpotlightSearcherService<Doc> {
public static readonly ID = "USER_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public readonly searcherID = "user"
public searcherSectionTitle = this.t("spotlight.section.user")
private readonly spotlight = this.bind(SpotlightService)
private activeActions = useStreamStatic(activeActions$, [], () => {
/* noop */
})[0]
private hasLoginAction = computed(() =>
this.activeActions.value.includes("user.login")
)
private hasLogoutAction = computed(() =>
this.activeActions.value.includes("user.logout")
)
private documents: Record<string, Doc> = reactive({
login: {
text: this.t("auth.login"),
excludeFromSearch: computed(() => !this.hasLoginAction.value),
alternates: ["sign in"],
icon: markRaw(IconLogin),
},
logout: {
text: this.t("auth.logout"),
excludeFromSearch: computed(() => !this.hasLogoutAction.value),
alternates: ["sign out"],
icon: markRaw(IconLogOut),
},
})
constructor() {
super({
searchFields: ["text", "alternates"],
fieldWeights: {
text: 2,
alternates: 1,
},
})
this.setDocuments(this.documents)
this.spotlight.registerSearcher(this)
}
protected getSearcherResultForSearchResult(
result: SearchResult<Doc>
): SpotlightSearcherResult {
return {
id: result.id,
icon: result.doc.icon,
text: { type: "text", text: result.doc.text },
score: result.score,
}
}
public onDocSelected(id: string): void {
switch (id) {
case "login":
invokeAction("user.login")
break
case "logout":
invokeAction("user.logout")
break
}
}
}

View File

@@ -1,5 +1,8 @@
import { defineConfig } from "vitest/config"
import * as path from "path"
import Icons from "unplugin-icons/vite"
import { FileSystemIconLoader } from "unplugin-icons/loaders"
import Vue from "@vitejs/plugin-vue"
export default defineConfig({
test: {
@@ -9,6 +12,23 @@ export default defineConfig({
resolve: {
alias: {
"~": path.resolve(__dirname, "../hoppscotch-common/src"),
"@composables": path.resolve(
__dirname,
"../hoppscotch-common/src/composables"
),
},
},
plugins: [
Vue(),
Icons({
compiler: "vue3",
customCollections: {
hopp: FileSystemIconLoader("../hoppscotch-common/assets/icons"),
auth: FileSystemIconLoader("../hoppscotch-common/assets/icons/auth"),
brands: FileSystemIconLoader(
"../hoppscotch-common/assets/icons/brands"
),
},
}) as any,
],
})

1073
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff