fix(settings): normalize shortcut edits to the persisted form

This commit is contained in:
zipg
2026-09-21 19:11:56 +08:00
committed by GitHub
parent 0c6fc610cd
commit 776f44c1cb
14 changed files with 174 additions and 4 deletions
@@ -174,7 +174,7 @@ import {
type WebDavConfig,
} from "@/lib/backend/api";
import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keyboardShortcuts";
import { SHORTCUT_DEFINITIONS, countShortcutConflictPairs, findCrossScopeShortcutConflicts, findShortcutConflict, isReservedShortcut, normalizeShortcutSettings, type ShortcutActionId, type ShortcutDefinition, type ShortcutScope } from "@/lib/editor/shortcutRegistry";
import { SHORTCUT_DEFINITIONS, countShortcutConflictPairs, findCrossScopeShortcutConflicts, findShortcutConflict, isReservedShortcut, normalizeShortcutSettings, resolveCapturedShortcutEdit, 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";
@@ -2065,6 +2065,9 @@ async function persistSettings() {
// result back into the input so an out-of-range draft doesn't keep
// reporting unsaved changes after a successful apply.
editRedisDatabaseDisplayLimit.value = settingsStore.editorSettings.redisDatabaseDisplayLimit;
// 同理:落盘口径可能改写键位(跨平台默认键、保留键回退),草稿要跟着回到
// 落盘值,避免面板卡在“未保存”无法应用(#9881)。
editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);
}
if (pendingBackgroundImageCleanup) {
const cleanupPath = pendingBackgroundImageCleanup;
@@ -2688,7 +2691,19 @@ function onShortcutKeydown(actionId: ShortcutActionId, event: KeyboardEvent) {
editingShortcutId.value = null;
return;
}
onShortcutChange(actionId, shortcut);
// 草稿必须正好等于落盘值:否则“未保存”状态永远消不掉,#9881 里
// 「应用」点了没反应、「应用并关闭」每次都弹未保存确认。
const captured = resolveCapturedShortcutEdit(actionId, shortcut, editShortcuts.value);
if (captured.rejectedByPlatformDefault) {
toast(t("settings.shortcutPlatformDefault"), 3000);
editingShortcutId.value = null;
return;
}
if (captured.changed) {
editShortcuts.value = captured.shortcuts;
} else {
onShortcutChange(actionId, shortcut);
}
editingShortcutId.value = null;
}
@@ -2729,10 +2744,14 @@ function cancelShortcutEdit() {
function resetShortcut(actionId: ShortcutActionId) {
const definition = SHORTCUT_DEFINITIONS.find((item) => item.id === actionId);
if (!definition) return;
editShortcuts.value = {
// The static definition default is the macOS literal for a few actions
// (selection-occurrence), so the reset must go through the same persisted
// normalization as capture — otherwise Windows drafts a key that can never
// save and silently flips to the platform default on apply.
editShortcuts.value = normalizeShortcutSettings({
...editShortcuts.value,
[actionId]: definition.defaultShortcut,
};
});
}
function clearShortcut(actionId: ShortcutActionId) {
@@ -0,0 +1,46 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const dialogSource = readFileSync(new URL("../EditorSettingsDialog.vue", import.meta.url), "utf8");
// Regression for https://github.com/t8y2/dbx/issues/9881: the shortcut draft was
// written straight from the captured key event, while persistence re-normalizes it
// and swaps "another platform's default" literals for the local platform default.
// The draft then never equaled the stored value again, so `hasChanges()` stayed true
// forever: "Apply" looked like it did nothing and "Apply and Close" always asked
// about unsaved changes — the shortcut (and every other pending edit) could never be
// saved. The capture path must therefore write the same value the store will keep.
describe("EditorSettingsDialog shortcut capture normalization", () => {
it("round-trips a captured combination through the persistence normalizer", () => {
expect(dialogSource).toContain("resolveCapturedShortcutEdit,");
expect(dialogSource).toContain("const captured = resolveCapturedShortcutEdit(actionId, shortcut, editShortcuts.value);");
});
it("refuses combinations the store would rewrite, keeping the previous shortcut", () => {
expect(dialogSource).toContain('toast(t("settings.shortcutPlatformDefault"), 3000);');
const guardStart = dialogSource.indexOf("if (captured.rejectedByPlatformDefault) {");
const guardEnd = dialogSource.indexOf("if (captured.changed) {", guardStart);
expect(guardStart).toBeGreaterThan(-1);
expect(guardEnd).toBeGreaterThan(guardStart);
// 拒绝分支必须先结束编辑、且不得写入草稿(保留原值 → 草稿仍是落盘值的不动点)
const guardBody = dialogSource.slice(guardStart, guardEnd);
expect(guardBody).toContain("editingShortcutId.value = null;");
expect(guardBody).toContain("return;");
expect(guardBody).not.toContain("editShortcuts.value =");
});
it("adopts the normalized result so the draft can never diverge from the stored value", () => {
expect(dialogSource).toContain("editShortcuts.value = captured.shortcuts;");
});
it("re-syncs the shortcut draft from the store after a successful apply", () => {
const bodyStart = dialogSource.indexOf("async function persistSettings() {");
const bodyEnd = dialogSource.indexOf("function applySettingsErrorToast(", bodyStart);
expect(bodyStart).toBeGreaterThan(-1);
expect(bodyEnd).toBeGreaterThan(bodyStart);
expect(dialogSource.slice(bodyStart, bodyEnd)).toContain("editShortcuts.value = normalizeShortcutSettings(settingsStore.editorSettings.shortcuts);");
});
});
+1
View File
@@ -7686,6 +7686,7 @@ export default withEnglishFallback({
shortcutSearchNoResults: "Axtarışınıza uyğun qısayol yoxdur.",
shortcutConflict: "Bu qısayol eyni əhatə dairəsində başqa əməliyyatla ziddiyyət təşkil edir.",
shortcutReserved: "Bu qısayol sistem tərəfindən qorunur.",
shortcutPlatformDefault: "Bu kombinasiya digər platformanın standart qısayoludur və burada təyin edilə bilməz. Başqa kombinasiya seçin.",
shortcutClear: "Qısayolu təmizlə",
preview: "Canlı önbaxış",
previewSyntaxErrorHint: "Yalnız oxumaq üçün önbaxış. Nümunə SQL-dəki FOM yazı xətası qəsdəndir və sintaksis xətalarının vurğulanmasını göstərir.",
+1
View File
@@ -8184,6 +8184,7 @@ export default {
shortcutSearchNoResults: "No shortcuts match your search.",
shortcutConflict: "This shortcut conflicts with another action in the same scope.",
shortcutReserved: "This shortcut is reserved by the system.",
shortcutPlatformDefault: "This combination is another platform's default shortcut and cannot be bound here. Please use a different one.",
shortcutClear: "Clear shortcut",
preview: "Live Preview",
previewSyntaxErrorHint: "Read-only preview. The FOM typo in the demo SQL is intentional and demonstrates syntax-error highlighting.",
+1
View File
@@ -7528,6 +7528,7 @@ export default withEnglishFallback({
shortcutSearchNoResults: "Ningún atajo coincide con la búsqueda.",
shortcutConflict: "Este atajo entra en conflicto con otra acción del mismo ámbito.",
shortcutReserved: "Este atajo está reservado por el sistema.",
shortcutPlatformDefault: "Esta combinación es el atajo predeterminado de otra plataforma y no se puede asignar aquí. Elige otra.",
shortcutClear: "Borrar atajo",
preview: "Vista previa en tiempo real",
previewSyntaxErrorHint: "Vista previa de solo lectura. El error FOM en el SQL de ejemplo es intencionado y demuestra el resaltado de errores de sintaxis.",
+1
View File
@@ -7527,6 +7527,7 @@ export default withEnglishFallback({
shortcutSearchNoResults: "Nessuna scorciatoia corrisponde alla ricerca.",
shortcutConflict: "Questa scorciatoia è in conflitto con un'altra azione nello stesso ambito.",
shortcutReserved: "Questa scorciatoia è riservata dal sistema.",
shortcutPlatformDefault: "Questa combinazione è la scorciatoia predefinita di un'altra piattaforma e non può essere assegnata qui. Scegline un'altra.",
shortcutClear: "Cancella scorciatoia",
preview: "Anteprima in tempo reale",
previewSyntaxErrorHint: "Anteprima di sola lettura. L'errore FOM nell'SQL di esempio è intenzionale e mostra l'evidenziazione degli errori di sintassi.",
+1
View File
@@ -7532,6 +7532,7 @@ export default withEnglishFallback({
shortcutSearchNoResults: "一致するショートカットはありません。",
shortcutConflict: "このショートカットは同じスコープ内の別のアクションと競合しています。",
shortcutReserved: "このショートカットはシステムで予約されています。",
shortcutPlatformDefault: "この組み合わせは他プラットフォームの既定キーです。ここでは割り当てできないため、別の組み合わせを選んでください。",
shortcutClear: "ショートカットをクリア",
preview: "ライブプレビュー",
previewSyntaxErrorHint: "プレビューは読み取り専用です。デモ SQL 内の FOM は意図的な構文エラーで、エラーのハイライトを示しています。",
+1
View File
@@ -7308,6 +7308,7 @@ export default withEnglishFallback({
shortcutSearchNoResults: "검색과 일치하는 단축키가 없습니다.",
shortcutConflict: "이 단축키는 같은 범위의 다른 작업과 충돌합니다.",
shortcutReserved: "이 단축키는 시스템에서 예약되었습니다.",
shortcutPlatformDefault: "이 조합은 다른 플랫폼의 기본 단축키라 여기에서는 지정할 수 없습니다. 다른 조합을 사용하세요.",
shortcutClear: "단축키 지우기",
preview: "실시간 미리보기",
previewSyntaxErrorHint: "미리보기는 읽기 전용입니다. 데모 SQL의 FOM 오타는 의도적인 구문 오류이며 오류 강조 표시를 보여줍니다.",
+1
View File
@@ -7529,6 +7529,7 @@ export default withEnglishFallback({
shortcutSearchNoResults: "Nenhum atalho corresponde à pesquisa.",
shortcutConflict: "Este atalho conflita com outra ação no mesmo escopo.",
shortcutReserved: "Este atalho é reservado pelo sistema.",
shortcutPlatformDefault: "Esta combinação é o atalho padrão de outra plataforma e não pode ser atribuída aqui. Escolha outra.",
shortcutClear: "Limpar atalho",
preview: "Pré-visualização ao vivo",
previewSyntaxErrorHint: "Pré-visualização somente leitura. O erro FOM no SQL de exemplo é intencional e demonstra o destaque de erros de sintaxe.",
+1
View File
@@ -7585,6 +7585,7 @@ export default withEnglishFallback({
shortcutSearchNoResults: "Aramanızla eşleşen kısayol yok.",
shortcutConflict: "Bu kısayol aynı kapsamdaki başka bir eylemle çakışıyor.",
shortcutReserved: "Bu kısayol sistem tarafından ayrılmıştır.",
shortcutPlatformDefault: "Bu kombinasyon diğer platformun varsayılan kısayoludur ve burada atanamaz. Başka bir kombinasyon seçin.",
shortcutClear: "Kısayolu temizle",
preview: "Canlı Önizleme",
previewSyntaxErrorHint: "Salt okunur önizleme. Örnek SQL'deki FOM yazım hatası kasıtlıdır ve sözdizimi hatası vurgulamasını gösterir.",
+1
View File
@@ -8158,6 +8158,7 @@ export default withEnglishFallback({
shortcutSearchNoResults: "没有匹配的快捷键。",
shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。",
shortcutReserved: "该快捷键由系统保留,请换一个组合。",
shortcutPlatformDefault: "该组合是其它平台的默认键,本平台无法绑定,请换一个组合。",
shortcutClear: "清除快捷键",
preview: "实时预览",
previewSyntaxErrorHint: "预览为只读。示例 SQL 中的 FOM 拼写是故意设置的语法错误,用于演示错误高亮效果。",
+1
View File
@@ -6829,6 +6829,7 @@ export default withEnglishFallback({
shortcutSearchNoResults: "沒有符合的快速鍵。",
shortcutConflict: "這個快速鍵與同一作用域內的其他操作衝突。",
shortcutReserved: "此快速鍵由系統保留,請改用其他組合。",
shortcutPlatformDefault: "該組合是其它平台的預設鍵,本平台無法綁定,請換一個組合。",
shortcutClear: "清除快速鍵",
preview: "即時預覽",
previewSyntaxErrorHint: "預覽為唯讀。範例 SQL 中的 FOM 拼寫是刻意設置的語法錯誤,用於示範錯誤高亮效果。",
@@ -11,6 +11,7 @@ import {
MACOS_RESERVED_SHORTCUTS,
normalizeModifierOnlyShortcut,
normalizeShortcutSettings,
resolveCapturedShortcutEdit,
selectionOccurrenceDefaultShortcut,
SHORTCUT_DEFINITIONS,
shortcutToCodeMirrorKey,
@@ -526,6 +527,67 @@ describe("shortcutRegistry editor actions", () => {
});
});
describe("resolveCapturedShortcutEdit", () => {
// #9881:设置面板「应用」点了毫无反应、「应用并关闭」每次都弹未保存确认,
// 根因就是捕获到的组合被落盘口径改写,草稿和 store 值再也对不上。
const macDefaults = normalizeShortcutSettings(undefined, "MacIntel");
const crossPlatformCases: [ShortcutActionId, string][] = [
["closeOtherTabs", "Shift+Alt+W"],
["navigateTabHistoryBack", "Mod+Alt+ArrowLeft"],
["navigateTabHistoryForward", "Mod+Alt+ArrowRight"],
["addNextSelectionOccurrence", "Alt+J"],
["selectAllSelectionOccurrences", "Ctrl+Alt+Shift+J"],
["toggleAiPanel", "Ctrl+Alt+I"],
["gotoLine", "Mod+G"],
];
it("flags combinations that are another platform's default for the same action", () => {
for (const [actionId, shortcut] of crossPlatformCases) {
const edit = resolveCapturedShortcutEdit(actionId, shortcut, macDefaults, "MacIntel");
expect(edit.rejectedByPlatformDefault).toBe(true);
// 落盘值是该动作本机平台的默认键,绝不能用它当草稿值写入
expect(edit.shortcuts[actionId]).not.toBe(shortcut);
}
});
it("accepts a plain rebind and keeps the draft a fixed point of the persistence normalizer", () => {
const edit = resolveCapturedShortcutEdit("executeSql", "Shift+Ctrl+U", macDefaults, "MacIntel");
expect(edit.rejectedByPlatformDefault).toBe(false);
expect(edit.changed).toBe(true);
expect(edit.shortcuts.executeSql).toBe("Shift+Ctrl+U");
// 关键不变式:草稿再走一遍落盘口径不会被改写,否则“未保存”永远消不掉
expect(normalizeShortcutSettings(edit.shortcuts, "MacIntel")).toEqual(edit.shortcuts);
});
it("adopts the copyCurrentRow/editTableStructure legacy migration into the draft", () => {
const draft = normalizeShortcutSettings({ ...macDefaults, copyCurrentRow: "" }, "MacIntel");
const edit = resolveCapturedShortcutEdit("editTableStructure", "Mod+D", draft, "MacIntel");
expect(edit.rejectedByPlatformDefault).toBe(false);
expect(edit.changed).toBe(true);
expect(edit.shortcuts).toMatchObject({ editTableStructure: "Mod+Shift+D", copyCurrentRow: "Mod+D" });
expect(normalizeShortcutSettings(edit.shortcuts, "MacIntel")).toEqual(edit.shortcuts);
});
it("reports no changes when the user re-presses the current combination", () => {
const edit = resolveCapturedShortcutEdit("closeOtherTabs", macDefaults.closeOtherTabs, macDefaults, "MacIntel");
expect(edit.rejectedByPlatformDefault).toBe(false);
expect(edit.changed).toBe(false);
expect(edit.shortcuts).toEqual(macDefaults);
});
it("still applies the cross-platform rule on Windows for the macOS default literals", () => {
const windowsDefaults = normalizeShortcutSettings(undefined, "Win32");
const edit = resolveCapturedShortcutEdit("closeOtherTabs", "Alt+Mod+W", windowsDefaults, "Win32");
expect(edit.rejectedByPlatformDefault).toBe(true);
expect(edit.shortcuts.closeOtherTabs).toBe(closeOtherTabsDefaultShortcut("Win32"));
});
});
describe("countShortcutConflictPairs", () => {
it("counts an unordered pair once even though the map is bidirectional", () => {
expect(countShortcutConflictPairs({ find: "focusSearch", focusSearch: "find" })).toBe(1);
@@ -745,6 +745,39 @@ export function normalizeShortcutSettings(settings?: Partial<ShortcutSettings>,
return normalized;
}
export interface CapturedShortcutEdit {
/** 归一化后的完整键位表:与 store 落盘口径一致,草稿直接采用它就不会与落盘值分叉。 */
shortcuts: ShortcutSettings;
/**
* 用户按下的组合命中了“另一平台的默认键”规则:落盘时该字面量会被换成本机平台
* 的默认键,所以这个组合在本平台不可能被保存(见上方 PLATFORM_DEFAULT_SHORTCUTS)。
*/
rejectedByPlatformDefault: boolean;
/** 归一化是否改动了任意一行(跨平台默认键、旧默认键、遗留迁移都会改动)。 */
changed: boolean;
}
/**
* 捕获阶段的归一化:把用户在设置面板按下的组合先过一遍落盘时会走的
* `normalizeShortcutSettings`,让草稿始终是持久化值的**不动点**。
*
* 设置面板的“有无未保存改动”是拿草稿和 store 里的值做 JSON 比较
* (`editorSettingsDraftChanged`)得来的:草稿里只要存在一个会被落盘口径改写的
* 值,这个比较就永远为真——「应用」看起来毫无反应,「应用并关闭」每次都弹
* “未保存的更改”,改了多少次都存不进去(#9881:把「关闭其他标签页」改成 ⇧⌥W,
* 落盘时被跨平台默认键规则换回 ⌥⌘W)。调用方拿到结果后必须采用 `shortcuts`,
* 并在 `rejectedByPlatformDefault` 为真时提示用户换一个组合。
*/
export function resolveCapturedShortcutEdit(actionId: ShortcutActionId, shortcut: string, shortcuts: ShortcutSettings, platform = globalThis.navigator?.platform || ""): CapturedShortcutEdit {
const next = normalizeShortcutSettings({ ...shortcuts, [actionId]: shortcut }, platform);
const rejectedByPlatformDefault = next[actionId] !== shortcut && (PLATFORM_DEFAULT_SHORTCUTS[actionId]?.has(shortcut) === true || (actionId === "closeTab" && shortcut === LEGACY_CLOSE_TAB_DEFAULT));
return {
shortcuts: next,
rejectedByPlatformDefault,
changed: SHORTCUT_DEFINITIONS.some((definition) => next[definition.id] !== shortcuts[definition.id]),
};
}
export function shortcutToCodeMirrorKey(shortcut: string): string {
return parseShortcutStrokes(shortcut)
.map((parts) =>