feat(editor): group shortcuts by scope with conflict tiers
Closes #9075
This commit is contained in:
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;
|
||||
|
||||
+295
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+61
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user