Compare commits
18 Commits
feat/migra
...
pr/AndrewB
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a60303ef7c | ||
|
|
53e4863a80 | ||
|
|
3340d0813c | ||
|
|
7c5722586c | ||
|
|
6b38363fab | ||
|
|
14202a3eed | ||
|
|
ea03223b8e | ||
|
|
235deb113c | ||
|
|
833e11ab0b | ||
|
|
0d101673d2 | ||
|
|
6fe565c30f | ||
|
|
4164de5a9e | ||
|
|
1ff35f45ee | ||
|
|
3bf8288de3 | ||
|
|
8c48d41eed | ||
|
|
38215be3bd | ||
|
|
edf57da9be | ||
|
|
be61b62825 |
@@ -4,6 +4,7 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"choose_file": "Choose a file",
|
"choose_file": "Choose a file",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
|
"clear_history": "Clear All History",
|
||||||
"clear_all": "Clear all",
|
"clear_all": "Clear all",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"connect": "Connect",
|
"connect": "Connect",
|
||||||
@@ -582,6 +583,11 @@
|
|||||||
"log": "Log",
|
"log": "Log",
|
||||||
"url": "URL"
|
"url": "URL"
|
||||||
},
|
},
|
||||||
|
"spotlight": {
|
||||||
|
"section": {
|
||||||
|
"user": "User"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sse": {
|
"sse": {
|
||||||
"event_type": "Event type",
|
"event_type": "Event type",
|
||||||
"log": "Log",
|
"log": "Log",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
"@urql/exchange-auth": "^0.1.7",
|
"@urql/exchange-auth": "^0.1.7",
|
||||||
"@urql/exchange-graphcache": "^4.4.3",
|
"@urql/exchange-graphcache": "^4.4.3",
|
||||||
"@vitejs/plugin-legacy": "^2.3.0",
|
"@vitejs/plugin-legacy": "^2.3.0",
|
||||||
"@vueuse/core": "^8.7.5",
|
"@vueuse/core": "^8.9.4",
|
||||||
"@vueuse/head": "^0.7.9",
|
"@vueuse/head": "^0.7.9",
|
||||||
"acorn-walk": "^8.2.0",
|
"acorn-walk": "^8.2.0",
|
||||||
"axios": "^0.21.4",
|
"axios": "^0.21.4",
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
"jsonpath-plus": "^7.0.0",
|
"jsonpath-plus": "^7.0.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lossless-json": "^2.0.8",
|
"lossless-json": "^2.0.8",
|
||||||
|
"minisearch": "^6.1.0",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"paho-mqtt": "^1.1.0",
|
"paho-mqtt": "^1.1.0",
|
||||||
"path": "^0.12.7",
|
"path": "^0.12.7",
|
||||||
|
|||||||
@@ -11,20 +11,20 @@ declare module '@vue/runtime-core' {
|
|||||||
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
|
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
|
||||||
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
|
AppDeveloperOptions: typeof import('./components/app/DeveloperOptions.vue')['default']
|
||||||
AppFooter: typeof import('./components/app/Footer.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']
|
AppGitHubStarButton: typeof import('./components/app/GitHubStarButton.vue')['default']
|
||||||
AppHeader: typeof import('./components/app/Header.vue')['default']
|
AppHeader: typeof import('./components/app/Header.vue')['default']
|
||||||
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
|
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
|
||||||
AppLogo: typeof import('./components/app/Logo.vue')['default']
|
AppLogo: typeof import('./components/app/Logo.vue')['default']
|
||||||
AppOptions: typeof import('./components/app/Options.vue')['default']
|
AppOptions: typeof import('./components/app/Options.vue')['default']
|
||||||
AppPaneLayout: typeof import('./components/app/PaneLayout.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']
|
AppShare: typeof import('./components/app/Share.vue')['default']
|
||||||
AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default']
|
AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default']
|
||||||
AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default']
|
AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default']
|
||||||
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
|
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
|
||||||
AppSidenav: typeof import('./components/app/Sidenav.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']
|
AppSupport: typeof import('./components/app/Support.vue')['default']
|
||||||
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
|
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
|
||||||
ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.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']
|
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
|
||||||
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
|
||||||
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
|
||||||
|
IconLucideRefreshCw: typeof import('~icons/lucide/refresh-cw')['default']
|
||||||
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
IconLucideSearch: typeof import('~icons/lucide/search')['default']
|
||||||
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
IconLucideUsers: typeof import('~icons/lucide/users')['default']
|
||||||
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.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>
|
|
||||||
@@ -242,7 +242,7 @@ import { pwaDefferedPrompt, installPWA } from "@modules/pwa"
|
|||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { invokeAction } from "@helpers/actions"
|
import { defineActionHandler, invokeAction } from "@helpers/actions"
|
||||||
import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
|
import { workspaceStatus$, updateWorkspaceTeamName } from "~/newstore/workspace"
|
||||||
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
import TeamListAdapter from "~/helpers/teams/TeamListAdapter"
|
||||||
import { onLoggedIn } from "~/composables/auth"
|
import { onLoggedIn } from "~/composables/auth"
|
||||||
@@ -374,4 +374,12 @@ const profile = ref<any | null>(null)
|
|||||||
const settings = ref<any | null>(null)
|
const settings = ref<any | null>(null)
|
||||||
const logout = ref<any | null>(null)
|
const logout = ref<any | null>(null)
|
||||||
const accountActions = ref<any | null>(null)
|
const accountActions = ref<any | null>(null)
|
||||||
|
|
||||||
|
defineActionHandler(
|
||||||
|
"user.login",
|
||||||
|
() => {
|
||||||
|
invokeAction("modals.login.toggle")
|
||||||
|
},
|
||||||
|
computed(() => !currentUser.value)
|
||||||
|
)
|
||||||
</script>
|
</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>
|
|
||||||
@@ -14,46 +14,18 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="filterText" class="flex flex-col divide-y divide-dividerLight">
|
<div class="flex flex-col divide-y divide-dividerLight">
|
||||||
<details
|
<HoppSmartPlaceholder v-if="isEmpty(shortcutsResults)">
|
||||||
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}”`"
|
|
||||||
>
|
|
||||||
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
<icon-lucide-search class="pb-2 opacity-75 svg-icons" />
|
||||||
<span class="my-2 text-center flex flex-col">
|
<span class="my-2 text-center flex flex-col">
|
||||||
{{ t("state.nothing_found") }}
|
{{ t("state.nothing_found") }}
|
||||||
<span class="break-all">"{{ filterText }}"</span>
|
<span class="break-all">"{{ filterText }}"</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</HoppSmartPlaceholder>
|
||||||
</div>
|
|
||||||
<div v-else class="flex flex-col divide-y divide-dividerLight">
|
|
||||||
<details
|
<details
|
||||||
v-for="(map, mapIndex) in mappings"
|
v-for="(sectionResults, sectionTitle) in shortcutsResults"
|
||||||
:key="`map-${mapIndex}`"
|
v-else
|
||||||
|
:key="`section-${sectionTitle}`"
|
||||||
class="flex flex-col"
|
class="flex flex-col"
|
||||||
open
|
open
|
||||||
>
|
>
|
||||||
@@ -64,13 +36,13 @@
|
|||||||
<span
|
<span
|
||||||
class="font-semibold truncate capitalize-first text-secondaryDark"
|
class="font-semibold truncate capitalize-first text-secondaryDark"
|
||||||
>
|
>
|
||||||
{{ t(map.section) }}
|
{{ sectionTitle }}
|
||||||
</span>
|
</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="flex flex-col px-6 pb-4 space-y-2">
|
<div class="flex flex-col px-6 pb-4 space-y-2">
|
||||||
<AppShortcutsEntry
|
<AppShortcutsEntry
|
||||||
v-for="(shortcut, shortcutIndex) in map.shortcuts"
|
v-for="(shortcut, index) in sectionResults"
|
||||||
:key="`map-${mapIndex}-shortcut-${shortcutIndex}`"
|
:key="`shortcut-${index}`"
|
||||||
:shortcut="shortcut"
|
:shortcut="shortcut"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,10 +53,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue"
|
import { computed, onBeforeMount, ref } from "vue"
|
||||||
import Fuse from "fuse.js"
|
import { ShortcutDef, getShortcuts } from "~/helpers/shortcuts"
|
||||||
import mappings from "~/helpers/shortcuts"
|
import MiniSearch from "minisearch"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
|
import { groupBy, isEmpty } from "lodash-es"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
@@ -92,15 +65,33 @@ defineProps<{
|
|||||||
show: boolean
|
show: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const options = {
|
const minisearch = new MiniSearch({
|
||||||
keys: ["shortcuts.label"],
|
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 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<{
|
const emit = defineEmits<{
|
||||||
(e: "close"): void
|
(e: "close"): void
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center py-1">
|
<div class="flex items-center py-1">
|
||||||
<span class="flex flex-1 mr-4">
|
<span class="flex flex-1 mr-4">
|
||||||
{{ t(shortcut.label) }}
|
{{ shortcut.label }}
|
||||||
</span>
|
</span>
|
||||||
<kbd
|
<kbd
|
||||||
v-for="(key, index) in shortcut.keys"
|
v-for="(key, index) in shortcut.keys"
|
||||||
@@ -14,14 +14,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from "@composables/i18n"
|
import { ShortcutDef } from "~/helpers/shortcuts"
|
||||||
|
|
||||||
const t = useI18n()
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
shortcut: {
|
shortcut: ShortcutDef
|
||||||
label: string
|
|
||||||
keys: string[]
|
|
||||||
}
|
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex" @click="OpenLogoutModal()">
|
<div class="flex" @click="openLogoutModal()">
|
||||||
<HoppSmartItem
|
<HoppSmartItem
|
||||||
ref="logoutItem"
|
ref="logoutItem"
|
||||||
:icon="IconLogOut"
|
:icon="IconLogOut"
|
||||||
:label="`${t('auth.logout')}`"
|
:label="`${t('auth.logout')}`"
|
||||||
:outline="outline"
|
:outline="outline"
|
||||||
:shortcut="shortcut"
|
:shortcut="shortcut"
|
||||||
@click="OpenLogoutModal()"
|
@click="openLogoutModal()"
|
||||||
/>
|
/>
|
||||||
<HoppSmartConfirmModal
|
<HoppSmartConfirmModal
|
||||||
:show="confirmLogout"
|
:show="confirmLogout"
|
||||||
@@ -23,6 +23,7 @@ import IconLogOut from "~icons/lucide/log-out"
|
|||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
|
import { defineActionHandler } from "~/helpers/actions"
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
outline: {
|
outline: {
|
||||||
@@ -55,8 +56,12 @@ const logout = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const OpenLogoutModal = () => {
|
const openLogoutModal = () => {
|
||||||
emit("confirm-logout")
|
emit("confirm-logout")
|
||||||
confirmLogout.value = true
|
confirmLogout.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineActionHandler("user.logout", () => {
|
||||||
|
openLogoutModal()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ import {
|
|||||||
import HistoryRestCard from "./rest/Card.vue"
|
import HistoryRestCard from "./rest/Card.vue"
|
||||||
import HistoryGraphqlCard from "./graphql/Card.vue"
|
import HistoryGraphqlCard from "./graphql/Card.vue"
|
||||||
import { createNewTab } from "~/helpers/rest/tab"
|
import { createNewTab } from "~/helpers/rest/tab"
|
||||||
|
import { defineActionHandler } from "~/helpers/actions"
|
||||||
|
|
||||||
type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
|
type HistoryEntry = GQLHistoryEntry | RESTHistoryEntry
|
||||||
|
|
||||||
@@ -329,4 +330,8 @@ const toggleStar = (entry: HistoryEntry) => {
|
|||||||
toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
|
toggleRESTHistoryEntryStar(entry as RESTHistoryEntry)
|
||||||
else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
|
else toggleGraphqlHistoryEntryStar(entry as GQLHistoryEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineActionHandler("history.clear", () => {
|
||||||
|
confirmRemove.value = true
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* For example, sending a request.
|
* For example, sending a request.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { onBeforeUnmount, onMounted } from "vue"
|
import { Ref, onBeforeUnmount, onMounted, watch } from "vue"
|
||||||
import { BehaviorSubject } from "rxjs"
|
import { BehaviorSubject } from "rxjs"
|
||||||
|
|
||||||
export type HoppAction =
|
export type HoppAction =
|
||||||
@@ -38,6 +38,9 @@ export type HoppAction =
|
|||||||
| "response.file.download" // Download response as file
|
| "response.file.download" // Download response as file
|
||||||
| "response.copy" // Copy response to clipboard
|
| "response.copy" // Copy response to clipboard
|
||||||
| "modals.login.toggle" // Login to Hoppscotch
|
| "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
|
* 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[])
|
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>(
|
export function defineActionHandler<A extends HoppAction>(
|
||||||
action: A,
|
action: A,
|
||||||
handler: ActionFunc<A>
|
handler: ActionFunc<A>,
|
||||||
|
isActive: Ref<boolean> | undefined = undefined
|
||||||
) {
|
) {
|
||||||
|
let mounted = false
|
||||||
|
let bound = true
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
mounted = true
|
||||||
|
bound = true
|
||||||
|
|
||||||
bindAction(action, handler)
|
bindAction(action, handler)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
mounted = false
|
||||||
|
bound = false
|
||||||
|
|
||||||
unbindAction(action, handler)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
import { getPlatformAlternateKey, getPlatformSpecialKey } from "./platformutils"
|
||||||
|
|
||||||
export default [
|
export type ShortcutDef = {
|
||||||
{
|
label: string
|
||||||
section: "shortcut.general.title",
|
keys: string[]
|
||||||
shortcuts: [
|
section: string
|
||||||
{
|
}
|
||||||
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 const spotlight = [
|
export function getShortcuts(t: (x: string) => string): ShortcutDef[] {
|
||||||
{
|
// General
|
||||||
section: "app.spotlight",
|
return [
|
||||||
shortcuts: [
|
{
|
||||||
{
|
label: t("shortcut.general.help_menu"),
|
||||||
keys: ["?"],
|
keys: ["?"],
|
||||||
label: "shortcut.general.help_menu",
|
section: t("shortcut.general.title"),
|
||||||
action: "modals.support.toggle",
|
},
|
||||||
icon: IconLifeBuoy,
|
{
|
||||||
},
|
label: t("shortcut.general.command_menu"),
|
||||||
{
|
keys: ["/"],
|
||||||
keys: [getPlatformSpecialKey(), "K"],
|
section: t("shortcut.general.title"),
|
||||||
label: "shortcut.general.show_all",
|
},
|
||||||
action: "flyouts.keybinds.toggle",
|
{
|
||||||
icon: IconZap,
|
label: t("shortcut.general.show_all"),
|
||||||
},
|
keys: [getPlatformSpecialKey(), "K"],
|
||||||
],
|
section: t("shortcut.general.title"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
section: "shortcut.navigation.title",
|
label: t("shortcut.general.close_current_menu"),
|
||||||
shortcuts: [
|
keys: ["ESC"],
|
||||||
{
|
section: t("shortcut.general.title"),
|
||||||
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 const fuse = [
|
// Request
|
||||||
{
|
{
|
||||||
keys: ["?"],
|
label: t("shortcut.request.send_request"),
|
||||||
label: "shortcut.general.help_menu",
|
keys: [getPlatformSpecialKey(), "↩"],
|
||||||
action: "modals.support.toggle",
|
section: t("shortcut.request.title"),
|
||||||
icon: IconLifeBuoy,
|
},
|
||||||
tags: [
|
{
|
||||||
"help",
|
keys: [getPlatformSpecialKey(), "S"],
|
||||||
"support",
|
label: t("shortcut.request.save_to_collections"),
|
||||||
"menu",
|
section: t("shortcut.request.title"),
|
||||||
"discord",
|
},
|
||||||
"twitter",
|
{
|
||||||
"documentation",
|
keys: [getPlatformSpecialKey(), "U"],
|
||||||
"troubleshooting",
|
label: t("shortcut.request.copy_request_link"),
|
||||||
"chat",
|
section: t("shortcut.request.title"),
|
||||||
"community",
|
},
|
||||||
"feedback",
|
{
|
||||||
"report",
|
keys: [getPlatformSpecialKey(), "I"],
|
||||||
"bug",
|
label: t("shortcut.request.reset_request"),
|
||||||
"issue",
|
section: t("shortcut.request.title"),
|
||||||
"ticket",
|
},
|
||||||
],
|
{
|
||||||
},
|
keys: [getPlatformAlternateKey(), "↑"],
|
||||||
{
|
label: t("shortcut.request.next_method"),
|
||||||
keys: [getPlatformSpecialKey(), "K"],
|
section: t("shortcut.request.title"),
|
||||||
label: "shortcut.general.show_all",
|
},
|
||||||
action: "flyouts.keybinds.toggle",
|
{
|
||||||
icon: IconZap,
|
keys: [getPlatformAlternateKey(), "↓"],
|
||||||
tags: ["keyboard", "shortcuts"],
|
label: t("shortcut.request.previous_method"),
|
||||||
},
|
section: t("shortcut.request.title"),
|
||||||
{
|
},
|
||||||
keys: [getPlatformAlternateKey(), "R"],
|
{
|
||||||
label: "shortcut.navigation.rest",
|
keys: [getPlatformAlternateKey(), "G"],
|
||||||
action: "navigation.jump.rest",
|
label: t("shortcut.request.get_method"),
|
||||||
icon: IconArrowRight,
|
section: t("shortcut.request.title"),
|
||||||
tags: ["rest", "jump", "page", "navigation", "go"],
|
},
|
||||||
},
|
{
|
||||||
{
|
keys: [getPlatformAlternateKey(), "H"],
|
||||||
keys: [getPlatformAlternateKey(), "Q"],
|
label: t("shortcut.request.head_method"),
|
||||||
label: "shortcut.navigation.graphql",
|
section: t("shortcut.request.title"),
|
||||||
action: "navigation.jump.graphql",
|
},
|
||||||
icon: IconArrowRight,
|
{
|
||||||
tags: ["graphql", "jump", "page", "navigation", "go"],
|
keys: [getPlatformAlternateKey(), "P"],
|
||||||
},
|
label: t("shortcut.request.post_method"),
|
||||||
{
|
section: t("shortcut.request.title"),
|
||||||
keys: [getPlatformAlternateKey(), "W"],
|
},
|
||||||
label: "shortcut.navigation.realtime",
|
{
|
||||||
action: "navigation.jump.realtime",
|
keys: [getPlatformAlternateKey(), "U"],
|
||||||
icon: IconArrowRight,
|
label: t("shortcut.request.put_method"),
|
||||||
tags: [
|
section: t("shortcut.request.title"),
|
||||||
"realtime",
|
},
|
||||||
"jump",
|
{
|
||||||
"page",
|
keys: [getPlatformAlternateKey(), "X"],
|
||||||
"navigation",
|
label: t("shortcut.request.delete_method"),
|
||||||
"websocket",
|
section: t("shortcut.request.title"),
|
||||||
"socket",
|
},
|
||||||
"mqtt",
|
|
||||||
"sse",
|
// Response
|
||||||
"go",
|
{
|
||||||
],
|
keys: [getPlatformSpecialKey(), "J"],
|
||||||
},
|
label: t("shortcut.response.download"),
|
||||||
{
|
section: t("shortcut.response.title"),
|
||||||
keys: [getPlatformAlternateKey(), "S"],
|
},
|
||||||
label: "shortcut.navigation.settings",
|
{
|
||||||
action: "navigation.jump.settings",
|
keys: [getPlatformSpecialKey(), "."],
|
||||||
icon: IconArrowRight,
|
label: t("shortcut.response.copy"),
|
||||||
tags: ["settings", "jump", "page", "navigation", "account", "theme", "go"],
|
section: t("shortcut.response.title"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
keys: [getPlatformAlternateKey(), "M"],
|
// Navigation
|
||||||
label: "shortcut.navigation.profile",
|
{
|
||||||
action: "navigation.jump.profile",
|
keys: [getPlatformSpecialKey(), "←"],
|
||||||
icon: IconArrowRight,
|
label: t("shortcut.navigation.back"),
|
||||||
tags: ["profile", "jump", "page", "navigation", "account", "theme", "go"],
|
section: t("shortcut.navigation.title"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keys: [getPlatformSpecialKey(), "M"],
|
keys: [getPlatformSpecialKey(), "→"],
|
||||||
label: "shortcut.miscellaneous.invite",
|
label: t("shortcut.navigation.forward"),
|
||||||
action: "modals.share.toggle",
|
section: t("shortcut.navigation.title"),
|
||||||
icon: IconGift,
|
},
|
||||||
tags: ["invite", "share", "app", "friends", "people", "social"],
|
{
|
||||||
},
|
keys: [getPlatformAlternateKey(), "R"],
|
||||||
{
|
label: t("shortcut.navigation.rest"),
|
||||||
keys: [getPlatformAlternateKey(), "0"],
|
section: t("shortcut.navigation.title"),
|
||||||
label: "shortcut.theme.system",
|
},
|
||||||
action: "settings.theme.system",
|
{
|
||||||
icon: IconMonitor,
|
keys: [getPlatformAlternateKey(), "Q"],
|
||||||
tags: ["theme", "system"],
|
label: t("shortcut.navigation.graphql"),
|
||||||
},
|
section: t("shortcut.navigation.title"),
|
||||||
{
|
},
|
||||||
keys: [getPlatformAlternateKey(), "1"],
|
{
|
||||||
label: "shortcut.theme.light",
|
keys: [getPlatformAlternateKey(), "W"],
|
||||||
action: "settings.theme.light",
|
label: t("shortcut.navigation.realtime"),
|
||||||
icon: IconSun,
|
section: t("shortcut.navigation.title"),
|
||||||
tags: ["theme", "light"],
|
},
|
||||||
},
|
{
|
||||||
{
|
keys: [getPlatformAlternateKey(), "S"],
|
||||||
keys: [getPlatformAlternateKey(), "2"],
|
label: t("shortcut.navigation.settings"),
|
||||||
label: "shortcut.theme.dark",
|
section: t("shortcut.navigation.title"),
|
||||||
action: "settings.theme.dark",
|
},
|
||||||
icon: IconCloud,
|
{
|
||||||
tags: ["theme", "dark"],
|
keys: [getPlatformAlternateKey(), "M"],
|
||||||
},
|
label: t("shortcut.navigation.profile"),
|
||||||
{
|
section: t("shortcut.navigation.title"),
|
||||||
keys: [getPlatformAlternateKey(), "3"],
|
},
|
||||||
label: "shortcut.theme.black",
|
|
||||||
action: "settings.theme.black",
|
// Miscellaneous
|
||||||
icon: IconMoon,
|
{
|
||||||
tags: ["theme", "black"],
|
keys: [getPlatformSpecialKey(), "M"],
|
||||||
},
|
label: t("shortcut.miscellaneous.invite"),
|
||||||
]
|
section: t("shortcut.miscellaneous.title"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
</Pane>
|
</Pane>
|
||||||
</Splitpanes>
|
</Splitpanes>
|
||||||
<AppActionHandler />
|
<AppActionHandler />
|
||||||
<AppPowerSearch :show="showSearch" @hide-modal="showSearch = false" />
|
<AppSpotlight :show="showSearch" @hide-modal="showSearch = false" />
|
||||||
<AppSupport
|
<AppSupport
|
||||||
v-if="mdAndLarger"
|
v-if="mdAndLarger"
|
||||||
:show="showSupport"
|
:show="showSupport"
|
||||||
|
|||||||
@@ -1,13 +1,38 @@
|
|||||||
import { HoppModule } from "."
|
import { HoppModule } from "."
|
||||||
import { Container } from "dioc"
|
import { Container, Service } from "dioc"
|
||||||
import { diocPlugin } from "dioc/vue"
|
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>{
|
export default <HoppModule>{
|
||||||
onVueAppInit(app) {
|
onVueAppInit(app) {
|
||||||
// TODO: look into this
|
// TODO: look into this
|
||||||
// @ts-expect-error Something weird with Vue versions
|
// @ts-expect-error Something weird with Vue versions
|
||||||
app.use(diocPlugin, {
|
app.use(diocPlugin, {
|
||||||
container: new Container(),
|
container: serviceContainer,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ export const changeAppLanguage = async (locale: string) => {
|
|||||||
setLocalConfig("locale", locale)
|
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>{
|
export default <HoppModule>{
|
||||||
onVueAppInit(app) {
|
onVueAppInit(app) {
|
||||||
const i18n = createI18n(<I18nOptions>{
|
const i18n = createI18n(<I18nOptions>{
|
||||||
|
|||||||
64
packages/hoppscotch-common/src/services/debug.service.ts
Normal file
64
packages/hoppscotch-common/src/services/debug.service.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
216
packages/hoppscotch-common/src/services/spotlight/index.ts
Normal file
216
packages/hoppscotch-common/src/services/spotlight/index.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { defineConfig } from "vitest/config"
|
import { defineConfig } from "vitest/config"
|
||||||
import * as path from "path"
|
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({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
@@ -9,6 +12,23 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"~": path.resolve(__dirname, "../hoppscotch-common/src"),
|
"~": 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
1073
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user