Repository navigation
feat(dashboard): Match typos in the quick switcher with our own matcher #2902
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
7908ecf
feat(dashboard): Match typos in the quick switcher with our own matcher
sentry-junior[bot] 12d80d2
fix(dashboard): Match typos that span words in the quick switcher
sentry-junior[bot] 20b8bf6
fix(dashboard): Score the full typo range at each word start
sentry-junior[bot] d4b5156
fix(dashboard): Keep quick switcher match indices aligned when lowerc…
sentry-junior[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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 hidden or 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 hidden or 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
163 changes: 163 additions & 0 deletions
163
packages/junior-dashboard/src/client/components/quickOpenMatch.ts
This file contains hidden or 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,163 @@ | ||
| export type QuickOpenMatch = { | ||
| /** UTF-16 indices in the label that the query matched, ascending. */ | ||
| indices: number[]; | ||
| score: number; | ||
| }; | ||
|
|
||
| // Each term match falls in a tier: a word-start substring beats a substring, | ||
| // which beats a scattered subsequence or a typo. Scores inside a tier stay | ||
| // below one tier step. | ||
| const TIER = 1_000; | ||
| const WORD_START_BONUS = 10; | ||
| // Each character a scatter spreads over costs this much, so "clt" in | ||
| // "Checkout latency triage" ranks far above letters strewn across a title. | ||
| const SPAN_PENALTY = 20; | ||
| // A one-edit typo beats a loose scatter but not a tight one. | ||
| const TYPO_SCORE = 600; | ||
| // Shorter terms are mostly noise when edited, and need an exact match. | ||
| const TYPO_MIN_LENGTH = 4; | ||
|
|
||
| /** | ||
| * Match `query` against `label` the way an editor's quick open does: every | ||
| * space-separated term must appear in order, contiguously or scattered, or | ||
| * within a typo of a word's start, with no case. Higher scores rank first. | ||
| * Returns undefined when a term misses. | ||
| */ | ||
| export function quickOpenMatch( | ||
| label: string, | ||
| query: string, | ||
| ): QuickOpenMatch | undefined { | ||
| const haystack = foldCase(label); | ||
| const terms = foldCase(query).split(/\s+/).filter(Boolean); | ||
| const indices = new Set<number>(); | ||
| let score = 0; | ||
| for (const term of terms) { | ||
| const match = matchTerm(haystack, term); | ||
| if (!match) return undefined; | ||
| score += match.score; | ||
| for (const index of match.indices) indices.add(index); | ||
| } | ||
| return { indices: [...indices].sort((a, b) => a - b), score }; | ||
| } | ||
|
|
||
| function matchTerm(haystack: string, term: string): QuickOpenMatch | undefined { | ||
| let substringAt = -1; | ||
| for ( | ||
| let at = haystack.indexOf(term); | ||
| at !== -1; | ||
| at = haystack.indexOf(term, at + 1) | ||
| ) { | ||
| if (isWordStart(haystack, at)) { | ||
| return { indices: range(at, term.length), score: 3 * TIER - at }; | ||
| } | ||
| if (substringAt === -1) substringAt = at; | ||
| } | ||
| if (substringAt !== -1) { | ||
| return { | ||
| indices: range(substringAt, term.length), | ||
| score: 2 * TIER - substringAt, | ||
| }; | ||
| } | ||
| const scatter = matchScatter(haystack, term); | ||
| // No typo can beat a scatter this tight, so skip the edit distances. | ||
| if (scatter && scatter.score >= TYPO_SCORE) return scatter; | ||
| const typo = matchTypo(haystack, term); | ||
| if (!scatter || !typo) return scatter ?? typo; | ||
| return scatter.score >= typo.score ? scatter : typo; | ||
| } | ||
|
|
||
| function matchScatter( | ||
| haystack: string, | ||
| term: string, | ||
| ): QuickOpenMatch | undefined { | ||
| const indices: number[] = []; | ||
| let wordStarts = 0; | ||
| let from = 0; | ||
| for (const char of term) { | ||
| const at = haystack.indexOf(char, from); | ||
| if (at === -1) return undefined; | ||
| if (isWordStart(haystack, at)) wordStarts += 1; | ||
| indices.push(...range(at, char.length)); | ||
| from = at + char.length; | ||
| } | ||
| const span = indices.at(-1)! - indices[0]!; | ||
| const score = TIER - span * SPAN_PENALTY + wordStarts * WORD_START_BONUS; | ||
| return { indices, score: Math.min(Math.max(score, 1), TIER - 1) }; | ||
| } | ||
|
|
||
| /** | ||
| * Compare `term` with the text from each word start, so an unfinished word | ||
| * still counts. Allows one edit for short terms and two for longer ones. | ||
| */ | ||
| function matchTypo(haystack: string, term: string): QuickOpenMatch | undefined { | ||
| if (term.length < TYPO_MIN_LENGTH) return undefined; | ||
| const allowed = term.length < 6 ? 1 : 2; | ||
| let best: { at: number; length: number; distance: number } | undefined; | ||
| // Compare against the rest of the title, not only the word, so | ||
| // `slef-update` still finds "self-update". | ||
| for (const word of haystack.matchAll(/[\p{L}\p{N}]+/gu)) { | ||
| const distances = prefixDistances( | ||
| term, | ||
| haystack.slice(word.index, word.index + term.length + allowed), | ||
| ); | ||
| // Each extra or missing letter costs an edit, so lengths further than | ||
| // `allowed` from the term can't match. A tie keeps the length closest | ||
| // to the term's, so the highlight covers the whole term. | ||
| for (let offset = 0; offset <= 2 * allowed; offset++) { | ||
| const length = | ||
| term.length + (offset % 2 ? -1 : 1) * Math.ceil(offset / 2); | ||
| const distance = distances[length]; | ||
| if (distance === undefined) continue; | ||
| if (distance <= allowed && (!best || distance < best.distance)) { | ||
| best = { at: word.index, length, distance }; | ||
| } | ||
| } | ||
| } | ||
| if (!best) return undefined; | ||
| return { | ||
| indices: range(best.at, best.length), | ||
| score: Math.max(TYPO_SCORE / best.distance - best.at, 1), | ||
| }; | ||
| } | ||
|
|
||
| // Levenshtein distance from `a` to each prefix of `b`, where swapping two | ||
| // neighbors costs one edit. Keeps three rows: the swap looks two rows back. | ||
| function prefixDistances(a: string, b: string): number[] { | ||
| let before = Array.from({ length: b.length + 1 }, () => 0); | ||
| let previous = Array.from({ length: b.length + 1 }, (_, j) => j); | ||
| let current = Array.from({ length: b.length + 1 }, () => 0); | ||
| for (let i = 1; i <= a.length; i++) { | ||
| current[0] = i; | ||
| for (let j = 1; j <= b.length; j++) { | ||
| current[j] = Math.min( | ||
| previous[j]! + 1, | ||
| current[j - 1]! + 1, | ||
| previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1), | ||
| ); | ||
| if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { | ||
| current[j] = Math.min(current[j]!, before[j - 2]! + 1); | ||
| } | ||
| } | ||
| [before, previous, current] = [previous, current, before]; | ||
| } | ||
| return previous; | ||
| } | ||
|
|
||
| // Lowercases one character at a time and keeps each one's UTF-16 length, so | ||
| // indices in the folded text stay valid in the label. "İ" lowercases to "i" | ||
| // plus a combining dot; only the "i" is kept. | ||
| function foldCase(text: string): string { | ||
| let folded = ""; | ||
| for (const char of text) { | ||
| folded += char.toLowerCase().slice(0, char.length); | ||
| } | ||
| return folded; | ||
| } | ||
|
|
||
| function isWordStart(haystack: string, at: number): boolean { | ||
| return at === 0 || !/[\p{L}\p{N}]/u.test(haystack[at - 1]!); | ||
| } | ||
|
|
||
| function range(start: number, length: number): number[] { | ||
| return Array.from({ length }, (_, offset) => start + offset); | ||
| } | ||
This file contains hidden or 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,68 @@ | ||
| import { expect, it } from "vitest"; | ||
|
|
||
| import { quickOpenMatch } from "../src/client/components/quickOpenMatch"; | ||
|
|
||
| // No describe block: CI parses the JUnit report, and each " > " in a test | ||
| // name adds an XML entity toward the parser's limit. | ||
| it("matches scattered letters in order, with no case", () => { | ||
| expect(quickOpenMatch("Checkout latency triage", "CLT")?.indices).toEqual([ | ||
| 0, 9, 11, | ||
| ]); | ||
| expect(quickOpenMatch("Checkout latency triage", "gc")).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("needs every space-separated term", () => { | ||
| expect(quickOpenMatch("Checkout latency triage", "chk tri")?.indices).toEqual( | ||
| [0, 1, 4, 17, 18, 19], | ||
| ); | ||
| expect(quickOpenMatch("Investigate checkout latency", "chk tri")).toBe( | ||
| undefined, | ||
| ); | ||
| }); | ||
|
|
||
| it("ranks a word-start run over a mid-word run over a scatter", () => { | ||
| const score = (label: string) => quickOpenMatch(label, "deploy")!.score; | ||
| expect(score("Deploy notes")).toBeGreaterThan(score("Redeploy notes")); | ||
| expect(score("Redeploy notes")).toBeGreaterThan( | ||
| score("Debug the payments loy"), | ||
| ); | ||
| }); | ||
|
|
||
| it("matches a word start within one typo, two for longer terms", () => { | ||
| expect(quickOpenMatch("Issue triage", "isseu")?.indices).toEqual([ | ||
| 0, 1, 2, 3, 4, | ||
| ]); | ||
| expect(quickOpenMatch("Customer escalation", "escelation")).toBeDefined(); | ||
| expect(quickOpenMatch("Checkout latency triage", "chekcout")).toBeDefined(); | ||
| expect( | ||
| quickOpenMatch("Checkout latency triage", "chheckkout")?.indices, | ||
| ).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); | ||
| expect(quickOpenMatch("Package self-update", "slef-update")).toBeDefined(); | ||
| expect(quickOpenMatch("Checkout latency triage", "cehkcuot")).toBeUndefined(); | ||
| // Short terms need their letters in order. | ||
| expect(quickOpenMatch("Uptime alerts", "tpi")).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("ranks a typo below a tight scatter and above a loose one", () => { | ||
| const score = (label: string, query: string) => | ||
| quickOpenMatch(label, query)!.score; | ||
| expect(score("Who is betegon on GitHub", "bategon")).toBeGreaterThan( | ||
| score("Snuba timeout investigation", "bategon"), | ||
| ); | ||
| expect(score("Checkout latency triage", "chklat")).toBeGreaterThan( | ||
| score("Checkout latency triage", "chekcout"), | ||
| ); | ||
| }); | ||
|
|
||
| it("keeps label indices when lowercasing adds characters", () => { | ||
| expect(quickOpenMatch("İstanbul latency", "latency")?.indices).toEqual([ | ||
| 9, 10, 11, 12, 13, 14, 15, | ||
| ]); | ||
| expect(quickOpenMatch("İstanbul latency", "ist")).toEqual( | ||
| quickOpenMatch("Istanbul latency", "ist"), | ||
| ); | ||
| }); | ||
|
|
||
| it("keeps surrogate pairs whole", () => { | ||
| expect(quickOpenMatch("Ship 🚀 now", "🚀")?.indices).toEqual([5, 6]); | ||
| }); |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.