feat(search): match quick-open items by pinyin initials

This commit is contained in:
Melody Ruch authored and GitHub committed 2026-09-04 12:41:49 +08:00
1 parent 299c342d6d
commit 434d44a284
2 files changed
+86 -3

No files matched your search

@@ -115,6 +115,30 @@ describe("useQuickOpen", () => {
expect(matchQuickOpenText("cardshoplog", label)?.kind).toBe("fuzzy");
});
it("matches Chinese table names by pinyin initials (issue #7912)", () => {
// Reported: 全局搜索中文表名输入首字母模糊搜索搜不出来
expect(matchQuickOpenText("zzj", "总租金")?.kind).toBe("initials");
expect(matchQuickOpenText("yhmx", "用户明细")?.kind).toBe("initials");
});
it("matches Chinese table names by a non-contiguous pinyin-initials subsequence", () => {
const match = matchQuickOpenText("zj", "总租金");
expect(match?.kind).toBe("fuzzy");
expect(match?.indices.map((index) => "总租金"[index]).join("")).toBe("总租金".slice(0, 1) + "总租金".slice(2, 3));
});
it("keeps pinyin-initials highlight indices aligned across unmapped supplementary-plane characters", () => {
const match = matchQuickOpenText("bx", "𠮷表x");
expect(match?.kind).toBe("fuzzy");
expect(match?.indices).toEqual([2, 3]);
});
it("prefers a literal prefix match over pinyin-initials for mixed Han+Latin names", () => {
const literal = matchQuickOpenText("abc", "abc表");
expect(literal?.kind).toBe("prefix");
expect(literal?.score).toBeLessThan(250);
});
it("puts the exact shop result before prefixed and containing names", () => {
vi.mocked(useConnectionStore).mockReturnValue({
connections: [
+62 -3
View File
@@ -7,6 +7,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
import * as api from "@/lib/backend/api";
import type { SqlFileEntry } from "@/lib/backend/api";
import { getSqlFileFilter, getSqlFileFolderPaths, sqlFileFoldersVersion } from "@/lib/sqlFile/sqlFileFolders";
import { containsHan, pinyinFirstLetters } from "@/lib/common/pinyin";
import i18n from "@/i18n";
const REMOTE_SEARCH_DEBOUNCE_MS = 180;
@@ -86,6 +87,23 @@ function rangeIndices(start: number, length: number): number[] {
return Array.from({ length }, (_, index) => start + index);
}
const PINYIN_QUERY_RE = /^[a-z0-9]+$/;
/**
* Original-text indices of the characters that feed `pinyinFirstLetters(text)`, in order.
* Iterates by Unicode code point (like `pinyinFirstLetters`), not UTF-16 code unit, so
* supplementary-plane Han characters (surrogate pairs) stay aligned with the letters they produce.
*/
function pinyinLetterPositions(text: string): number[] {
const positions: number[] = [];
let index = 0;
for (const char of text) {
if (/[\p{Script=Han}a-z0-9]/iu.test(char)) positions.push(index);
index += char.length;
}
return positions;
}
function matchWordPrefixes(words: IdentifierWord[], query: string): number[] | null {
interface PrefixState {
queryIndex: number;
@@ -150,6 +168,20 @@ export function matchQuickOpenText(query: string, text: string): QuickOpenMatch
return { kind: "prefix", score: 200 + Math.min(text.length - lowerQuery.length, 99), indices: rangeIndices(0, lowerQuery.length) };
}
// DataGrip-style pinyin-initials matching for Chinese identifiers, e.g. "总租金" via "zzj".
// Only tried after literal matches fail, so a mixed Han+Latin name that literally prefix-matches
// (e.g. "abc表" via "abc") keeps its better literal-prefix score instead of being intercepted here.
const isPinyinQuery = PINYIN_QUERY_RE.test(lowerQuery) && containsHan(text);
if (isPinyinQuery) {
const pinyinLetters = pinyinFirstLetters(text);
if (pinyinLetters === lowerQuery) {
return { kind: "initials", score: 150 + Math.min(text.length, 99), indices: pinyinLetterPositions(text) };
}
if (pinyinLetters.startsWith(lowerQuery)) {
return { kind: "prefix", score: 250 + Math.min(text.length - lowerQuery.length, 99), indices: pinyinLetterPositions(text).slice(0, lowerQuery.length) };
}
}
const wordPrefixIndices = matchWordPrefixes(words, lowerQuery);
if (wordPrefixIndices) {
return { kind: "word-prefix", score: 300 + Math.min(text.length - lowerQuery.length, 99), indices: wordPrefixIndices };
@@ -168,10 +200,37 @@ export function matchQuickOpenText(query: string, text: string): QuickOpenMatch
indices.push(index);
queryIndex++;
}
if (queryIndex !== lowerQuery.length) return null;
if (queryIndex === lowerQuery.length) {
const span = indices[indices.length - 1] - indices[0] + 1;
return { kind: "fuzzy", score: 500 + Math.min(span - lowerQuery.length, 99), indices };
}
const span = indices[indices.length - 1] - indices[0] + 1;
return { kind: "fuzzy", score: 500 + Math.min(span - lowerQuery.length, 99), indices };
// Non-contiguous pinyin-initials fallback, e.g. "zj" matching "总租金" (pinyin initials "zzj"),
// matching the ordered-subsequence behavior every other pinyin call site in the app already has.
if (isPinyinQuery) {
const pinyinLetters = pinyinFirstLetters(text);
const positions = pinyinLetterPositions(text);
const letterIndices: number[] = [];
let letterQueryIndex = 0;
let letterCount = 0;
// Iterate the initials by code point, keeping letterCount aligned with
// positions: unmapped supplementary-plane Han characters occupy two code
// units in pinyinLetters but still correspond to exactly one position.
for (const letter of pinyinLetters) {
if (letterQueryIndex >= lowerQuery.length) break;
if (letter === lowerQuery[letterQueryIndex]) {
letterIndices.push(positions[letterCount] ?? letterCount);
letterQueryIndex += 1;
}
letterCount += 1;
}
if (letterQueryIndex === lowerQuery.length) {
const pinyinSpan = letterIndices[letterIndices.length - 1] - letterIndices[0] + 1;
return { kind: "fuzzy", score: 500 + Math.min(pinyinSpan - lowerQuery.length, 99), indices: letterIndices };
}
}
return null;
}
interface MatchedItem extends QuickOpenItem {