feat: spotlight collection searcher (#3262)
Co-authored-by: Liyas Thomas <liyascthomas@gmail.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
|||||||
|
<template>
|
||||||
|
<span class="flex flex-1 space-x-2 items-center">
|
||||||
|
<template v-for="(folder, index) in pathFolders" :key="index">
|
||||||
|
<span class="block" :class="{ truncate: index !== 0 }">
|
||||||
|
{{ folder.name }}
|
||||||
|
</span>
|
||||||
|
<icon-lucide-chevron-right class="flex flex-shrink-0" />
|
||||||
|
</template>
|
||||||
|
<span v-if="request" class="block">
|
||||||
|
{{ request.name }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { HoppCollection, HoppGQLRequest } from "@hoppscotch/data"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { graphqlCollectionStore } from "~/newstore/collections"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
folderPath: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const pathFolders = computed(() => {
|
||||||
|
try {
|
||||||
|
const folderIndicies = props.folderPath
|
||||||
|
.split("/")
|
||||||
|
.slice(0, -1)
|
||||||
|
.map((x) => parseInt(x))
|
||||||
|
|
||||||
|
const pathItems: HoppCollection<HoppGQLRequest>[] = []
|
||||||
|
|
||||||
|
let currentFolder =
|
||||||
|
graphqlCollectionStore.value.state[folderIndicies.shift()!]
|
||||||
|
|
||||||
|
pathItems.push(currentFolder)
|
||||||
|
|
||||||
|
while (folderIndicies.length > 0) {
|
||||||
|
const folderIndex = folderIndicies.shift()!
|
||||||
|
|
||||||
|
const folder = currentFolder.folders[folderIndex]
|
||||||
|
pathItems.push(folder)
|
||||||
|
|
||||||
|
currentFolder = folder
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathItems
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const request = computed(() => {
|
||||||
|
try {
|
||||||
|
const requestIndex = parseInt(props.folderPath.split("/").at(-1)!)
|
||||||
|
|
||||||
|
return pathFolders.value[pathFolders.value.length - 1].requests[
|
||||||
|
requestIndex
|
||||||
|
]
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<span class="flex flex-1 items-center space-x-2">
|
||||||
|
<template v-for="(folder, index) in pathFolders" :key="index">
|
||||||
|
<span class="block" :class="{ truncate: index !== 0 }">
|
||||||
|
{{ folder.name }}
|
||||||
|
</span>
|
||||||
|
<icon-lucide-chevron-right class="flex flex-shrink-0" />
|
||||||
|
</template>
|
||||||
|
<span
|
||||||
|
v-if="request"
|
||||||
|
class="font-semibold truncate text-tiny flex flex-shrink-0 border border-dividerDark rounded-md px-1"
|
||||||
|
:class="getMethodLabelColorClassOf(request)"
|
||||||
|
>
|
||||||
|
{{ request.method.toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
<span v-if="request" class="block">
|
||||||
|
{{ request.name }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { restCollectionStore } from "~/newstore/collections"
|
||||||
|
import { getMethodLabelColorClassOf } from "~/helpers/rest/labelColoring"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
folderPath: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const pathFolders = computed(() => {
|
||||||
|
try {
|
||||||
|
const folderIndicies = props.folderPath
|
||||||
|
.split("/")
|
||||||
|
.slice(0, -1)
|
||||||
|
.map((x) => parseInt(x))
|
||||||
|
|
||||||
|
const pathItems: HoppCollection<HoppRESTRequest>[] = []
|
||||||
|
|
||||||
|
let currentFolder = restCollectionStore.value.state[folderIndicies.shift()!]
|
||||||
|
pathItems.push(currentFolder)
|
||||||
|
|
||||||
|
while (folderIndicies.length > 0) {
|
||||||
|
const folderIndex = folderIndicies.shift()!
|
||||||
|
|
||||||
|
const folder = currentFolder.folders[folderIndex]
|
||||||
|
pathItems.push(folder)
|
||||||
|
|
||||||
|
currentFolder = folder
|
||||||
|
}
|
||||||
|
|
||||||
|
return pathItems
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const request = computed(() => {
|
||||||
|
try {
|
||||||
|
const requestIndex = parseInt(props.folderPath.split("/").at(-1)!)
|
||||||
|
|
||||||
|
return pathFolders.value[pathFolders.value.length - 1].requests[
|
||||||
|
requestIndex
|
||||||
|
]
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -97,6 +97,7 @@ import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/
|
|||||||
import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher"
|
import { UserSpotlightSearcherService } from "~/services/spotlight/searchers/user.searcher"
|
||||||
import { NavigationSpotlightSearcherService } from "~/services/spotlight/searchers/navigation.searcher"
|
import { NavigationSpotlightSearcherService } from "~/services/spotlight/searchers/navigation.searcher"
|
||||||
import { SettingsSpotlightSearcherService } from "~/services/spotlight/searchers/settings.searcher"
|
import { SettingsSpotlightSearcherService } from "~/services/spotlight/searchers/settings.searcher"
|
||||||
|
import { CollectionsSpotlightSearcherService } from "~/services/spotlight/searchers/collections.searcher"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
@@ -114,6 +115,7 @@ useService(HistorySpotlightSearcherService)
|
|||||||
useService(UserSpotlightSearcherService)
|
useService(UserSpotlightSearcherService)
|
||||||
useService(NavigationSpotlightSearcherService)
|
useService(NavigationSpotlightSearcherService)
|
||||||
useService(SettingsSpotlightSearcherService)
|
useService(SettingsSpotlightSearcherService)
|
||||||
|
useService(CollectionsSpotlightSearcherService)
|
||||||
|
|
||||||
const search = ref("")
|
const search = ref("")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
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 {
|
||||||
|
graphqlCollectionStore,
|
||||||
|
restCollectionStore,
|
||||||
|
} from "~/newstore/collections"
|
||||||
|
import IconFolder from "~icons/lucide/folder"
|
||||||
|
import RESTRequestSpotlightEntry from "~/components/app/spotlight/entry/RESTRequest.vue"
|
||||||
|
import GQLRequestSpotlightEntry from "~/components/app/spotlight/entry/GQLRequest.vue"
|
||||||
|
import { createNewTab } from "~/helpers/rest/tab"
|
||||||
|
import { getTabRefWithSaveContext } from "~/helpers/rest/tab"
|
||||||
|
import { currentTabID } from "~/helpers/rest/tab"
|
||||||
|
import {
|
||||||
|
HoppCollection,
|
||||||
|
HoppGQLRequest,
|
||||||
|
HoppRESTRequest,
|
||||||
|
} from "@hoppscotch/data"
|
||||||
|
import { setGQLSession } from "~/newstore/GQLSession"
|
||||||
|
import { cloneDeep } from "lodash-es"
|
||||||
|
import { hoppWorkspaceStore } from "~/newstore/workspace"
|
||||||
|
import { changeWorkspace } from "~/newstore/workspace"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A spotlight searcher that searches through the user's collections
|
||||||
|
*
|
||||||
|
* NOTE: Initializing this service registers it as a searcher with the Spotlight Service.
|
||||||
|
*/
|
||||||
|
export class CollectionsSpotlightSearcherService
|
||||||
|
extends Service
|
||||||
|
implements SpotlightSearcher
|
||||||
|
{
|
||||||
|
public static readonly ID = "COLLECTIONS_SPOTLIGHT_SEARCHER_SERVICE"
|
||||||
|
|
||||||
|
private t = getI18n()
|
||||||
|
|
||||||
|
public searcherID = "collections"
|
||||||
|
public searcherSectionTitle = this.t("collection.my_collections")
|
||||||
|
|
||||||
|
private readonly spotlight = this.bind(SpotlightService)
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.spotlight.registerSearcher(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadGQLDocsIntoMinisearch(minisearch: MiniSearch) {
|
||||||
|
const gqlCollsQueue = [
|
||||||
|
...graphqlCollectionStore.value.state.map((coll, index) => ({
|
||||||
|
coll: coll,
|
||||||
|
index: `${index}`,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
while (gqlCollsQueue.length > 0) {
|
||||||
|
const { coll, index } = gqlCollsQueue.shift()!
|
||||||
|
|
||||||
|
gqlCollsQueue.push(
|
||||||
|
...coll.folders.map((folder, folderIndex) => ({
|
||||||
|
coll: folder,
|
||||||
|
index: `${index}/${folderIndex}`,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
minisearch.addAll(
|
||||||
|
coll.requests.map((req, reqIndex) => ({
|
||||||
|
id: `gql-${index}/${reqIndex}`,
|
||||||
|
name: req.name,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadRESTDocsIntoMinisearch(minisearch: MiniSearch) {
|
||||||
|
const restDocsQueue = [
|
||||||
|
...restCollectionStore.value.state.map((coll, index) => ({
|
||||||
|
coll: coll,
|
||||||
|
index: `${index}`,
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
while (restDocsQueue.length > 0) {
|
||||||
|
const { coll, index } = restDocsQueue.shift()!
|
||||||
|
|
||||||
|
restDocsQueue.push(
|
||||||
|
...coll.folders.map((folder, folderIndex) => ({
|
||||||
|
coll: folder,
|
||||||
|
index: `${index}/${folderIndex}`,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
minisearch.addAll(
|
||||||
|
coll.requests.map((req, reqIndex) => ({
|
||||||
|
id: `rest-${index}/${reqIndex}`,
|
||||||
|
name: req.name,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCurrentPageCategory() {
|
||||||
|
// TODO: Better logic for this ?
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
|
||||||
|
if (url.pathname.startsWith("/graphql")) {
|
||||||
|
return "graphql"
|
||||||
|
} else if (url.pathname === "/") {
|
||||||
|
return "rest"
|
||||||
|
} else {
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public createSearchSession(
|
||||||
|
query: Readonly<Ref<string>>
|
||||||
|
): [Ref<SpotlightSearcherSessionState>, () => void] {
|
||||||
|
const pageCategory = this.getCurrentPageCategory()
|
||||||
|
|
||||||
|
const minisearch = new MiniSearch({
|
||||||
|
fields: ["name"],
|
||||||
|
storeFields: ["name"],
|
||||||
|
searchOptions: {
|
||||||
|
prefix: true,
|
||||||
|
fuzzy: true,
|
||||||
|
boost: {
|
||||||
|
name: 2,
|
||||||
|
},
|
||||||
|
weights: {
|
||||||
|
fuzzy: 0.2,
|
||||||
|
prefix: 0.8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (pageCategory === "rest") {
|
||||||
|
this.loadRESTDocsIntoMinisearch(minisearch)
|
||||||
|
} else if (pageCategory === "graphql") {
|
||||||
|
this.loadGQLDocsIntoMinisearch(minisearch)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = ref<SpotlightSearcherResult[]>([])
|
||||||
|
|
||||||
|
const scopeHandle = effectScope()
|
||||||
|
|
||||||
|
scopeHandle.run(() => {
|
||||||
|
watch(query, (query) => {
|
||||||
|
if (pageCategory === "other") {
|
||||||
|
results.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageCategory === "rest") {
|
||||||
|
const searchResults = minisearch.search(query).slice(0, 10)
|
||||||
|
|
||||||
|
results.value = searchResults.map((result) => ({
|
||||||
|
id: result.id,
|
||||||
|
text: {
|
||||||
|
type: "custom",
|
||||||
|
component: markRaw(RESTRequestSpotlightEntry),
|
||||||
|
componentProps: {
|
||||||
|
folderPath: result.id.split("rest-")[1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icon: markRaw(IconFolder),
|
||||||
|
score: result.score,
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
const searchResults = minisearch.search(query).slice(0, 10)
|
||||||
|
|
||||||
|
results.value = searchResults.map((result) => ({
|
||||||
|
id: result.id,
|
||||||
|
text: {
|
||||||
|
type: "custom",
|
||||||
|
component: markRaw(GQLRequestSpotlightEntry),
|
||||||
|
componentProps: {
|
||||||
|
folderPath: result.id.split("gql-")[1],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
icon: markRaw(IconFolder),
|
||||||
|
score: result.score,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const resultObj = computed<SpotlightSearcherSessionState>(() => ({
|
||||||
|
loading: false,
|
||||||
|
results: results.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [
|
||||||
|
resultObj,
|
||||||
|
() => {
|
||||||
|
scopeHandle.stop()
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRESTFolderFromFolderPath(
|
||||||
|
folderPath: string
|
||||||
|
): HoppCollection<HoppRESTRequest> | undefined {
|
||||||
|
try {
|
||||||
|
const folderIndicies = folderPath.split("/").map((x) => parseInt(x))
|
||||||
|
|
||||||
|
let currentFolder =
|
||||||
|
restCollectionStore.value.state[folderIndicies.shift()!]
|
||||||
|
|
||||||
|
while (folderIndicies.length > 0) {
|
||||||
|
const folderIndex = folderIndicies.shift()!
|
||||||
|
|
||||||
|
const folder = currentFolder.folders[folderIndex]
|
||||||
|
|
||||||
|
currentFolder = folder
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentFolder
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGQLFolderFromFolderPath(
|
||||||
|
folderPath: string
|
||||||
|
): HoppCollection<HoppGQLRequest> | undefined {
|
||||||
|
try {
|
||||||
|
const folderIndicies = folderPath.split("/").map((x) => parseInt(x))
|
||||||
|
|
||||||
|
let currentFolder =
|
||||||
|
graphqlCollectionStore.value.state[folderIndicies.shift()!]
|
||||||
|
|
||||||
|
while (folderIndicies.length > 0) {
|
||||||
|
const folderIndex = folderIndicies.shift()!
|
||||||
|
|
||||||
|
const folder = currentFolder.folders[folderIndex]
|
||||||
|
|
||||||
|
currentFolder = folder
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentFolder
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onResultSelect(result: SpotlightSearcherResult): void {
|
||||||
|
const [type, path] = result.id.split("-")
|
||||||
|
|
||||||
|
if (type === "rest") {
|
||||||
|
const folderPath = path.split("/").map((x) => parseInt(x))
|
||||||
|
const reqIndex = folderPath.pop()!
|
||||||
|
|
||||||
|
if (hoppWorkspaceStore.value.workspace.type !== "personal") {
|
||||||
|
changeWorkspace({
|
||||||
|
type: "personal",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const possibleTab = getTabRefWithSaveContext({
|
||||||
|
originLocation: "user-collection",
|
||||||
|
folderPath: folderPath.join("/"),
|
||||||
|
requestIndex: reqIndex,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (possibleTab) {
|
||||||
|
currentTabID.value = possibleTab.value.id
|
||||||
|
} else {
|
||||||
|
const req = this.getRESTFolderFromFolderPath(folderPath.join("/"))
|
||||||
|
?.requests[reqIndex]
|
||||||
|
|
||||||
|
if (!req) return
|
||||||
|
|
||||||
|
createNewTab(
|
||||||
|
{
|
||||||
|
request: req,
|
||||||
|
isDirty: false,
|
||||||
|
saveContext: {
|
||||||
|
originLocation: "user-collection",
|
||||||
|
folderPath: folderPath.join("/"),
|
||||||
|
requestIndex: reqIndex,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (type === "gql") {
|
||||||
|
const folderPath = path.split("/").map((x) => parseInt(x))
|
||||||
|
const reqIndex = folderPath.pop()!
|
||||||
|
|
||||||
|
const req = this.getGQLFolderFromFolderPath(folderPath.join("/"))
|
||||||
|
?.requests[reqIndex]
|
||||||
|
|
||||||
|
if (!req) return
|
||||||
|
|
||||||
|
setGQLSession({
|
||||||
|
request: cloneDeep(req),
|
||||||
|
schema: "",
|
||||||
|
response: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user