-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: upgrade cmdk to 1.0.0 (#6401)
Also include the command score into our own repo for some tweaks. Might fix #6322
- Loading branch information
Showing
7 changed files
with
304 additions
and
232 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
99 changes: 99 additions & 0 deletions
99
packages/frontend/core/src/components/pure/cmdk/__tests__/command.score.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import { describe, expect, it } from 'vitest'; | ||
|
||
import { commandScore } from '../command-score'; | ||
|
||
describe('commandScore', function () { | ||
it('should match exact strings exactly', function () { | ||
expect(commandScore('hello', 'hello')).to.equal(1); | ||
}); | ||
|
||
it('should prefer case-sensitive matches', function () { | ||
expect(commandScore('Hello', 'Hello')).to.be.greaterThan( | ||
commandScore('Hello', 'hello') | ||
); | ||
}); | ||
|
||
it('should mark down prefixes', function () { | ||
expect(commandScore('hello', 'hello')).to.be.greaterThan( | ||
commandScore('hello', 'he') | ||
); | ||
}); | ||
|
||
it('should score all prefixes the same', function () { | ||
expect(commandScore('help', 'he')).to.equal(commandScore('hello', 'he')); | ||
}); | ||
|
||
it('should mark down word jumps', function () { | ||
expect(commandScore('hello world', 'hello')).to.be.greaterThan( | ||
commandScore('hello world', 'hewo') | ||
); | ||
}); | ||
|
||
it('should score similar word jumps the same', function () { | ||
expect(commandScore('hello world', 'hewo')).to.equal( | ||
commandScore('hey world', 'hewo') | ||
); | ||
}); | ||
|
||
it('should penalize long word jumps', function () { | ||
expect(commandScore('hello world', 'hewo')).to.be.greaterThan( | ||
commandScore('hello kind world', 'hewo') | ||
); | ||
}); | ||
|
||
it('should match missing characters', function () { | ||
expect(commandScore('hello', 'hl')).to.be.greaterThan(0); | ||
}); | ||
|
||
it('should penalize more for more missing characters', function () { | ||
expect(commandScore('hello', 'hllo')).to.be.greaterThan( | ||
commandScore('hello', 'hlo') | ||
); | ||
}); | ||
|
||
it('should penalize more for missing characters than case', function () { | ||
expect(commandScore('go to Inbox', 'in')).to.be.greaterThan( | ||
commandScore('go to Unversity/Societies/CUE/[email protected]', 'in') | ||
); | ||
}); | ||
|
||
it('should match transpotisions', function () { | ||
expect(commandScore('hello', 'hle')).to.be.greaterThan(0); | ||
}); | ||
|
||
it('should not match with a trailing letter', function () { | ||
expect(commandScore('ss', 'sss')).to.equal(0.1); | ||
}); | ||
|
||
it('should match long jumps', function () { | ||
expect(commandScore('go to @QuickFix', 'fix')).to.be.greaterThan(0); | ||
expect(commandScore('go to Quick Fix', 'fix')).to.be.greaterThan( | ||
commandScore('go to @QuickFix', 'fix') | ||
); | ||
}); | ||
|
||
it('should work well with the presence of an m-dash', function () { | ||
expect(commandScore('no go — Windows', 'windows')).to.be.greaterThan(0); | ||
}); | ||
|
||
it('should be robust to duplicated letters', function () { | ||
expect(commandScore('talent', 'tall')).to.be.equal(0.099); | ||
}); | ||
|
||
it('should not allow letter insertion', function () { | ||
expect(commandScore('talent', 'tadlent')).to.be.equal(0); | ||
}); | ||
|
||
it('should match - with " " characters', function () { | ||
expect(commandScore('Auto-Advance', 'Auto Advance')).to.be.equal(0.9999); | ||
}); | ||
|
||
it('should score long strings quickly', function () { | ||
expect( | ||
commandScore( | ||
'go to this is a really long label that is really longthis is a really long label that is really longthis is a really long label that is really longthis is a really long label that is really long', | ||
'this is a' | ||
) | ||
).to.be.equal(0.891); | ||
}); | ||
}); |
195 changes: 195 additions & 0 deletions
195
packages/frontend/core/src/components/pure/cmdk/command-score.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
// The scores are arranged so that a continuous match of characters will | ||
// result in a total score of 1. | ||
// | ||
// The best case, this character is a match, and either this is the start | ||
// of the string, or the previous character was also a match. | ||
const SCORE_CONTINUE_MATCH = 1, | ||
// A new match at the start of a word scores better than a new match | ||
// elsewhere as it's more likely that the user will type the starts | ||
// of fragments. | ||
// NOTE: We score word jumps between spaces slightly higher than slashes, brackets | ||
// hyphens, etc. | ||
SCORE_SPACE_WORD_JUMP = 0.9, | ||
SCORE_NON_SPACE_WORD_JUMP = 0.8, | ||
// Any other match isn't ideal, but we include it for completeness. | ||
SCORE_CHARACTER_JUMP = 0.17, | ||
// If the user transposed two letters, it should be significantly penalized. | ||
// | ||
// i.e. "ouch" is more likely than "curtain" when "uc" is typed. | ||
SCORE_TRANSPOSITION = 0.1, | ||
// The goodness of a match should decay slightly with each missing | ||
// character. | ||
// | ||
// i.e. "bad" is more likely than "bard" when "bd" is typed. | ||
// | ||
// This will not change the order of suggestions based on SCORE_* until | ||
// 100 characters are inserted between matches. | ||
PENALTY_SKIPPED = 0.999, | ||
// The goodness of an exact-case match should be higher than a | ||
// case-insensitive match by a small amount. | ||
// | ||
// i.e. "HTML" is more likely than "haml" when "HM" is typed. | ||
// | ||
// This will not change the order of suggestions based on SCORE_* until | ||
// 1000 characters are inserted between matches. | ||
PENALTY_CASE_MISMATCH = 0.9999, | ||
// If the word has more characters than the user typed, it should | ||
// be penalised slightly. | ||
// | ||
// i.e. "html" is more likely than "html5" if I type "html". | ||
// | ||
// However, it may well be the case that there's a sensible secondary | ||
// ordering (like alphabetical) that it makes sense to rely on when | ||
// there are many prefix matches, so we don't make the penalty increase | ||
// with the number of tokens. | ||
PENALTY_NOT_COMPLETE = 0.99; | ||
|
||
const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/, | ||
COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g, | ||
IS_SPACE_REGEXP = /[\s-]/, | ||
COUNT_SPACE_REGEXP = /[\s-]/g; | ||
|
||
const MAX_RECUR = 1500; | ||
|
||
function commandScoreInner( | ||
string: string, | ||
abbreviation: string, | ||
lowerString: string, | ||
lowerAbbreviation: string, | ||
stringIndex: number, | ||
abbreviationIndex: number, | ||
memoizedResults: Record<string, number>, | ||
recur: number = 0 | ||
) { | ||
recur += 1; | ||
if (abbreviationIndex === abbreviation.length) { | ||
if (stringIndex === string.length) { | ||
return SCORE_CONTINUE_MATCH; | ||
} | ||
return PENALTY_NOT_COMPLETE; | ||
} | ||
|
||
const memoizeKey = `${stringIndex},${abbreviationIndex}`; | ||
if (memoizedResults[memoizeKey] !== undefined) { | ||
return memoizedResults[memoizeKey]; | ||
} | ||
|
||
const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex); | ||
let index = lowerString.indexOf(abbreviationChar, stringIndex); | ||
let highScore = 0; | ||
|
||
let score, transposedScore, wordBreaks, spaceBreaks; | ||
|
||
while (index >= 0) { | ||
score = commandScoreInner( | ||
string, | ||
abbreviation, | ||
lowerString, | ||
lowerAbbreviation, | ||
index + 1, | ||
abbreviationIndex + 1, | ||
memoizedResults, | ||
recur | ||
); | ||
if (score > highScore) { | ||
if (index === stringIndex) { | ||
score *= SCORE_CONTINUE_MATCH; | ||
} else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { | ||
score *= SCORE_NON_SPACE_WORD_JUMP; | ||
wordBreaks = string | ||
.slice(stringIndex, index - 1) | ||
.match(COUNT_GAPS_REGEXP); | ||
if (wordBreaks && stringIndex > 0) { | ||
score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length); | ||
} | ||
} else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { | ||
score *= SCORE_SPACE_WORD_JUMP; | ||
spaceBreaks = string | ||
.slice(stringIndex, index - 1) | ||
.match(COUNT_SPACE_REGEXP); | ||
if (spaceBreaks && stringIndex > 0) { | ||
score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length); | ||
} | ||
} else { | ||
score *= SCORE_CHARACTER_JUMP; | ||
if (stringIndex > 0) { | ||
score *= Math.pow(PENALTY_SKIPPED, index - stringIndex); | ||
} | ||
} | ||
|
||
if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { | ||
score *= PENALTY_CASE_MISMATCH; | ||
} | ||
} | ||
|
||
if ( | ||
(score < SCORE_TRANSPOSITION && | ||
lowerString.charAt(index - 1) === | ||
lowerAbbreviation.charAt(abbreviationIndex + 1)) || | ||
(lowerAbbreviation.charAt(abbreviationIndex + 1) === | ||
lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428 | ||
lowerString.charAt(index - 1) !== | ||
lowerAbbreviation.charAt(abbreviationIndex)) | ||
) { | ||
transposedScore = commandScoreInner( | ||
string, | ||
abbreviation, | ||
lowerString, | ||
lowerAbbreviation, | ||
index + 1, | ||
abbreviationIndex + 2, | ||
memoizedResults, | ||
recur | ||
); | ||
|
||
if (transposedScore * SCORE_TRANSPOSITION > score) { | ||
score = transposedScore * SCORE_TRANSPOSITION; | ||
} | ||
} | ||
|
||
if (score > highScore) { | ||
highScore = score; | ||
} | ||
|
||
index = lowerString.indexOf(abbreviationChar, index + 1); | ||
|
||
if (recur > MAX_RECUR || score > 0.85) { | ||
break; | ||
} | ||
} | ||
|
||
memoizedResults[memoizeKey] = highScore; | ||
return highScore; | ||
} | ||
|
||
function formatInput(string: string) { | ||
// convert all valid space characters to space so they match each other | ||
return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' '); | ||
} | ||
|
||
export function commandScore( | ||
string: string, | ||
abbreviation: string, | ||
aliases?: string[] | ||
): number { | ||
/* NOTE: | ||
* in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase() | ||
* was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster. | ||
*/ | ||
string = | ||
aliases && aliases.length > 0 | ||
? `${string + ' ' + aliases.join(' ')}` | ||
: string; | ||
const memoizedResults = {}; | ||
const result = commandScoreInner( | ||
string, | ||
abbreviation, | ||
formatInput(string), | ||
formatInput(abbreviation), | ||
0, | ||
0, | ||
memoizedResults | ||
); | ||
|
||
return result; | ||
} |
2 changes: 1 addition & 1 deletion
2
packages/frontend/core/src/components/pure/cmdk/filter-commands.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters