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

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