diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 37384dadac..f7b3fa699b 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -11,6 +11,7 @@ import SqlExecutionTargetPicker from "./SqlExecutionTargetPicker.vue"; import DelimitedListDialog from "./DelimitedListDialog.vue"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { copyToClipboard, readTextFromClipboard } from "@/lib/common/clipboard"; +import { completionMatchRanges } from "@/lib/common/completionMatch"; import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget"; import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges"; import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache"; @@ -2506,16 +2507,41 @@ function consumeSqlCompletionAutoStartSuppression() { return true; } -function buildCompletionResult(items: QueryCompletionItem[], from: number, validFor?: RegExp) { +function buildCompletionResult(items: QueryCompletionItem[], from: number, validFor?: RegExp, prefix?: string) { if (items.length === 0) return null; + const bypassFilter = !!prefix && shouldBypassCompletionFilter(prefix, items); + const resultItems = bypassFilter && prefix ? completionItemsForBypassedFilter(prefix, items) : items; return { from, // Keep CodeMirror's live filtering enabled so an already-open menu follows the typed prefix. - options: items.map((item) => completionOptionForItem(item)), + options: resultItems.map((item) => completionOptionForItem(item)), validFor, + // When bypassing CodeMirror's matcher, supply our own highlight ranges so + // the matched characters (Han substring or pinyin initials) stay marked. + // Ranges must target the rendered text, which is displayLabel when set. + ...(bypassFilter && prefix ? { filter: false as const, getMatch: (option: { label: string; displayLabel?: string }) => completionMatchRanges(option.displayLabel ?? option.label, prefix) } : {}), }; } +// CodeMirror's built-in matcher only matches single-character queries against +// the label start, and cannot match pinyin initials against Han labels. Our +// provider already filters and ranks items itself (substring + pinyin), so +// skip the second-stage filter exactly in the cases it would break. +function shouldBypassCompletionFilter(prefix: string, items: QueryCompletionItem[]): boolean { + if (!prefix) return false; + if (/\p{Script=Han}/u.test(prefix)) return true; + return /^[a-z0-9]+$/i.test(prefix) && items.some((item) => /\p{Script=Han}/u.test(item.label)); +} + +function completionItemsForBypassedFilter(prefix: string, items: QueryCompletionItem[]): QueryCompletionItem[] { + if ([...prefix].length !== 1 || !/^[a-z0-9]$/i.test(prefix)) return items; + const normalized = prefix.toLowerCase(); + return items.filter((item) => { + const renderedLabel = "displayLabel" in item && typeof item.displayLabel === "string" ? item.displayLabel : item.label; + return /\p{Script=Han}/u.test(item.label) || renderedLabel.toLowerCase().startsWith(normalized); + }); +} + function localCompletionDatabaseNames(completionContext: ReturnType): string[] { if (!supportsDatabaseNameCompletion(props.databaseType) || !completionContext.suggestTables || completionContext.insertTable || !props.connectionId) return []; return connectionStore.lookupLocalCompletionDatabases(props.connectionId, completionContext.qualifier || completionContext.prefix, MAX_COMPLETION_TABLES); @@ -2761,7 +2787,7 @@ async function provideSqlCompletions(context: CompletionContext) { supportsSessionDatabaseSwitch: sqlServerContext.supports_session_database_switch, }); const items = buildSqlServerUseDatabaseCompletionItems(databaseNames, useDatabaseCompletion); - return buildCompletionResult(items, useDatabaseCompletion.from); + return buildCompletionResult(items, useDatabaseCompletion.from, undefined, useDatabaseCompletion.prefix); } const legacyCompletionContext = getSqlCompletionContext(fullDoc, position, sqlCompletionDialectOptions()); @@ -2783,7 +2809,7 @@ async function provideSqlCompletions(context: CompletionContext) { functionCase: settingsStore.editorSettings.sqlFormatter.functionCase, autoAliasTables: settingsStore.editorSettings.autoAliasTables, }); - return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position)); + return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix); } const useDatabase = props.databaseType === "sqlserver" ? sqlServerUseDatabaseBeforeCursor(fullDoc, position) : undefined; @@ -2843,7 +2869,7 @@ async function provideSqlCompletions(context: CompletionContext) { functionCase: settingsStore.editorSettings.sqlFormatter.functionCase, autoAliasTables: settingsStore.editorSettings.autoAliasTables, }); - return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position)); + return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix); } const tableNameCompletion = isTableNameCompletionContext(completionContext); @@ -3060,7 +3086,7 @@ function buildLocalSqlCompletionResult(completionContext: ReturnType, fullDoc: string, position: number, scope: CompletionMetadataScope) { @@ -3520,7 +3546,7 @@ async function performAsyncCompletionWithResult(epoch: number, completionContext autoAliasTables: settingsStore.editorSettings.autoAliasTables, }); - return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position)); + return buildCompletionResult(items, position - completionContext.prefix.length, getSqlCompletionResultValidFor(fullDoc, position), completionContext.prefix); } function isReferencedTableQualifier(completionContext: ReturnType): boolean { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts index ea0079e77f..d3a32d577f 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridConditionEditor.spec.ts @@ -338,3 +338,44 @@ describe("DataGridConditionEditor quote completion", () => { expect(expandedPaneCss).not.toContain("height 150ms"); }); }); + +describe("DataGridConditionEditor Chinese column matching", () => { + function mountChineseColumns() { + return mountEditor("where", "", { columns: ["总租金", "租赁日期", "amount"] }); + } + + async function typeToken(input: HTMLTextAreaElement, token: string) { + input.focus(); + input.value = token; + input.setSelectionRange(token.length, token.length); + input.dispatchEvent(new Event("input", { bubbles: true })); + } + + it("matches a single Han character anywhere in the column name", async () => { + const { input } = mountChineseColumns(); + await typeToken(input, "金"); + + await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(1)); + expect(document.querySelector('[role="option"]')?.textContent).toContain("总租金"); + }); + + it("matches pinyin initials and initials subsequences", async () => { + const { input } = mountChineseColumns(); + await typeToken(input, "zzj"); + + await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(1)); + expect(document.querySelector('[role="option"]')?.textContent).toContain("总租金"); + + await typeToken(input, "zj"); + await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(1)); + expect(document.querySelector('[role="option"]')?.textContent).toContain("总租金"); + }); + + it("still matches plain English columns", async () => { + const { input } = mountChineseColumns(); + await typeToken(input, "am"); + + await vi.waitFor(() => expect(document.querySelectorAll('[role="option"]')).toHaveLength(1)); + expect(document.querySelector('[role="option"]')?.textContent).toContain("amount"); + }); +}); diff --git a/apps/desktop/src/composables/useDataGridConditionEditor.ts b/apps/desktop/src/composables/useDataGridConditionEditor.ts index c903e749a3..0f20c23194 100644 --- a/apps/desktop/src/composables/useDataGridConditionEditor.ts +++ b/apps/desktop/src/composables/useDataGridConditionEditor.ts @@ -1,5 +1,6 @@ import { computed, getCurrentScope, onScopeDispose, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from "vue"; import { forgetDataGridConditionHistory, loadDataGridConditionHistory, rememberDataGridConditionHistory, type DataGridConditionHistoryKind, type DataGridConditionHistoryScope } from "@/lib/dataGrid/dataGridConditionHistory"; +import { pinyinAwareMatchScore } from "@/lib/common/pinyin"; export type DataGridConditionSuggestionKind = "column" | "keyword" | "history"; @@ -232,15 +233,23 @@ export function useDataGridConditionEditor(options: UseDataGridConditionEditorOp const seen = new Set(); const suggestions: DataGridConditionSuggestion[] = []; if (role === "field") { + const scored: Array<{ suggestion: DataGridConditionSuggestion; score: number; index: number }> = []; + let index = 0; for (const column of toValue(options.columns) ?? []) { const columnValue = typeof column === "string" ? column : column.name; const normalizedValue = columnValue.toLowerCase(); - if ((normalizedToken && (!normalizedValue.startsWith(normalizedToken) || normalizedValue === normalizedToken)) || seen.has(columnValue)) continue; + if ((normalizedToken && normalizedValue === normalizedToken) || seen.has(columnValue)) continue; seen.add(columnValue); + // Substring + pinyin-initials matching (金 / zzj / zj → 总租金), scored so + // prefix matches rank above looser ones and ties keep the column order. + const score = normalizedToken ? pinyinAwareMatchScore(columnValue, normalizedToken) : 0; + if (score < 0) continue; const comment = normalizedColumnComment(column); const insertText = target.quotedIdentifier ? columnValue : typeof column === "string" ? columnValue : column.insertText; - suggestions.push({ value: columnValue, kind: "column", ...(insertText !== undefined && insertText !== columnValue ? { insertText } : {}), ...(comment ? { comment } : {}) }); + scored.push({ suggestion: { value: columnValue, kind: "column", ...(insertText !== undefined && insertText !== columnValue ? { insertText } : {}), ...(comment ? { comment } : {}) }, score, index: index++ }); } + scored.sort((a, b) => b.score - a.score || a.index - b.index); + return scored.map((entry) => entry.suggestion); } else { for (const keyword of WHERE_CONNECTOR_KEYWORDS) { if (!keyword.toLowerCase().startsWith(normalizedToken) || keyword.toLowerCase() === normalizedToken) continue; diff --git a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts index 230e1fbe36..1b25676f88 100644 --- a/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/queryEditorCompletionKeymap.spec.ts @@ -101,6 +101,32 @@ describe("QueryEditor completion Tab keymap", () => { expect(result).not.toHaveProperty("filter"); }); + it("keeps ASCII single-character filtering while allowing Han pinyin matches", () => { + const source = [extractFunction("completionItemsForBypassedFilter"), extractFunction("shouldBypassCompletionFilter"), extractFunction("buildCompletionResult")].join("\n"); + const javascript = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const buildCompletionResult = new Function("completionOptionForItem", "completionMatchRanges", `${javascript}\nreturn buildCompletionResult;`)( + (item: { label: string }) => item, + () => [], + ) as (items: Array<{ label: string }>, from: number, validFor?: RegExp, prefix?: string) => { filter?: boolean; options: Array<{ label: string }> } | null; + + const result = buildCompletionResult([{ label: "amount" }, { label: "memo" }, { label: "明细" }], 0, undefined, "m"); + + expect(result?.filter).toBe(false); + expect(result?.options.map((item) => item.label)).toEqual(["memo", "明细"]); + }); + + it("bypasses CodeMirror filtering for Han substring matches", () => { + const javascript = ts.transpileModule(extractFunction("shouldBypassCompletionFilter"), { + compilerOptions: { module: ts.ModuleKind.None, target: ts.ScriptTarget.ES2022 }, + }).outputText; + const shouldBypassCompletionFilter = new Function(`${javascript}\nreturn shouldBypassCompletionFilter;`)() as (prefix: string, items: Array<{ label: string }>) => boolean; + + expect(shouldBypassCompletionFilter("金", [{ label: "总租金" }])).toBe(true); + expect(shouldBypassCompletionFilter("m", [{ label: "amount" }, { label: "memo" }])).toBe(false); + }); + it("keeps normal Tab indentation when completion is inactive", () => { const harness = createHarness({ completionStatus: () => null }); const view = createView(); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlCompletion.pinyin.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.pinyin.spec.ts new file mode 100644 index 0000000000..6fba8034cc --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/sqlCompletion.pinyin.spec.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; +import { containsHan, matchesPinyinInitials, orderedSubsequenceSpan, pinyinAwareMatchScore, pinyinFirstLetters } from "@/lib/common/pinyin"; +import { completionMatchRanges } from "@/lib/common/completionMatch"; +import { buildSqlCompletionItems } from "@/lib/sql/sqlCompletion"; + +describe("pinyinAwareMatchScore", () => { + it("ranks prefix above pinyin prefix above substring above pinyin subsequence", () => { + const prefix = pinyinAwareMatchScore("总租金", "总"); + const pinyinPrefix = pinyinAwareMatchScore("总租金", "zz"); + const substring = pinyinAwareMatchScore("总租金", "金"); + const subsequence = pinyinAwareMatchScore("总租金", "zj"); + expect(prefix).toBeGreaterThan(pinyinPrefix); + expect(pinyinPrefix).toBeGreaterThan(substring); + expect(substring).toBeGreaterThan(subsequence); + expect(subsequence).toBeGreaterThan(0); + }); + + it("returns -1 when nothing matches", () => { + expect(pinyinAwareMatchScore("总租金", "jz")).toBe(-1); + expect(pinyinAwareMatchScore("amount", "zz")).toBe(-1); + expect(pinyinAwareMatchScore("amount", "")).toBe(0); + }); +}); + +describe("ordered subsequence span", () => { + it("matches initials subsequences", () => { + expect(orderedSubsequenceSpan("zzj", "zj")).toEqual({ first: 0, span: 3 }); + expect(orderedSubsequenceSpan("zzj", "j")).toEqual({ first: 2, span: 1 }); + expect(orderedSubsequenceSpan("zzj", "jz")).toBeNull(); + expect(orderedSubsequenceSpan("zzj", "")).toBeNull(); + }); +}); + +describe("completion match highlight ranges", () => { + it("highlights a Han substring match", () => { + expect(completionMatchRanges("总租金", "金")).toEqual([2, 3]); + }); + + it("highlights every Han character matched by pinyin initials", () => { + expect(completionMatchRanges("总租金", "zzj")).toEqual([0, 1, 1, 2, 2, 3]); + expect(completionMatchRanges("总租金", "zz")).toEqual([0, 1, 1, 2]); + }); + + it("highlights only the Han characters hit by an initials subsequence", () => { + expect(completionMatchRanges("总租金", "zj")).toEqual([0, 1, 2, 3]); + expect(completionMatchRanges("总租金", "j")).toEqual([2, 3]); + }); + + it("highlights ASCII substrings and falls back to in-order fuzzy", () => { + expect(completionMatchRanges("amount", "am")).toEqual([0, 2]); + expect(completionMatchRanges("order_detail", "od")).toEqual([0, 1, 2, 3]); + expect(completionMatchRanges("amount", "zz")).toEqual([]); + }); +}); + +describe("pinyin first letters", () => { + it("maps Han characters to their pinyin initials", () => { + expect(pinyinFirstLetters("总租金")).toBe("zzj"); + expect(pinyinFirstLetters("租赁日期")).toBe("zlrq"); + }); + + it("keeps ASCII alphanumerics and drops other characters", () => { + expect(pinyinFirstLetters("order明细")).toBe("ordermx"); + expect(pinyinFirstLetters("Amount")).toBe("amount"); + }); + + it("matches only ASCII queries against Han candidates", () => { + expect(matchesPinyinInitials("总租金", "zzj")).toBe(true); + expect(matchesPinyinInitials("总租金", "zz")).toBe(true); + expect(matchesPinyinInitials("总租金", "zj")).toBe(false); + expect(matchesPinyinInitials("总租金", "租金")).toBe(false); + expect(matchesPinyinInitials("amount", "am")).toBe(false); + expect(containsHan("amount")).toBe(false); + }); +}); + +describe("sqlCompletion Chinese column matching", () => { + function completionItems(typedPrefix: string) { + const sql = `SELECT * FROM orders WHERE ${typedPrefix}`; + return buildSqlCompletionItems(sql, sql.length, { + databaseType: "mysql", + tables: [{ name: "orders", type: "table" }], + columnsByTable: new Map([ + [ + "orders", + [ + { name: "总租金", table: "orders" }, + { name: "租赁日期", table: "orders" }, + { name: "amount", table: "orders" }, + ], + ], + ]), + }); + } + + it("matches a single Han character anywhere in the column name", () => { + const items = completionItems("金"); + expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "总租金", type: "column" })])); + }); + + it("matches pinyin initials like DataGrip", () => { + const items = completionItems("zzj"); + expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "总租金", type: "column" })])); + expect(items.some((item) => item.label === "租赁日期")).toBe(false); + expect(items.some((item) => item.label === "amount")).toBe(false); + }); + + it("matches a single ASCII letter via pinyin initials", () => { + const items = completionItems("z"); + const labels = items.filter((item) => item.type === "column").map((item) => item.label); + expect(labels).toEqual(expect.arrayContaining(["总租金", "租赁日期"])); + }); + + it("matches initials subsequences like DataGrip", () => { + const subsequenceItems = completionItems("zj"); + expect(subsequenceItems).toEqual(expect.arrayContaining([expect.objectContaining({ label: "总租金", type: "column" })])); + expect(subsequenceItems.some((item) => item.label === "租赁日期")).toBe(false); + + const lastInitialItems = completionItems("j"); + expect(lastInitialItems).toEqual(expect.arrayContaining([expect.objectContaining({ label: "总租金", type: "column" })])); + expect(lastInitialItems.some((item) => item.label === "租赁日期")).toBe(false); + }); + + it("keeps plain English matching unchanged", () => { + const items = completionItems("am"); + expect(items).toEqual(expect.arrayContaining([expect.objectContaining({ label: "amount", type: "column" })])); + expect(items.some((item) => item.label === "总租金")).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/common/completionMatch.ts b/apps/desktop/src/lib/common/completionMatch.ts new file mode 100644 index 0000000000..a76cb67e4a --- /dev/null +++ b/apps/desktop/src/lib/common/completionMatch.ts @@ -0,0 +1,62 @@ +import { pinyin } from "pinyin-pro"; + +const HAN_CHAR = /\p{Script=Han}/u; +const ASCII_ALNUM = /[a-z0-9]/i; +const ASCII_QUERY = /^[a-z0-9]+$/; + +/** + * Highlight ranges (flat [from, to, ...] UTF-16 offsets into `label`) for a + * completion query, used when CodeMirror's built-in filter is bypassed. + * + * Tiers: case-insensitive substring → pinyin initials ("zzj" highlights each + * Han character of 总租金) → in-order fuzzy. Returns [] when nothing matches. + */ +export function completionMatchRanges(label: string, query: string): number[] { + if (!query) return []; + const lowerLabel = label.toLowerCase(); + const lowerQuery = query.toLowerCase(); + + const direct = lowerLabel.indexOf(lowerQuery); + if (direct >= 0) return [direct, direct + lowerQuery.length]; + + if (ASCII_QUERY.test(lowerQuery) && HAN_CHAR.test(label)) { + const pinyinRanges = pinyinInitialMatchRanges(label, lowerQuery); + if (pinyinRanges) return pinyinRanges; + } + + return fuzzyMatchRanges(lowerLabel, lowerQuery); +} + +function pinyinInitialMatchRanges(label: string, query: string): number[] | null { + // Greedy earliest subsequence over the pinyin-initial positions, so both + // prefix queries ("zz") and skipping queries ("zj") highlight the matched + // Han characters. + const ranges: number[] = []; + let queryIndex = 0; + let index = 0; + for (const char of label) { + if (queryIndex >= query.length) break; + const isHan = HAN_CHAR.test(char); + if (isHan || ASCII_ALNUM.test(char)) { + const initial = isHan ? pinyin(char, { pattern: "first", toneType: "none" }) : char.toLowerCase(); + if (initial === query[queryIndex]) { + ranges.push(index, index + char.length); + queryIndex += 1; + } + } + index += char.length; + } + return queryIndex === query.length ? ranges : null; +} + +function fuzzyMatchRanges(lowerLabel: string, lowerQuery: string): number[] { + const ranges: number[] = []; + let from = 0; + for (const char of lowerQuery) { + const position = lowerLabel.indexOf(char, from); + if (position < 0) return []; + ranges.push(position, position + char.length); + from = position + char.length; + } + return ranges; +} diff --git a/apps/desktop/src/lib/common/pinyin.ts b/apps/desktop/src/lib/common/pinyin.ts new file mode 100644 index 0000000000..2989303171 --- /dev/null +++ b/apps/desktop/src/lib/common/pinyin.ts @@ -0,0 +1,93 @@ +import { pinyin } from "pinyin-pro"; + +const HAN_CHAR = /\p{Script=Han}/u; +const ASCII_ALNUM = /[a-z0-9]/i; + +const firstLetterCache = new Map(); +const MAX_CACHE_ENTRIES = 100_000; + +function cacheFirstLetters(text: string, result: string): void { + if (firstLetterCache.size >= MAX_CACHE_ENTRIES) { + const oldest = firstLetterCache.keys().next().value; + if (oldest !== undefined) firstLetterCache.delete(oldest); + } + firstLetterCache.set(text, result); +} + +export function containsHan(text: string): boolean { + return HAN_CHAR.test(text); +} + +/** + * First pinyin letter of every Han character, with ASCII letters/digits kept + * as-is (lowercased) and every other character dropped. Used for DataGrip-style + * initials matching, e.g. pinyinFirstLetters("总租金") === "zzj". + * + * Only the default reading is used for polyphonic characters. + */ +export function pinyinFirstLetters(text: string): string { + const cached = firstLetterCache.get(text); + if (cached !== undefined) { + firstLetterCache.delete(text); + firstLetterCache.set(text, cached); + return cached; + } + let result = ""; + for (const char of text) { + if (HAN_CHAR.test(char)) { + result += pinyin(char, { pattern: "first", toneType: "none" }); + } else if (ASCII_ALNUM.test(char)) { + result += char.toLowerCase(); + } + } + cacheFirstLetters(text, result); + return result; +} + +/** True when an ASCII-letters/digits query can match `candidate` via pinyin initials. */ +export function matchesPinyinInitials(candidate: string, query: string): boolean { + if (!/^[a-z0-9]+$/.test(query) || !containsHan(candidate)) return false; + return pinyinFirstLetters(candidate).startsWith(query); +} + +/** + * Ordered-subsequence match of `query` against `text` (e.g. "zj" against the + * initials "zzj"). Returns the first matched index and the span covering all + * matched characters, or null when the query is not a subsequence. + */ +export function orderedSubsequenceSpan(text: string, query: string): { first: number; span: number } | null { + if (!query) return null; + let from = 0; + let first = -1; + let last = -1; + for (const char of query) { + const position = text.indexOf(char, from); + if (position < 0) return null; + if (first < 0) first = position; + last = position; + from = position + 1; + } + return { first, span: last - first + 1 }; +} + +/** + * Generic matcher for small pick-lists (grid condition editor, ...): + * prefix > pinyin-initials prefix > substring > pinyin-initials subsequence. + * Returns -1 for no match; scores are per-tier constants so same-tier + * candidates keep their original order. An empty query matches everything. + */ +export function pinyinAwareMatchScore(candidate: string, query: string): number { + const text = candidate.toLowerCase(); + const normalized = query.trim().toLowerCase(); + if (!normalized) return 0; + if (text.startsWith(normalized)) return 400; + const asciiQuery = /^[a-z0-9]+$/.test(normalized); + const han = containsHan(text); + if (asciiQuery && han && pinyinFirstLetters(text).startsWith(normalized)) return 300; + if (text.includes(normalized)) return 200; + if (asciiQuery && han) { + const subsequence = orderedSubsequenceSpan(pinyinFirstLetters(text), normalized); + if (subsequence) return 100; + } + return -1; +} diff --git a/apps/desktop/src/lib/sql/sqlCompletion.ts b/apps/desktop/src/lib/sql/sqlCompletion.ts index 13eafa229f..542a0c99b3 100644 --- a/apps/desktop/src/lib/sql/sqlCompletion.ts +++ b/apps/desktop/src/lib/sql/sqlCompletion.ts @@ -10,6 +10,7 @@ import { findActiveSqlStatementSpan, tokenizeSqlSemantic } from "@/lib/sql/seman import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semantic/types"; import { DEFAULT_SQL_SNIPPETS, MANTICORESEARCH_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; import { requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier"; +import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin"; export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates"; @@ -4289,6 +4290,8 @@ function matchesPrefix(candidate: string, prefix: string): boolean { * Scoring tiers: * Exact match: 3000 - len * Initials match: 2400 + exactInitialsBonus - len + * Pinyin initials: 2300 + exactInitialsBonus - len (ASCII query vs Han candidate, e.g. "zzj" → 总租金) + * Pinyin subsequence: 1600 - penalties - len (ordered initials, e.g. "zj" → 总租金) * Prefix match: 2000 - len * Substring: 900 + boundaryBonus - len * Tight fuzzy: 1500 - gapPenalty + earlyMatchBonus - len (gaps < prefix length) @@ -4311,6 +4314,21 @@ function computeMatchScore(candidate: string, prefix: string): number { return 2400 + exactInitialsBonus - c.length; } + // DataGrip-style pinyin initials: an ASCII query matches the first pinyin + // letters of Han characters — as a prefix ("zz" → 总租金) or as an ordered + // subsequence ("zj" → 总租金, "j" → 总租金). + if (/^[a-z0-9]+$/.test(p) && containsHan(c)) { + const pinyinInitials = pinyinFirstLetters(c); + if (pinyinInitials.startsWith(p)) { + const exactInitialsBonus = pinyinInitials === p ? 300 : 0; + return 2300 + exactInitialsBonus - c.length; + } + const subsequence = orderedSubsequenceSpan(pinyinInitials, p); + if (subsequence) { + return 1600 - subsequence.first * 30 - (subsequence.span - p.length) * 10 - c.length; + } + } + const substringIndex = c.indexOf(p); if (substringIndex >= 0) { const boundaryBonus = isIdentifierBoundary(candidate, substringIndex) ? 400 : Math.max(0, 180 - substringIndex * 12); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 2d2585b62d..34570c6332 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1,5 +1,6 @@ import { defineStore } from "pinia"; import { uuid } from "@/lib/common/utils"; +import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin"; import { ref, computed, watch, markRaw } from "vue"; import type { ColumnInfo, @@ -5649,6 +5650,15 @@ export const useConnectionStore = defineStore("connection", () => { const acronym = completionNameAcronym(table.name); if (acronym === normalized) return score + 7_100 - text.length; if (acronym.startsWith(normalized)) return score + 6_900 - text.length; + // DataGrip-style pinyin initials for Han names, e.g. "zzj" → 总租金, + // including ordered subsequences like "zj" → 总租金. + if (/^[a-z0-9]+$/.test(normalized) && containsHan(text)) { + const pinyinInitials = pinyinFirstLetters(text); + if (pinyinInitials === normalized) return score + 7_050 - text.length; + if (pinyinInitials.startsWith(normalized)) return score + 6_850 - text.length; + const subsequence = orderedSubsequenceSpan(pinyinInitials, normalized); + if (subsequence) return score + 5_000 - subsequence.first * 30 - subsequence.span * 10 - text.length; + } if (normalized.length <= segments.length && segments.every((segment, index) => segment.startsWith(normalized[index] ?? ""))) return score + 6_700 - text.length; if (text.includes(normalized)) return score + 4_000 - text.length; const subsequenceScore = orderedSubsequenceScore(text, normalized); @@ -6119,6 +6129,11 @@ export const useConnectionStore = defineStore("connection", () => { if (!filter) return true; const text = value.toLowerCase(); if (text.includes(filter)) return true; + // Pinyin initials, e.g. "zzj" or "zj" matches 总租金. + if (/^[a-z0-9]+$/.test(filter) && containsHan(text)) { + const pinyinInitials = pinyinFirstLetters(text); + if (pinyinInitials.startsWith(filter) || orderedSubsequenceSpan(pinyinInitials, filter)) return true; + } let index = 0; for (const ch of filter) { index = text.indexOf(ch, index); diff --git a/flake.nix b/flake.nix index b1b23ee676..30dd6c0566 100644 --- a/flake.nix +++ b/flake.nix @@ -197,7 +197,7 @@ fetcherVersion = 4; # Update with the hash reported by a failed fixed-output build: # nix build .#dbx-pnpm-deps 2>&1 | grep 'got:' - hash = "sha256-jRybq4tu642FYC6iOAJemPxxSy4A/qEtpUzwFCHGOMM="; + hash = "sha256-iFr+nYvhdFO6Y3fOs3tlhQL/10rgY3f2T1CbjnNZ3Nc="; }; # ── Step 2: vendor Cargo dependencies ───────────────────────────── # diff --git a/package.json b/package.json index 918f3b5f55..0e5f3e9478 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "marked": "^18.0.4", "pako": "^3.0.1", "pinia": "^3.0.0", + "pinyin-pro": "^3.28.2", "proj4": "^2.20.9", "reka-ui": "^2.10.1", "shadcn-vue": "^2.7.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0f71f90a89..2d1555747f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -164,6 +164,9 @@ importers: pinia: specifier: ^3.0.0 version: 3.0.4(typescript@6.0.3)(vue@3.5.35(typescript@6.0.3)) + pinyin-pro: + specifier: ^3.28.2 + version: 3.28.2 proj4: specifier: ^2.20.9 version: 2.20.9 @@ -3279,6 +3282,9 @@ packages: typescript: optional: true + pinyin-pro@3.28.2: + resolution: {integrity: sha512-jV38yxXHLfidirMC4hrXasLDozLCSq/4DfX88GnHcSEJ2+GpSedG6I9VOiEXJu6iQ5dbJC/RjmzyMuS5h/wH5A==} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -6864,6 +6870,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + pinyin-pro@3.28.2: {} + pkce-challenge@5.0.1: {} pkg-types@2.3.1: