From 48d928fc9ec4ef2c8496a6c41dba71194719fcbc Mon Sep 17 00:00:00 2001 From: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:43:33 +0100 Subject: [PATCH] feat: multi-keybinding support in settings panel (#9738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds VS Code-style multi-keybinding support to the Keybinding settings panel. Commands can now have multiple keybindings displayed, expanded, and individually managed. - Fixes #1088 ## Changes ### Store (`keybindingStore.ts`) - `removeAllKeybindingsForCommand(commandId)` — unsets all bindings for a command - `updateSpecificKeybinding(old, new)` — replaces a single binding without affecting others - `resetKeybindingForCommand` — updated to restore **all** default bindings, not just the first - `isCommandKeybindingModified` — updated to compare full sorted sets of bindings ### UI (`KeybindingPanel.vue`) - **Data model**: `keybinding: KeybindingImpl | null` → `keybindings: KeybindingImpl[]` - **Multi-binding display**: shows up to 2 combos inline with `, ` separator, then `+ N more` badge - **Expand/collapse**: click any row with 2+ bindings to expand individual binding rows; chevron-right icon rotates on expand - **Per-binding actions**: edit (pencil), reset, trash on each expanded sub-row - **Parent row actions**: `+`/trash for 2+ bindings, pencil/reset/trash for 1, `+`/disabled for 0 - **Edit modes**: `edit` (replace specific binding via `updateSpecificKeybinding`) and `add` (append via `addUserKeybinding`) - **Right-click context menu**: Change keybinding, Add new, Reset to default, Remove keybinding — with proper disabled states and lucide icons - **Remove all dialog**: confirmation via `showSmallLayoutDialog` with `RemoveAllKeybindingsHeader`/`Content` components - **Reset all dialog**: confirmation via `showConfirmDialog` before resetting all keybindings to defaults - **Double-click**: 0 bindings → add, 1 → edit, 2+ → no-op (single click toggles expand) - **Consistent alignment**: commands without chevron get `pl-5` padding to align with those that have it ### Tests (`keybindingStore.test.ts`) - 7 new tests covering `removeAllKeybindingsForCommand`, `updateSpecificKeybinding`, multi-binding `isCommandKeybindingModified`, and multi-binding `resetKeybindingForCommand` ### i18n (`main.json`) - 11 new keys: removeAllKeybindingsTitle/Message, removeAll, changeKeybinding, addNewKeybinding, resetToDefault, removeKeybinding, nMoreKeybindings, resetAllKeybindingsTitle/Message, allKeybindingsReset ### New components - `RemoveAllKeybindingsHeader.vue` — dialog header - `RemoveAllKeybindingsContent.vue` — dialog body with Close/Remove all buttons ## Test plan - [x] `pnpm typecheck` passes - [x] `pnpm lint` passes (no new errors) - [x] `pnpm vitest run src/platform/keybindings/` — 45 tests pass - [x] CodeRabbit review — 0 findings - [ ] Manual: open Settings → Keybindings, verify multi-binding commands (e.g. Delete Selected Items, Zoom In) show multiple combos - [ ] Manual: click row to expand, verify per-binding actions work - [ ] Manual: right-click row, verify context menu actions - [ ] Manual: click trash on 2+ binding command, verify "Remove all" confirmation dialog - [ ] Manual: click "Reset All" button, verify confirmation dialog appears - [ ] Manual: add/edit/remove individual bindings, verify persistence ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9738-feat-multi-keybinding-support-in-settings-panel-3206d73d365081e9b08bd3cfe21495f1) by [Unito](https://www.unito.io) --- browser_tests/tests/dialog.spec.ts | 8 +- .../content/setting/KeybindingPanel.vue | 543 ++++++++++++++---- .../keybinding/EditKeybindingFooter.vue | 17 +- .../setting/keybinding/KeyComboDisplay.vue | 5 +- src/composables/useEditKeybindingDialog.ts | 9 +- src/locales/en/main.json | 11 + .../keybindings/keybindingStore.test.ts | 142 +++++ src/platform/keybindings/keybindingStore.ts | 73 ++- 8 files changed, 664 insertions(+), 144 deletions(-) diff --git a/browser_tests/tests/dialog.spec.ts b/browser_tests/tests/dialog.spec.ts index 611c0c3c71..cef0cfa12b 100644 --- a/browser_tests/tests/dialog.spec.ts +++ b/browser_tests/tests/dialog.spec.ts @@ -328,9 +328,11 @@ test.describe('Settings', () => { }) await newBlankWorkflowRow.click() - // Click edit button - const editKeybindingButton = newBlankWorkflowRow.locator('.pi-pencil') - await editKeybindingButton.click() + // Click add keybinding button (New Blank Workflow has no default keybinding) + const addKeybindingButton = newBlankWorkflowRow.locator( + '.icon-\\[lucide--plus\\]' + ) + await addKeybindingButton.click() // Set new keybinding const input = comfyPage.page.getByPlaceholder('Enter your keybind') diff --git a/src/components/dialog/content/setting/KeybindingPanel.vue b/src/components/dialog/content/setting/KeybindingPanel.vue index db48f4d502..3fe79fc8f0 100644 --- a/src/components/dialog/content/setting/KeybindingPanel.vue +++ b/src/components/dialog/content/setting/KeybindingPanel.vue @@ -5,101 +5,257 @@ :placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })" /> - - - - - - - - - - - - - - + @select="ctxChangeKeybinding" + > + + {{ $t('g.changeKeybinding') }} + + + + {{ $t('g.addNewKeybinding') }} + + + + + {{ $t('g.resetToDefault') }} + + + + {{ $t('g.removeKeybinding') }} + + + + @@ -118,16 +274,26 @@ import { FilterMatchMode } from '@primevue/core/api' import Column from 'primevue/column' import DataTable from 'primevue/datatable' import { useToast } from 'primevue/usetoast' -import { computed, ref } from 'vue' +import { + ContextMenuContent, + ContextMenuItem, + ContextMenuPortal, + ContextMenuRoot, + ContextMenuSeparator, + ContextMenuTrigger +} from 'reka-ui' +import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' -import SearchInput from '@/components/ui/search-input/SearchInput.vue' +import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog' import Button from '@/components/ui/button/Button.vue' +import SearchInput from '@/components/ui/search-input/SearchInput.vue' import { useEditKeybindingDialog } from '@/composables/useEditKeybindingDialog' import type { KeybindingImpl } from '@/platform/keybindings/keybinding' import { useKeybindingService } from '@/platform/keybindings/keybindingService' import { useKeybindingStore } from '@/platform/keybindings/keybindingStore' import { useCommandStore } from '@/stores/commandStore' +import { useDialogStore } from '@/stores/dialogStore' import { normalizeI18nKey } from '@/utils/formatUtil' import KeyComboDisplay from './keybinding/KeyComboDisplay.vue' @@ -139,11 +305,12 @@ const filters = ref({ const keybindingStore = useKeybindingStore() const keybindingService = useKeybindingService() const commandStore = useCommandStore() +const dialogStore = useDialogStore() const { t } = useI18n() interface ICommandData { id: string - keybinding: KeybindingImpl | null + keybindings: KeybindingImpl[] label: string source?: string } @@ -155,31 +322,166 @@ const commandsData = computed(() => { `commands.${normalizeI18nKey(command.id)}.label`, command.label ?? '' ), - keybinding: keybindingStore.getKeybindingByCommandId(command.id), + keybindings: keybindingStore.getKeybindingsByCommandId(command.id), source: command.source })) }) +const expandedCommandIds = ref>(new Set()) + +const expandedRows = computed({ + get() { + const result: Record = {} + for (const id of expandedCommandIds.value) { + result[id] = true + } + return result + }, + set(value: Record) { + expandedCommandIds.value = new Set(Object.keys(value)) + } +}) + +function toggleExpanded(commandId: string) { + if (expandedCommandIds.value.has(commandId)) { + expandedCommandIds.value.delete(commandId) + } else { + expandedCommandIds.value.add(commandId) + } +} + +watch(filters, () => expandedCommandIds.value.clear(), { deep: true }) + const selectedCommandData = ref(null) const editKeybindingDialog = useEditKeybindingDialog() -function editKeybinding(commandData: ICommandData) { +const contextMenuTarget = ref(null) + +function editKeybinding(commandData: ICommandData, binding: KeybindingImpl) { editKeybindingDialog.show({ commandId: commandData.id, commandLabel: commandData.label, - currentCombo: commandData.keybinding?.combo ?? null + currentCombo: binding.combo, + mode: 'edit', + existingBinding: binding }) } -async function removeKeybinding(commandData: ICommandData) { - if (commandData.keybinding) { - keybindingStore.unsetKeybinding(commandData.keybinding) +function addKeybinding(commandData: ICommandData) { + editKeybindingDialog.show({ + commandId: commandData.id, + commandLabel: commandData.label, + currentCombo: null, + mode: 'add' + }) +} + +function handleRowClick(event: { originalEvent: Event; data: ICommandData }) { + const target = event.originalEvent.target as HTMLElement + if (target.closest('.actions')) return + const commandData = event.data + if ( + commandData.keybindings.length >= 2 || + expandedCommandIds.value.has(commandData.id) + ) { + toggleExpanded(commandData.id) + } +} + +function handleRowDblClick(commandData: ICommandData) { + if (commandData.keybindings.length === 0) { + addKeybinding(commandData) + } else if (commandData.keybindings.length === 1) { + editKeybinding(commandData, commandData.keybindings[0]) + } +} + +function handleRowContextMenu(event: { + originalEvent: Event + data: ICommandData +}) { + contextMenuTarget.value = event.data +} + +function clearContextMenuTarget() { + contextMenuTarget.value = null +} + +async function removeSingleKeybinding( + commandData: ICommandData, + index: number +) { + const binding = commandData.keybindings[index] + if (binding) { + keybindingStore.unsetKeybinding(binding) + if (commandData.keybindings.length <= 2) { + expandedCommandIds.value.delete(commandData.id) + } await keybindingService.persistUserKeybindings() } } +function handleRemoveAllKeybindings(commandData: ICommandData) { + const dialog = showConfirmDialog({ + headerProps: { title: t('g.removeAllKeybindingsTitle') }, + props: { promptText: t('g.removeAllKeybindingsMessage') }, + footerProps: { + confirmText: t('g.removeAll'), + confirmVariant: 'destructive', + onCancel: () => dialogStore.closeDialog(dialog), + onConfirm: async () => { + keybindingStore.removeAllKeybindingsForCommand(commandData.id) + await keybindingService.persistUserKeybindings() + dialogStore.closeDialog(dialog) + } + } + }) +} + +function handleRemoveKeybindingFromMenu(commandData: ICommandData) { + if (commandData.keybindings.length >= 2) { + handleRemoveAllKeybindings(commandData) + } else { + removeSingleKeybinding(commandData, 0) + } +} + +function ctxChangeKeybinding() { + if (!contextMenuTarget.value) return + const target = contextMenuTarget.value + if (target.keybindings.length === 1) { + editKeybinding(target, target.keybindings[0]) + } else if (target.keybindings.length >= 2) { + if (!expandedCommandIds.value.has(target.id)) { + toggleExpanded(target.id) + } + } +} + +function ctxAddKeybinding() { + if (contextMenuTarget.value) { + addKeybinding(contextMenuTarget.value) + } +} + +function ctxResetToDefault() { + if (contextMenuTarget.value) { + resetKeybinding(contextMenuTarget.value) + } +} + +function ctxRemoveKeybinding() { + if ( + contextMenuTarget.value && + contextMenuTarget.value.keybindings.length > 0 + ) { + handleRemoveKeybindingFromMenu(contextMenuTarget.value) + } +} + async function resetKeybinding(commandData: ICommandData) { if (keybindingStore.resetKeybindingForCommand(commandData.id)) { + expandedCommandIds.value.delete(commandData.id) await keybindingService.persistUserKeybindings() } else { console.warn( @@ -189,14 +491,33 @@ async function resetKeybinding(commandData: ICommandData) { } const toast = useToast() -async function resetAllKeybindings() { - keybindingStore.resetAllKeybindings() - await keybindingService.persistUserKeybindings() - toast.add({ - severity: 'info', - summary: 'Info', - detail: 'All keybindings reset', - life: 3000 + +function resetAllKeybindings() { + const dialog = showConfirmDialog({ + headerProps: { + title: t('g.resetAllKeybindingsTitle') + }, + props: { + promptText: t('g.resetAllKeybindingsMessage') + }, + footerProps: { + confirmText: t('g.resetAll'), + confirmVariant: 'destructive', + onCancel: () => { + dialogStore.closeDialog(dialog) + }, + onConfirm: async () => { + keybindingStore.resetAllKeybindings() + await keybindingService.persistUserKeybindings() + dialogStore.closeDialog(dialog) + toast.add({ + severity: 'info', + summary: t('g.info'), + detail: t('g.allKeybindingsReset'), + life: 3000 + }) + } + } }) } diff --git a/src/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue b/src/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue index 77b0fb85ce..012e5fce9c 100644 --- a/src/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue +++ b/src/components/dialog/content/setting/keybinding/EditKeybindingFooter.vue @@ -64,9 +64,18 @@ async function handleSave() { dialogStore.closeDialog({ key: DIALOG_KEY }) - const updated = keybindingStore.updateKeybindingOnCommand( - new KeybindingImpl({ commandId, combo }) - ) - if (updated) await keybindingService.persistUserKeybindings() + if (dialogState.mode === 'add') { + keybindingStore.addUserKeybinding(new KeybindingImpl({ commandId, combo })) + } else if (dialogState.existingBinding) { + keybindingStore.updateSpecificKeybinding( + dialogState.existingBinding, + new KeybindingImpl({ commandId, combo }) + ) + } else { + keybindingStore.updateKeybindingOnCommand( + new KeybindingImpl({ commandId, combo }) + ) + } + await keybindingService.persistUserKeybindings() } diff --git a/src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue b/src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue index 911a0f41b8..6e87002777 100644 --- a/src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue +++ b/src/components/dialog/content/setting/keybinding/KeyComboDisplay.vue @@ -1,7 +1,10 @@