fix(ui): refine shortcut and recent session interactions
This commit is contained in:
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { updateShortcutRecordingState } from './ShortcutRecordingDialog';
|
||||||
|
|
||||||
|
const emptyState = { chords: [], livePreview: null };
|
||||||
|
|
||||||
|
function keyEvent(key: string, modifiers: Partial<Record<'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey', boolean>> = {}) {
|
||||||
|
return { key, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers };
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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']);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses Enter and Escape for dialog actions and Backspace to remove 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,23 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
|
|
||||||
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
|
const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']);
|
||||||
|
|
||||||
|
interface RecordingKeyboardEvent {
|
||||||
|
altKey: boolean;
|
||||||
|
ctrlKey: boolean;
|
||||||
|
isComposing: boolean;
|
||||||
|
key: string;
|
||||||
|
metaKey: boolean;
|
||||||
|
repeat: boolean;
|
||||||
|
shiftKey: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortcutRecordingState {
|
||||||
|
chords: ShortcutCombo[];
|
||||||
|
livePreview: ShortcutCombo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShortcutRecordingAction = 'cancel' | 'none' | 'save';
|
||||||
|
|
||||||
interface ShortcutRecordingDialogProps {
|
interface ShortcutRecordingDialogProps {
|
||||||
action: CustomizableShortcutAction | null;
|
action: CustomizableShortcutAction | null;
|
||||||
actions: ReadonlyArray<CustomizableShortcutAction>;
|
actions: ReadonlyArray<CustomizableShortcutAction>;
|
||||||
@@ -36,7 +53,15 @@ interface ShortcutRecordingDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyboardEventToCombo(event: React.KeyboardEvent<HTMLDivElement>): ShortcutCombo | null {
|
function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (event.metaKey || event.ctrlKey) parts.push('mod');
|
||||||
|
if (event.shiftKey) parts.push('shift');
|
||||||
|
if (event.altKey) parts.push('alt');
|
||||||
|
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null {
|
||||||
if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null;
|
if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null;
|
||||||
|
|
||||||
const key = keyToShortcutToken(event.key);
|
const key = keyToShortcutToken(event.key);
|
||||||
@@ -61,6 +86,37 @@ function modifierKeyUpToCombo(event: React.KeyboardEvent<HTMLDivElement>): Short
|
|||||||
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
return parts.length > 0 ? normalizeCombo(parts.join('+')) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition
|
||||||
|
export function updateShortcutRecordingState(
|
||||||
|
state: ShortcutRecordingState,
|
||||||
|
event: RecordingKeyboardEvent,
|
||||||
|
phase: 'keydown' | 'keyup',
|
||||||
|
): { action: ShortcutRecordingAction; state: ShortcutRecordingState } {
|
||||||
|
if (event.repeat || event.isComposing) return { action: 'none', state };
|
||||||
|
if (phase === 'keyup') {
|
||||||
|
return { action: 'none', state: { ...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 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chord = keyboardEventToCombo(event);
|
||||||
|
if (chord) {
|
||||||
|
return {
|
||||||
|
action: 'none',
|
||||||
|
state: {
|
||||||
|
chords: state.chords.length < 2 ? [...state.chords, chord] : state.chords,
|
||||||
|
livePreview: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: 'none', state: { ...state, livePreview: getModifierPreview(event) } };
|
||||||
|
}
|
||||||
|
|
||||||
export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = ({
|
export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = ({
|
||||||
action,
|
action,
|
||||||
actions,
|
actions,
|
||||||
@@ -70,15 +126,16 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey);
|
const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey);
|
||||||
const [chords, setChords] = React.useState<ShortcutCombo[]>([]);
|
const [recording, setRecording] = React.useState<ShortcutRecordingState>({ chords: [], livePreview: null });
|
||||||
const recordingRef = React.useRef<HTMLDivElement>(null);
|
const recordingRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
setChords([]);
|
setRecording({ chords: [], livePreview: null });
|
||||||
|
recordingRef.current?.focus();
|
||||||
}, [action]);
|
}, [action]);
|
||||||
|
|
||||||
const combo = normalizeCombo(chords.join(' '));
|
const combo = normalizeCombo(recording.chords.join(' '));
|
||||||
const conflicts = React.useMemo(() => {
|
const conflicts = React.useMemo(() => {
|
||||||
if (!action || !combo) return [];
|
if (!action || !combo) return [];
|
||||||
const result: Array<{ action: CustomizableShortcutAction; kind: 'exact' | 'prefix' }> = [];
|
const result: Array<{ action: CustomizableShortcutAction; kind: 'exact' | 'prefix' }> = [];
|
||||||
@@ -96,95 +153,92 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
|||||||
const exactConflict = conflicts.find((conflict) => conflict.kind === 'exact');
|
const exactConflict = conflicts.find((conflict) => conflict.kind === 'exact');
|
||||||
|
|
||||||
const close = () => onOpenChange(false);
|
const close = () => onOpenChange(false);
|
||||||
|
const save = () => {
|
||||||
|
if (!action || !combo || prefixConflict || exactConflict) return;
|
||||||
|
onSave(action.id, combo);
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
const handleRecordingEvent = (event: React.KeyboardEvent<HTMLDivElement>, phase: 'keydown' | 'keyup') => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (phase === 'keyup' && action?.id === 'switch_context_surface' && recording.chords.length === 0) {
|
||||||
|
const modifierCombo = modifierKeyUpToCombo(event);
|
||||||
|
if (modifierCombo) {
|
||||||
|
setRecording({ chords: [modifierCombo], livePreview: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = updateShortcutRecordingState(recording, {
|
||||||
|
altKey: event.altKey,
|
||||||
|
ctrlKey: event.ctrlKey,
|
||||||
|
isComposing: event.nativeEvent.isComposing,
|
||||||
|
key: event.key,
|
||||||
|
metaKey: event.metaKey,
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={action !== null} onOpenChange={onOpenChange}>
|
<Dialog open={action !== null} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent className="max-w-md" initialFocus={recordingRef}>
|
||||||
className="max-w-md"
|
|
||||||
initialFocus={recordingRef}
|
|
||||||
>
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{action
|
{action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''}
|
||||||
? t('settings.openchamber.keyboardShortcuts.dialog.title', {
|
|
||||||
action: actionLabel(action),
|
|
||||||
})
|
|
||||||
: ''}
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>{t('settings.openchamber.keyboardShortcuts.dialog.instructions')}</DialogDescription>
|
<DialogDescription>{t('settings.openchamber.keyboardShortcuts.dialog.instructions')}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="space-y-3 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="flex min-h-28 items-center justify-center rounded-lg border border-border bg-[var(--surface-elevated)] px-4 py-5 text-center outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
ref={recordingRef}
|
ref={recordingRef}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => handleRecordingEvent(event, 'keydown')}
|
||||||
event.preventDefault();
|
onKeyUp={(event) => handleRecordingEvent(event, 'keyup')}
|
||||||
event.stopPropagation();
|
onBlur={() => setRecording((current) => ({ ...current, livePreview: null }))}
|
||||||
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.key === 'Backspace') {
|
|
||||||
setChords((current) => current.slice(0, -1));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chord = keyboardEventToCombo(event);
|
|
||||||
if (chord) {
|
|
||||||
setChords((current) => action?.id === 'switch_context_surface'
|
|
||||||
? [chord]
|
|
||||||
: current.length < 2 ? [...current, chord] : current);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyUp={(event) => {
|
|
||||||
if (action?.id !== 'switch_context_surface' || chords.length > 0) return;
|
|
||||||
const combo = modifierKeyUpToCombo(event);
|
|
||||||
if (!combo) return;
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
setChords([combo]);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{[0, 1].map((index) => (
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
<div key={index} className="flex items-center justify-between gap-3">
|
{recording.chords.map((chord, index) => (
|
||||||
<span className="typography-ui-label text-foreground">
|
<kbd key={`${chord}-${index}`} className="rounded-md border border-border bg-muted px-3 py-2 typography-ui-label font-mono text-foreground">
|
||||||
{t(index === 0
|
{formatShortcutForDisplay(chord)}
|
||||||
? 'settings.openchamber.keyboardShortcuts.dialog.firstChord'
|
|
||||||
: 'settings.openchamber.keyboardShortcuts.dialog.secondChord')}
|
|
||||||
</span>
|
|
||||||
<kbd
|
|
||||||
className="min-w-32 rounded-md border border-border bg-muted px-3 py-2 text-center typography-meta font-mono text-foreground"
|
|
||||||
>
|
|
||||||
{chords[index]
|
|
||||||
? formatShortcutForDisplay(chords[index])
|
|
||||||
: t('settings.openchamber.keyboardShortcuts.dialog.recording')}
|
|
||||||
</kbd>
|
</kbd>
|
||||||
</div>
|
))}
|
||||||
))}
|
{recording.livePreview ? (
|
||||||
{prefixConflict ? (
|
<kbd className="rounded-md border border-dashed border-border bg-muted px-3 py-2 typography-ui-label font-mono text-muted-foreground">
|
||||||
<p className="typography-meta text-[var(--status-error)]">
|
{formatShortcutForDisplay(recording.livePreview)}
|
||||||
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
|
</kbd>
|
||||||
</p>
|
) : null}
|
||||||
) : null}
|
{recording.chords.length === 0 && !recording.livePreview ? (
|
||||||
{exactConflict && !prefixConflict ? (
|
<span className="typography-ui-label text-muted-foreground">
|
||||||
<p className="typography-meta text-[var(--status-warning)]">
|
{t('settings.openchamber.keyboardShortcuts.dialog.recording')}
|
||||||
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
|
</span>
|
||||||
</p>
|
) : null}
|
||||||
) : null}
|
</div>
|
||||||
{combo && isRiskyBrowserShortcut(combo) ? (
|
|
||||||
<p className="typography-meta text-[var(--status-warning)]">
|
|
||||||
{t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
{prefixConflict ? (
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={close}>
|
<p className="typography-meta text-[var(--status-error)]">
|
||||||
{t('settings.common.actions.cancel')}
|
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
|
||||||
</Button>
|
</p>
|
||||||
{exactConflict && !prefixConflict ? (
|
) : null}
|
||||||
|
{exactConflict && !prefixConflict ? (
|
||||||
|
<p className="typography-meta text-[var(--status-warning)]">
|
||||||
|
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{combo && isRiskyBrowserShortcut(combo) ? (
|
||||||
|
<p className="typography-meta text-[var(--status-warning)]">
|
||||||
|
{t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{exactConflict && !prefixConflict ? (
|
||||||
|
<DialogFooter>
|
||||||
<Button type="button" size="sm" onClick={() => {
|
<Button type="button" size="sm" onClick={() => {
|
||||||
if (!action) return;
|
if (!action) return;
|
||||||
onSave(action.id, combo, exactConflict.action.id);
|
onSave(action.id, combo, exactConflict.action.id);
|
||||||
@@ -192,16 +246,8 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
|
|||||||
}}>
|
}}>
|
||||||
{t('settings.openchamber.keyboardShortcuts.actions.replaceAndSave')}
|
{t('settings.openchamber.keyboardShortcuts.actions.replaceAndSave')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
</DialogFooter>
|
||||||
<Button type="button" size="sm" disabled={!combo || Boolean(prefixConflict)} onClick={() => {
|
) : null}
|
||||||
if (!action) return;
|
|
||||||
onSave(action.id, combo);
|
|
||||||
close();
|
|
||||||
}}>
|
|
||||||
{t('settings.common.actions.saveChanges')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import { Icon } from '@/components/icon/Icon';
|
|||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
||||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||||
import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
import {
|
||||||
|
findSwitcherItemAncestorIds,
|
||||||
|
useSwitcherItems,
|
||||||
|
type SwitcherItem,
|
||||||
|
} from '@/components/session/sidebar/hooks/useSwitcherItems';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
import { formatSessionCompactDateLabel } from './sidebar/utils';
|
import { formatSessionCompactDateLabel } from './sidebar/utils';
|
||||||
@@ -22,6 +26,7 @@ import { cn } from '@/lib/utils';
|
|||||||
type SecondaryMeta = SwitcherItem['secondaryMeta'];
|
type SecondaryMeta = SwitcherItem['secondaryMeta'];
|
||||||
|
|
||||||
type SwitcherVariant = 'default' | 'compact';
|
type SwitcherVariant = 'default' | 'compact';
|
||||||
|
const NEW_SESSION_SWITCHER_TARGET = 'new-session';
|
||||||
|
|
||||||
type SessionSwitcherDropdownProps = {
|
type SessionSwitcherDropdownProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -40,7 +45,7 @@ export function SessionSwitcherDropdown({
|
|||||||
const setOpen = useUIStore((state) => state.setSessionDropdownOpen);
|
const setOpen = useUIStore((state) => state.setSessionDropdownOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false}>
|
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false} disableGlobalShortcuts>
|
||||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
align={align}
|
align={align}
|
||||||
@@ -69,7 +74,9 @@ type SwitcherContentProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
|
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
|
||||||
const items = useSwitcherItems(true, { scopeProjectId });
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
|
const isNewSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open === true);
|
||||||
|
const items = useSwitcherItems(true, { scopeProjectId, currentSessionId });
|
||||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -81,6 +88,9 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
|
|||||||
}, [onSelect, openNewSessionDraft, setActiveMainTab]);
|
}, [onSelect, openNewSessionDraft, setActiveMainTab]);
|
||||||
|
|
||||||
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
|
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
|
||||||
|
const contentRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const initialFocusCompleteRef = React.useRef(false);
|
||||||
|
const initialTarget = isNewSessionDraftOpen ? NEW_SESSION_SWITCHER_TARGET : currentSessionId;
|
||||||
const toggleParent = React.useCallback((sessionId: string) => {
|
const toggleParent = React.useCallback((sessionId: string) => {
|
||||||
setExpandedParents((prev) => {
|
setExpandedParents((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -93,10 +103,36 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (initialFocusCompleteRef.current || !initialTarget) return;
|
||||||
|
|
||||||
|
const ancestorIds = initialTarget === NEW_SESSION_SWITCHER_TARGET
|
||||||
|
? []
|
||||||
|
: findSwitcherItemAncestorIds(items, initialTarget);
|
||||||
|
if (!ancestorIds) return;
|
||||||
|
|
||||||
|
if (ancestorIds.some((id) => !expandedParents.has(id))) {
|
||||||
|
setExpandedParents((previous) => new Set([...previous, ...ancestorIds]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const animationFrame = requestAnimationFrame(() => {
|
||||||
|
const item = Array.from(
|
||||||
|
contentRef.current?.querySelectorAll<HTMLElement>('[data-switcher-item-id]') ?? [],
|
||||||
|
).find((element) => element.dataset.switcherItemId === initialTarget);
|
||||||
|
if (!item) return;
|
||||||
|
item.focus();
|
||||||
|
item.scrollIntoView({ block: 'nearest' });
|
||||||
|
initialFocusCompleteRef.current = true;
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(animationFrame);
|
||||||
|
}, [expandedParents, initialTarget, items]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-h-[60vh] overflow-y-auto">
|
<div ref={contentRef} className="max-h-[60vh] overflow-y-auto">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<BaseMenu.Item
|
<BaseMenu.Item
|
||||||
|
data-switcher-item-id={NEW_SESSION_SWITCHER_TARGET}
|
||||||
onClick={handleNewSession}
|
onClick={handleNewSession}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
|
'group relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
|
||||||
@@ -229,6 +265,7 @@ function SwitcherRow({ session, depth, variant, secondaryMeta, hasChildren, isEx
|
|||||||
handleSelect();
|
handleSelect();
|
||||||
}}
|
}}
|
||||||
data-slot="session-switcher-item"
|
data-slot="session-switcher-item"
|
||||||
|
data-switcher-item-id={session.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex w-full cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
|
'group relative flex w-full cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
|
||||||
'data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover',
|
'data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover',
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
|
import {
|
||||||
|
findSwitcherItemAncestorIds,
|
||||||
|
selectSwitcherParents,
|
||||||
|
type SwitcherItem,
|
||||||
|
} from './useSwitcherItems';
|
||||||
|
|
||||||
|
const session = (id: string, options: { parentID?: string; archived?: boolean; projectId?: string } = {}): Session => ({
|
||||||
|
id,
|
||||||
|
parentID: options.parentID,
|
||||||
|
time: options.archived ? { archived: Date.now() } : undefined,
|
||||||
|
projectId: options.projectId ?? 'project-a',
|
||||||
|
} as unknown as Session);
|
||||||
|
|
||||||
|
const selectParents = (sessions: Session[], currentSessionId: string | null, scopeProjectId: string | null = null): Session[] => (
|
||||||
|
selectSwitcherParents(sessions, new Set(), new Map(), scopeProjectId, currentSessionId, (item) => (item as Session & { projectId: string }).projectId)
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('session switcher initial selection', () => {
|
||||||
|
test('finds all local ancestors for a current child session', () => {
|
||||||
|
const items: SwitcherItem[] = [{
|
||||||
|
node: { session: session('root'), worktree: null, children: [{ session: session('parent', { parentID: 'root' }), worktree: null, children: [{ session: session('child', { parentID: 'parent' }), worktree: null, children: [] }] }] },
|
||||||
|
projectId: 'project-a', groupDirectory: null, secondaryMeta: null,
|
||||||
|
}];
|
||||||
|
|
||||||
|
expect(findSwitcherItemAncestorIds(items, 'child')).toEqual(['root', 'parent']);
|
||||||
|
expect(findSwitcherItemAncestorIds(items, 'missing')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaces the final recent slot with the current root and excludes invalid current sessions', () => {
|
||||||
|
const roots = Array.from({ length: 8 }, (_, index) => session(`root-${index}`));
|
||||||
|
const child = session('child', { parentID: 'root-7' });
|
||||||
|
|
||||||
|
expect(selectParents([...roots, child], 'child').map((item) => item.id)).toEqual([
|
||||||
|
'root-0', 'root-1', 'root-2', 'root-3', 'root-4', 'root-5', 'root-7',
|
||||||
|
]);
|
||||||
|
expect(selectParents([...roots, child], 'missing').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
|
||||||
|
expect(selectParents([...roots, child], 'child', 'project-b').map((item) => item.id)).toEqual([]);
|
||||||
|
expect(selectParents([...roots.slice(0, 7), session('archived', { archived: true })], 'archived').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
|
||||||
|
expect(selectParents([...roots, session('archived-child', { archived: true, parentID: 'root-7' })], 'archived-child').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ const MAX_PARENT_SESSIONS = 7;
|
|||||||
|
|
||||||
type SwitcherItemsOptions = {
|
type SwitcherItemsOptions = {
|
||||||
scopeProjectId?: string | null;
|
scopeProjectId?: string | null;
|
||||||
|
currentSessionId?: string | null;
|
||||||
/** How many parent sessions to return (default 7 — the desktop dropdown). */
|
/** How many parent sessions to return (default 7 — the desktop dropdown). */
|
||||||
maxParents?: number;
|
maxParents?: number;
|
||||||
};
|
};
|
||||||
@@ -43,8 +44,65 @@ const formatProjectLabel = (project: { label?: string | null; path: string } | n
|
|||||||
return segments[segments.length - 1] ?? null;
|
return segments[segments.length - 1] ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const findSwitcherItemAncestorIds = (items: SwitcherItem[], sessionId: string): string[] | null => {
|
||||||
|
const visit = (node: SessionNode, ancestors: string[]): string[] | null => {
|
||||||
|
if (node.session.id === sessionId) return ancestors;
|
||||||
|
for (const child of node.children) {
|
||||||
|
const result = visit(child, [...ancestors, node.session.id]);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const result = visit(item.node, []);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectSwitcherParents = (
|
||||||
|
activeSessions: Session[],
|
||||||
|
pinnedSessionIds: Set<string>,
|
||||||
|
sessionOrderRanks: Map<string, number>,
|
||||||
|
scopeProjectId: string | null,
|
||||||
|
currentSessionId: string | null,
|
||||||
|
getProjectId: (session: Session) => string | null,
|
||||||
|
maxParents = MAX_PARENT_SESSIONS,
|
||||||
|
): Session[] => {
|
||||||
|
const sessionsById = new Map(activeSessions.map((session) => [session.id, session]));
|
||||||
|
const isEligibleParent = (session: Session): boolean => {
|
||||||
|
if (session.time?.archived) return false;
|
||||||
|
if ((session as Session & { parentID?: string | null }).parentID) return false;
|
||||||
|
return !scopeProjectId || getProjectId(session) === scopeProjectId;
|
||||||
|
};
|
||||||
|
const parents = activeSessions
|
||||||
|
.filter(isEligibleParent)
|
||||||
|
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
|
||||||
|
|
||||||
|
const currentSession = currentSessionId ? sessionsById.get(currentSessionId) ?? null : null;
|
||||||
|
let currentRoot: Session | null = currentSession?.time?.archived ? null : currentSession;
|
||||||
|
const visited = new Set<string>();
|
||||||
|
while (currentRoot) {
|
||||||
|
const parentId = (currentRoot as Session & { parentID?: string | null }).parentID;
|
||||||
|
if (!parentId) break;
|
||||||
|
if (visited.has(parentId)) {
|
||||||
|
currentRoot = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
visited.add(parentId);
|
||||||
|
currentRoot = sessionsById.get(parentId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRootIndex = currentRoot && isEligibleParent(currentRoot) ? parents.indexOf(currentRoot) : -1;
|
||||||
|
if (currentRootIndex >= maxParents) {
|
||||||
|
return [...parents.slice(0, Math.max(0, maxParents - 1)), currentRoot!];
|
||||||
|
}
|
||||||
|
return parents.slice(0, maxParents);
|
||||||
|
};
|
||||||
|
|
||||||
export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => {
|
export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => {
|
||||||
const { scopeProjectId = null, maxParents = MAX_PARENT_SESSIONS } = options;
|
const { scopeProjectId = null, currentSessionId = null, maxParents = MAX_PARENT_SESSIONS } = options;
|
||||||
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
||||||
@@ -112,16 +170,15 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
|
|||||||
list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
|
list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks));
|
||||||
});
|
});
|
||||||
|
|
||||||
const parents = activeSessions
|
const parents = selectSwitcherParents(
|
||||||
.filter((session) => !session.time?.archived)
|
activeSessions,
|
||||||
.filter((session) => !(session as Session & { parentID?: string | null }).parentID)
|
pinnedSessionIds,
|
||||||
.filter((session) => {
|
sessionOrderRanks,
|
||||||
if (!scopeProjectId) return true;
|
scopeProjectId,
|
||||||
const directory = resolveGlobalSessionDirectory(session);
|
currentSessionId,
|
||||||
return findProjectForDirectory(directory)?.id === scopeProjectId;
|
(session) => findProjectForDirectory(resolveGlobalSessionDirectory(session))?.id ?? null,
|
||||||
})
|
maxParents,
|
||||||
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks))
|
);
|
||||||
.slice(0, maxParents);
|
|
||||||
|
|
||||||
const buildNode = (session: Session): SessionNode => {
|
const buildNode = (session: Session): SessionNode => {
|
||||||
const childSessions = childrenByParent.get(session.id) ?? [];
|
const childSessions = childrenByParent.get(session.id) ?? [];
|
||||||
@@ -151,7 +208,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
|
}, [activeSessions, branchesByDirectory, currentSessionId, enabled, findProjectForDirectory, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function getDropdownMenuNavigationKey(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): 'ArrowDown' | 'ArrowUp' | null {
|
||||||
|
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null;
|
||||||
|
if (event.key.toLowerCase() === 'n') return 'ArrowDown';
|
||||||
|
if (event.key.toLowerCase() === 'p') return 'ArrowUp';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import { getDropdownMenuNavigationKey } from './dropdown-menu-keyboard';
|
||||||
|
|
||||||
|
function keyEvent(key: string, modifiers: Partial<Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>> = {}) {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
ctrlKey: false,
|
||||||
|
metaKey: false,
|
||||||
|
altKey: false,
|
||||||
|
shiftKey: false,
|
||||||
|
...modifiers,
|
||||||
|
} as KeyboardEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('maps only exact Ctrl+N and Ctrl+P to menu navigation keys', () => {
|
||||||
|
expect(getDropdownMenuNavigationKey(keyEvent('n', { ctrlKey: true }))).toBe('ArrowDown');
|
||||||
|
expect(getDropdownMenuNavigationKey(keyEvent('p', { ctrlKey: true }))).toBe('ArrowUp');
|
||||||
|
expect(getDropdownMenuNavigationKey(keyEvent('N', { ctrlKey: true }))).toBe('ArrowDown');
|
||||||
|
expect(getDropdownMenuNavigationKey(keyEvent('n'))).toBe(null);
|
||||||
|
expect(getDropdownMenuNavigationKey(keyEvent('n', { ctrlKey: true, shiftKey: true }))).toBe(null);
|
||||||
|
expect(getDropdownMenuNavigationKey(keyEvent('p', { ctrlKey: true, altKey: true }))).toBe(null);
|
||||||
|
expect(getDropdownMenuNavigationKey(keyEvent('p', { ctrlKey: true, metaKey: true }))).toBe(null);
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import { Menu as BaseMenu } from "@base-ui/react/menu"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
|
import { shortcutRegistry } from "@/lib/shortcuts";
|
||||||
|
import { getDropdownMenuNavigationKey } from "./dropdown-menu-keyboard";
|
||||||
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles";
|
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles";
|
||||||
|
|
||||||
type AsChildProps = { asChild?: boolean };
|
type AsChildProps = { asChild?: boolean };
|
||||||
@@ -32,18 +34,43 @@ function renderFromAsChild(asChild: boolean | undefined, children: React.ReactNo
|
|||||||
return { children };
|
return { children };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DropdownMenuProps = React.ComponentProps<typeof BaseMenu.Root> & {
|
||||||
|
disableGlobalShortcuts?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
|
disableGlobalShortcuts = false,
|
||||||
|
open,
|
||||||
|
defaultOpen,
|
||||||
|
onOpenChange,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof BaseMenu.Root>) {
|
}: DropdownMenuProps) {
|
||||||
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
|
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
|
||||||
|
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false);
|
||||||
|
const isOpen = open ?? uncontrolledOpen;
|
||||||
const portalContextValue = React.useMemo<DropdownPortalContextValue>(() => ({
|
const portalContextValue = React.useMemo<DropdownPortalContextValue>(() => ({
|
||||||
portalContainer,
|
portalContainer,
|
||||||
setPortalContainer,
|
setPortalContainer,
|
||||||
}), [portalContainer]);
|
}), [portalContainer]);
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!disableGlobalShortcuts || !isOpen) return;
|
||||||
|
return shortcutRegistry.suspend();
|
||||||
|
}, [disableGlobalShortcuts, isOpen]);
|
||||||
|
|
||||||
|
const handleOpenChange: NonNullable<React.ComponentProps<typeof BaseMenu.Root>['onOpenChange']> = (nextOpen, eventDetails) => {
|
||||||
|
if (open === undefined) setUncontrolledOpen(nextOpen);
|
||||||
|
onOpenChange?.(nextOpen, eventDetails);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownPortalContext.Provider value={portalContextValue}>
|
<DropdownPortalContext.Provider value={portalContextValue}>
|
||||||
<BaseMenu.Root {...props} />
|
<BaseMenu.Root
|
||||||
|
{...props}
|
||||||
|
defaultOpen={defaultOpen}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
/>
|
||||||
</DropdownPortalContext.Provider>
|
</DropdownPortalContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -106,11 +133,27 @@ function DropdownMenuContent({
|
|||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
onCloseAutoFocus,
|
onCloseAutoFocus,
|
||||||
|
onKeyDown,
|
||||||
...props
|
...props
|
||||||
}: ContentProps) {
|
}: ContentProps) {
|
||||||
const portalContext = React.useContext(DropdownPortalContext);
|
const portalContext = React.useContext(DropdownPortalContext);
|
||||||
void onCloseAutoFocus
|
void onCloseAutoFocus
|
||||||
|
|
||||||
|
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseMenu.Popup>['onKeyDown']> = (event) => {
|
||||||
|
onKeyDown?.(event);
|
||||||
|
if (event.defaultPrevented || event.isPropagationStopped() || event.nativeEvent.isComposing) return;
|
||||||
|
const navigationKey = getDropdownMenuNavigationKey(event);
|
||||||
|
if (!navigationKey) return;
|
||||||
|
|
||||||
|
event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', {
|
||||||
|
key: navigationKey,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}));
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseMenu.Portal container={portalToBody ? undefined : portalContext?.portalContainer || undefined}>
|
<BaseMenu.Portal container={portalToBody ? undefined : portalContext?.portalContainer || undefined}>
|
||||||
<BaseMenu.Positioner
|
<BaseMenu.Positioner
|
||||||
@@ -132,6 +175,7 @@ function DropdownMenuContent({
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</BaseMenu.Popup>
|
</BaseMenu.Popup>
|
||||||
|
|||||||
@@ -399,7 +399,16 @@ export const useKeyboardShortcuts = () => {
|
|||||||
}
|
}
|
||||||
}, Math.max(expiresAt - now, 0));
|
}, Math.max(expiresAt - now, 0));
|
||||||
};
|
};
|
||||||
|
const handleActivePrefixKeyDownCapture = (event: KeyboardEvent) => {
|
||||||
|
if (isTerminalEventTarget(event.target)) return;
|
||||||
|
if (!dispatcher.hasActivePrefix()) return;
|
||||||
|
if (dispatcher.dispatchActivePrefix(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (dispatcher.consumeCapturedPrefixEvent(event)) return;
|
||||||
if (event.key === 'Escape' || isTerminalEventTarget(event.target)) return;
|
if (event.key === 'Escape' || isTerminalEventTarget(event.target)) return;
|
||||||
const combo = getEffectiveShortcutCombo('cycle_agent', useUIStore.getState().shortcutOverrides);
|
const combo = getEffectiveShortcutCombo('cycle_agent', useUIStore.getState().shortcutOverrides);
|
||||||
const backward = combo && !combo.includes('shift') ? normalizeCombo(`shift+${combo}`) : '';
|
const backward = combo && !combo.includes('shift') ? normalizeCombo(`shift+${combo}`) : '';
|
||||||
@@ -456,6 +465,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
window.addEventListener('keyup', handleKeyUp, true);
|
window.addEventListener('keyup', handleKeyUp, true);
|
||||||
window.addEventListener('keydown', handleTerminalShortcutCapture, true);
|
window.addEventListener('keydown', handleTerminalShortcutCapture, true);
|
||||||
window.addEventListener('keydown', handleEscapeKeyDownCapture, true);
|
window.addEventListener('keydown', handleEscapeKeyDownCapture, true);
|
||||||
|
window.addEventListener('keydown', handleActivePrefixKeyDownCapture, true);
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
window.addEventListener('blur', handleBlur);
|
window.addEventListener('blur', handleBlur);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -463,6 +473,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
window.removeEventListener('keyup', handleKeyUp, true);
|
window.removeEventListener('keyup', handleKeyUp, true);
|
||||||
window.removeEventListener('keydown', handleTerminalShortcutCapture, true);
|
window.removeEventListener('keydown', handleTerminalShortcutCapture, true);
|
||||||
window.removeEventListener('keydown', handleEscapeKeyDownCapture, true);
|
window.removeEventListener('keydown', handleEscapeKeyDownCapture, true);
|
||||||
|
window.removeEventListener('keydown', handleActivePrefixKeyDownCapture, true);
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
window.removeEventListener('blur', handleBlur);
|
window.removeEventListener('blur', handleBlur);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -98,14 +98,24 @@ export const useMiniChatKeyboardShortcuts = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
const handleActivePrefixKeyDownCapture = (event: KeyboardEvent) => {
|
||||||
|
if (!dispatcher.hasActivePrefix()) return;
|
||||||
|
if (dispatcher.dispatchActivePrefix(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
};
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (dispatcher.consumeCapturedPrefixEvent(event)) return;
|
||||||
if (dispatcher.dispatch(event)) event.preventDefault();
|
if (dispatcher.dispatch(event)) event.preventDefault();
|
||||||
};
|
};
|
||||||
const handleBlur = () => dispatcher.handleBlur();
|
const handleBlur = () => dispatcher.handleBlur();
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleActivePrefixKeyDownCapture, true);
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
window.addEventListener('blur', handleBlur);
|
window.addEventListener('blur', handleBlur);
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleActivePrefixKeyDownCapture, true);
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
window.removeEventListener('blur', handleBlur);
|
window.removeEventListener('blur', handleBlur);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1131,9 +1131,9 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation',
|
'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation',
|
||||||
'settings.openchamber.keyboardShortcuts.category.application': 'Application',
|
'settings.openchamber.keyboardShortcuts.category.application': 'Application',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edit',
|
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edit',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Replace and Save',
|
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Replace and save',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edit {action}',
|
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edit {action}',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Press up to two key combinations. Press Backspace to remove the last one.',
|
'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.firstChord': 'First combination',
|
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'First combination',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Second combination',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Second combination',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Press keys…',
|
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Press keys…',
|
||||||
@@ -1142,7 +1142,7 @@ export const settingsDict = {
|
|||||||
'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.action.open_draft_project_picker.label': 'Open draft project picker',
|
'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_draft_worktree_picker.label': 'Open draft worktree picker',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Open session list',
|
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Open recent sessions',
|
||||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Voice input',
|
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Voice input',
|
||||||
'settings.projects.sidebar.total': 'Total {count}',
|
'settings.projects.sidebar.total': 'Total {count}',
|
||||||
'settings.projects.sidebar.actions.addProject': 'Add project',
|
'settings.projects.sidebar.actions.addProject': 'Add project',
|
||||||
|
|||||||
@@ -1100,7 +1100,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
|
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
|
||||||
"settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Reemplazar y guardar",
|
"settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Reemplazar y guardar",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
|
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pulse hasta dos combinaciones de teclas. Pulse Retroceso para quitar la última.",
|
"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.firstChord": "Primera combinación",
|
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primera combinación",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinación",
|
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinación",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pulse las teclas…",
|
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pulse las teclas…",
|
||||||
@@ -1109,7 +1109,7 @@ export const settingsDict = {
|
|||||||
"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.action.open_draft_project_picker.label": "Abrir selector de proyecto de borrador",
|
"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_draft_worktree_picker.label": "Abrir selector de árbol de trabajo de borrador",
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir lista de sesiones",
|
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sesiones recientes",
|
||||||
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada de voz",
|
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada de voz",
|
||||||
"settings.projects.sidebar.total": "Total {count}",
|
"settings.projects.sidebar.total": "Total {count}",
|
||||||
"settings.projects.sidebar.actions.addProject": "Añadir proyecto",
|
"settings.projects.sidebar.actions.addProject": "Añadir proyecto",
|
||||||
|
|||||||
@@ -1021,7 +1021,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.actions.edit': 'Modifier',
|
'settings.openchamber.keyboardShortcuts.actions.edit': 'Modifier',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Remplacer et enregistrer',
|
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Remplacer et enregistrer',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Modifier {action}',
|
'settings.openchamber.keyboardShortcuts.dialog.title': 'Modifier {action}',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Appuyez sur deux combinaisons de touches au maximum. Appuyez sur Retour arrière pour supprimer la dernière.',
|
'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.firstChord': 'Première combinaison',
|
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Première combinaison',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Deuxième combinaison',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Deuxième combinaison',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Appuyez sur les touches…',
|
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Appuyez sur les touches…',
|
||||||
@@ -1030,7 +1030,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Cette combinaison est déjà utilisée par {action}.',
|
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Cette combinaison est déjà utilisée par {action}.',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Ouvrir le sélecteur de projet de brouillon',
|
'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_draft_worktree_picker.label': 'Ouvrir le sélecteur de worktree de brouillon',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Ouvrir la liste des sessions',
|
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Ouvrir les sessions récentes',
|
||||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Saisie vocale',
|
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Saisie vocale',
|
||||||
'settings.projects.sidebar.total': 'Total {count}',
|
'settings.projects.sidebar.total': 'Total {count}',
|
||||||
'settings.projects.sidebar.actions.addProject': 'Ajouter un projet',
|
'settings.projects.sidebar.actions.addProject': 'Ajouter un projet',
|
||||||
|
|||||||
@@ -1133,7 +1133,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.actions.edit': '編集',
|
'settings.openchamber.keyboardShortcuts.actions.edit': '編集',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '置き換えて保存',
|
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '置き換えて保存',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} を編集',
|
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} を編集',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力できます。Backspace で最後の組み合わせを削除します。',
|
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力できます。Enter で完了、Esc でキャンセル、Backspace で最後の組み合わせを削除します。',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '最初の組み合わせ',
|
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '最初の組み合わせ',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '2番目の組み合わせ',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '2番目の組み合わせ',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'キーを押してください…',
|
'settings.openchamber.keyboardShortcuts.dialog.recording': 'キーを押してください…',
|
||||||
@@ -1142,7 +1142,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'この組み合わせは {action} で使用されています。',
|
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'この組み合わせは {action} で使用されています。',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '下書きプロジェクト選択を開く',
|
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '下書きプロジェクト選択を開く',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '下書きワークツリー選択を開く',
|
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '下書きワークツリー選択を開く',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'セッション一覧を開く',
|
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '最近のセッションを開く',
|
||||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '音声入力',
|
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '音声入力',
|
||||||
'settings.projects.sidebar.total': '合計 {count}',
|
'settings.projects.sidebar.total': '合計 {count}',
|
||||||
'settings.projects.sidebar.actions.addProject': 'プロジェクトを追加',
|
'settings.projects.sidebar.actions.addProject': 'プロジェクトを追加',
|
||||||
|
|||||||
@@ -1100,7 +1100,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.actions.edit': '편집',
|
'settings.openchamber.keyboardShortcuts.actions.edit': '편집',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '바꾸고 저장',
|
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '바꾸고 저장',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} 편집',
|
'settings.openchamber.keyboardShortcuts.dialog.title': '{action} 편집',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. Backspace를 누르면 마지막 조합이 삭제됩니다.',
|
'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. Enter로 완료하고 Esc로 취소하거나 Backspace로 마지막 조합을 삭제하세요.',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '첫 번째 조합',
|
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '첫 번째 조합',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '두 번째 조합',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '두 번째 조합',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '키를 누르세요…',
|
'settings.openchamber.keyboardShortcuts.dialog.recording': '키를 누르세요…',
|
||||||
@@ -1109,7 +1109,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': '이 조합은 이미 {action}에서 사용합니다.',
|
'settings.openchamber.keyboardShortcuts.error.exactConflict': '이 조합은 이미 {action}에서 사용합니다.',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '초안 프로젝트 선택기 열기',
|
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '초안 프로젝트 선택기 열기',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '초안 워크트리 선택기 열기',
|
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '초안 워크트리 선택기 열기',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '세션 목록 열기',
|
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '최근 세션 열기',
|
||||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '음성 입력',
|
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '음성 입력',
|
||||||
'settings.projects.sidebar.total': '총 {count}개',
|
'settings.projects.sidebar.total': '총 {count}개',
|
||||||
'settings.projects.sidebar.actions.addProject': '프로젝트 추가',
|
'settings.projects.sidebar.actions.addProject': '프로젝트 추가',
|
||||||
|
|||||||
@@ -1368,7 +1368,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edytuj',
|
'settings.openchamber.keyboardShortcuts.actions.edit': 'Edytuj',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Zastąp i zapisz',
|
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Zastąp i zapisz',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edytuj: {action}',
|
'settings.openchamber.keyboardShortcuts.dialog.title': 'Edytuj: {action}',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Naciśnij maksymalnie dwie kombinacje klawiszy. Naciśnij Backspace, aby usunąć ostatnią.',
|
'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.firstChord': 'Pierwsza kombinacja',
|
'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Pierwsza kombinacja',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Druga kombinacja',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Druga kombinacja',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Naciśnij klawisze…',
|
'settings.openchamber.keyboardShortcuts.dialog.recording': 'Naciśnij klawisze…',
|
||||||
@@ -1377,7 +1377,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Ta kombinacja jest już używana przez {action}.',
|
'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Ta kombinacja jest już używana przez {action}.',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Otwórz wybór projektu szkicu',
|
'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_draft_worktree_picker.label': 'Otwórz wybór worktree szkicu',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Otwórz listę sesji',
|
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Otwórz ostatnie sesje',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Otwórz oś czasu rozmowy',
|
'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Otwórz oś czasu rozmowy',
|
||||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Wprowadzanie głosowe',
|
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Wprowadzanie głosowe',
|
||||||
'settings.projects.sidebar.total': 'Suma: {count}',
|
'settings.projects.sidebar.total': 'Suma: {count}',
|
||||||
|
|||||||
@@ -1100,7 +1100,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
|
"settings.openchamber.keyboardShortcuts.actions.edit": "Editar",
|
||||||
"settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Substituir e salvar",
|
"settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Substituir e salvar",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
|
"settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Pressione até duas combinações de teclas. Pressione Backspace para remover a última.",
|
"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.firstChord": "Primeira combinação",
|
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primeira combinação",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinação",
|
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinação",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pressione as teclas…",
|
"settings.openchamber.keyboardShortcuts.dialog.recording": "Pressione as teclas…",
|
||||||
@@ -1109,7 +1109,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinação já é usada por {action}.",
|
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinação já é usada por {action}.",
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Abrir seletor de projeto do rascunho",
|
"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_draft_worktree_picker.label": "Abrir seletor de worktree do rascunho",
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir lista de sessões",
|
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sessões recentes",
|
||||||
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada por voz",
|
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada por voz",
|
||||||
"settings.projects.sidebar.total": "Total {count}",
|
"settings.projects.sidebar.total": "Total {count}",
|
||||||
"settings.projects.sidebar.actions.addProject": "Adicionar projeto",
|
"settings.projects.sidebar.actions.addProject": "Adicionar projeto",
|
||||||
|
|||||||
@@ -1100,7 +1100,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.keyboardShortcuts.actions.edit": "Редагувати",
|
"settings.openchamber.keyboardShortcuts.actions.edit": "Редагувати",
|
||||||
"settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Замінити й зберегти",
|
"settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Замінити й зберегти",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.title": "Редагувати {action}",
|
"settings.openchamber.keyboardShortcuts.dialog.title": "Редагувати {action}",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш. Натисніть Backspace, щоб видалити останню.",
|
"settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш. Натисніть Enter, щоб завершити, Esc, щоб скасувати, або Backspace, щоб видалити останню.",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Перша комбінація",
|
"settings.openchamber.keyboardShortcuts.dialog.firstChord": "Перша комбінація",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Друга комбінація",
|
"settings.openchamber.keyboardShortcuts.dialog.secondChord": "Друга комбінація",
|
||||||
"settings.openchamber.keyboardShortcuts.dialog.recording": "Натисніть клавіші…",
|
"settings.openchamber.keyboardShortcuts.dialog.recording": "Натисніть клавіші…",
|
||||||
@@ -1109,7 +1109,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Цю комбінацію вже використовує {action}.",
|
"settings.openchamber.keyboardShortcuts.error.exactConflict": "Цю комбінацію вже використовує {action}.",
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Відкрити вибір проєкту чернетки",
|
"settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Відкрити вибір проєкту чернетки",
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Відкрити вибір worktree чернетки",
|
"settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Відкрити вибір worktree чернетки",
|
||||||
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Відкрити список сесій",
|
"settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Відкрити останні сесії",
|
||||||
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Голосове введення",
|
"settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Голосове введення",
|
||||||
"settings.projects.sidebar.total": "Усього {count}",
|
"settings.projects.sidebar.total": "Усього {count}",
|
||||||
"settings.projects.sidebar.actions.addProject": "Додати проєкт",
|
"settings.projects.sidebar.actions.addProject": "Додати проєкт",
|
||||||
|
|||||||
@@ -1100,7 +1100,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.actions.edit': '编辑',
|
'settings.openchamber.keyboardShortcuts.actions.edit': '编辑',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '替换并保存',
|
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '替换并保存',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': '编辑{action}',
|
'settings.openchamber.keyboardShortcuts.dialog.title': '编辑{action}',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下两个按键组合。按 Backspace 删除最后一个。',
|
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下两个按键组合。按 Enter 完成,按 Esc 取消,按 Backspace 删除最后一个。',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一个组合',
|
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一个组合',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二个组合',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二个组合',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按键…',
|
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按键…',
|
||||||
@@ -1109,7 +1109,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': '此组合已被 {action} 使用。',
|
'settings.openchamber.keyboardShortcuts.error.exactConflict': '此组合已被 {action} 使用。',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '打开草稿项目选择器',
|
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '打开草稿项目选择器',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '打开草稿工作树选择器',
|
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '打开草稿工作树选择器',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '打开会话列表',
|
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '打开最近会话',
|
||||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '语音输入',
|
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '语音输入',
|
||||||
'settings.projects.sidebar.total': '总计 {count}',
|
'settings.projects.sidebar.total': '总计 {count}',
|
||||||
'settings.projects.sidebar.actions.addProject': '添加项目',
|
'settings.projects.sidebar.actions.addProject': '添加项目',
|
||||||
|
|||||||
@@ -1007,7 +1007,7 @@
|
|||||||
'settings.openchamber.keyboardShortcuts.actions.edit': '編輯',
|
'settings.openchamber.keyboardShortcuts.actions.edit': '編輯',
|
||||||
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '取代並儲存',
|
'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '取代並儲存',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.title': '編輯 {action}',
|
'settings.openchamber.keyboardShortcuts.dialog.title': '編輯 {action}',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下兩個按鍵組合。按 Backspace 可刪除最後一個。',
|
'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下兩個按鍵組合。按 Enter 完成,按 Esc 取消,按 Backspace 可刪除最後一個。',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一個組合',
|
'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一個組合',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二個組合',
|
'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二個組合',
|
||||||
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按鍵…',
|
'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按鍵…',
|
||||||
@@ -1016,7 +1016,7 @@
|
|||||||
'settings.openchamber.keyboardShortcuts.error.exactConflict': '此組合已由 {action} 使用。',
|
'settings.openchamber.keyboardShortcuts.error.exactConflict': '此組合已由 {action} 使用。',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '開啟草稿專案選擇器',
|
'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '開啟草稿專案選擇器',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '開啟草稿 worktree 選擇器',
|
'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '開啟草稿 worktree 選擇器',
|
||||||
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '開啟工作階段列表',
|
'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '開啟最近工作階段',
|
||||||
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '語音輸入',
|
'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '語音輸入',
|
||||||
'settings.projects.sidebar.total': '總計 {count}',
|
'settings.projects.sidebar.total': '總計 {count}',
|
||||||
'settings.projects.sidebar.actions.addProject': '新增專案',
|
'settings.projects.sidebar.actions.addProject': '新增專案',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Component interaction keys that are not application commands, such as list navig
|
|||||||
- `config.ts` owns grouped declarations and the final `SHORTCUT_SCHEMA`.
|
- `config.ts` owns grouped declarations and the final `SHORTCUT_SCHEMA`.
|
||||||
- `schema.ts` derives action and category types and provides schema lookup and effective binding resolution.
|
- `schema.ts` derives action and category types and provides schema lookup and effective binding resolution.
|
||||||
- `bindings.ts` owns chord parsing, normalization, display, browser-risk checks, and conflict rules.
|
- `bindings.ts` owns chord parsing, normalization, display, browser-risk checks, and conflict rules.
|
||||||
- `registry.ts` owns the active handler for each action ID.
|
- `registry.ts` owns the active handler for each action ID and stack-safe temporary suspension of all application handlers.
|
||||||
- `dispatcher.ts` resolves current bindings and turns keyboard events into registered command calls.
|
- `dispatcher.ts` resolves current bindings and turns keyboard events into registered command calls.
|
||||||
- `useKeybind.ts` ties registrations to React component lifetimes while keeping handlers current without re-registering after every render.
|
- `useKeybind.ts` ties registrations to React component lifetimes while keeping handlers current without re-registering after every render.
|
||||||
- Runtime hooks install one dispatcher listener for their window. The main application and Mini Chat have separate windows but use the same contracts.
|
- Runtime hooks install one dispatcher listener for their window. The main application and Mini Chat have separate windows but use the same contracts.
|
||||||
@@ -35,7 +35,11 @@ The settings recorder also stops at two chords. It keeps the recording local unt
|
|||||||
|
|
||||||
# Dispatching
|
# Dispatching
|
||||||
|
|
||||||
`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 1500ms. The application keydown route clears that prefix on window blur and consumes Escape only when it cancels a prefix. A handler returns `false` to leave the completed binding unconsumed.
|
`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 1500ms. The application keydown route clears that prefix on window blur and consumes Escape only when it cancels a prefix. A handler returns `false` to leave the completed binding unconsumed. When a sequence prefix is active, only its second key is dispatched during window capture so local input handlers cannot block it; unconsumed keys retain local input behavior, while consumed keys are prevented and stopped. Normal application shortcuts remain window-bubble listeners.
|
||||||
|
|
||||||
|
`shortcutRegistry.suspend()` disables all application handlers and returns an idempotent cleanup. Suspensions nest; handlers resume only after the final cleanup. Starting or ending a suspension invalidates every pending dispatcher prefix, so stale second keys and Escape cannot consume it.
|
||||||
|
|
||||||
|
Shared `DropdownMenu` can opt into this boundary with `disableGlobalShortcuts`; it suspends while open for both controlled and uncontrolled menus and resumes on close or unmount.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,35 @@ describe('ShortcutDispatcher', () => {
|
|||||||
expect(calls).toEqual(['x', 'y']);
|
expect(calls).toEqual(['x', 'y']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('invalidates a prefix when shortcut suspension changes', () => {
|
||||||
|
const registry = new ShortcutRegistry();
|
||||||
|
const calls: string[] = [];
|
||||||
|
registry.register('open_command_palette', () => { calls.push('sequence'); });
|
||||||
|
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
|
||||||
|
|
||||||
|
expect(dispatcher.dispatch(key('g'))).toBe(true);
|
||||||
|
const resume = registry.suspend();
|
||||||
|
expect(dispatcher.hasActivePrefix()).toBe(false);
|
||||||
|
expect(dispatcher.handleEscape()).toBe(false);
|
||||||
|
resume();
|
||||||
|
expect(dispatcher.dispatch(key('h'))).toBe(false);
|
||||||
|
expect(calls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('marks a second key dispatched from capture so bubble does not dispatch it again', () => {
|
||||||
|
const registry = new ShortcutRegistry();
|
||||||
|
const calls: string[] = [];
|
||||||
|
registry.register('open_command_palette', () => { calls.push('sequence'); });
|
||||||
|
const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' });
|
||||||
|
const secondKey = key('h');
|
||||||
|
|
||||||
|
dispatcher.dispatch(key('g'));
|
||||||
|
expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true);
|
||||||
|
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true);
|
||||||
|
expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(false);
|
||||||
|
expect(calls).toEqual(['sequence']);
|
||||||
|
});
|
||||||
|
|
||||||
test('stops after the first handler that accepts a conflicting binding', () => {
|
test('stops after the first handler that accepts a conflicting binding', () => {
|
||||||
const registry = new ShortcutRegistry();
|
const registry = new ShortcutRegistry();
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export class ShortcutDispatcher {
|
|||||||
private readonly timeoutMs: number;
|
private readonly timeoutMs: number;
|
||||||
private prefix: string | undefined;
|
private prefix: string | undefined;
|
||||||
private expiresAt = 0;
|
private expiresAt = 0;
|
||||||
|
private prefixSuspensionVersion = 0;
|
||||||
|
private readonly capturedPrefixEvents = new WeakSet<KeyboardEvent>();
|
||||||
|
|
||||||
constructor(private readonly options: ShortcutDispatcherOptions) {
|
constructor(private readonly options: ShortcutDispatcherOptions) {
|
||||||
this.now = options.now ?? Date.now;
|
this.now = options.now ?? Date.now;
|
||||||
@@ -39,12 +41,10 @@ export class ShortcutDispatcher {
|
|||||||
if (event.repeat || event.isComposing || MODIFIER_KEYS.has(event.key.toLowerCase())) {
|
if (event.repeat || event.isComposing || MODIFIER_KEYS.has(event.key.toLowerCase())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (event.key === 'Escape' && this.prefix) {
|
if (event.key === 'Escape' && this.hasActivePrefix()) {
|
||||||
return this.handleEscape();
|
return this.handleEscape();
|
||||||
}
|
}
|
||||||
if (this.prefix && this.now() >= this.expiresAt) {
|
this.hasActivePrefix();
|
||||||
this.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = this.getMatches();
|
const matches = this.getMatches();
|
||||||
if (this.prefix) {
|
if (this.prefix) {
|
||||||
@@ -73,6 +73,7 @@ export class ShortcutDispatcher {
|
|||||||
if (leader) {
|
if (leader) {
|
||||||
this.prefix = leader.chords[0];
|
this.prefix = leader.chords[0];
|
||||||
this.expiresAt = this.now() + this.timeoutMs;
|
this.expiresAt = this.now() + this.timeoutMs;
|
||||||
|
this.prefixSuspensionVersion = this.options.registry.getSuspensionVersion();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -81,6 +82,7 @@ export class ShortcutDispatcher {
|
|||||||
clear(): void {
|
clear(): void {
|
||||||
this.prefix = undefined;
|
this.prefix = undefined;
|
||||||
this.expiresAt = 0;
|
this.expiresAt = 0;
|
||||||
|
this.prefixSuspensionVersion = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleBlur(): void {
|
handleBlur(): void {
|
||||||
@@ -88,11 +90,34 @@ export class ShortcutDispatcher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleEscape(): boolean {
|
handleEscape(): boolean {
|
||||||
const hadPrefix = Boolean(this.prefix);
|
const hadPrefix = this.hasActivePrefix();
|
||||||
this.clear();
|
this.clear();
|
||||||
return hadPrefix;
|
return hadPrefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasActivePrefix(): boolean {
|
||||||
|
if (!this.prefix) return false;
|
||||||
|
if (
|
||||||
|
this.now() >= this.expiresAt
|
||||||
|
|| this.prefixSuspensionVersion !== this.options.registry.getSuspensionVersion()
|
||||||
|
) {
|
||||||
|
this.clear();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchActivePrefix(event: KeyboardEvent): boolean {
|
||||||
|
this.capturedPrefixEvents.add(event);
|
||||||
|
return this.dispatch(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
consumeCapturedPrefixEvent(event: KeyboardEvent): boolean {
|
||||||
|
if (!this.capturedPrefixEvents.has(event)) return false;
|
||||||
|
this.capturedPrefixEvents.delete(event);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private invoke(matches: BindingMatch[], event: KeyboardEvent): boolean {
|
private invoke(matches: BindingMatch[], event: KeyboardEvent): boolean {
|
||||||
for (const match of matches) {
|
for (const match of matches) {
|
||||||
if (match.handler(event) !== false) {
|
if (match.handler(event) !== false) {
|
||||||
|
|||||||
@@ -25,3 +25,20 @@ test('a later registration takes over after the first unregisters', () => {
|
|||||||
first();
|
first();
|
||||||
expect(registry.get('open_settings')).toBe(secondHandler);
|
expect(registry.get('open_settings')).toBe(secondHandler);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('suspends all handlers until every idempotent cleanup completes', () => {
|
||||||
|
const registry = new ShortcutRegistry();
|
||||||
|
const handler = () => undefined;
|
||||||
|
registry.register('open_settings', handler);
|
||||||
|
|
||||||
|
const resumeFirst = registry.suspend();
|
||||||
|
const resumeSecond = registry.suspend();
|
||||||
|
expect(registry.get('open_settings')).toBe(undefined);
|
||||||
|
|
||||||
|
resumeFirst();
|
||||||
|
resumeFirst();
|
||||||
|
expect(registry.get('open_settings')).toBe(undefined);
|
||||||
|
resumeSecond();
|
||||||
|
resumeSecond();
|
||||||
|
expect(registry.get('open_settings')).toBe(handler);
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface RegisteredHandler {
|
|||||||
/** Active application command handlers, keyed by shortcut action ID. */
|
/** Active application command handlers, keyed by shortcut action ID. */
|
||||||
export class ShortcutRegistry {
|
export class ShortcutRegistry {
|
||||||
private readonly handlers = new Map<ShortcutActionId, RegisteredHandler[]>();
|
private readonly handlers = new Map<ShortcutActionId, RegisteredHandler[]>();
|
||||||
|
private suspensionCount = 0;
|
||||||
|
private suspensionVersion = 0;
|
||||||
|
|
||||||
register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void {
|
register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void {
|
||||||
const registration = { handler };
|
const registration = { handler };
|
||||||
@@ -28,9 +30,29 @@ export class ShortcutRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get(actionId: ShortcutActionId): ShortcutHandler | undefined {
|
get(actionId: ShortcutActionId): ShortcutHandler | undefined {
|
||||||
|
if (this.suspensionCount > 0) return undefined;
|
||||||
return this.handlers.get(actionId)?.[0]?.handler;
|
return this.handlers.get(actionId)?.[0]?.handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Temporarily disables every registered application shortcut. */
|
||||||
|
suspend(): () => void {
|
||||||
|
this.suspensionCount += 1;
|
||||||
|
this.suspensionVersion += 1;
|
||||||
|
let active = true;
|
||||||
|
return () => {
|
||||||
|
if (!active) return;
|
||||||
|
active = false;
|
||||||
|
this.suspensionCount -= 1;
|
||||||
|
if (this.suspensionCount === 0) {
|
||||||
|
this.suspensionVersion += 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getSuspensionVersion(): number {
|
||||||
|
return this.suspensionVersion;
|
||||||
|
}
|
||||||
|
|
||||||
actionIds(): IterableIterator<ShortcutActionId> {
|
actionIds(): IterableIterator<ShortcutActionId> {
|
||||||
return this.handlers.keys();
|
return this.handlers.keys();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user