diff --git a/package.json b/package.json index 2ded3e8fb56..37c8950b4b2 100644 --- a/package.json +++ b/package.json @@ -258,7 +258,7 @@ "@grafana/ui": "workspace:*", "@jaegertracing/jaeger-ui-components": "workspace:*", "@kusto/monaco-kusto": "5.3.6", - "@leeoniya/ufuzzy": "0.9.0", + "@leeoniya/ufuzzy": "0.9.1", "@lezer/common": "1.0.1", "@lezer/highlight": "1.1.2", "@lezer/lr": "1.2.3", diff --git a/public/app/features/commandPalette/CommandPalette.tsx b/public/app/features/commandPalette/CommandPalette.tsx index 91b16bf54a0..1d5507eb799 100644 --- a/public/app/features/commandPalette/CommandPalette.tsx +++ b/public/app/features/commandPalette/CommandPalette.tsx @@ -8,7 +8,6 @@ import { KBarPositioner, KBarResults, KBarSearch, - useMatches, VisualState, useRegisterActions, useKBar, @@ -25,6 +24,7 @@ import { ResultItem } from './ResultItem'; import { useDashboardResults } from './actions/dashboardActions'; import useActions from './actions/useActions'; import { CommandPaletteAction } from './types'; +import { useMatches } from './useMatches'; export const CommandPalette = () => { const styles = useStyles2(getSearchStyles); diff --git a/public/app/features/commandPalette/useMatches.ts b/public/app/features/commandPalette/useMatches.ts new file mode 100644 index 00000000000..22c1b48cc10 --- /dev/null +++ b/public/app/features/commandPalette/useMatches.ts @@ -0,0 +1,257 @@ +import uFuzzy from '@leeoniya/ufuzzy'; +import { ActionImpl, Priority, useKBar } from 'kbar'; +import { useThrottledValue } from 'kbar/lib/utils'; +import * as React from 'react'; + +// From https://github.dev/timc1/kbar/blob/main/src/useMatches.tsx +// TODO: Go back to useMatches from kbar when https://github.com/timc1/kbar/issues/255 is fixed + +export const NO_GROUP = { + name: 'none', + priority: Priority.NORMAL, +}; + +interface Prioritised { + priority: number; +} + +function order(a: Prioritised, b: Prioritised) { + /** + * Larger the priority = higher up the list + */ + return b.priority - a.priority; +} + +type SectionName = string; + +/** + * returns deep matches only when a search query is present + */ +export function useMatches() { + const { search, actions, rootActionId } = useKBar((state) => ({ + search: state.searchQuery, + actions: state.actions, + rootActionId: state.currentRootActionId, + })); + + const rootResults = React.useMemo(() => { + return Object.keys(actions) + .reduce((acc, actionId) => { + const action = actions[actionId]; + if (!action.parent && !rootActionId) { + acc.push(action); + } + if (action.id === rootActionId) { + for (let i = 0; i < action.children.length; i++) { + acc.push(action.children[i]); + } + } + return acc; + }, []) + .sort(order); + }, [actions, rootActionId]); + + const getDeepResults = React.useCallback((actions: ActionImpl[]) => { + let actionsClone: ActionImpl[] = []; + for (let i = 0; i < actions.length; i++) { + actionsClone.push(actions[i]); + } + return (function collectChildren(actions: ActionImpl[], all = actionsClone) { + for (let i = 0; i < actions.length; i++) { + if (actions[i].children.length > 0) { + let childsChildren = actions[i].children; + for (let i = 0; i < childsChildren.length; i++) { + all.push(childsChildren[i]); + } + collectChildren(actions[i].children, all); + } + } + return all; + })(actions); + }, []); + + const emptySearch = !search; + + const filtered = React.useMemo(() => { + if (emptySearch) { + return rootResults; + } + return getDeepResults(rootResults); + }, [getDeepResults, rootResults, emptySearch]); + + const matches = useInternalMatches(filtered, search); + + const results = React.useMemo(() => { + /** + * Store a reference to a section and it's list of actions. + * Alongside these actions, we'll keep a temporary record of the + * final priority calculated by taking the commandScore + the + * explicitly set `action.priority` value. + */ + let map: Record> = {}; + /** + * Store another reference to a list of sections alongside + * the section's final priority, calculated the same as above. + */ + let list: Array<{ priority: number; name: SectionName }> = []; + /** + * We'll take the list above and sort by its priority. Then we'll + * collect all actions from the map above for this specific name and + * sort by its priority as well. + */ + let ordered: Array<{ name: SectionName; actions: ActionImpl[] }> = []; + + for (let i = 0; i < matches.length; i++) { + const match = matches[i]; + const action = match.action; + const score = match.score || Priority.NORMAL; + + const section = { + name: typeof action.section === 'string' ? action.section : action.section?.name || NO_GROUP.name, + priority: typeof action.section === 'string' ? score : action.section?.priority || 0 + score, + }; + + if (!map[section.name]) { + map[section.name] = []; + list.push(section); + } + + map[section.name].push({ + priority: action.priority + score, + action, + }); + } + + ordered = list.sort(order).map((group) => ({ + name: group.name, + actions: map[group.name].sort(order).map((item) => item.action), + })); + + /** + * Our final result is simply flattening the ordered list into + * our familiar (ActionImpl | string)[] shape. + */ + let results: Array = []; + for (let i = 0; i < ordered.length; i++) { + let group = ordered[i]; + if (group.name !== NO_GROUP.name) { + results.push(group.name); + } + for (let i = 0; i < group.actions.length; i++) { + results.push(group.actions[i]); + } + } + return results; + }, [matches]); + + // ensure that users have an accurate `currentRootActionId` + // that syncs with the throttled return value. + // eslint-disable-next-line react-hooks/exhaustive-deps + const memoRootActionId = React.useMemo(() => rootActionId, [results]); + + return React.useMemo( + () => ({ + results, + rootActionId: memoRootActionId, + }), + [memoRootActionId, results] + ); +} + +type Match = { + action: ActionImpl; + /** + * Represents the commandScore matchiness value which we use + * in addition to the explicitly set `action.priority` to + * calculate a more fine tuned fuzzy search. + */ + score: number; +}; + +function useInternalMatches(filtered: ActionImpl[], search: string): Match[] { + const ufuzzy = useUfuzzy(); + + const value = React.useMemo( + () => ({ + filtered, + search, + }), + [filtered, search] + ); + + const { filtered: throttledFiltered, search: throttledSearch } = useThrottledValue(value); + + return React.useMemo(() => { + if (throttledSearch.trim() === '') { + return throttledFiltered.map((action) => ({ score: 0, action })); + } + + const haystack = throttledFiltered.map((action) => + [action.name, action.keywords, action.subtitle].join(' ').toLowerCase() + ); + + const results: Match[] = []; + + // If the search term is too long, then just do a simple substring search. + // We don't expect users to actually hit this frequently, but want to prevent browser hangs + if (throttledSearch.length > FUZZY_SEARCH_LIMIT) { + const query = throttledSearch.toLowerCase(); + + for (let haystackIndex = 0; haystackIndex < haystack.length; haystackIndex++) { + const haystackItem = haystack[haystackIndex]; + + // Use the position of the match as a stand-in for score + const substringPosition = haystackItem.toLowerCase().indexOf(query); + + if (substringPosition > -1) { + const score = haystack.length - substringPosition; + const action = throttledFiltered[haystackIndex]; + results.push({ score, action }); + } + } + } else { + const allMatchedIndexes = new Set(); + + const queryWords = ufuzzy.split(throttledSearch); + const queryPermutations = + queryWords.length < 5 ? uFuzzy.permute(queryWords).map((terms) => terms.join(' ')) : [throttledSearch]; + + for (const permutedSearchTerm of queryPermutations) { + const indexes = ufuzzy.filter(haystack, permutedSearchTerm); + const info = ufuzzy.info(indexes, haystack, permutedSearchTerm); + const order = ufuzzy.sort(info, haystack, permutedSearchTerm); + + for (let orderIndex = 0; orderIndex < order.length; orderIndex++) { + const actionIndex = order[orderIndex]; + + if (!allMatchedIndexes.has(actionIndex)) { + allMatchedIndexes.add(actionIndex); + const score = order.length - orderIndex; + const action = throttledFiltered[info.idx[actionIndex]]; + results.push({ score, action }); + } + } + } + } + + return results; + }, [throttledFiltered, throttledSearch, ufuzzy]); +} + +const FUZZY_SEARCH_LIMIT = 25; + +function useUfuzzy(): uFuzzy { + const ref = React.useRef(); + + if (!ref.current) { + ref.current = new uFuzzy({ + intraMode: 1, + intraIns: 1, + intraSub: 1, + intraTrn: 1, + intraDel: 1, + }); + } + + return ref.current; +} diff --git a/yarn.lock b/yarn.lock index 38420d0e61f..fa2d5541fa0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6255,6 +6255,13 @@ __metadata: languageName: node linkType: hard +"@leeoniya/ufuzzy@npm:0.9.1": + version: 0.9.1 + resolution: "@leeoniya/ufuzzy@npm:0.9.1" + checksum: 27750dff2e754ec3729937abce7c36b87917325e934cef7b1bb54a2310fd1e497dcb725397abee8c714f24a84da155120177a93da8f9706eefe32a8a1bb66945 + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.3 resolution: "@leichtgewicht/ip-codec@npm:2.0.3" @@ -22004,7 +22011,7 @@ __metadata: "@grafana/ui": "workspace:*" "@jaegertracing/jaeger-ui-components": "workspace:*" "@kusto/monaco-kusto": 5.3.6 - "@leeoniya/ufuzzy": 0.9.0 + "@leeoniya/ufuzzy": 0.9.1 "@lezer/common": 1.0.1 "@lezer/highlight": 1.1.2 "@lezer/lr": 1.2.3