Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes. Co-authored-by: Cursor <cursoragent@cursor.com>
278 lines
9.1 KiB
TypeScript
278 lines
9.1 KiB
TypeScript
/**
|
|
* useKeyboardShortcuts - 全局快捷键管理 Hook (v1.4.0 自定义快捷键)
|
|
*
|
|
* 在 App.tsx 挂载,处理全局键盘快捷键。
|
|
* 从 keybindingStore 读取用户自定义的键绑定,实现可配置的快捷键。
|
|
*
|
|
* 默认快捷键:
|
|
* - Ctrl+Tab / Ctrl+Shift+Tab: 切换标签
|
|
* - Ctrl+1~9: 跳转到指定索引标签
|
|
* - Ctrl+W: 关闭当前标签
|
|
* - F11: 全屏切换
|
|
* - Ctrl/Cmd+B: 折叠/展开侧栏
|
|
* - Ctrl+Shift+N: 新建标签
|
|
* - Ctrl+Shift+P: 打开命令面板
|
|
* - Ctrl+N: 新建会话
|
|
* - Ctrl+K: 快速连接
|
|
*/
|
|
|
|
import { useEffect } from 'react'
|
|
import { useTabStore } from '../stores/tabStore'
|
|
import { useCommandPaletteStore } from '../stores/commandPaletteStore'
|
|
import { useKeybindingStore } from '../stores/keybindingStore'
|
|
import { useDesktopFullscreenStore } from '../stores/desktopFullscreenStore'
|
|
import { KeybindingAction } from '../constants/keybindings'
|
|
import { createLogger } from '../utils/logger'
|
|
import i18n from '../i18n'
|
|
import { disposeAndCloseTab } from '../utils/disposeTab'
|
|
|
|
const logger = createLogger('KeyboardShortcuts')
|
|
|
|
/** 检查事件目标是否为输入元素 */
|
|
function isInputElement(e: KeyboardEvent): boolean {
|
|
const target = e.target as HTMLElement
|
|
if (!target) return false
|
|
const tagName = target.tagName.toLowerCase()
|
|
return tagName === 'input' || tagName === 'textarea' || tagName === 'select' || target.isContentEditable
|
|
}
|
|
|
|
/** Native text editing commands are reserved inside editable controls. */
|
|
function isNativeEditingShortcut(e: KeyboardEvent): boolean {
|
|
if (!(e.ctrlKey || e.metaKey) || e.altKey) return false
|
|
const key = e.key.toLowerCase()
|
|
return ['a', 'c', 'v', 'x', 'z', 'y'].includes(key)
|
|
}
|
|
|
|
/**
|
|
* 将键序列字符串 (如 "Ctrl+Shift+P") 匹配到 KeyboardEvent
|
|
* 支持跨平台: Ctrl 和 Meta (Cmd) 均可触发
|
|
*/
|
|
function matchesKeySequence(e: KeyboardEvent, keySequence: string): boolean {
|
|
if (!keySequence) return false
|
|
|
|
const parts = keySequence
|
|
.split('+')
|
|
.map((p) => p.trim())
|
|
.filter(Boolean)
|
|
const modifiers: string[] = []
|
|
let mainKey = ''
|
|
|
|
for (const part of parts) {
|
|
const lower = part.toLowerCase()
|
|
if (lower === 'ctrl' || lower === 'control' || lower === 'meta' || lower === 'cmd') {
|
|
modifiers.push('ctrl')
|
|
} else if (lower === 'shift') {
|
|
modifiers.push('shift')
|
|
} else if (lower === 'alt' || lower === 'option') {
|
|
modifiers.push('alt')
|
|
} else {
|
|
mainKey = part
|
|
}
|
|
}
|
|
|
|
const needsCtrl = modifiers.includes('ctrl')
|
|
const needsShift = modifiers.includes('shift')
|
|
const needsAlt = modifiers.includes('alt')
|
|
|
|
// 跨平台: Ctrl (Win/Linux) 或 Meta (macOS Cmd)
|
|
const ctrlOrMeta = e.ctrlKey || e.metaKey
|
|
const eventHasCtrl = ctrlOrMeta
|
|
const eventHasShift = e.shiftKey
|
|
const eventHasAlt = e.altKey
|
|
|
|
if (needsCtrl !== eventHasCtrl) return false
|
|
if (needsShift !== eventHasShift) return false
|
|
if (needsAlt !== eventHasAlt) return false
|
|
|
|
// 匹配主键
|
|
if (!mainKey) return false
|
|
|
|
// 特殊键映射
|
|
const keyLower = mainKey.toLowerCase()
|
|
const eventKey = e.key
|
|
|
|
switch (keyLower) {
|
|
case 'tab':
|
|
return eventKey === 'Tab'
|
|
case 'enter':
|
|
return eventKey === 'Enter'
|
|
case 'escape':
|
|
return eventKey === 'Escape'
|
|
case 'space':
|
|
return eventKey === ' '
|
|
case 'backspace':
|
|
return eventKey === 'Backspace'
|
|
case 'delete':
|
|
return eventKey === 'Delete'
|
|
default:
|
|
// F-keys
|
|
if (keyLower.startsWith('f') && keyLower.length <= 3) {
|
|
return eventKey === mainKey
|
|
}
|
|
// 普通字母/数字
|
|
return eventKey.toLowerCase() === keyLower
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 判断快捷键是否应该在输入框中触发
|
|
* 通过 keybindingStore 的 fireInInput 属性决定
|
|
*/
|
|
function shouldFireInInput(e: KeyboardEvent): boolean {
|
|
const store = useKeybindingStore.getState()
|
|
const allBindings = store.getAllEffective()
|
|
|
|
// 使用动态 import 避免的 KEYBINDING_DEFINITIONS
|
|
// 直接检查已知的 fireInInput 操作
|
|
const fireAlways = [
|
|
KeybindingAction.TAB_NEXT,
|
|
KeybindingAction.TAB_PREV,
|
|
KeybindingAction.TAB_GOTO_N,
|
|
KeybindingAction.VIEW_FULLSCREEN,
|
|
KeybindingAction.VIEW_TOGGLE_SIDEBAR,
|
|
KeybindingAction.TOOLS_COMMAND_PALETTE,
|
|
]
|
|
|
|
for (const actionId of fireAlways) {
|
|
if (matchesKeySequence(e, allBindings[actionId])) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
export function useKeyboardShortcuts(): void {
|
|
useEffect(() => {
|
|
// 加载自定义快捷键
|
|
useKeybindingStore.getState().loadFromSettings()
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const ctrlOrMeta = e.ctrlKey || e.metaKey
|
|
const store = useKeybindingStore.getState()
|
|
const keys = store.getAllEffective()
|
|
|
|
// Never let configurable application shortcuts shadow the platform's
|
|
// select/copy/cut/paste/undo/redo behavior in text controls.
|
|
if (isInputElement(e) && isNativeEditingShortcut(e)) return
|
|
|
|
// ---- 命令面板 ----
|
|
if (matchesKeySequence(e, keys[KeybindingAction.TOOLS_COMMAND_PALETTE])) {
|
|
e.preventDefault()
|
|
useCommandPaletteStore.getState().toggle()
|
|
return
|
|
}
|
|
|
|
// ---- Ctrl+Tab: 下一个标签 ----
|
|
if (matchesKeySequence(e, keys[KeybindingAction.TAB_NEXT])) {
|
|
e.preventDefault()
|
|
useTabStore.getState().switchToNextTab()
|
|
return
|
|
}
|
|
|
|
// ---- Ctrl+Shift+Tab: 上一个标签 ----
|
|
if (matchesKeySequence(e, keys[KeybindingAction.TAB_PREV])) {
|
|
e.preventDefault()
|
|
useTabStore.getState().switchToPrevTab()
|
|
return
|
|
}
|
|
|
|
// ---- Ctrl+1~9: 跳转指定标签 ----
|
|
if (ctrlOrMeta && !e.shiftKey && !e.altKey) {
|
|
const num = parseInt(e.key, 10)
|
|
if (num >= 1 && num <= 9) {
|
|
// 特殊处理: Ctrl+1~9 是范围键,不匹配单个键序列
|
|
const gotoKeys = keys[KeybindingAction.TAB_GOTO_N]
|
|
if (gotoKeys.includes('…') || gotoKeys.includes('...')) {
|
|
e.preventDefault()
|
|
useTabStore.getState().activateTabByIndex(num - 1)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- F11: 全屏(RDP/VNC 会话全屏;其他标签走窗口全屏)----
|
|
if (matchesKeySequence(e, keys[KeybindingAction.VIEW_FULLSCREEN])) {
|
|
e.preventDefault()
|
|
const { tabs, activeTabId } = useTabStore.getState()
|
|
const activeTab = tabs.find((tab) => tab.id === activeTabId)
|
|
if (activeTab && (activeTab.type === 'rdp' || activeTab.type === 'vnc')) {
|
|
void useDesktopFullscreenStore.getState().toggle(activeTab.id)
|
|
return
|
|
}
|
|
void (async () => {
|
|
try {
|
|
const current = (await window.opsxApi?.isFullscreen?.()) === true
|
|
await window.opsxApi?.setFullscreen?.(!current)
|
|
} catch {
|
|
logger.warn('Failed to toggle fullscreen')
|
|
}
|
|
})()
|
|
return
|
|
}
|
|
|
|
// ---- Ctrl/Cmd+B: 折叠侧栏 ----
|
|
if (matchesKeySequence(e, keys[KeybindingAction.VIEW_TOGGLE_SIDEBAR])) {
|
|
e.preventDefault()
|
|
window.dispatchEvent(new CustomEvent('opsxshell:toggle-sidebar'))
|
|
return
|
|
}
|
|
|
|
// ---- Cmd+Option+I / F12: 开发者工具(输入框内也可用)----
|
|
if ((ctrlOrMeta && e.altKey && !e.shiftKey && e.key.toLowerCase() === 'i') || e.key === 'F12') {
|
|
e.preventDefault()
|
|
void window.opsxApi?.toggleDevtools?.().catch(() => {
|
|
logger.warn('Failed to toggle developer tools')
|
|
})
|
|
return
|
|
}
|
|
|
|
// ---- 以下快捷键在输入框中不触发 (除非 fireInInput=true) ----
|
|
if (isInputElement(e) && !shouldFireInInput(e)) {
|
|
return
|
|
}
|
|
|
|
// ---- Ctrl+W: 关闭标签 ----
|
|
if (matchesKeySequence(e, keys[KeybindingAction.TAB_CLOSE])) {
|
|
e.preventDefault()
|
|
const { tabs, activeTabId } = useTabStore.getState()
|
|
const active = tabs.find((tab) => tab.id === activeTabId)
|
|
if (activeTabId && (tabs.length > 1 || active?.type !== 'welcome')) {
|
|
logger.debug('Ctrl+W: closing tab', activeTabId)
|
|
void disposeAndCloseTab(activeTabId)
|
|
}
|
|
return
|
|
}
|
|
|
|
// ---- Ctrl+Shift+N: 新建标签 ----
|
|
if (matchesKeySequence(e, keys[KeybindingAction.TAB_NEW])) {
|
|
e.preventDefault()
|
|
useTabStore.getState().addTab({ title: i18n.t('common:tab.new'), type: 'welcome' })
|
|
return
|
|
}
|
|
|
|
// ---- Ctrl+N: 新建会话 (在 App.tsx 通过 onMenuNewSession 处理) ----
|
|
if (matchesKeySequence(e, keys[KeybindingAction.SESSION_NEW])) {
|
|
e.preventDefault()
|
|
// 触发新建会话对话框 — 通过 CustomEvent 与 App 通信
|
|
window.dispatchEvent(new CustomEvent('opsxshell:new-session'))
|
|
return
|
|
}
|
|
|
|
// ---- Ctrl+K: 快速连接 ----
|
|
if (matchesKeySequence(e, keys[KeybindingAction.SESSION_QUICK_CONNECT])) {
|
|
e.preventDefault()
|
|
window.dispatchEvent(new CustomEvent('opsxshell:quick-connect'))
|
|
return
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
}, [])
|
|
}
|
|
|
|
// 导出匹配函数供测试使用
|
|
export { isNativeEditingShortcut, matchesKeySequence }
|
|
|
|
export default useKeyboardShortcuts
|