fix(editor): normalize shortcut search separator spaces

This commit is contained in:
Abeautifulsnow
2026-10-12 14:38:52 +08:00
committed by GitHub
parent a7dac3434d
commit 6b293e9eaa
4 changed files with 119 additions and 3 deletions
@@ -222,7 +222,7 @@ import {
type ShortcutScope,
} from "@/lib/editor/shortcutRegistry";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
import { formatShortcutDisplay, normalizeShortcutSearch } from "@/lib/editor/shortcutDisplay";
import { COLUMN_NAME_COPY_SEPARATOR_LABELS, COLUMN_NAME_COPY_SEPARATOR_OPTIONS, isColumnNameCopySeparator, type ColumnNameCopySeparator } from "@/lib/dataGrid/dataGridColumnNameCopy";
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
import { normalizeExcludedCompletionItems } from "@/lib/editor/excludedCompletions";
@@ -2227,10 +2227,14 @@ const formatterEditorShortcutDefinitions = computed(() => formatterEditorShortcu
const filteredShortcutDefinitions = computed(() => {
const query = shortcutSearchQuery.value.trim().toLowerCase();
if (!query) return SHORTCUT_DEFINITIONS;
// #11632:组合键单独按“去空白”形式比较,用户写 Ctrl+T / Ctrl + T / ⌘T 都能命中。
// ID、功能名、作用域仍按原文匹配——把 query 整体去空白会让 "new query" 这类多词标签失配。
const shortcutQuery = normalizeShortcutSearch(shortcutSearchQuery.value);
return SHORTCUT_DEFINITIONS.filter((definition) => {
const scope = t(shortcutScopeLabelKey(definition.scope));
const shortcut = formatShortcutPill(editShortcuts.value[definition.id]);
return [definition.id, t(definition.labelKey), scope, shortcut].some((value) => value.toLowerCase().includes(query));
if (normalizeShortcutSearch(shortcut).includes(shortcutQuery)) return true;
return [definition.id, t(definition.labelKey), scope].some((value) => value.toLowerCase().includes(query));
});
});
@@ -21,6 +21,10 @@ vi.mock("vue-i18n", async () => {
// Interpolate params into the returned string so count assertions can read
// them (real vue-i18n would fold them into the message text).
const t = (key: string, params?: Record<string, unknown>) => {
// #11632: one label carries a space AND a word its action id does not ("tab"),
// so the shortcut-search spec can tell a label match apart from an id match.
// A whitespace-stripped query would strip the label too and lose this row.
if (key === "settings.shortcutNewQuery") return "New Query Tab";
if (!params) return key;
const suffix = Object.keys(params)
.sort()
@@ -271,6 +275,57 @@ describe("EditorSettingsDialog shortcut scope grouping (behaviour)", () => {
expect(contentFor(host, "editor").hasAttribute("inert")).toBe(true);
});
// #11632: the search box used to compare the raw query against the rendered
// pill, so only the exact "Ctrl + T" spelling matched — "Ctrl+T" fell through.
it("matches a shortcut however the user spaces or cases the separators", async () => {
hoisted.shortcuts = defaults();
const host = await mountShortcutsTab();
const search = shortcutsPane(host).querySelector<HTMLInputElement>('input[placeholder="settings.shortcutSearchPlaceholder"]')!;
const searchIds = async (value: string) => {
search.value = value;
search.dispatchEvent(new Event("input", { bubbles: true }));
await flushAsyncUpdates();
return rows(host).map((row) => row.querySelector<HTMLInputElement>("input[data-shortcut-input]")!.dataset.shortcutInput!);
};
// The pill is platform-dependent ("Ctrl + T" vs "⌘ T"), so read it off the
// rendered row instead of hardcoding a spelling.
const displayed = inputFor(host, "newQuery").value;
expect(displayed).not.toBe("");
const compact = displayed.replace(/\s+/g, "");
const matched = await searchIds(displayed);
expect(matched).toContain("newQuery");
if (!/mac/i.test(globalThis.navigator?.platform ?? "")) {
// The issue's own example: Ctrl+T is a prefix of Ctrl+Tab, so both rows
// must survive. (On macOS the modifiers differ: ⌘ T vs ⌃ Tab.)
expect(matched).toContain("tabSwitcher");
}
expect(await searchIds(compact)).toEqual(matched);
expect(await searchIds(compact.toUpperCase())).toEqual(matched);
expect(await searchIds(displayed.replace(/\s+/g, " "))).toEqual(matched);
// Still a substring match, not an equality match: dropping the key leaves a
// bare modifier prefix, which several actions share.
const prefixMatched = await searchIds(compact.slice(0, -1));
expect(prefixMatched.length).toBeGreaterThan(1);
expect(prefixMatched).toContain("newQuery");
// The untouched dimensions keep matching on their own terms: a two-word label
// still matches with its space, so only the shortcut field may be normalised.
// "tab" appears in the label but not in the id, so an id match cannot mask this.
expect(await searchIds("new query tab")).toContain("newQuery");
expect(await searchIds("NEW QUERY TAB")).toContain("newQuery");
// Whitespace-only input behaves as no search (the early return), not as
// "matches nothing".
const all = await searchIds("");
expect(all.length).toBeGreaterThan(matched.length);
expect(await searchIds(" ")).toEqual(all);
});
it("treats a same-scope duplicate as blocking: red pills, footer reason and a disabled Apply", async () => {
const base = defaults();
hoisted.shortcuts = base;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { formatShortcutDisplay, formatShortcutTooltip, shortcutDisplayKeys } from "@/lib/editor/shortcutDisplay";
import { formatShortcutDisplay, formatShortcutTooltip, normalizeShortcutSearch, shortcutDisplayKeys } from "@/lib/editor/shortcutDisplay";
describe("shortcut display", () => {
it("shows the default mouse modifier as Option on macOS and Alt elsewhere", () => {
@@ -63,3 +63,42 @@ describe("shortcut display", () => {
expect(formatShortcutTooltip("New Query", " ")).toBe("New Query");
});
});
// #11632: the shortcut search must not care how the user spaces the separators.
describe("shortcut search normalization", () => {
it("folds separator spaces and case on the Windows pill form", () => {
const displayed = formatShortcutDisplay("Mod+T", "Win32");
expect(displayed).toBe("Ctrl + T");
expect(normalizeShortcutSearch("Ctrl+T")).toBe(normalizeShortcutSearch(displayed));
expect(normalizeShortcutSearch("ctrl+t")).toBe(normalizeShortcutSearch(displayed));
expect(normalizeShortcutSearch(" CTRL + T ")).toBe(normalizeShortcutSearch(displayed));
});
it("folds the space-separated macOS pill form, which has no + at all", () => {
const displayed = formatShortcutDisplay("Mod+T", "MacIntel");
expect(displayed).toBe("⌘ T");
expect(normalizeShortcutSearch("⌘T")).toBe(normalizeShortcutSearch(displayed));
expect(normalizeShortcutSearch("⌘ T")).toBe(normalizeShortcutSearch(displayed));
});
it("normalizes the full-width plus and spaces a CJK IME produces", () => {
expect(normalizeShortcutSearch("Ctrl+T")).toBe("ctrl+t");
expect(normalizeShortcutSearch("Ctrl + T")).toBe("ctrl+t");
});
it("keeps the plus key distinguishable from its separators", () => {
expect(normalizeShortcutSearch(formatShortcutDisplay("Mod+Plus", "Win32"))).toBe("ctrl++");
expect(normalizeShortcutSearch("Ctrl++")).toBe("ctrl++");
});
it("keeps words separated, which is why it must stay off the label fields", () => {
// The call-site guard lives in the component spec ("…however the user spaces
// or cases the separators"), where a two-word label must still match.
expect(normalizeShortcutSearch("New Query")).toBe("newquery");
});
it("folds full-width letters, for CJK IME full-width mode", () => {
expect(normalizeShortcutSearch("ctrl+t")).toBe("ctrl+t");
expect(normalizeShortcutSearch("CTRL+T")).toBe("ctrl+t");
});
});
@@ -59,6 +59,24 @@ export function canonicalShortcutKey(shortcut: string): string {
.toLowerCase();
}
/**
* #11632: 组合键搜索用的归一化形式——抹掉全部空白(`\s` 已含全角空格)并把全角
* + 折成半角,使 `Ctrl+T` / `Ctrl + T` / `⌘T` / `⌘ T` 归一到同一串。只作用于
* 组合键字段:功能名等多词文本必须保留空格,否则 "new query" 会因去空白而失配。
*
* 不复用 canonicalShortcutKey:它按 Win32 语义把 Mod 展开成 Ctrl(macOS 的 ⌘ 会
* 被误判),而且 parseShortcutParts 不 trim,`"Ctrl + T"` 会原样留下内部空格。
*/
export function normalizeShortcutSearch(value: string): string {
// 全角 A-Z / a-z / + 等(码点 U+FF01–U+FF5E,与 ASCII 相差 0xFEE0)一并折成
// 半角:中文输入法全角状态下打出的 ctrl+t 也要能命中。该折叠已覆盖独立
// 处理全角 + 的做法;全角空格由下面的 \s 处理(\s 含 U+3000)。
return value
.replace(/[!-~]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0xfee0))
.toLowerCase()
.replace(/\s+/g, "");
}
export function shortcutDisplayStrokes(shortcut?: string, platform = globalThis.navigator?.platform || ""): string[][] {
return parseShortcutStrokes(shortcut).map((parts) => shortcutDisplayOrder(parts, platform));
}