feat(editor): group shortcuts by scope with conflict tiers

Closes #9075
This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-16 21:36:55 +08:00
1 parent b2d56625e0
commit ea1de14517
17 files changed
+970 -80

No files matched your search

@@ -13,16 +13,19 @@ import {
ChevronUp,
CircleHelp,
Cloud,
Code,
Copy,
Download,
ExternalLink,
Eye,
Filter,
Globe,
GripVertical,
Loader2,
Moon,
PackageSearch,
Palette,
PanelLeft,
Pencil,
Plus,
RefreshCw,
@@ -32,6 +35,7 @@ import {
Sun,
Star,
SunMoon,
Table,
Terminal,
Trash2,
Upload,
@@ -167,7 +171,8 @@ import {
type WebDavConfig,
} from "@/lib/backend/api";
import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keyboardShortcuts";
import { SHORTCUT_DEFINITIONS, findShortcutConflict, isReservedShortcut, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
import { SHORTCUT_DEFINITIONS, countShortcutConflictPairs, findCrossScopeShortcutConflicts, findShortcutConflict, isReservedShortcut, normalizeShortcutSettings, type ShortcutActionId, type ShortcutDefinition, type ShortcutScope } from "@/lib/editor/shortcutRegistry";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { formatShortcutDisplay } 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";
@@ -1544,12 +1549,24 @@ watch(
{ deep: true },
);
const shortcutConflicts = computed(() =>
SHORTCUT_DEFINITIONS.flatMap((definition) => {
// 行 → 同作用域冲突对象(必顶阻断)。保留 map 形态是为了能算“重复对数”:
// 行级列表会把同一对重复计两次(双向各一次),而摘要文案说的是“几组重复”。
const shortcutConflictMap = computed(() => {
const conflicts: Partial<Record<ShortcutActionId, ShortcutActionId>> = {};
for (const definition of SHORTCUT_DEFINITIONS) {
const conflict = findShortcutConflict(definition.id, editShortcuts.value[definition.id], editShortcuts.value);
return conflict ? [definition.id] : [];
}),
);
if (conflict) conflicts[definition.id] = conflict;
}
return conflicts;
});
const shortcutConflicts = computed(() => Object.keys(shortcutConflictMap.value) as ShortcutActionId[]);
// 行 → 跨作用域同键对象(**仅提示**,不进应用门禁)。不同作用域共用组合是
// 有意的设计(find / focusSearch 默认都是 Mod+F,运行时按焦点路由),所以这里
// 只用于展示;normalizeShortcutSettings 的占用判定依旧只看同作用域。
const crossScopeShortcutConflicts = computed(() => findCrossScopeShortcutConflicts(editShortcuts.value));
const crossScopeShortcutConflictIds = computed(() => Object.keys(crossScopeShortcutConflicts.value) as ShortcutActionId[]);
const shortcutConflictPairCount = computed(() => countShortcutConflictPairs(shortcutConflictMap.value));
const crossScopeShortcutPairCount = computed(() => countShortcutConflictPairs(crossScopeShortcutConflicts.value));
const sqlShortcutConflicts = computed(() => findSqlShortcutConflicts(editSqlShortcuts.value, editShortcuts.value));
const hasSqlShortcutConflicts = computed(() => sqlShortcutConflicts.value.length > 0);
const shortcutSearchQuery = ref("");
@@ -1582,11 +1599,103 @@ const filteredShortcutDefinitions = computed(() => {
const query = shortcutSearchQuery.value.trim().toLowerCase();
if (!query) return SHORTCUT_DEFINITIONS;
return SHORTCUT_DEFINITIONS.filter((definition) => {
const scope = t(`settings.shortcutScope${definition.scope[0].toUpperCase()}${definition.scope.slice(1)}`);
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));
});
});
// 二级归类:快捷键页签按作用域分组展示。顺序即运行时优先级——越外层先响应,
// 用户读到的顺序与事件实际分发顺序一致。
const SHORTCUT_SCOPE_ORDER: readonly ShortcutScope[] = ["global", "editor", "grid", "search", "sidebar"];
function shortcutScopeLabelKey(scope: ShortcutScope): string {
return `settings.shortcutScope${scope[0].toUpperCase()}${scope.slice(1)}`;
}
function shortcutScopeHintKey(scope: ShortcutScope): string {
return `settings.shortcutScopeHint${scope[0].toUpperCase()}${scope.slice(1)}`;
}
function shortcutScopeIcon(scope: ShortcutScope) {
switch (scope) {
case "global":
return Globe;
case "editor":
return Code;
case "grid":
return Table;
case "search":
return Search;
default:
return PanelLeft;
}
}
interface ShortcutScopeGroup {
scope: ShortcutScope;
label: string;
hint: string;
definitions: ShortcutDefinition[];
unboundCount: number;
conflictCount: number;
crossScopeCount: number;
}
// 计数一律基于“当前可见行”(受搜索过滤影响):组头是它下方那份列表的摘要,
// 拿全局总数会导致搜索时组头数目与实际行数对不上。
const shortcutScopeGroups = computed<ShortcutScopeGroup[]>(() =>
SHORTCUT_SCOPE_ORDER.map((scope) => {
const definitions = filteredShortcutDefinitions.value.filter((definition) => definition.scope === scope);
return {
scope,
label: t(shortcutScopeLabelKey(scope)),
hint: t(shortcutScopeHintKey(scope)),
definitions,
unboundCount: definitions.filter((definition) => !editShortcuts.value[definition.id]).length,
conflictCount: definitions.filter((definition) => shortcutConflictMap.value[definition.id]).length,
crossScopeCount: definitions.filter((definition) => (crossScopeShortcutConflicts.value[definition.id] ?? []).length > 0).length,
};
}).filter((group) => group.definitions.length > 0),
);
function isShortcutModified(definition: ShortcutDefinition): boolean {
return editShortcuts.value[definition.id] !== definition.defaultShortcut;
}
function shortcutDefinitionById(actionId: ShortcutActionId): ShortcutDefinition | undefined {
return SHORTCUT_DEFINITIONS.find((definition) => definition.id === actionId);
}
// 同作用域冲突的浮层文案:说清“与谁重复”与“为什么必须改”。
function shortcutConflictTooltipText(definition: ShortcutDefinition): string {
const partnerId = shortcutConflictMap.value[definition.id];
const partner = partnerId ? shortcutDefinitionById(partnerId) : undefined;
if (!partner) return "";
return t("settings.shortcutConflictTooltip", { label: t(partner.labelKey), scope: t(shortcutScopeLabelKey(partner.scope)) });
}
// 跨作用域同键的浮层文案:列全对方动作及其作用域,并说明“无需处理”。
function shortcutCrossScopeTooltipText(definition: ShortcutDefinition): string {
const others = crossScopeShortcutConflicts.value[definition.id] ?? [];
const targets = others
.map((actionId) => shortcutDefinitionById(actionId))
.filter((partner): partner is ShortcutDefinition => !!partner)
.map((partner) => `${t(shortcutScopeLabelKey(partner.scope))} · ${t(partner.labelKey)}`)
.join(t("settings.shortcutCrossScopeTooltipSeparator"));
if (!targets) return "";
return t("settings.shortcutCrossScopeTooltip", { targets });
}
function shortcutHasCrossScopeConflict(definition: ShortcutDefinition): boolean {
return (crossScopeShortcutConflicts.value[definition.id] ?? []).length > 0;
}
// 行级浮层的唯一入口:优先讲阻断性冲突(同作用域),其次才是跨作用域提示。
// 返回空串时 LightTooltip 不展示(hasContent 为假),无需额外开关。
function shortcutConflictHintText(definition: ShortcutDefinition): string {
return shortcutConflictTooltipText(definition) || shortcutCrossScopeTooltipText(definition);
}
const hasShortcutConflicts = computed(() => shortcutConflicts.value.length > 0);
const shortcutsChanged = computed(() => JSON.stringify(editShortcuts.value) !== JSON.stringify(editEditorSettingsBase.value.shortcuts));
const sqlShortcutsChanged = computed(() => JSON.stringify(editSqlShortcuts.value) !== JSON.stringify(editEditorSettingsBase.value.sqlShortcuts));
@@ -7554,81 +7663,131 @@ onUnmounted(() => {
</section>
<section v-else-if="activeSettingsTab === 'shortcuts'" data-settings-search-id="shortcuts" :class="['flex flex-col gap-2 py-2', settingsSearchTargetClass('shortcuts')]">
<div class="relative">
<Search class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="shortcutSearchQuery" autocomplete="off" :placeholder="t('settings.shortcutSearchPlaceholder')" class="h-9 pl-9 text-sm" />
<div class="flex items-center gap-2">
<div class="relative min-w-0 flex-1">
<Search class="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="shortcutSearchQuery" autocomplete="off" :placeholder="t('settings.shortcutSearchPlaceholder')" class="h-9 pl-9 text-sm" />
</div>
<!-- 冲突摘只需计数,明细进浮层:不再用整条横幅占掉列表高度。 -->
<LightTooltip v-if="shortcutConflicts.length > 0" :text="t('settings.shortcutConflictSummaryTooltip', { count: shortcutConflicts.length, pairs: shortcutConflictPairCount })">
<span class="inline-flex h-9 shrink-0 cursor-help items-center gap-1.5 rounded-md border border-destructive/25 bg-destructive/10 px-2 text-[11.5px] font-medium tabular-nums text-destructive">
<span class="size-1.5 rounded-full bg-current" aria-hidden="true" />
{{ t("settings.shortcutConflictBadge", { count: shortcutConflicts.length }) }}
</span>
</LightTooltip>
<LightTooltip v-if="crossScopeShortcutConflictIds.length > 0" :text="t('settings.shortcutCrossScopeSummaryTooltip', { count: crossScopeShortcutConflictIds.length, pairs: crossScopeShortcutPairCount })">
<span class="inline-flex h-9 shrink-0 cursor-help items-center gap-1.5 rounded-md border border-warning/30 bg-warning/10 px-2 text-[11.5px] font-medium tabular-nums text-warning">
<span class="size-1.5 rounded-full bg-current" aria-hidden="true" />
{{ t("settings.shortcutCrossScopeBadge", { count: crossScopeShortcutConflictIds.length }) }}
</span>
</LightTooltip>
</div>
<div class="overflow-hidden rounded-md border border-border/70 bg-background">
<div v-if="filteredShortcutDefinitions.length === 0" class="px-3 py-8 text-center text-sm text-muted-foreground">
{{ t("settings.shortcutSearchNoResults") }}
</div>
<div v-for="definition in filteredShortcutDefinitions" :key="definition.id" class="settings-shortcut-row group -mt-px grid gap-2 border-t border-border/70 px-3 py-2 transition-colors first:mt-0 first:border-t-0 hover:bg-muted/40 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div class="settings-shortcut-label min-w-0">
<div class="flex min-w-0 items-center gap-2">
<Label class="min-w-0 truncate leading-none">{{ t(definition.labelKey) }}</Label>
<Badge variant="outline" class="h-5 shrink-0 rounded-md border-border/60 px-1.5 text-[11px] font-normal text-muted-foreground">
{{ t(`settings.shortcutScope${definition.scope[0].toUpperCase()}${definition.scope.slice(1)}`) }}
</Badge>
<div v-if="filteredShortcutDefinitions.length === 0" class="rounded-md border border-border/70 px-3 py-8 text-center text-sm text-muted-foreground">
{{ t("settings.shortcutSearchNoResults") }}
</div>
<!-- 二级归类:按作用域分组,组头吸顶。顺序即运行时优先级(越外层越先响应)。
分组容器刻意不用 overflow-hidden —— 它会成为嵌套滚动容器,使组头吸顶失效;
圆角改由组头(上)与末行(下)分别承担。 -->
<div v-else class="flex flex-col gap-2">
<section v-for="group in shortcutScopeGroups" :key="group.scope" class="rounded-md border border-border/70 bg-background">
<header class="sticky top-0 z-10 flex items-center gap-2 rounded-t-md border-b border-border/70 bg-popover px-3 py-2">
<component :is="shortcutScopeIcon(group.scope)" class="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
<h3 class="shrink-0 text-[13px] leading-none font-semibold">{{ group.label }}</h3>
<span class="shrink-0 rounded-sm border border-border/80 px-1 font-mono text-[10px] leading-4 text-muted-foreground/75">{{ group.scope }}</span>
<span class="shrink-0 text-[11px] tabular-nums text-muted-foreground">{{ t("settings.shortcutGroupCount", { count: group.definitions.length }) }}</span>
<LightTooltip v-if="group.unboundCount > 0" :text="t('settings.shortcutGroupUnboundTooltip')">
<span class="shrink-0 cursor-help text-[11px] tabular-nums text-muted-foreground/70">
{{ t("settings.shortcutGroupUnbound", { count: group.unboundCount }) }}
</span>
</LightTooltip>
<LightTooltip v-if="group.conflictCount > 0" :text="t('settings.shortcutConflictSummaryTooltip', { count: group.conflictCount, pairs: shortcutConflictPairCount })">
<span class="inline-flex shrink-0 cursor-help items-center gap-1 text-[11px] font-medium tabular-nums text-destructive"> <span class="size-[5px] rounded-full bg-current" aria-hidden="true" />{{ group.conflictCount }} </span>
</LightTooltip>
<LightTooltip v-if="group.crossScopeCount > 0" :text="t('settings.shortcutCrossScopeSummaryTooltip', { count: group.crossScopeCount, pairs: crossScopeShortcutPairCount })">
<span class="inline-flex shrink-0 cursor-help items-center gap-1 text-[11px] font-medium tabular-nums text-warning"> <span class="size-[5px] rounded-full bg-current" aria-hidden="true" />{{ group.crossScopeCount }} </span>
</LightTooltip>
<span class="ml-auto hidden truncate text-[11px] text-muted-foreground xl:block">{{ group.hint }}</span>
</header>
<div
v-for="(definition, index) in group.definitions"
:key="definition.id"
class="settings-shortcut-row group grid gap-2 border-t border-border/70 px-3 py-2 transition-colors hover:bg-muted/40 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"
:class="index === group.definitions.length - 1 ? 'rounded-b-md' : ''"
:data-conflict="shortcutConflictMap[definition.id] ? 'true' : undefined"
:data-cross-scope="shortcutHasCrossScopeConflict(definition) ? 'true' : undefined"
>
<div class="settings-shortcut-label min-w-0">
<div class="flex min-w-0 items-center gap-2">
<Label class="min-w-0 truncate leading-none">{{ t(definition.labelKey) }}</Label>
<!-- scope 已由分组标题承载,行内不再重复散章 -->
<LightTooltip v-if="isShortcutModified(definition)" :text="t('settings.shortcutModifiedTagTooltip')">
<span class="shrink-0 cursor-help rounded-sm border border-border/90 px-1 text-[10px] leading-4 text-muted-foreground">
{{ t("settings.shortcutModifiedTag") }}
</span>
</LightTooltip>
</div>
</div>
<div class="settings-shortcut-actions min-w-0 text-right">
<div class="settings-shortcut-controls flex items-center justify-end gap-1.5">
<!-- 冲突解释改为悬停才出现:默认只留胶囊颜色这一条定位线索。 -->
<LightTooltip side="left" :disabled="editingShortcutId === definition.id" :text="shortcutConflictHintText(definition)" content-class="max-w-[320px]">
<input
:data-shortcut-input="definition.id"
:value="editingShortcutId === definition.id ? '' : formatShortcutPill(editShortcuts[definition.id])"
:style="{
width: editingShortcutId === definition.id ? shortcutPressShortcutInputWidth : `${Math.max(4, formatShortcutPill(editShortcuts[definition.id]).length + 3)}ch`,
}"
readonly
:aria-invalid="shortcutConflicts.includes(definition.id)"
:placeholder="t('settings.shortcutPressShortcut')"
class="settings-shortcut-pill h-7 w-auto min-w-12 max-w-64 shrink-0 cursor-default rounded-[6px] border border-transparent bg-muted px-2.5 text-center font-mono text-[13px] font-semibold text-foreground/75 shadow-inner outline-none selection:bg-transparent placeholder:text-muted-foreground aria-invalid:border-destructive/55 aria-invalid:text-destructive"
:class="editingShortcutId === definition.id ? 'max-w-64 cursor-text border-border/80 bg-background text-left text-foreground shadow-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35' : ''"
@keydown="(event: KeyboardEvent) => onShortcutKeydown(definition.id, event)"
/>
</LightTooltip>
<Button
v-if="editingShortcutId !== definition.id"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button settings-shortcut-action-button--fix h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.shortcutPressShortcut')"
@click="focusShortcutInput(definition.id)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button v-else type="button" variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-sm font-medium text-muted-foreground hover:text-foreground" @click="cancelShortcutEdit">
{{ t("settings.cancel") }}
</Button>
<Button
v-if="editingShortcutId !== definition.id"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button settings-shortcut-action-button--fix h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.reset')"
@click="resetShortcut(definition.id)"
>
<RotateCcw class="h-4 w-4" />
</Button>
<Button
v-if="editingShortcutId !== definition.id && editShortcuts[definition.id]"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.shortcutClear')"
@click="clearShortcut(definition.id)"
>
<X class="h-4 w-4" />
</Button>
<span v-else-if="editingShortcutId !== definition.id" class="h-7 w-7 shrink-0" aria-hidden="true" />
</div>
</div>
</div>
<div class="settings-shortcut-actions min-w-0 space-y-1 text-right">
<div class="settings-shortcut-controls flex items-center justify-end gap-1.5">
<input
:data-shortcut-input="definition.id"
:value="editingShortcutId === definition.id ? '' : formatShortcutPill(editShortcuts[definition.id])"
:style="{
width: editingShortcutId === definition.id ? shortcutPressShortcutInputWidth : `${Math.max(4, formatShortcutPill(editShortcuts[definition.id]).length + 3)}ch`,
}"
readonly
:aria-invalid="shortcutConflicts.includes(definition.id)"
:placeholder="t('settings.shortcutPressShortcut')"
class="h-7 w-auto min-w-12 max-w-64 shrink-0 cursor-default rounded-[6px] border border-transparent bg-muted px-2.5 text-center font-mono text-[13px] font-semibold text-foreground/75 shadow-inner outline-none selection:bg-transparent placeholder:text-muted-foreground aria-invalid:border-destructive/70 aria-invalid:text-destructive aria-invalid:ring-destructive/20"
:class="editingShortcutId === definition.id ? 'max-w-64 cursor-text border-border/80 bg-background text-left text-foreground shadow-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/35' : ''"
@keydown="(event: KeyboardEvent) => onShortcutKeydown(definition.id, event)"
/>
<Button
v-if="editingShortcutId !== definition.id"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.shortcutPressShortcut')"
@click="focusShortcutInput(definition.id)"
>
<Pencil class="h-4 w-4" />
</Button>
<Button v-else type="button" variant="ghost" size="sm" class="h-7 shrink-0 px-2 text-sm font-medium text-muted-foreground hover:text-foreground" @click="cancelShortcutEdit">
{{ t("settings.cancel") }}
</Button>
<Button
v-if="editingShortcutId !== definition.id"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.reset')"
@click="resetShortcut(definition.id)"
>
<RotateCcw class="h-4 w-4" />
</Button>
<Button
v-if="editingShortcutId !== definition.id && editShortcuts[definition.id]"
type="button"
variant="ghost"
size="icon"
class="settings-shortcut-action-button h-7 w-7 shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
:aria-label="t('settings.shortcutClear')"
@click="clearShortcut(definition.id)"
>
<X class="h-4 w-4" />
</Button>
<span v-else-if="editingShortcutId !== definition.id" class="h-7 w-7 shrink-0" aria-hidden="true" />
</div>
<p v-if="shortcutConflicts.includes(definition.id)" class="text-xs text-destructive">
{{ t("settings.shortcutConflict") }}
</p>
</div>
</div>
</section>
</div>
<div class="mt-6 border-t border-border/70 pt-6" data-settings-search-id="sql-shortcuts">
@@ -9569,6 +9728,11 @@ LIMIT 100;</pre
<Button variant="outline" @click="resetDefaultsForTab(activeSettingsTab as SettingsCategory)">
{{ t("settings.resetDefaults") }}
</Button>
<!-- 行内冲突说明收进浮层后,“应用为何被禁用”需要一处常驻解释。 -->
<span v-if="hasBlockingShortcutConflicts" class="inline-flex items-center gap-1.5 text-xs text-destructive">
<AlertTriangle class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
{{ t("settings.shortcutConflictBlocksApply", { count: shortcutConflicts.length, pairs: shortcutConflictPairCount }) }}
</span>
<div class="flex-1" />
<Button variant="outline" @click="closeSettings">
{{ t("common.close") }}
@@ -9945,6 +10109,28 @@ LIMIT 100;</pre
background: color-mix(in oklab, var(--muted-foreground) 38%, transparent);
}
/*
* 快捷键页签的分组与冲突呈现。
*
* 跨作用域同键只给一条细琥珀边作为定位线索(不做光晕):不同作用域共用组合是
* 有意的设计(find / focusSearch 默认都是 Mod+F),把它渲染成错误会让用户去改
* 本来无需处理的键;光晕留给悬停,避免十几行同时发光。
*
* 写在这里而不是用 Tailwind 的 border-warning/45,是因为基类里的
* border-transparent 与它同为单类选择器,谁生效取决于样式表顺序而不是 class
* 书写顺序;aria-invalid 那条能工作是因为变体在生成顺序上排在基类之后。
* `:not([aria-invalid="true"])` 保证阻断性冲突(红)优先于提示(琥珀)。
*/
.settings-shortcut-row[data-cross-scope="true"] .settings-shortcut-pill:not([aria-invalid="true"]) {
border-color: color-mix(in srgb, var(--warning) 45%, transparent);
}
/* 阻断性冲突行常显“改键 / 恢复默认”两个修复入口;清除按钮与普通行一致随悬停出现,
不在有问题的行上堆叠常驻控件。 */
.settings-shortcut-row[data-conflict="true"] .settings-shortcut-action-button--fix {
opacity: 1;
}
html.dbx-legacy-webview .settings-shortcut-row:hover .settings-shortcut-action-button,
html.dbx-legacy-webview .settings-shortcut-row:focus-within .settings-shortcut-action-button {
opacity: 1 !important;
@@ -0,0 +1,295 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createPinia } from "pinia";
import { DEFAULT_EDITOR_SETTINGS } from "@/stores/settingsStore";
import { SHORTCUT_DEFINITIONS } from "@/lib/editor/shortcutRegistry";
// Behaviour proof for the scope-grouping / two-tier conflict work, on top of the
// source-contract spec (EditorSettingsDialog.shortcutScopeGroups.spec.ts). The
// load-bearing claim is the tier split:
// · L1 same-scope duplicate -> blocking (red pill, footer reason, Apply gate)
// · L2 cross-scope overlap -> informational only (amber pill, never blocking)
// The source spec pins the wiring; this one drives the real rendered controls so
// a wrong tier can't hide behind a passing string assertion.
const hoisted = vi.hoisted(() => ({ shortcuts: {} as Record<string, string> }));
vi.mock("vue-i18n", async () => {
const { ref } = await import("vue");
const locale = ref("en");
const t = (key: string) => key;
return {
createI18n: () => ({ global: { t, locale }, install: () => undefined }),
useI18n: () => ({ t, locale }),
};
});
vi.mock("@/components/ui/dialog", async () => {
const { defineComponent, h } = await import("vue");
const passthrough = defineComponent({
setup(_props, { slots }) {
return () => h("div", slots.default?.());
},
});
return {
Dialog: passthrough,
DialogContent: passthrough,
DialogFooter: passthrough,
DialogHeader: passthrough,
DialogTitle: passthrough,
};
});
vi.mock("@/components/ui/button", async () => {
const { defineComponent, h } = await import("vue");
return {
Button: defineComponent({
inheritAttrs: false,
setup(props, { attrs, slots }) {
return () => h("button", { ...attrs, disabled: (props as { disabled?: boolean }).disabled ? true : undefined }, slots.default?.());
},
props: { disabled: { type: Boolean, default: false } },
}),
};
});
// ChangelogPanel fires a network fetch on mount (About tab); irrelevant here.
vi.mock("@/components/settings/ChangelogPanel.vue", async () => {
const { defineComponent } = await import("vue");
return { default: defineComponent({ setup: () => () => null }) };
});
// Back the store composable with a stub so the dialog boots without the Tauri
// backend, but read `shortcuts` through the hoisted object so each test can seed
// the configuration it wants before mounting.
vi.mock("@/stores/settingsStore", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/stores/settingsStore")>();
const store = {
settingsPageActive: false,
editorSettings: {
...actual.DEFAULT_EDITOR_SETTINGS,
get shortcuts() {
return hoisted.shortcuts;
},
},
desktopSettings: actual.DEFAULT_DESKTOP_SETTINGS,
mcpGlobalPolicy: { configured: false, readOnly: false },
aiConfigs: [],
aiDefaultTemplatesByDbType: {},
defaultAiMode: "ask",
restoreLastConversation: false,
initMcpGlobalPolicy: vi.fn(async () => undefined),
updateMcpGlobalPolicy: vi.fn(async () => undefined),
initAiConfigs: vi.fn(async () => undefined),
initDesktopSettings: vi.fn(async () => undefined),
updateEditorSettingsAndPersist: vi.fn(async () => undefined),
updateEditorSettings: vi.fn(),
persistEditorSettings: vi.fn(async () => undefined),
updateDesktopSettings: vi.fn(async () => undefined),
reloadAiConfigs: vi.fn(async () => undefined),
removeTemplateFromDefaultAndLastUsed: vi.fn(),
setDefaultTemplatesForDbType: vi.fn(),
updateAiConfigItem: vi.fn(),
createAiConfig: vi.fn(),
deleteAiConfig: vi.fn(),
setDefaultAiConfig: vi.fn(),
setDefaultAiMode: vi.fn(),
setRestoreLastConversation: vi.fn(),
};
return { ...actual, useSettingsStore: () => store };
});
import EditorSettingsDialog from "../EditorSettingsDialog.vue";
if (!("ResizeObserver" in globalThis)) {
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
}
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
afterEach(() => {
for (const { app, host } of mountedApps.splice(0)) {
app.unmount();
host.remove();
}
});
async function flushAsyncUpdates() {
await nextTick();
await Promise.resolve();
await nextTick();
}
function defaults() {
return { ...DEFAULT_EDITOR_SETTINGS.shortcuts };
}
async function mountShortcutsTab() {
const host = document.createElement("div");
document.body.append(host);
const app = createApp(defineComponent({ setup: () => () => h(EditorSettingsDialog, { variant: "page", initialTab: "shortcuts" }) }));
app.use(createPinia());
app.mount(host);
mountedApps.push({ app, host });
await flushAsyncUpdates();
return host;
}
const shortcutsPane = (host: HTMLElement) => host.querySelector<HTMLElement>('[data-settings-search-id="shortcuts"]')!;
// Scope groups are the only sections whose own header carries an h3 title; the
// pane also hosts the SQL-shortcuts block, which must not join the group list.
const groupSections = (host: HTMLElement) => [...shortcutsPane(host).querySelectorAll<HTMLElement>("section")].filter((section) => [...section.children].some((child) => child.tagName === "HEADER"));
const groupLabels = (host: HTMLElement) => groupSections(host).map((section) => section.querySelector("h3")!.textContent!.trim());
const rows = (host: HTMLElement) => [...shortcutsPane(host).querySelectorAll<HTMLElement>(".settings-shortcut-row")];
const inputFor = (host: HTMLElement, id: string) => shortcutsPane(host).querySelector<HTMLInputElement>(`input[data-shortcut-input="${id}"]`)!;
const rowFor = (host: HTMLElement, id: string) => inputFor(host, id).closest<HTMLElement>(".settings-shortcut-row")!;
function button(host: HTMLElement, text: string) {
return [...host.querySelectorAll<HTMLButtonElement>("button")].find((item) => item.textContent?.trim() === text) ?? null;
}
const applyButton = (host: HTMLElement) => button(host, "settings.apply")!;
// Turn a stored shortcut string ("Ctrl+Shift+F" / "Shift+Mod+F") into the keydown
// a real capture input would receive, resolving `Mod` for the host platform.
function shortcutKeyEvent(shortcut: string): KeyboardEvent {
const parts = shortcut
.split("+")
.map((part) => part.trim())
.filter(Boolean);
const key = parts.pop() ?? "";
const isMac = /mac/i.test(globalThis.navigator?.platform ?? "");
const init: KeyboardEventInit = { key: key.length === 1 ? key.toLowerCase() : key, bubbles: true };
for (const part of parts) {
if (/^(ctrl|control)$/i.test(part)) init.ctrlKey = true;
else if (/^shift$/i.test(part)) init.shiftKey = true;
else if (/^(alt|option)$/i.test(part)) init.altKey = true;
else if (/^meta$/i.test(part)) init.metaKey = true;
else if (/^mod$/i.test(part)) {
if (isMac) init.metaKey = true;
else init.ctrlKey = true;
}
}
return new KeyboardEvent("keydown", init);
}
// The footer keeps a standing explanation of a blocked Apply now that the row
// no longer carries inline conflict text.
const hasFooterBlockerReason = (host: HTMLElement) => (host.textContent ?? "").includes("settings.shortcutConflictBlocksApply");
describe("EditorSettingsDialog shortcut scope grouping (behaviour)", () => {
it("renders one group per scope in runtime dispatch order, without repeating the scope on rows", async () => {
hoisted.shortcuts = defaults();
const host = await mountShortcutsTab();
expect(groupLabels(host)).toEqual(["settings.shortcutScopeGlobal", "settings.shortcutScopeEditor", "settings.shortcutScopeGrid", "settings.shortcutScopeSearch", "settings.shortcutScopeSidebar"]);
// Scope moved into the group header — rows must not repeat it.
const rendered = rows(host);
expect(rendered.length).toBeGreaterThan(0);
for (const row of rendered) {
expect(row.textContent).not.toContain("settings.shortcutScope");
}
});
it("treats a same-scope duplicate as blocking: red pills, footer reason and a disabled Apply", async () => {
const base = defaults();
hoisted.shortcuts = base;
const host = await mountShortcutsTab();
// Pristine draft: nothing to block yet.
expect(hasFooterBlockerReason(host)).toBe(false);
// explainSql and formatSql both live in the editor scope. Capture the chord
// formatSql already owns through the real capture input.
rowFor(host, "explainSql").querySelector<HTMLButtonElement>("button")!.click();
await flushAsyncUpdates();
inputFor(host, "explainSql").dispatchEvent(shortcutKeyEvent(base.formatSql));
await flushAsyncUpdates();
expect(inputFor(host, "explainSql").getAttribute("aria-invalid")).toBe("true");
expect(inputFor(host, "formatSql").getAttribute("aria-invalid")).toBe("true");
expect(rowFor(host, "formatSql").dataset.conflict).toBe("true");
expect(rowFor(host, "explainSql").dataset.conflict).toBe("true");
// The row no longer carries inline text, so the footer has to explain why
// Apply is unavailable.
expect(hasFooterBlockerReason(host)).toBe(true);
expect(applyButton(host).disabled).toBe(true);
});
it("keeps a cross-scope overlap informational: amber tier renders, Apply stays reachable", async () => {
hoisted.shortcuts = defaults();
const host = await mountShortcutsTab();
// `find` (editor) and `focusSearch` (global) share Mod+F by design.
const crossScopeRows = shortcutsPane(host).querySelectorAll('.settings-shortcut-row[data-cross-scope="true"]');
expect(crossScopeRows.length).toBeGreaterThan(0);
expect(shortcutsPane(host).querySelectorAll('.settings-shortcut-row[data-conflict="true"]').length).toBe(0);
expect(hasFooterBlockerReason(host)).toBe(false);
// Drive a real rebind through the rendered controls (capture state + keydown):
// any change makes Apply reachable, and a chord that collides with no other
// action in the same scope must NOT be blocked by the cross-scope tier.
const taken = new Set(Object.values(defaults()));
const candidate = ["Ctrl+Alt+Shift+1", "Ctrl+Alt+Shift+2", "Ctrl+Alt+Shift+3"].find((key) => !taken.has(key))!;
const row = rowFor(host, "executeSql");
row.querySelector<HTMLButtonElement>("button")!.click();
await flushAsyncUpdates();
const capture = inputFor(host, "executeSql");
expect(capture.value).toBe("");
capture.dispatchEvent(new KeyboardEvent("keydown", { key: candidate.slice(-1), ctrlKey: true, altKey: true, shiftKey: true, bubbles: true }));
await flushAsyncUpdates();
expect(inputFor(host, "executeSql").value).not.toBe("");
expect(shortcutsPane(host).querySelectorAll('.settings-shortcut-row[data-conflict="true"]').length).toBe(0);
expect(hasFooterBlockerReason(host)).toBe(false);
expect(applyButton(host).disabled).toBe(false);
});
it("drops groups that have no row left after a search, so header counts cannot disagree", async () => {
hoisted.shortcuts = defaults();
const host = await mountShortcutsTab();
const search = shortcutsPane(host).querySelector<HTMLInputElement>('input[placeholder="settings.shortcutSearchPlaceholder"]')!;
search.value = "shortcutFormatSql";
search.dispatchEvent(new Event("input", { bubbles: true }));
await flushAsyncUpdates();
const sections = groupSections(host);
expect(sections.length).toBe(1);
expect(sections[0].querySelectorAll(".settings-shortcut-row").length).toBe(1);
expect(groupLabels(host)).toEqual(["settings.shortcutScopeEditor"]);
});
it("keeps the scope order aligned with the registry scopes", async () => {
hoisted.shortcuts = defaults();
const host = await mountShortcutsTab();
const scopes = new Set(SHORTCUT_DEFINITIONS.map((definition) => definition.scope));
const rendered = groupLabels(host).map((label) => {
const raw = label.replace("settings.shortcutScope", "");
return raw.charAt(0).toLowerCase() + raw.slice(1);
});
// Only scopes that actually have actions render, but whatever renders must be
// a sub-sequence of the dispatch order (global -> editor -> grid -> search -> sidebar).
const dispatchOrder = ["global", "editor", "grid", "search", "sidebar"];
for (const scope of rendered) expect(scopes).toContain(scope);
const positions = rendered.map((scope) => dispatchOrder.indexOf(scope));
expect(positions).toEqual([...positions].sort((a, b) => a - b));
expect(positions.every((index) => index >= 0)).toBe(true);
});
});
@@ -0,0 +1,61 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
// The shortcuts tab groups its 78 actions by scope (global → editor → grid →
// search → sidebar) and reports conflicts on two levels:
// · same-scope duplicates → blocking (Apply stays disabled, pill turns red)
// · cross-scope same chord → informational only (thin amber pill border)
// These assertions pin the load-bearing parts of that split: cross-scope
// overlaps must never reach the apply gate, the group order must keep matching
// the runtime dispatch order, and the L1 red must win over the L2 amber.
describe("EditorSettingsDialog shortcut scope grouping", () => {
const shortcutsTab = dialogSource.slice(dialogSource.indexOf("activeSettingsTab === 'shortcuts'"), dialogSource.indexOf('data-settings-search-id="sql-shortcuts"'));
it("groups the shortcuts tab by scope and drops the per-row scope badge", () => {
expect(shortcutsTab).toContain('v-for="group in shortcutScopeGroups"');
expect(shortcutsTab).toContain(':is="shortcutScopeIcon(group.scope)"');
// The scope chip moved into the group header; rows must not repeat it
// (the template literal lookup used by the old row badge is gone).
expect(dialogSource).not.toContain("shortcutScope${definition.scope");
});
it("keeps the group order equal to the runtime dispatch order", () => {
const order = dialogSource.slice(dialogSource.indexOf("const SHORTCUT_SCOPE_ORDER"), dialogSource.indexOf("function shortcutScopeLabelKey"));
expect(order).toContain('["global", "editor", "grid", "search", "sidebar"]');
});
it("keeps same-scope conflicts blocking and cross-scope overlaps informational", () => {
// L1 comes from findShortcutConflict only…
const conflictMap = dialogSource.slice(dialogSource.indexOf("const shortcutConflictMap = computed"), dialogSource.indexOf("const shortcutConflicts = computed(() => Object.keys"));
expect(conflictMap).toContain("findShortcutConflict(");
expect(conflictMap).not.toContain("findCrossScopeShortcutConflicts");
// …and the apply gate must not learn about the cross-scope tier.
const blocker = dialogSource.slice(dialogSource.indexOf("const hasBlockingShortcutConflicts = computed"), dialogSource.indexOf("const hasBlockingFormatterConfig"));
expect(blocker).toContain("hasShortcutConflicts.value || hasSqlShortcutConflicts.value");
expect(blocker).not.toContain("crossScope");
});
it("explains a disabled Apply in the footer now that rows carry no inline message", () => {
expect(dialogSource).toContain('v-if="hasBlockingShortcutConflicts"');
expect(dialogSource).toContain('t("settings.shortcutConflictBlocksApply"');
expect(shortcutsTab).not.toContain('{{ t("settings.shortcutConflict") }}');
expect(shortcutsTab).toContain(':text="shortcutConflictHintText(definition)"');
});
it("lets the blocking red win over the informational amber on the pill", () => {
// Both states tint the same pill; the cross-scope rule must exclude
// aria-invalid rows, otherwise a row that is both would render amber.
expect(dialogSource).toContain('.settings-shortcut-row[data-cross-scope="true"] .settings-shortcut-pill:not([aria-invalid="true"])');
expect(dialogSource).toContain("border-color: color-mix(in srgb, var(--warning) 45%, transparent);");
});
it("keeps the capture pill's measured-width contract untouched", () => {
// The non-editing pill branch is a separate, deliberate formula (see
// .trellis/spec/dbx/frontend/keyboard-shortcuts.md, contract 5) — do not
// unify it with the measured capture-input width.
expect(shortcutsTab).toContain("Math.max(4, formatShortcutPill(editShortcuts[definition.id]).length + 3)}ch");
expect(shortcutsTab).toContain("editingShortcutId === definition.id ? shortcutPressShortcutInputWidth");
});
});
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { cn } from "@/lib/common/utils";
import { floatingArrowOffset, floatingViewportShift } from "@/lib/common/floatingViewportPosition";
const props = withDefaults(
@@ -13,6 +14,7 @@ const props = withDefaults(
openOnFocus?: boolean;
nowrap?: boolean;
surface?: "foreground" | "popover";
contentClass?: string;
}>(),
{
disabled: false,
@@ -279,7 +281,7 @@ watch(
v-if="show"
ref="tooltipRef"
class="fixed z-50 rounded-md text-xs"
:class="[tooltipSurfaceClass, slots.content ? '' : ['inline-flex w-fit max-w-xs items-center gap-1.5 px-3 py-1.5', nowrap ? 'whitespace-nowrap' : ''], tooltipTransformClass]"
:class="cn([tooltipSurfaceClass, slots.content ? '' : ['inline-flex w-fit max-w-xs items-center gap-1.5 px-3 py-1.5', nowrap ? 'whitespace-nowrap' : ''], tooltipTransformClass], contentClass)"
:style="{ left: `${x}px`, top: `${y}px` }"
role="tooltip"
@mouseenter="clearCloseTimer"
@@ -50,3 +50,56 @@ describe("LightTooltip scrolling", () => {
app.unmount();
});
});
describe("LightTooltip contentClass", () => {
async function mountAndHover(props: Record<string, unknown>) {
const root = defineComponent({
setup() {
return () =>
h(
LightTooltip,
{ delay: 0, ...props },
{
default: () => h("span", { id: "content-class-trigger" }, "pill"),
},
);
},
});
const container = document.createElement("div");
document.body.append(container);
const app = createApp(root);
app.mount(container);
const trigger = container.querySelector<HTMLElement>("#content-class-trigger");
expect(trigger).toBeTruthy();
vi.spyOn(trigger!, "matches").mockImplementation((selector) => selector === ":hover");
trigger?.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
await vi.runAllTimersAsync();
await nextTick();
return { app, tooltip: document.querySelector<HTMLElement>("[role=tooltip]") };
}
it("merges contentClass into the teleported tooltip and drops the default max-w-xs", async () => {
vi.useFakeTimers();
const { app, tooltip } = await mountAndHover({ text: "Conflict hint", contentClass: "max-w-[320px]" });
expect(tooltip).toBeTruthy();
expect(tooltip!.classList.contains("max-w-[320px]")).toBe(true);
// With both classes present Tailwind v4 emits max-w-[320px] before max-w-xs,
// so the default must be removed for the caller width to take effect.
expect(tooltip!.classList.contains("max-w-xs")).toBe(false);
app.unmount();
});
it("keeps the default max-w-xs when no contentClass is passed", async () => {
vi.useFakeTimers();
const { app, tooltip } = await mountAndHover({ text: "Plain hint" });
expect(tooltip).toBeTruthy();
expect(tooltip!.classList.contains("max-w-xs")).toBe(true);
app.unmount();
});
});
+18
View File
@@ -7422,6 +7422,24 @@ export default withEnglishFallback({
shortcutScopeGrid: "Məlumat cədvəli",
shortcutScopeSearch: "Axtarış sahələri",
shortcutScopeSidebar: "Yan panel",
shortcutScopeHintGlobal: "Bütün ekranlarda keçərlidir",
shortcutScopeHintEditor: "Redaktor fokusda olduqda keçərlidir",
shortcutScopeHintGrid: "Nəticə cədvəli fokusda olduqda keçərlidir",
shortcutScopeHintSearch: "Axtarış sahəsi fokusda olduqda keçərlidir",
shortcutScopeHintSidebar: "Yan panel fokusda olduqda keçərlidir",
shortcutGroupCount: "{count} əməliyyat",
shortcutGroupUnbound: "{count} təyin edilməyib",
shortcutGroupUnboundTooltip: "Bu əməliyyatlara qısayol təyin edilməyib; sətirdəki karandaş ikonundan istifadə edərək təyin edin.",
shortcutModifiedTag: "Dəyişdirilib",
shortcutModifiedTagTooltip: "Standart qısayoldan fərqlənir; sətirdəki bərpa ikonundan istifadə edərək standarta qaytarın.",
shortcutConflictBadge: "{count} toqquşma",
shortcutConflictTooltip: "«{label}» ({scope}) ilə eyni düymələr. Eyni əhatə dairəsində bir kombinasiyanı yalnızca bir əməliyyat istifadə edə bilər; tətbiq etməzdən əvvəl birini dəyişmək lazımdır.",
shortcutConflictSummaryTooltip: "Eyni əhatə dairəsində {count} toqquşma ({pairs} təkrarlanan cüt). Eyni əhatə dairəsində bir kombinasiyanı yalnızca bir əməliyyat istifadə edə bilər; həll edilmədən tətbiq olunmur.",
shortcutCrossScopeBadge: "{count} əhatələr arası eyni düymə",
shortcutCrossScopeTooltip: "{targets} ilə eyni düymələr. Hər əhatə dairəsi öz kombinasiyasını həll edir, ona görə eyni anda yalnızca biri işə düşür; düzəlişə ehtiyac yoxdur.",
shortcutCrossScopeTooltipSeparator: ", ",
shortcutCrossScopeSummaryTooltip: "{count} əhatələr arası eyni düymə ({pairs} təkrarlanan cüt). Hər əhatə dairəsi öz kombinasiyasını həll edir, ona görə eyni anda yalnızca biri işə düşür; yalnız məlumat üçündür və tətbiqi maneə törətmir.",
shortcutConflictBlocksApply: "Eyni əhatə dairəsində {count} toqquşma var ({pairs} təkrarlanan cüt)",
shortcutPressShortcut: "Qısayol düymələrini bas",
shortcutSearchPlaceholder: "Qısayolları axtar",
shortcutSearchNoResults: "Axtarışınıza uyğun qısayol yoxdur.",
+18
View File
@@ -7609,6 +7609,24 @@ export default {
shortcutScopeGrid: "Data grid",
shortcutScopeSearch: "Search fields",
shortcutScopeSidebar: "Sidebar",
shortcutScopeHintGlobal: "Applies on any surface",
shortcutScopeHintEditor: "Applies while the editor is focused",
shortcutScopeHintGrid: "Applies while the results grid is focused",
shortcutScopeHintSearch: "Applies while the search field is focused",
shortcutScopeHintSidebar: "Applies while the sidebar is focused",
shortcutGroupCount: "{count} actions",
shortcutGroupUnbound: "{count} unbound",
shortcutGroupUnboundTooltip: "These actions have no shortcut assigned; use the pencil icon on the row to record one.",
shortcutModifiedTag: "Changed",
shortcutModifiedTagTooltip: "Differs from the default shortcut; use the reset icon on the row to restore it.",
shortcutConflictBadge: "{count} conflicts",
shortcutConflictTooltip: "Duplicates “{label}” ({scope}). Only one action per scope can own a chord, so one of them has to change before you can apply.",
shortcutConflictSummaryTooltip: "{count} conflicts within one scope ({pairs} duplicate pairs). Only one action per scope can own a chord, so this has to be resolved before applying.",
shortcutCrossScopeBadge: "{count} cross-scope overlaps",
shortcutCrossScopeTooltip: "Shares a chord with {targets}. Each scope resolves its own binding, so only one fires at a time — there is nothing to fix.",
shortcutCrossScopeTooltipSeparator: ", ",
shortcutCrossScopeSummaryTooltip: "{count} cross-scope overlaps ({pairs} duplicate pairs). Different scopes resolve their own bindings, so only one fires at a time; informational only, it does not block applying.",
shortcutConflictBlocksApply: "{count} conflicts within one scope ({pairs} duplicate pairs)",
shortcutPressShortcut: "Press shortcut",
shortcutSearchPlaceholder: "Search shortcuts",
shortcutSearchNoResults: "No shortcuts match your search.",
+18
View File
@@ -7122,6 +7122,24 @@ export default withEnglishFallback({
shortcutScopeGrid: "Cuadrícula de datos",
shortcutScopeSearch: "Campos de búsqueda",
shortcutScopeSidebar: "Barra lateral",
shortcutScopeHintGlobal: "Se aplica en cualquier pantalla",
shortcutScopeHintEditor: "Se aplica con el editor enfocado",
shortcutScopeHintGrid: "Se aplica con la tabla de resultados enfocada",
shortcutScopeHintSearch: "Se aplica con el campo de búsqueda enfocado",
shortcutScopeHintSidebar: "Se aplica con la barra lateral enfocada",
shortcutGroupCount: "{count} acciones",
shortcutGroupUnbound: "{count} sin asignar",
shortcutGroupUnboundTooltip: "Estas acciones no tienen atajo asignado; usa el icono de lápiz de la fila para asignar uno.",
shortcutModifiedTag: "Modificado",
shortcutModifiedTagTooltip: "Difiere del atajo predeterminado; usa el icono de restablecer de la fila para volver a él.",
shortcutConflictBadge: "{count} conflictos",
shortcutConflictTooltip: "Coincide con «{label}» ({scope}). En un mismo ámbito solo una acción puede usar una combinación, así que hay que cambiar una antes de aplicar.",
shortcutConflictSummaryTooltip: "{count} conflictos en un mismo ámbito ({pairs} pares duplicados). En un mismo ámbito solo una acción puede usar una combinación; hay que resolverlo antes de aplicar.",
shortcutCrossScopeBadge: "{count} coincidencias entre ámbitos",
shortcutCrossScopeTooltip: "Coincide con {targets}. Cada ámbito resuelve su propia combinación, así que solo se activa una a la vez; no hay nada que corregir.",
shortcutCrossScopeTooltipSeparator: ", ",
shortcutCrossScopeSummaryTooltip: "{count} coincidencias entre ámbitos ({pairs} pares duplicados). Cada ámbito resuelve su propia combinación, así que solo se activa una a la vez; es solo informativo y no bloquea la aplicación.",
shortcutConflictBlocksApply: "Hay {count} conflictos en un mismo ámbito ({pairs} pares duplicados)",
shortcutPressShortcut: "Presiona un atajo",
shortcutSearchPlaceholder: "Buscar atajos",
shortcutSearchNoResults: "Ningún atajo coincide con la búsqueda.",
+18
View File
@@ -7122,6 +7122,24 @@ export default withEnglishFallback({
shortcutScopeGrid: "Griglia dati",
shortcutScopeSearch: "Cerca campi",
shortcutScopeSidebar: "Barra laterale",
shortcutScopeHintGlobal: "Si applica in qualsiasi schermata",
shortcutScopeHintEditor: "Si applica con l’editor attivo",
shortcutScopeHintGrid: "Si applica con la griglia dei risultati attiva",
shortcutScopeHintSearch: "Si applica con il campo di ricerca attivo",
shortcutScopeHintSidebar: "Si applica con la barra laterale attiva",
shortcutGroupCount: "{count} azioni",
shortcutGroupUnbound: "{count} non assegnate",
shortcutGroupUnboundTooltip: "Queste azioni non hanno una scorciatoia assegnata; usa l’icona a matita nella riga per assegnarne una.",
shortcutModifiedTag: "Modificata",
shortcutModifiedTagTooltip: "Differisce dalla scorciatoia predefinita; usa l’icona di ripristino nella riga per riportarla a quella predefinita.",
shortcutConflictBadge: "{count} conflitti",
shortcutConflictTooltip: "Coincide con «{label}» ({scope}). Nello stesso ambito una sola azione può usare una combinazione, quindi occorre modificarne una prima di applicare.",
shortcutConflictSummaryTooltip: "{count} conflitti nello stesso ambito ({pairs} coppie duplicate). Nello stesso ambito una sola azione può usare una combinazione; va risolto prima di applicare.",
shortcutCrossScopeBadge: "{count} sovrapposizioni tra ambiti",
shortcutCrossScopeTooltip: "Coincide con {targets}. Ogni ambito risolve la propria combinazione, quindi se ne attiva una alla volta; non serve intervenire.",
shortcutCrossScopeTooltipSeparator: ", ",
shortcutCrossScopeSummaryTooltip: "{count} sovrapposizioni tra ambiti ({pairs} coppie duplicate). Ogni ambito risolve la propria combinazione, quindi se ne attiva una alla volta; è solo informativo e non blocca l’applicazione.",
shortcutConflictBlocksApply: "Ci sono {count} conflitti nello stesso ambito ({pairs} coppie duplicate)",
shortcutPressShortcut: "Premi scorciatoia",
shortcutSearchPlaceholder: "Cerca scorciatoie",
shortcutSearchNoResults: "Nessuna scorciatoia corrisponde alla ricerca.",
+18
View File
@@ -7128,6 +7128,24 @@ export default withEnglishFallback({
shortcutScopeGrid: "データグリッド",
shortcutScopeSearch: "フィールド検索",
shortcutScopeSidebar: "サイドバー",
shortcutScopeHintGlobal: "すべての画面で有効",
shortcutScopeHintEditor: "エディターにフォーカス時",
shortcutScopeHintGrid: "結果グリッドにフォーカス時",
shortcutScopeHintSearch: "検索フィールドにフォーカス時",
shortcutScopeHintSidebar: "サイドバーにフォーカス時",
shortcutGroupCount: "{count} 件",
shortcutGroupUnbound: "未割り当て {count} 件",
shortcutGroupUnboundTooltip: "これらの操作にはショートカットが割り当てられていません。行内の鉛筆アイコンから登録できます。",
shortcutModifiedTag: "変更済み",
shortcutModifiedTagTooltip: "既定のショートカットから変更されています。行内のリセットアイコンで既定に戻せます。",
shortcutConflictBadge: "競合 {count} 件",
shortcutConflictTooltip: "「{label}」({scope})と重複しています。同じスコープでは 1 つの組み合わせに 1 つの操作だけを割り当てられるため、適用前にどちらかを変更してください。",
shortcutConflictSummaryTooltip: "同一スコープの競合 {count} 件(重複 {pairs} 組)。同じスコープでは 1 つの組み合わせに 1 つの操作だけを割り当てられます。解決するまで適用できません。",
shortcutCrossScopeBadge: "スコープ間の重複 {count} 件",
shortcutCrossScopeTooltip: "{targets} とキーが重複しています。スコープごとに解決されるため同時に 1 つだけ動作します。対応は不要です。",
shortcutCrossScopeTooltipSeparator: "、",
shortcutCrossScopeSummaryTooltip: "スコープ間の重複 {count} 件(重複 {pairs} 組)。スコープごとに解決されるため同時に 1 つだけ動作します。提示のみで、適用は妨げません。",
shortcutConflictBlocksApply: "同一スコープの競合が {count} 件あります(重複 {pairs} 組)",
shortcutPressShortcut: "ショートカットを押してください",
shortcutSearchPlaceholder: "ショートカットを検索",
shortcutSearchNoResults: "一致するショートカットはありません。",
+18
View File
@@ -6893,6 +6893,24 @@ export default withEnglishFallback({
shortcutScopeGrid: "데이터 그리드",
shortcutScopeSearch: "필드 검색",
shortcutScopeSidebar: "사이드바",
shortcutScopeHintGlobal: "모든 화면에서 적용",
shortcutScopeHintEditor: "편집기에 포커스 시 적용",
shortcutScopeHintGrid: "결과 그리드에 포커스 시 적용",
shortcutScopeHintSearch: "검색 필드에 포커스 시 적용",
shortcutScopeHintSidebar: "사이드바에 포커스 시 적용",
shortcutGroupCount: "{count}개",
shortcutGroupUnbound: "{count}개 미할당",
shortcutGroupUnboundTooltip: "이 작업에는 단축키가 지정되어 있지 않습니다. 행의 연필 아이콘으로 지정할 수 있습니다.",
shortcutModifiedTag: "변경됨",
shortcutModifiedTagTooltip: "기본 단축키와 다릅니다. 행의 초기화 아이콘으로 기본값으로 되돌릴 수 있습니다.",
shortcutConflictBadge: "충돌 {count}개",
shortcutConflictTooltip: "「{label}」({scope})과(와) 키가 중복됩니다. 같은 스코프에서는 하나의 조합에 하나의 작업만 지정할 수 있으므로 적용 전에 하나를 변경해야 합니다.",
shortcutConflictSummaryTooltip: "같은 스코프 충돌 {count}개(중복 {pairs}쌍). 같은 스코프에서는 하나의 조합에 하나의 작업만 지정할 수 있습니다. 해결하기 전에는 적용할 수 없습니다.",
shortcutCrossScopeBadge: "스코프 간 중복 {count}개",
shortcutCrossScopeTooltip: "{targets}과(와) 키가 중복됩니다. 스코프별로 처리되므로 동시에 하나만 동작합니다. 조치가 필요하지 않습니다.",
shortcutCrossScopeTooltipSeparator: ", ",
shortcutCrossScopeSummaryTooltip: "스코프 간 중복 {count}개(중복 {pairs}쌍). 스코프별로 처리되므로 동시에 하나만 동작합니다. 안내용이며 적용을 막지 않습니다.",
shortcutConflictBlocksApply: "같은 스코프 충돌 {count}개(중복 {pairs}쌍)",
shortcutPressShortcut: "단축키를 누르세요",
shortcutSearchPlaceholder: "단축키 검색",
shortcutSearchNoResults: "검색과 일치하는 단축키가 없습니다.",
+18
View File
@@ -7124,6 +7124,24 @@ export default withEnglishFallback({
shortcutScopeGrid: "Grade de dados",
shortcutScopeSearch: "Campos de pesquisa",
shortcutScopeSidebar: "Barra lateral",
shortcutScopeHintGlobal: "Aplica-se em qualquer tela",
shortcutScopeHintEditor: "Aplica-se com o editor em foco",
shortcutScopeHintGrid: "Aplica-se com a grade de resultados em foco",
shortcutScopeHintSearch: "Aplica-se com o campo de busca em foco",
shortcutScopeHintSidebar: "Aplica-se com a barra lateral em foco",
shortcutGroupCount: "{count} ações",
shortcutGroupUnbound: "{count} sem atalho",
shortcutGroupUnboundTooltip: "Estas ações não têm atalho atribuído; use o ícone de lápis na linha para definir um.",
shortcutModifiedTag: "Alterado",
shortcutModifiedTagTooltip: "Difere do atalho padrão; use o ícone de restaurar na linha para voltar ao padrão.",
shortcutConflictBadge: "{count} conflitos",
shortcutConflictTooltip: "Coincide com “{label}” ({scope}). No mesmo escopo apenas uma ação pode usar uma combinação, então é preciso alterar uma delas antes de aplicar.",
shortcutConflictSummaryTooltip: "{count} conflitos no mesmo escopo ({pairs} pares duplicados). No mesmo escopo apenas uma ação pode usar uma combinação; é preciso resolver antes de aplicar.",
shortcutCrossScopeBadge: "{count} sobreposições entre escopos",
shortcutCrossScopeTooltip: "Coincide com {targets}. Cada escopo resolve a própria combinação, então apenas uma é acionada por vez; não há nada a corrigir.",
shortcutCrossScopeTooltipSeparator: ", ",
shortcutCrossScopeSummaryTooltip: "{count} sobreposições entre escopos ({pairs} pares duplicados). Cada escopo resolve a própria combinação, então apenas uma é acionada por vez; é apenas informativo e não bloqueia a aplicação.",
shortcutConflictBlocksApply: "Há {count} conflitos no mesmo escopo ({pairs} pares duplicados)",
shortcutPressShortcut: "Pressione o atalho",
shortcutSearchPlaceholder: "Pesquisar atalhos",
shortcutSearchNoResults: "Nenhum atalho corresponde à pesquisa.",
+18
View File
@@ -7303,6 +7303,24 @@ export default withEnglishFallback({
shortcutScopeGrid: "Veri tablosu",
shortcutScopeSearch: "Alanlarda ara",
shortcutScopeSidebar: "Kenar çubuğu",
shortcutScopeHintGlobal: "Her ekranda geçerli",
shortcutScopeHintEditor: "Düzenleyici odaktayken geçerli",
shortcutScopeHintGrid: "Sonuç tablosu odaktayken geçerli",
shortcutScopeHintSearch: "Arama alanı odaktayken geçerli",
shortcutScopeHintSidebar: "Kenar çubuğu odaktayken geçerli",
shortcutGroupCount: "{count} eylem",
shortcutGroupUnbound: "{count} atanmamış",
shortcutGroupUnboundTooltip: "Bu eylemlerde kısayol atanmamış; satırdaki kalem simgesiyle atayabilirsiniz.",
shortcutModifiedTag: "Değiştirildi",
shortcutModifiedTagTooltip: "Varsayılan kısayoldan farklı; satırdaki sıfırlama simgesiyle varsayılana dönebilirsiniz.",
shortcutConflictBadge: "{count} çakışma",
shortcutConflictTooltip: "«{label}» ({scope}) ile aynı tuş. Aynı kapsamda bir bileşimi yalnızca bir eylem kullanabilir; uygulamadan önce birini değiştirmeniz gerekir.",
shortcutConflictSummaryTooltip: "Aynı kapsamda {count} çakışma ({pairs} yinelenen çift). Aynı kapsamda bir bileşimi yalnızca bir eylem kullanabilir; çözülmeden uygulanamaz.",
shortcutCrossScopeBadge: "{count} kapsamlar arası aynı tuş",
shortcutCrossScopeTooltip: "{targets} ile aynı tuş. Her kapsam kendi bileşimini çözer, bu yüzden aynı anda yalnızca biri tetiklenir; yapılacak bir şey yok.",
shortcutCrossScopeTooltipSeparator: ", ",
shortcutCrossScopeSummaryTooltip: "{count} kapsamlar arası aynı tuş ({pairs} yinelenen çift). Her kapsam kendi bileşimini çözer, bu yüzden aynı anda yalnızca biri tetiklenir; yalnızca bilgilendirme amaçlıdır ve uygulamayı engellemez.",
shortcutConflictBlocksApply: "Aynı kapsamda {count} çakışma var ({pairs} yinelenen çift)",
shortcutPressShortcut: "Kısayola basın",
shortcutSearchPlaceholder: "Kısayollarda ara",
shortcutSearchNoResults: "Aramanızla eşleşen kısayol yok.",
+18
View File
@@ -7585,6 +7585,24 @@ export default withEnglishFallback({
shortcutScopeGrid: "数据表格",
shortcutScopeSearch: "搜索框",
shortcutScopeSidebar: "侧边栏",
shortcutScopeHintGlobal: "任意界面生效",
shortcutScopeHintEditor: "编辑器聚焦时生效",
shortcutScopeHintGrid: "结果表格聚焦时生效",
shortcutScopeHintSearch: "搜索框聚焦时生效",
shortcutScopeHintSidebar: "侧边栏聚焦时生效",
shortcutGroupCount: "{count} 项",
shortcutGroupUnbound: "{count} 项未绑定",
shortcutGroupUnboundTooltip: "这些操作当前没有绑定快捷键;点击行内铅笔图标即可录入。",
shortcutModifiedTag: "已改",
shortcutModifiedTagTooltip: "已偏离默认快捷键;点击行内回转图标可恢复默认。",
shortcutConflictBadge: "{count} 项冲突",
shortcutConflictTooltip: "与「{label}」({scope})同键。同一作用域内一个组合只能绑定一个操作,需改掉其中一个才能应用。",
shortcutConflictSummaryTooltip: "同作用域冲突 {count} 项({pairs} 组重复)。同一作用域内一个组合只能绑定一个操作,解决前无法应用。",
shortcutCrossScopeBadge: "{count} 项跨域同键",
shortcutCrossScopeTooltip: "与 {targets} 同键。不同作用域各自生效,按焦点路由,同一时刻只触发一个;无需处理。",
shortcutCrossScopeTooltipSeparator: "、",
shortcutCrossScopeSummaryTooltip: "跨作用域同键 {count} 项({pairs} 组重复)。不同作用域各自生效,同一时刻只触发一个;仅作提示,不影响应用。",
shortcutConflictBlocksApply: "存在 {count} 项同作用域冲突({pairs} 组重复)",
shortcutPressShortcut: "按下快捷键",
shortcutSearchPlaceholder: "搜索快捷键",
shortcutSearchNoResults: "没有匹配的快捷键。",
+18
View File
@@ -6420,6 +6420,24 @@ export default withEnglishFallback({
shortcutScopeGrid: "資料表格",
shortcutScopeSearch: "搜尋框",
shortcutScopeSidebar: "側邊欄",
shortcutScopeHintGlobal: "任意介面生效",
shortcutScopeHintEditor: "編輯器聚焦時生效",
shortcutScopeHintGrid: "結果表格聚焦時生效",
shortcutScopeHintSearch: "搜尋框聚焦時生效",
shortcutScopeHintSidebar: "側邊欄聚焦時生效",
shortcutGroupCount: "{count} 項",
shortcutGroupUnbound: "{count} 項未綁定",
shortcutGroupUnboundTooltip: "這些操作目前沒有綁定快速鍵;點擊行內鉛筆圖示即可錄入。",
shortcutModifiedTag: "已改",
shortcutModifiedTagTooltip: "已偏離預設快速鍵;點擊行內迴轉圖示可還原預設。",
shortcutConflictBadge: "{count} 項衝突",
shortcutConflictTooltip: "與「{label}」({scope})同鍵。同一作用域內一個組合只能綁定一個操作,需改掉其中一個才能套用。",
shortcutConflictSummaryTooltip: "同作用域衝突 {count} 項({pairs} 組重複)。同一作用域內一個組合只能綁定一個操作,解決前無法套用。",
shortcutCrossScopeBadge: "{count} 項跨域同鍵",
shortcutCrossScopeTooltip: "與 {targets} 同鍵。不同作用域各自生效,依焦點路由,同一時刻只觸發一個;無需處理。",
shortcutCrossScopeTooltipSeparator: "、",
shortcutCrossScopeSummaryTooltip: "跨作用域同鍵 {count} 項({pairs} 組重複)。不同作用域各自生效,同一時刻只觸發一個;僅作提示,不影響套用。",
shortcutConflictBlocksApply: "存在 {count} 項同作用域衝突({pairs} 組重複)",
shortcutPressShortcut: "按下快速鍵",
shortcutSearchPlaceholder: "搜尋快速鍵",
shortcutSearchNoResults: "沒有符合的快速鍵。",
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
closeOtherTabsDefaultShortcut,
countShortcutConflictPairs,
DEFAULT_SHORTCUT_SETTINGS,
findCrossScopeShortcutConflicts,
findShortcutConflict,
formatShortcut,
isReservedShortcut,
@@ -431,4 +433,69 @@ describe("shortcutRegistry editor actions", () => {
expect(windows.find).toBe("Mod+H");
expect(windows.formatSql).toBe("Mod+F");
});
describe("findCrossScopeShortcutConflicts", () => {
it("reports the intentional cross-scope overlaps in the defaults", () => {
// 默认配置里确实存在跨作用域同键,这是设计使然;设置界面把它作为“提示”
// 展示,而 normalizeShortcutSettings 的占用判定刻意不管它们。
const conflicts = findCrossScopeShortcutConflicts(DEFAULT_SHORTCUT_SETTINGS);
expect(conflicts.find).toContain("focusSearch");
expect(conflicts.focusSearch).toContain("find");
expect(conflicts.acceptCompletion).toContain("toggleTranspose");
expect(conflicts.explainSql).toContain("editSidebarConnection");
// Shift+Mod+D 与 Mod+Shift+D 经 formatShortcut 规范化后同键。
expect(conflicts.viewTableDdl).toContain("editTableStructure");
});
it("never reports same-scope duplicates (those are blocking conflicts)", () => {
// uppercaseSelection 改成 Mod+A 只与同作用域的 selectAll 重复,
// 跨作用域提示不应把它列出来——那属于 findShortcutConflict 的职责。
const shortcuts = normalizeShortcutSettings({ uppercaseSelection: "Mod+A" });
expect(findShortcutConflict("uppercaseSelection", shortcuts.uppercaseSelection, shortcuts)).toBe("selectAll");
expect(findCrossScopeShortcutConflicts(shortcuts).uppercaseSelection).toBeUndefined();
});
it("ignores unbound shortcuts", () => {
const shortcuts = normalizeShortcutSettings({ find: "", focusSearch: "" });
expect(shortcuts.find).toBe("");
expect(shortcuts.focusSearch).toBe("");
const conflicts = findCrossScopeShortcutConflicts(shortcuts);
expect(conflicts.find).toBeUndefined();
expect(conflicts.focusSearch).toBeUndefined();
// goToColumn 默认未绑定,也不参与比较。
expect(conflicts.goToColumn).toBeUndefined();
});
it("resolves platform-dependent keys before comparing", () => {
// Win32 上 Mod+F 展开为 Ctrl+F,两边仍然是同键,提示照旧成立;
// 未自定义的组合也不会被误判。
const windows = findCrossScopeShortcutConflicts(normalizeShortcutSettings(undefined, "Win32"), "Win32");
expect(windows.find).toContain("focusSearch");
const mac = findCrossScopeShortcutConflicts(normalizeShortcutSettings(undefined, "MacIntel"), "MacIntel");
expect(mac.find).toContain("focusSearch");
});
it("does not mutate the settings it inspects", () => {
const shortcuts = normalizeShortcutSettings({ duplicateLine: "Mod+Alt+K" });
const before = { ...shortcuts };
findCrossScopeShortcutConflicts(shortcuts);
expect(shortcuts).toEqual(before);
});
});
describe("countShortcutConflictPairs", () => {
it("counts an unordered pair once even though the map is bidirectional", () => {
expect(countShortcutConflictPairs({ find: "focusSearch", focusSearch: "find" })).toBe(1);
});
it("flattens multi-partner entries", () => {
expect(countShortcutConflictPairs({ find: ["focusSearch", "formatSql"], formatSql: ["find"] })).toBe(2);
});
it("ignores empty entries", () => {
expect(countShortcutConflictPairs({})).toBe(0);
expect(countShortcutConflictPairs({ find: "" })).toBe(0);
expect(countShortcutConflictPairs({ find: [] })).toBe(0);
});
});
});
@@ -778,3 +778,49 @@ export function findShortcutConflict(actionId: ShortcutActionId, shortcut: strin
const conflict = SHORTCUT_DEFINITIONS.find((item) => item.id !== actionId && item.scope === definition.scope && shortcutsUseSameKeys(shortcuts[item.id], shortcut, platform));
return conflict?.id ?? null;
}
/**
* 跨作用域同键(**仅用于提示,不参与任何修复**)。
*
* 不同作用域共用同一个组合是**有意的设计**:`find`(editor)与
* `focusSearch`(global)默认都是 `Mod+F`,运行时由焦点/目标路由区分
* (`App.vue` 在 `defaultPrevented` 时让位,`ContentArea.focusSearch()`
* 按聚焦面路由)。因此本函数只**只读地报告**这类重叠,供设置界面以
* “提示”级别展示;绝不能拿它的结果去改写配置——`normalizeShortcutSettings`
* 的占用判定刻意只统计**同作用域**(见该函数内的两张占用轮),跨作用域
* 重叠不在其范围内。
*
* 同作用域重复**不**在此返回(那是必须阻断的冲突,见 `findShortcutConflict`)。
* 未绑定(空串)不参与比较。比较口径与 `findShortcutConflict` 一致:
* `formatShortcut` 展开 `Mod`、修饰键排序、大小写不敏感,因此
* `Mod+Shift+D` 与 `Shift+Mod+D` 视为同键。
*/
export function findCrossScopeShortcutConflicts(shortcuts: ShortcutSettings, platform = globalThis.navigator?.platform || ""): Partial<Record<ShortcutActionId, ShortcutActionId[]>> {
const conflicts: Partial<Record<ShortcutActionId, ShortcutActionId[]>> = {};
for (const definition of SHORTCUT_DEFINITIONS) {
const shortcut = shortcuts[definition.id];
if (!shortcut) continue;
const others = SHORTCUT_DEFINITIONS.filter((item) => item.id !== definition.id && item.scope !== definition.scope && shortcutsUseSameKeys(shortcuts[item.id], shortcut, platform)).map((item) => item.id);
if (others.length > 0) conflicts[definition.id] = others;
}
return conflicts;
}
/**
* 去重后的重复对数。行→对方 的映射会把同一对重复算两次(双向各一次),
* 而摘要文案要说的是“有多少组重复”,故统一按无序对折算。
*
* 同时接受 L1(每行单个对方)与 L2(每行多个对方)两种形状,
* 让同作用域与跨作用域的计数口径保持一致。
*/
export function countShortcutConflictPairs(conflicts: Partial<Record<ShortcutActionId, ShortcutActionId | ShortcutActionId[]>>): number {
const pairs = new Set<string>();
for (const [actionId, value] of Object.entries(conflicts)) {
if (!value) continue;
for (const other of Array.isArray(value) ? value : [value]) {
if (!other) continue;
pairs.add([actionId, other].sort().join("|"));
}
}
return pairs.size;
}