refactor(ui): register application shortcuts centrally

This commit is contained in:
ChangeHow
2026-08-06 14:18:14 +08:00
parent 82f099be0a
commit ab3ccf395d
4 changed files with 517 additions and 815 deletions
+51 -75
View File
@@ -33,11 +33,12 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout';
import { useKeybinds } from '@/hooks/useKeybind';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls';
import { UpdateDialog } from '@/components/ui/UpdateDialog';
import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device';
import { cn, hasModifier } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
import { McpIcon } from '@/components/icons/McpIcon';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
@@ -46,7 +47,11 @@ import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
import { updateDesktopSettings } from '@/lib/persistence';
import { formatTimeForPreference } from '@/lib/timeFormat';
import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import {
formatShortcutForDisplay,
getEffectiveShortcutCombo,
type ShortcutActionId,
} from '@/lib/shortcuts';
import type { TimeFormatPreference } from '@/stores/useUIStore';
import {
getAllModelFamilies,
@@ -285,7 +290,7 @@ type DesktopServicesMenuProps = {
rateLimitGroups: RateLimitGroup[];
expandedFamilies: Record<string, string[]>;
toggleFamilyExpanded: (providerId: string, familyId: string) => void;
shortcutLabel: (actionId: string) => string;
shortcutLabel: (actionId: ShortcutActionId) => string;
showDevShutdown: boolean;
isDevShutdownInFlight: boolean;
onDevShutdown: () => Promise<void>;
@@ -1935,7 +1940,7 @@ export const Header: React.FC<HeaderProps> = ({
return [];
}, [isMobile, showPlanTab, t]);
const shortcutLabel = React.useCallback((actionId: string) => {
const shortcutLabel = React.useCallback((actionId: ShortcutActionId) => {
return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides));
}, [shortcutOverrides]);
@@ -2043,82 +2048,53 @@ export const Header: React.FC<HeaderProps> = ({
];
}, [t]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (hasModifier(e) && !e.shiftKey && !e.altKey) {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= tabs.length) {
e.preventDefault();
if (isMobile) {
blurActiveElement();
closeMobileHeaderPanels();
}
setActiveMainTab(tabs[num - 1].id);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [blurActiveElement, closeMobileHeaderPanels, isMobile, setActiveMainTab, tabs]);
const switchToIndexedTab = (index: number) => {
const tab = tabs[index];
if (!tab) return false;
if (isMobile) {
blurActiveElement();
closeMobileHeaderPanels();
}
setActiveMainTab(tab.id);
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides);
if (eventMatchesShortcut(e, toggleServicesCombo)) {
e.preventDefault();
if (isDesktopServicesOpen) {
setIsDesktopServicesOpen(false);
} else {
setIsDesktopServicesOpen(true);
void refreshCurrentInstanceLabel();
if (desktopServicesTab === 'usage' && quotaResults.length === 0) {
void fetchAllQuotas();
}
}
useKeybinds({
switch_tab_1: () => switchToIndexedTab(0),
switch_tab_2: () => switchToIndexedTab(1),
switch_tab_3: () => switchToIndexedTab(2),
switch_tab_4: () => switchToIndexedTab(3),
switch_tab_5: () => switchToIndexedTab(4),
switch_tab_6: () => switchToIndexedTab(5),
switch_tab_7: () => switchToIndexedTab(6),
switch_tab_8: () => switchToIndexedTab(7),
switch_tab_9: () => switchToIndexedTab(8),
toggle_services_menu: () => {
if (isDesktopServicesOpen) {
setIsDesktopServicesOpen(false);
return;
}
const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides);
if (eventMatchesShortcut(e, cycleServicesCombo)) {
e.preventDefault();
const tabValues = servicesTabs.map((tab) => tab.value) as Array<'instance' | 'usage' | 'mcp'>;
if (tabValues.length === 0) {
return;
}
const currentIndex = tabValues.indexOf(desktopServicesTab);
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % tabValues.length;
const nextTab = tabValues[nextIndex];
setDesktopServicesTab(nextTab);
setIsDesktopServicesOpen(true);
void refreshCurrentInstanceLabel();
if (nextTab === 'usage' && quotaResults.length === 0) {
void fetchAllQuotas();
}
return;
setIsDesktopServicesOpen(true);
void refreshCurrentInstanceLabel();
if (desktopServicesTab === 'usage' && quotaResults.length === 0) {
void fetchAllQuotas();
}
const toggleContextPlanCombo = getEffectiveShortcutCombo('toggle_context_plan', shortcutOverrides);
if (eventMatchesShortcut(e, toggleContextPlanCombo)) {
e.preventDefault();
handleOpenContextPlan();
},
cycle_services_tab: () => {
const tabValues = servicesTabs.map((tab) => tab.value) as Array<'instance' | 'usage' | 'mcp'>;
if (tabValues.length === 0) return false;
const currentIndex = tabValues.indexOf(desktopServicesTab);
const nextTab = tabValues[currentIndex === -1 ? 0 : (currentIndex + 1) % tabValues.length];
setDesktopServicesTab(nextTab);
setIsDesktopServicesOpen(true);
void refreshCurrentInstanceLabel();
if (nextTab === 'usage' && quotaResults.length === 0) {
void fetchAllQuotas();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
shortcutOverrides,
isDesktopServicesOpen,
desktopServicesTab,
servicesTabs,
quotaResults.length,
fetchAllQuotas,
refreshCurrentInstanceLabel,
handleOpenContextPlan,
]);
},
toggle_context_plan: () => {
handleOpenContextPlan();
},
});
const renderTab = (tab: TabConfig) => {
const isActive = activeMainTab === tab.id;
+32 -61
View File
@@ -43,7 +43,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, getModifierLabel, 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';
@@ -52,6 +52,7 @@ import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
import { getOutsideFileGrant } from '@/lib/outsideFileGrants';
import { DiagramEditor } from '@/components/diagram';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useKeybind, useKeybinds } from '@/hooks/useKeybind';
import { EditorView } from '@codemirror/view';
import type { Extension } from '@codemirror/state';
import { useThemeSystem } from '@/contexts/useThemeSystem';
@@ -72,7 +73,6 @@ 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 { useI18n } from '@/lib/i18n';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -1022,7 +1022,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);
@@ -1767,35 +1766,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;
@@ -2908,42 +2900,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);
File diff suppressed because it is too large Load Diff
@@ -1,102 +1,115 @@
import React from 'react';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop';
import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { ShortcutDispatcher } from '@/lib/shortcutDispatcher';
import { shortcutRegistry } from '@/lib/shortcutRegistry';
import { getEffectiveShortcutCombo } from '@/lib/shortcuts';
import { useConfigStore } from '@/stores/useConfigStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSelectionStore } from '@/sync/selection-store';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useKeybinds } from './useKeybind';
export const useMiniChatKeyboardShortcuts = () => {
const shortcutOverrides = useUIStore((state) => state.shortcutOverrides);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const dispatcherRef = React.useRef<ShortcutDispatcher | null>(null);
if (!dispatcherRef.current) {
dispatcherRef.current = new ShortcutDispatcher({
registry: shortcutRegistry,
getBinding: (actionId) => getEffectiveShortcutCombo(
actionId,
useUIStore.getState().shortcutOverrides,
),
});
}
const dispatcher = dispatcherRef.current;
const cycleFavoriteModel = (delta: number): boolean | void => {
const { favoriteModels, addRecentModel } = useUIStore.getState();
if (favoriteModels.length === 0) return false;
const {
currentProviderId,
currentModelId,
setProvider,
setModel,
} = useConfigStore.getState();
const currentIndex = favoriteModels.findIndex(
(favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId,
);
const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length];
setProvider(next.providerID);
setModel(next.modelID);
addRecentModel(next.providerID, next.modelID);
};
useKeybinds({
focus_input: () => {
focusChatInput();
},
new_mini_chat: () => {
if (!canUseElectronDesktopIPC()) return false;
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: currentDirectory || activeProject?.path || '',
projectId: activeProject?.id ?? null,
})?.catch((error) => {
console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error);
});
},
new_chat: () => {
openNewSessionDraft({
selectedProjectId: activeProject?.id ?? null,
directoryOverride: currentDirectory || activeProject?.path || null,
preserveDirectoryOverride: Boolean(currentDirectory || activeProject?.path),
});
focusChatInput();
},
open_model_selector: () => {
const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState();
setModelSelectorOpen(!isModelSelectorOpen);
},
cycle_thinking_variant: () => {
const configState = useConfigStore.getState();
if (configState.getCurrentModelVariants().length === 0) return false;
configState.cycleCurrentVariant();
const sessionId = useSessionUIStore.getState().currentSessionId;
const {
currentVariant,
currentAgentName,
currentProviderId,
currentModelId,
} = useConfigStore.getState();
if (sessionId && currentAgentName && currentProviderId && currentModelId) {
useSelectionStore.getState().saveAgentModelVariantForSession(
sessionId,
currentAgentName,
currentProviderId,
currentModelId,
currentVariant,
);
}
},
cycle_favorite_model_forward: () => cycleFavoriteModel(1),
cycle_favorite_model_backward: () => cycleFavoriteModel(-1),
});
React.useEffect(() => {
const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides);
const handleKeyDown = (event: KeyboardEvent) => {
if (eventMatchesShortcut(event, combo('focus_input'))) {
event.preventDefault();
focusChatInput();
return;
}
if (canUseElectronDesktopIPC() && eventMatchesShortcut(event, combo('new_mini_chat'))) {
event.preventDefault();
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: currentDirectory || activeProject?.path || '',
projectId: activeProject?.id ?? null,
})?.catch((error) => {
console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error);
});
return;
}
if (eventMatchesShortcut(event, combo('new_chat'))) {
event.preventDefault();
openNewSessionDraft({
selectedProjectId: activeProject?.id ?? null,
directoryOverride: currentDirectory || activeProject?.path || null,
preserveDirectoryOverride: Boolean(currentDirectory || activeProject?.path),
});
focusChatInput();
return;
}
if (eventMatchesShortcut(event, combo('open_model_selector'))) {
event.preventDefault();
const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState();
setModelSelectorOpen(!isModelSelectorOpen);
return;
}
if (eventMatchesShortcut(event, combo('cycle_thinking_variant'))) {
const configState = useConfigStore.getState();
const variants = configState.getCurrentModelVariants();
if (variants.length === 0) {
return;
}
event.preventDefault();
configState.cycleCurrentVariant();
const nextVariant = useConfigStore.getState().currentVariant;
const sessionId = useSessionUIStore.getState().currentSessionId;
const agentName = useConfigStore.getState().currentAgentName;
const providerId = useConfigStore.getState().currentProviderId;
const modelId = useConfigStore.getState().currentModelId;
if (sessionId && agentName && providerId && modelId) {
useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant);
}
return;
}
const cyclesForward = eventMatchesShortcut(event, combo('cycle_favorite_model_forward'));
const cyclesBackward = eventMatchesShortcut(event, combo('cycle_favorite_model_backward'));
if (cyclesForward || cyclesBackward) {
const { favoriteModels, addRecentModel } = useUIStore.getState();
if (favoriteModels.length === 0) {
return;
}
event.preventDefault();
const { currentProviderId, currentModelId, setProvider, setModel } = useConfigStore.getState();
const currentIndex = favoriteModels.findIndex((favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId);
const delta = cyclesForward ? 1 : -1;
const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length];
setProvider(next.providerID);
setModel(next.modelID);
addRecentModel(next.providerID, next.modelID);
}
if (dispatcher.dispatch(event)) event.preventDefault();
};
const handleBlur = () => dispatcher.handleBlur();
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [activeProject?.id, activeProject?.path, currentDirectory, openNewSessionDraft, shortcutOverrides]);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleBlur);
};
}, [dispatcher]);
};