diff --git a/src/components/dialog/content/setting/KeybindingPanel.test.ts b/src/components/dialog/content/setting/KeybindingPanel.test.ts
new file mode 100644
index 0000000000..948faaf502
--- /dev/null
+++ b/src/components/dialog/content/setting/KeybindingPanel.test.ts
@@ -0,0 +1,252 @@
+import { mount } from '@vue/test-utils'
+import { createPinia, setActivePinia } from 'pinia'
+import PrimeVue from 'primevue/config'
+import Tooltip from 'primevue/tooltip'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { nextTick } from 'vue'
+import { createI18n } from 'vue-i18n'
+
+import KeybindingPanel from './KeybindingPanel.vue'
+import {
+ KeyComboImpl,
+ KeybindingImpl,
+ useKeybindingStore
+} from '@/stores/keybindingStore'
+
+const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ messages: { en: {} }
+})
+
+const mockPersist = vi.fn().mockResolvedValue(undefined)
+vi.mock('@/services/keybindingService', () => ({
+ useKeybindingService: () => ({
+ persistUserKeybindings: mockPersist
+ })
+}))
+
+vi.mock('primevue/usetoast', () => ({
+ useToast: () => ({
+ add: vi.fn()
+ })
+}))
+
+vi.mock('@/stores/commandStore', () => ({
+ useCommandStore: () => ({
+ commands: {
+ 'test-command': { id: 'test-command', label: 'Test Command' },
+ 'command-a': { id: 'command-a', label: 'Command A' },
+ 'command-b': { id: 'command-b', label: 'Command B' }
+ }
+ })
+}))
+
+describe('KeybindingPanel', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.clearAllMocks()
+ })
+
+ const mountPanel = () => {
+ return mount(KeybindingPanel, {
+ global: {
+ plugins: [PrimeVue, i18n],
+ directives: { tooltip: Tooltip },
+ stubs: {
+ DataTable: true,
+ Column: true,
+ Dialog: {
+ template:
+ '
',
+ props: ['visible', 'header']
+ },
+ InputText: true,
+ Message: true,
+ Tag: true,
+ Button: true,
+ SearchBox: true,
+ PanelTemplate: {
+ template: '
'
+ },
+ KeyComboDisplay: true
+ }
+ }
+ })
+ }
+
+ describe('saveKeybinding', () => {
+ it('should close dialog before updating store to prevent warning flash', async () => {
+ const keybindingStore = useKeybindingStore()
+ let dialogVisibleWhenStoreUpdated: boolean | undefined
+
+ const wrapper = mountPanel()
+ const vm = wrapper.vm as InstanceType
+
+ // Patch keybindingStore to capture dialog state when store is updated
+ const originalUpdate = keybindingStore.updateKeybindingOnCommand
+ keybindingStore.updateKeybindingOnCommand = (keybinding) => {
+ dialogVisibleWhenStoreUpdated = (
+ vm as unknown as { editDialogVisible: boolean }
+ ).editDialogVisible
+ return originalUpdate.call(keybindingStore, keybinding)
+ }
+
+ // Setup editing state
+ ;(
+ vm as unknown as { currentEditingCommand: { id: string } }
+ ).currentEditingCommand = { id: 'test-command' }
+ ;(
+ vm as unknown as { newBindingKeyCombo: KeyComboImpl }
+ ).newBindingKeyCombo = new KeyComboImpl({ key: 's', ctrl: true })
+ ;(vm as unknown as { editDialogVisible: boolean }).editDialogVisible =
+ true
+
+ await (
+ vm as unknown as { saveKeybinding: () => Promise }
+ ).saveKeybinding()
+
+ // Dialog should be closed when store updates (prevents warning flash)
+ expect(dialogVisibleWhenStoreUpdated).toBe(false)
+ })
+
+ it('should persist keybinding after closing dialog', async () => {
+ const wrapper = mountPanel()
+ const vm = wrapper.vm as InstanceType
+
+ // Setup editing state
+ ;(
+ vm as unknown as { currentEditingCommand: { id: string } }
+ ).currentEditingCommand = { id: 'test-command' }
+ ;(
+ vm as unknown as { newBindingKeyCombo: KeyComboImpl }
+ ).newBindingKeyCombo = new KeyComboImpl({ key: 's', ctrl: true })
+ ;(vm as unknown as { editDialogVisible: boolean }).editDialogVisible =
+ true
+
+ await (
+ vm as unknown as { saveKeybinding: () => Promise }
+ ).saveKeybinding()
+
+ expect(mockPersist).toHaveBeenCalled()
+ expect(
+ (vm as unknown as { editDialogVisible: boolean }).editDialogVisible
+ ).toBe(false)
+ })
+
+ it('should not show warning after save button is clicked', async () => {
+ const keybindingStore = useKeybindingStore()
+
+ // Setup existing keybinding that will conflict
+ keybindingStore.addUserKeybinding(
+ new KeybindingImpl({
+ commandId: 'existing-command',
+ combo: new KeyComboImpl({ key: 's', ctrl: true })
+ })
+ )
+
+ const wrapper = mountPanel()
+ const vm = wrapper.vm as InstanceType
+
+ // Setup editing with conflicting combo
+ ;(
+ vm as unknown as {
+ currentEditingCommand: { id: string; keybinding: null }
+ }
+ ).currentEditingCommand = {
+ id: 'new-command',
+ keybinding: null
+ }
+ ;(
+ vm as unknown as { newBindingKeyCombo: KeyComboImpl }
+ ).newBindingKeyCombo = new KeyComboImpl({ key: 's', ctrl: true })
+ ;(vm as unknown as { editDialogVisible: boolean }).editDialogVisible =
+ true
+ await nextTick()
+
+ // Verify warning is shown before save
+ expect(
+ (vm as unknown as { existingKeybindingOnCombo: KeybindingImpl | null })
+ .existingKeybindingOnCombo
+ ).not.toBeNull()
+
+ // Click save
+ await (
+ vm as unknown as { saveKeybinding: () => Promise }
+ ).saveKeybinding()
+ await nextTick()
+
+ // After save, dialog should be closed AND state should be cleared
+ expect(
+ (vm as unknown as { editDialogVisible: boolean }).editDialogVisible
+ ).toBe(false)
+ expect(
+ (vm as unknown as { currentEditingCommand: null }).currentEditingCommand
+ ).toBeNull()
+ expect(
+ (vm as unknown as { newBindingKeyCombo: null }).newBindingKeyCombo
+ ).toBeNull()
+ })
+ })
+
+ describe('keybinding overwrite flow', () => {
+ it('should successfully overwrite existing keybinding without flash', async () => {
+ const keybindingStore = useKeybindingStore()
+
+ // Setup existing keybinding
+ keybindingStore.addUserKeybinding(
+ new KeybindingImpl({
+ commandId: 'command-a',
+ combo: new KeyComboImpl({ key: 'a', ctrl: true })
+ })
+ )
+
+ const wrapper = mountPanel()
+ const vm = wrapper.vm as InstanceType
+
+ // Open edit for command-b with same combo (conflict)
+ ;(
+ vm as unknown as {
+ currentEditingCommand: { id: string; keybinding: null }
+ }
+ ).currentEditingCommand = {
+ id: 'command-b',
+ keybinding: null
+ }
+ ;(
+ vm as unknown as { newBindingKeyCombo: KeyComboImpl }
+ ).newBindingKeyCombo = new KeyComboImpl({ key: 'a', ctrl: true })
+ ;(vm as unknown as { editDialogVisible: boolean }).editDialogVisible =
+ true
+ await nextTick()
+
+ // Verify conflict detected
+ expect(
+ (vm as unknown as { existingKeybindingOnCombo: KeybindingImpl })
+ .existingKeybindingOnCombo?.commandId
+ ).toBe('command-a')
+
+ // Save (overwrite)
+ await (
+ vm as unknown as { saveKeybinding: () => Promise }
+ ).saveKeybinding()
+
+ // Verify dialog closed
+ expect(
+ (vm as unknown as { editDialogVisible: boolean }).editDialogVisible
+ ).toBe(false)
+
+ // Verify new keybinding saved
+ expect(
+ keybindingStore.getKeybindingByCommandId('command-b')
+ ).toBeDefined()
+
+ // Verify combo now belongs to command-b
+ const combo = new KeyComboImpl({ key: 'a', ctrl: true })
+ expect(keybindingStore.getKeybinding(combo)?.commandId).toBe('command-b')
+
+ // Verify persistence called
+ expect(mockPersist).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/src/components/dialog/content/setting/KeybindingPanel.vue b/src/components/dialog/content/setting/KeybindingPanel.vue
index 03ca3df47c..4e4b141e72 100644
--- a/src/components/dialog/content/setting/KeybindingPanel.vue
+++ b/src/components/dialog/content/setting/KeybindingPanel.vue
@@ -145,7 +145,7 @@ import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import { useToast } from 'primevue/usetoast'
-import { computed, ref, watchEffect } from 'vue'
+import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SearchBox from '@/components/common/SearchBox.vue'
@@ -194,7 +194,7 @@ const selectedCommandData = ref(null)
const editDialogVisible = ref(false)
const newBindingKeyCombo = ref(null)
const currentEditingCommand = ref(null)
-const keybindingInput = ref | null>(null)
+const keybindingInput = ref()
const existingKeybindingOnCombo = computed(() => {
if (!currentEditingCommand.value) {
@@ -225,11 +225,9 @@ function editKeybinding(commandData: ICommandData) {
editDialogVisible.value = true
}
-watchEffect(() => {
- if (editDialogVisible.value) {
- // nextTick doesn't work here, so we use a timeout instead
+watch(editDialogVisible, (visible) => {
+ if (visible) {
setTimeout(() => {
- // @ts-expect-error - $el is an internal property of the InputText component
keybindingInput.value?.$el?.focus()
}, 300)
}
@@ -265,18 +263,23 @@ function cancelEdit() {
}
async function saveKeybinding() {
- if (currentEditingCommand.value && newBindingKeyCombo.value) {
- const updated = keybindingStore.updateKeybindingOnCommand(
- new KeybindingImpl({
- commandId: currentEditingCommand.value.id,
- combo: newBindingKeyCombo.value
- })
- )
- if (updated) {
- await keybindingService.persistUserKeybindings()
- }
- }
+ if (!currentEditingCommand.value || !newBindingKeyCombo.value) return
+
+ // Capture values before closing dialog
+ const commandId = currentEditingCommand.value.id
+ const combo = newBindingKeyCombo.value
+
+ // Close dialog FIRST to prevent warning flash during store update
cancelEdit()
+
+ // Update store after dialog is closed
+ const updated = keybindingStore.updateKeybindingOnCommand(
+ new KeybindingImpl({ commandId, combo })
+ )
+
+ if (updated) {
+ await keybindingService.persistUserKeybindings()
+ }
}
async function resetKeybinding(commandData: ICommandData) {