import { Service } from "dioc" import { watch, type Ref, ref, reactive, effectScope, Component } from "vue" /** * Defines how to render the entry text in a Spotlight Search Result */ export type SpotlightResultTextType = | { type: "text" /** * The text to render. Passing an array of strings will render each string separated by a chevron */ text: string[] | string } | { type: "custom" /** * The component to render in place of the text */ component: T /** * The props to pass to the component */ componentProps: T extends Component ? Props : never } /** * Defines info about a spotlight light so the UI can render it */ export type SpotlightSearcherResult = { /** * The unique ID of the result */ id: string /** * The text to render in the result */ text: SpotlightResultTextType /** * The icon to render as the signifier of the result */ icon: object | Component /** * The score of the result, the UI should sort the results by this */ score: number /** * Additional metadata about the result */ meta?: { /** * The keyboard shortcut to trigger the result */ keyboardShortcut?: string[] additionalInfo?: unknown } } /** * Defines the state of a searcher during a spotlight search session */ export type SpotlightSearcherSessionState = { /** * Whether the searcher is currently loading results */ loading: boolean /** * The results presented by the corresponding searcher in a session */ results: SpotlightSearcherResult[] } export interface SpotlightSearcher { searcherID: string searcherSectionTitle: string createSearchSession( query: Readonly> ): [Ref, () => void] onResultSelect(result: SpotlightSearcherResult): void } /** * Defines the state of a searcher during a search session that * is exposed to through the spotlight service */ export type SpotlightSearchSearcherState = { title: string avgScore: number results: SpotlightSearcherResult[] } /** * Defines the state of a spotlight search session */ export type SpotlightSearchState = { /** * Whether any of the searchers are currently loading results */ loading: boolean /** * The results presented by the corresponding searcher in a session */ results: Record } export class SpotlightService extends Service { public static readonly ID = "SPOTLIGHT_SERVICE" private searchers: Map = new Map() /** * Registers a searcher with the spotlight service * @param searcher The searcher instance to register */ public registerSearcher(searcher: SpotlightSearcher) { this.searchers.set(searcher.searcherID, searcher) } /** * Gets an iterator over all registered searchers and their IDs */ public getAllSearchers(): IterableIterator<[string, SpotlightSearcher]> { return this.searchers.entries() } /** * Creates a new search session * @param query A ref to the query to search for, updating this ref will notify the searchers about the change * @returns A ref to the state of the search session and a function to end the session */ 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.searcherID) } else { loadingSearchers.delete(searcher.searcherID) } if (newState.results.length === 0) { delete resultObj.value.results[searcher.searcherID] } else { resultObj.value.results[searcher.searcherID] = { title: searcher.searcherSectionTitle, 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] } /** * Selects a search result. To be called when the user selects a result * @param searcherID The ID of the searcher that the result belongs to * @param result The resuklt to look at */ public selectSearchResult( searcherID: string, result: SpotlightSearcherResult ) { this.searchers.get(searcherID)?.onResultSelect(result) } }