feat: expanded search capabilities of spotlight (#3255)

Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
Anwarul Islam
2023-08-21 20:33:51 +06:00
committed by GitHub
parent b0b6edc58e
commit 5a91fb53b2
23 changed files with 1633 additions and 25 deletions

View File

@@ -69,6 +69,8 @@
"invite": "Invite",
"invite_description": "Hoppscotch is an open source API development ecosystem. We designed a simple and intuitive interface for creating and managing your APIs. Hoppscotch is a tool that helps you build, test, document and share your APIs.",
"invite_your_friends": "Invite your friends",
"social_links": "Social links",
"social_description": "Follow us on social media to stay updated with the latest news, updates and releases.",
"join_discord_community": "Join our Discord community",
"keyboard_shortcuts": "Keyboard shortcuts",
"name": "Hoppscotch",
@@ -203,6 +205,7 @@
"create_new": "Create new environment",
"created": "Environment created",
"deleted": "Environment deletion",
"duplicated": "Environment duplicated",
"edit": "Edit Environment",
"global": "Global",
"empty_variables": "No variables",
@@ -220,6 +223,7 @@
"replace_with_variable": "Replace with variable",
"scope": "Scope",
"select": "Select environment",
"set": "Set environment",
"set_as_environment": "Set as environment",
"team_environments": "Team Environments",
"title": "Environments",
@@ -594,6 +598,9 @@
"delete_method": "Select DELETE method",
"get_method": "Select GET method",
"head_method": "Select HEAD method",
"rename": "Rename Current Request",
"import_curl": "Import cURL",
"show_code": "Show generated code",
"method": "Method",
"next_method": "Select Next method",
"post_method": "Select POST method",
@@ -602,6 +609,7 @@
"reset_request": "Reset Request",
"save_to_collections": "Save to Collections",
"send_request": "Send Request",
"save_request": "Save Request",
"title": "Request"
},
"response": {
@@ -632,6 +640,55 @@
"url": "URL"
},
"spotlight": {
"general": {
"help_menu": "Open help and support menu",
"chat": "Chat with support",
"open_docs": "Open documentation",
"open_keybindings": "Open keyboard shortcuts",
"social": "Social links and GitHub",
"title": "General"
},
"miscellaneous": {
"invite": "Invite people to Hoppscotch",
"title": "Miscellaneous"
},
"request": {
"tab_parameters": "Open parameters tab",
"tab_body": "Open body tab",
"tab_headers": "Open headers tab",
"tab_authorization": "Open authorization tab",
"tab_pre_request_script": "Open pre-request script tab",
"tab_tests": "Open tests tab"
},
"response": {
"copy": "Copy response as JSON",
"download": "Download response as file",
"title": "Response"
},
"environments": {
"new": "Create new environment",
"new_variable": "Create a new environment variable",
"edit": "Edit selected environment",
"delete": "Delete selected environment",
"duplicate": "Duplicate selected environment",
"edit_global": "Edit global environment",
"duplicate_global": "Duplicate global environment",
"title": "Environments"
},
"workspace": {
"new": "Create new team",
"edit": "Edit selected team",
"delete": "Delete selected team",
"invite": "Invite people to team",
"switch_to_personal": "Switch to personal workspace",
"title": "Teams"
},
"tab": {
"close_current": "Close current tab",
"close_others": "Close others tab",
"new_tab": "Open a new tab",
"title": "Tabs"
},
"section": {
"user": "User",
"theme": "Theme",
@@ -649,9 +706,9 @@
"system": "System Mode"
},
"font": {
"size_sm": "Change Font Size to Small",
"size_md": "Change Font Size to Medium",
"size_lg": "Change Font Size to Large"
"size_sm": "Change to Small",
"size_md": "Change to Medium",
"size_lg": "Change to Large"
},
"change_interceptor": "Change Interceptor",
"change_language": "Change Language",

View File

@@ -1,11 +1,11 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module 'vue' {
declare module '@vue/runtime-core' {
export interface GlobalComponents {
AppActionHandler: typeof import('./components/app/ActionHandler.vue')['default']
AppAnnouncement: typeof import('./components/app/Announcement.vue')['default']
@@ -24,10 +24,13 @@ declare module 'vue' {
AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default']
AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default']
AppSidenav: typeof import('./components/app/Sidenav.vue')['default']
AppSocial: typeof import('./components/app/Social.vue')['default']
AppSpotlight: typeof import('./components/app/spotlight/index.vue')['default']
AppSpotlightEntry: typeof import('./components/app/spotlight/Entry.vue')['default']
AppSpotlightEntryGQLHistory: typeof import('./components/app/spotlight/entry/GQLHistory.vue')['default']
AppSpotlightEntryGQLRequest: typeof import('./components/app/spotlight/entry/GQLRequest.vue')['default']
AppSpotlightEntryRESTHistory: typeof import('./components/app/spotlight/entry/RESTHistory.vue')['default']
AppSpotlightEntryRESTRequest: typeof import('./components/app/spotlight/entry/RESTRequest.vue')['default']
AppSupport: typeof import('./components/app/Support.vue')['default']
ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default']
ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default']
@@ -81,6 +84,7 @@ declare module 'vue' {
HoppButtonPrimary: typeof import('@hoppscotch/ui')['HoppButtonPrimary']
HoppButtonSecondary: typeof import('@hoppscotch/ui')['HoppButtonSecondary']
HoppSmartAnchor: typeof import('@hoppscotch/ui')['HoppSmartAnchor']
HoppSmartAutoComplete: typeof import('@hoppscotch/ui')['HoppSmartAutoComplete']
HoppSmartCheckbox: typeof import('@hoppscotch/ui')['HoppSmartCheckbox']
HoppSmartConfirmModal: typeof import('@hoppscotch/ui')['HoppSmartConfirmModal']
HoppSmartExpand: typeof import('@hoppscotch/ui')['HoppSmartExpand']
@@ -210,4 +214,5 @@ declare module 'vue' {
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
}
}

View File

@@ -1,17 +1,57 @@
<template>
<AppShortcuts :show="showShortcuts" @close="showShortcuts = false" />
<AppShare :show="showShare" @hide-modal="showShare = false" />
<AppSocial :show="showSocial" @hide-modal="showSocial = false" />
<FirebaseLogin :show="showLogin" @hide-modal="showLogin = false" />
<HoppSmartConfirmModal
:show="confirmRemove"
:title="t('confirm.remove_team')"
@hide-modal="confirmRemove = false"
@resolve="deleteTeam()"
/>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { defineActionHandler } from "~/helpers/actions"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { deleteTeam as backendDeleteTeam } from "~/helpers/backend/mutations/Team"
import { defineActionHandler, invokeAction } from "~/helpers/actions"
import { showChat } from "~/modules/crisp"
import { useToast } from "~/composables/toast"
import { useI18n } from "~/composables/i18n"
const toast = useToast()
const t = useI18n()
const showShortcuts = ref(false)
const showShare = ref(false)
const showSocial = ref(false)
const showLogin = ref(false)
const confirmRemove = ref(false)
const teamID = ref<string | null>(null)
const deleteTeam = () => {
if (!teamID.value) return
pipe(
backendDeleteTeam(teamID.value),
TE.match(
(err) => {
// TODO: Better errors ? We know the possible errors now
toast.error(`${t("error.something_went_wrong")}`)
console.error(err)
},
() => {
invokeAction("workspace.switch.personal")
toast.success(`${t("team.deleted")}`)
}
)
)() // Tasks (and TEs) are lazy, so call the function returned
}
defineActionHandler("flyouts.keybinds.toggle", () => {
showShortcuts.value = !showShortcuts.value
})
@@ -20,7 +60,20 @@ defineActionHandler("modals.share.toggle", () => {
showShare.value = !showShare.value
})
defineActionHandler("modals.social.toggle", () => {
showSocial.value = !showSocial.value
})
defineActionHandler("modals.login.toggle", () => {
showLogin.value = !showLogin.value
})
defineActionHandler("flyouts.chat.open", () => {
showChat()
})
defineActionHandler("modals.team.delete", ({ teamId }) => {
teamID.value = teamId
confirmRemove.value = true
})
</script>

View File

@@ -382,6 +382,22 @@ const settings = ref<any | null>(null)
const logout = ref<any | null>(null)
const accountActions = ref<any | null>(null)
defineActionHandler("modals.team.edit", () => {
// TODO: Remove this hack
setTimeout(() => {
handleTeamEdit()
}, 100)
})
defineActionHandler("modals.team.invite", () => {
if (
selectedTeam.value?.myRole === "OWNER" ||
selectedTeam.value?.myRole === "EDITOR"
) {
inviteTeam({ name: selectedTeam.value.name }, selectedTeam.value.id)
}
})
defineActionHandler(
"user.login",
() => {

View File

@@ -130,13 +130,12 @@
@click="nativeShare()"
/>
</div>
<AppShare :show="showShare" @hide-modal="showShare = false" />
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { ref, watch } from "vue"
import { watch } from "vue"
import IconSidebar from "~icons/lucide/sidebar"
import IconSidebarOpen from "~icons/lucide/sidebar-open"
import IconBook from "~icons/lucide/book"
@@ -151,13 +150,12 @@ import IconUserPlus from "~icons/lucide/user-plus"
import IconShare2 from "~icons/lucide/share-2"
import IconChevronRight from "~icons/lucide/chevron-right"
import { useSetting } from "@composables/settings"
import { defineActionHandler } from "~/helpers/actions"
import { invokeAction } from "~/helpers/actions"
import { showChat } from "@modules/crisp"
import { useI18n } from "@composables/i18n"
const t = useI18n()
const navigatorShare = !!navigator.share
const showShare = ref(false)
const ZEN_MODE = useSetting("ZEN_MODE")
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
@@ -174,10 +172,6 @@ defineProps<{
show: boolean
}>()
defineActionHandler("modals.share.toggle", () => {
showShare.value = !showShare.value
})
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
@@ -198,7 +192,7 @@ const expandCollection = () => {
}
const expandInvite = () => {
showShare.value = true
invokeAction("modals.share.toggle")
}
const nativeShare = () => {

View File

@@ -0,0 +1,135 @@
<template>
<HoppSmartModal
v-if="show"
dialog
:title="t('app.social_links')"
@close="hideModal"
>
<template #body>
<div class="flex flex-col space-y-2">
<div class="grid grid-cols-3 gap-4">
<a
v-for="(platform, index) in platforms"
:key="`platform-${index}`"
:href="platform.link"
target="_blank"
class="social-link"
tabindex="0"
>
<component :is="platform.icon" class="w-6 h-6" />
<span class="mt-3">
{{ platform.name }}
</span>
</a>
<button class="social-link" @click="copyAppLink">
<component :is="copyIcon" class="w-6 h-6 text-xl" />
<span class="mt-3">
{{ t("app.copy") }}
</span>
</button>
</div>
</div>
</template>
<template #footer>
<p class="text-secondaryLight">
{{ t("app.social_description") }}
</p>
</template>
</HoppSmartModal>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { refAutoReset } from "@vueuse/core"
import { copyToClipboard } from "~/helpers/utils/clipboard"
import IconFacebook from "~icons/brands/facebook"
import IconLinkedIn from "~icons/brands/linkedin"
import IconReddit from "~icons/brands/reddit"
import IconTwitter from "~icons/brands/twitter"
import IconCheck from "~icons/lucide/check"
import IconCopy from "~icons/lucide/copy"
import IconGitHub from "~icons/lucide/github"
const t = useI18n()
const toast = useToast()
defineProps<{
show: boolean
}>()
const emit = defineEmits<{
(e: "hide-modal"): void
}>()
const url = "https://hoppscotch.io"
const copyIcon = refAutoReset<typeof IconCopy | typeof IconCheck>(
IconCopy,
1000
)
const platforms = [
{
name: "GitHub",
icon: IconGitHub,
link: `https://hoppscotch.io/github`,
},
{
name: "Twitter",
icon: IconTwitter,
link: `https://twitter.com/hoppscotch_io`,
},
{
name: "Facebook",
icon: IconFacebook,
link: `https://www.facebook.com/hoppscotch.io`,
},
{
name: "Reddit",
icon: IconReddit,
link: `https://www.reddit.com/r/hoppscotch`,
},
{
name: "LinkedIn",
icon: IconLinkedIn,
link: `https://www.linkedin.com/company/hoppscotch/`,
},
]
const copyAppLink = () => {
copyToClipboard(url)
copyIcon.value = IconCheck
toast.success(`${t("state.copied_to_clipboard")}`)
}
const hideModal = () => {
emit("hide-modal")
}
</script>
<style lang="scss" scoped>
.social-link {
@apply border border-dividerLight;
@apply rounded;
@apply flex-col flex;
@apply p-4;
@apply items-center;
@apply justify-center;
@apply font-semibold;
@apply hover: (bg-primaryLight text-secondaryDark);
@apply focus: outline-none;
@apply focus-visible: border-divider;
svg {
@apply opacity-80;
}
&:hover {
svg {
@apply opacity-100;
}
}
}
</style>

View File

@@ -98,6 +98,19 @@ import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/use
import { NavigationSpotlightSearcherService } from "~/services/spotlight/searchers/navigation.searcher"
import { SettingsSpotlightSearcherService } from "~/services/spotlight/searchers/settings.searcher"
import { CollectionsSpotlightSearcherService } from "~/services/spotlight/searchers/collections.searcher"
import { MiscellaneousSpotlightSearcherService } from "~/services/spotlight/searchers/miscellaneous.searcher"
import { TabSpotlightSearcherService } from "~/services/spotlight/searchers/tab.searcher"
import { GeneralSpotlightSearcherService } from "~/services/spotlight/searchers/general.searcher"
import { ResponseSpotlightSearcherService } from "~/services/spotlight/searchers/response.searcher"
import { RequestSpotlightSearcherService } from "~/services/spotlight/searchers/request.searcher"
import {
EnvironmentsSpotlightSearcherService,
SwitchEnvSpotlightSearcherService,
} from "~/services/spotlight/searchers/environment.searcher"
import {
SwitchWorkspaceSpotlightSearcherService,
WorkspaceSpotlightSearcherService,
} from "~/services/spotlight/searchers/workspace.searcher"
const t = useI18n()
@@ -116,6 +129,15 @@ useService(UserSpotlightSearcherService)
useService(NavigationSpotlightSearcherService)
useService(SettingsSpotlightSearcherService)
useService(CollectionsSpotlightSearcherService)
useService(MiscellaneousSpotlightSearcherService)
useService(TabSpotlightSearcherService)
useService(GeneralSpotlightSearcherService)
useService(ResponseSpotlightSearcherService)
useService(RequestSpotlightSearcherService)
useService(EnvironmentsSpotlightSearcherService)
useService(SwitchEnvSpotlightSearcherService)
useService(WorkspaceSpotlightSearcherService)
useService(SwitchWorkspaceSpotlightSearcherService)
const search = ref("")
@@ -242,3 +264,4 @@ function newUseArrowKeysForNavigation() {
return { selectedEntry }
}
</script>
~/services/spotlight/searchers/workspace.searcher

View File

@@ -198,6 +198,11 @@ const resetSelectedData = () => {
editingEnvironmentIndex.value = null
}
defineActionHandler("modals.environment.new", () => {
action.value = "new"
showModalDetails.value = true
})
defineActionHandler(
"modals.my.environment.edit",
({ envName, variableName }) => {

View File

@@ -158,5 +158,7 @@ const duplicateEnvironments = () => {
cloneDeep(getGlobalVariables())
)
} else duplicateEnvironment(props.environmentIndex)
toast.success(`${t("environment.duplicated")}`)
}
</script>

View File

@@ -154,7 +154,7 @@ const duplicateEnvironments = () => {
toast.error(`${getErrorMessage(err)}`)
},
() => {
toast.success(`${t("team_environment.duplicate")}`)
toast.success(`${t("environment.duplicated")}`)
}
)
)()

View File

@@ -630,6 +630,13 @@ defineActionHandler("request.method.put", () => updateMethod("PUT"))
defineActionHandler("request.method.delete", () => updateMethod("DELETE"))
defineActionHandler("request.method.head", () => updateMethod("HEAD"))
defineActionHandler("request.import-curl", () => {
showCurlImportModal.value = true
})
defineActionHandler("request.show-code", () => {
showCodegenModal.value = true
})
const isCustomMethod = computed(() => {
return (
tab.value.document.request.method === "CUSTOM" ||

View File

@@ -1,6 +1,6 @@
<template>
<HoppSmartTabs
v-model="selectedRealtimeTab"
v-model="selectedOptionsTab"
styles="sticky overflow-x-auto flex-shrink-0 bg-primary top-upperMobilePrimaryStickyFold sm:top-upperPrimaryStickyFold z-10"
render-inactive-tabs
>
@@ -56,12 +56,15 @@ import { useI18n } from "@composables/i18n"
import { HoppRESTRequest } from "@hoppscotch/data"
import { useVModel } from "@vueuse/core"
import { computed, ref } from "vue"
import { defineActionHandler } from "~/helpers/actions"
export type RequestOptionTabs =
| "params"
| "bodyParams"
| "headers"
| "authorization"
| "preRequestScript"
| "tests"
const t = useI18n()
@@ -73,10 +76,10 @@ const emit = defineEmits<{
const request = useVModel(props, "modelValue", emit)
const selectedRealtimeTab = ref<RequestOptionTabs>("params")
const selectedOptionsTab = ref<RequestOptionTabs>("params")
const changeTab = (e: RequestOptionTabs) => {
selectedRealtimeTab.value = e
selectedOptionsTab.value = e
}
const newActiveParamsCount$ = computed(() => {
@@ -96,4 +99,8 @@ const newActiveHeadersCount$ = computed(() => {
if (e === 0) return null
return `${e}`
})
defineActionHandler("request.open-tab", ({ tab }) => {
selectedOptionsTab.value = tab
})
</script>

View File

@@ -82,6 +82,7 @@ import { changeWorkspace, workspaceStatus$ } from "~/newstore/workspace"
import { GetMyTeamsQuery } from "~/helpers/backend/graphql"
import IconDone from "~icons/lucide/check"
import { useLocalState } from "~/newstore/localstate"
import { defineActionHandler } from "~/helpers/actions"
const t = useI18n()
const colorMode = useColorMode()
@@ -154,4 +155,14 @@ const displayModalAdd = (shouldDisplay: boolean) => {
showModalAdd.value = shouldDisplay
teamListadapter.fetchList()
}
defineActionHandler("modals.team.new", () => {
displayModalAdd(true)
})
defineActionHandler("workspace.switch.personal", switchToPersonalWorkspace)
defineActionHandler("workspace.switch", ({ teamId }) => {
const team = myTeams.value.find((t) => t.id === teamId)
if (team) switchToTeamWorkspace(team)
})
</script>

View File

@@ -6,6 +6,7 @@ import { Ref, onBeforeUnmount, onMounted, watch } from "vue"
import { BehaviorSubject } from "rxjs"
import { HoppRESTDocument } from "./rest/document"
import { HoppGQLRequest, HoppRESTRequest } from "@hoppscotch/data"
import { RequestOptionTabs } from "~/components/http/RequestOptions.vue"
export type HoppAction =
| "contextmenu.open" // Send/Cancel a Hoppscotch Request
@@ -14,6 +15,7 @@ export type HoppAction =
| "request.copy-link" // Copy Request Link
| "request.save" // Save to Collections
| "request.save-as" // Save As
| "rest.request.rename" // Rename
| "request.method.next" // Select Next Method
| "request.method.prev" // Select Previous Method
| "request.method.get" // Select GET Method
@@ -21,13 +23,22 @@ export type HoppAction =
| "request.method.post" // Select POST Method
| "request.method.put" // Select PUT Method
| "request.method.delete" // Select DELETE Method
| "request.import-curl" // Import cURL
| "request.show-code" // Show generated code
| "flyouts.chat.open" // Shows the keybinds flyout
| "flyouts.keybinds.toggle" // Shows the keybinds flyout
| "modals.search.toggle" // Shows the search modal
| "modals.support.toggle" // Shows the support modal
| "modals.share.toggle" // Shows the share modal
| "modals.social.toggle" // Shows the social links modal
| "modals.environment.add" // Show add environment modal via context menu
| "modals.environment.new" // Add new environment
| "modals.my.environment.edit" // Edit current personal environment
| "modals.team.environment.edit" // Edit current team environment
| "modals.team.new" // Add new team
| "modals.team.edit" // Edit selected team
| "modals.team.invite" // Invite selected team
| "workspace.switch.personal" // Switch to personal workspace
| "navigation.jump.rest" // Jump to REST page
| "navigation.jump.graphql" // Jump to GraphQL page
| "navigation.jump.realtime" // Jump to realtime page
@@ -73,6 +84,12 @@ type HoppActionArgsMap = {
envName: string
variableName?: string
}
"modals.team.delete": {
teamId: string
}
"workspace.switch": {
teamId: string
}
"rest.request.open": {
doc: HoppRESTDocument
}
@@ -85,6 +102,10 @@ type HoppActionArgsMap = {
requestType: "gql"
request: HoppGQLRequest
}
"request.open-tab": {
tab: RequestOptionTabs
}
"gql.request.open": {
request: HoppGQLRequest
}

View File

@@ -458,6 +458,13 @@ defineActionHandler("rest.request.open", ({ doc }) => {
createNewTab(doc)
})
defineActionHandler("rest.request.rename", () => {
// TODO: Fix this hack to open the modal
setTimeout(() => {
openReqRenameModal()
}, 100)
})
const inspectionService = useService(InspectionService)
useService(HeaderInspectorService)
useService(EnvironmentInspectorService)

View File

@@ -0,0 +1,356 @@
import {
Component,
Ref,
computed,
effectScope,
markRaw,
reactive,
ref,
watch,
} from "vue"
import { activeActions$, invokeAction } from "~/helpers/actions"
import { getI18n } from "~/modules/i18n"
import {
SpotlightSearcher,
SpotlightSearcherResult,
SpotlightSearcherSessionState,
SpotlightService,
} from ".."
import {
SearchResult,
StaticSpotlightSearcherService,
} from "./base/static.searcher"
import IconEdit from "~icons/lucide/edit"
import IconTrash2 from "~icons/lucide/trash-2"
import IconCopy from "~icons/lucide/copy"
import IconLayers from "~icons/lucide/layers"
import { useStreamStatic } from "~/composables/stream"
import {
createEnvironment,
currentEnvironment$,
deleteEnvironment,
duplicateEnvironment,
environmentsStore,
getGlobalVariables,
selectedEnvironmentIndex$,
setSelectedEnvironmentIndex,
} from "~/newstore/environments"
import { pipe } from "fp-ts/function"
import * as TE from "fp-ts/TaskEither"
import { deleteTeamEnvironment } from "~/helpers/backend/mutations/TeamEnvironment"
import { GQLError } from "~/helpers/backend/GQLClient"
import { cloneDeep } from "lodash-es"
import { Service } from "dioc"
import MiniSearch from "minisearch"
import { map } from "rxjs"
type Doc = {
text: string
alternates: string[]
icon: object | Component
excludeFromSearch?: boolean
}
/**
*
* This searcher is responsible for providing environments related actions on the spotlight results.
*
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
*/
export class EnvironmentsSpotlightSearcherService extends StaticSpotlightSearcherService<Doc> {
public static readonly ID = "ENVIRONMENTS_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public readonly searcherID = "environments"
public searcherSectionTitle = this.t("spotlight.environments.title")
private readonly spotlight = this.bind(SpotlightService)
private selectedEnvIndex = useStreamStatic(
selectedEnvironmentIndex$,
null,
() => {
/* noop */
}
)[0]
private selectedEnv = useStreamStatic(currentEnvironment$, null, () => {
/* noop */
})[0]
private hasSelectedEnv = computed(
() => this.selectedEnvIndex.value?.type !== "NO_ENV_SELECTED"
)
private documents: Record<string, Doc> = reactive({
new_environment: {
text: this.t("spotlight.environments.new"),
alternates: ["new", "environment"],
icon: markRaw(IconLayers),
},
new_environment_variable: {
text: this.t("spotlight.environments.new_variable"),
alternates: ["new", "environment", "variable"],
icon: markRaw(IconLayers),
},
edit_selected_env: {
text: this.t("spotlight.environments.edit"),
alternates: ["edit", "environment"],
icon: markRaw(IconEdit),
excludeFromSearch: computed(() => !this.hasSelectedEnv.value),
},
delete_selected_env: {
text: this.t("spotlight.environments.delete"),
alternates: ["delete", "environment"],
icon: markRaw(IconTrash2),
excludeFromSearch: computed(() => !this.hasSelectedEnv.value),
},
duplicate_selected_env: {
text: this.t("spotlight.environments.duplicate"),
alternates: ["duplicate", "environment"],
icon: markRaw(IconCopy),
excludeFromSearch: computed(() => !this.hasSelectedEnv.value),
},
edit_global_env: {
text: this.t("spotlight.environments.edit_global"),
alternates: ["edit", "global", "environment"],
icon: markRaw(IconEdit),
},
duplicate_global_env: {
text: this.t("spotlight.environments.duplicate_global"),
alternates: ["duplicate", "global", "environment"],
icon: markRaw(IconCopy),
},
})
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,
}
}
private getSelectedText() {
const selection = window.getSelection()
return selection?.toString() ?? ""
}
duplicateGlobalEnv() {
createEnvironment(
`Global - ${this.t("action.duplicate")}`,
cloneDeep(getGlobalVariables())
)
// this.toast.success(`${t("environment.duplicated")}`)
}
duplicateSelectedEnv() {
if (this.selectedEnvIndex.value?.type === "NO_ENV_SELECTED") return
if (this.selectedEnvIndex.value?.type === "MY_ENV") {
duplicateEnvironment(this.selectedEnvIndex.value.index)
// this.toast.success(`${t("environment.duplicated")}`)
}
if (this.selectedEnvIndex.value?.type === "TEAM_ENV") {
pipe(
deleteTeamEnvironment(this.selectedEnvIndex.value.teamEnvID),
TE.match(
(err: GQLError<string>) => {
console.error(err)
},
() => {
// this.toast.success(`${this.t("environment.duplicated")}`)
}
)
)()
}
}
removeSelectedEnvironment = () => {
if (this.selectedEnvIndex.value?.type === "NO_ENV_SELECTED") return
if (this.selectedEnvIndex.value?.type === "MY_ENV") {
deleteEnvironment(this.selectedEnvIndex.value.index)
// this.toast.success(`${t("state.deleted")}`)
}
if (this.selectedEnvIndex.value?.type === "TEAM_ENV") {
pipe(
deleteTeamEnvironment(this.selectedEnvIndex.value.teamEnvID),
TE.match(
(err: GQLError<string>) => {
console.error(err)
},
() => {
// this.toast.success(`${this.t("team_environment.deleted")}`)
}
)
)()
}
}
public onDocSelected(id: string): void {
switch (id) {
case "new_environment":
invokeAction(`modals.environment.new`)
break
case "new_environment_variable":
invokeAction(`modals.environment.add`, {
envName: "",
variableName: this.getSelectedText(),
})
break
case "edit_selected_env":
if (this.selectedEnv.value)
invokeAction(`modals.my.environment.edit`, {
envName: this.selectedEnv.value.name,
})
break
case "delete_selected_env":
this.removeSelectedEnvironment()
break
case "duplicate_selected_env":
this.duplicateSelectedEnv()
break
case "edit_global_env":
invokeAction(`modals.my.environment.edit`, {
envName: "Global",
})
break
case "duplicate_global_env":
this.duplicateGlobalEnv()
break
}
}
}
/**
* This searcher is responsible for searching through the environment.
* And switching between them.
*/
export class SwitchEnvSpotlightSearcherService
extends Service
implements SpotlightSearcher
{
public static readonly ID = "SWITCH_ENV_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public searcherID = "switch_env"
public searcherSectionTitle = this.t("tab.environments")
private readonly spotlight = this.bind(SpotlightService)
constructor() {
super()
this.spotlight.registerSearcher(this)
}
private environmentSearchable = useStreamStatic(
activeActions$.pipe(
map((actions) => actions.includes("modals.environment.add"))
),
activeActions$.value.includes("modals.environment.add"),
() => {
/* noop */
}
)[0]
createSearchSession(
query: Readonly<Ref<string>>
): [Ref<SpotlightSearcherSessionState>, () => void] {
const loading = ref(false)
const results = ref<SpotlightSearcherResult[]>([])
const minisearch = new MiniSearch({
fields: ["name"],
storeFields: ["name"],
})
if (this.environmentSearchable.value) {
minisearch.addAll(
environmentsStore.value.environments.map((entry, index) => {
return {
id: `environment-${index}`,
name: entry.name,
}
})
)
}
const scopeHandle = effectScope()
scopeHandle.run(() => {
watch(
[query],
([query]) => {
results.value = minisearch
.search(query, {
prefix: true,
fuzzy: true,
boost: {
reltime: 2,
},
weights: {
fuzzy: 0.2,
prefix: 0.8,
},
})
.map((x) => {
return {
id: x.id,
icon: markRaw(IconLayers),
score: x.score,
text: {
type: "text",
text: [this.t("environment.set"), x.name],
},
}
})
},
{ immediate: true }
)
})
const onSessionEnd = () => {
scopeHandle.stop()
minisearch.removeAll()
}
const resultObj = computed<SpotlightSearcherSessionState>(() => ({
loading: loading.value,
results: results.value,
}))
return [resultObj, onSessionEnd]
}
onResultSelect(result: SpotlightSearcherResult): void {
const selectedEnvIndex = Number(result.id.split("-")[1])
setSelectedEnvironmentIndex({
type: "MY_ENV",
index: selectedEnvIndex,
})
}
}

View File

@@ -0,0 +1,114 @@
import { Component, markRaw, reactive } from "vue"
import { invokeAction } from "~/helpers/actions"
import { getI18n } from "~/modules/i18n"
import { SpotlightSearcherResult, SpotlightService } from ".."
import {
SearchResult,
StaticSpotlightSearcherService,
} from "./base/static.searcher"
import IconBook from "~icons/lucide/book"
import IconGithub from "~icons/lucide/github"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconMessageCircle from "~icons/lucide/message-circle"
import IconZap from "~icons/lucide/zap"
type Doc = {
text: string
alternates: string[]
icon: object | Component
}
/**
*
* This searcher is responsible for providing general related actions on the spotlight results.
*
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
*/
export class GeneralSpotlightSearcherService extends StaticSpotlightSearcherService<Doc> {
public static readonly ID = "GENERAL_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public readonly searcherID = "general"
public searcherSectionTitle = this.t("spotlight.general.title")
private readonly spotlight = this.bind(SpotlightService)
private documents: Record<string, Doc> = reactive({
open_help: {
text: this.t("spotlight.general.help_menu"),
alternates: ["help", "hoppscotch"],
icon: markRaw(IconLifeBuoy),
},
chat_with_support: {
text: this.t("spotlight.general.chat"),
alternates: ["chat", "support", "hoppscotch"],
icon: markRaw(IconMessageCircle),
},
open_docs: {
text: this.t("spotlight.general.open_docs"),
alternates: ["docs", "documentation", "hoppscotch"],
icon: markRaw(IconBook),
},
open_keybindings: {
text: this.t("spotlight.general.open_keybindings"),
alternates: ["key", "shortcuts", "binding"],
icon: markRaw(IconZap),
},
social_links: {
text: this.t("spotlight.general.social"),
alternates: ["social", "github", "binding"],
icon: markRaw(IconGithub),
},
})
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,
}
}
private openDocs() {
const url = "https://docs.hoppscotch.io"
window.open(url, "_blank")
}
public onDocSelected(id: string): void {
switch (id) {
case "open_help":
invokeAction("modals.support.toggle")
break
case "chat_with_support":
invokeAction("flyouts.chat.open")
break
case "open_docs":
this.openDocs()
break
case "open_keybindings":
invokeAction("flyouts.keybinds.toggle")
break
case "social_links":
invokeAction("modals.social.toggle")
break
}
}
}

View File

@@ -0,0 +1,69 @@
import { Component, markRaw, reactive } from "vue"
import { invokeAction } from "~/helpers/actions"
import { getI18n } from "~/modules/i18n"
import { SpotlightSearcherResult, SpotlightService } from ".."
import {
SearchResult,
StaticSpotlightSearcherService,
} from "./base/static.searcher"
import IconShare from "~icons/lucide/share"
type Doc = {
text: string
alternates: string[]
icon: object | Component
}
/**
*
* This searcher is responsible for providing miscellaneous related actions on the spotlight results.
*
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
*/
export class MiscellaneousSpotlightSearcherService extends StaticSpotlightSearcherService<Doc> {
public static readonly ID = "MISCELLANEOUS_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public readonly searcherID = "miscellaneous"
public searcherSectionTitle = this.t("spotlight.miscellaneous.title")
private readonly spotlight = this.bind(SpotlightService)
private documents: Record<string, Doc> = reactive({
invite_hoppscotch: {
text: this.t("spotlight.miscellaneous.invite"),
alternates: ["invite", "share", "hoppscotch"],
icon: markRaw(IconShare),
},
})
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 {
if (id === "invite_hoppscotch") invokeAction(`modals.share.toggle`)
}
}

View File

@@ -0,0 +1,259 @@
import { Component, markRaw, reactive } from "vue"
import { invokeAction } from "~/helpers/actions"
import { getI18n } from "~/modules/i18n"
import { SpotlightSearcherResult, SpotlightService } from ".."
import {
SearchResult,
StaticSpotlightSearcherService,
} from "./base/static.searcher"
import { RequestOptionTabs } from "~/components/http/RequestOptions.vue"
import { currentActiveTab } from "~/helpers/rest/tab"
import IconWindow from "~icons/lucide/app-window"
import IconCheck from "~icons/lucide/check"
import IconChevronLeft from "~icons/lucide/chevron-left"
import IconChevronRight from "~icons/lucide/chevron-right"
import IconCode2 from "~icons/lucide/code-2"
import IconCopy from "~icons/lucide/copy"
import IconFileCode from "~icons/lucide/file-code"
import IconRename from "~icons/lucide/file-edit"
import IconPlay from "~icons/lucide/play"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconSave from "~icons/lucide/save"
type Doc = {
text: string | string[]
alternates: string[]
icon: object | Component
}
/**
*
* This searcher is responsible for providing request related actions on the spotlight results.
*
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
*/
export class RequestSpotlightSearcherService extends StaticSpotlightSearcherService<Doc> {
public static readonly ID = "REQUEST_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public readonly searcherID = "request"
public searcherSectionTitle = this.t("shortcut.request.title")
private readonly spotlight = this.bind(SpotlightService)
private documents: Record<string, Doc> = reactive({
send_request: {
text: this.t("shortcut.request.send_request"),
alternates: ["request", "send"],
icon: markRaw(IconPlay),
},
save_to_collections: {
text: [
this.t("request.save_as"),
this.t("shortcut.request.save_to_collections"),
],
alternates: ["save", "collections"],
icon: markRaw(IconSave),
},
save_request: {
text: this.t("shortcut.request.save_request"),
alternates: ["save", "request"],
icon: markRaw(IconSave),
},
rename_request: {
text: this.t("shortcut.request.rename"),
alternates: ["rename", "request"],
icon: markRaw(IconRename),
},
copy_request_link: {
text: this.t("shortcut.request.copy_request_link"),
alternates: ["copy", "link"],
icon: markRaw(IconCopy),
},
reset_request: {
text: this.t("shortcut.request.reset_request"),
alternates: ["reset", "request"],
icon: markRaw(IconRotateCCW),
},
import_curl: {
text: this.t("shortcut.request.import_curl"),
alternates: ["import", "curl"],
icon: markRaw(IconFileCode),
},
show_code: {
text: this.t("shortcut.request.show_code"),
alternates: ["show", "code"],
icon: markRaw(IconCode2),
},
// Change request method
next_method: {
text: this.t("shortcut.request.next_method"),
alternates: ["next", "method"],
icon: markRaw(IconChevronRight),
},
previous_method: {
text: this.t("shortcut.request.previous_method"),
alternates: ["previous", "method"],
icon: markRaw(IconChevronLeft),
},
get_method: {
text: this.t("shortcut.request.get_method"),
alternates: ["get", "method"],
icon: markRaw(IconCheck),
},
head_method: {
text: this.t("shortcut.request.head_method"),
alternates: ["head", "method"],
icon: markRaw(IconCheck),
},
post_method: {
text: this.t("shortcut.request.post_method"),
alternates: ["post", "method"],
icon: markRaw(IconCheck),
},
put_method: {
text: this.t("shortcut.request.put_method"),
alternates: ["put", "method"],
icon: markRaw(IconCheck),
},
delete_method: {
text: this.t("shortcut.request.delete_method"),
alternates: ["delete", "method"],
icon: markRaw(IconCheck),
},
// Change sub tabs
tab_parameters: {
text: this.t("spotlight.request.tab_parameters"),
alternates: ["parameters", "tab"],
icon: markRaw(IconWindow),
},
tab_body: {
text: this.t("spotlight.request.tab_body"),
alternates: ["body", "tab"],
icon: markRaw(IconWindow),
},
tab_headers: {
text: this.t("spotlight.request.tab_headers"),
alternates: ["headers", "tab"],
icon: markRaw(IconWindow),
},
tab_authorization: {
text: this.t("spotlight.request.tab_authorization"),
alternates: ["authorization", "tab"],
icon: markRaw(IconWindow),
},
tab_pre_request_script: {
text: this.t("spotlight.request.tab_pre_request_script"),
alternates: ["pre-request", "script", "tab"],
icon: markRaw(IconWindow),
},
tab_tests: {
text: this.t("spotlight.request.tab_tests"),
alternates: ["tests", "tab"],
icon: markRaw(IconWindow),
},
})
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,
}
}
private openRequestTab(tab: RequestOptionTabs): void {
invokeAction("request.open-tab", {
tab,
})
}
public onDocSelected(id: string): void {
switch (id) {
case "send_request":
invokeAction("request.send-cancel")
break
case "save_to_collections":
invokeAction("request.save-as", {
requestType: "rest",
request: currentActiveTab.value?.document.request,
})
break
case "save_request":
invokeAction("request.save")
break
case "rename_request":
invokeAction("rest.request.rename")
break
case "copy_request_link":
invokeAction("request.copy-link")
break
case "reset_request":
invokeAction("request.reset")
break
case "next_method":
invokeAction("request.method.next")
break
case "previous_method":
invokeAction("request.method.prev")
break
case "get_method":
invokeAction("request.method.get")
break
case "head_method":
invokeAction("request.method.head")
break
case "post_method":
invokeAction("request.method.post")
break
case "put_method":
invokeAction("request.method.put")
break
case "delete_method":
invokeAction("request.method.delete")
break
case "import_curl":
invokeAction("request.import-curl")
break
case "show_code":
invokeAction("request.show-code")
break
case "tab_parameters":
this.openRequestTab("params")
break
case "tab_body":
this.openRequestTab("bodyParams")
break
case "tab_headers":
this.openRequestTab("headers")
break
case "tab_authorization":
this.openRequestTab("authorization")
break
case "tab_pre_request_script":
this.openRequestTab("preRequestScript")
break
case "tab_tests":
this.openRequestTab("tests")
break
}
}
}

View File

@@ -0,0 +1,101 @@
import { Component, computed, markRaw, reactive } from "vue"
import { activeActions$, invokeAction } from "~/helpers/actions"
import { getI18n } from "~/modules/i18n"
import { SpotlightSearcherResult, SpotlightService } from ".."
import {
SearchResult,
StaticSpotlightSearcherService,
} from "./base/static.searcher"
import IconDownload from "~icons/lucide/download"
import IconCopy from "~icons/lucide/copy"
import { map } from "rxjs"
import { useStreamStatic } from "~/composables/stream"
type Doc = {
text: string
alternates: string[]
icon: object | Component
excludeFromSearch?: boolean
}
/**
*
* This searcher is responsible for providing response related actions on the spotlight results.
*
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
*/
export class ResponseSpotlightSearcherService extends StaticSpotlightSearcherService<Doc> {
public static readonly ID = "RESPONSE_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public readonly searcherID = "response"
public searcherSectionTitle = this.t("spotlight.response.title")
private readonly spotlight = this.bind(SpotlightService)
private copyResponseActionEnabled = useStreamStatic(
activeActions$.pipe(map((actions) => actions.includes("response.copy"))),
activeActions$.value.includes("response.copy"),
() => {
/* noop */
}
)[0]
private downloadResponseActionEnabled = useStreamStatic(
activeActions$.pipe(
map((actions) => actions.includes("response.file.download"))
),
activeActions$.value.includes("response.file.download"),
() => {
/* noop */
}
)[0]
private documents: Record<string, Doc> = reactive({
copy_response: {
text: this.t("spotlight.response.copy"),
alternates: ["copy", "response"],
icon: markRaw(IconCopy),
excludeFromSearch: computed(() => !this.copyResponseActionEnabled.value),
},
download_response: {
text: this.t("spotlight.response.download"),
alternates: ["download", "response"],
icon: markRaw(IconDownload),
excludeFromSearch: computed(
() => !this.downloadResponseActionEnabled.value
),
},
})
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 {
if (id === "copy_response") invokeAction(`response.copy`)
if (id === "download_response") invokeAction(`response.file.download`)
}
}

View File

@@ -76,7 +76,10 @@ export class SettingsSpotlightSearcherService extends StaticSpotlightSearcherSer
icon: markRaw(IconMoon),
},
font_size_sm: {
text: this.t("spotlight.font.size_sm"),
text: [
this.t("settings.font_size"),
this.t("spotlight.settings.font.size_sm"),
],
onClick: () => {
console.log("clicked")
},
@@ -90,7 +93,10 @@ export class SettingsSpotlightSearcherService extends StaticSpotlightSearcherSer
icon: markRaw(IconType),
},
font_size_md: {
text: this.t("spotlight.font.size_md"),
text: [
this.t("settings.font_size"),
this.t("spotlight.settings.font.size_md"),
],
excludeFromSearch: computed(() => this.activeFontSize.value === "medium"),
alternates: [
"font size",
@@ -101,7 +107,10 @@ export class SettingsSpotlightSearcherService extends StaticSpotlightSearcherSer
icon: markRaw(IconType),
},
font_size_lg: {
text: this.t("spotlight.font.size_lg"),
text: [
this.t("settings.font_size"),
this.t("spotlight.settings.font.size_lg"),
],
excludeFromSearch: computed(() => this.activeFontSize.value === "large"),
alternates: [
"font size",

View File

@@ -0,0 +1,91 @@
import { Component, markRaw, reactive } from "vue"
import { getI18n } from "~/modules/i18n"
import { SpotlightSearcherResult, SpotlightService } from ".."
import {
SearchResult,
StaticSpotlightSearcherService,
} from "./base/static.searcher"
import {
closeOtherTabs,
closeTab,
createNewTab,
currentTabID,
} from "~/helpers/rest/tab"
import IconWindow from "~icons/lucide/app-window"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
type Doc = {
text: string
alternates: string[]
icon: object | Component
}
/**
*
* This searcher is responsible for providing REST Tab related actions on the spotlight results.
*
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
*/
export class TabSpotlightSearcherService extends StaticSpotlightSearcherService<Doc> {
public static readonly ID = "TAB_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public readonly searcherID = "tab"
public searcherSectionTitle = this.t("spotlight.tab.title")
private readonly spotlight = this.bind(SpotlightService)
private documents: Record<string, Doc> = reactive({
close_current_tab: {
text: this.t("spotlight.tab.close_current"),
alternates: ["tab", "close", "close tab"],
icon: markRaw(IconWindow),
},
close_others_tab: {
text: this.t("spotlight.tab.close_others"),
alternates: ["tab", "close", "close all"],
icon: markRaw(IconWindow),
},
open_new_tab: {
text: this.t("spotlight.tab.new_tab"),
alternates: ["tab", "new", "open tab"],
icon: markRaw(IconWindow),
},
})
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 {
if (id === "close_current_tab") closeTab(currentTabID.value)
if (id === "close_others_tab") closeOtherTabs(currentTabID.value)
if (id === "open_new_tab")
createNewTab({
request: getDefaultRESTRequest(),
isDirty: false,
})
}
}

View File

@@ -0,0 +1,266 @@
import {
Component,
Ref,
computed,
effectScope,
markRaw,
reactive,
ref,
watch,
} from "vue"
import { invokeAction } from "~/helpers/actions"
import { getI18n } from "~/modules/i18n"
import {
SpotlightSearcher,
SpotlightSearcherResult,
SpotlightSearcherSessionState,
SpotlightService,
} from ".."
import {
SearchResult,
StaticSpotlightSearcherService,
} from "./base/static.searcher"
import { Service } from "dioc"
import * as E from "fp-ts/Either"
import MiniSearch from "minisearch"
import { useStreamStatic } from "~/composables/stream"
import { runGQLQuery } from "~/helpers/backend/GQLClient"
import { GetMyTeamsDocument, GetMyTeamsQuery } from "~/helpers/backend/graphql"
import { workspaceStatus$ } from "~/newstore/workspace"
import { platform } from "~/platform"
import IconEdit from "~icons/lucide/edit"
import IconTrash2 from "~icons/lucide/trash-2"
import IconUser from "~icons/lucide/user"
import IconUserPlus from "~icons/lucide/user-plus"
import IconUsers from "~icons/lucide/users"
type Doc = {
text: string
alternates: string[]
icon: object | Component
excludeFromSearch?: boolean
}
/**
*
* This searcher is responsible for providing team related actions on the spotlight results.
*
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
*/
export class WorkspaceSpotlightSearcherService extends StaticSpotlightSearcherService<Doc> {
public static readonly ID = "WORKSPACE_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public readonly searcherID = "workspace"
public searcherSectionTitle = this.t("spotlight.workspace.title")
private readonly spotlight = this.bind(SpotlightService)
private workspace = useStreamStatic(
workspaceStatus$,
{ type: "personal" },
() => {
/* noop */
}
)[0]
private isTeamSelected = computed(
() =>
this.workspace.value.type === "team" &&
this.workspace.value.teamID !== undefined
)
private documents: Record<string, Doc> = reactive({
new_team: {
text: this.t("spotlight.workspace.new"),
alternates: ["new", "team", "workspace"],
icon: markRaw(IconUsers),
},
edit_team: {
text: this.t("spotlight.workspace.edit"),
alternates: ["edit", "team", "workspace"],
icon: markRaw(IconEdit),
excludeFromSearch: computed(() => !this.isTeamSelected.value),
},
invite_members: {
text: this.t("spotlight.workspace.invite"),
alternates: ["invite", "members", "workspace"],
icon: markRaw(IconUserPlus),
excludeFromSearch: computed(() => !this.isTeamSelected.value),
},
delete_team: {
text: this.t("spotlight.workspace.delete"),
alternates: ["delete", "team", "workspace"],
icon: markRaw(IconTrash2),
excludeFromSearch: computed(() => !this.isTeamSelected.value),
},
switch_to_personal: {
text: this.t("spotlight.workspace.switch_to_personal"),
alternates: ["switch", "team", "workspace", "personal"],
icon: markRaw(IconUser),
excludeFromSearch: computed(() => !this.isTeamSelected.value),
},
})
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,
}
}
private deleteTeam(): void {
if (this.workspace.value.type === "team")
invokeAction(`modals.team.delete`, {
teamId: this.workspace.value.teamID,
})
}
public onDocSelected(id: string): void {
if (id === "new_team") invokeAction(`modals.team.new`)
else if (id === "edit_team") invokeAction(`modals.team.edit`)
else if (id === "invite_members") invokeAction(`modals.team.invite`)
else if (id === "delete_team") this.deleteTeam()
else if (id === "switch_to_personal")
invokeAction(`workspace.switch.personal`)
}
}
/**
* This searcher is responsible for searching through the environment.
* And switching between them.
*/
export class SwitchWorkspaceSpotlightSearcherService
extends Service
implements SpotlightSearcher
{
public static readonly ID = "SWITCH_WORKSPACE_SPOTLIGHT_SEARCHER_SERVICE"
private t = getI18n()
public searcherID = "switch_workspace"
public searcherSectionTitle = this.t("workspace.title")
private readonly spotlight = this.bind(SpotlightService)
constructor() {
super()
this.spotlight.registerSearcher(this)
}
private fetchMyTeams(): Promise<GetMyTeamsQuery["myTeams"]> {
return new Promise(async (resolve) => {
const currentUser = platform.auth.getCurrentUser()
if (!currentUser) return resolve([])
const results: GetMyTeamsQuery["myTeams"] = []
const result = await runGQLQuery({
query: GetMyTeamsDocument,
variables: {
cursor:
results.length > 0 ? results[results.length - 1].id : undefined,
},
})
if (E.isRight(result)) results.push(...result.right.myTeams)
resolve(results)
})
}
createSearchSession(
query: Readonly<Ref<string>>
): [Ref<SpotlightSearcherSessionState>, () => void] {
const loading = ref(false)
const results = ref<SpotlightSearcherResult[]>([])
const minisearch = new MiniSearch({
fields: ["name", "alternates"],
storeFields: ["name"],
})
this.fetchMyTeams().then((teams) => {
minisearch.addAll(
teams.map((entry) => {
return {
id: `workspace-${entry.id}`,
name: entry.name,
alternates: ["team", "workspace", "change", "switch"],
}
})
)
})
const scopeHandle = effectScope()
scopeHandle.run(() => {
watch(
[query],
([query]) => {
results.value = minisearch
.search(query, {
prefix: true,
fuzzy: true,
boost: {
reltime: 2,
},
weights: {
fuzzy: 0.2,
prefix: 0.8,
},
})
.map((x) => {
return {
id: x.id,
icon: markRaw(IconUsers),
score: x.score,
text: {
type: "text",
text: [this.t("workspace.change"), x.name],
},
}
})
},
{ immediate: true }
)
})
const onSessionEnd = () => {
scopeHandle.stop()
minisearch.removeAll()
}
const resultObj = computed<SpotlightSearcherSessionState>(() => ({
loading: loading.value,
results: results.value,
}))
return [resultObj, onSessionEnd]
}
onResultSelect(result: SpotlightSearcherResult): void {
invokeAction("workspace.switch", {
teamId: result.id.split("-")[1],
})
}
}