feat(ui): migrate all shortcut surfaces to the centralized registry

This commit is contained in:
Bohdan Triapitsyn
2026-08-26 10:59:04 +03:00
parent f1c3870909
commit 94f6b5fd38
24 changed files with 1057 additions and 904 deletions
+37 -64
View File
@@ -45,7 +45,7 @@ import {
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils';
import { cn, getRevealLabelKey } from '@/lib/utils';
import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers';
import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave';
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
@@ -75,7 +75,8 @@ import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
import { getDefaultTheme } from '@/lib/theme/themes';
import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop';
import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useKeybind, useKeybinds } from '@/hooks/useKeybind';
import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
import { syncScheduledTaskLoops } from '@/lib/scheduledTasksApi';
@@ -1032,7 +1033,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation);
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath);
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap);
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
@@ -1759,35 +1759,28 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
setAutoSaveStatus('idle');
}, [selectedFile?.path]);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!hasModifier(e)) {
return;
}
useKeybinds({
save_file: (event) => {
if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false;
if (e.key.toLowerCase() === 's') {
e.preventDefault();
// Cancel pending auto-save; user wants immediate save
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (!isSaving) {
void saveDraft().then((saved) => {
if (!saved) return;
setAutoSaveStatus('saved');
setTimeout(() => setAutoSaveStatus('idle'), 2000);
});
}
} else if (e.key.toLowerCase() === 'f') {
e.preventDefault();
setIsSearchOpen(true);
// Cancel pending auto-save because the explicit save should run immediately.
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isSaving, saveDraft]);
if (!isSaving) {
void saveDraft().then((saved) => {
if (!saved) return;
setAutoSaveStatus('saved');
setTimeout(() => setAutoSaveStatus('idle'), 2000);
});
}
},
find_in_file: (event) => {
if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false;
setIsSearchOpen(true);
},
});
const loadSelectedFile = React.useCallback(async (node: FileNode) => {
const loadId = activeFileLoadIdRef.current + 1;
@@ -2906,42 +2899,21 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
};
}, [isMobile, nudgeEditorSelectionAboveKeyboard]);
React.useEffect(() => {
useKeybind('open_go_to_line', (event) => {
if (!canEdit || textViewMode !== 'edit' || isMobile) {
return;
return false;
}
const goToLineCombo = getEffectiveShortcutCombo('open_go_to_line', shortcutOverrides);
const target = event.target as Element | null;
if (target?.closest('[role="dialog"]')) return false;
if (!(target instanceof Node) || !editorWrapperRef.current?.contains(target)) return false;
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as Element | null;
if (target?.closest('[role="dialog"]')) {
return;
}
const isEditorTarget = Boolean(target?.closest('.cm-editor'));
const isTypingTarget = Boolean(target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]'));
if (isTypingTarget && !isEditorTarget) return false;
const isEditorTarget = Boolean(target?.closest('.cm-editor'));
const isTypingTarget = Boolean(
target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]')
);
if (isTypingTarget && !isEditorTarget) {
return;
}
const activeElement = document.activeElement as Element | null;
const editorHasFocus = Boolean(activeElement?.closest('.cm-editor'));
if (!editorHasFocus) {
return;
}
if (eventMatchesShortcut(event, goToLineCombo)) {
event.preventDefault();
setIsGoToLineOpen(true);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [canEdit, isMobile, shortcutOverrides, textViewMode]);
setIsGoToLineOpen(true);
});
const editorFontSize = useUIStore((state) => state.editorFontSize);
@@ -3196,6 +3168,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
const docked = layout === 'docked';
const saveShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('save_file'));
const wrapperCls = docked
? 'pointer-events-auto flex flex-wrap items-center gap-1'
: 'pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-1 shadow-sm';
@@ -3225,14 +3198,14 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
<Icon name="check" className="size-3.5" />
{t('filesView.editor.saved')}
</span>
) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` }),
) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut }),
<Button
variant="ghost"
size="sm"
onClick={() => void saveDraft()}
className="h-6 gap-1 px-1 text-muted-foreground opacity-80 hover:bg-transparent hover:opacity-100 focus-visible:bg-transparent active:bg-transparent"
title={t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` })}
aria-label={t('filesView.editor.saveAria', { shortcut: `${getModifierLabel()}+S` })}
title={t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut })}
aria-label={t('filesView.editor.saveAria', { shortcut: saveShortcut })}
>
<Icon name="save-3" className="size-4" />
</Button>
@@ -1,5 +1,9 @@
import React from 'react';
import { cn, getModifierLabel } from '@/lib/utils';
import { cn } from '@/lib/utils';
import {
formatShortcutForDisplay,
getEffectiveShortcutCombo,
} from '@/lib/shortcuts';
import { useUIStore } from '@/stores/useUIStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useProjectsStore } from '@/stores/useProjectsStore';
@@ -187,6 +191,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const settingsPageRaw = useUIStore((state) => state.settingsPage);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const openSettingsShortcutOverride = useUIStore((state) => state.shortcutOverrides.open_settings);
const settingsSlug = resolveSettingsSlug(settingsPageRaw);
const [mobileStage, setMobileStage] = React.useState<MobileStage>(initialMobileStage);
@@ -728,7 +733,15 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
: showBackButton
? t('settings.view.actions.backToSettings')
: t('settings.view.actions.closeSettings');
const shortcutKey = getModifierLabel();
const openSettingsCombo = getEffectiveShortcutCombo(
'open_settings',
openSettingsShortcutOverride === undefined ? undefined : { open_settings: openSettingsShortcutOverride },
);
const closeSettingsTitle = openSettingsCombo
? t('settings.view.actions.closeSettingsWithShortcut', {
shortcut: formatShortcutForDisplay(openSettingsCombo),
})
: t('settings.view.actions.closeSettings');
const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => {
if (typeof window === 'undefined' || runtimeCtx.isVSCode) {
@@ -1077,7 +1090,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
type="button"
onClick={onClose}
aria-label={t('settings.view.actions.closeSettings')}
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })}
title={closeSettingsTitle}
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="close" className="h-5 w-5" />
@@ -1105,7 +1118,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
type="button"
onClick={onClose}
aria-label={t('settings.view.actions.closeSettings')}
title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })}
title={closeSettingsTitle}
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
<Icon name="close" className="h-5 w-5" />
@@ -21,6 +21,7 @@ import { useI18n } from '@/lib/i18n';
import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions';
import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore';
import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput';
import { formatShortcutForDisplay } from '@/lib/shortcuts';
type TerminalViewProps = {
visible?: boolean;
@@ -968,7 +969,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
onClick={() => handleModifierToggle('ctrl')}
disabled={quickKeysDisabled}
>
<span className="text-xs font-medium">{t('terminalView.quickKeys.controlLabel')}</span>
<span className="text-xs font-medium">{formatShortcutForDisplay('ctrl')}</span>
<span className="sr-only">{t('terminalView.quickKeys.controlModifierAria')}</span>
</Button>
<Button
@@ -981,7 +982,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
onClick={() => handleModifierToggle('alt')}
disabled={quickKeysDisabled}
>
<span className="text-xs font-medium">{t('terminalView.quickKeys.altLabel')}</span>
<span className="text-xs font-medium">{formatShortcutForDisplay('alt')}</span>
<span className="sr-only">{t('terminalView.quickKeys.altModifierAria')}</span>
</Button>
<Button