feat: initial reworked spotlight implementation

This commit is contained in:
Andrew Bastin
2023-07-03 12:03:17 +05:30
parent 8c48d41eed
commit 3bf8288de3
15 changed files with 1334 additions and 318 deletions

View File

@@ -0,0 +1,550 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
SpotlightSearcher,
SpotlightSearcherSessionState,
SpotlightSearcherResult,
SpotlightService,
} from "../"
import { Ref, computed, nextTick, ref, watch } from "vue"
import { TestContainer } from "dioc/testing"
const echoSearcher: SpotlightSearcher = {
id: "echo-searcher",
sectionTitle: "Echo Searcher",
createSearchSession: (query: Readonly<Ref<string>>) => {
// A basic searcher that returns the query string as the sole result
const loading = ref(false)
const results = ref<SpotlightSearcherResult[]>([])
watch(
query,
(query) => {
loading.value = true
results.value = [
{
id: "searcher-a-result",
text: {
type: "text",
text: query,
},
icon: {},
score: 1,
},
]
loading.value = false
},
{ immediate: true }
)
const onSessionEnd = () => {
/* noop */
}
return [
computed<SpotlightSearcherSessionState>(() => ({
loading: loading.value,
results: results.value,
})),
onSessionEnd,
]
},
onResultSelect: () => {
/* noop */
},
}
const emptySearcher: SpotlightSearcher = {
id: "empty-searcher",
sectionTitle: "Empty Searcher",
createSearchSession: () => {
const loading = ref(false)
return [
computed<SpotlightSearcherSessionState>(() => ({
loading: loading.value,
results: [],
})),
() => {
/* noop */
},
]
},
onResultSelect: () => {
/* noop */
},
}
describe("SpotlightService", () => {
describe("registerSearcher", () => {
it("registers a searcher with a given ID", () => {
const container = new TestContainer()
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(echoSearcher)
const [id, searcher] = spotlight.getAllSearchers().next().value
expect(id).toEqual("echo-searcher")
expect(searcher).toBe(echoSearcher)
})
it("if 2 searchers are registered with the same ID, the last one overwrites the first one", () => {
const echoSearcherFake: SpotlightSearcher = {
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<SpotlightSearcherSessionState>({
loading: false,
results: [],
}),
() => {
stop()
},
]
},
onResultSelect: () => {
/* noop */
},
}
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(sampleSearcher)
const query = ref("test")
const [, dispose] = spotlight.createSearchSession(query)
query.value = "test2"
await nextTick()
expect(notifiedFn).toHaveBeenCalledTimes(2)
dispose()
})
it("when a searcher returns results, they are added to the results", async () => {
const container = new TestContainer()
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(echoSearcher)
const query = ref("test")
const [session, dispose] = spotlight.createSearchSession(query)
await nextTick()
expect(session.value.results).toHaveProperty("echo-searcher")
expect(session.value.results["echo-searcher"]).toEqual({
title: "Echo Searcher",
avgScore: 1,
results: [
{
id: "searcher-a-result",
text: {
type: "text",
text: "test",
},
icon: {},
score: 1,
},
],
})
dispose()
})
it("when a searcher does not return any results, they are not added to the results", () => {
const container = new TestContainer()
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(emptySearcher)
const query = ref("test")
const [session, dispose] = spotlight.createSearchSession(query)
expect(session.value.results).not.toHaveProperty("empty-searcher")
expect(session.value.results).toEqual({})
dispose()
})
it("when any of the searchers report they are loading, the search session says it is loading", () => {
const container = new TestContainer()
const loadingSearcher: SpotlightSearcher = {
id: "loading-searcher",
sectionTitle: "Loading Searcher",
createSearchSession: () => {
const loading = ref(true)
const results = ref<SpotlightSearcherResult[]>([])
return [
computed<SpotlightSearcherSessionState>(() => ({
loading: loading.value,
results: results.value,
})),
() => {
/* noop */
},
]
},
onResultSelect: () => {
/* noop */
},
}
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(loadingSearcher)
spotlight.registerSearcher(echoSearcher)
const query = ref("test")
const [session, dispose] = spotlight.createSearchSession(query)
expect(session.value.loading).toBe(true)
dispose()
})
it("when all of the searchers report they are not loading, the search session says it is not loading", () => {
const container = new TestContainer()
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(echoSearcher)
spotlight.registerSearcher(emptySearcher)
const query = ref("test")
const [session, dispose] = spotlight.createSearchSession(query)
expect(session.value.loading).toBe(false)
dispose()
})
it("when a searcher changes its loading state after a while, the search session state updates", async () => {
const container = new TestContainer()
const loading = ref(true)
const loadingSearcher: SpotlightSearcher = {
id: "loading-searcher",
sectionTitle: "Loading Searcher",
createSearchSession: () => {
return [
computed<SpotlightSearcherSessionState>(() => ({
loading: loading.value,
results: [],
})),
() => {
/* noop */
},
]
},
onResultSelect: () => {
/* noop */
},
}
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(loadingSearcher)
const query = ref("test")
const [session, dispose] = spotlight.createSearchSession(query)
expect(session.value.loading).toBe(true)
loading.value = false
await nextTick()
expect(session.value.loading).toBe(false)
dispose()
})
it("when the searcher updates its results, the search session state updates", async () => {
const container = new TestContainer()
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(echoSearcher)
const query = ref("test")
const [session, dispose] = spotlight.createSearchSession(query)
expect(session.value.results).toHaveProperty("echo-searcher")
expect(session.value.results["echo-searcher"]).toEqual({
title: "Echo Searcher",
avgScore: 1,
results: [
{
id: "searcher-a-result",
text: {
type: "text",
text: "test",
},
icon: {},
score: 1,
},
],
})
query.value = "test2"
await nextTick()
expect(session.value.results).toHaveProperty("echo-searcher")
expect(session.value.results["echo-searcher"]).toEqual({
title: "Echo Searcher",
avgScore: 1,
results: [
{
id: "searcher-a-result",
text: {
type: "text",
text: "test2",
},
icon: {},
score: 1,
},
],
})
dispose()
})
it("when the returned dispose function is called, the searchers are notified", () => {
const container = new TestContainer()
const disposeFn = vi.fn()
const testSearcher: SpotlightSearcher = {
id: "test-searcher",
sectionTitle: "Test Searcher",
createSearchSession: () => {
return [
computed<SpotlightSearcherSessionState>(() => ({
loading: false,
results: [],
})),
disposeFn,
]
},
onResultSelect: () => {
/* noop */
},
}
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(testSearcher)
const query = ref("test")
const [, dispose] = spotlight.createSearchSession(query)
dispose()
expect(disposeFn).toHaveBeenCalledOnce()
})
it("when the search session is disposed, changes to the query are not notified to the searchers", async () => {
const container = new TestContainer()
const notifiedFn = vi.fn()
const testSearcher: SpotlightSearcher = {
id: "test-searcher",
sectionTitle: "Test Searcher",
createSearchSession: (query) => {
watch(query, notifiedFn, { immediate: true })
return [
computed<SpotlightSearcherSessionState>(() => ({
loading: false,
results: [],
})),
() => {
/* noop */
},
]
},
onResultSelect: () => {
/* noop */
},
}
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(testSearcher)
const query = ref("test")
const [, dispose] = spotlight.createSearchSession(query)
query.value = "test2"
await nextTick()
expect(notifiedFn).toHaveBeenCalledTimes(2)
dispose()
query.value = "test3"
await nextTick()
expect(notifiedFn).toHaveBeenCalledTimes(3)
})
describe("selectSearchResult", () => {
const onResultSelectFn = vi.fn()
const testSearcher: SpotlightSearcher = {
id: "test-searcher",
sectionTitle: "Test Searcher",
createSearchSession: () => {
return [
computed<SpotlightSearcherSessionState>(() => ({
loading: false,
results: [],
})),
() => {
/* noop */
},
]
},
onResultSelect: onResultSelectFn,
}
beforeEach(() => {
onResultSelectFn.mockReset()
})
it("does nothing if the searcherID is invalid", () => {
const container = new TestContainer()
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(testSearcher)
spotlight.selectSearchResult("invalid-searcher-id", {
id: "test-result",
text: {
type: "text",
text: "test",
},
icon: {},
score: 1,
})
expect(onResultSelectFn).not.toHaveBeenCalled()
})
it("calls the correspondig searcher's onResultSelect method", () => {
const container = new TestContainer()
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(testSearcher)
spotlight.selectSearchResult("test-searcher", {
id: "test-result",
text: {
type: "text",
text: "test",
},
icon: {},
score: 1,
})
expect(onResultSelectFn).toHaveBeenCalledOnce()
})
it("passes the correct information to the searcher's onResultSelect method", () => {
const container = new TestContainer()
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(testSearcher)
spotlight.selectSearchResult("test-searcher", {
id: "test-result",
text: {
type: "text",
text: "test",
},
icon: {},
score: 1,
})
expect(onResultSelectFn).toHaveBeenCalledWith({
id: "test-result",
text: {
type: "text",
text: "test",
},
icon: {},
score: 1,
})
})
})
})
describe("getAllSearchers", () => {
it("when no searchers are registered, it returns an empty array", () => {
const container = new TestContainer()
const spotlight = container.bind(SpotlightService)
expect(Array.from(spotlight.getAllSearchers())).toEqual([])
})
it("when a searcher is registered, it returns an array with a tuple of the searcher id and then then searcher", () => {
const container = new TestContainer()
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(echoSearcher)
expect(Array.from(spotlight.getAllSearchers())).toEqual([
["echo-searcher", echoSearcher],
])
})
it("returns all registered searchers", () => {
const container = new TestContainer()
const spotlight = container.bind(SpotlightService)
spotlight.registerSearcher(echoSearcher)
spotlight.registerSearcher(emptySearcher)
expect(Array.from(spotlight.getAllSearchers())).toEqual([
["echo-searcher", echoSearcher],
["empty-searcher", emptySearcher],
])
})
})
})

