Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/junior-dashboard/e2e/command-palette.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ test("switches conversations from the command palette without a server search",
await expect(
palette.getByRole("option", { name: /Checkout latency triage/ }),
).toHaveCount(0);
await input.fill("checkout");
// A typo still matches.
await input.fill("chekcout");
await expect(
palette.getByRole("option", { name: /Investigate checkout latency/ }),
).toHaveAttribute("aria-selected", "true");
Expand Down
1 change: 0 additions & 1 deletion packages/junior-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"cn": "^0.5.0",
"fuzzysort": "^4.0.2",
"hono": "^4.13.11",
"lucide-react": "^1.17.0",
"react": "^19.2.6",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
SquarePen,
type LucideIcon,
} from "lucide-react";
import fuzzysort from "fuzzysort";
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router";

Expand All @@ -20,6 +19,7 @@ import { isPlatformShortcut, platformShortcut } from "../keyboard";
import type { Conversation } from "../types";
import type { DashboardHeaderNavItem } from "./layout/DashboardHeader";
import { navItemIcon } from "./layout/navIcons";
import { quickOpenMatch } from "./quickOpenMatch";
import {
Command,
CommandDialog,
Expand Down Expand Up @@ -271,10 +271,10 @@ function paletteItems(input: {
);
const matchedPages = ranked(
pages.map((page) => {
const match = fuzzysort.single(input.query, page.label);
const match = quickOpenMatch(page.label, input.query);
return {
...pageItem(page),
matches: match ? [...match.indexes] : undefined,
matches: match?.indices,
score: match?.score ?? 0,
};
}),
Expand All @@ -299,13 +299,13 @@ function conversationItem(
query?: string,
): PaletteItem {
const label = conversationDisplayTitle(conversation);
const match = query ? fuzzysort.single(query, label) : undefined;
const match = query ? quickOpenMatch(label, query) : undefined;
return {
group,
icon: conversation.visibility === "private" ? LockKeyhole : MessageSquare,
id: `conversation-${conversation.id}`,
label,
matches: match ? [...match.indexes] : undefined,
matches: match?.indices,
meta: conversation.archivedAt
? "Archived"
: formatCompactAge(conversation.lastSeenAt),
Expand Down
163 changes: 163 additions & 0 deletions packages/junior-dashboard/src/client/components/quickOpenMatch.ts
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 };
Comment thread
sentry-junior[bot] marked this conversation as resolved.
Resolved
}

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);
}
68 changes: 68 additions & 0 deletions packages/junior-dashboard/tests/quickOpenMatch.test.ts
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]);
});
8 changes: 0 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading