first commit
This commit is contained in:
340
claude-code源码-中文注释/src/keybindings/defaultBindings.ts
Normal file
340
claude-code源码-中文注释/src/keybindings/defaultBindings.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { satisfies } from 'src/utils/semver.js'
|
||||
import { isRunningWithBun } from '../utils/bundledMode.js'
|
||||
import { getPlatform } from '../utils/platform.js'
|
||||
import type { KeybindingBlock } from './types.js'
|
||||
|
||||
/**
|
||||
* 匹配当前 Claude Code 行为的默认键绑定。
|
||||
* 这些首先加载,然后用户 keybindings.json 覆盖它们。
|
||||
*/
|
||||
|
||||
// 特定平台的图像粘贴快捷键:
|
||||
// - Windows:alt+v(ctrl+v 是系统粘贴)
|
||||
// - 其他平台:ctrl+v
|
||||
const IMAGE_PASTE_KEY = getPlatform() === 'windows' ? 'alt+v' : 'ctrl+v'
|
||||
|
||||
// 修饰符-only 和弦(如 shift+tab)在没有 VT 模式的 Windows Terminal 上可能失败
|
||||
// 参见:https://github.com/microsoft/terminal/issues/879#issuecomment-618801651
|
||||
// Node 在 24.2.0 / 22.17.0 启用 VT 模式:https://github.com/nodejs/node/pull/58358
|
||||
// Bun 在 1.2.23 启用 VT 模式:https://github.com/oven-sh/bun/pull/21161
|
||||
const SUPPORTS_TERMINAL_VT_MODE =
|
||||
getPlatform() !== 'windows' ||
|
||||
(isRunningWithBun()
|
||||
? satisfies(process.versions.bun, '>=1.2.23')
|
||||
: satisfies(process.versions.node, '>=22.17.0 <23.0.0 || >=24.2.0'))
|
||||
|
||||
// 特定平台的模式循环快捷键:
|
||||
// - 没有 VT 模式的 Windows:meta+m(shift+tab 不能可靠工作)
|
||||
// - 其他平台:shift+tab
|
||||
const MODE_CYCLE_KEY = SUPPORTS_TERMINAL_VT_MODE ? 'shift+tab' : 'meta+m'
|
||||
|
||||
export const DEFAULT_BINDINGS: KeybindingBlock[] = [
|
||||
{
|
||||
context: 'Global',
|
||||
bindings: {
|
||||
// ctrl+c 和 ctrl+d 使用特殊的时间-based 双击处理。
|
||||
// 它们在此定义以便解析器可以找到,但用户
|
||||
// 不能重新绑定 - validate.ts 中的 reservedShortcuts.ts
|
||||
// 会在用户尝试覆盖这些键时显示错误。
|
||||
'ctrl+c': 'app:interrupt',
|
||||
'ctrl+d': 'app:exit',
|
||||
'ctrl+l': 'app:redraw',
|
||||
'ctrl+t': 'app:toggleTodos',
|
||||
'ctrl+o': 'app:toggleTranscript',
|
||||
...(feature('KAIROS') || feature('KAIROS_BRIEF')
|
||||
? { 'ctrl+shift+b': 'app:toggleBrief' as const }
|
||||
: {}),
|
||||
'ctrl+shift+o': 'app:toggleTeammatePreview',
|
||||
'ctrl+r': 'history:search',
|
||||
// 文件导航。cmd+ 绑定仅在 kitty 协议终端上触发;
|
||||
// ctrl+shift 是可移植的回退。
|
||||
...(feature('QUICK_SEARCH')
|
||||
? {
|
||||
'ctrl+shift+f': 'app:globalSearch' as const,
|
||||
'cmd+shift+f': 'app:globalSearch' as const,
|
||||
'ctrl+shift+p': 'app:quickOpen' as const,
|
||||
'cmd+shift+p': 'app:quickOpen' as const,
|
||||
}
|
||||
: {}),
|
||||
...(feature('TERMINAL_PANEL') ? { 'meta+j': 'app:toggleTerminal' } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Chat',
|
||||
bindings: {
|
||||
escape: 'chat:cancel',
|
||||
// ctrl+x chord 前缀避免与 readline 编辑键冲突(ctrl+a/b/e/f/...)。
|
||||
'ctrl+x ctrl+k': 'chat:killAgents',
|
||||
[MODE_CYCLE_KEY]: 'chat:cycleMode',
|
||||
'meta+p': 'chat:modelPicker',
|
||||
'meta+o': 'chat:fastMode',
|
||||
'meta+t': 'chat:thinkingToggle',
|
||||
enter: 'chat:submit',
|
||||
up: 'history:previous',
|
||||
down: 'history:next',
|
||||
// 编辑快捷键(此处定义,迁移进行中)
|
||||
// 撤消有两个绑定以支持不同的终端行为:
|
||||
// - ctrl+_ 对于传统终端(发送 \x1f 控制字符)
|
||||
// - ctrl+shift+- 对于 Kitty 协议(发送带修饰符的物理键)
|
||||
'ctrl+_': 'chat:undo',
|
||||
'ctrl+shift+-': 'chat:undo',
|
||||
// ctrl+x ctrl+e 是 readline 原生的编辑和执行命令绑定。
|
||||
'ctrl+x ctrl+e': 'chat:externalEditor',
|
||||
'ctrl+g': 'chat:externalEditor',
|
||||
'ctrl+s': 'chat:stash',
|
||||
// 图像粘贴快捷键(特定平台密钥在上面定义)
|
||||
[IMAGE_PASTE_KEY]: 'chat:imagePaste',
|
||||
...(feature('MESSAGE_ACTIONS')
|
||||
? { 'shift+up': 'chat:messageActions' as const }
|
||||
: {}),
|
||||
// 语音激活(按住说话)。注册以便 getShortcutDisplay
|
||||
// 找到它而不触发回退分析日志。要重新绑定,
|
||||
// 添加 voice:pushToTalk 条目(后者获胜);要禁用,使用 /voice
|
||||
// — null 取消绑定空间会击中 useKeybinding.ts 中预先存在的陷阱
|
||||
// 其中 'unbound' 吞下事件(空格对输入无效)。
|
||||
...(feature('VOICE_MODE') ? { space: 'voice:pushToTalk' } : {}),
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Autocomplete',
|
||||
bindings: {
|
||||
tab: 'autocomplete:accept',
|
||||
escape: 'autocomplete:dismiss',
|
||||
up: 'autocomplete:previous',
|
||||
down: 'autocomplete:next',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Settings',
|
||||
bindings: {
|
||||
// 设置菜单仅使用 escape(不是 'n')来关闭
|
||||
escape: 'confirm:no',
|
||||
// 配置面板列表导航(重用 Select 操作)
|
||||
up: 'select:previous',
|
||||
down: 'select:next',
|
||||
k: 'select:previous',
|
||||
j: 'select:next',
|
||||
'ctrl+p': 'select:previous',
|
||||
'ctrl+n': 'select:next',
|
||||
// 切换/激活选定的设置(仅空格 — enter 保存并关闭)
|
||||
space: 'select:accept',
|
||||
// 保存并关闭配置面板
|
||||
enter: 'settings:close',
|
||||
// 进入搜索模式
|
||||
'/': 'settings:search',
|
||||
// 重试加载使用数据(仅在错误时活动)
|
||||
r: 'settings:retry',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Confirmation',
|
||||
bindings: {
|
||||
y: 'confirm:yes',
|
||||
n: 'confirm:no',
|
||||
enter: 'confirm:yes',
|
||||
escape: 'confirm:no',
|
||||
// 带列表的对话框导航
|
||||
up: 'confirm:previous',
|
||||
down: 'confirm:next',
|
||||
tab: 'confirm:nextField',
|
||||
space: 'confirm:toggle',
|
||||
// 循环模式(用于文件权限对话框和团队对话框)
|
||||
'shift+tab': 'confirm:cycleMode',
|
||||
// 在权限对话框中切换权限说明
|
||||
'ctrl+e': 'confirm:toggleExplanation',
|
||||
// 切换权限调试信息
|
||||
'ctrl+d': 'permission:toggleDebug',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Tabs',
|
||||
bindings: {
|
||||
// 标签循环导航
|
||||
tab: 'tabs:next',
|
||||
'shift+tab': 'tabs:previous',
|
||||
right: 'tabs:next',
|
||||
left: 'tabs:previous',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Transcript',
|
||||
bindings: {
|
||||
'ctrl+e': 'transcript:toggleShowAll',
|
||||
'ctrl+c': 'transcript:exit',
|
||||
escape: 'transcript:exit',
|
||||
// q — 分页器约定(less、tmux 复制模式)。Transcript 是一个模态
|
||||
// 阅读视图,没有提示,所以 q 作为文字字符没有所有者。
|
||||
q: 'transcript:exit',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'HistorySearch',
|
||||
bindings: {
|
||||
'ctrl+r': 'historySearch:next',
|
||||
escape: 'historySearch:accept',
|
||||
tab: 'historySearch:accept',
|
||||
'ctrl+c': 'historySearch:cancel',
|
||||
enter: 'historySearch:execute',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Task',
|
||||
bindings: {
|
||||
// 在前台运行后台任务(bash 命令、代理)
|
||||
// 在 tmux 中,用户必须按两次 ctrl+b(tmux 前缀转义)
|
||||
'ctrl+b': 'task:background',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'ThemePicker',
|
||||
bindings: {
|
||||
'ctrl+t': 'theme:toggleSyntaxHighlighting',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Scroll',
|
||||
bindings: {
|
||||
pageup: 'scroll:pageUp',
|
||||
pagedown: 'scroll:pageDown',
|
||||
wheelup: 'scroll:lineUp',
|
||||
wheeldown: 'scroll:lineDown',
|
||||
'ctrl+home': 'scroll:top',
|
||||
'ctrl+end': 'scroll:bottom',
|
||||
// 选择复制。ctrl+shift+c 是标准终端复制。
|
||||
// cmd+c 仅在使用 kitty 键盘
|
||||
// 协议(kitty/WezTerm/ghostty/iTerm2)的终端上触发,其中 super
|
||||
// 修饰符实际到达 pty — 在其他地方无效。
|
||||
// Esc-to-clear 和上下文 ctrl+c 通过原始
|
||||
// useInput 处理,因此它们可以有条件地传播。
|
||||
'ctrl+shift+c': 'selection:copy',
|
||||
'cmd+c': 'selection:copy',
|
||||
},
|
||||
},
|
||||
{
|
||||
context: 'Help',
|
||||
bindings: {
|
||||
escape: 'help:dismiss',
|
||||
},
|
||||
},
|
||||
// 附件导航(选择对话框图像附件)
|
||||
{
|
||||
context: 'Attachments',
|
||||
bindings: {
|
||||
right: 'attachments:next',
|
||||
left: 'attachments:previous',
|
||||
backspace: 'attachments:remove',
|
||||
delete: 'attachments:remove',
|
||||
down: 'attachments:exit',
|
||||
escape: 'attachments:exit',
|
||||
},
|
||||
},
|
||||
// 页脚指示器导航(任务、团队、差异、循环)
|
||||
{
|
||||
context: 'Footer',
|
||||
bindings: {
|
||||
up: 'footer:up',
|
||||
'ctrl+p': 'footer:up',
|
||||
down: 'footer:down',
|
||||
'ctrl+n': 'footer:down',
|
||||
right: 'footer:next',
|
||||
left: 'footer:previous',
|
||||
enter: 'footer:openSelected',
|
||||
escape: 'footer:clearSelection',
|
||||
},
|
||||
},
|
||||
// 消息选择器(倒带对话框)导航
|
||||
{
|
||||
context: 'MessageSelector',
|
||||
bindings: {
|
||||
up: 'messageSelector:up',
|
||||
down: 'messageSelector:down',
|
||||
k: 'messageSelector:up',
|
||||
j: 'messageSelector:down',
|
||||
'ctrl+p': 'messageSelector:up',
|
||||
'ctrl+n': 'messageSelector:down',
|
||||
'ctrl+up': 'messageSelector:top',
|
||||
'shift+up': 'messageSelector:top',
|
||||
'meta+up': 'messageSelector:top',
|
||||
'shift+k': 'messageSelector:top',
|
||||
'ctrl+down': 'messageSelector:bottom',
|
||||
'shift+down': 'messageSelector:bottom',
|
||||
'meta+down': 'messageSelector:bottom',
|
||||
'shift+j': 'messageSelector:bottom',
|
||||
enter: 'messageSelector:select',
|
||||
},
|
||||
},
|
||||
// PromptInput 在光标活动时卸载 — 没有键冲突。
|
||||
...(feature('MESSAGE_ACTIONS')
|
||||
? [
|
||||
{
|
||||
context: 'MessageActions' as const,
|
||||
bindings: {
|
||||
up: 'messageActions:prev' as const,
|
||||
down: 'messageActions:next' as const,
|
||||
k: 'messageActions:prev' as const,
|
||||
j: 'messageActions:next' as const,
|
||||
// meta = macOS 上的 cmd;kitty 键盘协议的 super — 绑定两者。
|
||||
'meta+up': 'messageActions:top' as const,
|
||||
'meta+down': 'messageActions:bottom' as const,
|
||||
'super+up': 'messageActions:top' as const,
|
||||
'super+down': 'messageActions:bottom' as const,
|
||||
// 当存在时,鼠标选择扩展 shift+arrow(ScrollKeybindingHandler:573)—
|
||||
// 正确的分层 UX:esc 清除选择,然后 shift+↑ 跳转。
|
||||
'shift+up': 'messageActions:prevUser' as const,
|
||||
'shift+down': 'messageActions:nextUser' as const,
|
||||
escape: 'messageActions:escape' as const,
|
||||
'ctrl+c': 'messageActions:ctrlc' as const,
|
||||
// 镜像 MESSAGE_ACTIONS。不是导入 — 会将 React/ink 拉入此配置模块。
|
||||
enter: 'messageActions:enter' as const,
|
||||
c: 'messageActions:c' as const,
|
||||
p: 'messageActions:p' as const,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// 差异对话框导航
|
||||
{
|
||||
context: 'DiffDialog',
|
||||
bindings: {
|
||||
escape: 'diff:dismiss',
|
||||
left: 'diff:previousSource',
|
||||
right: 'diff:nextSource',
|
||||
up: 'diff:previousFile',
|
||||
down: 'diff:nextFile',
|
||||
enter: 'diff:viewDetails',
|
||||
// 注意:diff:back 在详细模式下由左箭头处理
|
||||
},
|
||||
},
|
||||
// 模型选择器工作循环(仅限 ant)
|
||||
{
|
||||
context: 'ModelPicker',
|
||||
bindings: {
|
||||
left: 'modelPicker:decreaseEffort',
|
||||
right: 'modelPicker:increaseEffort',
|
||||
},
|
||||
},
|
||||
// 选择组件导航(用于 /model、/resume、权限提示等)
|
||||
{
|
||||
context: 'Select',
|
||||
bindings: {
|
||||
up: 'select:previous',
|
||||
down: 'select:next',
|
||||
j: 'select:next',
|
||||
k: 'select:previous',
|
||||
'ctrl+n': 'select:next',
|
||||
'ctrl+p': 'select:previous',
|
||||
enter: 'select:accept',
|
||||
escape: 'select:cancel',
|
||||
},
|
||||
},
|
||||
// 插件对话框操作(管理、浏览、发现插件)
|
||||
// 导航(select:*)使用上面的 Select 上下文
|
||||
{
|
||||
context: 'Plugin',
|
||||
bindings: {
|
||||
space: 'plugin:toggle',
|
||||
i: 'plugin:install',
|
||||
},
|
||||
},
|
||||
]
|
||||
472
claude-code源码-中文注释/src/keybindings/loadUserBindings.ts
Normal file
472
claude-code源码-中文注释/src/keybindings/loadUserBindings.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* 用户键绑定配置加载器,支持热重载。
|
||||
*
|
||||
* 从 ~/.claude/keybindings.json 加载键绑定并监视
|
||||
* 变化以自动重新加载。
|
||||
*
|
||||
* 注意:用户键绑定自定义目前仅适用于
|
||||
* Anthropic 员工(USER_TYPE === 'ant')。外部用户始终
|
||||
* 使用默认绑定。
|
||||
*/
|
||||
|
||||
import chokidar, { type FSWatcher } from 'chokidar'
|
||||
import { readFileSync } from 'fs'
|
||||
import { readFile, stat } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
|
||||
import { logEvent } from '../services/analytics/index.js'
|
||||
import { registerCleanup } from '../utils/cleanupRegistry.js'
|
||||
import { logForDebugging } from '../utils/debug.js'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
import { errorMessage, isENOENT } from '../utils/errors.js'
|
||||
import { createSignal } from '../utils/signal.js'
|
||||
import { jsonParse } from '../utils/slowOperations.js'
|
||||
import { DEFAULT_BINDINGS } from './defaultBindings.js'
|
||||
import { parseBindings } from './parser.js'
|
||||
import type { KeybindingBlock, ParsedBinding } from './types.js'
|
||||
import {
|
||||
checkDuplicateKeysInJson,
|
||||
type KeybindingWarning,
|
||||
validateBindings,
|
||||
} from './validate.js'
|
||||
|
||||
/**
|
||||
* 检查键绑定自定义是否启用。
|
||||
*
|
||||
* 如果 tengu_keybinding_customization_release GrowthBook 开关启用则返回 true。
|
||||
*
|
||||
* 此函数被导出以便代码库的其他部分(例如 /doctor)
|
||||
* 可以一致地检查相同的条件。
|
||||
*/
|
||||
export function isKeybindingCustomizationEnabled(): boolean {
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE(
|
||||
'tengu_keybinding_customization_release',
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待文件写入稳定的毫秒数。
|
||||
*/
|
||||
const FILE_STABILITY_THRESHOLD_MS = 500
|
||||
|
||||
/**
|
||||
* 检查文件稳定性的轮询间隔。
|
||||
*/
|
||||
const FILE_STABILITY_POLL_INTERVAL_MS = 200
|
||||
|
||||
/**
|
||||
* 加载键绑定的结果,包括任何验证警告。
|
||||
*/
|
||||
export type KeybindingsLoadResult = {
|
||||
bindings: ParsedBinding[]
|
||||
warnings: KeybindingWarning[]
|
||||
}
|
||||
|
||||
let watcher: FSWatcher | null = null
|
||||
let initialized = false
|
||||
let disposed = false
|
||||
let cachedBindings: ParsedBinding[] | null = null
|
||||
let cachedWarnings: KeybindingWarning[] = []
|
||||
const keybindingsChanged = createSignal<[result: KeybindingsLoadResult]>()
|
||||
|
||||
/**
|
||||
* 跟踪上次记录自定义键绑定加载事件的日期(YYYY-MM-DD)。
|
||||
* 用于确保我们每天最多触发一次事件。
|
||||
*/
|
||||
let lastCustomBindingsLogDate: string | null = null
|
||||
|
||||
/**
|
||||
* 当自定义键绑定加载时记录遥测事件,每天最多一次。
|
||||
* 这使我们能够估计自定义键绑定的用户百分比。
|
||||
*/
|
||||
function logCustomBindingsLoadedOncePerDay(userBindingCount: number): void {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
if (lastCustomBindingsLogDate === today) return
|
||||
lastCustomBindingsLogDate = today
|
||||
logEvent('tengu_custom_keybindings_loaded', {
|
||||
user_binding_count: userBindingCount,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫,检查对象是否是有效的 KeybindingBlock。
|
||||
*/
|
||||
function isKeybindingBlock(obj: unknown): obj is KeybindingBlock {
|
||||
if (typeof obj !== 'object' || obj === null) return false
|
||||
const b = obj as Record<string, unknown>
|
||||
return (
|
||||
typeof b.context === 'string' &&
|
||||
typeof b.bindings === 'object' &&
|
||||
b.bindings !== null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫,检查数组是否仅包含有效的 KeybindingBlock。
|
||||
*/
|
||||
function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] {
|
||||
return Array.isArray(arr) && arr.every(isKeybindingBlock)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户键绑定文件的路径。
|
||||
*/
|
||||
export function getKeybindingsPath(): string {
|
||||
return join(getClaudeConfigHomeDir(), 'keybindings.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析默认绑定(缓存以提高性能)。
|
||||
*/
|
||||
function getDefaultParsedBindings(): ParsedBinding[] {
|
||||
return parseBindings(DEFAULT_BINDINGS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从用户配置文件加载并解析键绑定。
|
||||
* 返回合并的默认 + 用户绑定以及验证警告。
|
||||
*
|
||||
* 对于外部用户,始终仅返回默认绑定。
|
||||
* 用户自定义目前仅限于 Anthropic 员工。
|
||||
*/
|
||||
export async function loadKeybindings(): Promise<KeybindingsLoadResult> {
|
||||
const defaultBindings = getDefaultParsedBindings()
|
||||
|
||||
// 跳过外部用户加载用户配置
|
||||
if (!isKeybindingCustomizationEnabled()) {
|
||||
return { bindings: defaultBindings, warnings: [] }
|
||||
}
|
||||
|
||||
const userPath = getKeybindingsPath()
|
||||
|
||||
try {
|
||||
const content = await readFile(userPath, 'utf-8')
|
||||
const parsed: unknown = jsonParse(content)
|
||||
|
||||
// 从对象包装格式中提取绑定数组:{ "bindings": [...] }
|
||||
let userBlocks: unknown
|
||||
if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) {
|
||||
userBlocks = (parsed as { bindings: unknown }).bindings
|
||||
} else {
|
||||
// 格式无效 - 缺少 bindings 属性
|
||||
const errorMessage = 'keybindings.json must have a "bindings" array'
|
||||
const suggestion = 'Use format: { "bindings": [ ... ] }'
|
||||
logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`)
|
||||
return {
|
||||
bindings: defaultBindings,
|
||||
warnings: [
|
||||
{
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: errorMessage,
|
||||
suggestion,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// 验证结构 - bindings 必须是有效键绑定块的数组
|
||||
if (!isKeybindingBlockArray(userBlocks)) {
|
||||
const errorMessage = !Array.isArray(userBlocks)
|
||||
? '"bindings" must be an array'
|
||||
: 'keybindings.json contains invalid block structure'
|
||||
const suggestion = !Array.isArray(userBlocks)
|
||||
? 'Set "bindings" to an array of keybinding blocks'
|
||||
: 'Each block must have "context" (string) and "bindings" (object)'
|
||||
logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`)
|
||||
return {
|
||||
bindings: defaultBindings,
|
||||
warnings: [
|
||||
{
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: errorMessage,
|
||||
suggestion,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const userParsed = parseBindings(userBlocks)
|
||||
logForDebugging(
|
||||
`[keybindings] Loaded ${userParsed.length} user bindings from ${userPath}`,
|
||||
)
|
||||
|
||||
// 用户绑定在默认绑定之后,所以它们会覆盖
|
||||
const mergedBindings = [...defaultBindings, ...userParsed]
|
||||
|
||||
logCustomBindingsLoadedOncePerDay(userParsed.length)
|
||||
|
||||
// 对用户配置运行验证
|
||||
// 首先检查原始 JSON 中的重复键(JSON.parse 会静默丢弃较早的值)
|
||||
const duplicateKeyWarnings = checkDuplicateKeysInJson(content)
|
||||
const warnings = [
|
||||
...duplicateKeyWarnings,
|
||||
...validateBindings(userBlocks, mergedBindings),
|
||||
]
|
||||
|
||||
if (warnings.length > 0) {
|
||||
logForDebugging(
|
||||
`[keybindings] Found ${warnings.length} validation issue(s)`,
|
||||
)
|
||||
}
|
||||
|
||||
return { bindings: mergedBindings, warnings }
|
||||
} catch (error) {
|
||||
// 文件不存在 - 使用默认值(用户可以运行 /keybindings 创建)
|
||||
if (isENOENT(error)) {
|
||||
return { bindings: defaultBindings, warnings: [] }
|
||||
}
|
||||
|
||||
// 其他错误 - 记录并返回带警告的默认值
|
||||
logForDebugging(
|
||||
`[keybindings] Error loading ${userPath}: ${errorMessage(error)}`,
|
||||
)
|
||||
return {
|
||||
bindings: defaultBindings,
|
||||
warnings: [
|
||||
{
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: `Failed to parse keybindings.json: ${errorMessage(error)}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步加载键绑定(用于初始渲染)。
|
||||
* 如果有缓存值则使用缓存。
|
||||
*/
|
||||
export function loadKeybindingsSync(): ParsedBinding[] {
|
||||
if (cachedBindings) {
|
||||
return cachedBindings
|
||||
}
|
||||
|
||||
const result = loadKeybindingsSyncWithWarnings()
|
||||
return result.bindings
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步加载带验证警告的键绑定。
|
||||
* 如果有缓存值则使用缓存。
|
||||
*
|
||||
* 对于外部用户,始终仅返回默认绑定。
|
||||
* 用户自定义目前仅限于 Anthropic 员工。
|
||||
*/
|
||||
export function loadKeybindingsSyncWithWarnings(): KeybindingsLoadResult {
|
||||
if (cachedBindings) {
|
||||
return { bindings: cachedBindings, warnings: cachedWarnings }
|
||||
}
|
||||
|
||||
const defaultBindings = getDefaultParsedBindings()
|
||||
|
||||
// 跳过外部用户加载用户配置
|
||||
if (!isKeybindingCustomizationEnabled()) {
|
||||
cachedBindings = defaultBindings
|
||||
cachedWarnings = []
|
||||
return { bindings: cachedBindings, warnings: cachedWarnings }
|
||||
}
|
||||
|
||||
const userPath = getKeybindingsPath()
|
||||
|
||||
try {
|
||||
// 同步 IO:从同步上下文调用(React useState 初始化器)
|
||||
const content = readFileSync(userPath, 'utf-8')
|
||||
const parsed: unknown = jsonParse(content)
|
||||
|
||||
// 从对象包装格式中提取绑定数组:{ "bindings": [...] }
|
||||
let userBlocks: unknown
|
||||
if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) {
|
||||
userBlocks = (parsed as { bindings: unknown }).bindings
|
||||
} else {
|
||||
// 格式无效 - 缺少 bindings 属性
|
||||
cachedBindings = defaultBindings
|
||||
cachedWarnings = [
|
||||
{
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: 'keybindings.json must have a "bindings" array',
|
||||
suggestion: 'Use format: { "bindings": [ ... ] }',
|
||||
},
|
||||
]
|
||||
return { bindings: cachedBindings, warnings: cachedWarnings }
|
||||
}
|
||||
|
||||
// 验证结构 - bindings 必须是有效键绑定块的数组
|
||||
if (!isKeybindingBlockArray(userBlocks)) {
|
||||
const errorMessage = !Array.isArray(userBlocks)
|
||||
? '"bindings" must be an array'
|
||||
: 'keybindings.json contains invalid block structure'
|
||||
const suggestion = !Array.isArray(userBlocks)
|
||||
? 'Set "bindings" to an array of keybinding blocks'
|
||||
: 'Each block must have "context" (string) and "bindings" (object)'
|
||||
cachedBindings = defaultBindings
|
||||
cachedWarnings = [
|
||||
{
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: errorMessage,
|
||||
suggestion,
|
||||
},
|
||||
]
|
||||
return { bindings: cachedBindings, warnings: cachedWarnings }
|
||||
}
|
||||
|
||||
const userParsed = parseBindings(userBlocks)
|
||||
logForDebugging(
|
||||
`[keybindings] Loaded ${userParsed.length} user bindings from ${userPath}`,
|
||||
)
|
||||
cachedBindings = [...defaultBindings, ...userParsed]
|
||||
|
||||
logCustomBindingsLoadedOncePerDay(userParsed.length)
|
||||
|
||||
// 运行验证 - 首先检查原始 JSON 中的重复键
|
||||
const duplicateKeyWarnings = checkDuplicateKeysInJson(content)
|
||||
cachedWarnings = [
|
||||
...duplicateKeyWarnings,
|
||||
...validateBindings(userBlocks, cachedBindings),
|
||||
]
|
||||
if (cachedWarnings.length > 0) {
|
||||
logForDebugging(
|
||||
`[keybindings] Found ${cachedWarnings.length} validation issue(s)`,
|
||||
)
|
||||
}
|
||||
|
||||
return { bindings: cachedBindings, warnings: cachedWarnings }
|
||||
} catch {
|
||||
// 文件不存在或出错 - 使用默认值(用户可以运行 /keybindings 创建)
|
||||
cachedBindings = defaultBindings
|
||||
cachedWarnings = []
|
||||
return { bindings: cachedBindings, warnings: cachedWarnings }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 keybindings.json 的文件监视。
|
||||
* 在应用启动时调用一次。
|
||||
*
|
||||
* 对于外部用户,这是空操作,因为用户自定义已禁用。
|
||||
*/
|
||||
export async function initializeKeybindingWatcher(): Promise<void> {
|
||||
if (initialized || disposed) return
|
||||
|
||||
// 跳过外部用户的文件监视
|
||||
if (!isKeybindingCustomizationEnabled()) {
|
||||
logForDebugging(
|
||||
'[keybindings] Skipping file watcher - user customization disabled',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const userPath = getKeybindingsPath()
|
||||
const watchDir = dirname(userPath)
|
||||
|
||||
// 仅在父目录存在时监视
|
||||
try {
|
||||
const stats = await stat(watchDir)
|
||||
if (!stats.isDirectory()) {
|
||||
logForDebugging(
|
||||
`[keybindings] Not watching: ${watchDir} is not a directory`,
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
logForDebugging(`[keybindings] Not watching: ${watchDir} does not exist`)
|
||||
return
|
||||
}
|
||||
|
||||
// 仅在我们确认可以监视后才设置 initialized
|
||||
initialized = true
|
||||
|
||||
logForDebugging(`[keybindings] Watching for changes to ${userPath}`)
|
||||
|
||||
watcher = chokidar.watch(userPath, {
|
||||
persistent: true,
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: FILE_STABILITY_THRESHOLD_MS,
|
||||
pollInterval: FILE_STABILITY_POLL_INTERVAL_MS,
|
||||
},
|
||||
ignorePermissionErrors: true,
|
||||
usePolling: false,
|
||||
atomic: true,
|
||||
})
|
||||
|
||||
watcher.on('add', handleChange)
|
||||
watcher.on('change', handleChange)
|
||||
watcher.on('unlink', handleDelete)
|
||||
|
||||
// 注册清理
|
||||
registerCleanup(async () => disposeKeybindingWatcher())
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文件监视器。
|
||||
*/
|
||||
export function disposeKeybindingWatcher(): void {
|
||||
disposed = true
|
||||
if (watcher) {
|
||||
void watcher.close()
|
||||
watcher = null
|
||||
}
|
||||
keybindingsChanged.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅键绑定更改。
|
||||
* 监听器在文件更改时接收新的解析绑定。
|
||||
*/
|
||||
export const subscribeToKeybindingChanges = keybindingsChanged.subscribe
|
||||
|
||||
async function handleChange(path: string): Promise<void> {
|
||||
logForDebugging(`[keybindings] Detected change to ${path}`)
|
||||
|
||||
try {
|
||||
const result = await loadKeybindings()
|
||||
cachedBindings = result.bindings
|
||||
cachedWarnings = result.warnings
|
||||
|
||||
// 用完整结果通知所有监听器
|
||||
keybindingsChanged.emit(result)
|
||||
} catch (error) {
|
||||
logForDebugging(`[keybindings] Error reloading: ${errorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete(path: string): void {
|
||||
logForDebugging(`[keybindings] Detected deletion of ${path}`)
|
||||
|
||||
// 删除文件时重置为默认值
|
||||
const defaultBindings = getDefaultParsedBindings()
|
||||
cachedBindings = defaultBindings
|
||||
cachedWarnings = []
|
||||
|
||||
keybindingsChanged.emit({ bindings: defaultBindings, warnings: [] })
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的键绑定警告。
|
||||
* 如果没有警告或绑定尚未加载,则返回空数组。
|
||||
*/
|
||||
export function getCachedKeybindingWarnings(): KeybindingWarning[] {
|
||||
return cachedWarnings
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置内部状态以进行测试。
|
||||
*/
|
||||
export function resetKeybindingLoaderForTesting(): void {
|
||||
initialized = false
|
||||
disposed = false
|
||||
cachedBindings = null
|
||||
cachedWarnings = []
|
||||
lastCustomBindingsLogDate = null
|
||||
if (watcher) {
|
||||
void watcher.close()
|
||||
watcher = null
|
||||
}
|
||||
keybindingsChanged.clear()
|
||||
}
|
||||
117
claude-code源码-中文注释/src/keybindings/match.ts
Normal file
117
claude-code源码-中文注释/src/keybindings/match.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { Key } from '../ink.js'
|
||||
import type { ParsedBinding, ParsedKeystroke } from './types.js'
|
||||
|
||||
/**
|
||||
* Ink Key 类型中我们关心的修饰符键。
|
||||
* 注意:`fn` 从 Key 中故意排除,因为它很少使用且
|
||||
* 在终端应用程序中通常不可配置。
|
||||
*/
|
||||
type InkModifiers = Pick<Key, 'ctrl' | 'shift' | 'meta' | 'super'>
|
||||
|
||||
/**
|
||||
* 从 Ink Key 对象提取修饰符。
|
||||
* 此函数确保我们明确提取我们关心的修饰符。
|
||||
*/
|
||||
function getInkModifiers(key: Key): InkModifiers {
|
||||
return {
|
||||
ctrl: key.ctrl,
|
||||
shift: key.shift,
|
||||
meta: key.meta,
|
||||
super: key.super,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Ink 的 Key + input 提取规范化键名。
|
||||
* 将 Ink 的布尔标志(key.escape、key.return 等)映射到与我们的 ParsedKeystroke.key 格式匹配的字符串名称。
|
||||
*/
|
||||
export function getKeyName(input: string, key: Key): string | null {
|
||||
if (key.escape) return 'escape'
|
||||
if (key.return) return 'enter'
|
||||
if (key.tab) return 'tab'
|
||||
if (key.backspace) return 'backspace'
|
||||
if (key.delete) return 'delete'
|
||||
if (key.upArrow) return 'up'
|
||||
if (key.downArrow) return 'down'
|
||||
if (key.leftArrow) return 'left'
|
||||
if (key.rightArrow) return 'right'
|
||||
if (key.pageUp) return 'pageup'
|
||||
if (key.pageDown) return 'pagedown'
|
||||
if (key.wheelUp) return 'wheelup'
|
||||
if (key.wheelDown) return 'wheeldown'
|
||||
if (key.home) return 'home'
|
||||
if (key.end) return 'end'
|
||||
if (input.length === 1) return input.toLowerCase()
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查所有修饰符是否在 Ink Key 和 ParsedKeystroke 之间匹配。
|
||||
*
|
||||
* Alt 和 Meta:Ink 历史上为 Alt/Option 设置 `key.meta`。配置中的 `meta`
|
||||
* 修饰符被视为 `alt` 的别名 — 当 `key.meta` 为 true 时两者都匹配。
|
||||
*
|
||||
* Super (Cmd/Win):与 alt/meta 不同。仅通过 kitty
|
||||
* 键盘协议的 支持终端到达。`cmd`/`super` 绑定将在不发送它的终端上永远不会触发。
|
||||
*/
|
||||
function modifiersMatch(
|
||||
inkMods: InkModifiers,
|
||||
target: ParsedKeystroke,
|
||||
): boolean {
|
||||
// 检查 ctrl 修饰符
|
||||
if (inkMods.ctrl !== target.ctrl) return false
|
||||
|
||||
// 检查 shift 修饰符
|
||||
if (inkMods.shift !== target.shift) return false
|
||||
|
||||
// Alt 和 meta 都映射到 Ink 中的 key.meta(终端限制)
|
||||
// 所以我们检查 EITHER alt OR meta 是否在目标中需要
|
||||
const targetNeedsMeta = target.alt || target.meta
|
||||
if (inkMods.meta !== targetNeedsMeta) return false
|
||||
|
||||
// Super (cmd/win) 是与 alt/meta 不同的修饰符
|
||||
if (inkMods.super !== target.super) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 ParsedKeystroke 是否与给定的 Ink input + Key 匹配。
|
||||
*
|
||||
* 显示文本将显示平台适当的名称(macOS 上的 opt,其他地方 alt)。
|
||||
*/
|
||||
export function matchesKeystroke(
|
||||
input: string,
|
||||
key: Key,
|
||||
target: ParsedKeystroke,
|
||||
): boolean {
|
||||
const keyName = getKeyName(input, key)
|
||||
if (keyName !== target.key) return false
|
||||
|
||||
const inkMods = getInkModifiers(key)
|
||||
|
||||
// 怪癖:当按下 escape 时 Ink 设置 key.meta=true(见 input-event.ts)。
|
||||
// 这是终端中转义序列工作方式的遗留行为。
|
||||
// 我们需要在匹配 escape 键本身时忽略 meta 修饰符,
|
||||
// 否则像 "escape"(无修饰符)这样的绑定永远不会匹配。
|
||||
if (key.escape) {
|
||||
return modifiersMatch({ ...inkMods, meta: false }, target)
|
||||
}
|
||||
|
||||
return modifiersMatch(inkMods, target)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Ink 的 Key + input 是否匹配解析绑定的第一个按键。
|
||||
* 仅适用于单按键绑定(第 1 阶段)。
|
||||
*/
|
||||
export function matchesBinding(
|
||||
input: string,
|
||||
key: Key,
|
||||
binding: ParsedBinding,
|
||||
): boolean {
|
||||
if (binding.chord.length !== 1) return false
|
||||
const keystroke = binding.chord[0]
|
||||
if (!keystroke) return false
|
||||
return matchesKeystroke(input, key, keystroke)
|
||||
}
|
||||
202
claude-code源码-中文注释/src/keybindings/parser.ts
Normal file
202
claude-code源码-中文注释/src/keybindings/parser.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
Chord,
|
||||
KeybindingBlock,
|
||||
ParsedBinding,
|
||||
ParsedKeystroke,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* 将如 "ctrl+shift+k" 这样的按键字符串解析为 ParsedKeystroke。
|
||||
* 支持各种修饰符别名(ctrl/control、alt/opt/option/meta、cmd/command/super/win)。
|
||||
*/
|
||||
export function parseKeystroke(input: string): ParsedKeystroke {
|
||||
const parts = input.split('+')
|
||||
const keystroke: ParsedKeystroke = {
|
||||
key: '',
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
super: false,
|
||||
}
|
||||
for (const part of parts) {
|
||||
const lower = part.toLowerCase()
|
||||
switch (lower) {
|
||||
case 'ctrl':
|
||||
case 'control':
|
||||
keystroke.ctrl = true
|
||||
break
|
||||
case 'alt':
|
||||
case 'opt':
|
||||
case 'option':
|
||||
keystroke.alt = true
|
||||
break
|
||||
case 'shift':
|
||||
keystroke.shift = true
|
||||
break
|
||||
case 'meta':
|
||||
keystroke.meta = true
|
||||
break
|
||||
case 'cmd':
|
||||
case 'command':
|
||||
case 'super':
|
||||
case 'win':
|
||||
keystroke.super = true
|
||||
break
|
||||
case 'esc':
|
||||
keystroke.key = 'escape'
|
||||
break
|
||||
case 'return':
|
||||
keystroke.key = 'enter'
|
||||
break
|
||||
case 'space':
|
||||
keystroke.key = ' '
|
||||
break
|
||||
case '↑':
|
||||
keystroke.key = 'up'
|
||||
break
|
||||
case '↓':
|
||||
keystroke.key = 'down'
|
||||
break
|
||||
case '←':
|
||||
keystroke.key = 'left'
|
||||
break
|
||||
case '→':
|
||||
keystroke.key = 'right'
|
||||
break
|
||||
default:
|
||||
keystroke.key = lower
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return keystroke
|
||||
}
|
||||
|
||||
/**
|
||||
* 将如 "ctrl+k ctrl+s" 这样的和弦字符串解析为 ParsedKeystroke 数组。
|
||||
*/
|
||||
export function parseChord(input: string): Chord {
|
||||
// 单独的空格字符是空格键绑定,不是分隔符
|
||||
if (input === ' ') return [parseKeystroke('space')]
|
||||
return input.trim().split(/\s+/).map(parseKeystroke)
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ParsedKeystroke 转换为其规范字符串表示以进行显示。
|
||||
*/
|
||||
export function keystrokeToString(ks: ParsedKeystroke): string {
|
||||
const parts: string[] = []
|
||||
if (ks.ctrl) parts.push('ctrl')
|
||||
if (ks.alt) parts.push('alt')
|
||||
if (ks.shift) parts.push('shift')
|
||||
if (ks.meta) parts.push('meta')
|
||||
if (ks.super) parts.push('cmd')
|
||||
// 使用可读名称显示
|
||||
const displayKey = keyToDisplayName(ks.key)
|
||||
parts.push(displayKey)
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将内部键名映射到可读的显示名称。
|
||||
*/
|
||||
function keyToDisplayName(key: string): string {
|
||||
switch (key) {
|
||||
case 'escape':
|
||||
return 'Esc'
|
||||
case ' ':
|
||||
return 'Space'
|
||||
case 'tab':
|
||||
return 'tab'
|
||||
case 'enter':
|
||||
return 'Enter'
|
||||
case 'backspace':
|
||||
return 'Backspace'
|
||||
case 'delete':
|
||||
return 'Delete'
|
||||
case 'up':
|
||||
return '↑'
|
||||
case 'down':
|
||||
return '↓'
|
||||
case 'left':
|
||||
return '←'
|
||||
case 'right':
|
||||
return '→'
|
||||
case 'pageup':
|
||||
return 'PageUp'
|
||||
case 'pagedown':
|
||||
return 'PageDown'
|
||||
case 'home':
|
||||
return 'Home'
|
||||
case 'end':
|
||||
return 'End'
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Chord 转换为其规范字符串表示以进行显示。
|
||||
*/
|
||||
export function chordToString(chord: Chord): string {
|
||||
return chord.map(keystrokeToString).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示平台类型 - 我们关心的平台子集,用于显示。
|
||||
* WSL 和 unknown 被视为 linux 用于显示目的。
|
||||
*/
|
||||
type DisplayPlatform = 'macos' | 'windows' | 'linux' | 'wsl' | 'unknown'
|
||||
|
||||
/**
|
||||
* 将 ParsedKeystroke 转换为平台适当的显示字符串。
|
||||
* 在 macOS 上使用 "opt" 表示 alt,其他地方使用 "alt"。
|
||||
*/
|
||||
export function keystrokeToDisplayString(
|
||||
ks: ParsedKeystroke,
|
||||
platform: DisplayPlatform = 'linux',
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
if (ks.ctrl) parts.push('ctrl')
|
||||
// Alt/meta 在终端中是等价的,显示平台适当的名称
|
||||
if (ks.alt || ks.meta) {
|
||||
// 只有 macOS 使用 "opt",所有其他平台使用 "alt"
|
||||
parts.push(platform === 'macos' ? 'opt' : 'alt')
|
||||
}
|
||||
if (ks.shift) parts.push('shift')
|
||||
if (ks.super) {
|
||||
parts.push(platform === 'macos' ? 'cmd' : 'super')
|
||||
}
|
||||
// 使用可读名称显示
|
||||
const displayKey = keyToDisplayName(ks.key)
|
||||
parts.push(displayKey)
|
||||
return parts.join('+')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Chord 转换为平台适当的显示字符串。
|
||||
*/
|
||||
export function chordToDisplayString(
|
||||
chord: Chord,
|
||||
platform: DisplayPlatform = 'linux',
|
||||
): string {
|
||||
return chord.map(ks => keystrokeToDisplayString(ks, platform)).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析键绑定块(来自 JSON 配置)为 ParsedBinding 平面列表。
|
||||
*/
|
||||
export function parseBindings(blocks: KeybindingBlock[]): ParsedBinding[] {
|
||||
const bindings: ParsedBinding[] = []
|
||||
for (const block of blocks) {
|
||||
for (const [key, action] of Object.entries(block.bindings)) {
|
||||
bindings.push({
|
||||
chord: parseChord(key),
|
||||
action,
|
||||
context: block.context,
|
||||
})
|
||||
}
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
127
claude-code源码-中文注释/src/keybindings/reservedShortcuts.ts
Normal file
127
claude-code源码-中文注释/src/keybindings/reservedShortcuts.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { getPlatform } from '../utils/platform.js'
|
||||
|
||||
/**
|
||||
* 通常被操作系统、终端或 shell 拦截的快捷键,
|
||||
* 可能永远不会到达应用程序。
|
||||
*/
|
||||
export type ReservedShortcut = {
|
||||
key: string
|
||||
reason: string
|
||||
severity: 'error' | 'warning'
|
||||
}
|
||||
|
||||
/**
|
||||
* 无法重新绑定的快捷键 - 它们在 Claude Code 中是硬编码的。
|
||||
*/
|
||||
export const NON_REBINDABLE: ReservedShortcut[] = [
|
||||
{
|
||||
key: 'ctrl+c',
|
||||
reason: '无法重新绑定 - 用于中断/退出(硬编码)',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
key: 'ctrl+d',
|
||||
reason: '无法重新绑定 - 用于退出(硬编码)',
|
||||
severity: 'error',
|
||||
},
|
||||
{
|
||||
key: 'ctrl+m',
|
||||
reason:
|
||||
'无法重新绑定 - 在终端中与 Enter 相同(都发送 CR)',
|
||||
severity: 'error',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* 被终端/操作系统拦截的终端控制快捷键。
|
||||
* 这些可能永远不会到达应用程序。
|
||||
*
|
||||
* 注意:ctrl+s(XOFF)和 ctrl+q(XON)不包含在此处,因为:
|
||||
* - 大多数现代终端默认禁用流控制
|
||||
* - 我们使用 ctrl+s 作为 stash 功能
|
||||
*/
|
||||
export const TERMINAL_RESERVED: ReservedShortcut[] = [
|
||||
{
|
||||
key: 'ctrl+z',
|
||||
reason: 'Unix 进程挂起(SIGTSTP)',
|
||||
severity: 'warning',
|
||||
},
|
||||
{
|
||||
key: 'ctrl+\\',
|
||||
reason: '终端退出信号(SIGQUIT)',
|
||||
severity: 'error',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* macOS 特定的快捷键,操作系统会拦截。
|
||||
*/
|
||||
export const MACOS_RESERVED: ReservedShortcut[] = [
|
||||
{ key: 'cmd+c', reason: 'macOS 系统复制', severity: 'error' },
|
||||
{ key: 'cmd+v', reason: 'macOS 系统粘贴', severity: 'error' },
|
||||
{ key: 'cmd+x', reason: 'macOS 系统剪切', severity: 'error' },
|
||||
{ key: 'cmd+q', reason: 'macOS 退出应用程序', severity: 'error' },
|
||||
{ key: 'cmd+w', reason: 'macOS 关闭窗口/标签页', severity: 'error' },
|
||||
{ key: 'cmd+tab', reason: 'macOS 应用切换器', severity: 'error' },
|
||||
{ key: 'cmd+space', reason: 'macOS Spotlight', severity: 'error' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 获取当前平台的所有保留快捷键。
|
||||
* 包括不可重新绑定的快捷键和终端保留的快捷键。
|
||||
*/
|
||||
export function getReservedShortcuts(): ReservedShortcut[] {
|
||||
const platform = getPlatform()
|
||||
// 不可重新绑定的快捷键优先(最高优先级)
|
||||
const reserved = [...NON_REBINDABLE, ...TERMINAL_RESERVED]
|
||||
|
||||
if (platform === 'macos') {
|
||||
reserved.push(...MACOS_RESERVED)
|
||||
}
|
||||
|
||||
return reserved
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化按键字符串以便比较(小写、排序修饰符)。
|
||||
* 和弦(以空格分隔的步骤如 "ctrl+x ctrl+b")被规范化
|
||||
* 每个步骤 - 首先在 '+' 上分割会损坏 "x ctrl" 为 mainKey
|
||||
* 被下一个步骤覆盖,将和弦折叠为其最后一个键。
|
||||
*/
|
||||
export function normalizeKeyForComparison(key: string): string {
|
||||
return key.trim().split(/\s+/).map(normalizeStep).join(' ')
|
||||
}
|
||||
|
||||
function normalizeStep(step: string): string {
|
||||
const parts = step.split('+')
|
||||
const modifiers: string[] = []
|
||||
let mainKey = ''
|
||||
|
||||
for (const part of parts) {
|
||||
const lower = part.trim().toLowerCase()
|
||||
if (
|
||||
[
|
||||
'ctrl',
|
||||
'control',
|
||||
'alt',
|
||||
'opt',
|
||||
'option',
|
||||
'meta',
|
||||
'cmd',
|
||||
'command',
|
||||
'shift',
|
||||
].includes(lower)
|
||||
) {
|
||||
// 规范化修饰符名称
|
||||
if (lower === 'control') modifiers.push('ctrl')
|
||||
else if (lower === 'option' || lower === 'opt') modifiers.push('alt')
|
||||
else if (lower === 'command' || lower === 'cmd') modifiers.push('cmd')
|
||||
else modifiers.push(lower)
|
||||
} else {
|
||||
mainKey = lower
|
||||
}
|
||||
}
|
||||
|
||||
modifiers.sort()
|
||||
return [...modifiers, mainKey].join('+')
|
||||
}
|
||||
244
claude-code源码-中文注释/src/keybindings/resolver.ts
Normal file
244
claude-code源码-中文注释/src/keybindings/resolver.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import type { Key } from '../ink.js'
|
||||
import { getKeyName, matchesBinding } from './match.js'
|
||||
import { chordToString } from './parser.js'
|
||||
import type {
|
||||
KeybindingContextName,
|
||||
ParsedBinding,
|
||||
ParsedKeystroke,
|
||||
} from './types.js'
|
||||
|
||||
export type ResolveResult =
|
||||
| { type: 'match'; action: string }
|
||||
| { type: 'none' }
|
||||
| { type: 'unbound' }
|
||||
|
||||
export type ChordResolveResult =
|
||||
| { type: 'match'; action: string }
|
||||
| { type: 'none' }
|
||||
| { type: 'unbound' }
|
||||
| { type: 'chord_started'; pending: ParsedKeystroke[] }
|
||||
| { type: 'chord_cancelled' }
|
||||
|
||||
/**
|
||||
* 将键输入解析为操作。
|
||||
* 纯函数 — 无状态,无副作用,只是匹配逻辑。
|
||||
*
|
||||
* @param input - 来自 Ink 的字符输入
|
||||
* @param key - 带修饰符标志的 Ink Key 对象
|
||||
* @param activeContexts - 当前活动上下文的数组(例如 ['Chat', 'Global'])
|
||||
* @param bindings - 要搜索的所有解析绑定
|
||||
* @returns 解析结果
|
||||
*/
|
||||
export function resolveKey(
|
||||
input: string,
|
||||
key: Key,
|
||||
activeContexts: KeybindingContextName[],
|
||||
bindings: ParsedBinding[],
|
||||
): ResolveResult {
|
||||
// 查找匹配的绑定(用户覆盖,后者获胜)
|
||||
let match: ParsedBinding | undefined
|
||||
const ctxSet = new Set(activeContexts)
|
||||
|
||||
for (const binding of bindings) {
|
||||
// 阶段 1:仅单按键绑定
|
||||
if (binding.chord.length !== 1) continue
|
||||
if (!ctxSet.has(binding.context)) continue
|
||||
|
||||
if (matchesBinding(input, key, binding)) {
|
||||
match = binding
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
return { type: 'none' }
|
||||
}
|
||||
|
||||
if (match.action === null) {
|
||||
return { type: 'unbound' }
|
||||
}
|
||||
|
||||
return { type: 'match', action: match.action }
|
||||
}
|
||||
|
||||
/**
|
||||
* 从绑定获取操作的可显示文本(例如 "ctrl+t" 表示 "app:toggleTodos")。
|
||||
* 逆序搜索以便用户覆盖优先。
|
||||
*/
|
||||
export function getBindingDisplayText(
|
||||
action: string,
|
||||
context: KeybindingContextName,
|
||||
bindings: ParsedBinding[],
|
||||
): string | undefined {
|
||||
// 在此上下文中查找此操作的最后一个绑定
|
||||
const binding = bindings.findLast(
|
||||
b => b.action === action && b.context === context,
|
||||
)
|
||||
return binding ? chordToString(binding.chord) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Ink 的 input/key 构建 ParsedKeystroke。
|
||||
*/
|
||||
function buildKeystroke(input: string, key: Key): ParsedKeystroke | null {
|
||||
const keyName = getKeyName(input, key)
|
||||
if (!keyName) return null
|
||||
|
||||
// 怪癖:按下 escape 时 Ink 设置 key.meta=true(见 input-event.ts)。
|
||||
// 这是遗留的终端行为 — 我们不应该将其记录为修饰符
|
||||
// 对于 escape 键本身,否则和弦匹配会失败。
|
||||
const effectiveMeta = key.escape ? false : key.meta
|
||||
|
||||
return {
|
||||
key: keyName,
|
||||
ctrl: key.ctrl,
|
||||
alt: effectiveMeta,
|
||||
shift: key.shift,
|
||||
meta: effectiveMeta,
|
||||
super: key.super,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 比较两个 ParsedKeystroke 是否相等。将 alt/meta 折叠为
|
||||
* 一个逻辑修饰符 — 遗留终端无法区分(见
|
||||
* match.ts modifiersMatch),因此 "alt+k" 和 "meta+k" 是相同的键。
|
||||
* Super (cmd/win) 是不同的 — 仅通过 kitty 键盘协议到达。
|
||||
*/
|
||||
export function keystrokesEqual(
|
||||
a: ParsedKeystroke,
|
||||
b: ParsedKeystroke,
|
||||
): boolean {
|
||||
return (
|
||||
a.key === b.key &&
|
||||
a.ctrl === b.ctrl &&
|
||||
a.shift === b.shift &&
|
||||
(a.alt || a.meta) === (b.alt || b.meta) &&
|
||||
a.super === b.super
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查和弦前缀是否匹配绑定和弦的开头。
|
||||
*/
|
||||
function chordPrefixMatches(
|
||||
prefix: ParsedKeystroke[],
|
||||
binding: ParsedBinding,
|
||||
): boolean {
|
||||
if (prefix.length >= binding.chord.length) return false
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
const prefixKey = prefix[i]
|
||||
const bindingKey = binding.chord[i]
|
||||
if (!prefixKey || !bindingKey) return false
|
||||
if (!keystrokesEqual(prefixKey, bindingKey)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查完整和弦是否匹配绑定的和弦。
|
||||
*/
|
||||
function chordExactlyMatches(
|
||||
chord: ParsedKeystroke[],
|
||||
binding: ParsedBinding,
|
||||
): boolean {
|
||||
if (chord.length !== binding.chord.length) return false
|
||||
for (let i = 0; i < chord.length; i++) {
|
||||
const chordKey = chord[i]
|
||||
const bindingKey = binding.chord[i]
|
||||
if (!chordKey || !bindingKey) return false
|
||||
if (!keystrokesEqual(chordKey, bindingKey)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 带和弦状态支持解析键。
|
||||
*
|
||||
* 此函数处理如 "ctrl+k ctrl+s" 的多按键和弦绑定。
|
||||
*
|
||||
* @param input - 来自 Ink 的字符输入
|
||||
* @param key - 带修饰符标志的 Ink Key 对象
|
||||
* @param activeContexts - 当前活动上下文的数组
|
||||
* @param bindings - 所有解析的绑定
|
||||
* @param pending - 当前和弦状态(如果不处于和弦中则为 null)
|
||||
* @returns 带和弦状态的解析结果
|
||||
*/
|
||||
export function resolveKeyWithChordState(
|
||||
input: string,
|
||||
key: Key,
|
||||
activeContexts: KeybindingContextName[],
|
||||
bindings: ParsedBinding[],
|
||||
pending: ParsedKeystroke[] | null,
|
||||
): ChordResolveResult {
|
||||
// 在 escape 上取消和弦
|
||||
if (key.escape && pending !== null) {
|
||||
return { type: 'chord_cancelled' }
|
||||
}
|
||||
|
||||
// 构建当前按键
|
||||
const currentKeystroke = buildKeystroke(input, key)
|
||||
if (!currentKeystroke) {
|
||||
if (pending !== null) {
|
||||
return { type: 'chord_cancelled' }
|
||||
}
|
||||
return { type: 'none' }
|
||||
}
|
||||
|
||||
// 构建要测试的完整和弦序列
|
||||
const testChord = pending
|
||||
? [...pending, currentKeystroke]
|
||||
: [currentKeystroke]
|
||||
|
||||
// 按活动上下文过滤绑定(Set 查找:O(n) 而不是 O(n·m))
|
||||
const ctxSet = new Set(activeContexts)
|
||||
const contextBindings = bindings.filter(b => ctxSet.has(b.context))
|
||||
|
||||
// 检查这是否可能是更长和弦的前缀。按和弦
|
||||
// 字符串分组,以便后面的 null 覆盖覆盖它取消绑定的默认 —
|
||||
// 否则 null 取消绑定 `ctrl+x ctrl+k` 仍使 `ctrl+x` 进入
|
||||
// 和弦等待,单键绑定在前缀上永远不会触发。
|
||||
const chordWinners = new Map<string, string | null>()
|
||||
for (const binding of contextBindings) {
|
||||
if (
|
||||
binding.chord.length > testChord.length &&
|
||||
chordPrefixMatches(testChord, binding)
|
||||
) {
|
||||
chordWinners.set(chordToString(binding.chord), binding.action)
|
||||
}
|
||||
}
|
||||
let hasLongerChords = false
|
||||
for (const action of chordWinners.values()) {
|
||||
if (action !== null) {
|
||||
hasLongerChords = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 如果此按键可能是更长和弦的开始,优先处理
|
||||
// (即使有精确的单键匹配)
|
||||
if (hasLongerChords) {
|
||||
return { type: 'chord_started', pending: testChord }
|
||||
}
|
||||
|
||||
// 检查精确匹配(后者获胜)
|
||||
let exactMatch: ParsedBinding | undefined
|
||||
for (const binding of contextBindings) {
|
||||
if (chordExactlyMatches(testChord, binding)) {
|
||||
exactMatch = binding
|
||||
}
|
||||
}
|
||||
|
||||
if (exactMatch) {
|
||||
if (exactMatch.action === null) {
|
||||
return { type: 'unbound' }
|
||||
}
|
||||
return { type: 'match', action: exactMatch.action }
|
||||
}
|
||||
|
||||
// 没有匹配且没有潜在的长和弦
|
||||
if (pending !== null) {
|
||||
return { type: 'chord_cancelled' }
|
||||
}
|
||||
|
||||
return { type: 'none' }
|
||||
}
|
||||
236
claude-code源码-中文注释/src/keybindings/schema.ts
Normal file
236
claude-code源码-中文注释/src/keybindings/schema.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* keybindings.json 配置的 Zod 模式。
|
||||
* 用于验证和 JSON 模式生成。
|
||||
*/
|
||||
|
||||
import { z } from 'zod/v4'
|
||||
import { lazySchema } from '../utils/lazySchema.js'
|
||||
|
||||
/**
|
||||
* 有效的键绑定上下文名称。
|
||||
*/
|
||||
export const KEYBINDING_CONTEXTS = [
|
||||
'Global',
|
||||
'Chat',
|
||||
'Autocomplete',
|
||||
'Confirmation',
|
||||
'Help',
|
||||
'Transcript',
|
||||
'HistorySearch',
|
||||
'Task',
|
||||
'ThemePicker',
|
||||
'Settings',
|
||||
'Tabs',
|
||||
// 键绑定迁移的新上下文
|
||||
'Attachments',
|
||||
'Footer',
|
||||
'MessageSelector',
|
||||
'DiffDialog',
|
||||
'ModelPicker',
|
||||
'Select',
|
||||
'Plugin',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 每个键绑定上下文的可读描述。
|
||||
*/
|
||||
export const KEYBINDING_CONTEXT_DESCRIPTIONS: Record<
|
||||
(typeof KEYBINDING_CONTEXTS)[number],
|
||||
string
|
||||
> = {
|
||||
Global: '无论焦点在哪里,活动无处不在',
|
||||
Chat: '当聊天输入获得焦点时',
|
||||
Autocomplete: '当自动完成菜单可见时',
|
||||
Confirmation: '当显示确认/权限对话框时',
|
||||
Help: '当帮助覆盖层打开时',
|
||||
Transcript: '当查看转录时',
|
||||
HistorySearch: '当搜索命令历史时(ctrl+r)',
|
||||
Task: '当任务/代理在前台运行时',
|
||||
ThemePicker: '当主题选择器打开时',
|
||||
Settings: '当设置菜单打开时',
|
||||
Tabs: '当标签导航活动时',
|
||||
Attachments: '当在选择对话框中导航图像附件时',
|
||||
Footer: '当页脚指示器获得焦点时',
|
||||
MessageSelector: '当消息选择器(倒带)打开时',
|
||||
DiffDialog: '当差异对话框打开时',
|
||||
ModelPicker: '当模型选择器打开时',
|
||||
Select: '当选择/列表组件获得焦点时',
|
||||
Plugin: '当插件对话框打开时',
|
||||
}
|
||||
|
||||
/**
|
||||
* 所有有效的键绑定操作标识符。
|
||||
*/
|
||||
export const KEYBINDING_ACTIONS = [
|
||||
// 应用级操作(全局上下文)
|
||||
'app:interrupt',
|
||||
'app:exit',
|
||||
'app:toggleTodos',
|
||||
'app:toggleTranscript',
|
||||
'app:toggleBrief',
|
||||
'app:toggleTeammatePreview',
|
||||
'app:toggleTerminal',
|
||||
'app:redraw',
|
||||
'app:globalSearch',
|
||||
'app:quickOpen',
|
||||
// 历史导航
|
||||
'history:search',
|
||||
'history:previous',
|
||||
'history:next',
|
||||
// 聊天输入操作
|
||||
'chat:cancel',
|
||||
'chat:killAgents',
|
||||
'chat:cycleMode',
|
||||
'chat:modelPicker',
|
||||
'chat:fastMode',
|
||||
'chat:thinkingToggle',
|
||||
'chat:submit',
|
||||
'chat:newline',
|
||||
'chat:undo',
|
||||
'chat:externalEditor',
|
||||
'chat:stash',
|
||||
'chat:imagePaste',
|
||||
'chat:messageActions',
|
||||
// 自动完成菜单操作
|
||||
'autocomplete:accept',
|
||||
'autocomplete:dismiss',
|
||||
'autocomplete:previous',
|
||||
'autocomplete:next',
|
||||
// 确认对话框操作
|
||||
'confirm:yes',
|
||||
'confirm:no',
|
||||
'confirm:previous',
|
||||
'confirm:next',
|
||||
'confirm:nextField',
|
||||
'confirm:previousField',
|
||||
'confirm:cycleMode',
|
||||
'confirm:toggle',
|
||||
'confirm:toggleExplanation',
|
||||
// 标签导航操作
|
||||
'tabs:next',
|
||||
'tabs:previous',
|
||||
// 转录查看器操作
|
||||
'transcript:toggleShowAll',
|
||||
'transcript:exit',
|
||||
// 历史搜索操作
|
||||
'historySearch:next',
|
||||
'historySearch:accept',
|
||||
'historySearch:cancel',
|
||||
'historySearch:execute',
|
||||
// 任务/代理操作
|
||||
'task:background',
|
||||
// 主题选择器操作
|
||||
'theme:toggleSyntaxHighlighting',
|
||||
// 帮助菜单操作
|
||||
'help:dismiss',
|
||||
// 附件导航(选择对话框图像附件)
|
||||
'attachments:next',
|
||||
'attachments:previous',
|
||||
'attachments:remove',
|
||||
'attachments:exit',
|
||||
// 页脚指示器操作
|
||||
'footer:up',
|
||||
'footer:down',
|
||||
'footer:next',
|
||||
'footer:previous',
|
||||
'footer:openSelected',
|
||||
'footer:clearSelection',
|
||||
'footer:close',
|
||||
// 消息选择器(倒带)操作
|
||||
'messageSelector:up',
|
||||
'messageSelector:down',
|
||||
'messageSelector:top',
|
||||
'messageSelector:bottom',
|
||||
'messageSelector:select',
|
||||
// 差异对话框操作
|
||||
'diff:dismiss',
|
||||
'diff:previousSource',
|
||||
'diff:nextSource',
|
||||
'diff:back',
|
||||
'diff:viewDetails',
|
||||
'diff:previousFile',
|
||||
'diff:nextFile',
|
||||
// 模型选择器操作(仅限 ant)
|
||||
'modelPicker:decreaseEffort',
|
||||
'modelPicker:increaseEffort',
|
||||
// 选择组件操作(与 confirm: 区分以避免冲突)
|
||||
'select:next',
|
||||
'select:previous',
|
||||
'select:accept',
|
||||
'select:cancel',
|
||||
// 插件对话框操作
|
||||
'plugin:toggle',
|
||||
'plugin:install',
|
||||
// 权限对话框操作
|
||||
'permission:toggleDebug',
|
||||
// 设置配置面板操作
|
||||
'settings:search',
|
||||
'settings:retry',
|
||||
'settings:close',
|
||||
// 语音操作
|
||||
'voice:pushToTalk',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* 单个键绑定块的模式。
|
||||
*/
|
||||
export const KeybindingBlockSchema = lazySchema(() =>
|
||||
z
|
||||
.object({
|
||||
context: z
|
||||
.enum(KEYBINDING_CONTEXTS)
|
||||
.describe(
|
||||
'这些绑定适用的 UI 上下文。全局绑定在任何地方都有效。',
|
||||
),
|
||||
bindings: z
|
||||
.record(
|
||||
z
|
||||
.string()
|
||||
.describe('按键模式(例如 "ctrl+k"、"shift+tab")'),
|
||||
z
|
||||
.union([
|
||||
z.enum(KEYBINDING_ACTIONS),
|
||||
z
|
||||
.string()
|
||||
.regex(/^command:[a-zA-Z0-9:\-_]+$/)
|
||||
.describe(
|
||||
'命令绑定(例如 "command:help"、"command:compact")。执行,就好像输入了斜杠命令。',
|
||||
),
|
||||
z.null().describe('设置为 null 以取消绑定默认快捷键'),
|
||||
])
|
||||
.describe(
|
||||
'要触发的操作、执行的命令,或 null 以取消绑定',
|
||||
),
|
||||
)
|
||||
.describe('按键模式到操作的映射'),
|
||||
})
|
||||
.describe('特定上下文的键绑定块'),
|
||||
)
|
||||
|
||||
/**
|
||||
* 整个 keybindings.json 文件的模式。
|
||||
* 使用可选的 $schema 和 $docs 元数据的目标对象包装格式。
|
||||
*/
|
||||
export const KeybindingsSchema = lazySchema(() =>
|
||||
z
|
||||
.object({
|
||||
$schema: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('用于编辑器验证的 JSON Schema URL'),
|
||||
$docs: z.string().optional().describe('文档 URL'),
|
||||
bindings: z
|
||||
.array(KeybindingBlockSchema())
|
||||
.describe('按上下文划分的键绑定块数组'),
|
||||
})
|
||||
.describe(
|
||||
'Claude Code 键绑定配置。按上下文自定义键盘快捷键。',
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* 从模式派生的 TypeScript 类型。
|
||||
*/
|
||||
export type KeybindingsSchemaType = z.infer<
|
||||
ReturnType<typeof KeybindingsSchema>
|
||||
>
|
||||
52
claude-code源码-中文注释/src/keybindings/template.ts
Normal file
52
claude-code源码-中文注释/src/keybindings/template.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 键绑定模板生成器。
|
||||
* 为 ~/.claude/keybindings.json 生成格式良好的文档模板文件。
|
||||
*/
|
||||
|
||||
import { jsonStringify } from '../utils/slowOperations.js'
|
||||
import { DEFAULT_BINDINGS } from './defaultBindings.js'
|
||||
import {
|
||||
NON_REBINDABLE,
|
||||
normalizeKeyForComparison,
|
||||
} from './reservedShortcuts.js'
|
||||
import type { KeybindingBlock } from './types.js'
|
||||
|
||||
/**
|
||||
* 过滤无法重新绑定的保留快捷键。
|
||||
* 这些会导致 /doctor 警告,因此我们从模板中排除它们。
|
||||
*/
|
||||
function filterReservedShortcuts(blocks: KeybindingBlock[]): KeybindingBlock[] {
|
||||
const reservedKeys = new Set(
|
||||
NON_REBINDABLE.map(r => normalizeKeyForComparison(r.key)),
|
||||
)
|
||||
|
||||
return blocks
|
||||
.map(block => {
|
||||
const filteredBindings: Record<string, string | null> = {}
|
||||
for (const [key, action] of Object.entries(block.bindings)) {
|
||||
if (!reservedKeys.has(normalizeKeyForComparison(key))) {
|
||||
filteredBindings[key] = action
|
||||
}
|
||||
}
|
||||
return { context: block.context, bindings: filteredBindings }
|
||||
})
|
||||
.filter(block => Object.keys(block.bindings).length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成模板 keybindings.json 文件内容。
|
||||
* 创建完全有效的 JSON 文件,包含用户可自定义的所有默认绑定。
|
||||
*/
|
||||
export function generateKeybindingsTemplate(): string {
|
||||
// 过滤无法重新绑定的保留快捷键
|
||||
const bindings = filterReservedShortcuts(DEFAULT_BINDINGS)
|
||||
|
||||
// 格式化为带 bindings 数组的对象包装
|
||||
const config = {
|
||||
$schema: 'https://www.schemastore.org/claude-code-keybindings.json',
|
||||
$docs: 'https://code.claude.com/docs/en/keybindings',
|
||||
bindings,
|
||||
}
|
||||
|
||||
return jsonStringify(config, null, 2) + '\n'
|
||||
}
|
||||
196
claude-code源码-中文注释/src/keybindings/useKeybinding.ts
Normal file
196
claude-code源码-中文注释/src/keybindings/useKeybinding.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import type { InputEvent } from '../ink/events/input-event.js'
|
||||
import { type Key, useInput } from '../ink.js'
|
||||
import { useOptionalKeybindingContext } from './KeybindingContext.js'
|
||||
import type { KeybindingContextName } from './types.js'
|
||||
|
||||
type Options = {
|
||||
/** 此绑定所属的上下文(默认:'Global') */
|
||||
context?: KeybindingContextName
|
||||
/** 仅在激活时处理(类似于 useInput 的 isActive) */
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 用于处理键绑定的 Ink 原生 hook。
|
||||
*
|
||||
* 处理程序保留在组件中(React 方式)。
|
||||
* 绑定(按键 → 操作)来自配置。
|
||||
*
|
||||
* 支持和弦序列(例如 "ctrl+k ctrl+s")。当和弦开始时,
|
||||
* hook 将自动管理待处理状态。
|
||||
*
|
||||
* 使用 stopImmediatePropagation() 在此绑定被处理后
|
||||
* 阻止其他处理程序触发。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* useKeybinding('app:toggleTodos', () => {
|
||||
* setShowTodos(prev => !prev)
|
||||
* }, { context: 'Global' })
|
||||
* ```
|
||||
*/
|
||||
export function useKeybinding(
|
||||
action: string,
|
||||
handler: () => void | false | Promise<void>,
|
||||
options: Options = {},
|
||||
): void {
|
||||
const { context = 'Global', isActive = true } = options
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
|
||||
// 向上下文注册处理程序以供 ChordInterceptor 调用
|
||||
useEffect(() => {
|
||||
if (!keybindingContext || !isActive) return
|
||||
return keybindingContext.registerHandler({ action, context, handler })
|
||||
}, [action, context, handler, keybindingContext, isActive])
|
||||
|
||||
const handleInput = useCallback(
|
||||
(input: string, key: Key, event: InputEvent) => {
|
||||
// 如果没有可用的键绑定上下文,跳过解析
|
||||
if (!keybindingContext) return
|
||||
|
||||
// 构建上下文列表:注册的活动上下文 + 此上下文 + Global
|
||||
// 更具体的上下文(注册的)比 Global 优先级更高
|
||||
const contextsToCheck: KeybindingContextName[] = [
|
||||
...keybindingContext.activeContexts,
|
||||
context,
|
||||
'Global',
|
||||
]
|
||||
// 去重同时保持顺序(第一次出现的优先级最高)
|
||||
const uniqueContexts = [...new Set(contextsToCheck)]
|
||||
|
||||
const result = keybindingContext.resolve(input, key, uniqueContexts)
|
||||
|
||||
switch (result.type) {
|
||||
case 'match':
|
||||
// 和弦完成(如果有)- 清除待处理状态
|
||||
keybindingContext.setPendingChord(null)
|
||||
if (result.action === action) {
|
||||
if (handler() !== false) {
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'chord_started':
|
||||
// 用户开始了一个和弦序列 - 更新待处理状态
|
||||
keybindingContext.setPendingChord(result.pending)
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
case 'chord_cancelled':
|
||||
// 和弦被取消(escape 或无效键)
|
||||
keybindingContext.setPendingChord(null)
|
||||
break
|
||||
case 'unbound':
|
||||
// 显式取消绑定 - 清除任何待处理和弦
|
||||
keybindingContext.setPendingChord(null)
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
case 'none':
|
||||
// 没有匹配 - 让其他处理程序尝试
|
||||
break
|
||||
}
|
||||
},
|
||||
[action, context, handler, keybindingContext],
|
||||
)
|
||||
|
||||
useInput(handleInput, { isActive })
|
||||
}
|
||||
|
||||
/**
|
||||
* 在一个 hook 中处理多个键绑定(减少 useInput 调用)。
|
||||
*
|
||||
* 支持和弦序列。当和弦开始时,hook 将
|
||||
* 自动管理待处理状态。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* useKeybindings({
|
||||
* 'chat:submit': () => handleSubmit(),
|
||||
* 'chat:cancel': () => handleCancel(),
|
||||
* }, { context: 'Chat' })
|
||||
* ```
|
||||
*/
|
||||
export function useKeybindings(
|
||||
// 返回 `false` 的处理程序意味着"未消费" — 事件传播到
|
||||
// 后续的 useInput/useKeybindings 处理程序。用于回退:
|
||||
// 例如 ScrollKeybindingHandler 的 scroll:line* 在
|
||||
// ScrollBox 内容适合时返回 false(滚动是无操作),
|
||||
// 让子组件的处理程序接管列表导航的车轮事件。
|
||||
// Promise<void> 允许火即忘的异步处理程序(`!== false` 检查
|
||||
// 仅对同步 `false` 跳过传播,而不是待处理的 Promise)。
|
||||
handlers: Record<string, () => void | false | Promise<void>>,
|
||||
options: Options = {},
|
||||
): void {
|
||||
const { context = 'Global', isActive = true } = options
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
|
||||
// 向上下文注册所有处理程序以供 ChordInterceptor 调用
|
||||
useEffect(() => {
|
||||
if (!keybindingContext || !isActive) return
|
||||
|
||||
const unregisterFns: Array<() => void> = []
|
||||
for (const [action, handler] of Object.entries(handlers)) {
|
||||
unregisterFns.push(
|
||||
keybindingContext.registerHandler({ action, context, handler }),
|
||||
)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const unregister of unregisterFns) {
|
||||
unregister()
|
||||
}
|
||||
}
|
||||
}, [context, handlers, keybindingContext, isActive])
|
||||
|
||||
const handleInput = useCallback(
|
||||
(input: string, key: Key, event: InputEvent) => {
|
||||
// 如果没有可用的键绑定上下文,跳过解析
|
||||
if (!keybindingContext) return
|
||||
|
||||
// 构建上下文列表:注册的活动上下文 + 此上下文 + Global
|
||||
// 更具体的上下文(注册的)比 Global 优先级更高
|
||||
const contextsToCheck: KeybindingContextName[] = [
|
||||
...keybindingContext.activeContexts,
|
||||
context,
|
||||
'Global',
|
||||
]
|
||||
// 去重同时保持顺序(第一次出现的优先级最高)
|
||||
const uniqueContexts = [...new Set(contextsToCheck)]
|
||||
|
||||
const result = keybindingContext.resolve(input, key, uniqueContexts)
|
||||
|
||||
switch (result.type) {
|
||||
case 'match':
|
||||
// 和弦完成(如果有)- 清除待处理状态
|
||||
keybindingContext.setPendingChord(null)
|
||||
if (result.action in handlers) {
|
||||
const handler = handlers[result.action]
|
||||
if (handler && handler() !== false) {
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'chord_started':
|
||||
// 用户开始了一个和弦序列 - 更新待处理状态
|
||||
keybindingContext.setPendingChord(result.pending)
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
case 'chord_cancelled':
|
||||
// 和弦被取消(escape 或无效键)
|
||||
keybindingContext.setPendingChord(null)
|
||||
break
|
||||
case 'unbound':
|
||||
// 显式取消绑定 - 清除任何待处理和弦
|
||||
keybindingContext.setPendingChord(null)
|
||||
event.stopImmediatePropagation()
|
||||
break
|
||||
case 'none':
|
||||
// 没有匹配 - 让其他处理程序尝试
|
||||
break
|
||||
}
|
||||
},
|
||||
[context, handlers, keybindingContext],
|
||||
)
|
||||
|
||||
useInput(handleInput, { isActive })
|
||||
}
|
||||
59
claude-code源码-中文注释/src/keybindings/useShortcutDisplay.ts
Normal file
59
claude-code源码-中文注释/src/keybindings/useShortcutDisplay.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../services/analytics/index.js'
|
||||
import { useOptionalKeybindingContext } from './KeybindingContext.js'
|
||||
import type { KeybindingContextName } from './types.js'
|
||||
|
||||
// TODO(keybindings-migration): 迁移完成后移除 fallback 参数
|
||||
// 确认没有 'keybinding_fallback_used' 事件被记录。
|
||||
// fallback 作为迁移期间的安全网存在 - 如果绑定加载失败
|
||||
// 或找不到操作,我们回退到硬编码值。一旦稳定,调用者
|
||||
// 应该能够信任 getBindingDisplayText 始终为已知操作返回值,
|
||||
// 我们可以移除这种防御模式。
|
||||
|
||||
/**
|
||||
* 获取配置快捷键显示文本的 hook。
|
||||
* 返回配置的绑定或不可用时的回退值。
|
||||
*
|
||||
* @param action - 操作名称(例如 'app:toggleTranscript')
|
||||
* @param context - 键绑定上下文(例如 'Global')
|
||||
* @param fallback - 如果键绑定上下文不可用时的回退文本
|
||||
* @returns 配置的快捷键显示文本
|
||||
*
|
||||
* @example
|
||||
* const expandShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o')
|
||||
* // 返回用户配置的绑定,或 'ctrl+o' 作为默认值
|
||||
*/
|
||||
export function useShortcutDisplay(
|
||||
action: string,
|
||||
context: KeybindingContextName,
|
||||
fallback: string,
|
||||
): string {
|
||||
const keybindingContext = useOptionalKeybindingContext()
|
||||
const resolved = keybindingContext?.getDisplayText(action, context)
|
||||
const isFallback = resolved === undefined
|
||||
const reason = keybindingContext ? 'action_not_found' : 'no_context'
|
||||
|
||||
// 在每次挂载时记录一次回退使用(而不是每次渲染)以避免
|
||||
// 频繁渲染导致的分析事件泛滥。
|
||||
const hasLoggedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (isFallback && !hasLoggedRef.current) {
|
||||
hasLoggedRef.current = true
|
||||
logEvent('tengu_keybinding_fallback_used', {
|
||||
action:
|
||||
action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
context:
|
||||
context as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
fallback:
|
||||
fallback as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
reason:
|
||||
reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
}
|
||||
}, [isFallback, action, context, fallback, reason])
|
||||
|
||||
return isFallback ? fallback : resolved
|
||||
}
|
||||
496
claude-code源码-中文注释/src/keybindings/validate.ts
Normal file
496
claude-code源码-中文注释/src/keybindings/validate.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
import { plural } from '../utils/stringUtils.js'
|
||||
import { chordToString, parseChord, parseKeystroke } from './parser.js'
|
||||
import {
|
||||
getReservedShortcuts,
|
||||
normalizeKeyForComparison,
|
||||
} from './reservedShortcuts.js'
|
||||
import type {
|
||||
KeybindingBlock,
|
||||
KeybindingContextName,
|
||||
ParsedBinding,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* 键绑定可能发生的验证问题类型。
|
||||
*/
|
||||
export type KeybindingWarningType =
|
||||
| 'parse_error'
|
||||
| 'duplicate'
|
||||
| 'reserved'
|
||||
| 'invalid_context'
|
||||
| 'invalid_action'
|
||||
|
||||
/**
|
||||
* 关于键绑定配置问题的警告或错误。
|
||||
*/
|
||||
export type KeybindingWarning = {
|
||||
type: KeybindingWarningType
|
||||
severity: 'error' | 'warning'
|
||||
message: string
|
||||
key?: string
|
||||
context?: string
|
||||
action?: string
|
||||
suggestion?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫,检查对象是否是有效的 KeybindingBlock。
|
||||
*/
|
||||
function isKeybindingBlock(obj: unknown): obj is KeybindingBlock {
|
||||
if (typeof obj !== 'object' || obj === null) return false
|
||||
const b = obj as Record<string, unknown>
|
||||
return (
|
||||
typeof b.context === 'string' &&
|
||||
typeof b.bindings === 'object' &&
|
||||
b.bindings !== null
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫,检查数组是否只包含有效的 KeybindingBlock。
|
||||
*/
|
||||
function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] {
|
||||
return Array.isArray(arr) && arr.every(isKeybindingBlock)
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效的键绑定上下文名称。
|
||||
* 必须与 types.ts 中的 KeybindingContextName 匹配
|
||||
*/
|
||||
const VALID_CONTEXTS: KeybindingContextName[] = [
|
||||
'Global',
|
||||
'Chat',
|
||||
'Autocomplete',
|
||||
'Confirmation',
|
||||
'Help',
|
||||
'Transcript',
|
||||
'HistorySearch',
|
||||
'Task',
|
||||
'ThemePicker',
|
||||
'Settings',
|
||||
'Tabs',
|
||||
'Attachments',
|
||||
'Footer',
|
||||
'MessageSelector',
|
||||
'DiffDialog',
|
||||
'ModelPicker',
|
||||
'Select',
|
||||
'Plugin',
|
||||
]
|
||||
|
||||
/**
|
||||
* 类型守卫,检查字符串是否是有效的上下文名称。
|
||||
*/
|
||||
function isValidContext(value: string): value is KeybindingContextName {
|
||||
return (VALID_CONTEXTS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证单个按键字符串并返回任何解析错误。
|
||||
*/
|
||||
function validateKeystroke(keystroke: string): KeybindingWarning | null {
|
||||
const parts = keystroke.toLowerCase().split('+')
|
||||
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (!trimmed) {
|
||||
return {
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: `空按键部分:"${keystroke}"`,
|
||||
key: keystroke,
|
||||
suggestion: '删除额外的 "+" 字符',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试解析并查看是否失败
|
||||
const parsed = parseKeystroke(keystroke)
|
||||
if (
|
||||
!parsed.key &&
|
||||
!parsed.ctrl &&
|
||||
!parsed.alt &&
|
||||
!parsed.shift &&
|
||||
!parsed.meta
|
||||
) {
|
||||
return {
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: `无法解析按键:"${keystroke}"`,
|
||||
key: keystroke,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证来自用户配置的键绑定块。
|
||||
*/
|
||||
function validateBlock(
|
||||
block: unknown,
|
||||
blockIndex: number,
|
||||
): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
|
||||
if (typeof block !== 'object' || block === null) {
|
||||
warnings.push({
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: `键绑定块 ${blockIndex + 1} 不是对象`,
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
const b = block as Record<string, unknown>
|
||||
|
||||
// 验证上下文 - 提取为缩小变量以保证类型安全
|
||||
const rawContext = b.context
|
||||
let contextName: string | undefined
|
||||
if (typeof rawContext !== 'string') {
|
||||
warnings.push({
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: `键绑定块 ${blockIndex + 1} 缺少 "context" 字段`,
|
||||
})
|
||||
} else if (!isValidContext(rawContext)) {
|
||||
warnings.push({
|
||||
type: 'invalid_context',
|
||||
severity: 'error',
|
||||
message: `未知的上下文:"${rawContext}"`,
|
||||
context: rawContext,
|
||||
suggestion: `有效的上下文:${VALID_CONTEXTS.join(', ')}`,
|
||||
})
|
||||
} else {
|
||||
contextName = rawContext
|
||||
}
|
||||
|
||||
// 验证绑定
|
||||
if (typeof b.bindings !== 'object' || b.bindings === null) {
|
||||
warnings.push({
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: `键绑定块 ${blockIndex + 1} 缺少 "bindings" 字段`,
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
const bindings = b.bindings as Record<string, unknown>
|
||||
for (const [key, action] of Object.entries(bindings)) {
|
||||
// 验证键语法
|
||||
const keyError = validateKeystroke(key)
|
||||
if (keyError) {
|
||||
keyError.context = contextName
|
||||
warnings.push(keyError)
|
||||
}
|
||||
|
||||
// 验证操作
|
||||
if (action !== null && typeof action !== 'string') {
|
||||
warnings.push({
|
||||
type: 'invalid_action',
|
||||
severity: 'error',
|
||||
message: `"${key}" 的操作无效:必须是字符串或 null`,
|
||||
key,
|
||||
context: contextName,
|
||||
})
|
||||
} else if (typeof action === 'string' && action.startsWith('command:')) {
|
||||
// 验证命令绑定格式
|
||||
if (!/^command:[a-zA-Z0-9:\-_]+$/.test(action)) {
|
||||
warnings.push({
|
||||
type: 'invalid_action',
|
||||
severity: 'warning',
|
||||
message: `"${key}" 的命令绑定 "${action}" 无效:命令名只能包含字母数字字符、冒号、连字符和下划线`,
|
||||
key,
|
||||
context: contextName,
|
||||
action,
|
||||
})
|
||||
}
|
||||
// 命令绑定必须在聊天上下文中
|
||||
if (contextName && contextName !== 'Chat') {
|
||||
warnings.push({
|
||||
type: 'invalid_action',
|
||||
severity: 'warning',
|
||||
message: `命令绑定 "${action}" 必须在 "Chat" 上下文中,而不是 "${contextName}"`,
|
||||
key,
|
||||
context: contextName,
|
||||
action,
|
||||
suggestion: '将此绑定移动到具有 "context": "Chat" 的块',
|
||||
})
|
||||
}
|
||||
} else if (action === 'voice:pushToTalk') {
|
||||
// 按键检测需要操作系统自动重复。裸字母在预热期间会打印到输入中,
|
||||
// 激活条是尽力的 — 空格或像 meta+k 这样的修饰符组合可以避免这种情况。
|
||||
const ks = parseChord(key)[0]
|
||||
if (
|
||||
ks &&
|
||||
!ks.ctrl &&
|
||||
!ks.alt &&
|
||||
!ks.shift &&
|
||||
!ks.meta &&
|
||||
!ks.super &&
|
||||
/^[a-z]$/.test(ks.key)
|
||||
) {
|
||||
warnings.push({
|
||||
type: 'invalid_action',
|
||||
severity: 'warning',
|
||||
message: `将 "${key}" 绑定到 voice:pushToTalk 会在预热期间打印到输入中;使用空格或像 meta+k 这样的修饰符组合`,
|
||||
key,
|
||||
context: contextName,
|
||||
action,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 JSON 字符串中同一绑定块内的重复键。
|
||||
* JSON.parse 静默使用重复键的最后一个值,
|
||||
* 因此我们需要检查原始字符串以警告用户。
|
||||
*
|
||||
* 仅警告同一上下文的绑定对象内的重复。
|
||||
* 不同上下文之间的重复是允许的(例如 "enter" 在 Chat 和 Confirmation 中)。
|
||||
*/
|
||||
export function checkDuplicateKeysInJson(
|
||||
jsonString: string,
|
||||
): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
|
||||
// 找到每个 "bindings" 块并检查其内的重复
|
||||
// 模式:"bindings" : { ... }
|
||||
const bindingsBlockPattern =
|
||||
/"bindings"\s*:\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g
|
||||
|
||||
let blockMatch
|
||||
while ((blockMatch = bindingsBlockPattern.exec(jsonString)) !== null) {
|
||||
const blockContent = blockMatch[1]
|
||||
if (!blockContent) continue
|
||||
|
||||
// 通过向后查找来找到此块的上下文
|
||||
const textBeforeBlock = jsonString.slice(0, blockMatch.index)
|
||||
const contextMatch = textBeforeBlock.match(
|
||||
/"context"\s*:\s*"([^"]+)"[^{]*$/,
|
||||
)
|
||||
const context = contextMatch?.[1] ?? 'unknown'
|
||||
|
||||
// 找到此绑定块内的所有键
|
||||
const keyPattern = /"([^"]+)"\s*:/g
|
||||
const keysByName = new Map<string, number>()
|
||||
|
||||
let keyMatch
|
||||
while ((keyMatch = keyPattern.exec(blockContent)) !== null) {
|
||||
const key = keyMatch[1]
|
||||
if (!key) continue
|
||||
|
||||
const count = (keysByName.get(key) ?? 0) + 1
|
||||
keysByName.set(key, count)
|
||||
|
||||
if (count === 2) {
|
||||
// 仅在第二次出现时警告
|
||||
warnings.push({
|
||||
type: 'duplicate',
|
||||
severity: 'warning',
|
||||
message: `${context} 绑定中有重复键 "${key}"`,
|
||||
key,
|
||||
context,
|
||||
suggestion: `此键在同一上下文中出现多次。JSON 使用最后一个值,较早的值被忽略。`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证用户键绑定配置并返回所有警告。
|
||||
*/
|
||||
export function validateUserConfig(userBlocks: unknown): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
|
||||
if (!Array.isArray(userBlocks)) {
|
||||
warnings.push({
|
||||
type: 'parse_error',
|
||||
severity: 'error',
|
||||
message: 'keybindings.json 必须包含一个数组',
|
||||
suggestion: '将您的绑定包装在 [ ] 中',
|
||||
})
|
||||
return warnings
|
||||
}
|
||||
|
||||
for (let i = 0; i < userBlocks.length; i++) {
|
||||
warnings.push(...validateBlock(userBlocks[i], i))
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查同一上下文内的重复绑定。
|
||||
* 仅检查用户绑定(不是默认 + 用户合并)。
|
||||
*/
|
||||
export function checkDuplicates(
|
||||
blocks: KeybindingBlock[],
|
||||
): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
const seenByContext = new Map<string, Map<string, string>>()
|
||||
|
||||
for (const block of blocks) {
|
||||
const contextMap =
|
||||
seenByContext.get(block.context) ?? new Map<string, string>()
|
||||
seenByContext.set(block.context, contextMap)
|
||||
|
||||
for (const [key, action] of Object.entries(block.bindings)) {
|
||||
const normalizedKey = normalizeKeyForComparison(key)
|
||||
const existingAction = contextMap.get(normalizedKey)
|
||||
|
||||
if (existingAction && existingAction !== action) {
|
||||
warnings.push({
|
||||
type: 'duplicate',
|
||||
severity: 'warning',
|
||||
message: `${block.context} 上下文中有重复绑定 "${key}"`,
|
||||
key,
|
||||
context: block.context,
|
||||
action: action ?? 'null (unbind)',
|
||||
suggestion: `之前绑定到 "${existingAction}"。只会使用最后一个绑定。`,
|
||||
})
|
||||
}
|
||||
|
||||
contextMap.set(normalizedKey, action ?? 'null')
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查可能不工作的保留快捷键。
|
||||
*/
|
||||
export function checkReservedShortcuts(
|
||||
bindings: ParsedBinding[],
|
||||
): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
const reserved = getReservedShortcuts()
|
||||
|
||||
for (const binding of bindings) {
|
||||
const keyDisplay = chordToString(binding.chord)
|
||||
const normalizedKey = normalizeKeyForComparison(keyDisplay)
|
||||
|
||||
// 检查保留快捷键
|
||||
for (const res of reserved) {
|
||||
if (normalizeKeyForComparison(res.key) === normalizedKey) {
|
||||
warnings.push({
|
||||
type: 'reserved',
|
||||
severity: res.severity,
|
||||
message: `"${keyDisplay}" 可能不工作:${res.reason}`,
|
||||
key: keyDisplay,
|
||||
context: binding.context,
|
||||
action: binding.action ?? undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户块解析为绑定以进行验证。
|
||||
* 这与主解析器分开以避免导入它。
|
||||
*/
|
||||
function getUserBindingsForValidation(
|
||||
userBlocks: KeybindingBlock[],
|
||||
): ParsedBinding[] {
|
||||
const bindings: ParsedBinding[] = []
|
||||
for (const block of userBlocks) {
|
||||
for (const [key, action] of Object.entries(block.bindings)) {
|
||||
const chord = key.split(' ').map(k => parseKeystroke(k))
|
||||
bindings.push({
|
||||
chord,
|
||||
action,
|
||||
context: block.context,
|
||||
})
|
||||
}
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行所有验证并返回合并的警告。
|
||||
*/
|
||||
export function validateBindings(
|
||||
userBlocks: unknown,
|
||||
_parsedBindings: ParsedBinding[],
|
||||
): KeybindingWarning[] {
|
||||
const warnings: KeybindingWarning[] = []
|
||||
|
||||
// 验证用户配置结构
|
||||
warnings.push(...validateUserConfig(userBlocks))
|
||||
|
||||
// 检查用户配置中的重复
|
||||
if (isKeybindingBlockArray(userBlocks)) {
|
||||
warnings.push(...checkDuplicates(userBlocks))
|
||||
|
||||
// 检查保留/冲突快捷键 - 仅检查用户绑定
|
||||
const userBindings = getUserBindingsForValidation(userBlocks)
|
||||
warnings.push(...checkReservedShortcuts(userBindings))
|
||||
}
|
||||
|
||||
// 去重警告(相同的 key+context+type)
|
||||
const seen = new Set<string>()
|
||||
return warnings.filter(w => {
|
||||
const key = `${w.type}:${w.key}:${w.context}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化警告以显示给用户。
|
||||
*/
|
||||
export function formatWarning(warning: KeybindingWarning): string {
|
||||
const icon = warning.severity === 'error' ? '✗' : '⚠'
|
||||
let msg = `${icon} 键绑定 ${warning.severity}:${warning.message}`
|
||||
|
||||
if (warning.suggestion) {
|
||||
msg += `\n ${warning.suggestion}`
|
||||
}
|
||||
|
||||
return msg
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化多个警告。
|
||||
*/
|
||||
export function formatWarnings(warnings: KeybindingWarning[]): string {
|
||||
if (warnings.length === 0) return ''
|
||||
|
||||
const errors = warnings.filter(w => w.severity === 'error')
|
||||
const warns = warnings.filter(w => w.severity === 'warning')
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
if (errors.length > 0) {
|
||||
lines.push(
|
||||
`发现 ${errors.length} 个键绑定 ${plural(errors.length, 'error')}:`,
|
||||
)
|
||||
for (const e of errors) {
|
||||
lines.push(formatWarning(e))
|
||||
}
|
||||
}
|
||||
|
||||
if (warns.length > 0) {
|
||||
if (lines.length > 0) lines.push('')
|
||||
lines.push(
|
||||
`发现 ${warns.length} 个键绑定 ${plural(warns.length, 'warning')}:`,
|
||||
)
|
||||
for (const w of warns) {
|
||||
lines.push(formatWarning(w))
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
Reference in New Issue
Block a user