{isShortcut && (
- <>
-
- {appName}
+
+
-
-
- >
+
+
)}
- {title}
+
{showDescription && (
{description}
@@ -156,6 +160,9 @@ function ListItem({
ListItem.propTypes = {
appName: PropTypes.string,
+ appNameMatchIndices: PropTypes.arrayOf(
+ PropTypes.arrayOf(PropTypes.number.isRequired)
+ ),
dataTest: PropTypes.string,
description: PropTypes.string,
highlighted: PropTypes.bool,
@@ -164,6 +171,9 @@ ListItem.propTypes = {
path: PropTypes.string,
resetModal: PropTypes.func,
title: PropTypes.string,
+ titleMatchIndices: PropTypes.arrayOf(
+ PropTypes.arrayOf(PropTypes.number.isRequired)
+ ),
type: PropTypes.string,
onClickHandler: PropTypes.func,
}
diff --git a/src/components/header-bar/command-palette/utils/filter.js b/src/components/header-bar/command-palette/utils/filter.js
index 5c67529..b06a0e9 100644
--- a/src/components/header-bar/command-palette/utils/filter.js
+++ b/src/components/header-bar/command-palette/utils/filter.js
@@ -7,21 +7,49 @@ import {
FILTERABLE_ACTION,
SHORTCUT,
} from './constants.js'
+import { filterItemsArray, wrapAsFuseResult } from './fuzzy-matching.js'
-export const fuseOptions = {
- includeScore: true,
- threshold: 0.3,
- ignoreDiacritics: true,
- shouldSort: true,
- keys: ['displayName', 'name', 'appName'],
- includeMatches: true,
-}
+const groupAppsWithShortcuts = ({
+ filteredApps,
+ filteredShortcuts,
+ shortcuts,
+}) => {
+ // Group each app with its shortcuts
+ const shortcutsByApp = new Map()
+ for (const shortcut of shortcuts) {
+ if (!shortcutsByApp.has(shortcut.appName)) {
+ shortcutsByApp.set(shortcut.appName, [])
+ }
+ shortcutsByApp.get(shortcut.appName).push(shortcut)
+ }
-export const filterItemsArray = (fuse, filter) =>
- fuse.search(filter).map(({ item, matches }) => ({ item, matches }))
+ const matchesByShortcut = new Map(
+ // retain all the fuse matches for each filtered shortcut
+ filteredShortcuts.map(({ item, matches }) => [item, matches])
+ )
+
+ // For all matched apps, return them with their shortcuts and their matches
+ const appsWithShortcuts = filteredApps.flatMap(({ item, matches }) => {
+ const appShortcuts = shortcutsByApp.get(item.displayName) ?? []
+
+ const appShortcutResults = appShortcuts.map((shortcut) => ({
+ item: shortcut,
+ matches: matchesByShortcut.get(shortcut),
+ }))
+ return [{ item, matches }, ...appShortcutResults]
+ })
+
+ const matchedAppNames = new Set(
+ filteredApps.map(({ item }) => item.displayName || item.name)
+ )
+
+ // Filter for remaining shortcuts that matched the filter without their parent app
+ const remainingShortcuts = filteredShortcuts.filter(
+ ({ item }) => !matchedAppNames.has(item.appName)
+ )
-export const wrapAsFuseResult = (list) =>
- list.map((item) => ({ item, matches: undefined }))
+ return { appsWithShortcuts, remainingShortcuts }
+}
export const filterItemsPerView = ({
appsFuse,
@@ -78,34 +106,14 @@ export const filterItemsPerView = ({
({ item }) => item.type === FILTERABLE_ACTION
)
- // Group each app with its shortcuts
- // Filter for matched apps and return them with their shortcuts
- // Append remaining shortcuts that match
- const shortcutsByApp = new Map()
- for (const shortcut of shortcuts) {
- if (!shortcutsByApp.has(shortcut.appName)) {
- shortcutsByApp.set(shortcut.appName, [])
- }
- shortcutsByApp.get(shortcut.appName).push(shortcut)
- }
-
- const filteredAppsWithShortcuts = filteredApps.flatMap(
- ({ item, matches }) => [
- { item, matches },
- ...wrapAsFuseResult(shortcutsByApp.get(item.displayName) ?? []),
- ]
- )
-
- const matchedAppNames = new Set(
- filteredApps.map(({ item }) => item.displayName || item.name)
- )
-
- const remainingShortcuts = filteredShortcuts.filter(
- ({ item }) => !matchedAppNames.has(item.appName)
- )
+ const { appsWithShortcuts, remainingShortcuts } = groupAppsWithShortcuts({
+ filteredApps,
+ filteredShortcuts,
+ shortcuts,
+ })
return [
- ...filteredAppsWithShortcuts,
+ ...appsWithShortcuts,
...remainingShortcuts,
...filteredCommands,
...filteredActions,
diff --git a/src/components/header-bar/command-palette/utils/filter.test.js b/src/components/header-bar/command-palette/utils/filter.test.js
index 4a6917a..5eeeca5 100644
--- a/src/components/header-bar/command-palette/utils/filter.test.js
+++ b/src/components/header-bar/command-palette/utils/filter.test.js
@@ -1,5 +1,5 @@
import Fuse from 'fuse.js'
-import { filterItemsArray, fuseOptions } from './filter.js'
+import { filterItemsArray, fuseOptions } from './fuzzy-matching.js'
describe('filter helper functions', () => {
const itemsToSearch = [
diff --git a/src/components/header-bar/command-palette/utils/fuzzy-matching.js b/src/components/header-bar/command-palette/utils/fuzzy-matching.js
new file mode 100644
index 0000000..ed21d27
--- /dev/null
+++ b/src/components/header-bar/command-palette/utils/fuzzy-matching.js
@@ -0,0 +1,18 @@
+// Fuzzy matching helpers and options
+// Library: FuseJS
+// Link: https://www.fusejs.io/fuzzy-search.html
+
+export const fuseOptions = {
+ includeScore: true,
+ threshold: 0.4,
+ ignoreDiacritics: true,
+ shouldSort: true,
+ keys: ['displayName', 'name', 'appName'],
+ includeMatches: true,
+}
+
+export const filterItemsArray = (fuse, filter) =>
+ fuse.search(filter).map(({ item, matches }) => ({ item, matches }))
+
+export const wrapAsFuseResult = (list) =>
+ list.map((item) => ({ item, matches: undefined }))
diff --git a/src/components/header-bar/command-palette/utils/highlighting.js b/src/components/header-bar/command-palette/utils/highlighting.js
new file mode 100644
index 0000000..53c14eb
--- /dev/null
+++ b/src/components/header-bar/command-palette/utils/highlighting.js
@@ -0,0 +1,66 @@
+// Highlighting functionality:
+// For every returned search result, we only highlight the parts of the word/phrase that match most with the filter/query
+
+// TODO: see if new TokenSearch feature in fuse js library can be used - https://www.fusejs.io/token-search.html
+
+export const pickHighlightRanges = ({
+ textToHighlight,
+ matchedIndices,
+ query,
+}) => {
+ if (!matchedIndices?.length || !textToHighlight) {
+ return matchedIndices
+ }
+
+ // for every word in the text to highlight, find its matched ranges
+ // find the longest range or the one with the most matched characters to determine what to highlight
+ const wordsWithMatches = []
+ const allWordsInText = textToHighlight.matchAll(/\S+/g)
+
+ for (const word of allWordsInText) {
+ const wordStart = word.index
+ const wordEnd = wordStart + word[0].length - 1
+
+ const rangesInWord = matchedIndices
+ .map(([rangeStart, rangeEnd]) => [
+ Math.max(rangeStart, wordStart),
+ Math.min(rangeEnd, wordEnd),
+ ])
+ .filter(([rangeStart, rangeEnd]) => rangeStart <= rangeEnd)
+ if (rangesInWord.length === 0) {
+ continue
+ }
+
+ // longestRangeLength = length of the longest contiguous matched range
+ // totalMatchedChars = sum of all matched character lengths in this word
+ let longestRangeLength = 0
+ let totalMatchedChars = 0
+ for (const [rangeStart, rangeEnd] of rangesInWord) {
+ const rangeLength = rangeEnd - rangeStart + 1
+ if (rangeLength > longestRangeLength) {
+ longestRangeLength = rangeLength
+ }
+ totalMatchedChars += rangeLength
+ }
+ wordsWithMatches.push({
+ ranges: rangesInWord,
+ longestRangeLength,
+ totalMatchedChars,
+ })
+ }
+
+ // ranking words: longest range > total matched characters
+ wordsWithMatches.sort(
+ (a, b) =>
+ b.longestRangeLength - a.longestRangeLength ||
+ b.totalMatchedChars - a.totalMatchedChars
+ )
+
+ // highlight as many words as those entered into the filter/query
+ const queryWordCount = query?.match(/\S+/g)?.length || 1
+ const wordsToHighlight = wordsWithMatches.slice(0, queryWordCount)
+
+ const rangesToHighlight = wordsToHighlight.flatMap((word) => word.ranges)
+
+ return rangesToHighlight
+}
diff --git a/src/components/header-bar/command-palette/utils/highlighting.test.js b/src/components/header-bar/command-palette/utils/highlighting.test.js
new file mode 100644
index 0000000..5d2bbe0
--- /dev/null
+++ b/src/components/header-bar/command-palette/utils/highlighting.test.js
@@ -0,0 +1,67 @@
+import { pickHighlightRanges } from './highlighting.js'
+
+describe('pickHighlightRanges function', () => {
+ it('returns matchedIndices unchanged when text to highlight is empty', () => {
+ expect(
+ pickHighlightRanges({
+ textToHighlight: '',
+ matchedIndices: [[0, 1]],
+ query: 'a',
+ })
+ ).toEqual([[0, 1]])
+ })
+
+ it('returns matchedIndices unchanged when matchedIndices is empty', () => {
+ expect(
+ pickHighlightRanges({
+ textToHighlight: 'Hello',
+ matchedIndices: [],
+ query: 'a',
+ })
+ ).toEqual([])
+ })
+
+ it('returns undefined when matchedIndices is undefined', () => {
+ expect(
+ pickHighlightRanges({
+ textToHighlight: 'Hello',
+ matchedIndices: undefined,
+ query: 'a',
+ })
+ ).toBeUndefined()
+ })
+
+ it('matches the same number of words in the query as in the text to highlight', () => {
+ const result = pickHighlightRanges({
+ textToHighlight: 'App Notifications',
+ matchedIndices: [
+ [0, 2],
+ [4, 8],
+ ],
+ query: 'app notif',
+ })
+ expect(result).toEqual([
+ [4, 8],
+ [0, 2],
+ ])
+ })
+
+ it('picks the longest contiguous range of matches and drops the rest', () => {
+ // Fuse output for "setting" against "Notification Settings":
+ // "Notification" (0-11): [[0,0],[2,3],[5,5],[8,9],[11,11]] — total matches: 7, longest contiguous match: 2
+ // "Settings" (13-19): [[13,19]] — total matches: 7, longest contiguous match: 7
+ const result = pickHighlightRanges({
+ textToHighlight: 'Notification Settings',
+ matchedIndices: [
+ [0, 0],
+ [2, 3],
+ [5, 5],
+ [8, 9],
+ [11, 11],
+ [13, 19],
+ ],
+ query: 'setting',
+ })
+ expect(result).toEqual([[13, 19]])
+ })
+})
diff --git a/src/components/header-bar/command-palette/views/list-view.jsx b/src/components/header-bar/command-palette/views/list-view.jsx
index 2e352b5..5d46148 100644
--- a/src/components/header-bar/command-palette/views/list-view.jsx
+++ b/src/components/header-bar/command-palette/views/list-view.jsx
@@ -2,8 +2,9 @@ import PropTypes from 'prop-types'
import React from 'react'
import EmptySearchResults from '../sections/empty-search-results.jsx'
import ListItem from '../sections/list-item.jsx'
+import { pickHighlightRanges } from '../utils/highlighting.js'
-const ListView = ({ grid, currentItem, resetModal }) => {
+const ListView = ({ grid, currentItem, resetModal, filter }) => {
const listItems = grid.reduce((acc, arr) => {
acc.push(arr[0])
return acc
@@ -14,7 +15,7 @@ const ListView = ({ grid, currentItem, resetModal }) => {
{listItems.length > 0 ? (
{listItems.map((listItem, idx) => {
- const { item } = listItem
+ const { item, matches } = listItem
const {
appName,
action,
@@ -29,13 +30,32 @@ const ListView = ({ grid, currentItem, resetModal }) => {
const isImage = typeof icon === 'string'
const isIcon = React.isValidElement(icon)
+ const titleKey = displayName ? 'displayName' : 'name'
+ const title = displayName || name
+ const titleMatchIndices = pickHighlightRanges({
+ textToHighlight: title,
+ matchedIndices: matches?.find(
+ (match) => match.key === titleKey
+ )?.indices,
+ query: filter,
+ })
+ const appNameMatchIndices = pickHighlightRanges({
+ textToHighlight: appName,
+ matchedIndices: matches?.find(
+ (match) => match.key === 'appName'
+ )?.indices,
+ query: filter,
+ })
+
return (
{
ListView.propTypes = {
currentItem: PropTypes.object,
+ filter: PropTypes.string,
grid: PropTypes.array,
resetModal: PropTypes.func,
}
diff --git a/yarn.lock b/yarn.lock
index e158b64..29def66 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6588,6 +6588,11 @@ he@^1.2.0:
resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+highlight-words-core@^1.2.0:
+ version "1.2.3"
+ resolved "https://registry.yarnpkg.com/highlight-words-core/-/highlight-words-core-1.2.3.tgz#781f37b2a220bf998114e4ef8c8cb6c7a4802ea8"
+ integrity sha512-m1O9HW3/GNHxzSIXWw1wCNXXsgLlxrP0OI6+ycGUhiUHkikqW3OrwVHz+lxeNBe5yqLESdIcj8PowHQ2zLvUvQ==
+
hosted-git-info@^2.1.4:
version "2.8.9"
resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz"
@@ -8305,6 +8310,11 @@ memfs@^3.1.2, memfs@^3.4.3:
dependencies:
fs-monkey "^1.0.4"
+memoize-one@^4.0.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-4.1.0.tgz#a2387c58c03fff27ca390c31b764a79addf3f906"
+ integrity sha512-2GApq0yI/b22J2j9rhbrAlsHb0Qcz+7yWxeLG8h+95sl1XPUgeLimQSOdur4Vw7cUhrBHwaUZxWFZueojqNRzA==
+
meow@^13.2.0:
version "13.2.0"
resolved "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz"
@@ -9531,6 +9541,14 @@ react-final-form@^6.5.3:
dependencies:
"@babel/runtime" "^7.15.4"
+react-highlight-words@^0.21.0:
+ version "0.21.0"
+ resolved "https://registry.yarnpkg.com/react-highlight-words/-/react-highlight-words-0.21.0.tgz#a109acdf7dc6fac3ed7db82e9cba94e8d65c281c"
+ integrity sha512-SdWEeU9fIINArEPO1rO5OxPyuhdEKZQhHzZZP1ie6UeXQf+CjycT1kWaB+9bwGcVbR0NowuHK3RqgqNg6bgBDQ==
+ dependencies:
+ highlight-words-core "^1.2.0"
+ memoize-one "^4.0.0"
+
react-is@^16.13.1:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"