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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "一致するショートカットはありません。",
|
||||
|
||||
@@ -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: "검색과 일치하는 단축키가 없습니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: "没有匹配的快捷键。",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user