fix(ui): enforce shortcut conflict rules
This commit is contained in:
@@ -148,7 +148,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
</SelectTrigger>
|
||||
<SelectContent fitContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
|
||||
<SelectItem key={project.id} value={project.id} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{<ProjectLabel project={project} theme={theme} />}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -176,7 +176,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
{projectRootBranchOption ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
|
||||
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} className="max-w-[24rem] truncate">
|
||||
<SelectItem key={projectRootBranchOption.value} value={projectRootBranchOption.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{projectRootBranchOption.label}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
@@ -195,13 +195,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
|
||||
</button>
|
||||
</div>
|
||||
{worktreeBranchOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value} className="max-w-[24rem] truncate">
|
||||
<SelectItem key={option.value} value={option.value} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{option.pending ? '⏳ ' : ''}{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
{selectedDirectory && !selectedBranchIsKnown ? (
|
||||
<SelectItem value={selectedDirectory} className="max-w-[24rem] truncate">
|
||||
<SelectItem value={selectedDirectory} showSelectedBackground={false} className="max-w-[24rem] truncate">
|
||||
{selectedBranchLabel}
|
||||
</SelectItem>
|
||||
) : null}
|
||||
|
||||
@@ -121,7 +121,6 @@ export const KeyboardShortcutsSettings: React.FC = () => {
|
||||
})}
|
||||
<ShortcutRecordingDialog
|
||||
action={editingAction}
|
||||
actions={actions}
|
||||
overrides={shortcutOverrides}
|
||||
onSave={save}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -10,28 +10,28 @@ function keyEvent(key: string, modifiers: Partial<Record<'altKey' | 'ctrlKey' |
|
||||
describe('ShortcutRecordingDialog recording state', () => {
|
||||
test('previews modifiers and clears the preview when they are released', () => {
|
||||
const pressed = updateShortcutRecordingState(emptyState, keyEvent('Control', { ctrlKey: true, shiftKey: true }), 'keydown');
|
||||
expect(pressed.state.livePreview).toBe('mod+shift');
|
||||
expect(updateShortcutRecordingState(pressed.state, keyEvent('Control'), 'keyup').state.livePreview).toBeNull();
|
||||
expect(pressed.livePreview).toBe('mod+shift');
|
||||
expect(updateShortcutRecordingState(pressed, keyEvent('Control'), 'keyup').livePreview).toBeNull();
|
||||
});
|
||||
|
||||
test('records up to two chords', () => {
|
||||
const first = updateShortcutRecordingState(emptyState, keyEvent('k', { ctrlKey: true }), 'keydown');
|
||||
const second = updateShortcutRecordingState(first.state, keyEvent('p', { ctrlKey: true }), 'keydown');
|
||||
const third = updateShortcutRecordingState(second.state, keyEvent('x', { ctrlKey: true }), 'keydown');
|
||||
expect(first.state.chords).toEqual(['mod+k']);
|
||||
expect(second.state.chords).toEqual(['mod+k', 'mod+p']);
|
||||
expect(third.state.chords).toEqual(['mod+k', 'mod+p']);
|
||||
const second = updateShortcutRecordingState(first, keyEvent('p', { ctrlKey: true }), 'keydown');
|
||||
const third = updateShortcutRecordingState(second, keyEvent('x', { ctrlKey: true }), 'keydown');
|
||||
expect(first.chords).toEqual(['mod+k']);
|
||||
expect(second.chords).toEqual(['mod+k', 'mod+p']);
|
||||
expect(third.chords).toEqual(['mod+k', 'mod+p']);
|
||||
});
|
||||
|
||||
test('ignores repeat and IME events', () => {
|
||||
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), repeat: true }, 'keydown').state).toEqual(emptyState);
|
||||
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), isComposing: true }, 'keydown').state).toEqual(emptyState);
|
||||
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), repeat: true }, 'keydown')).toEqual(emptyState);
|
||||
expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), isComposing: true }, 'keydown')).toEqual(emptyState);
|
||||
});
|
||||
|
||||
test('uses Enter and Escape for dialog actions and Backspace to remove the final chord', () => {
|
||||
test('records Enter and Escape while Backspace removes the final chord', () => {
|
||||
const state = { chords: ['mod+k', 'mod+p'], livePreview: null };
|
||||
expect(updateShortcutRecordingState(state, keyEvent('Enter'), 'keydown').action).toBe('save');
|
||||
expect(updateShortcutRecordingState(state, keyEvent('Escape'), 'keydown').action).toBe('cancel');
|
||||
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').state.chords).toEqual(['mod+k']);
|
||||
expect(updateShortcutRecordingState(emptyState, keyEvent('Enter'), 'keydown').chords).toEqual(['enter']);
|
||||
expect(updateShortcutRecordingState(emptyState, keyEvent('Escape'), 'keydown').chords).toEqual(['escape']);
|
||||
expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').chords).toEqual(['mod+k']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,13 +10,12 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
formatShortcutForDisplay,
|
||||
getEffectiveShortcutCombo,
|
||||
getEffectiveShortcutPrefix,
|
||||
getShortcutConflict,
|
||||
getShortcutBindingConflicts,
|
||||
isRiskyBrowserShortcut,
|
||||
keyToShortcutToken,
|
||||
normalizeCombo,
|
||||
type ShortcutActionId,
|
||||
type ShortcutBindingConflict,
|
||||
type ShortcutCombo,
|
||||
type CustomizableShortcutAction,
|
||||
} from '@/lib/shortcuts';
|
||||
@@ -39,11 +38,8 @@ interface ShortcutRecordingState {
|
||||
livePreview: ShortcutCombo | null;
|
||||
}
|
||||
|
||||
type ShortcutRecordingAction = 'cancel' | 'none' | 'save';
|
||||
|
||||
interface ShortcutRecordingDialogProps {
|
||||
action: CustomizableShortcutAction | null;
|
||||
actions: ReadonlyArray<CustomizableShortcutAction>;
|
||||
overrides: Record<string, string>;
|
||||
onSave: (
|
||||
actionId: ShortcutActionId,
|
||||
@@ -53,6 +49,12 @@ interface ShortcutRecordingDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function isCustomizableConflict(
|
||||
conflict: ShortcutBindingConflict,
|
||||
): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } {
|
||||
return conflict.action.customizable;
|
||||
}
|
||||
|
||||
function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null {
|
||||
const parts: string[] = [];
|
||||
if (event.metaKey || event.ctrlKey) parts.push('mod');
|
||||
@@ -91,35 +93,29 @@ export function updateShortcutRecordingState(
|
||||
state: ShortcutRecordingState,
|
||||
event: RecordingKeyboardEvent,
|
||||
phase: 'keydown' | 'keyup',
|
||||
): { action: ShortcutRecordingAction; state: ShortcutRecordingState } {
|
||||
if (event.repeat || event.isComposing) return { action: 'none', state };
|
||||
): ShortcutRecordingState {
|
||||
if (event.repeat || event.isComposing) return state;
|
||||
if (phase === 'keyup') {
|
||||
return { action: 'none', state: { ...state, livePreview: getModifierPreview(event) } };
|
||||
return { ...state, livePreview: getModifierPreview(event) };
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') return { action: 'cancel', state };
|
||||
if (event.key === 'Enter') return { action: 'save', state };
|
||||
if (event.key === 'Backspace') {
|
||||
return { action: 'none', state: { chords: state.chords.slice(0, -1), livePreview: null } };
|
||||
return { chords: state.chords.slice(0, -1), livePreview: null };
|
||||
}
|
||||
|
||||
const chord = keyboardEventToCombo(event);
|
||||
if (chord) {
|
||||
return {
|
||||
action: 'none',
|
||||
state: {
|
||||
chords: state.chords.length < 2 ? [...state.chords, chord] : state.chords,
|
||||
livePreview: null,
|
||||
},
|
||||
chords: state.chords.length < 2 ? [...state.chords, chord] : state.chords,
|
||||
livePreview: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { action: 'none', state: { ...state, livePreview: getModifierPreview(event) } };
|
||||
return { ...state, livePreview: getModifierPreview(event) };
|
||||
}
|
||||
|
||||
export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = ({
|
||||
action,
|
||||
actions,
|
||||
overrides,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
@@ -136,26 +132,19 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
||||
}, [action]);
|
||||
|
||||
const combo = normalizeCombo(recording.chords.join(' '));
|
||||
const conflicts = React.useMemo(() => {
|
||||
if (!action || !combo) return [];
|
||||
const result: Array<{ action: CustomizableShortcutAction; kind: 'exact' | 'prefix' }> = [];
|
||||
for (const candidate of actions) {
|
||||
if (candidate.id === action.id) continue;
|
||||
const candidateCombo = candidate.id === 'switch_context_surface'
|
||||
? getEffectiveShortcutPrefix(candidate.id, overrides)
|
||||
: getEffectiveShortcutCombo(candidate.id, overrides);
|
||||
const kind = getShortcutConflict(combo, candidateCombo);
|
||||
if (kind) result.push({ action: candidate, kind });
|
||||
}
|
||||
return result;
|
||||
}, [action, actions, combo, overrides]);
|
||||
const prefixConflict = conflicts.find((conflict) => conflict.kind === 'prefix');
|
||||
const exactConflict = conflicts.find((conflict) => conflict.kind === 'exact');
|
||||
const conflicts = React.useMemo(
|
||||
() => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [],
|
||||
[action, combo, overrides],
|
||||
);
|
||||
const protectedConflict = conflicts.find((conflict) => !conflict.action.customizable);
|
||||
const customizableConflicts = conflicts.filter(isCustomizableConflict);
|
||||
const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix');
|
||||
const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact');
|
||||
|
||||
const close = () => onOpenChange(false);
|
||||
const save = () => {
|
||||
if (!action || !combo || prefixConflict || exactConflict) return;
|
||||
onSave(action.id, combo);
|
||||
const confirm = () => {
|
||||
if (!action || !combo || protectedConflict || prefixConflict) return;
|
||||
onSave(action.id, combo, exactConflict?.action.id);
|
||||
close();
|
||||
};
|
||||
const handleRecordingEvent = (event: React.KeyboardEvent<HTMLDivElement>, phase: 'keydown' | 'keyup') => {
|
||||
@@ -168,7 +157,7 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = updateShortcutRecordingState(recording, {
|
||||
const nextRecording = updateShortcutRecordingState(recording, {
|
||||
altKey: event.altKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
isComposing: event.nativeEvent.isComposing,
|
||||
@@ -177,16 +166,21 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
||||
repeat: event.repeat,
|
||||
shiftKey: event.shiftKey,
|
||||
}, phase);
|
||||
setRecording(action?.id === 'switch_context_surface' && result.state.chords.length > 1
|
||||
? { ...result.state, chords: result.state.chords.slice(0, 1) }
|
||||
: result.state);
|
||||
if (result.action === 'cancel') close();
|
||||
if (result.action === 'save') save();
|
||||
setRecording(action?.id === 'switch_context_surface' && nextRecording.chords.length > 1
|
||||
? { ...nextRecording, chords: nextRecording.chords.slice(0, 1) }
|
||||
: nextRecording);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={action !== null} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md" initialFocus={recordingRef}>
|
||||
<Dialog
|
||||
open={action !== null}
|
||||
onOpenChange={(open, eventDetails) => {
|
||||
if (!open) {
|
||||
eventDetails.cancel();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md" initialFocus={recordingRef} showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''}
|
||||
@@ -221,12 +215,16 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prefixConflict ? (
|
||||
{protectedConflict ? (
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.error.internalConflict')}
|
||||
</p>
|
||||
) : prefixConflict ? (
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
|
||||
</p>
|
||||
) : null}
|
||||
{exactConflict && !prefixConflict ? (
|
||||
{exactConflict && !protectedConflict && !prefixConflict ? (
|
||||
<p className="typography-meta text-[var(--status-warning)]">
|
||||
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
|
||||
</p>
|
||||
@@ -237,17 +235,19 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{exactConflict && !prefixConflict ? (
|
||||
<DialogFooter>
|
||||
<Button type="button" size="sm" onClick={() => {
|
||||
if (!action) return;
|
||||
onSave(action.id, combo, exactConflict.action.id);
|
||||
close();
|
||||
}}>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.replaceAndSave')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={close}>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!combo || Boolean(protectedConflict) || Boolean(prefixConflict)}
|
||||
onClick={confirm}
|
||||
>
|
||||
{t('settings.openchamber.keyboardShortcuts.actions.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -286,13 +286,17 @@ function SelectLabel({
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
showSelectedBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof BaseSelect.Item>) {
|
||||
}: React.ComponentProps<typeof BaseSelect.Item> & {
|
||||
showSelectedBackground?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<BaseSelect.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-1.5 pr-8 pl-2 typography-ui-label outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
showSelectedBackground && "data-[selected]:bg-interactive-selection data-[selected]:text-interactive-selection-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1060,6 +1060,26 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Eingabe erweitern',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Konversations-Zeitleiste öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Prompt-Navigator umschalten',
|
||||
'settings.openchamber.keyboardShortcuts.category.session': 'Sitzungssteuerung',
|
||||
'settings.openchamber.keyboardShortcuts.category.models': 'Modelle und Agenten',
|
||||
'settings.openchamber.keyboardShortcuts.category.panels': 'Panels und Werkzeuge',
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': 'Anwendung',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': 'Bearbeiten',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Bestätigen',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} bearbeiten',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Drücken Sie bis zu zwei Tastenkombinationen. Wählen Sie Bestätigen zum Anwenden oder Abbrechen zum Verwerfen. Mit der Rücktaste entfernen Sie die letzte.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Erste Kombination',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Zweite Kombination',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Tasten drücken…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': 'Nicht zugewiesen',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'Dies kollidiert mit der von {action} verwendeten Sequenz. Wählen Sie eine andere Kombination.',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Diese Kombination wird bereits von {action} verwendet.',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Diese Kombination kollidiert mit einem integrierten Tastenkürzel, das nicht ersetzt werden kann.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Projektauswahl für Entwurf öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Worktree-Auswahl für Entwurf öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Letzte Sitzungen öffnen',
|
||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Spracheingabe',
|
||||
'settings.projects.sidebar.total': 'Gesamt {count}',
|
||||
'settings.projects.sidebar.actions.addProject': 'Projekt hinzufügen',
|
||||
'settings.projects.page.empty.noProjects': 'Keine Projekte verfügbar.',
|
||||
|
||||
@@ -1131,15 +1131,16 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': 'Application',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edit',
|
||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Replace and save',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirm',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edit {action}',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Press up to two key combinations. Enter to finish, Esc to cancel, or Backspace to remove the last one.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Press up to two key combinations. Use Confirm to apply or Cancel to discard. Backspace removes the last one.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'First combination',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Second combination',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Press keys…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': 'Unassigned',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'This conflicts with the sequence used by {action}. Choose a different combination.',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'This combination is already used by {action}.',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'This combination is already used by {action}.',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'This combination conflicts with a built-in shortcut, which cannot be replaced.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Open draft project picker',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Open draft worktree picker',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Open recent sessions',
|
||||
|
||||
@@ -1098,15 +1098,16 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.category.navigation": "Navegación",
|
||||
"settings.openchamber.keyboardShortcuts.category.application": "Aplicación",
|
||||
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
|
||||
"settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Reemplazar y guardar",
|
||||
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pulse hasta dos combinaciones de teclas. Pulse Intro para terminar, Esc para cancelar o Retroceso para quitar la última.",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pulse hasta dos combinaciones de teclas. Use Confirmar para aplicar o Cancelar para descartar. Retroceso elimina la última.",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primera combinación",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinación",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pulse las teclas…",
|
||||
"settings.openchamber.keyboardShortcuts.unassigned": "Sin asignar",
|
||||
"settings.openchamber.keyboardShortcuts.error.prefixConflict": "Esto entra en conflicto con la secuencia usada por {action}. Elija otra combinación.",
|
||||
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinación ya la usa {action}.",
|
||||
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinación ya la usa {action}.",
|
||||
"settings.openchamber.keyboardShortcuts.error.internalConflict": "Esta combinación entra en conflicto con un atajo integrado, que no se puede reemplazar.",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Abrir selector de proyecto de borrador",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Abrir selector de árbol de trabajo de borrador",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sesiones recientes",
|
||||
|
||||
@@ -1019,15 +1019,16 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': 'Application',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': 'Modifier',
|
||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Remplacer et enregistrer',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Confirmer',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Modifier {action}',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Appuyez sur deux combinaisons de touches au maximum. Appuyez sur Entrée pour terminer, Échap pour annuler ou Retour arrière pour supprimer la dernière.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Appuyez sur deux combinaisons de touches au maximum. Utilisez Confirmer pour appliquer ou Annuler pour abandonner. Retour arrière supprime la dernière.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Première combinaison',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Deuxième combinaison',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Appuyez sur les touches…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': 'Non attribué',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'Cela entre en conflit avec la séquence utilisée par {action}. Choisissez une autre combinaison.',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Cette combinaison est déjà utilisée par {action}.',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Cette combinaison entre en conflit avec un raccourci intégré qui ne peut pas être remplacé.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Ouvrir le sélecteur de projet de brouillon',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Ouvrir le sélecteur de worktree de brouillon',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Ouvrir les sessions récentes',
|
||||
|
||||
@@ -1131,15 +1131,16 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': 'ナビゲーション',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': 'アプリケーション',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': '編集',
|
||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '置き換えて保存',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} を編集',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力できます。Enter で完了、Esc でキャンセル、Backspace で最後の組み合わせを削除します。',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力できます。適用するには確認、破棄するにはキャンセルを選択してください。Backspace で最後の組み合わせを削除します。',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '最初の組み合わせ',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '2番目の組み合わせ',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'キーを押してください…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': '未割り当て',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '{action} のシーケンスと競合しています。別の組み合わせを選択してください。',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'この組み合わせは {action} で使用されています。',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'この組み合わせは組み込みショートカットと競合しています。組み込みショートカットは置き換えられません。',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '下書きプロジェクト選択を開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '下書きワークツリー選択を開く',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '最近のセッションを開く',
|
||||
|
||||
@@ -1098,15 +1098,16 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': '탐색',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': '애플리케이션',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': '편집',
|
||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '바꾸고 저장',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '확인',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} 편집',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. Enter로 완료하고 Esc로 취소하거나 Backspace로 마지막 조합을 삭제하세요.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. 적용하려면 확인을, 취소하려면 취소를 선택하세요. Backspace로 마지막 조합을 삭제합니다.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '첫 번째 조합',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '두 번째 조합',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '키를 누르세요…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': '할당되지 않음',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '{action}에서 사용하는 시퀀스와 충돌합니다. 다른 조합을 선택하세요.',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': '이 조합은 이미 {action}에서 사용합니다.',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': '이 조합은 바꿀 수 없는 기본 제공 단축키와 충돌합니다.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '초안 프로젝트 선택기 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '초안 워크트리 선택기 열기',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '최근 세션 열기',
|
||||
|
||||
@@ -1366,15 +1366,16 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': 'Nawigacja',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': 'Aplikacja',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edytuj',
|
||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Zastąp i zapisz',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': 'Potwierdź',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edytuj: {action}',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Naciśnij maksymalnie dwie kombinacje klawiszy. Naciśnij Enter, aby zakończyć, Esc, aby anulować, lub Backspace, aby usunąć ostatnią.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Naciśnij maksymalnie dwie kombinacje klawiszy. Wybierz Potwierdź, aby zastosować, lub Anuluj, aby odrzucić. Backspace usuwa ostatnią.',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Pierwsza kombinacja',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Druga kombinacja',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Naciśnij klawisze…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': 'Nieprzypisany',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'To koliduje z sekwencją używaną przez {action}. Wybierz inną kombinację.',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Ta kombinacja jest już używana przez {action}.',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Ta kombinacja koliduje z wbudowanym skrótem, którego nie można zastąpić.',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Otwórz wybór projektu szkicu',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Otwórz wybór worktree szkicu',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Otwórz ostatnie sesje',
|
||||
|
||||
@@ -1098,15 +1098,16 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.category.navigation": "Navegação",
|
||||
"settings.openchamber.keyboardShortcuts.category.application": "Aplicação",
|
||||
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
|
||||
"settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Substituir e salvar",
|
||||
"settings.openchamber.keyboardShortcuts.actions.confirm": "Confirmar",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pressione até duas combinações de teclas. Pressione Enter para concluir, Esc para cancelar ou Backspace para remover a última.",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pressione até duas combinações de teclas. Use Confirmar para aplicar ou Cancelar para descartar. Backspace remove a última.",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primeira combinação",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinação",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pressione as teclas…",
|
||||
"settings.openchamber.keyboardShortcuts.unassigned": "Não atribuído",
|
||||
"settings.openchamber.keyboardShortcuts.error.prefixConflict": "Isto entra em conflito com a sequência usada por {action}. Escolha outra combinação.",
|
||||
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinação já é usada por {action}.",
|
||||
"settings.openchamber.keyboardShortcuts.error.internalConflict": "Esta combinação entra em conflito com um atalho integrado, que não pode ser substituído.",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Abrir seletor de projeto do rascunho",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Abrir seletor de worktree do rascunho",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sessões recentes",
|
||||
|
||||
@@ -1098,15 +1098,16 @@ export const settingsDict = {
|
||||
"settings.openchamber.keyboardShortcuts.category.navigation": "Навігація",
|
||||
"settings.openchamber.keyboardShortcuts.category.application": "Застосунок",
|
||||
"settings.openchamber.keyboardShortcuts.actions.edit": "Редагувати",
|
||||
"settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Замінити й зберегти",
|
||||
"settings.openchamber.keyboardShortcuts.actions.confirm": "Підтвердити",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Редагувати {action}",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш. Натисніть Enter, щоб завершити, Esc, щоб скасувати, або Backspace, щоб видалити останню.",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш. Виберіть Підтвердити, щоб застосувати, або Скасувати, щоб відхилити. Backspace видаляє останню.",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Перша комбінація",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Друга комбінація",
|
||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Натисніть клавіші…",
|
||||
"settings.openchamber.keyboardShortcuts.unassigned": "Не призначено",
|
||||
"settings.openchamber.keyboardShortcuts.error.prefixConflict": "Це конфліктує з послідовністю, яку використовує {action}. Виберіть іншу комбінацію.",
|
||||
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Цю комбінацію вже використовує {action}.",
|
||||
"settings.openchamber.keyboardShortcuts.error.internalConflict": "Ця комбінація конфліктує з вбудованим скороченням, яке не можна замінити.",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Відкрити вибір проєкту чернетки",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Відкрити вибір worktree чернетки",
|
||||
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Відкрити останні сесії",
|
||||
|
||||
@@ -1098,15 +1098,16 @@ export const settingsDict = {
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': '导航',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': '应用程序',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': '编辑',
|
||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '替换并保存',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '确认',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': '编辑{action}',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下两个按键组合。按 Enter 完成,按 Esc 取消,按 Backspace 删除最后一个。',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下两个按键组合。点击确认应用,或点击取消放弃;按 Backspace 删除最后一个组合。',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一个组合',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二个组合',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按键…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': '未分配',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '这与 {action} 使用的序列冲突。请选择其他组合。',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': '此组合已被 {action} 使用。',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': '此组合与内置快捷键冲突,内置快捷键不能被替换。',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '打开草稿项目选择器',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '打开草稿工作树选择器',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '打开最近会话',
|
||||
|
||||
@@ -1005,15 +1005,16 @@
|
||||
'settings.openchamber.keyboardShortcuts.category.navigation': '導覽',
|
||||
'settings.openchamber.keyboardShortcuts.category.application': '應用程式',
|
||||
'settings.openchamber.keyboardShortcuts.actions.edit': '編輯',
|
||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '取代並儲存',
|
||||
'settings.openchamber.keyboardShortcuts.actions.confirm': '確認',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.title': '編輯 {action}',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下兩個按鍵組合。按 Enter 完成,按 Esc 取消,按 Backspace 可刪除最後一個。',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下兩個按鍵組合。點擊確認套用,或點擊取消放棄;按 Backspace 刪除最後一個組合。',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一個組合',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二個組合',
|
||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按鍵…',
|
||||
'settings.openchamber.keyboardShortcuts.unassigned': '未指派',
|
||||
'settings.openchamber.keyboardShortcuts.error.prefixConflict': '這與 {action} 使用的序列衝突。請選擇其他組合。',
|
||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': '此組合已由 {action} 使用。',
|
||||
'settings.openchamber.keyboardShortcuts.error.internalConflict': '此組合與內建快捷鍵衝突,內建快捷鍵不能被取代。',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '開啟草稿專案選擇器',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '開啟草稿 worktree 選擇器',
|
||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '開啟最近工作階段',
|
||||
|
||||
@@ -29,9 +29,9 @@ Bindings remain persisted as `Record<string, string>`. Each binding has one chor
|
||||
|
||||
Contextual internal commands may deliberately share a sequence leader. The single-chord handler gets the first chance to handle the event; returning `false` lets the dispatcher start the sequence. The active file editor therefore owns `mod+s` for saving, while a mounted but unfocused editor yields `mod+s p`, `mod+s g`, and `mod+s l` to the draft target pickers and session list.
|
||||
|
||||
Runtime-specific commands may also share an exact binding when their handlers are mutually exclusive. `open_diff_panel` handles `mod+2` on desktop, while `switch_tab_2` handles it on mobile; each returns `false` outside its runtime so the dispatcher can try the next registered action.
|
||||
The internal `switch_tab_*` bindings remain available to mobile handlers. Desktop numeric context-surface switching is resolved by the configurable `switch_context_surface` prefix before normal dispatcher matching and falls through on mobile.
|
||||
|
||||
The settings recorder also stops at two chords. It keeps the recording local until the user explicitly saves, allows an exact conflict to replace the previous assignment, and blocks prefix conflicts because they make dispatch ambiguous.
|
||||
The settings recorder also stops at two chords and checks the complete schema, not only customizable actions. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts because they make dispatch ambiguous. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced.
|
||||
|
||||
# Dispatching
|
||||
|
||||
@@ -43,7 +43,7 @@ Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGloba
|
||||
|
||||
Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior.
|
||||
|
||||
Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands.
|
||||
Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording.
|
||||
|
||||
# Adding shortcuts
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export { shortcutRegistry } from './registry';
|
||||
export type { ShortcutHandler } from './registry';
|
||||
export {
|
||||
getCustomizableShortcutActions,
|
||||
getShortcutBindingConflicts,
|
||||
getEffectiveShortcutCombo,
|
||||
getEffectiveShortcutPrefix,
|
||||
getShortcutAction,
|
||||
@@ -24,6 +25,7 @@ export {
|
||||
} from './schema';
|
||||
export type {
|
||||
CustomizableShortcutAction,
|
||||
ShortcutBindingConflict,
|
||||
ShortcutActionId,
|
||||
ShortcutCategory,
|
||||
} from './schema';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
getCustomizableShortcutActions,
|
||||
getEffectiveShortcutCombo,
|
||||
getShortcutBindingConflicts,
|
||||
getShortcutAction,
|
||||
parseShortcut,
|
||||
SHORTCUT_SCHEMA,
|
||||
@@ -59,4 +60,25 @@ describe('shortcut schema', () => {
|
||||
expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k' })).toBe('mod+k');
|
||||
expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k x y' })).toBe('mod+n');
|
||||
});
|
||||
|
||||
test('keeps internal bindings authoritative over persisted overrides', () => {
|
||||
expect(getEffectiveShortcutCombo('save_file', { save_file: 'mod+k' })).toBe('mod+s');
|
||||
expect(getEffectiveShortcutCombo('save_file', { save_file: '__unassigned__' })).toBe('mod+s');
|
||||
});
|
||||
|
||||
test('detects conflicts against customizable and internal bindings', () => {
|
||||
const customizableConflict = getShortcutBindingConflicts('new_chat', 'mod+p')
|
||||
.find((conflict) => conflict.action.id === 'open_command_palette');
|
||||
const internalConflict = getShortcutBindingConflicts('new_chat', 'mod+f')
|
||||
.find((conflict) => conflict.action.id === 'find_in_file');
|
||||
const internalPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+s x')
|
||||
.find((conflict) => conflict.action.id === 'save_file');
|
||||
|
||||
expect(customizableConflict?.kind).toBe('exact');
|
||||
expect(customizableConflict?.action.customizable).toBe(true);
|
||||
expect(internalConflict?.kind).toBe('exact');
|
||||
expect(internalConflict?.action.customizable).toBe(false);
|
||||
expect(internalPrefixConflict?.kind).toBe('prefix');
|
||||
expect(internalPrefixConflict?.action.customizable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
getShortcutConflict,
|
||||
isValidShortcutCombo,
|
||||
normalizeCombo,
|
||||
parseShortcut,
|
||||
UNASSIGNED_SHORTCUT,
|
||||
type ShortcutCombo,
|
||||
type ShortcutConflict,
|
||||
} from './bindings';
|
||||
import { SHORTCUT_SCHEMA } from './config';
|
||||
|
||||
@@ -13,6 +15,10 @@ export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number];
|
||||
export type ShortcutActionId = ShortcutAction['id'];
|
||||
export type ShortcutCategory = ShortcutAction['category'];
|
||||
export type CustomizableShortcutAction = Extract<ShortcutAction, { customizable: true }>;
|
||||
export type ShortcutBindingConflict = {
|
||||
action: ShortcutAction;
|
||||
kind: ShortcutConflict;
|
||||
};
|
||||
|
||||
export function getShortcutAction(id: string): ShortcutAction | undefined {
|
||||
return SHORTCUT_SCHEMA.find((action) => action.id === id);
|
||||
@@ -30,6 +36,7 @@ export function getEffectiveShortcutCombo(
|
||||
): ShortcutCombo {
|
||||
const action = getShortcutAction(actionId);
|
||||
if (!action) return '';
|
||||
if (!action.customizable) return action.defaultBinding;
|
||||
|
||||
const override = overrides?.[actionId];
|
||||
if (typeof override === 'string') {
|
||||
@@ -47,6 +54,7 @@ export function getEffectiveShortcutPrefix(
|
||||
): ShortcutCombo {
|
||||
const action = getShortcutAction(actionId);
|
||||
if (!action) return '';
|
||||
if (!action.customizable) return action.defaultBinding;
|
||||
|
||||
const override = overrides?.[actionId];
|
||||
if (typeof override === 'string' && override.trim() !== '') {
|
||||
@@ -58,3 +66,20 @@ export function getEffectiveShortcutPrefix(
|
||||
|
||||
return action.defaultBinding;
|
||||
}
|
||||
|
||||
export function getShortcutBindingConflicts(
|
||||
actionId: ShortcutActionId,
|
||||
combo: ShortcutCombo,
|
||||
overrides?: Record<string, ShortcutCombo>,
|
||||
): ShortcutBindingConflict[] {
|
||||
const conflicts: ShortcutBindingConflict[] = [];
|
||||
for (const candidate of SHORTCUT_SCHEMA) {
|
||||
if (candidate.id === actionId) continue;
|
||||
const candidateCombo = candidate.id === 'switch_context_surface'
|
||||
? getEffectiveShortcutPrefix(candidate.id, overrides)
|
||||
: getEffectiveShortcutCombo(candidate.id, overrides);
|
||||
const kind = getShortcutConflict(combo, candidateCombo);
|
||||
if (kind) conflicts.push({ action: candidate, kind });
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user