mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
fix(editor): normalize shortcut search separator spaces
This commit is contained in:
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+55
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user