Compare commits

...

2 Commits

Author SHA1 Message Date
Matt Miller
3b85227089 fix: hide API key login option on Cloud (#10343)
## Summary
- Hide the "Comfy API Key" login button and help text from the sign-in
modal when running on Cloud
- API key auth is only used on local ComfyUI; Cloud has
Firebase-whitelisted Google/GitHub auth
- The button was appearing on Cloud as a relic of shared code between
Cloud and local ComfyUI

## Context
[Discussion with Robin in
#proj-cloud-frontend](https://comfy-organization.slack.com/archives/C09FY39CC3V/p1773977756997559)
— API key auth only works on local because Firebase requires whitelisted
domains. On Cloud, SSO (Google/GitHub) works natively, so the API key
option is unnecessary and confusing.

<img width="470" height="865" alt="Screenshot 2026-03-20 at 9 53 20 AM"
src="https://github.com/user-attachments/assets/5bbdcbaf-243c-48c6-9bd0-aaae815925ea"
/>

## Test plan
- [ ] Verify login modal on local ComfyUI still shows the "Comfy API
Key" button
- [ ] Verify login modal on cloud.comfy.org no longer shows the "Comfy
API Key" button
2026-03-20 10:14:36 -07:00
Johnpaul Chiwetelu
944f78adf4 feat: import/export keybinding presets (#9681)
## Summary
- Add keybinding preset system: save, load, switch, import, export, and
delete named keybinding sets stored via `/api/userdata/keybindings/`
- Preset selector dropdown with "Save Changes" button for modified
custom presets, and "Import keybinding preset" action
- More-options menu in header row with save as new, reset, delete,
import, and export actions
- Search box and menu teleported to settings dialog header (matching
templates modal layout)
- 11 unit tests for preset service CRUD operations

Fixes #1084
Fixes #1085

## Test plan
- [ ] Open Settings > Keybinding, verify search box and "..." menu
appear in header
- [ ] Modify a keybinding, verify "Default *" shows modified indicator
- [ ] Use "Save as new preset" from menu, verify preset appears in
dropdown
- [ ] Switch between presets, verify unsaved changes prompt
- [ ] Export preset, import it back, verify bindings restored
- [ ] Delete a custom preset, verify reset to default
- [ ] Verify "Save Changes" button appears only on modified custom
presets
- [ ] Run `pnpm vitest run
src/platform/keybindings/presetService.test.ts`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9681-feat-import-export-keybinding-presets-31e6d73d3650810f88e4d21b3df3e2dd)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-20 12:24:31 +01:00
17 changed files with 1695 additions and 73 deletions

View File

@@ -33,6 +33,9 @@ export const TestIds = {
about: 'about-panel',
whatsNewSection: 'whats-new-section'
},
keybindings: {
presetMenu: 'keybinding-preset-menu'
},
topbar: {
queueButton: 'queue-button',
queueModeMenuTrigger: 'queue-mode-menu-trigger',
@@ -83,6 +86,7 @@ export type TestIdValue =
| (typeof TestIds.tree)[keyof typeof TestIds.tree]
| (typeof TestIds.canvas)[keyof typeof TestIds.canvas]
| (typeof TestIds.dialogs)[keyof typeof TestIds.dialogs]
| (typeof TestIds.keybindings)[keyof typeof TestIds.keybindings]
| (typeof TestIds.topbar)[keyof typeof TestIds.topbar]
| (typeof TestIds.nodeLibrary)[keyof typeof TestIds.nodeLibrary]
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]

View File

@@ -0,0 +1,257 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
const TEST_PRESET = {
name: 'test-preset',
newBindings: [
{
commandId: 'Comfy.Canvas.SelectAll',
combo: { key: 'a', ctrl: true, shift: true },
targetElementId: 'graph-canvas-container'
}
],
unsetBindings: [
{
commandId: 'Comfy.Canvas.SelectAll',
combo: { key: 'a', ctrl: true },
targetElementId: 'graph-canvas-container'
}
]
}
async function importPreset(page: Page, preset: typeof TEST_PRESET) {
const menuButton = page.getByTestId('keybinding-preset-menu')
await menuButton.click()
const fileChooserPromise = page.waitForEvent('filechooser')
await page.getByRole('menuitem', { name: /Import preset/i }).click()
const fileChooser = await fileChooserPromise
const presetPath = path.join(os.tmpdir(), 'test-preset.json')
fs.writeFileSync(presetPath, JSON.stringify(preset))
await fileChooser.setFiles(presetPath)
}
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.request.fetch(
`${comfyPage.url}/api/userdata/keybindings%2Ftest-preset.json`,
{ method: 'DELETE' }
)
await comfyPage.settings.setSetting(
'Comfy.Keybinding.CurrentPreset',
'default'
)
})
test.describe('Keybinding Presets', { tag: '@keyboard' }, () => {
test('Can import a preset, use remapped keybinding, and switch back to default', async ({
comfyPage
}) => {
test.setTimeout(30000)
const { page } = comfyPage
// Verify default Ctrl+A select-all works
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.canvas.press('Control+a')
await comfyPage.canvas.press('Delete')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss, then close settings via Escape
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
// Load workflow again, use new keybind Ctrl+Shift+A
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.canvas.press('Control+Shift+a')
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBeGreaterThan(0)
await comfyPage.canvas.press('Delete')
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
// Switch back to default preset
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await presetTrigger.click()
await page.getByRole('option', { name: /Default Preset/i }).click()
// Handle unsaved changes dialog if the preset was marked as modified
const discardButton = page.getByRole('button', {
name: /Discard and Switch/i
})
if (await discardButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await discardButton.click()
}
await expect(presetTrigger).toContainText('Default Preset')
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
})
test('Can export a preset and re-import it', async ({ comfyPage }) => {
test.setTimeout(30000)
const { page } = comfyPage
const menuButton = page.getByTestId('keybinding-preset-menu')
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Export via ellipsis menu
await menuButton.click()
const downloadPromise = page.waitForEvent('download')
await page.getByRole('menuitem', { name: /Export preset/i }).click()
const download = await downloadPromise
// Verify filename contains test-preset
expect(download.suggestedFilename()).toContain('test-preset')
// Close settings
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
// Verify the downloaded file is valid JSON with correct structure
const downloadPath = await download.path()
expect(downloadPath).toBeTruthy()
const content = fs.readFileSync(downloadPath!, 'utf-8')
const parsed = JSON.parse(content) as {
name: string
newBindings: unknown[]
unsetBindings: unknown[]
}
expect(parsed).toHaveProperty('name')
expect(parsed).toHaveProperty('newBindings')
expect(parsed).toHaveProperty('unsetBindings')
expect(parsed.name).toBe('test-preset')
})
test('Can delete an imported preset', async ({ comfyPage }) => {
test.setTimeout(30000)
const { page } = comfyPage
const menuButton = page.getByTestId('keybinding-preset-menu')
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Delete via ellipsis menu
await menuButton.click()
await page.getByRole('menuitem', { name: /Delete preset/i }).click()
// Confirm deletion in the dialog
const confirmDialog = page.getByRole('dialog', {
name: /Delete the current preset/i
})
await confirmDialog.getByRole('button', { name: /Delete/i }).click()
// Verify preset trigger now shows Default Preset
await expect(presetTrigger).toContainText('Default Preset')
// Close settings
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
})
test('Can save modifications as a new preset', async ({ comfyPage }) => {
test.setTimeout(30000)
const { page } = comfyPage
const menuButton = page.getByTestId('keybinding-preset-menu')
// Open keybinding settings panel
await comfyPage.settingDialog.open()
await comfyPage.settingDialog.category('Keybinding').click()
await importPreset(page, TEST_PRESET)
// Verify active preset switched to test-preset
const presetTrigger = page
.locator('#keybinding-panel-actions')
.locator('button[role="combobox"]')
await expect(presetTrigger).toContainText('test-preset')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Save as new preset via ellipsis menu
await menuButton.click()
await page.getByRole('menuitem', { name: /Save as new preset/i }).click()
// Fill in the preset name in the prompt dialog
const promptInput = page.locator('.prompt-dialog-content input')
await promptInput.fill('my-custom-preset')
await promptInput.press('Enter')
// Wait for toast to auto-dismiss
await expect(comfyPage.toast.visibleToasts).toHaveCount(0, {
timeout: 5000
})
// Verify preset trigger shows my-custom-preset
await expect(presetTrigger).toContainText('my-custom-preset')
// Close settings
await page.keyboard.press('Escape')
await comfyPage.settingDialog.waitForHidden()
// Cleanup: delete the my-custom-preset file
await comfyPage.request.fetch(
`${comfyPage.url}/api/userdata/keybindings%2Fmy-custom-preset.json`,
{ method: 'DELETE' }
)
})
})

