mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(shortcuts): search keyboard shortcuts by recorded key combination
This commit is contained in:
@@ -83,6 +83,7 @@ issue-assets/
|
||||
outputs/
|
||||
target-ai-write-confirmation/
|
||||
.serena/
|
||||
.mimosa/
|
||||
|
||||
# Code review tool artifacts
|
||||
review_diff.json
|
||||
|
||||
Generated
+2
@@ -2202,6 +2202,8 @@ dependencies = [
|
||||
"tokio-postgres",
|
||||
"tokio-util",
|
||||
"uuid",
|
||||
"webview2-com",
|
||||
"windows-core 0.61.2",
|
||||
"windows-sys 0.61.2",
|
||||
"wry",
|
||||
"zip",
|
||||
|
||||
@@ -158,6 +158,7 @@ import { initialTabSwitcherSelection, moveTabSwitcherSelection, tabSwitcherOrder
|
||||
import { createModifierDoubleTapShortcutController, isModifierDoubleTapShortcut, matchesModifierDoubleTapShortcut } from "@/lib/editor/modifierDoubleTapShortcut";
|
||||
import { createTabSwitcherKeyboardController } from "@/lib/tabs/tabSwitcherKeyboard";
|
||||
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
|
||||
import { isShortcutEditingKeydown, isShortcutEditingTarget } from "@/lib/editor/shortcutRecorder";
|
||||
import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities";
|
||||
import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy";
|
||||
import { buildAppendedEditorSql, buildDeduplicatedAppendedEditorSql } from "@/lib/ai/aiSqlAppend";
|
||||
@@ -4068,11 +4069,11 @@ watch(
|
||||
);
|
||||
|
||||
function canCaptureModifierDoubleTapSearch(e: KeyboardEvent): boolean {
|
||||
const target = e.target instanceof Element ? e.target : document.activeElement;
|
||||
const shortcuts = settingsStore.editorSettings.shortcuts;
|
||||
const enabled = isModifierDoubleTapShortcut(shortcuts.quickOpen) || isModifierDoubleTapShortcut(shortcuts.globalSearch);
|
||||
// Capture runs before the settings input can stop propagation.
|
||||
if (e.defaultPrevented || showTabSwitcher.value || !enabled || target?.closest("[data-shortcut-input], [data-sql-shortcut-input]")) {
|
||||
// Capture runs before the settings input can stop propagation, so it can only tell
|
||||
// by the focused element; see isShortcutEditingKeydown (#11787 added the recorder).
|
||||
if (e.defaultPrevented || showTabSwitcher.value || !enabled || isShortcutEditingKeydown(e)) {
|
||||
modifierDoubleTapSearch.reset();
|
||||
return false;
|
||||
}
|
||||
@@ -4098,8 +4099,7 @@ function handleTabSwitcherKeydownCapture(e: KeyboardEvent) {
|
||||
|
||||
function handleGlobalSearchKeydownCapture(e: KeyboardEvent) {
|
||||
if (canCaptureModifierDoubleTapSearch(e)) modifierDoubleTapSearch.handleKeydown(e);
|
||||
const target = e.target instanceof Element ? e.target : document.activeElement;
|
||||
if (target?.closest("[data-shortcut-input], [data-sql-shortcut-input]")) return;
|
||||
if (isShortcutEditingKeydown(e)) return;
|
||||
if (e.defaultPrevented || !isGlobalSearchShortcut(e, settingsStore.editorSettings.shortcuts)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -4110,6 +4110,10 @@ function handleGlobalSearchKeydownCapture(e: KeyboardEvent) {
|
||||
function handleAuxiliarySearchKeydownCapture(e: KeyboardEvent) {
|
||||
if (e.defaultPrevented || !isFocusSearchShortcut(e, settingsStore.editorSettings.shortcuts)) return;
|
||||
const target = e.target instanceof Element ? e.target : document.activeElement instanceof Element ? document.activeElement : null;
|
||||
// 快捷键录入表面(每行录入框、SQL 录入框、#11787 的录入态)自己拥有键盘:焦点搜索键
|
||||
// 默认就是 Mod+F,若在这里把焦点抢到设置页顶部的全局搜索框,用户正在录入的 Ctrl+F
|
||||
// 会被中断(录入态随焦点丢失退出),等于「录入期间执行了真实快捷键命令」。
|
||||
if (isShortcutEditingTarget(target)) return;
|
||||
if (!focusSearchInAuxiliarySurface(target)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Globe,
|
||||
GripVertical,
|
||||
HardDrive,
|
||||
Keyboard,
|
||||
Loader2,
|
||||
LogOut,
|
||||
Moon,
|
||||
@@ -172,6 +173,7 @@ import {
|
||||
saveBackgroundImage,
|
||||
loadMaxAgentTurns,
|
||||
saveMaxAgentTurns,
|
||||
setShortcutRecording,
|
||||
loadMaxRetries,
|
||||
saveMaxRetries,
|
||||
loadSqlFileUploadMaxBytes,
|
||||
@@ -208,6 +210,7 @@ import {
|
||||
} from "@/lib/backend/api";
|
||||
import { createModifierDoubleTapShortcutController } from "@/lib/editor/modifierDoubleTapShortcut";
|
||||
import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keyboardShortcuts";
|
||||
import { shortcutRecorderAction } from "@/lib/editor/shortcutRecorder";
|
||||
import {
|
||||
SHORTCUT_DEFINITIONS,
|
||||
countShortcutConflictPairs,
|
||||
@@ -2203,6 +2206,16 @@ const shortcutConflictPairCount = computed(() => countShortcutConflictPairs(shor
|
||||
const sqlShortcutConflicts = computed(() => findSqlShortcutConflicts(editSqlShortcuts.value, editShortcuts.value));
|
||||
const hasSqlShortcutConflicts = computed(() => sqlShortcutConflicts.value.length > 0);
|
||||
const shortcutSearchQuery = ref("");
|
||||
// #11787:快捷键页签的「键盘录入」态。录入结果写进上面同一个搜索框,因此过滤、
|
||||
// 冲突徽章、作用域分组、空态全部沿用既有管线,不新增第二套过滤口径。
|
||||
const shortcutRecording = ref(false);
|
||||
// 录入态下窗口是否处于激活状态。窗口不在前台时按下的键会被其它应用(或系统)先拿走,
|
||||
// DBX 收不到任何事件——提示行必须把这件事说出来,否则「静默待命」看起来就是功能失灵。
|
||||
const shortcutRecordingWindowActive = ref(true);
|
||||
// 录入态靠「焦点元素带 data-shortcut-recorder」让 App.vue 的两个 capture 阶段
|
||||
// 全局快捷键处理器让路(见 App.vue 的 handleGlobalSearchKeydownCapture)。属性只在
|
||||
// 录入态渲染:非录入时焦点停在这个按钮上的行为与普通按钮完全一致。
|
||||
const SHORTCUT_RECORDER_SELECTOR = '[data-settings-search-id="shortcuts"] [data-shortcut-recorder]';
|
||||
const collapsedShortcutScopes = ref<Set<ShortcutScope>>(new Set());
|
||||
const formatterEditorShortcutIds: ShortcutActionId[] = [
|
||||
"formatSql",
|
||||
@@ -3302,6 +3315,100 @@ function clearShortcut(actionId: ShortcutActionId) {
|
||||
editShortcuts.value = { ...editShortcuts.value, [actionId]: "" };
|
||||
}
|
||||
|
||||
/**
|
||||
* #11787:把焦点交给录入按钮并返回是否成功。录入期间 App.vue 的三条 capture 阶段
|
||||
* 分支只看**焦点元素**是否带 `data-shortcut-recorder` 才让路,而 macOS 的 WKWebView
|
||||
* (沿用 Safari 规则)点击按钮不会给按钮焦点,所以进入录入态必须显式 focus,不能只
|
||||
* 靠点击。
|
||||
*/
|
||||
async function focusShortcutRecorderButton(): Promise<boolean> {
|
||||
await nextTick();
|
||||
const button = document.querySelector<HTMLElement>(SHORTCUT_RECORDER_SELECTOR);
|
||||
if (!button) return false;
|
||||
button.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 录入态的唯一写入口:本地状态与宿主侧开关一起变。桌面端要让宿主在录入期间放下 WebView2
|
||||
* 的浏览器加速键(`F5`/`Ctrl+R`、`Ctrl+F`、`Ctrl+P`、`F12`……),否则那批键在页面之前就被
|
||||
* 消费掉——既录不进搜索框,按下 `F5` 还会重载整个 webview 丢掉录入现场。幂等过滤避免重复 IPC。
|
||||
*/
|
||||
function applyShortcutRecording(recording: boolean) {
|
||||
if (shortcutRecording.value === recording) return;
|
||||
shortcutRecording.value = recording;
|
||||
// 宿主侧是尽力而为的增强:失败只降级成「那批浏览器加速键仍录不进来」,错误由 api 层记录,
|
||||
// 不再额外打扰用户。
|
||||
void setShortcutRecording(recording).catch(() => undefined);
|
||||
}
|
||||
|
||||
async function toggleShortcutRecording() {
|
||||
if (shortcutRecording.value) {
|
||||
applyShortcutRecording(false);
|
||||
return;
|
||||
}
|
||||
applyShortcutRecording(true);
|
||||
// 没有焦点就没有让路依据:宁可退回未录入态,也不要留下一个「显示正在录入、实际
|
||||
// 吞不掉任何按键」的空转状态。
|
||||
if (!(await focusShortcutRecorderButton())) applyShortcutRecording(false);
|
||||
}
|
||||
|
||||
function stopShortcutRecording() {
|
||||
applyShortcutRecording(false);
|
||||
}
|
||||
|
||||
function onShortcutRecorderBlur() {
|
||||
// 窗口失焦不算放弃录入:被系统抢占的组合(Win+*、Alt+Tab、系统保留热键)本来就
|
||||
// 到不了页面,但按下它们会把焦点带走(#11787 的实测反馈),此时把录入态一起丢掉,
|
||||
// 用户回到 DBX 后按的键就会被当普通快捷键执行。窗口重新获得焦点时再把焦点还给
|
||||
// 录入按钮(见 onShortcutRecordingWindowFocus)。
|
||||
if (!document.hasFocus()) return;
|
||||
// 焦点转移到应用内的其它元素(点了搜索框、某一行……)才是真的放弃录入。
|
||||
stopShortcutRecording();
|
||||
}
|
||||
|
||||
function onShortcutRecordingWindowBlur() {
|
||||
shortcutRecordingWindowActive.value = false;
|
||||
}
|
||||
|
||||
function onShortcutRecordingWindowFocus() {
|
||||
shortcutRecordingWindowActive.value = true;
|
||||
if (!shortcutRecording.value) return;
|
||||
void nextTick().then(() => {
|
||||
if (!shortcutRecording.value) return;
|
||||
// 别抢用户“一回来就点”的那个控件:窗口激活后焦点可能已经落到搜索框/某一行上,
|
||||
// 此时把焦点抢回录入按钮会让这次点击白点一下(那种情况 blur 已经按应用内规则
|
||||
// 结束录入)。只有焦点没有归属(body)或本来就还在录入按钮上时才恢复。
|
||||
const active = document.activeElement;
|
||||
const activeIsControl = active instanceof HTMLElement && active !== document.body && active !== document.documentElement && !active.closest(SHORTCUT_RECORDER_SELECTOR);
|
||||
if (activeIsControl) return;
|
||||
void focusShortcutRecorderButton().then((focused) => {
|
||||
// 录入表面已不存在(换了页签、设置被关掉)时不保留空转的录入态。
|
||||
if (!focused) applyShortcutRecording(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function onShortcutRecorderKeydown(event: KeyboardEvent) {
|
||||
if (!shortcutRecording.value) return;
|
||||
// 录入态必须吞掉这次按键:既不执行真实快捷键命令(App.vue 的 window keydown
|
||||
// 以 defaultPrevented 让路),也不让 Space/Enter 再去触发按钮自身的 click
|
||||
// 把录入态点掉。
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const action = shortcutRecorderAction(event);
|
||||
if (!action) return;
|
||||
if (action.type === "exit") {
|
||||
stopShortcutRecording();
|
||||
return;
|
||||
}
|
||||
if (action.type === "clear") {
|
||||
shortcutSearchQuery.value = "";
|
||||
return;
|
||||
}
|
||||
shortcutSearchQuery.value = action.query;
|
||||
}
|
||||
|
||||
function setAppLayout(value: InterfaceLayout) {
|
||||
editAppLayout.value = value;
|
||||
}
|
||||
@@ -3501,6 +3608,7 @@ function resetSettingsSearchState() {
|
||||
settingsSearchActiveIndex.value = 0;
|
||||
pendingSettingsSearchResult = null;
|
||||
shortcutSearchQuery.value = "";
|
||||
applyShortcutRecording(false);
|
||||
mcpPermissionPreviewSearchQuery.value = "";
|
||||
clearSettingsSearchHighlight();
|
||||
}
|
||||
@@ -5239,6 +5347,9 @@ watch(snippetProvider, (provider) => {
|
||||
});
|
||||
|
||||
watch(activeSettingsTab, async (tab) => {
|
||||
// 离开快捷键页签会卸载录入按钮,而被移除的焦点元素不会再收到 blur:不在这里
|
||||
// 复位的话,录入态会残留到下次回到该页签(届时按钮已不在焦点上,吞不掉快捷键)。
|
||||
if (tab !== "shortcuts") applyShortcutRecording(false);
|
||||
void resetSettingsContentScroll();
|
||||
if (tab === "mcp" && !mcpStatus.value && !mcpStatusLoading.value) void refreshMcpStatus();
|
||||
if (tab === "ai" && aiIsCliProvider.value) void ensureCliMcpStatus();
|
||||
@@ -5284,6 +5395,10 @@ onMounted(() => {
|
||||
document.addEventListener("visibilitychange", modifierDoubleTapRecorder.reset);
|
||||
window.addEventListener("pointerdown", modifierDoubleTapRecorder.cancel, true);
|
||||
window.addEventListener("compositionstart", modifierDoubleTapRecorder.cancel, true);
|
||||
// #11787:系统快捷键(Win+*、Alt+Tab、系统保留热键)把窗口焦点带走后,录入态要
|
||||
// 留着,回到 DBX 时再把焦点还给录入按钮;同时把「窗口不在前台」这件事显示出来。
|
||||
window.addEventListener("focus", onShortcutRecordingWindowFocus);
|
||||
window.addEventListener("blur", onShortcutRecordingWindowBlur);
|
||||
void refreshWebDavPasswordStatus();
|
||||
checkLayoutDescTruncation();
|
||||
initTruncationObservers();
|
||||
@@ -5295,6 +5410,8 @@ onUnmounted(() => {
|
||||
document.removeEventListener("visibilitychange", modifierDoubleTapRecorder.reset);
|
||||
window.removeEventListener("pointerdown", modifierDoubleTapRecorder.cancel, true);
|
||||
window.removeEventListener("compositionstart", modifierDoubleTapRecorder.cancel, true);
|
||||
window.removeEventListener("focus", onShortcutRecordingWindowFocus);
|
||||
window.removeEventListener("blur", onShortcutRecordingWindowBlur);
|
||||
modifierDoubleTapRecorder.reset();
|
||||
clearThemePaletteOptionPreview();
|
||||
clearUiFontOptionPreview();
|
||||
@@ -9613,6 +9730,26 @@ onUnmounted(() => {
|
||||
<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>
|
||||
<!-- #11787:VS Code 式键盘录入。data-shortcut-recorder 只在录入态渲染:
|
||||
App.vue 的两个 capture 阶段全局快捷键处理器按「焦点元素是否带该属性」
|
||||
决定让路,非录入态焦点停在这个按钮上的行为与普通按钮一致。 -->
|
||||
<LightTooltip :text="shortcutRecording ? t('settings.shortcutRecordKeysRecordingHint') : t('settings.shortcutRecordKeys')" :open-on-focus="false">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0"
|
||||
:class="shortcutRecording ? 'bg-primary/10 text-primary ring-2 ring-primary/40' : ''"
|
||||
:aria-pressed="shortcutRecording"
|
||||
:aria-label="t('settings.shortcutRecordKeys')"
|
||||
:data-shortcut-recorder="shortcutRecording ? 'active' : undefined"
|
||||
@click="toggleShortcutRecording"
|
||||
@keydown="onShortcutRecorderKeydown"
|
||||
@blur="onShortcutRecorderBlur"
|
||||
>
|
||||
<Keyboard class="h-4 w-4" />
|
||||
</Button>
|
||||
</LightTooltip>
|
||||
<!-- 冲突摘要只需计数,明细进浮层:不再用整条横幅占掉列表高度。
|
||||
计数随搜索同口径(见 visibleShortcutConflict* computeds);
|
||||
页脚的应用门禁保持全局,不受搜索影响。 -->
|
||||
@@ -9631,6 +9768,13 @@ onUnmounted(() => {
|
||||
</LightTooltip>
|
||||
</div>
|
||||
|
||||
<!-- 录入态的操作提示常驻一条:此时焦点在录入按钮上,tooltip 不可用,
|
||||
Esc/Backspace 这两个控制键必须让用户看得见;窗口不在前台时提示要换
|
||||
成「按键会被别的应用先抢走」,否则静默待命看起来就是功能失灵。 -->
|
||||
<p v-if="shortcutRecording" class="rounded-md border px-3 py-1.5 text-xs" :class="shortcutRecordingWindowActive ? 'border-primary/30 bg-primary/5 text-muted-foreground' : 'border-warning/40 bg-warning/10 text-warning'" aria-live="polite">
|
||||
{{ shortcutRecordingWindowActive ? t("settings.shortcutRecordKeysRecordingHint") : t("settings.shortcutRecordKeysWindowInactive") }}
|
||||
</p>
|
||||
|
||||
<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>
|
||||
|
||||
+456
@@ -0,0 +1,456 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App } from "vue";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia } from "pinia";
|
||||
import { DEFAULT_EDITOR_SETTINGS } from "@/stores/settingsStore";
|
||||
|
||||
// #11787:快捷键页签的「键盘录入」。承重声明有两条:
|
||||
// 1. 录入的组合键写进**现有搜索框**,因此过滤/冲突徽章/作用域分组全部沿用既有管线;
|
||||
// 2. 录入态吞掉按键(preventDefault + 停止传播),不执行真实快捷键命令。
|
||||
// 这里驱动真实控件,避免把「写进了某个 ref」当成行为证明。
|
||||
|
||||
const hoisted = vi.hoisted(() => ({ shortcuts: {} as Record<string, string>, hostRecording: [] as boolean[] }));
|
||||
|
||||
vi.mock("vue-i18n", async () => {
|
||||
const { ref } = await import("vue");
|
||||
const locale = ref("en");
|
||||
// Interpolate params so count assertions can read them (real vue-i18n folds them
|
||||
// into the message text).
|
||||
const t = (key: string, params?: Record<string, unknown>) => {
|
||||
if (!params) return key;
|
||||
const suffix = Object.keys(params)
|
||||
.sort()
|
||||
.map((name) => `${name}=${String(params[name])}`)
|
||||
.join(" ");
|
||||
return suffix ? `${key} ${suffix}` : 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 };
|
||||
});
|
||||
|
||||
// The dialog already resolves this module for its other Tauri calls; here the host-side
|
||||
// recording flag is recorded instead of invoked so the wiring is observable.
|
||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/backend/api")>();
|
||||
return {
|
||||
...actual,
|
||||
setShortcutRecording: (recording: boolean) => {
|
||||
hoisted.hostRecording.push(recording);
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import EditorSettingsDialog from "../EditorSettingsDialog.vue";
|
||||
|
||||
if (!("ResizeObserver" in globalThis)) {
|
||||
(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
};
|
||||
}
|
||||
|
||||
// The pill and the recorded text are both display strings, so pin the platform
|
||||
// instead of branching on the host's: the expectations below are spelled for
|
||||
// Windows/Linux ("Ctrl + K"), which is what CI and this machine run.
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "platform");
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(globalThis.navigator, "platform", { value: "Win32", configurable: true });
|
||||
});
|
||||
afterAll(() => {
|
||||
if (platformDescriptor) Object.defineProperty(globalThis.navigator, "platform", platformDescriptor);
|
||||
});
|
||||
|
||||
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() {
|
||||
hoisted.hostRecording = [];
|
||||
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"]')!;
|
||||
|
||||
const searchInput = (host: HTMLElement) => shortcutsPane(host).querySelector<HTMLInputElement>('input[placeholder="settings.shortcutSearchPlaceholder"]')!;
|
||||
|
||||
const recorderButton = (host: HTMLElement) => shortcutsPane(host).querySelector<HTMLButtonElement>('[data-shortcut-recorder], button[aria-label="settings.shortcutRecordKeys"]')!;
|
||||
|
||||
const rows = (host: HTMLElement) => [...shortcutsPane(host).querySelectorAll<HTMLElement>(".settings-shortcut-row")];
|
||||
|
||||
const visibleRowIds = (host: HTMLElement) => rows(host).map((row) => row.querySelector<HTMLInputElement>("input[data-shortcut-input]")!.dataset.shortcutInput!);
|
||||
|
||||
function pressKeys(target: HTMLElement, init: KeyboardEventInit) {
|
||||
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("EditorSettingsDialog shortcut key recording (behaviour)", () => {
|
||||
it("records a pressed combination into the search box and filters the list to it", async () => {
|
||||
hoisted.shortcuts = defaults();
|
||||
const host = await mountShortcutsTab();
|
||||
const button = recorderButton(host);
|
||||
const search = searchInput(host);
|
||||
|
||||
expect(button.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(shortcutsPane(host).textContent).not.toContain("settings.shortcutRecordKeysRecordingHint");
|
||||
|
||||
button.click();
|
||||
await flushAsyncUpdates();
|
||||
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(button.getAttribute("data-shortcut-recorder")).toBe("active");
|
||||
// 桌面端要让宿主在录入期间放下 WebView2 的浏览器加速键(F5/Ctrl+R、Ctrl+F、F12…),
|
||||
// 否则那批键在页面之前被消费:既录不到,按 F5 还会重载 webview 丢掉录入现场。
|
||||
expect(hoisted.hostRecording).toEqual([true]);
|
||||
expect(shortcutsPane(host).textContent).toContain("settings.shortcutRecordKeysRecordingHint");
|
||||
|
||||
pressKeys(button, { key: "k", ctrlKey: true, shiftKey: true });
|
||||
await flushAsyncUpdates();
|
||||
|
||||
// deleteLine is the only action bound to Shift+Mod+K in the defaults.
|
||||
expect(search.value).toBe("Ctrl + Shift + K");
|
||||
expect(visibleRowIds(host)).toEqual(["deleteLine"]);
|
||||
});
|
||||
|
||||
it("finds every action sharing the combination, and reports their conflict", async () => {
|
||||
// The reported pain point (#11787) is hunting conflicts: the same chord on two
|
||||
// actions must both show up once the chord is recorded.
|
||||
hoisted.shortcuts = { ...defaults(), find: "Ctrl+Shift+K" };
|
||||
const host = await mountShortcutsTab();
|
||||
|
||||
recorderButton(host).click();
|
||||
await flushAsyncUpdates();
|
||||
pressKeys(recorderButton(host), { key: "k", ctrlKey: true, shiftKey: true });
|
||||
await flushAsyncUpdates();
|
||||
|
||||
const ids = visibleRowIds(host);
|
||||
expect(ids).toContain("deleteLine");
|
||||
expect(ids).toContain("find");
|
||||
// Conflict badges / group counts follow the same search pipeline, so the
|
||||
// duplicate is reported without any extra wiring.
|
||||
expect(shortcutsPane(host).textContent).toContain("settings.shortcutConflictBadge");
|
||||
});
|
||||
|
||||
it("swallows the pressed combination instead of running it", async () => {
|
||||
hoisted.shortcuts = defaults();
|
||||
const host = await mountShortcutsTab();
|
||||
const button = recorderButton(host);
|
||||
button.click();
|
||||
await flushAsyncUpdates();
|
||||
|
||||
// App.vue's global handler returns early on `defaultPrevented`; a combination that
|
||||
// would otherwise close the tab must not reach anything outside the recorder.
|
||||
let reachedDocument = false;
|
||||
const listener = () => {
|
||||
reachedDocument = true;
|
||||
};
|
||||
document.addEventListener("keydown", listener);
|
||||
const event = pressKeys(button, { key: "w", ctrlKey: true });
|
||||
document.removeEventListener("keydown", listener);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(reachedDocument).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps waiting while only a modifier is held, then exits on Esc and clears on Backspace", async () => {
|
||||
hoisted.shortcuts = defaults();
|
||||
const host = await mountShortcutsTab();
|
||||
const button = recorderButton(host);
|
||||
const search = searchInput(host);
|
||||
button.click();
|
||||
await flushAsyncUpdates();
|
||||
|
||||
pressKeys(button, { key: "Control", ctrlKey: true });
|
||||
await flushAsyncUpdates();
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(search.value).toBe("");
|
||||
|
||||
pressKeys(button, { key: "k", ctrlKey: true, shiftKey: true });
|
||||
await flushAsyncUpdates();
|
||||
expect(search.value).toBe("Ctrl + Shift + K");
|
||||
|
||||
// Esc leaves recording but keeps what was recorded, so the filtered list stays.
|
||||
pressKeys(button, { key: "Escape" });
|
||||
await flushAsyncUpdates();
|
||||
expect(button.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(button.hasAttribute("data-shortcut-recorder")).toBe(false);
|
||||
expect(search.value).toBe("Ctrl + Shift + K");
|
||||
expect(visibleRowIds(host)).toEqual(["deleteLine"]);
|
||||
|
||||
button.click();
|
||||
await flushAsyncUpdates();
|
||||
pressKeys(button, { key: "Backspace" });
|
||||
await flushAsyncUpdates();
|
||||
expect(search.value).toBe("");
|
||||
expect(visibleRowIds(host).length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("shows the empty state for a combination nothing is bound to", async () => {
|
||||
hoisted.shortcuts = defaults();
|
||||
const host = await mountShortcutsTab();
|
||||
|
||||
recorderButton(host).click();
|
||||
await flushAsyncUpdates();
|
||||
pressKeys(recorderButton(host), { key: "u", ctrlKey: true, altKey: true, shiftKey: true });
|
||||
await flushAsyncUpdates();
|
||||
|
||||
// Modifier order comes from the shared display formatter (Shift before Alt), so the
|
||||
// recorded text is spelled exactly like a pill would be.
|
||||
expect(searchInput(host).value).toBe("Ctrl + Shift + Alt + U");
|
||||
expect(rows(host).length).toBe(0);
|
||||
expect(shortcutsPane(host).textContent).toContain("settings.shortcutSearchNoResults");
|
||||
});
|
||||
|
||||
it("leaves recording when focus moves inside the app", async () => {
|
||||
hoisted.shortcuts = defaults();
|
||||
const host = await mountShortcutsTab();
|
||||
const button = recorderButton(host);
|
||||
button.click();
|
||||
await flushAsyncUpdates();
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
|
||||
// 应用内把焦点挪到别处(点了搜索框、某一行)才是真的放弃录入。
|
||||
const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(true);
|
||||
try {
|
||||
searchInput(host).focus();
|
||||
await flushAsyncUpdates();
|
||||
} finally {
|
||||
hasFocus.mockRestore();
|
||||
}
|
||||
|
||||
expect(button.getAttribute("aria-pressed")).toBe("false");
|
||||
expect(button.hasAttribute("data-shortcut-recorder")).toBe(false);
|
||||
expect(shortcutsPane(host).textContent).not.toContain("settings.shortcutRecordKeysRecordingHint");
|
||||
// 退出录入必须把宿主侧开关一起恢复,否则浏览器加速键会一直被关着。
|
||||
expect(hoisted.hostRecording).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("survives losing the window and re-arms the recorder when the window comes back", async () => {
|
||||
hoisted.shortcuts = defaults();
|
||||
const host = await mountShortcutsTab();
|
||||
const button = recorderButton(host);
|
||||
button.click();
|
||||
await flushAsyncUpdates();
|
||||
|
||||
// 被系统抢占的组合(Win+*、Alt+Tab、系统热键)把窗口焦点带走:键到不了页面,
|
||||
// 但录入态必须留着,否则用户回到 DBX 后按的键会被当普通快捷键执行。
|
||||
const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(false);
|
||||
try {
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
// 真实失焦:窗口失焦时浏览器把焦点交回 body。
|
||||
button.blur();
|
||||
await flushAsyncUpdates();
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
// 窗口不在前台时按键会被别的应用先抢走:提示行必须换文案,否则静默待命看起来
|
||||
// 就像功能失灵(#11787 的实测反馈)。
|
||||
expect(shortcutsPane(host).textContent).toContain("settings.shortcutRecordKeysWindowInactive");
|
||||
expect(shortcutsPane(host).textContent).not.toContain("settings.shortcutRecordKeysRecordingHint");
|
||||
|
||||
// 回到 DBX:焦点自动还给录入按钮,提示回到正常文案,接着录。
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
await flushAsyncUpdates();
|
||||
} finally {
|
||||
hasFocus.mockRestore();
|
||||
}
|
||||
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(document.activeElement).toBe(button);
|
||||
expect(shortcutsPane(host).textContent).toContain("settings.shortcutRecordKeysRecordingHint");
|
||||
// 窗口失焦不算退出录入:宿主侧不能被恢复(否则回到 DBX 后浏览器加速键又会被 WebView2 吃掉)。
|
||||
expect(hoisted.hostRecording).toEqual([true]);
|
||||
|
||||
pressKeys(button, { key: "k", ctrlKey: true, shiftKey: true });
|
||||
await flushAsyncUpdates();
|
||||
expect(searchInput(host).value).toBe("Ctrl + Shift + K");
|
||||
});
|
||||
|
||||
it("records keys that would otherwise activate the button itself", async () => {
|
||||
hoisted.shortcuts = defaults();
|
||||
const host = await mountShortcutsTab();
|
||||
const button = recorderButton(host);
|
||||
button.click();
|
||||
await flushAsyncUpdates();
|
||||
|
||||
// Space/Enter 是按钮的默认激活键:录入态必须吞掉它们,不能把自己点掉。
|
||||
const space = pressKeys(button, { key: " " });
|
||||
await flushAsyncUpdates();
|
||||
expect(space.defaultPrevented).toBe(true);
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(searchInput(host).value).toBe("Space");
|
||||
|
||||
const enter = pressKeys(button, { key: "Enter" });
|
||||
await flushAsyncUpdates();
|
||||
expect(enter.defaultPrevented).toBe(true);
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(searchInput(host).value).toBe("↵");
|
||||
});
|
||||
|
||||
it("holds focus on the recorder for combinations other surfaces would act on", async () => {
|
||||
hoisted.shortcuts = defaults();
|
||||
const host = await mountShortcutsTab();
|
||||
const button = recorderButton(host);
|
||||
|
||||
button.click();
|
||||
await flushAsyncUpdates();
|
||||
// 进入录入态要把焦点显式放到按钮上(macOS 点击按钮不给焦点,而录入的让路判定
|
||||
// 只看焦点元素)。
|
||||
expect(document.activeElement).toBe(button);
|
||||
|
||||
// Ctrl+P(快速打开)、Ctrl+F(焦点搜索)、Ctrl+W(关闭标签页)都是默认全局绑定:
|
||||
// 录入它们时组合键必须被吞掉、焦点留在录入按钮上、文本照常进搜索框,录入态继续。
|
||||
for (const [init, expected] of [
|
||||
[{ key: "p", ctrlKey: true }, "Ctrl + P"],
|
||||
[{ key: "f", ctrlKey: true }, "Ctrl + F"],
|
||||
[{ key: "w", ctrlKey: true }, "Ctrl + W"],
|
||||
] as const) {
|
||||
const event = pressKeys(button, init);
|
||||
await flushAsyncUpdates();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(button);
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(searchInput(host).value).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("records Ctrl+Shift+W, which is closeWindow's own Windows default", async () => {
|
||||
// closeWindow 的默认键在 Windows/Linux 上展开就是 Ctrl+Shift+W(#7654),同时它也是
|
||||
// 「浏览器保留的关窗键」:实测反馈里这条组合录不出来。应用内这条路径必须是通的——
|
||||
// 录制态吞掉按键(否则会触发关闭窗口),并把文本写进搜索框命中 closeWindow。
|
||||
hoisted.shortcuts = defaults();
|
||||
const host = await mountShortcutsTab();
|
||||
const button = recorderButton(host);
|
||||
button.click();
|
||||
await flushAsyncUpdates();
|
||||
|
||||
const event = pressKeys(button, { key: "W", shiftKey: true, ctrlKey: true });
|
||||
await flushAsyncUpdates();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(searchInput(host).value).toBe("Ctrl + Shift + W");
|
||||
expect(visibleRowIds(host)).toContain("closeWindow");
|
||||
expect(document.activeElement).toBe(button);
|
||||
});
|
||||
|
||||
it("leaves plain text search untouched after recording", async () => {
|
||||
hoisted.shortcuts = defaults();
|
||||
const host = await mountShortcutsTab();
|
||||
const button = recorderButton(host);
|
||||
button.click();
|
||||
await flushAsyncUpdates();
|
||||
pressKeys(button, { key: "k", ctrlKey: true, shiftKey: true });
|
||||
await flushAsyncUpdates();
|
||||
pressKeys(button, { key: "Escape" });
|
||||
await flushAsyncUpdates();
|
||||
|
||||
const search = searchInput(host);
|
||||
search.value = "shortcutFormatSql";
|
||||
search.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
await flushAsyncUpdates();
|
||||
|
||||
expect(visibleRowIds(host)).toEqual(["formatSql"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// #11787 的快捷键录入按钮与录入态提示,必须由每种语言的源文件自己声明:导入的 locale
|
||||
// 会经 `withEnglishFallback` 深合并英文,缺 key 时仍能解析(但会显示英文),因此这里
|
||||
// 直接断言源文件(合并前),避免静默回落。
|
||||
|
||||
function localeSource(name: string): string {
|
||||
return readFileSync(new URL(`../locales/${name}.ts`, import.meta.url), "utf8");
|
||||
}
|
||||
|
||||
// 与 ../index.ts 的 supportedLocales 保持一致(12 种语言)
|
||||
const LOCALES = ["en", "zh-CN", "zh-TW", "ja", "ko", "es", "it", "pt-BR", "ru", "az", "tr", "id"] as const;
|
||||
|
||||
function declaredString(source: string, key: string): string | undefined {
|
||||
return source.match(new RegExp(String.raw`^\s*${key}:\s*"((?:[^"\\]|\\.)*)",\s*$`, "m"))?.[1];
|
||||
}
|
||||
|
||||
describe("shortcut record-keys locale parity", () => {
|
||||
it.each(LOCALES)("%s declares the recorder button and recording hint", (name) => {
|
||||
const source = localeSource(name);
|
||||
|
||||
expect(declaredString(source, "shortcutRecordKeys"), `${name}: settings.shortcutRecordKeys is missing`).toBeTruthy();
|
||||
expect(declaredString(source, "shortcutRecordKeysRecordingHint"), `${name}: settings.shortcutRecordKeysRecordingHint is missing`).toBeTruthy();
|
||||
expect(declaredString(source, "shortcutRecordKeysWindowInactive"), `${name}: settings.shortcutRecordKeysWindowInactive is missing`).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -8129,6 +8129,9 @@ export default withEnglishFallback({
|
||||
shortcutModifierDoubleTapHint: "Düymə kombinasiyasını basın və ya eyni dəyişdirici düyməni sürətlə iki dəfə basıb buraxın (Shift, Cmd, Ctrl, Alt).",
|
||||
shortcutSearchPlaceholder: "Qısayolları axtar",
|
||||
shortcutSearchNoResults: "Axtarışınıza uyğun qısayol yoxdur.",
|
||||
shortcutRecordKeys: "Qısayol düymələrini yaz",
|
||||
shortcutRecordKeysRecordingHint: "Axtarmaq üçün düymə kombinasiyasını basın (Esc — çıxış, Backspace — təmizlə). Sistemi tərəfindən tutulan kombinasiyaları mətn kimi yazın.",
|
||||
shortcutRecordKeysWindowInactive: "DBX ön planda deyil: DBX-ə qayıdıb kombinasiyanı basın, əks halda düyməni başqa tətbiq tutur.",
|
||||
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.",
|
||||
|
||||
@@ -9260,6 +9260,9 @@ export default {
|
||||
shortcutModifierDoubleTapHint: "Press a key combination, or quickly press and release the same modifier twice (e.g. Shift, Cmd, Ctrl, Alt).",
|
||||
shortcutSearchPlaceholder: "Search shortcuts",
|
||||
shortcutSearchNoResults: "No shortcuts match your search.",
|
||||
shortcutRecordKeys: "Record keys",
|
||||
shortcutRecordKeysRecordingHint: "Press a key combination to search (Esc to exit, Backspace to clear). Combinations the system claims can't be recorded — type them as text instead.",
|
||||
shortcutRecordKeysWindowInactive: "DBX is not in the foreground: switch back to DBX, then press the combination — otherwise another app takes the key first.",
|
||||
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.",
|
||||
|
||||
@@ -8476,6 +8476,9 @@ export default withEnglishFallback({
|
||||
shortcutModifierDoubleTapHint: "Pulsa una combinación de teclas o pulsa y suelta rápidamente el mismo modificador dos veces (Shift, Cmd, Ctrl, Alt).",
|
||||
shortcutSearchPlaceholder: "Buscar atajos",
|
||||
shortcutSearchNoResults: "Ningún atajo coincide con la búsqueda.",
|
||||
shortcutRecordKeys: "Grabar teclas",
|
||||
shortcutRecordKeysRecordingHint: "Pulsa una combinación de teclas para buscar (Esc para salir, Retroceso para borrar). Las combinaciones que ocupa el sistema no se pueden grabar: escríbelas como texto.",
|
||||
shortcutRecordKeysWindowInactive: "DBX no está en primer plano: vuelve a DBX y pulsa la combinación; si no, otra aplicación captura la tecla antes.",
|
||||
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.",
|
||||
|
||||
@@ -8939,6 +8939,9 @@ export default withEnglishFallback({
|
||||
shortcutModifierDoubleTapHint: "Tekan kombinasi tombol, atau tekan dan lepaskan tombol pengubah yang sama dua kali dengan cepat (Shift, Cmd, Ctrl, Alt).",
|
||||
shortcutSearchPlaceholder: "Cari pintasan",
|
||||
shortcutSearchNoResults: "Tidak ada pintasan yang cocok dengan pencarian Anda.",
|
||||
shortcutRecordKeys: "Rekam tombol",
|
||||
shortcutRecordKeysRecordingHint: "Tekan kombinasi tombol untuk mencari (Esc untuk keluar, Backspace untuk menghapus). Kombinasi yang dipakai sistem tidak dapat direkam: ketik saja sebagai teks.",
|
||||
shortcutRecordKeysWindowInactive: "DBX tidak di depan: kembali ke DBX lalu tekan kombinasi, jika tidak aplikasi lain menangkap tombol lebih dulu.",
|
||||
shortcutConflict: "Pintasan ini berkonflik dengan aksi lain pada cakupan yang sama.",
|
||||
shortcutReserved: "Pintasan ini dicadangkan oleh sistem.",
|
||||
shortcutPlatformDefault: "Kombinasi ini adalah pintasan default pada platform lain dan tidak dapat diikat di sini. Silakan gunakan kombinasi lain.",
|
||||
|
||||
@@ -8239,6 +8239,9 @@ export default withEnglishFallback({
|
||||
shortcutModifierDoubleTapHint: "Premi una combinazione di tasti oppure premi e rilascia rapidamente lo stesso modificatore due volte (Shift, Cmd, Ctrl, Alt).",
|
||||
shortcutSearchPlaceholder: "Cerca scorciatoie",
|
||||
shortcutSearchNoResults: "Nessuna scorciatoia corrisponde alla ricerca.",
|
||||
shortcutRecordKeys: "Registra tasti",
|
||||
shortcutRecordKeysRecordingHint: "Premi una combinazione di tasti per cercare (Esc per uscire, Backspace per cancellare). Le combinazioni occupate dal sistema non si possono registrare: scrivile come testo.",
|
||||
shortcutRecordKeysWindowInactive: "DBX non è in primo piano: torna su DBX e premi la combinazione, altrimenti un'altra applicazione intercetta prima il tasto.",
|
||||
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.",
|
||||
|
||||
@@ -8228,6 +8228,9 @@ export default withEnglishFallback({
|
||||
shortcutModifierDoubleTapHint: "キーの組み合わせ、または同じ修飾キー(Shift、Cmd、Ctrl、Alt など)を素早く2回押して離してください。",
|
||||
shortcutSearchPlaceholder: "ショートカットを検索",
|
||||
shortcutSearchNoResults: "一致するショートカットはありません。",
|
||||
shortcutRecordKeys: "キーを記録",
|
||||
shortcutRecordKeysRecordingHint: "キーの組み合わせを押して検索します(Esc で終了、Backspace でクリア)。システムが占有している組み合わせはテキストで直接入力してください。",
|
||||
shortcutRecordKeysWindowInactive: "DBX が前面にありません。DBX に戻ってからキーの組み合わせを押してください(他のアプリが先にキーを取得します)。",
|
||||
shortcutConflict: "このショートカットは同じスコープ内の別のアクションと競合しています。",
|
||||
shortcutReserved: "このショートカットはシステムで予約されています。",
|
||||
shortcutPlatformDefault: "この組み合わせは他プラットフォームの既定キーです。ここでは割り当てできないため、別の組み合わせを選んでください。",
|
||||
|
||||
@@ -8107,6 +8107,9 @@ export default withEnglishFallback({
|
||||
shortcutModifierDoubleTapHint: "키 조합을 누르거나 같은 보조 키(Shift, Cmd, Ctrl, Alt 등)를 빠르게 두 번 눌렀다 놓으세요.",
|
||||
shortcutSearchPlaceholder: "단축키 검색",
|
||||
shortcutSearchNoResults: "검색과 일치하는 단축키가 없습니다.",
|
||||
shortcutRecordKeys: "키 기록",
|
||||
shortcutRecordKeysRecordingHint: "키 조합을 눌러 검색합니다(Esc: 종료, Backspace: 지우기). 시스템이 점유한 조합은 텍스트로 직접 입력하세요.",
|
||||
shortcutRecordKeysWindowInactive: "DBX가 앞에 없습니다. DBX로 돌아온 뒤 키 조합을 누르세요(다른 앱이 먼저 키를 가져갑니다).",
|
||||
shortcutConflict: "이 단축키는 같은 범위의 다른 작업과 충돌합니다.",
|
||||
shortcutReserved: "이 단축키는 시스템에서 예약되었습니다.",
|
||||
shortcutPlatformDefault: "이 조합은 다른 플랫폼의 기본 단축키라 여기에서는 지정할 수 없습니다. 다른 조합을 사용하세요.",
|
||||
|
||||
@@ -8237,6 +8237,9 @@ export default withEnglishFallback({
|
||||
shortcutModifierDoubleTapHint: "Pressione uma combinação de teclas ou pressione e solte rapidamente o mesmo modificador duas vezes (Shift, Cmd, Ctrl, Alt).",
|
||||
shortcutSearchPlaceholder: "Pesquisar atalhos",
|
||||
shortcutSearchNoResults: "Nenhum atalho corresponde à pesquisa.",
|
||||
shortcutRecordKeys: "Gravar teclas",
|
||||
shortcutRecordKeysRecordingHint: "Pressione uma combinação de teclas para pesquisar (Esc para sair, Backspace para limpar). Combinações ocupadas pelo sistema não podem ser gravadas: digite-as como texto.",
|
||||
shortcutRecordKeysWindowInactive: "O DBX não está em primeiro plano: volte para o DBX e pressione a combinação, senão outro aplicativo captura a tecla antes.",
|
||||
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.",
|
||||
|
||||
@@ -9594,6 +9594,9 @@ export default withEnglishFallback({
|
||||
shortcutModifierDoubleTapHint: "Нажмите сочетание клавиш или быстро нажмите и отпустите одну и ту же клавишу-модификатор дважды (Shift, Cmd, Ctrl, Alt).",
|
||||
shortcutSearchPlaceholder: "Поиск сочетаний клавиш",
|
||||
shortcutSearchNoResults: "Нет сочетаний клавиш, соответствующих запросу.",
|
||||
shortcutRecordKeys: "Записать клавиши",
|
||||
shortcutRecordKeysRecordingHint: "Нажмите сочетание клавиш для поиска (Esc — выход, Backspace — очистить). Сочетания, занятые системой, записать нельзя — введите их текстом.",
|
||||
shortcutRecordKeysWindowInactive: "DBX не на переднем плане: вернитесь в DBX и нажмите сочетание, иначе клавишу перехватит другое приложение.",
|
||||
shortcutConflict: "Это сочетание клавиш конфликтует с другим действием в той же области.",
|
||||
shortcutReserved: "Это сочетание клавиш зарезервировано системой.",
|
||||
shortcutPlatformDefault: "Эта комбинация является сочетанием клавиш по умолчанию для другой платформы и не может быть назначена здесь. Используйте другую.",
|
||||
|
||||
@@ -8030,6 +8030,9 @@ export default withEnglishFallback({
|
||||
shortcutModifierDoubleTapHint: "Bir tuş birleşimine basın veya aynı değiştirici tuşa hızla iki kez basıp bırakın (Shift, Cmd, Ctrl, Alt).",
|
||||
shortcutSearchPlaceholder: "Kısayollarda ara",
|
||||
shortcutSearchNoResults: "Aramanızla eşleşen kısayol yok.",
|
||||
shortcutRecordKeys: "Tuşları kaydet",
|
||||
shortcutRecordKeysRecordingHint: "Aramak için bir tuş kombinasyonuna basın (Çıkmak için Esc, temizlemek için Backspace). Sistemin kullandığı kombinasyonlar kaydedilemez: bunları metin olarak yazın.",
|
||||
shortcutRecordKeysWindowInactive: "DBX ön planda değil: DBX'e dönüp kombinasyona basın, aksi halde tuşu başka bir uygulama yakalar.",
|
||||
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.",
|
||||
|
||||
@@ -9244,6 +9244,9 @@ export default withEnglishFallback({
|
||||
shortcutModifierDoubleTapHint: "可按下组合键,或快速按下并松开同一个修饰键两次(如 Shift、Cmd、Ctrl、Alt)。",
|
||||
shortcutSearchPlaceholder: "搜索快捷键",
|
||||
shortcutSearchNoResults: "没有匹配的快捷键。",
|
||||
shortcutRecordKeys: "录制组合键",
|
||||
shortcutRecordKeysRecordingHint: "请按下组合键搜索(Esc 退出,Backspace 清空)。被系统占用的组合请直接输入文本。",
|
||||
shortcutRecordKeysWindowInactive: "DBX 不在前台:先切回 DBX 再按组合键,否则按键会先被其它应用抢走。",
|
||||
shortcutConflict: "这个快捷键与同一作用域内的其他操作冲突。",
|
||||
shortcutReserved: "该快捷键由系统保留,请换一个组合。",
|
||||
shortcutPlatformDefault: "该组合是其它平台的默认键,本平台无法绑定,请换一个组合。",
|
||||
|
||||
@@ -7548,6 +7548,9 @@ export default withEnglishFallback({
|
||||
shortcutModifierDoubleTapHint: "可按下組合鍵,或快速按下並放開同一個修飾鍵兩次(如 Shift、Cmd、Ctrl、Alt)。",
|
||||
shortcutSearchPlaceholder: "搜尋快速鍵",
|
||||
shortcutSearchNoResults: "沒有符合的快速鍵。",
|
||||
shortcutRecordKeys: "錄製組合鍵",
|
||||
shortcutRecordKeysRecordingHint: "請按下組合鍵搜尋(Esc 退出,Backspace 清除)。被系統占用的組合請直接輸入文字。",
|
||||
shortcutRecordKeysWindowInactive: "DBX 不在前景:先切回 DBX 再按組合鍵,否則按鍵會先被其它應用程式搶走。",
|
||||
shortcutConflict: "這個快速鍵與同一作用域內的其他操作衝突。",
|
||||
shortcutReserved: "此快速鍵由系統保留,請改用其他組合。",
|
||||
shortcutPlatformDefault: "該組合是其它平台的預設鍵,本平台無法綁定,請換一個組合。",
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isShortcutEditingKeydown, isShortcutEditingTarget, shortcutRecorderAction } from "@/lib/editor/shortcutRecorder";
|
||||
import { formatShortcutDisplay, normalizeShortcutSearch } from "@/lib/editor/shortcutDisplay";
|
||||
|
||||
// #11787:录入态按键 → 搜索文本的翻译。断言分两层:
|
||||
// 1. 单次按键被翻成哪个动作(录入 / 退出 / 清空 / 继续等待);
|
||||
// 2. 录出来的文本必须与「行上渲染的绑定胶囊」归一到同一个键——这是整个特性的
|
||||
// 承重契约,写错一侧就只在 macOS 上表现为「录了却搜不到」。
|
||||
describe("shortcut recorder key translation", () => {
|
||||
it("turns a pressed combination into the display string a bound row renders", () => {
|
||||
expect(shortcutRecorderAction({ key: "w", ctrlKey: true, shiftKey: true }, "Win32")).toEqual({ type: "record", query: "Ctrl + Shift + W" });
|
||||
expect(shortcutRecorderAction({ key: "/", ctrlKey: true }, "Win32")).toEqual({ type: "record", query: "Ctrl + /" });
|
||||
expect(shortcutRecorderAction({ key: "Enter", ctrlKey: true }, "Win32")).toEqual({ type: "record", query: "Ctrl + ↵" });
|
||||
});
|
||||
|
||||
it("normalizes to the same key as the stored binding on every platform", () => {
|
||||
const cases = [
|
||||
["Win32", { key: "w", ctrlKey: true, shiftKey: true }, "Shift+Mod+W"],
|
||||
["MacIntel", { key: "w", metaKey: true, shiftKey: true }, "Shift+Mod+W"],
|
||||
["MacIntel", { key: "Enter", metaKey: true }, "Mod+Enter"],
|
||||
] as const;
|
||||
|
||||
for (const [platform, pressed, stored] of cases) {
|
||||
const action = shortcutRecorderAction(pressed, platform);
|
||||
expect(action?.type).toBe("record");
|
||||
const query = action?.type === "record" ? action.query : "";
|
||||
expect(normalizeShortcutSearch(query)).toBe(normalizeShortcutSearch(formatShortcutDisplay(stored, platform)));
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps waiting while only a modifier is held", () => {
|
||||
expect(shortcutRecorderAction({ key: "Control", ctrlKey: true }, "Win32")).toBeNull();
|
||||
expect(shortcutRecorderAction({ key: "Shift", shiftKey: true }, "Win32")).toBeNull();
|
||||
expect(shortcutRecorderAction({ key: "Meta", metaKey: true }, "MacIntel")).toBeNull();
|
||||
expect(shortcutRecorderAction({ key: "Alt", altKey: true }, "Win32")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps Esc and Backspace as recorder controls", () => {
|
||||
expect(shortcutRecorderAction({ key: "Escape" }, "Win32")).toEqual({ type: "exit" });
|
||||
// 修饰键状态不影响 Esc:录入态必须能无条件退出。
|
||||
expect(shortcutRecorderAction({ key: "Escape", ctrlKey: true }, "Win32")).toEqual({ type: "exit" });
|
||||
expect(shortcutRecorderAction({ key: "Backspace" }, "Win32")).toEqual({ type: "clear" });
|
||||
});
|
||||
|
||||
it("still records keys that carry their own default binding", () => {
|
||||
// deleteCurrentRow 的默认键就是 Delete:它必须照常录入,否则该行动作在录入态下
|
||||
// 永远搜不到自己。写进搜索框的是显示串(Win32 下 Delete 渲染成 "Del"),与行上
|
||||
// 胶囊同源,因此归一化后仍然命中。
|
||||
expect(shortcutRecorderAction({ key: "Delete" }, "Win32")).toEqual({ type: "record", query: formatShortcutDisplay("Delete", "Win32") });
|
||||
expect(shortcutRecorderAction({ key: "F5" }, "Win32")).toEqual({ type: "record", query: "F5" });
|
||||
expect(shortcutRecorderAction({ key: "Tab" }, "Win32")).toEqual({ type: "record", query: "Tab" });
|
||||
});
|
||||
|
||||
it("ignores keys pressed while an IME is composing", () => {
|
||||
expect(shortcutRecorderAction({ key: "w", ctrlKey: true, isComposing: true }, "Win32")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// App.vue 的三条 capture 阶段分支靠这个判定给「自己拥有键盘输入」的表面让路:
|
||||
// 判定错了就会出现「录入 Ctrl+P 顺手弹出快速打开」「录入 Ctrl+F 被抢走焦点」这类
|
||||
// 副作用。这里用真实 KeyboardEvent 打在真实元素上,断言生产函数的可观测结果。
|
||||
describe("shortcut editing surfaces own the keyboard", () => {
|
||||
function keydownOn(element: Element, init: KeyboardEventInit = { key: "p", ctrlKey: true }): KeyboardEvent {
|
||||
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init });
|
||||
element.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
it("recognizes the per-row, SQL and #11787 recorder surfaces", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const rowInput = document.createElement("input");
|
||||
rowInput.dataset.shortcutInput = "formatSql";
|
||||
const sqlInput = document.createElement("input");
|
||||
sqlInput.dataset.sqlShortcutInput = "dialog";
|
||||
const recorder = document.createElement("button");
|
||||
recorder.dataset.shortcutRecorder = "active";
|
||||
const recorderIcon = document.createElement("span");
|
||||
recorder.append(recorderIcon);
|
||||
const plainButton = document.createElement("button");
|
||||
host.append(rowInput, sqlInput, recorder, plainButton);
|
||||
|
||||
expect(isShortcutEditingKeydown(keydownOn(rowInput))).toBe(true);
|
||||
expect(isShortcutEditingKeydown(keydownOn(sqlInput))).toBe(true);
|
||||
expect(isShortcutEditingKeydown(keydownOn(recorder))).toBe(true);
|
||||
// 焦点可能落在按钮内部的图标上:仍要算录入表面(closest 向上匹配)。
|
||||
expect(isShortcutEditingKeydown(keydownOn(recorderIcon))).toBe(true);
|
||||
|
||||
expect(isShortcutEditingKeydown(keydownOn(plainButton))).toBe(false);
|
||||
expect(isShortcutEditingKeydown(keydownOn(document.body))).toBe(false);
|
||||
expect(isShortcutEditingTarget(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the focused element when the event carries no element target", () => {
|
||||
// capture 阶段的处理器拿到的事件目标可能不是元素(如在 window 上派发):
|
||||
// 这时按焦点元素判定,与 App.vue 里三条分支原先各自解析 target 的口径一致。
|
||||
const recorder = document.createElement("button");
|
||||
recorder.dataset.shortcutRecorder = "active";
|
||||
const plainButton = document.createElement("button");
|
||||
document.body.append(recorder, plainButton);
|
||||
|
||||
const windowEvent = keydownOn(window);
|
||||
recorder.focus();
|
||||
expect(isShortcutEditingKeydown(windowEvent)).toBe(true);
|
||||
|
||||
plainButton.focus();
|
||||
expect(isShortcutEditingKeydown(windowEvent)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -397,6 +397,7 @@ export const loadMaxRetries = forward("loadMaxRetries");
|
||||
export const saveMaxRetries = forward("saveMaxRetries");
|
||||
export const completeAppClose = forward("completeAppClose");
|
||||
export const requestAppClose = forward("requestAppClose");
|
||||
export const setShortcutRecording = forward("setShortcutRecording");
|
||||
export const setDriverStoreDir = forward("setDriverStoreDir");
|
||||
export const setPluginStoreDir = forward("setPluginStoreDir");
|
||||
export const setAgentStoreDir = forward("setAgentStoreDir");
|
||||
|
||||
@@ -2541,6 +2541,11 @@ export async function requestAppClose(): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** #11787:Web 形态没有宿主层可配合(浏览器自行处理 Ctrl+W/Ctrl+T 等保留键),空实现。 */
|
||||
export async function setShortcutRecording(_recording: boolean): Promise<void> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface DriverStoreMigrationResult {
|
||||
driver_store_dir: string | null;
|
||||
plugin_store_dir: string | null;
|
||||
|
||||
@@ -1093,6 +1093,15 @@ export async function requestAppClose(): Promise<void> {
|
||||
return invoke("request_app_close_from_window_controls");
|
||||
}
|
||||
|
||||
/**
|
||||
* #11787:告诉宿主快捷键录入态是否开启。Windows 上宿主据此关掉 WebView2 的浏览器加速键
|
||||
* (F5/Ctrl+R 重载、Ctrl+F 查找、Ctrl+P 打印、Ctrl+± 缩放、F12 DevTools……),让这批键
|
||||
* 在录入期间落到页面被录入态吞掉;退出录入即恢复。其余平台是空实现。
|
||||
*/
|
||||
export async function setShortcutRecording(recording: boolean): Promise<void> {
|
||||
return invoke("set_shortcut_recording", { recording });
|
||||
}
|
||||
|
||||
export interface DriverStoreMigrationResult {
|
||||
driver_store_dir: string | null;
|
||||
plugin_store_dir: string | null;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { formatShortcutDisplay } from "./shortcutDisplay";
|
||||
import { eventToShortcut, type ShortcutLikeEvent } from "./keyboardShortcuts";
|
||||
|
||||
/**
|
||||
* 快捷键页签「键盘录入」的按键翻译(#11787,照 VS Code Keyboard Shortcuts 的
|
||||
* Record Keys):把录入态下按下的一次按键翻译成对搜索框的动作。
|
||||
*
|
||||
* `null` 表示这次按键还不构成完整组合键——纯修饰键按下(Ctrl/Shift/Alt/⌘)是录入
|
||||
* 过程的必经中间态,必须继续等待;IME 组字期间(isComposing)已由 eventToShortcut
|
||||
* 内部屏蔽,同样落入 null。
|
||||
*/
|
||||
export type ShortcutRecorderAction = { type: "exit" } | { type: "clear" } | { type: "record"; query: string };
|
||||
|
||||
/**
|
||||
* 三个「自己拥有键盘输入」的表面:每行快捷键录入框、SQL 快捷键录入框、以及 #11787 的
|
||||
* 页签级录入按钮。全局 capture 阶段的快捷键处理器(App.vue 的快速打开 / 全局搜索 /
|
||||
* 修饰键双敲)必须在这些表面上让路——capture 早于目标元素的处理器执行,只能按
|
||||
* **焦点元素**判定,靠事件的 defaultPrevented 已经来不及。
|
||||
*/
|
||||
export const SHORTCUT_EDITING_TARGET_SELECTOR = "[data-shortcut-input], [data-sql-shortcut-input], [data-shortcut-recorder]";
|
||||
|
||||
export function isShortcutEditingTarget(target: EventTarget | null): boolean {
|
||||
return target instanceof Element && !!target.closest(SHORTCUT_EDITING_TARGET_SELECTOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按键事件是否发生在快捷键录入表面上。capture 阶段的全局快捷键处理器拿到的就是事件
|
||||
* 本身,焦点元素没在事件里时退回 `document.activeElement`——与它们原先各自解析 target
|
||||
* 的口径一致。
|
||||
*/
|
||||
export function isShortcutEditingKeydown(event: KeyboardEvent): boolean {
|
||||
const target = event.target instanceof Element ? event.target : document.activeElement;
|
||||
return isShortcutEditingTarget(target);
|
||||
}
|
||||
|
||||
export function shortcutRecorderAction(event: ShortcutLikeEvent, platform = globalThis.navigator?.platform || ""): ShortcutRecorderAction | null {
|
||||
// Esc / Backspace 在录入态是控制键,不参与录入(与 VS Code 一致):否则「退出录入」
|
||||
// 「清空已录入」就没有键位可用。代价是**裸 Esc / Backspace 绑定无法被录入到**——
|
||||
// 带修饰键的组合(Mod+Esc 等)照常录入,裸键仍可用文本搜索找到(如 cancelSearch 的
|
||||
// 默认键 Escape)。判定刻意不看修饰键状态:Esc 必须无条件能退出录入态。
|
||||
if (event.key === "Escape") return { type: "exit" };
|
||||
if (event.key === "Backspace") return { type: "clear" };
|
||||
|
||||
const shortcut = eventToShortcut(event, platform);
|
||||
if (!shortcut) return null;
|
||||
|
||||
// 写进搜索框的是**显示串**(⌘ ⇧ W / Ctrl + Shift + W)而不是规范串
|
||||
// (Shift+Mod+W):过滤管线比较的是每一行的显示串(见 EditorSettingsDialog 的
|
||||
// filteredShortcutDefinitions 与 shortcutDisplay 的归一化),写规范串在 macOS
|
||||
// 上永远匹配不到任何一行。
|
||||
return { type: "record", query: formatShortcutDisplay(shortcut, platform) };
|
||||
}
|
||||
@@ -74,6 +74,12 @@ DBX 的快捷键来自同一份可配置 action registry。**设置 → 快捷
|
||||
|
||||
DBX 会检测同一作用域中的冲突。编辑器、表格、搜索、侧边栏和全局 action 具有不同作用域,但浏览器、操作系统、输入法或辅助功能仍可能先占用按键。
|
||||
|
||||
快捷键页签的搜索框还支持**键盘录入**:点击搜索框右侧的键盘图标,直接按下组合键即可筛出绑定该组合的 action;跨作用域共用同一组合的 action 会一并列出,排查冲突时不必先知道组合键的写法。录入期间按下的组合键不会触发真实命令;`Esc` 退出录入,`Backspace` 清空已录入的组合。也可以在搜索框里直接输入文本形式,`Ctrl+T` 与 `Ctrl + T` 都能命中。
|
||||
|
||||
**被系统抢占的组合无法录入**:`Alt+F4`、`Win` 键、`Ctrl+Alt+Del`、macOS 的 `⌘Tab` 这类按键先被操作系统或窗口管理器处理,DBX 收不到,录制时不会有任何结果(也不是录入失败)。这类组合即使手工配置了绑定也不会触发,排查时请改用文本搜索。
|
||||
|
||||
Web 形态还要多一层:浏览器自己保留的按键(`Ctrl+W` / `Ctrl+Shift+W` / `Ctrl+T` / `Ctrl+N` 等,macOS 的 `⌘W`)由浏览器直接处理,页面拿不到事件。桌面形态没有这条限制——`Ctrl+Shift+W` 是「关闭窗口」的默认键,`Ctrl+W` 是「关闭标签页」的默认键,都可以正常录入。
|
||||
|
||||
Windows/Linux 上 `Ctrl+Alt` 可能被键盘布局当作 AltGr;DBX 对相关组合采取更保守的匹配。macOS 的 Option+字母可能产生变形字符,DBX 会在安全情况下使用物理键位回退。
|
||||
|
||||
## 相关页面
|
||||
|
||||
@@ -74,6 +74,12 @@ Press `Mod+P` to search connections, databases, schemas, tables, views, and save
|
||||
|
||||
DBX detects conflicts within a scope. Editor, grid, search, sidebar, and global actions have separate scopes, but the browser, operating system, input method, or accessibility software may still reserve a key first.
|
||||
|
||||
The Shortcuts-tab search box also supports **key recording**: click the keyboard icon next to it and press a combination to filter down to the actions bound to it. Actions that share the same chord in another scope are listed as well, so you can hunt conflicts without knowing how the combination is spelled. Keys pressed while recording never run their command; `Esc` exits recording and `Backspace` clears the recorded combination. You can also type the combination as text — both `Ctrl+T` and `Ctrl + T` match.
|
||||
|
||||
**Combinations the system claims cannot be recorded**: keys such as `Alt+F4`, `Win`, `Ctrl+Alt+Del`, or macOS `⌘Tab` are handled by the operating system or window manager before DBX sees them, so recording yields nothing — that is not a recording failure. Such a combination also never fires even if it is configured by hand; use the text search instead when you need to inspect it.
|
||||
|
||||
The web build has one more layer: keys the browser reserves (`Ctrl+W` / `Ctrl+Shift+W` / `Ctrl+T` / `Ctrl+N`, or `⌘W` on macOS) are handled by the browser itself and never reach the page. The desktop build has no such restriction — `Ctrl+Shift+W` is the default for Close Window and `Ctrl+W` for Close Tab, and both can be recorded normally.
|
||||
|
||||
On Windows/Linux, `Ctrl+Alt` may represent AltGr, so DBX matches those combinations conservatively. On macOS, Option+letter can produce transformed characters, and DBX uses physical-key fallback only where safe.
|
||||
|
||||
## Related Pages
|
||||
|
||||
@@ -97,4 +97,10 @@ libloading = "0.8"
|
||||
gtk = "0.18"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
# 与 vendored wry / tauri-runtime-wry 同版本(Cargo.lock 只有一份 0.38.x),用于在
|
||||
# 快捷键录入态运行时切换 WebView2 的浏览器加速键开关(见 commands/shortcut_recording.rs)。
|
||||
webview2-com = "0.38"
|
||||
# `cast`(Settings → Settings3 的 QueryInterface)来自 windows-core 的 Interface trait,
|
||||
# 版本与 vendored wry / webview2-com-sys 一致。
|
||||
windows-core = "0.61"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
@@ -55,6 +55,7 @@ pub mod salesforce_oauth;
|
||||
pub mod saved_sql;
|
||||
pub mod schema_cache;
|
||||
pub mod schema_diff;
|
||||
pub mod shortcut_recording;
|
||||
pub mod sql_file;
|
||||
pub mod sqlite_backup;
|
||||
pub mod ssh_config;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
//! #11787 快捷键录入态的宿主侧配合(Windows / WebView2)。
|
||||
//!
|
||||
//! Windows 上 WebView2 会先吃掉一批「浏览器加速键」:`F5`/`Ctrl+R`(重载)、
|
||||
//! `Ctrl+F`/`F3`(页内查找)、`Ctrl+P`(打印)、`Ctrl+±`(缩放)、`Ctrl+Shift+C`/`F12`
|
||||
//! (DevTools)、前进/后退/搜索键。它们在页面之前被处理,页面既收不到(录不进搜索框),
|
||||
//! 宿主也不该让它们在录入时真的执行——例如按 F5 重载整个 webview 会直接丢掉录入现场。
|
||||
//!
|
||||
//! 录入态下把这个开关关掉,这批键就按普通按键落到页面,由快捷键录入态自己吞掉;
|
||||
//! 退出录入立刻恢复 WebView2 默认行为,非录入期的既有行为不做任何改变(包括网格 F5
|
||||
//! 刷新与 WebView2 默认行为之间原有的关系)。
|
||||
//!
|
||||
//! 非 Windows 平台没有这层拦截(WKWebView / WebKitGTK 不消费这些键),命令是空实现。
|
||||
|
||||
/// 录入态与「浏览器加速键是否开启」是互斥的:录入时关掉,其余时间保持 WebView2 默认。
|
||||
fn browser_accelerator_keys_enabled(recording: bool) -> bool {
|
||||
!recording
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn apply_browser_accelerator_keys(webview: &tauri::webview::PlatformWebview, enabled: bool) -> Result<(), String> {
|
||||
use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Settings3;
|
||||
// Settings3 是从 Settings(v1) 经 QueryInterface 拿到的,`cast` 来自 windows-core 的
|
||||
// Interface trait(版本与 vendored wry / webview2-com-sys 一致,均为 0.61)。
|
||||
use windows_core::Interface;
|
||||
|
||||
let controller = webview.controller();
|
||||
// SAFETY: WebView2 的 COM 调用契约要求在其所属 UI 线程上执行——`with_webview` 的闭包
|
||||
// 正是投递到主线程运行的。接口可用性由各步 Result 兜住:`ICoreWebView2Settings3` 需要
|
||||
// 较新的运行时(≥ 92.0.902.0),拿不到就是 Err,调用方只记日志、不影响任何功能。
|
||||
let settings3 = unsafe {
|
||||
let core = controller.CoreWebView2().map_err(|error| error.to_string())?;
|
||||
let settings = core.Settings().map_err(|error| error.to_string())?;
|
||||
settings.cast::<ICoreWebView2Settings3>().map_err(|error| error.to_string())?
|
||||
};
|
||||
// SAFETY: 同一次主线程调用里取得的 Settings3,未跨线程传递。
|
||||
unsafe { settings3.SetAreBrowserAcceleratorKeysEnabled(enabled) }.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// 前端进入/退出快捷键录入态时调用。`with_webview` 把闭包投递到主线程执行,这里不等结果
|
||||
/// (失败只记日志):关掉加速键是尽力而为的增强,失败时录入态照旧可用,只是那批浏览器
|
||||
/// 加速键仍然录不进来。
|
||||
#[tauri::command]
|
||||
pub fn set_shortcut_recording(window: tauri::WebviewWindow, recording: bool) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let enabled = browser_accelerator_keys_enabled(recording);
|
||||
window
|
||||
.with_webview(move |webview| {
|
||||
if let Err(error) = apply_browser_accelerator_keys(&webview, enabled) {
|
||||
eprintln!("[WARN] failed to toggle WebView2 browser accelerator keys: {error}");
|
||||
}
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (window, recording);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::browser_accelerator_keys_enabled;
|
||||
|
||||
#[test]
|
||||
fn recording_turns_browser_accelerator_keys_off() {
|
||||
assert!(!browser_accelerator_keys_enabled(true));
|
||||
assert!(browser_accelerator_keys_enabled(false));
|
||||
}
|
||||
}
|
||||
@@ -2450,6 +2450,7 @@ pub fn run() {
|
||||
commands::saved_sql::saved_sql_storage_dir,
|
||||
commands::saved_sql::open_saved_sql_storage_dir,
|
||||
commands::saved_sql::sync_saved_sql_directory,
|
||||
commands::shortcut_recording::set_shortcut_recording,
|
||||
commands::fs_open::reveal_path_in_file_manager,
|
||||
commands::fs_open::is_sqlite_database_file,
|
||||
commands::fs_open::delete_database_backup_files,
|
||||
|
||||
Reference in New Issue
Block a user