From 3bf8288de3fb85df99b93c451e2e8d7f65646c1c Mon Sep 17 00:00:00 2001 From: Andrew Bastin Date: Mon, 3 Jul 2023 12:03:17 +0530 Subject: [PATCH] feat: initial reworked spotlight implementation --- packages/hoppscotch-common/locales/en.json | 1 + .../hoppscotch-common/src/components.d.ts | 5 +- .../src/components/app/Fuse.vue | 69 --- .../src/components/app/PowerSearch.vue | 122 ---- .../src/components/app/PowerSearchEntry.vue | 68 --- .../src/components/app/Shortcuts.vue | 2 +- .../src/components/app/spotlight/Entry.vue | 123 ++++ .../app/spotlight/entry/History.vue | 40 ++ .../src/components/app/spotlight/index.vue | 205 +++++++ .../src/helpers/powerSearchNavigation.ts | 55 -- .../hoppscotch-common/src/layouts/default.vue | 2 +- .../spotlight/__tests__/index.spec.ts | 550 ++++++++++++++++++ .../src/services/spotlight/index.ts | 135 +++++ .../searchers/base/static.searcher.ts | 106 ++++ .../spotlight/searchers/history.searcher.ts | 169 ++++++ 15 files changed, 1334 insertions(+), 318 deletions(-) delete mode 100644 packages/hoppscotch-common/src/components/app/Fuse.vue delete mode 100644 packages/hoppscotch-common/src/components/app/PowerSearch.vue delete mode 100644 packages/hoppscotch-common/src/components/app/PowerSearchEntry.vue create mode 100644 packages/hoppscotch-common/src/components/app/spotlight/Entry.vue create mode 100644 packages/hoppscotch-common/src/components/app/spotlight/entry/History.vue create mode 100644 packages/hoppscotch-common/src/components/app/spotlight/index.vue delete mode 100644 packages/hoppscotch-common/src/helpers/powerSearchNavigation.ts create mode 100644 packages/hoppscotch-common/src/services/spotlight/__tests__/index.spec.ts create mode 100644 packages/hoppscotch-common/src/services/spotlight/index.ts create mode 100644 packages/hoppscotch-common/src/services/spotlight/searchers/base/static.searcher.ts create mode 100644 packages/hoppscotch-common/src/services/spotlight/searchers/history.searcher.ts diff --git a/packages/hoppscotch-common/locales/en.json b/packages/hoppscotch-common/locales/en.json index 092225236..79f7304a8 100644 --- a/packages/hoppscotch-common/locales/en.json +++ b/packages/hoppscotch-common/locales/en.json @@ -4,6 +4,7 @@ "cancel": "Cancel", "choose_file": "Choose a file", "clear": "Clear", + "clear_history": "Clear All History", "clear_all": "Clear all", "close": "Close", "connect": "Connect", diff --git a/packages/hoppscotch-common/src/components.d.ts b/packages/hoppscotch-common/src/components.d.ts index 21264cc9e..2f65709a4 100644 --- a/packages/hoppscotch-common/src/components.d.ts +++ b/packages/hoppscotch-common/src/components.d.ts @@ -18,13 +18,14 @@ declare module '@vue/runtime-core' { AppLogo: typeof import('./components/app/Logo.vue')['default'] AppOptions: typeof import('./components/app/Options.vue')['default'] AppPaneLayout: typeof import('./components/app/PaneLayout.vue')['default'] - AppPowerSearch: typeof import('./components/app/PowerSearch.vue')['default'] - AppPowerSearchEntry: typeof import('./components/app/PowerSearchEntry.vue')['default'] AppShare: typeof import('./components/app/Share.vue')['default'] AppShortcuts: typeof import('./components/app/Shortcuts.vue')['default'] AppShortcutsEntry: typeof import('./components/app/ShortcutsEntry.vue')['default'] AppShortcutsPrompt: typeof import('./components/app/ShortcutsPrompt.vue')['default'] AppSidenav: typeof import('./components/app/Sidenav.vue')['default'] + AppSpotlight: typeof import('./components/app/spotlight/index.vue')['default'] + AppSpotlightEntry: typeof import('./components/app/spotlight/Entry.vue')['default'] + AppSpotlightEntryHistory: typeof import('./components/app/spotlight/entry/History.vue')['default'] AppSupport: typeof import('./components/app/Support.vue')['default'] ButtonPrimary: typeof import('./../../hoppscotch-ui/src/components/button/Primary.vue')['default'] ButtonSecondary: typeof import('./../../hoppscotch-ui/src/components/button/Secondary.vue')['default'] diff --git a/packages/hoppscotch-common/src/components/app/Fuse.vue b/packages/hoppscotch-common/src/components/app/Fuse.vue deleted file mode 100644 index c6740137e..000000000 --- a/packages/hoppscotch-common/src/components/app/Fuse.vue +++ /dev/null @@ -1,69 +0,0 @@ - - - diff --git a/packages/hoppscotch-common/src/components/app/PowerSearch.vue b/packages/hoppscotch-common/src/components/app/PowerSearch.vue deleted file mode 100644 index 88645191b..000000000 --- a/packages/hoppscotch-common/src/components/app/PowerSearch.vue +++ /dev/null @@ -1,122 +0,0 @@ - - - diff --git a/packages/hoppscotch-common/src/components/app/PowerSearchEntry.vue b/packages/hoppscotch-common/src/components/app/PowerSearchEntry.vue deleted file mode 100644 index 631e06cfb..000000000 --- a/packages/hoppscotch-common/src/components/app/PowerSearchEntry.vue +++ /dev/null @@ -1,68 +0,0 @@ - - - - - diff --git a/packages/hoppscotch-common/src/components/app/Shortcuts.vue b/packages/hoppscotch-common/src/components/app/Shortcuts.vue index 5fc9dc342..0f367416b 100644 --- a/packages/hoppscotch-common/src/components/app/Shortcuts.vue +++ b/packages/hoppscotch-common/src/components/app/Shortcuts.vue @@ -48,7 +48,7 @@ {{ t("state.nothing_found") }} "{{ filterText }}" - +
+ + + + + + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/entry/History.vue b/packages/hoppscotch-common/src/components/app/spotlight/entry/History.vue new file mode 100644 index 000000000..15a453c74 --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/entry/History.vue @@ -0,0 +1,40 @@ + + + diff --git a/packages/hoppscotch-common/src/components/app/spotlight/index.vue b/packages/hoppscotch-common/src/components/app/spotlight/index.vue new file mode 100644 index 000000000..7542562fb --- /dev/null +++ b/packages/hoppscotch-common/src/components/app/spotlight/index.vue @@ -0,0 +1,205 @@ + + + diff --git a/packages/hoppscotch-common/src/helpers/powerSearchNavigation.ts b/packages/hoppscotch-common/src/helpers/powerSearchNavigation.ts deleted file mode 100644 index 683b3a579..000000000 --- a/packages/hoppscotch-common/src/helpers/powerSearchNavigation.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { ref } from "vue" - -const NAVIGATION_KEYS = ["ArrowDown", "ArrowUp", "Enter"] - -export function useArrowKeysNavigation(searchItems: any, options: any = {}) { - function handleArrowKeysNavigation( - event: any, - itemIndex: any, - preventPropagation: boolean - ) { - if (!NAVIGATION_KEYS.includes(event.key)) return - - if (preventPropagation) event.stopImmediatePropagation() - - const itemsLength = searchItems.value.length - const lastItemIndex = itemsLength - 1 - const itemIndexValue = itemIndex.value - const action = searchItems.value[itemIndexValue]?.action - - if (action && event.key === "Enter" && options.onEnter) { - options.onEnter(action) - return - } - - if (itemsLength && event.key === "ArrowDown") { - itemIndex.value = itemIndexValue < lastItemIndex ? itemIndexValue + 1 : 0 - } else if (itemIndexValue === 0) itemIndex.value = lastItemIndex - else if (itemsLength && event.key === "ArrowUp") - itemIndex.value = itemIndexValue - 1 - } - - const preventPropagation = options && options.stopPropagation - - const selectedEntry = ref(0) - - const onKeyUp = (event: any) => { - handleArrowKeysNavigation(event, selectedEntry, preventPropagation) - } - - function bindArrowKeysListeners() { - window.addEventListener("keydown", onKeyUp, { capture: preventPropagation }) - } - - function unbindArrowKeysListeners() { - window.removeEventListener("keydown", onKeyUp, { - capture: preventPropagation, - }) - } - - return { - bindArrowKeysListeners, - unbindArrowKeysListeners, - selectedEntry, - } -} diff --git a/packages/hoppscotch-common/src/layouts/default.vue b/packages/hoppscotch-common/src/layouts/default.vue index 7be727a63..4e307bec0 100644 --- a/packages/hoppscotch-common/src/layouts/default.vue +++ b/packages/hoppscotch-common/src/layouts/default.vue @@ -50,7 +50,7 @@ - + >) => { + // A basic searcher that returns the query string as the sole result + const loading = ref(false) + const results = ref([]) + + watch( + query, + (query) => { + loading.value = true + + results.value = [ + { + id: "searcher-a-result", + text: { + type: "text", + text: query, + }, + icon: {}, + score: 1, + }, + ] + + loading.value = false + }, + { immediate: true } + ) + + const onSessionEnd = () => { + /* noop */ + } + + return [ + computed(() => ({ + loading: loading.value, + results: results.value, + })), + onSessionEnd, + ] + }, + + onResultSelect: () => { + /* noop */ + }, +} + +const emptySearcher: SpotlightSearcher = { + id: "empty-searcher", + sectionTitle: "Empty Searcher", + createSearchSession: () => { + const loading = ref(false) + + return [ + computed(() => ({ + loading: loading.value, + results: [], + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, +} + +describe("SpotlightService", () => { + describe("registerSearcher", () => { + it("registers a searcher with a given ID", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + const [id, searcher] = spotlight.getAllSearchers().next().value + + expect(id).toEqual("echo-searcher") + expect(searcher).toBe(echoSearcher) + }) + + it("if 2 searchers are registered with the same ID, the last one overwrites the first one", () => { + const echoSearcherFake: SpotlightSearcher = { + id: "echo-searcher", + sectionTitle: "Echo Searcher", + createSearchSession: () => { + throw new Error("not implemented") + }, + onResultSelect: () => { + throw new Error("not implemented") + }, + } + + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + spotlight.registerSearcher(echoSearcherFake) + + const [id, searcher] = spotlight.getAllSearchers().next().value + + expect(id).toEqual("echo-searcher") + expect(searcher).toBe(echoSearcherFake) + }) + }) + + describe("createSearchSession", () => { + it("when the source query changes, the searchers are notified", async () => { + const container = new TestContainer() + + const notifiedFn = vi.fn() + + const sampleSearcher: SpotlightSearcher = { + id: "searcher", + sectionTitle: "Searcher", + createSearchSession: (query) => { + const stop = watch(query, notifiedFn, { immediate: true }) + + return [ + ref({ + loading: false, + results: [], + }), + () => { + stop() + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(sampleSearcher) + + const query = ref("test") + + const [, dispose] = spotlight.createSearchSession(query) + + query.value = "test2" + await nextTick() + + expect(notifiedFn).toHaveBeenCalledTimes(2) + + dispose() + }) + + it("when a searcher returns results, they are added to the results", async () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + await nextTick() + + expect(session.value.results).toHaveProperty("echo-searcher") + expect(session.value.results["echo-searcher"]).toEqual({ + title: "Echo Searcher", + avgScore: 1, + results: [ + { + id: "searcher-a-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }, + ], + }) + + dispose() + }) + + it("when a searcher does not return any results, they are not added to the results", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(emptySearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.results).not.toHaveProperty("empty-searcher") + expect(session.value.results).toEqual({}) + + dispose() + }) + + it("when any of the searchers report they are loading, the search session says it is loading", () => { + const container = new TestContainer() + + const loadingSearcher: SpotlightSearcher = { + id: "loading-searcher", + sectionTitle: "Loading Searcher", + createSearchSession: () => { + const loading = ref(true) + const results = ref([]) + + return [ + computed(() => ({ + loading: loading.value, + results: results.value, + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(loadingSearcher) + spotlight.registerSearcher(echoSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.loading).toBe(true) + + dispose() + }) + + it("when all of the searchers report they are not loading, the search session says it is not loading", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + spotlight.registerSearcher(emptySearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.loading).toBe(false) + + dispose() + }) + + it("when a searcher changes its loading state after a while, the search session state updates", async () => { + const container = new TestContainer() + + const loading = ref(true) + + const loadingSearcher: SpotlightSearcher = { + id: "loading-searcher", + sectionTitle: "Loading Searcher", + createSearchSession: () => { + return [ + computed(() => ({ + loading: loading.value, + results: [], + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(loadingSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.loading).toBe(true) + + loading.value = false + + await nextTick() + + expect(session.value.loading).toBe(false) + + dispose() + }) + + it("when the searcher updates its results, the search session state updates", async () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + const query = ref("test") + const [session, dispose] = spotlight.createSearchSession(query) + + expect(session.value.results).toHaveProperty("echo-searcher") + expect(session.value.results["echo-searcher"]).toEqual({ + title: "Echo Searcher", + avgScore: 1, + results: [ + { + id: "searcher-a-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }, + ], + }) + + query.value = "test2" + await nextTick() + + expect(session.value.results).toHaveProperty("echo-searcher") + expect(session.value.results["echo-searcher"]).toEqual({ + title: "Echo Searcher", + avgScore: 1, + results: [ + { + id: "searcher-a-result", + text: { + type: "text", + text: "test2", + }, + icon: {}, + score: 1, + }, + ], + }) + + dispose() + }) + + it("when the returned dispose function is called, the searchers are notified", () => { + const container = new TestContainer() + + const disposeFn = vi.fn() + + const testSearcher: SpotlightSearcher = { + id: "test-searcher", + sectionTitle: "Test Searcher", + createSearchSession: () => { + return [ + computed(() => ({ + loading: false, + results: [], + })), + disposeFn, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + const query = ref("test") + const [, dispose] = spotlight.createSearchSession(query) + + dispose() + + expect(disposeFn).toHaveBeenCalledOnce() + }) + + it("when the search session is disposed, changes to the query are not notified to the searchers", async () => { + const container = new TestContainer() + + const notifiedFn = vi.fn() + + const testSearcher: SpotlightSearcher = { + id: "test-searcher", + sectionTitle: "Test Searcher", + createSearchSession: (query) => { + watch(query, notifiedFn, { immediate: true }) + + return [ + computed(() => ({ + loading: false, + results: [], + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: () => { + /* noop */ + }, + } + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + const query = ref("test") + const [, dispose] = spotlight.createSearchSession(query) + + query.value = "test2" + await nextTick() + + expect(notifiedFn).toHaveBeenCalledTimes(2) + + dispose() + + query.value = "test3" + await nextTick() + + expect(notifiedFn).toHaveBeenCalledTimes(3) + }) + + describe("selectSearchResult", () => { + const onResultSelectFn = vi.fn() + + const testSearcher: SpotlightSearcher = { + id: "test-searcher", + sectionTitle: "Test Searcher", + createSearchSession: () => { + return [ + computed(() => ({ + loading: false, + results: [], + })), + () => { + /* noop */ + }, + ] + }, + onResultSelect: onResultSelectFn, + } + + beforeEach(() => { + onResultSelectFn.mockReset() + }) + + it("does nothing if the searcherID is invalid", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + spotlight.selectSearchResult("invalid-searcher-id", { + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + + expect(onResultSelectFn).not.toHaveBeenCalled() + }) + + it("calls the correspondig searcher's onResultSelect method", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + spotlight.selectSearchResult("test-searcher", { + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + + expect(onResultSelectFn).toHaveBeenCalledOnce() + }) + + it("passes the correct information to the searcher's onResultSelect method", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(testSearcher) + + spotlight.selectSearchResult("test-searcher", { + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + + expect(onResultSelectFn).toHaveBeenCalledWith({ + id: "test-result", + text: { + type: "text", + text: "test", + }, + icon: {}, + score: 1, + }) + }) + }) + }) + + describe("getAllSearchers", () => { + it("when no searchers are registered, it returns an empty array", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + + expect(Array.from(spotlight.getAllSearchers())).toEqual([]) + }) + + it("when a searcher is registered, it returns an array with a tuple of the searcher id and then then searcher", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + + expect(Array.from(spotlight.getAllSearchers())).toEqual([ + ["echo-searcher", echoSearcher], + ]) + }) + + it("returns all registered searchers", () => { + const container = new TestContainer() + + const spotlight = container.bind(SpotlightService) + spotlight.registerSearcher(echoSearcher) + spotlight.registerSearcher(emptySearcher) + + expect(Array.from(spotlight.getAllSearchers())).toEqual([ + ["echo-searcher", echoSearcher], + ["empty-searcher", emptySearcher], + ]) + }) + }) +}) diff --git a/packages/hoppscotch-common/src/services/spotlight/index.ts b/packages/hoppscotch-common/src/services/spotlight/index.ts new file mode 100644 index 000000000..73ccab48c --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/index.ts @@ -0,0 +1,135 @@ +import { Service } from "dioc" +import { watch, type Ref, ref, reactive, effectScope, Component } from "vue" + +export type SpotlightResultTextType = + | { type: "text"; text: string[] | string } + | { + type: "custom" + component: T + componentProps: T extends Component ? Props : never + } + +export type SpotlightSearcherResult = { + id: string + text: SpotlightResultTextType + icon: object | Component + score: number + meta?: { + keyboardShortcut?: string[] + } +} + +export type SpotlightSearcherSessionState = { + loading: boolean + results: SpotlightSearcherResult[] +} + +export interface SpotlightSearcher { + id: string + sectionTitle: string + + createSearchSession( + query: Readonly> + ): [Ref, () => void] + + onResultSelect(result: SpotlightSearcherResult): void +} + +export type SpotlightSearchState = { + loading: boolean + results: Record< + string, + { + title: string + avgScore: number + results: SpotlightSearcherResult[] + } + > +} + +export class SpotlightService extends Service { + public static readonly ID = "SPOTLIGHT_SERVICE" + + private searchers: Map = new Map() + + public registerSearcher(searcher: SpotlightSearcher) { + this.searchers.set(searcher.id, searcher) + } + + public getAllSearchers(): IterableIterator<[string, SpotlightSearcher]> { + return this.searchers.entries() + } + + public createSearchSession( + query: Ref + ): [Ref, () => void] { + const searchSessions = Array.from(this.searchers.values()).map( + (x) => [x, ...x.createSearchSession(query)] as const + ) + + const loadingSearchers = reactive(new Set()) + const onSessionEndList: Array<() => void> = [] + + const resultObj = ref({ + loading: false, + results: {}, + }) + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + for (const [searcher, state, onSessionEnd] of searchSessions) { + watch( + state, + (newState) => { + if (newState.loading) { + loadingSearchers.add(searcher.id) + } else { + loadingSearchers.delete(searcher.id) + } + + if (newState.results.length === 0) { + delete resultObj.value.results[searcher.id] + } else { + resultObj.value.results[searcher.id] = { + title: searcher.sectionTitle, + avgScore: + newState.results.reduce((acc, x) => acc + x.score, 0) / + newState.results.length, + results: newState.results, + } + } + }, + { immediate: true } + ) + + onSessionEndList.push(onSessionEnd) + } + + watch( + loadingSearchers, + (set) => { + resultObj.value.loading = set.size > 0 + }, + { immediate: true } + ) + }) + + const onSearchEnd = () => { + scopeHandle.stop() + + for (const onEnd of onSessionEndList) { + onEnd() + } + } + + return [resultObj, onSearchEnd] + } + + public selectSearchResult( + searcherID: string, + result: SpotlightSearcherResult + ) { + this.searchers.get(searcherID)?.onResultSelect(result) + } +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/base/static.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/base/static.searcher.ts new file mode 100644 index 000000000..533a32c0d --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/base/static.searcher.ts @@ -0,0 +1,106 @@ +import { Service } from "dioc" +import { + type SpotlightSearcher, + type SpotlightSearcherResult, + type SpotlightSearcherSessionState, +} from "../" +import MiniSearch, { type SearchResult } from "minisearch" +import { Ref, computed, effectScope, ref, watch } from "vue" +import { MaybeRef, resolveUnref } from "@vueuse/core" + +export abstract class StaticSpotlightSearcherService< + Doc extends Record & { + excludeFromSearch?: boolean + } & Record, + DocFields extends Array + > + extends Service + implements SpotlightSearcher +{ + public abstract readonly id: string + public abstract readonly sectionTitle: string + + private minisearch: MiniSearch + + private loading = ref(false) + + constructor( + private documents: MaybeRef>, + searchFields: Array, + resultFields: DocFields + ) { + super() + + this.minisearch = new MiniSearch({ + fields: searchFields as string[], + storeFields: resultFields as string[], + }) + + this.addDocsToSearchIndex(resolveUnref(documents)) + } + + private async addDocsToSearchIndex(docs: Record) { + this.loading.value = true + + await this.minisearch.addAllAsync( + Object.entries(docs).map(([id, doc]) => ({ + id, + ...doc, + })) + ) + + this.loading.value = false + } + + protected abstract getSearcherResultForSearchResult( + result: Pick + ): SpotlightSearcherResult + + public createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const results = ref([]) + + const resultObj = computed(() => ({ + loading: this.loading.value, + results: results.value, + })) + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + watch( + [query, () => resolveUnref(this.documents)], + ([query, docs]) => { + const searchResults = this.minisearch.search(query, { + prefix: true, + fuzzy: 0.2, + weights: { + fuzzy: 0.2, + prefix: 0.6, + }, + }) + + results.value = searchResults + .filter( + (result) => + docs[result.id].excludeFromSearch === undefined || + docs[result.id].excludeFromSearch === false + ) + .map((result) => + this.getSearcherResultForSearchResult(result as any) + ) + }, + { immediate: true } + ) + }) + + const onSessionEnd = () => { + scopeHandle.stop() + } + + return [resultObj, onSessionEnd] + } + + public abstract onResultSelect(result: SpotlightSearcherResult): void +} diff --git a/packages/hoppscotch-common/src/services/spotlight/searchers/history.searcher.ts b/packages/hoppscotch-common/src/services/spotlight/searchers/history.searcher.ts new file mode 100644 index 000000000..672a3da2d --- /dev/null +++ b/packages/hoppscotch-common/src/services/spotlight/searchers/history.searcher.ts @@ -0,0 +1,169 @@ +import { Service } from "dioc" +import { + SpotlightSearcher, + SpotlightSearcherResult, + SpotlightSearcherSessionState, + SpotlightService, +} from "../" +import { Ref, computed, effectScope, markRaw, ref, watch } from "vue" +import { getI18n } from "~/modules/i18n" +import MiniSearch from "minisearch" +import { restHistoryStore } from "~/newstore/history" +import { useTimeAgo } from "@vueuse/core" +import IconHistory from "~icons/lucide/history" +import IconTrash2 from "~icons/lucide/trash-2" +import SpotlightHistoryEntry from "~/components/app/spotlight/entry/History.vue" +import { createNewTab } from "~/helpers/rest/tab" +import { capitalize } from "lodash-es" +import { shortDateTime } from "~/helpers/utils/date" +import { useStreamStatic } from "~/composables/stream" +import { activeActions$, invokeAction } from "~/helpers/actions" +import { map } from "rxjs/operators" + +export class HistorySpotlightSearcherService + extends Service + implements SpotlightSearcher +{ + public static readonly ID = "HISTORY_SPOTLIGHT_SEARCHER_SERVICE" + + private t = getI18n() + + public id = "history" + public sectionTitle = this.t("tab.history") + + private readonly spotlight = this.bind(SpotlightService) + + private clearHistoryActionEnabled = useStreamStatic( + activeActions$.pipe(map((actions) => actions.includes("history.clear"))), + activeActions$.value.includes("history.clear"), + () => { + /* noop */ + } + )[0] + + constructor() { + super() + + this.spotlight.registerSearcher(this) + } + + createSearchSession( + query: Readonly> + ): [Ref, () => void] { + const loading = ref(false) + const results = ref([]) + + const minisearch = new MiniSearch({ + fields: ["url", "title", "reltime", "date"], + storeFields: ["url"], + }) + + const stopWatchHandle = watch( + this.clearHistoryActionEnabled, + (enabled) => { + if (enabled) { + minisearch.add({ + id: "clear-history", + title: this.t("action.clear_history"), + }) + } else { + minisearch.discard("clear-history") + } + }, + { immediate: true } + ) + + minisearch.addAll( + restHistoryStore.value.state + .filter((x) => !!x.updatedOn) + .map((entry, index) => { + const relTimeString = capitalize( + useTimeAgo(entry.updatedOn!, { + updateInterval: 0, + }).value + ) + + return { + id: index.toString(), + url: entry.request.endpoint, + reltime: relTimeString, + date: shortDateTime(entry.updatedOn!), + } + }) + ) + + const scopeHandle = effectScope() + + scopeHandle.run(() => { + watch(query, (query) => { + results.value = minisearch + .search(query, { + prefix: true, + fuzzy: true, + boost: { + reltime: 2, + }, + weights: { + fuzzy: 0.2, + prefix: 0.8, + }, + }) + .map((x) => { + const entry = restHistoryStore.value.state[parseInt(x.id)] + + if (x.id === "clear-history") { + return { + id: "clear-history", + icon: markRaw(IconTrash2), + score: x.score, + text: { + type: "text", + text: this.t("action.clear_history"), + }, + } + } + + return { + id: x.id, + icon: markRaw(IconHistory), + score: x.score, + text: { + type: "custom", + component: markRaw(SpotlightHistoryEntry), + componentProps: { + historyEntry: entry, + }, + }, + } + }) + }) + }) + + const onSessionEnd = () => { + scopeHandle.stop() + stopWatchHandle() + minisearch.removeAll() + } + + const resultObj = computed(() => ({ + loading: loading.value, + results: results.value, + })) + + return [resultObj, onSessionEnd] + } + + onResultSelect(result: SpotlightSearcherResult): void { + if (result.id === "clear-history") { + invokeAction("history.clear") + return + } + + const req = restHistoryStore.value.state[parseInt(result.id)].request + + createNewTab({ + request: req, + isDirty: false, + }) + } +}