View File

@@ -0,0 +1,135 @@
import { Service } from "dioc"
import { watch, type Ref, ref, reactive, effectScope, Component } from "vue"
export type SpotlightResultTextType<T extends object | Component = never> =
| { type: "text"; text: string[] | string }
| {
type: "custom"
component: T
componentProps: T extends Component<infer Props> ? Props : never
}
export type SpotlightSearcherResult = {
id: string
text: SpotlightResultTextType<any>
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<string>>
): [Ref<SpotlightSearcherSessionState>, () => 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<string, SpotlightSearcher> = 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<string>
): [Ref<SpotlightSearchState>, () => void] {
const searchSessions = Array.from(this.searchers.values()).map(
(x) => [x, ...x.createSearchSession(query)] as const
)
const loadingSearchers = reactive(new Set())
const onSessionEndList: Array<() => void> = []
const resultObj = ref<SpotlightSearchState>({
loading: false,
results: {},
})
const scopeHandle = effectScope()
scopeHandle.run(() => {
for (const [searcher, state, onSessionEnd] of searchSessions) {
watch(
state,
(newState) => {
if (newState.loading) {
loadingSearchers.add(searcher.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)
}
}

View File

@@ -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<string, unknown> & {
excludeFromSearch?: boolean
} & Record<DocFields[number], string>,
DocFields extends Array<keyof Doc>
>
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<Record<string, Doc>>,
searchFields: Array<keyof Doc>,
resultFields: DocFields
) {
super()
this.minisearch = new MiniSearch({
fields: searchFields as string[],
storeFields: resultFields as string[],
})
this.addDocsToSearchIndex(resolveUnref(documents))
}
private async addDocsToSearchIndex(docs: Record<string, Doc>) {
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<Doc & SearchResult, DocFields[number] | "id" | "score">
): SpotlightSearcherResult
public createSearchSession(
query: Readonly<Ref<string>>
): [Ref<SpotlightSearcherSessionState>, () => void] {
const results = ref<SpotlightSearcherResult[]>([])
const resultObj = computed<SpotlightSearcherSessionState>(() => ({
loading: this.loading.value,
results: results.value,
}))
const scopeHandle = effectScope()
scopeHandle.run(() => {
watch(
[query, () => 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
}

View File

@@ -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<string>>
): [Ref<SpotlightSearcherSessionState>, () => void] {
const loading = ref(false)
const results = ref<SpotlightSearcherResult[]>([])
const minisearch = new MiniSearch({
fields: ["url", "title", "reltime", "date"],
storeFields: ["url"],
})
const stopWatchHandle = watch(
this.clearHistoryActionEnabled,
(enabled) => {
if (enabled) {
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<SpotlightSearcherSessionState>(() => ({
loading: loading.value,
results: results.value,
}))
return [resultObj, onSessionEnd]
}
onResultSelect(result: SpotlightSearcherResult): void {
if (result.id === "clear-history") {
invokeAction("history.clear")
return
}
const req = restHistoryStore.value.state[parseInt(result.id)].request
createNewTab({
request: req,
isDirty: false,
})
}
}