From 6dba8bd555fad3791587bc5bf9a3cce46f2aab61 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:38:03 +0000 Subject: [PATCH] feat(dashboard): Treat camelCase capitals as word starts in the quick switcher Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- .../src/client/components/quickOpenMatch.ts | 52 ++++++++++++++----- .../tests/quickOpenMatch.test.ts | 11 ++++ 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/quickOpenMatch.ts b/packages/junior-dashboard/src/client/components/quickOpenMatch.ts index 44726f1a65..716010012e 100644 --- a/packages/junior-dashboard/src/client/components/quickOpenMatch.ts +++ b/packages/junior-dashboard/src/client/components/quickOpenMatch.ts @@ -16,11 +16,16 @@ const SPAN_PENALTY = 20; const TYPO_SCORE = 600; // Shorter terms are mostly noise when edited, and need an exact match. const TYPO_MIN_LENGTH = 4; +const WORD_CHAR = /[\p{L}\p{N}]/u; +const UPPER = /\p{Lu}/u; +const LOWER = /\p{Ll}/u; /** * 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. + * within a typo of a word's start, with no case. A word starts after a + * space or symbol, or at a capital after a lowercase letter, so + * `isolationScope` has two. Higher scores rank first. * Returns undefined when a term misses. */ export function quickOpenMatch( @@ -28,11 +33,12 @@ export function quickOpenMatch( query: string, ): QuickOpenMatch | undefined { const haystack = foldCase(label); + const starts = wordStarts(label); const terms = foldCase(query).split(/\s+/).filter(Boolean); const indices = new Set(); let score = 0; for (const term of terms) { - const match = matchTerm(haystack, term); + const match = matchTerm(haystack, starts, term); if (!match) return undefined; score += match.score; for (const index of match.indices) indices.add(index); @@ -40,14 +46,18 @@ export function quickOpenMatch( return { indices: [...indices].sort((a, b) => a - b), score }; } -function matchTerm(haystack: string, term: string): QuickOpenMatch | undefined { +function matchTerm( + haystack: string, + starts: Set, + 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)) { + if (starts.has(at)) { return { indices: range(at, term.length), score: 3 * TIER - at }; } if (substringAt === -1) substringAt = at; @@ -58,16 +68,17 @@ function matchTerm(haystack: string, term: string): QuickOpenMatch | undefined { score: 2 * TIER - substringAt, }; } - const scatter = matchScatter(haystack, term); + const scatter = matchScatter(haystack, starts, 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); + const typo = matchTypo(haystack, starts, term); if (!scatter || !typo) return scatter ?? typo; return scatter.score >= typo.score ? scatter : typo; } function matchScatter( haystack: string, + starts: Set, term: string, ): QuickOpenMatch | undefined { const indices: number[] = []; @@ -76,7 +87,7 @@ function matchScatter( for (const char of term) { const at = haystack.indexOf(char, from); if (at === -1) return undefined; - if (isWordStart(haystack, at)) wordStarts += 1; + if (starts.has(at)) wordStarts += 1; indices.push(...range(at, char.length)); from = at + char.length; } @@ -89,16 +100,21 @@ function matchScatter( * 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 { +function matchTypo( + haystack: string, + starts: Set, + 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)) { + for (const at of starts) { + if (!WORD_CHAR.test(haystack[at]!)) continue; const distances = prefixDistances( term, - haystack.slice(word.index, word.index + term.length + allowed), + haystack.slice(at, at + 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 @@ -109,7 +125,7 @@ function matchTypo(haystack: string, term: string): QuickOpenMatch | undefined { const distance = distances[length]; if (distance === undefined) continue; if (distance <= allowed && (!best || distance < best.distance)) { - best = { at: word.index, length, distance }; + best = { at, length, distance }; } } } @@ -154,8 +170,18 @@ function foldCase(text: string): string { return folded; } -function isWordStart(haystack: string, at: number): boolean { - return at === 0 || !/[\p{L}\p{N}]/u.test(haystack[at - 1]!); +// Ascending word start indices in `label`, read before case folding so +// camelCase boundaries survive. +function wordStarts(label: string): Set { + const starts = new Set([0]); + for (let at = 1; at < label.length; at++) { + const char = label[at]!; + const before = label[at - 1]!; + if (!WORD_CHAR.test(before) || (UPPER.test(char) && LOWER.test(before))) { + starts.add(at); + } + } + return starts; } function range(start: number, length: number): number[] { diff --git a/packages/junior-dashboard/tests/quickOpenMatch.test.ts b/packages/junior-dashboard/tests/quickOpenMatch.test.ts index bdb8a4ea17..7ce6fea23f 100644 --- a/packages/junior-dashboard/tests/quickOpenMatch.test.ts +++ b/packages/junior-dashboard/tests/quickOpenMatch.test.ts @@ -20,6 +20,17 @@ it("needs every space-separated term", () => { ); }); +it("treats a camelCase capital as a word start", () => { + const score = (label: string, query: string) => + quickOpenMatch(label, query)!.score; + expect(score("Investigate isolationScope leak", "scope")).toBeGreaterThan( + score("Investigate telescope leak", "scope"), + ); + expect(quickOpenMatch("Fix getCurrentHub usage", "curnet")?.indices).toEqual([ + 7, 8, 9, 10, 11, 12, + ]); +}); + 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"));