mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
fix(settings): normalize shortcut edits to the persisted form
This commit is contained in:
@@ -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) {
|
||||
|
||||
+46
@@ -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);");
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -7532,6 +7532,7 @@ export default withEnglishFallback({
|
||||
shortcutSearchNoResults: "一致するショートカットはありません。",
|
||||
shortcutConflict: "このショートカットは同じスコープ内の別のアクションと競合しています。",
|
||||
shortcutReserved: "このショートカットはシステムで予約されています。",
|
||||
shortcutPlatformDefault: "この組み合わせは他プラットフォームの既定キーです。ここでは割り当てできないため、別の組み合わせを選んでください。",
|
||||
shortcutClear: "ショートカットをクリア",
|
||||
preview: "ライブプレビュー",
|
||||
previewSyntaxErrorHint: "プレビューは読み取り専用です。デモ SQL 内の FOM は意図的な構文エラーで、エラーのハイライトを示しています。",
|
||||
|
||||
@@ -7308,6 +7308,7 @@ export default withEnglishFallback({
|
||||
shortcutSearchNoResults: "검색과 일치하는 단축키가 없습니다.",
|
||||
shortcutConflict: "이 단축키는 같은 범위의 다른 작업과 충돌합니다.",
|
||||
shortcutReserved: "이 단축키는 시스템에서 예약되었습니다.",
|
||||
shortcutPlatformDefault: "이 조합은 다른 플랫폼의 기본 단축키라 여기에서는 지정할 수 없습니다. 다른 조합을 사용하세요.",
|
||||
shortcutClear: "단축키 지우기",
|
||||
preview: "실시간 미리보기",
|
||||
previewSyntaxErrorHint: "미리보기는 읽기 전용입니다. 데모 SQL의 FOM 오타는 의도적인 구문 오류이며 오류 강조 표시를 보여줍니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -8158,6 +8158,7 @@ export default withEnglishFallback({
|
||||
shortcutSearchNoResults: "没有匹配的快捷键。",
|
||||
shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。",
|
||||
shortcutReserved: "该快捷键由系统保留,请换一个组合。",
|
||||
shortcutPlatformDefault: "该组合是其它平台的默认键,本平台无法绑定,请换一个组合。",
|
||||
shortcutClear: "清除快捷键",
|
||||
preview: "实时预览",
|
||||
previewSyntaxErrorHint: "预览为只读。示例 SQL 中的 FOM 拼写是故意设置的语法错误,用于演示错误高亮效果。",
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user