mirror of https://github.com/grafana/grafana
CommandPalette: Fix long dashboard names freezing the browser (#61278)
* CommandPalette: Use custom useMatches to avoid perf issues with long names * wip start to multi search * use search permutations, and do substring match if search is over 25 chars * update ufuzzy * don't permute query if greater than 5 wordspull/61497/head
parent
8f72893076
commit
9b10f6c7dd
@ -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<ActionImpl[]>((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<SectionName, Array<{ priority: number; action: ActionImpl }>> = {}; |
||||
/** |
||||
* 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<string | ActionImpl> = []; |
||||
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<number>(); |
||||
|
||||
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<uFuzzy>(); |
||||
|
||||
if (!ref.current) { |
||||
ref.current = new uFuzzy({ |
||||
intraMode: 1, |
||||
intraIns: 1, |
||||
intraSub: 1, |
||||
intraTrn: 1, |
||||
intraDel: 1, |
||||
}); |
||||
} |
||||
|
||||
return ref.current; |
||||
} |
Loading…
Reference in new issue