View File

@@ -12,6 +12,7 @@ import { computed, toValue } from 'vue'
import DropdownItem from '@/components/common/DropdownItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
import type { ButtonVariants } from '../ui/button/button.variants'
defineOptions({
inheritAttrs: false
@@ -23,6 +24,8 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
to?: string | HTMLElement
itemClass?: string
contentClass?: string
buttonSize?: ButtonVariants['size']
buttonClass?: string
}>()
const itemClass = computed(() =>
@@ -44,7 +47,7 @@ const contentClass = computed(() =>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<slot name="button">
<Button size="icon">
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
<i :class="icon ?? 'icon-[lucide--menu]'" />
</Button>
</slot>

View File

@@ -77,29 +77,31 @@
</Button>
</template>
<Button
type="button"
class="h-10"
variant="secondary"
@click="showApiKeyForm = true"
>
<img
src="/assets/images/comfy-logo-mono.svg"
class="mr-2 size-5"
:alt="$t('g.comfy')"
/>
{{ t('auth.login.useApiKey') }}
</Button>
<small class="text-center text-muted">
{{ t('auth.apiKey.helpText') }}
<a
:href="`${comfyPlatformBaseUrl}/login`"
target="_blank"
class="cursor-pointer text-blue-500"
<template v-if="!isCloud">
<Button
type="button"
class="h-10"
variant="secondary"
@click="showApiKeyForm = true"
>
{{ t('auth.apiKey.generateKey') }}
</a>
</small>
<img
src="/assets/images/comfy-logo-mono.svg"
class="mr-2 size-5"
:alt="$t('g.comfy')"
/>
{{ t('auth.login.useApiKey') }}
</Button>
<small class="text-center text-muted">
{{ t('auth.apiKey.helpText') }}
<a
:href="`${comfyPlatformBaseUrl}/login`"
target="_blank"
class="cursor-pointer text-blue-500"
>
{{ t('auth.apiKey.generateKey') }}
</a>
</small>
</template>
<Message
v-if="authActions.accessError.value"
severity="info"
@@ -152,6 +154,7 @@ import {
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { isCloud } from '@/platform/distribution/types'
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
import { isInChina } from '@/utils/networkUtil'

View File

@@ -1,9 +1,41 @@
<template>
<div class="keybinding-panel flex flex-col gap-2">
<SearchInput
v-model="filters['global'].value"
:placeholder="$t('g.searchPlaceholder', { subject: $t('g.keybindings') })"
/>
<Teleport defer to="#keybinding-panel-header">
<SearchInput
v-model="filters['global'].value"
class="max-w-96"
size="lg"
:placeholder="
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
"
/>
</Teleport>
<Teleport defer to="#keybinding-panel-actions">
<div class="flex items-center gap-2">
<KeybindingPresetToolbar
:preset-names="presetNames"
@presets-changed="refreshPresetList"
/>
<DropdownMenu
:entries="menuEntries"
icon="icon-[lucide--ellipsis]"
item-class="text-sm gap-2"
button-size="unset"
button-class="size-10"
>
<template #button>
<Button
size="unset"
class="size-10"
data-testid="keybinding-preset-menu"
>
<i class="icon-[lucide--ellipsis]" />
</Button>
</template>
</DropdownMenu>
</div>
</Teleport>
<ContextMenuRoot>
<ContextMenuTrigger as-child>
@@ -15,6 +47,9 @@
data-key="id"
:global-filter-fields="['id', 'label']"
:filters="filters"
:paginator="true"
:rows="50"
:rows-per-page-options="[25, 50, 100]"
selection-mode="single"
context-menu
striped-rows
@@ -77,11 +112,7 @@
<span v-if="idx > 0" class="text-muted-foreground">,</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
:is-modified="slotProps.data.isModified"
/>
</template>
<span
@@ -141,11 +172,7 @@
variant="textonly"
size="icon"
:aria-label="$t('g.reset')"
:disabled="
!keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
:disabled="!slotProps.data.isModified"
@click="resetKeybinding(slotProps.data)"
>
<i class="icon-[lucide--rotate-ccw]" />
@@ -177,11 +204,7 @@
}}</span>
<KeyComboDisplay
:key-combo="binding.combo"
:is-modified="
keybindingStore.isCommandKeybindingModified(
slotProps.data.id
)
"
:is-modified="slotProps.data.isModified"
/>
</div>
<div class="flex flex-row">
@@ -234,10 +257,7 @@
<ContextMenuSeparator class="my-1 h-px bg-border-subtle" />
<ContextMenuItem
class="flex cursor-pointer items-center gap-2 rounded-sm px-3 py-2 text-sm text-text-primary outline-none select-none hover:bg-node-component-surface-hovered focus:bg-node-component-surface-hovered data-disabled:cursor-default data-disabled:opacity-50"
:disabled="
!contextMenuTarget ||
!keybindingStore.isCommandKeybindingModified(contextMenuTarget.id)
"
:disabled="!contextMenuTarget?.isModified"
@select="ctxResetToDefault"
>
<i class="icon-[lucide--rotate-ccw] size-4" />
@@ -270,6 +290,7 @@
</template>
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import { FilterMatchMode } from '@primevue/core/api'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
@@ -282,9 +303,10 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import DropdownMenu from '@/components/common/DropdownMenu.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'
@@ -292,10 +314,13 @@ 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 { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import KeybindingPresetToolbar from './keybinding/KeybindingPresetToolbar.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
const filters = ref({
@@ -304,15 +329,97 @@ const filters = ref({
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const presetService = useKeybindingPresetService()
const settingStore = useSettingStore()
const commandStore = useCommandStore()
const dialogStore = useDialogStore()
const { t } = useI18n()
const presetNames = ref<string[]>([])
async function refreshPresetList() {
presetNames.value = (await presetService.listPresets()) ?? []
}
async function initPresets() {
await refreshPresetList()
const currentName = settingStore.get('Comfy.Keybinding.CurrentPreset')
if (currentName !== 'default') {
const preset = await presetService.loadPreset(currentName)
if (preset) {
keybindingStore.savedPresetData = preset
keybindingStore.currentPresetName = currentName
} else {
await presetService.switchToDefaultPreset()
}
}
}
onMounted(() => initPresets())
// "..." menu entries (teleported to header)
async function saveAsNewPreset() {
await presetService.promptAndSaveNewPreset()
refreshPresetList()
}
async function handleDeletePreset() {
await presetService.deletePreset(keybindingStore.currentPresetName)
refreshPresetList()
}
async function handleImportPreset() {
await presetService.importPreset()
refreshPresetList()
}
const showSaveAsNew = computed(
() =>
keybindingStore.currentPresetName !== 'default' ||
keybindingStore.isCurrentPresetModified
)
const menuEntries = computed<MenuItem[]>(() => [
...(showSaveAsNew.value
? [
{
label: t('g.keybindingPresets.saveAsNewPreset'),
icon: 'icon-[lucide--save]',
command: saveAsNewPreset
}
]
: []),
{
label: t('g.keybindingPresets.resetToDefault'),
icon: 'icon-[lucide--rotate-cw]',
command: () =>
presetService.switchPreset('default').then(() => refreshPresetList())
},
{
label: t('g.keybindingPresets.deletePreset'),
icon: 'icon-[lucide--trash-2]',
disabled: keybindingStore.currentPresetName === 'default',
command: handleDeletePreset
},
{
label: t('g.keybindingPresets.importPreset'),
icon: 'icon-[lucide--file-input]',
command: handleImportPreset
},
{
label: t('g.keybindingPresets.exportPreset'),
icon: 'icon-[lucide--file-output]',
command: () => presetService.exportPreset()
}
])
// Keybinding table logic
interface ICommandData {
id: string
keybindings: KeybindingImpl[]
label: string
source?: string
isModified: boolean
}
const commandsData = computed<ICommandData[]>(() => {
@@ -323,7 +430,8 @@ const commandsData = computed<ICommandData[]>(() => {
command.label ?? ''
),
keybindings: keybindingStore.getKeybindingsByCommandId(command.id),
source: command.source
source: command.source,
isModified: keybindingStore.isCommandKeybindingModified(command.id)
}))
})

View File

@@ -0,0 +1,111 @@
<template>
<div class="flex items-center gap-2">
<Button v-if="showSaveButton" size="lg" @click="handleSavePreset">
{{ $t('g.keybindingPresets.saveChanges') }}
</Button>
<Select v-model="selectedPreset">
<SelectTrigger class="w-64">
<SelectValue :placeholder="$t('g.keybindingPresets.default')">
{{ displayLabel }}
</SelectValue>
</SelectTrigger>
<SelectContent class="max-w-64 min-w-0 **:[[role=listbox]]:gap-1">
<div class="max-w-60">
<SelectItem
value="default"
class="max-w-60 p-2 data-[state=checked]:bg-transparent"
>
{{ $t('g.keybindingPresets.default') }}
</SelectItem>
<SelectItem
v-for="name in presetNames"
:key="name"
:value="name"
class="max-w-60 p-2 data-[state=checked]:bg-transparent"
>
{{ name }}
</SelectItem>
<hr class="h-px max-w-60 border border-border-default" />
<button
class="relative flex w-full max-w-60 cursor-pointer items-center justify-between gap-3 rounded-sm border-none bg-transparent p-2 text-sm outline-none select-none hover:bg-secondary-background-hover focus:bg-secondary-background-hover"
@click.stop="handleImportFromDropdown"
>
<span class="truncate">
{{ $t('g.keybindingPresets.importKeybindingPreset') }}
</span>
<i
class="icon-[lucide--file-input] shrink-0 text-base-foreground"
aria-hidden="true"
/>
</button>
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import { useKeybindingPresetService } from '@/platform/keybindings/presetService'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
const { presetNames } = defineProps<{
presetNames: string[]
}>()
const emit = defineEmits<{
'presets-changed': []
}>()
const { t } = useI18n()
const keybindingStore = useKeybindingStore()
const presetService = useKeybindingPresetService()
const selectedPreset = ref(keybindingStore.currentPresetName)
const displayLabel = computed(() => {
const name =
selectedPreset.value === 'default'
? t('g.keybindingPresets.default')
: selectedPreset.value
return keybindingStore.isCurrentPresetModified ? `${name} *` : name
})
watch(selectedPreset, async (newValue) => {
if (newValue !== keybindingStore.currentPresetName) {
await presetService.switchPreset(newValue)
selectedPreset.value = keybindingStore.currentPresetName
emit('presets-changed')
}
})
watch(
() => keybindingStore.currentPresetName,
(name) => {
selectedPreset.value = name
}
)
const showSaveButton = computed(
() =>
keybindingStore.currentPresetName !== 'default' &&
keybindingStore.isCurrentPresetModified
)
async function handleSavePreset() {
await presetService.savePreset(keybindingStore.currentPresetName)
}
async function handleImportFromDropdown() {
await presetService.importPreset()
emit('presets-changed')
}
</script>

View File

@@ -0,0 +1,42 @@
<template>
<div
class="flex w-full max-w-[420px] flex-col border-t border-border-default"
>
<div class="flex flex-col gap-4 p-4">
<p class="m-0 text-sm text-muted-foreground">
{{ $t('g.keybindingPresets.unsavedChangesMessage') }}
</p>
<div class="flex justify-end gap-2">
<Button
variant="textonly"
class="text-muted-foreground"
@click="onResult(null)"
>
{{ $t('g.cancel') }}
</Button>
<Button
variant="secondary"
class="bg-secondary-background"
@click="onResult(false)"
>
{{ $t('g.keybindingPresets.discardAndSwitch') }}
</Button>
<Button
variant="secondary"
class="bg-base-foreground text-base-background"
@click="onResult(true)"
>
{{ $t('g.keybindingPresets.saveAndSwitch') }}
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
const { onResult } = defineProps<{
onResult: (result: boolean | null) => void
}>()
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div class="flex w-full items-center p-4">
<p class="m-0 text-sm font-medium">
{{ $t('g.keybindingPresets.unsavedChangesTo', { name: presetName }) }}
</p>
</div>
</template>
<script setup lang="ts">
const { presetName } = defineProps<{
presetName: string
}>()
</script>

View File

@@ -287,6 +287,32 @@
"browserReservedKeybinding": "This shortcut is reserved by some browsers and may have unexpected results.",
"browserReservedKeybindingTooltip": "This shortcut conflicts with browser-reserved shortcuts",
"keybindingAlreadyExists": "Keybinding already exists on",
"keybindingPresets": {
"importPreset": "Import preset",
"importKeybindingPreset": "Import keybinding preset",
"exportPreset": "Export preset",
"saveChanges": "Save Changes",
"saveAsNewPreset": "Save as new preset",
"resetToDefault": "Reset to default",
"deletePreset": "Delete preset",
"unsavedChangesTo": "Unsaved changes to {name}",
"unsavedChangesMessage": "You have unsaved changes that will be lost if you switch without saving.",
"discardAndSwitch": "Discard and Switch",
"saveAndSwitch": "Save and Switch",
"deletePresetTitle": "Delete the current preset?",
"deletePresetWarning": "This preset will be deleted. This cannot be undone.",
"presetSaved": "Preset \"{name}\" saved",
"presetDeleted": "Preset \"{name}\" deleted",
"presetImported": "Keybinding preset imported",
"invalidPresetFile": "Preset file must be valid JSON exported from ComfyUI",
"invalidPresetName": "Preset name must not be empty, \"default\", start with a dot, contain path separators, or end with .json",
"loadPresetFailed": "Failed to load preset \"{name}\"",
"deletePresetFailed": "Failed to delete preset \"{name}\"",
"overwritePresetTitle": "Overwrite Preset",
"overwritePresetMessage": "A preset named \"{name}\" already exists. Overwrite it?",
"presetNamePrompt": "Enter a name for the preset",
"default": "Default Preset"
},
"commandProhibited": "Command {command} is prohibited. Contact an administrator for more information.",
"startRecording": "Start Recording",
"stopRecording": "Stop Recording",

View File

@@ -1,15 +1,54 @@
import { groupBy } from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import type { Ref } from 'vue'
import { computed, ref } from 'vue'
import { computed, ref, shallowRef } from 'vue'
import type { KeyComboImpl } from './keyCombo'
import type { KeybindingImpl } from './keybinding'
import { KeybindingImpl } from './keybinding'
import type { KeybindingPreset } from './types'
export const useKeybindingStore = defineStore('keybinding', () => {
const defaultKeybindings = ref<Record<string, KeybindingImpl>>({})
const userKeybindings = ref<Record<string, KeybindingImpl>>({})
const userUnsetKeybindings = ref<Record<string, KeybindingImpl>>({})
const defaultKeybindings = shallowRef<Record<string, KeybindingImpl>>({})
const userKeybindings = shallowRef<Record<string, KeybindingImpl>>({})
const userUnsetKeybindings = shallowRef<Record<string, KeybindingImpl>>({})
const currentPresetName = ref('default')
const savedPresetData = ref<KeybindingPreset | null>(null)
const serializeBinding = (b: KeybindingImpl) =>
`${b.commandId}:${b.combo.serialize()}:${b.targetElementId ?? ''}`
const savedPresetSerialized = computed(() => {
if (!savedPresetData.value) return null
const savedNew = savedPresetData.value.newBindings
.map((b) => serializeBinding(new KeybindingImpl(b)))
.sort()
.join('|')
const savedUnset = savedPresetData.value.unsetBindings
.map((b) => serializeBinding(new KeybindingImpl(b)))
.sort()
.join('|')
return { savedNew, savedUnset }
})
const isCurrentPresetModified = computed(() => {
const newBindings = Object.values(userKeybindings.value)
const unsetBindings = Object.values(userUnsetKeybindings.value)
if (currentPresetName.value === 'default') {
return newBindings.length > 0 || unsetBindings.length > 0
}
if (!savedPresetSerialized.value) return false
const currentNew = newBindings.map(serializeBinding).sort().join('|')
const currentUnset = unsetBindings.map(serializeBinding).sort().join('|')
return (
currentNew !== savedPresetSerialized.value.savedNew ||
currentUnset !== savedPresetSerialized.value.savedUnset
)
})
function getUserKeybindings() {
return userKeybindings.value
@@ -77,7 +116,10 @@ export const useKeybindingStore = defineStore('keybinding', () => {
}`
)
}
target.value[keybinding.combo.serialize()] = keybinding
target.value = {
...target.value,
[keybinding.combo.serialize()]: keybinding
}
}
function addDefaultKeybinding(keybinding: KeybindingImpl) {
@@ -94,7 +136,9 @@ export const useKeybindingStore = defineStore('keybinding', () => {
keybinding.equals(defaultKeybinding) &&
keybinding.equals(userUnsetKeybinding)
) {
delete userUnsetKeybindings.value[keybinding.combo.serialize()]
const updated = { ...userUnsetKeybindings.value }
delete updated[keybinding.combo.serialize()]
userUnsetKeybindings.value = updated
return
}
@@ -115,7 +159,9 @@ export const useKeybindingStore = defineStore('keybinding', () => {
}
if (userKeybindings.value[serializedCombo]?.equals(keybinding)) {
delete userKeybindings.value[serializedCombo]
const updated = { ...userKeybindings.value }
delete updated[serializedCombo]
userKeybindings.value = updated
return
}
@@ -183,31 +229,53 @@ export const useKeybindingStore = defineStore('keybinding', () => {
unsetKeybinding(binding)
}
const updatedUnset = { ...userUnsetKeybindings.value }
for (const defaultBinding of defaultBindings) {
const serializedCombo = defaultBinding.combo.serialize()
if (userUnsetKeybindings.value[serializedCombo]?.equals(defaultBinding)) {
delete userUnsetKeybindings.value[serializedCombo]
if (updatedUnset[serializedCombo]?.equals(defaultBinding)) {
delete updatedUnset[serializedCombo]
}
}
userUnsetKeybindings.value = updatedUnset
return true
}
const modifiedCommandIds = computed<Set<string>>(() => {
const result = new Set<string>()
const allCommandIds = new Set([
...Object.keys(keybindingsByCommandId.value),
...Object.keys(defaultKeybindingsByCommandId.value)
])
for (const commandId of allCommandIds) {
const currentBindings = keybindingsByCommandId.value[commandId] ?? []
const defaultBindings =
defaultKeybindingsByCommandId.value[commandId] ?? []
if (currentBindings.length !== defaultBindings.length) {
result.add(commandId)
continue
}
if (currentBindings.length === 0) continue
const sortedCurrent = [...currentBindings]
.map((b) => b.combo.serialize())
.sort()
const sortedDefault = [...defaultBindings]
.map((b) => b.combo.serialize())
.sort()
if (sortedCurrent.some((combo, i) => combo !== sortedDefault[i])) {
result.add(commandId)
}
}
return result
})
function isCommandKeybindingModified(commandId: string): boolean {
const currentBindings = getKeybindingsByCommandId(commandId)
const defaultBindings = defaultKeybindingsByCommandId.value[commandId] ?? []
if (currentBindings.length !== defaultBindings.length) return true
if (currentBindings.length === 0) return false
const sortedCurrent = [...currentBindings]
.map((b) => b.combo.serialize())
.sort()
const sortedDefault = [...defaultBindings]
.map((b) => b.combo.serialize())
.sort()
return sortedCurrent.some((combo, i) => combo !== sortedDefault[i])
return modifiedCommandIds.value.has(commandId)
}
return {
@@ -224,6 +292,9 @@ export const useKeybindingStore = defineStore('keybinding', () => {
resetAllKeybindings,
resetKeybindingForCommand,
isCommandKeybindingModified,
currentPresetName,
savedPresetData,
isCurrentPresetModified,
removeAllKeybindingsForCommand,
updateSpecificKeybinding
}

View File

@@ -0,0 +1,665 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { KeybindingImpl } from '@/platform/keybindings/keybinding'
import { useKeybindingStore } from '@/platform/keybindings/keybindingStore'
import type { KeybindingPreset } from '@/platform/keybindings/types'
const mockApi = vi.hoisted(() => ({
listUserDataFullInfo: vi.fn(),
getUserData: vi.fn(),
storeUserData: vi.fn(),
deleteUserData: vi.fn()
}))
const mockDownloadBlob = vi.hoisted(() => vi.fn())
const mockUploadFile = vi.hoisted(() => vi.fn())
const mockConfirm = vi.hoisted(() => vi.fn().mockResolvedValue(true))
const mockPrompt = vi.hoisted(() => vi.fn().mockResolvedValue('test-preset'))
const mockShowSmallLayoutDialog = vi.hoisted(() =>
vi.fn().mockImplementation((options: Record<string, unknown>) => {
const props = options.props as Record<string, unknown> | undefined
const onResult = props?.onResult as ((v: boolean) => void) | undefined
onResult?.(true)
})
)
const mockSettingSet = vi.hoisted(() => vi.fn())
const mockToastAdd = vi.hoisted(() => vi.fn())
const mockPersistUserKeybindings = vi.hoisted(() =>
vi.fn().mockResolvedValue(undefined)
)
vi.mock('@/scripts/api', () => ({
api: mockApi
}))
vi.mock('@/base/common/downloadUtil', () => ({
downloadBlob: mockDownloadBlob
}))
vi.mock('@/scripts/utils', () => ({
uploadFile: mockUploadFile
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
confirm: mockConfirm,
prompt: mockPrompt,
showSmallLayoutDialog: mockShowSmallLayoutDialog
})
}))
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => ({
set: mockSettingSet,
get: vi.fn().mockReturnValue('default')
})
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({
add: mockToastAdd
})
}))
vi.mock('@/composables/useErrorHandling', () => ({
useErrorHandling: () => ({
wrapWithErrorHandling: <T extends (...args: unknown[]) => unknown>(fn: T) =>
fn,
wrapWithErrorHandlingAsync: <T extends (...args: unknown[]) => unknown>(
fn: T
) => fn,
toastErrorHandler: vi.fn()
})
}))
vi.mock('@/platform/keybindings/keybindingService', () => ({
useKeybindingService: () => ({
persistUserKeybindings: mockPersistUserKeybindings
})
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
showDialog: vi.fn(),
closeDialog: vi.fn(),
dialogStack: []
})
}))
vi.mock('@/i18n', () => ({
t: (key: string) => key
}))
describe('useKeybindingPresetService', () => {
let store: ReturnType<typeof useKeybindingStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useKeybindingStore()
})
async function getPresetService() {
const { useKeybindingPresetService } = await import('./presetService')
return useKeybindingPresetService()
}
describe('listPresets', () => {
it('parses API response correctly', async () => {
mockApi.listUserDataFullInfo.mockResolvedValue([
{ path: 'vim.json', size: 100, modified: 123 },
{ path: 'emacs.json', size: 200, modified: 456 }
])
const service = await getPresetService()
const presets = await service.listPresets()
expect(mockApi.listUserDataFullInfo).toHaveBeenCalledWith('keybindings')
expect(presets).toEqual(['vim', 'emacs'])
})
it('returns empty array when no presets exist', async () => {
mockApi.listUserDataFullInfo.mockResolvedValue([])
const service = await getPresetService()
const presets = await service.listPresets()
expect(presets).toEqual([])
})
})
describe('savePreset', () => {
it('calls storeUserData with correct path and data', async () => {
mockApi.storeUserData.mockResolvedValue(new Response())
const keybinding = new KeybindingImpl({
commandId: 'test.cmd',
combo: { key: 'A', ctrl: true }
})
store.addUserKeybinding(keybinding)
const service = await getPresetService()
await service.savePreset('my-preset')
expect(mockApi.storeUserData).toHaveBeenCalledWith(
'keybindings/my-preset.json',
expect.stringContaining('"name":"my-preset"'),
{ overwrite: true, stringify: false }
)
expect(store.currentPresetName).toBe('my-preset')
})
it('does not update store when storeUserData rejects', async () => {
mockApi.storeUserData.mockRejectedValue(new Error('Server error'))
const keybinding = new KeybindingImpl({
commandId: 'test.cmd',
combo: { key: 'A', ctrl: true }
})
store.addUserKeybinding(keybinding)
store.currentPresetName = 'old-preset'
const service = await getPresetService()
await expect(service.savePreset('my-preset')).rejects.toThrow(
'Server error'
)
expect(store.currentPresetName).toBe('old-preset')
expect(store.savedPresetData).toBeNull()
})
})
describe('deletePreset', () => {
it('calls deleteUserData and resets to default if active', async () => {
mockApi.deleteUserData.mockResolvedValue(
new Response(null, { status: 200 })
)
store.currentPresetName = 'vim'
store.addUserKeybinding(
new KeybindingImpl({
commandId: 'test.cmd',
combo: { key: 'A', ctrl: true }
})
)
const service = await getPresetService()
await service.deletePreset('vim')
expect(mockApi.deleteUserData).toHaveBeenCalledWith(
'keybindings/vim.json'
)
expect(store.currentPresetName).toBe('default')
expect(Object.keys(store.getUserKeybindings())).toHaveLength(0)
})
it('throws when deleteUserData response is not ok', async () => {
mockApi.deleteUserData.mockResolvedValue(
new Response(null, { status: 500 })
)
store.currentPresetName = 'vim'
const service = await getPresetService()
await expect(service.deletePreset('vim')).rejects.toThrow(
'g.keybindingPresets.deletePresetFailed'
)
})
})
describe('exportPreset', () => {
it('calls downloadBlob with correct JSON', async () => {
store.currentPresetName = 'my-preset'
const service = await getPresetService()
service.exportPreset()
expect(mockDownloadBlob).toHaveBeenCalledWith(
'my-preset.json',
expect.any(Blob)
)
})
})
describe('importPreset', () => {
it('validates and rejects invalid files', async () => {
mockUploadFile.mockResolvedValue(
new File(['{"invalid": true}'], 'bad.json', {
type: 'application/json'
})
)
const service = await getPresetService()
await expect(service.importPreset()).rejects.toThrow()
})
it('saves preset to storage and switches to it', async () => {
const validPreset: KeybindingPreset = {
name: 'imported',
newBindings: [
{ commandId: 'test.cmd', combo: { key: 'B', alt: true } }
],
unsetBindings: []
}
mockUploadFile.mockResolvedValue(
new File([JSON.stringify(validPreset)], 'imported.json', {
type: 'application/json'
})
)
mockApi.storeUserData.mockResolvedValue(new Response())
mockApi.getUserData.mockResolvedValue(
new Response(JSON.stringify(validPreset), { status: 200 })
)
const service = await getPresetService()
await service.importPreset()
expect(mockApi.storeUserData).toHaveBeenCalledWith(
'keybindings/imported.json',
JSON.stringify(validPreset),
{ overwrite: true, stringify: false }
)
expect(store.currentPresetName).toBe('imported')
expect(Object.keys(store.getUserKeybindings())).toHaveLength(1)
})
})
describe('presetFilePath sanitization', () => {
it('rejects names with path separators', async () => {
const service = await getPresetService()
await expect(service.savePreset('../evil')).rejects.toThrow()
await expect(service.savePreset('foo/bar')).rejects.toThrow()
await expect(service.savePreset('foo\\bar')).rejects.toThrow()
})
it('rejects names starting with a dot', async () => {
const service = await getPresetService()
await expect(service.savePreset('.hidden')).rejects.toThrow()
})
it('rejects the reserved name "default"', async () => {
const service = await getPresetService()
await expect(service.savePreset('default')).rejects.toThrow()
})
it('rejects names ending with .json extension', async () => {
const service = await getPresetService()
await expect(service.savePreset('vim.json')).rejects.toThrow()
await expect(service.savePreset('preset.JSON')).rejects.toThrow()
})
it('rejects empty names', async () => {
const service = await getPresetService()
await expect(service.savePreset('')).rejects.toThrow()
await expect(service.savePreset(' ')).rejects.toThrow()
})
})
describe('loadPreset name override', () => {
it('overrides embedded name with the requested name', async () => {
const presetData = {
name: 'wrong-name',
newBindings: [],
unsetBindings: []
}
mockApi.getUserData.mockResolvedValue(
new Response(JSON.stringify(presetData), { status: 200 })
)
const service = await getPresetService()
const loaded = await service.loadPreset('correct-name')
expect(loaded?.name).toBe('correct-name')
})
})
describe('isCurrentPresetModified', () => {
it('detects modifications when on default preset', () => {
expect(store.isCurrentPresetModified).toBe(false)
store.addUserKeybinding(
new KeybindingImpl({
commandId: 'test.cmd',
combo: { key: 'A', ctrl: true }
})
)
expect(store.isCurrentPresetModified).toBe(true)
})
it('detects no modifications when saved data matches current state', () => {
const keybinding = new KeybindingImpl({
commandId: 'test.cmd',
combo: { key: 'A', ctrl: true }
})
store.addUserKeybinding(keybinding)
store.currentPresetName = 'my-preset'
store.savedPresetData = {
name: 'my-preset',
newBindings: [
{ commandId: 'test.cmd', combo: { key: 'A', ctrl: true } }
],
unsetBindings: []
}
expect(store.isCurrentPresetModified).toBe(false)
})
it('detects modifications when saved data differs from current state', () => {
store.addUserKeybinding(
new KeybindingImpl({
commandId: 'test.cmd',
combo: { key: 'A', ctrl: true }
})
)
store.currentPresetName = 'my-preset'
store.savedPresetData = {
name: 'my-preset',
newBindings: [
{ commandId: 'test.cmd', combo: { key: 'B', alt: true } }
],
unsetBindings: []
}
expect(store.isCurrentPresetModified).toBe(true)
})
})
describe('applyPreset', () => {
it('resets keybindings and applies preset data', async () => {
store.addUserKeybinding(
new KeybindingImpl({
commandId: 'old.cmd',
combo: { key: 'Z', ctrl: true }
})
)
const preset: KeybindingPreset = {
name: 'vim',
newBindings: [
{ commandId: 'new.cmd', combo: { key: 'A', ctrl: true } }
],
unsetBindings: []
}
const service = await getPresetService()
service.applyPreset(preset)
expect(store.currentPresetName).toBe('vim')
expect(store.savedPresetData?.name).toBe('vim')
expect(store.savedPresetData?.newBindings).toHaveLength(1)
expect(store.savedPresetData?.newBindings[0].commandId).toBe('new.cmd')
expect(Object.keys(store.getUserKeybindings())).toHaveLength(1)
const bindings = Object.values(store.getUserKeybindings())
expect(bindings[0].commandId).toBe('new.cmd')
})
})
describe('switchPreset', () => {
it('discards unsaved changes when dialog returns false', async () => {
store.addUserKeybinding(
new KeybindingImpl({
commandId: 'dirty.cmd',
combo: { key: 'X', ctrl: true }
})
)
mockShowSmallLayoutDialog.mockImplementationOnce(
(options: Record<string, unknown>) => {
const props = options.props as Record<string, unknown> | undefined
const onResult = props?.onResult as ((v: boolean) => void) | undefined
onResult?.(false)
}
)
const targetPreset: KeybindingPreset = {
name: 'vim',
newBindings: [
{ commandId: 'vim.cmd', combo: { key: 'J', ctrl: false } }
],
unsetBindings: []
}
mockApi.getUserData.mockResolvedValueOnce(
new Response(JSON.stringify(targetPreset), { status: 200 })
)
const service = await getPresetService()
await service.switchPreset('vim')
expect(store.currentPresetName).toBe('vim')
})
it('saves unsaved changes when dialog returns true on non-default preset', async () => {
store.currentPresetName = 'my-preset'
store.savedPresetData = {
name: 'my-preset',
newBindings: [],
unsetBindings: []
}
store.addUserKeybinding(
new KeybindingImpl({
commandId: 'dirty.cmd',
combo: { key: 'X', ctrl: true }
})
)
mockApi.storeUserData.mockResolvedValueOnce(new Response())
const targetPreset: KeybindingPreset = {
name: 'vim',
newBindings: [
{ commandId: 'vim.cmd', combo: { key: 'J', ctrl: false } }
],
unsetBindings: []
}
mockApi.getUserData.mockResolvedValueOnce(
new Response(JSON.stringify(targetPreset), { status: 200 })
)
const service = await getPresetService()
await service.switchPreset('vim')
expect(mockApi.storeUserData).toHaveBeenCalledWith(
'keybindings/my-preset.json',
expect.any(String),
{ overwrite: true, stringify: false }
)
expect(store.currentPresetName).toBe('vim')
})
it('cancels switch when dialog returns null', async () => {
store.addUserKeybinding(
new KeybindingImpl({
commandId: 'dirty.cmd',
combo: { key: 'X', ctrl: true }
})
)
mockShowSmallLayoutDialog.mockImplementationOnce(
(options: Record<string, unknown>) => {
const dialogComponentProps = options.dialogComponentProps as
| Record<string, unknown>
| undefined
const onClose = dialogComponentProps?.onClose as
| (() => void)
| undefined
onClose?.()
}
)
const service = await getPresetService()
await service.switchPreset('vim')
expect(store.currentPresetName).toBe('default')
expect(mockApi.getUserData).not.toHaveBeenCalled()
})
it('switches without dialog when preset is not modified', async () => {
const targetPreset: KeybindingPreset = {
name: 'vim',
newBindings: [
{ commandId: 'vim.cmd', combo: { key: 'J', ctrl: false } }
],
unsetBindings: []
}
mockApi.getUserData.mockResolvedValueOnce(
new Response(JSON.stringify(targetPreset), { status: 200 })
)
const service = await getPresetService()
await service.switchPreset('vim')
expect(mockShowSmallLayoutDialog).not.toHaveBeenCalled()
expect(store.currentPresetName).toBe('vim')
})
})
describe('promptAndSaveNewPreset', () => {
it('returns false when user cancels prompt', async () => {
mockPrompt.mockResolvedValueOnce(null)
const service = await getPresetService()
const result = await service.promptAndSaveNewPreset()
expect(result).toBe(false)
})
it('returns false when user enters empty name', async () => {
mockPrompt.mockResolvedValueOnce(' ')
const service = await getPresetService()
const result = await service.promptAndSaveNewPreset()
expect(result).toBe(false)
})
it('saves successfully with valid name', async () => {
mockApi.listUserDataFullInfo.mockResolvedValueOnce([])
mockApi.storeUserData.mockResolvedValueOnce(new Response())
const service = await getPresetService()
const result = await service.promptAndSaveNewPreset()
expect(result).toBe(true)
expect(mockApi.storeUserData).toHaveBeenCalledWith(
'keybindings/test-preset.json',
expect.any(String),
{ overwrite: true, stringify: false }
)
})
it('confirms overwrite when preset name already exists', async () => {
mockApi.listUserDataFullInfo.mockResolvedValueOnce([
{ path: 'test-preset.json', size: 100, modified: 123 }
])
mockApi.storeUserData.mockResolvedValueOnce(new Response())
const service = await getPresetService()
const result = await service.promptAndSaveNewPreset()
expect(result).toBe(true)
expect(mockConfirm).toHaveBeenCalled()
expect(mockApi.storeUserData).toHaveBeenCalledWith(
'keybindings/test-preset.json',
expect.any(String),
{ overwrite: true, stringify: false }
)
})
it('returns false when user rejects overwrite', async () => {
mockApi.listUserDataFullInfo.mockResolvedValueOnce([
{ path: 'test-preset.json', size: 100, modified: 123 }
])
mockConfirm.mockResolvedValueOnce(false)
const service = await getPresetService()
const result = await service.promptAndSaveNewPreset()
expect(result).toBe(false)
expect(mockApi.storeUserData).not.toHaveBeenCalled()
})
})
describe('switchToDefaultPreset', () => {
it('resets bindings and updates store and settings', async () => {
store.addUserKeybinding(
new KeybindingImpl({
commandId: 'test.cmd',
combo: { key: 'A', ctrl: true }
})
)
store.currentPresetName = 'vim'
store.savedPresetData = {
name: 'vim',
newBindings: [],
unsetBindings: []
}
const service = await getPresetService()
await service.switchToDefaultPreset()
expect(Object.keys(store.getUserKeybindings())).toHaveLength(0)
expect(store.currentPresetName).toBe('default')
expect(store.savedPresetData).toBeNull()
expect(mockPersistUserKeybindings).toHaveBeenCalled()
expect(mockSettingSet).toHaveBeenCalledWith(
'Comfy.Keybinding.CurrentPreset',
'default'
)
})
it('does not reset bindings when resetBindings is false', async () => {
store.addUserKeybinding(
new KeybindingImpl({
commandId: 'test.cmd',
combo: { key: 'A', ctrl: true }
})
)
store.currentPresetName = 'vim'
const service = await getPresetService()
await service.switchToDefaultPreset({ resetBindings: false })
expect(Object.keys(store.getUserKeybindings())).toHaveLength(1)
expect(store.currentPresetName).toBe('default')
expect(store.savedPresetData).toBeNull()
})
})
describe('loadPreset error handling', () => {
it('throws when API returns non-ok response', async () => {
mockApi.getUserData.mockResolvedValueOnce(
new Response(null, { status: 404 })
)
const service = await getPresetService()
await expect(service.loadPreset('missing')).rejects.toThrow(
'g.keybindingPresets.loadPresetFailed'
)
})
it('throws when response contains invalid JSON', async () => {
mockApi.getUserData.mockResolvedValueOnce(
new Response('not-json{{{', { status: 200 })
)
const service = await getPresetService()
await expect(service.loadPreset('bad-json')).rejects.toThrow()
})
it('throws when Zod validation fails', async () => {
mockApi.getUserData.mockResolvedValueOnce(
new Response(JSON.stringify({ name: 'valid', wrongField: true }), {
status: 200
})
)
const service = await getPresetService()
await expect(service.loadPreset('bad-schema')).rejects.toThrow(
'g.keybindingPresets.invalidPresetFile'
)
})
})
})

View File

@@ -0,0 +1,290 @@
import { toRaw } from 'vue'
import { fromZodError } from 'zod-validation-error'
import { downloadBlob } from '@/base/common/downloadUtil'
import UnsavedChangesContent from '@/components/dialog/content/setting/keybinding/UnsavedChangesContent.vue'
import UnsavedChangesHeader from '@/components/dialog/content/setting/keybinding/UnsavedChangesHeader.vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { uploadFile } from '@/scripts/utils'
import { useDialogService } from '@/services/dialogService'
import { useDialogStore } from '@/stores/dialogStore'
import { KeybindingImpl } from './keybinding'
import { useKeybindingService } from './keybindingService'
import { useKeybindingStore } from './keybindingStore'
import type { KeybindingPreset } from './types'
import { zKeybindingPreset } from './types'
const PRESETS_DIR = 'keybindings'
function presetFilePath(name: string): string {
const trimmed = name.trim()
if (
!trimmed ||
trimmed === 'default' ||
trimmed.toLowerCase().endsWith('.json') ||
trimmed.includes('/') ||
trimmed.includes('\\') ||
trimmed.includes('..') ||
trimmed.startsWith('.')
) {
throw new Error(t('g.keybindingPresets.invalidPresetName'))
}
return `${PRESETS_DIR}/${trimmed}.json`
}
function buildPresetFromStore(
name: string,
keybindingStore: ReturnType<typeof useKeybindingStore>
): KeybindingPreset {
const newBindings = Object.values(toRaw(keybindingStore.getUserKeybindings()))
const unsetBindings = Object.values(
toRaw(keybindingStore.getUserUnsetKeybindings())
)
return { name, newBindings, unsetBindings }
}
export function useKeybindingPresetService() {
const keybindingStore = useKeybindingStore()
const keybindingService = useKeybindingService()
const settingStore = useSettingStore()
const dialogService = useDialogService()
const dialogStore = useDialogStore()
const toast = useToastStore()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
async function switchToDefaultPreset({ resetBindings = true } = {}) {
if (resetBindings) keybindingStore.resetAllKeybindings()
keybindingStore.currentPresetName = 'default'
keybindingStore.savedPresetData = null
await keybindingService.persistUserKeybindings()
await settingStore.set('Comfy.Keybinding.CurrentPreset', 'default')
}
const UNSAVED_DIALOG_KEY = 'unsaved-keybinding-changes'
function showUnsavedChangesDialog(
presetName: string
): Promise<boolean | null> {
return new Promise((resolve) => {
dialogService.showSmallLayoutDialog({
key: UNSAVED_DIALOG_KEY,
headerComponent: UnsavedChangesHeader,
headerProps: { presetName },
component: UnsavedChangesContent,
props: {
onResult: (result: boolean | null) => {
resolve(result)
dialogStore.closeDialog({ key: UNSAVED_DIALOG_KEY })
}
},
dialogComponentProps: {
onClose: () => resolve(null)
}
})
})
}
async function listPresets(): Promise<string[]> {
const files = await api.listUserDataFullInfo(PRESETS_DIR)
return files
.map((f) => f.path.replace(/\.json$/, ''))
.filter((name) => name.length > 0)
}
async function loadPreset(name: string): Promise<KeybindingPreset> {
const resp = await api.getUserData(presetFilePath(name))
if (!resp.ok) {
throw new Error(t('g.keybindingPresets.loadPresetFailed', { name }))
}
const data = await resp.json()
const result = zKeybindingPreset.safeParse(data)
if (!result.success) {
throw new Error(
t('g.keybindingPresets.invalidPresetFile') +
': ' +
fromZodError(result.error).message
)
}
return { ...result.data, name }
}
function applyPreset(preset: KeybindingPreset) {
keybindingStore.resetAllKeybindings()
for (const binding of preset.unsetBindings) {
keybindingStore.unsetKeybinding(new KeybindingImpl(binding))
}
for (const binding of preset.newBindings) {
keybindingStore.addUserKeybinding(new KeybindingImpl(binding))
}
// Snapshot savedPresetData from the store's actual state after applying,
// because addUserKeybinding may auto-unset conflicting defaults beyond
// what the raw preset specifies.
keybindingStore.savedPresetData = buildPresetFromStore(
preset.name,
keybindingStore
)
keybindingStore.currentPresetName = preset.name
}
async function savePreset(name: string) {
const preset = buildPresetFromStore(name, keybindingStore)
await api.storeUserData(presetFilePath(name), JSON.stringify(preset), {
overwrite: true,
stringify: false
})
keybindingStore.savedPresetData = preset
keybindingStore.currentPresetName = name
await keybindingService.persistUserKeybindings()
await settingStore.set('Comfy.Keybinding.CurrentPreset', name)
toast.add({
severity: 'success',
summary: t('g.keybindingPresets.presetSaved', { name }),
life: 3000
})
}
async function deletePreset(name: string) {
const confirmed = await dialogService.confirm({
title: t('g.keybindingPresets.deletePresetTitle'),
message: t('g.keybindingPresets.deletePresetWarning'),
type: 'delete'
})
if (!confirmed) return
const resp = await api.deleteUserData(presetFilePath(name))
if (!resp.ok) {
throw new Error(t('g.keybindingPresets.deletePresetFailed', { name }))
}
if (keybindingStore.currentPresetName === name) {
await switchToDefaultPreset()
}
toast.add({
severity: 'info',
summary: t('g.keybindingPresets.presetDeleted', { name }),
life: 3000
})
}
function exportPreset() {
const preset = buildPresetFromStore(
keybindingStore.currentPresetName,
keybindingStore
)
downloadBlob(
`${preset.name}.json`,
new Blob([JSON.stringify(preset, null, 2)], {
type: 'application/json'
})
)
}
async function importPreset() {
const file = await uploadFile('application/json')
const text = await file.text()
let data: unknown
try {
data = JSON.parse(text)
} catch {
throw new Error(t('g.keybindingPresets.invalidPresetFile'))
}
const result = zKeybindingPreset.safeParse(data)
if (!result.success) {
throw new Error(
t('g.keybindingPresets.invalidPresetFile') +
': ' +
fromZodError(result.error).message
)
}
const preset = result.data
// Save the imported preset file to storage
await api.storeUserData(
presetFilePath(preset.name),
JSON.stringify(preset),
{ overwrite: true, stringify: false }
)
// Switch to the imported preset (handles dirty check)
await switchPreset(preset.name)
toast.add({
severity: 'success',
summary: t('g.keybindingPresets.presetImported'),
life: 3000
})
}
async function promptAndSaveNewPreset(): Promise<boolean> {
const name = await dialogService.prompt({
title: t('g.keybindingPresets.saveAsNewPreset'),
message: t('g.keybindingPresets.presetNamePrompt'),
defaultValue: ''
})
if (!name) return false
const trimmedName = name.trim()
if (!trimmedName) return false
const existingPresets = await listPresets()
if (existingPresets.includes(trimmedName)) {
const overwrite = await dialogService.confirm({
title: t('g.keybindingPresets.overwritePresetTitle'),
message: t('g.keybindingPresets.overwritePresetMessage', {
name: trimmedName
}),
type: 'overwrite'
})
if (!overwrite) return false
}
await savePreset(trimmedName)
return true
}
async function switchPreset(targetName: string) {
if (keybindingStore.isCurrentPresetModified) {
const displayName =
keybindingStore.currentPresetName === 'default'
? t('g.keybindingPresets.default')
: keybindingStore.currentPresetName
const result = await showUnsavedChangesDialog(displayName)
if (result === null) return
if (result) {
if (keybindingStore.currentPresetName !== 'default') {
await savePreset(keybindingStore.currentPresetName)
} else {
const saved = await promptAndSaveNewPreset()
if (!saved) return
}
}
}
if (targetName === 'default') {
await switchToDefaultPreset()
return
}
const preset = await loadPreset(targetName)
applyPreset(preset)
await keybindingService.persistUserKeybindings()
await settingStore.set('Comfy.Keybinding.CurrentPreset', targetName)
}
return {
listPresets: wrapWithErrorHandlingAsync(listPresets),
loadPreset: wrapWithErrorHandlingAsync(loadPreset),
savePreset: wrapWithErrorHandlingAsync(savePreset),
deletePreset: wrapWithErrorHandlingAsync(deletePreset),
exportPreset,
importPreset: wrapWithErrorHandlingAsync(importPreset),
switchPreset: wrapWithErrorHandlingAsync(switchPreset),
switchToDefaultPreset: wrapWithErrorHandlingAsync(switchToDefaultPreset),
promptAndSaveNewPreset: wrapWithErrorHandlingAsync(promptAndSaveNewPreset),
applyPreset
}
}

View File

@@ -14,5 +14,12 @@ export const zKeybinding = z.object({
targetElementId: z.string().optional()
})
export const zKeybindingPreset = z.object({
name: z.string().trim().min(1, 'Preset name cannot be empty'),
newBindings: z.array(zKeybinding),
unsetBindings: z.array(zKeybinding)
})
export type KeyCombo = z.infer<typeof zKeyCombo>
export type Keybinding = z.infer<typeof zKeybinding>
export type KeybindingPreset = z.infer<typeof zKeybindingPreset>

View File

@@ -41,7 +41,20 @@
</nav>
</template>
<template #header />
<template #header>
<div
v-if="activeCategoryKey === 'keybinding'"
id="keybinding-panel-header"
class="flex-1"
/>
</template>
<template #header-right-area>
<div
v-if="activeCategoryKey === 'keybinding'"
id="keybinding-panel-actions"
/>
</template>
<template #content>
<template v-if="activePanel">

View File

@@ -676,6 +676,13 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: [] as Keybinding[],
versionAdded: '1.3.7'
},
{
id: 'Comfy.Keybinding.CurrentPreset',
name: 'Current keybinding preset name',
type: 'hidden',
defaultValue: 'default',
versionAdded: '1.8.8'
},
{
id: 'Comfy.Extension.Disabled',
name: 'Disabled extension names',

View File

@@ -382,6 +382,7 @@ const zSettings = z.object({
'Comfy.WorkflowActions.SeenItems': z.array(z.string()),
'Comfy.Keybinding.UnsetBindings': z.array(zKeybinding),
'Comfy.Keybinding.NewBindings': z.array(zKeybinding),
'Comfy.Keybinding.CurrentPreset': z.string(),
'Comfy.Extension.Disabled': z.array(z.string()),
'Comfy.LinkRenderMode': z.number(),
'Comfy.Node.AutoSnapLinkToSlot': z.boolean(),

View File

@@ -638,6 +638,7 @@ export default defineConfig({
optimizeDeps: {
exclude: ['@comfyorg/comfyui-electron-types'],
include: ['primevue/datatable', 'primevue/column'],
entries: ['index.html']
},