diff --git a/docs/复刻待办清单-2026-10-17.md b/docs/复刻待办清单-2026-10-17.md index 1acb0f4..2086a25 100644 --- a/docs/复刻待办清单-2026-10-17.md +++ b/docs/复刻待办清单-2026-10-17.md @@ -372,11 +372,39 @@ ### 12. 快捷键页面与真实功能 -- [ ] 拆解页面布局、分组、按键显示、编辑/恢复默认等实际存在的交互。 -- [ ] 建立快捷键 → 命令 → 实际动作映射,按 REA 证据确认支持范围。 -- [ ] 验收:展示按键与实际绑定一致;输入框、编辑器、弹层、独立窗口中的作用域和冲突处理正确;可修改项持久化。 +- [x] 拆解页面布局、分组、按键显示、编辑/恢复默认等实际存在的交互。 +- [x] 建立快捷键 → 命令 → 实际动作映射:24 行中 14 行已真实绑定,其余在 tooltip 明确标注未接入。 +- [x] 验收:展示按键与实际绑定一致;搜索、录制态/取消、深色主题与 5 个新全局键位通过真实窗口回归。可修改项持久化未接入(见遗留)。 -状态:待 REA 拆解与命令绑定盘点。 +状态:已实现并通过真实窗口回归(32 项断言)。原版 24 行全部按 1:1 呈现,其中 14 行在 YCode 真实生效。 + +原版证据: +- REA `ev_5f3148eb51ea4a6e5b6f2ffea5f6e3dfb94e4009ecf2d16173fa676ce7c51b4b`(WorkBuddy 5.8.3,设置窗口 `6D5A7FEE…`,900×680):快捷键页浅色顶部布局、578 个 DOM 节点、计算样式与模块样式表 `keyboard-shortcuts-DOrivHwf.css`(sha256 `d2c570c8d65db87c1cb9df06b59e6aa191b772330857376b85fd923aed580ae8`,8,430 字节)。 +- REA `ev_7266946b025216056fc67f7c6d3c991894e985b6a8486a3153c3508e21771f73`:四段滚动采样(顶/中/底/回顶),覆盖全部 24 行。 +- REA `ev_b3e014e5cff4971d7c5e75fefbbfefb0088db9ce655ef7ff33114cd3c4caead9`:搜索「语音」→ 单行、禁用「全部恢复默认」、点击绑定进入「按下快捷键 / ESC 取消」、Esc 取消后保留 `Ctrl+K`。 +- REA `ev_f4fd417445481ef93c34a7e85be45e63327daa2acbccaf169706d7e19bacc531`(深色同页布局)与 `ev_528724699462fa08e2e4119bb8504c0180483c86cd844461cc969422b915650b`(最终浅色顶部稳定态)。 +- 页面结构:无分组标题的平面表格,列名 命令 / 按键绑定 / 操作。面板 `[232,40,638,620]`;toolbar `[232,40,638,32]`、搜索 `[232,40,516,32]`、恢复默认 `[760,40,110,32]`;列表 `[232,88,638,572]`、表头 39px、滚动区 `[232,127,638,533]`;行宽 623,前 16 行 41px、17–23 行 45px、末行 44px;键帽高 22px。 +- 24 行命令与键位:打开全局搜索 `Ctrl+K`、打开设置 `Ctrl+,`、语音录制开关 `长按 Alt`、对话内搜索 `Ctrl+F`、全局搜索 `Ctrl+K`、发送消息 `Enter`、输入时换行 `Shift+Enter`、新建对话 `Ctrl+N`、停止生成 `Esc`、上一个任务 `Ctrl+[`、下一个任务 `Ctrl+]`、定位当前任务 `Ctrl+Shift+E`、切换左侧栏 `Ctrl+B`、切换右侧栏 `Ctrl+Shift+B`、进入/退出全屏 `F11`、唤起/隐藏主窗口 `Shift+Alt+W`、放大内容字号 `Ctrl+"+"`、缩小内容字号 `Ctrl+"-"`、重置内容字号 `Ctrl+0`、唤起选择器 `@`、唤起斜杠命令 `/`、按住后点击始终外部打开 `Ctrl`、新建标签页 `Ctrl+T`、关闭标签页 `Ctrl+W`。 +- 深浅差异:几何不变;列表/行背景 `#fff → rgb(31,31,31)`,正文 `rgb(0,0,0) → rgb(242,243,245)`,键帽由 `#f2f2f2` 变为 8% 白色填充;只读行键帽不透明度 0.5。 +- 观察范围:桌面端 900×680 设置窗口浅色 + 深色抽查;未覆盖:单条删除按钮的实际点击结果、空搜索结果的原文案、复合主题切换的单次连续采集(深色/浅色点击各遇到一次 `target_changed`,已用独立稳定采样确认)。 + +实现: +- `scripts/rea/recover-shortcuts-panel.mjs`:从 `ev_5f3148…` 投影 49 条 `.keyboard-shortcuts-panel*` / `.shortcut-item*` 模块规则(含 `recording-cursor-blink` 关键帧),深色选择器改写为 `[data-theme=dark]`,按引用闭包复制 53 个浅色/53 个深色作用域 token,生成 `source-shortcuts-panel-recovered.css`(12,540 字节,sha256 `414aa003…`,两次运行一致)。 +- `src/renderer/src/components/shortcuts-catalog.ts`:24 行命令/键位/可编辑性,以及 `supported` 真实绑定标记(14 行为 true)。 +- `src/renderer/src/components/ShortcutsSettingsPanel.tsx`:工具栏(搜索 + 禁用「全部恢复默认」)、三列表头、24 行表格、前 16 行删除按钮(禁用)、后 8 行 `shortcut-item--not-editable` + 「—」、点击绑定进入「按下快捷键 / ESC 取消」、捕获按键时提示「自定义键位尚未接入,已保留默认绑定」、真实搜索过滤与空结果态。 +- `src/renderer/src/components/source-shortcuts-panel.css`:import 回收样式 + 本地适配原版 UI kit 的输入/按钮基件(浅色取值来自 REA computed style:搜索框 32 高/radius 8/`#e6e6e6` 边、恢复默认按钮 110×32、删除按钮 24×24),深色下文色按 REA 实测 `rgb(242,243,245)`。 +- `SettingsDialog.tsx`:`keyboardShortcuts` 加入 supportedTabs 并渲染面板。 +- `App.tsx`:新增全局键位——`Ctrl+K` 展开侧栏并聚焦搜索、`Ctrl+B` / `Ctrl+Shift+B` 切换左/右侧栏、`Ctrl+N` 新建对话、生成中 `Esc` 停止;`Sidebar.tsx` 监听 `ycode:new-task` 复用「新建任务」路径,并补上搜索框挂载后的聚焦(修复 rAF 早于条件渲染导致焦点丢失)。 + +验证:`scripts/verify-shortcuts-settings.cjs`(真实隔离 Electron + 原生设置窗口,900×680)32 项断言全部通过:导航启用/选中;面板、工具栏、搜索、恢复默认、列表、表头、滚动区几何;三列表头文案;24 行文案/键位顺序;41/45/44 行高与 22px 键帽;前 16 行删除按钮与可编辑、后 8 行不可编辑且「—」;`长按 Alt` 拆分;14 个 supported id 精确匹配;未接入行 tooltip 含「尚未接入」;搜索「语音」筛为 1 行、无匹配显示空态、清空恢复 24 行;录音态文案与光标、Esc 取消保留 `Ctrl+K`、按键捕获提示未接入;深色几何不变且行底 `rgb(31,31,31)`、正文 `rgb(242,243,245)`、键帽 8% 白;恢复浅色正确。主窗口真实按键:`Ctrl+B` 折叠/展开并写入 `localStorage`、`Ctrl+K` 聚焦 `.sidebar__search-input`、`Ctrl+Shift+B` 开/关右栏、`Ctrl+N` 触发新建任务通路。报告 `.tmp/shortcuts-settings-neCeel/report.json`。 +回归:`npm run typecheck`、`npm run build`、`npm run test:chat`、`npm run test:conversation`、`npm run test:conversation-ipc`、`node scripts/verify-settings-nav.cjs`、`node scripts/verify-appearance-settings.cjs`、`node scripts/verify-general-settings.cjs`、`node scripts/verify-desktop-appearance.cjs`、`node scripts/verify-account-menu.cjs` 全部通过。 + +遗留: +1. 自定义键位编辑/单条删除/整表恢复默认未接入:YCode 尚无键位配置存储与命令注册表,页面保持原版控件形态但按钮禁用,捕获按键后明确提示「自定义键位尚未接入」,不伪造成功,也未宣称可持久化。 +2. 未真实接入的 10 行(语音录制、对话内搜索、上一个/下一个任务、定位当前任务、全屏、唤起/隐藏主窗口、`@` 选择器、`/` 斜杠命令、`Ctrl` 外部打开)只还原原版展示,tooltip 标注「YCode 尚未接入」。 +3. `Ctrl+T` / `Ctrl+W` 为工作台焦点内作用域绑定,`Ctrl+K` 落到侧栏任务搜索,与原版「全局搜索」的服务端检索范围不同(云端服务本轮不处理)。 +4. 深色下 toolbar 输入框/按钮来自本地适配(原版把 UI kit 放在独立样式表,未随该页模块导出),已按 tokens 深色取值还原。 +5. 空搜索结果文案「未找到匹配的快捷键」是 YCode 本地文案,原版空态未采集。 ### 13. 个性化页面与真实功能 diff --git a/scripts/rea/probe-todo12-appearance-for-theme.json b/scripts/rea/probe-todo12-appearance-for-theme.json new file mode 100644 index 0000000..ae4bbc3 --- /dev/null +++ b/scripts/rea/probe-todo12-appearance-for-theme.json @@ -0,0 +1,13 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + ".settings-navigation__item", + ".appearance-panel", + ".appearance-card" + ], + "actions": [ + { "kind": "click", "step_id": "open-appearance-for-theme-sample", "selector": ".settings-navigation__item[data-settings-tab=\"appearance\"]", "index": 0, "frames": 0 }, + { "kind": "wait", "step_id": "appearance-theme-control-ready", "duration_ms": 400, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-todo12-open-appearance-dark.json b/scripts/rea/probe-todo12-open-appearance-dark.json new file mode 100644 index 0000000..b836e2f --- /dev/null +++ b/scripts/rea/probe-todo12-open-appearance-dark.json @@ -0,0 +1,13 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + ".settings-navigation__item", + ".appearance-panel", + ".appearance-card" + ], + "actions": [ + { "kind": "click", "step_id": "open-appearance-to-restore-light", "selector": ".settings-navigation__item", "index": 3, "frames": 0 }, + { "kind": "wait", "step_id": "appearance-dark-ready-to-restore", "duration_ms": 400, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-todo12-open-shortcuts-dark.json b/scripts/rea/probe-todo12-open-shortcuts-dark.json new file mode 100644 index 0000000..906ac42 --- /dev/null +++ b/scripts/rea/probe-todo12-open-shortcuts-dark.json @@ -0,0 +1,15 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + ".settings-modal__panel", + ".keyboard-shortcuts-panel", + ".keyboard-shortcuts-panel__list-scroll", + ".shortcut-item", + ".shortcut-item__key" + ], + "actions": [ + { "kind": "click", "step_id": "open-shortcuts-in-dark-theme", "selector": ".settings-navigation__item", "index": 4, "frames": 0 }, + { "kind": "wait", "step_id": "shortcuts-dark-ready", "duration_ms": 400, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-todo12-open-shortcuts-light.json b/scripts/rea/probe-todo12-open-shortcuts-light.json new file mode 100644 index 0000000..b418f88 --- /dev/null +++ b/scripts/rea/probe-todo12-open-shortcuts-light.json @@ -0,0 +1,16 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + ".settings-navigation__item", + ".settings-modal__panel", + ".keyboard-shortcuts-panel", + ".keyboard-shortcuts-panel__list-scroll", + ".shortcut-item", + ".shortcut-item__key" + ], + "actions": [ + { "kind": "click", "step_id": "return-to-shortcuts-light", "selector": ".settings-navigation__item", "index": 4, "frames": 0 }, + { "kind": "wait", "step_id": "shortcuts-light-restored", "duration_ms": 400, "frames": 1 } + ] +} diff --git a/scripts/rea/probe-todo12-restore-light.json b/scripts/rea/probe-todo12-restore-light.json new file mode 100644 index 0000000..ed021c0 --- /dev/null +++ b/scripts/rea/probe-todo12-restore-light.json @@ -0,0 +1,8 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [], + "actions": [ + { "kind": "click", "step_id": "restore-light-theme", "selector": ".appearance-card--light", "index": 0, "frames": 0 } + ] +} diff --git a/scripts/rea/probe-todo12-select-dark.json b/scripts/rea/probe-todo12-select-dark.json new file mode 100644 index 0000000..9795a3e --- /dev/null +++ b/scripts/rea/probe-todo12-select-dark.json @@ -0,0 +1,8 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [], + "actions": [ + { "kind": "click", "step_id": "select-dark-theme", "selector": ".appearance-card--dark", "index": 0, "frames": 0 } + ] +} diff --git a/scripts/rea/probe-todo12-shortcuts-interactions.json b/scripts/rea/probe-todo12-shortcuts-interactions.json new file mode 100644 index 0000000..f5fc937 --- /dev/null +++ b/scripts/rea/probe-todo12-shortcuts-interactions.json @@ -0,0 +1,22 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + ".keyboard-shortcuts-panel__toolbar", + ".keyboard-shortcuts-panel__search", + ".keyboard-shortcuts-panel__list", + ".shortcut-item", + ".shortcut-item__binding-tooltip-anchor", + ".wb-button" + ], + "actions": [ + { "kind": "wait", "step_id": "shortcuts-interaction-baseline", "duration_ms": 200, "frames": 1 }, + { "kind": "text", "step_id": "search-voice-shortcut", "selector": ".keyboard-shortcuts-panel__search input", "text": "语音", "frames": 0 }, + { "kind": "wait", "step_id": "search-voice-result", "duration_ms": 200, "frames": 1 }, + { "kind": "text", "step_id": "clear-shortcut-search", "selector": ".keyboard-shortcuts-panel__search input", "text": "", "frames": 0 }, + { "kind": "wait", "step_id": "search-cleared", "duration_ms": 200, "frames": 1 }, + { "kind": "click", "step_id": "click-disabled-restore-defaults", "selector": ".keyboard-shortcuts-panel__toolbar .wb-button--disabled", "index": 0, "frames": 1 }, + { "kind": "click", "step_id": "open-shortcut-key-editor", "selector": "#settings-keyboard-shortcut-open-global-search .shortcut-item__binding-tooltip-anchor", "index": 0, "frames": 1 }, + { "kind": "key", "step_id": "cancel-shortcut-key-editor", "key": "Escape", "frames": 1 } + ] +} diff --git a/scripts/rea/probe-todo12-shortcuts.json b/scripts/rea/probe-todo12-shortcuts.json new file mode 100644 index 0000000..846127e --- /dev/null +++ b/scripts/rea/probe-todo12-shortcuts.json @@ -0,0 +1,22 @@ +{ + "include_fonts": false, + "include_message_content": false, + "track_selectors": [ + "html", + "body", + ".settings-modal__panel", + ".settings-navigation", + ".settings-navigation__item", + ".keyboard-shortcuts-panel", + ".keyboard-shortcuts-panel__list-scroll", + ".shortcut-item", + ".shortcut-item__label", + ".shortcut-item__key" + ], + "actions": [ + { "kind": "wait", "step_id": "shortcuts-top", "duration_ms": 300, "frames": 1 }, + { "kind": "scroll", "step_id": "shortcuts-scroll-middle", "selector": ".keyboard-shortcuts-panel__list-scroll", "delta_y": 250, "frames": 1 }, + { "kind": "scroll", "step_id": "shortcuts-scroll-bottom", "selector": ".keyboard-shortcuts-panel__list-scroll", "delta_y": 250, "frames": 1 }, + { "kind": "scroll", "step_id": "shortcuts-return-top", "selector": ".keyboard-shortcuts-panel__list-scroll", "delta_y": -1000, "frames": 1 } + ] +} diff --git a/scripts/rea/recover-shortcuts-panel.mjs b/scripts/rea/recover-shortcuts-panel.mjs new file mode 100644 index 0000000..89566a5 --- /dev/null +++ b/scripts/rea/recover-shortcuts-panel.mjs @@ -0,0 +1,86 @@ +// Recover only already-retained REA CSS; never access the installed application here. +// Source: REA evidence ev_5f3148eb51ea4a6e5b6f2ffea5f6e3dfb94e4009ecf2d16173fa676ce7c51b4b +// (WorkBuddy 5.8.3 设置 → 快捷键), keyboard-shortcuts-DOrivHwf.css. +import fs from 'node:fs/promises' +import { createHash } from 'node:crypto' +import postcss from 'postcss' + +const sha = value => createHash('sha256').update(value).digest('hex') +const evidenceId = 'ev_5f3148eb51ea4a6e5b6f2ffea5f6e3dfb94e4009ecf2d16173fa676ce7c51b4b' +const evidence = JSON.parse(await fs.readFile(`.tmp/rea-css-evidence/${evidenceId}.json`, 'utf8')) +if (evidence.evidence_id !== evidenceId) throw new Error('REA identity mismatch') + +const module = evidence.normalized_result.styles.items.find(item => /keyboard-shortcuts-DOrivHwf\.css$/.test(item.file_path)) +if (!module) throw new Error('Keyboard shortcuts module CSS is absent from the evidence') +const moduleCss = module.source.artifact.text +const moduleSha = sha(moduleCss) +if (moduleSha !== 'd2c570c8d65db87c1cb9df06b59e6aa191b772330857376b85fd923aed580ae8') throw new Error('Keyboard shortcuts module CSS changed') + +const tokens = await fs.readFile('.tmp/rea-css-recovered/tokens-Df-i-zsO.css', 'utf8') +const tokensSha = sha(tokens) +if (tokensSha !== '13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696') throw new Error('Tokens CSS changed') + +const lightTokens = new Map() +const darkTokens = new Map() +for (const rule of postcss.parse(tokens).nodes) { + if (rule.type !== 'rule') continue + const selectors = rule.selectors ?? [] + const dark = selectors.some(selector => selector.includes('[data-theme=dark]')) + const light = selectors.some(selector => selector.trim() === ':root') + if (!dark && !light) continue + for (const node of rule.nodes ?? []) { + if (node.type !== 'decl' || !node.prop.startsWith('--')) continue + if (dark) darkTokens.set(node.prop, node.value) + if (light) lightTokens.set(node.prop, node.value) + } +} + +const relatedSelector = /\.(?:keyboard-shortcuts-panel|shortcut-item)(?:[\w-]*)?(?![\w-])/i +const darkScope = /^\s*(?:\[data-theme=["']?dark["']?\]|body\[data-vscode-theme-name=["']IDE Night["']\]|body\.vscode-dark|\.vscode-dark|body\.dark|\.dark)(?=\s|$)/i +function project(node) { + if (node.type === 'rule') { + const selectors = [] + for (const selector of node.selectors) { + if (!relatedSelector.test(selector)) continue + const mapped = selector.replace(darkScope, '[data-theme=dark]') + if (!selectors.includes(mapped)) selectors.push(mapped) + } + return selectors.length ? node.clone({ selector: selectors.join(',') }) : null + } + if (node.type === 'atrule' && node.nodes) { + if (node.name === 'keyframes' && /recording-cursor-blink/.test(node.params)) return node.clone() + const nodes = node.nodes.map(project).filter(Boolean) + return nodes.length ? node.clone({ nodes }) : null + } + return null +} + +const root = postcss.root({ nodes: postcss.parse(moduleCss).nodes.map(project).filter(Boolean) }) +const referenced = new Set() +const collect = text => { + for (const match of text.matchAll(/var\(\s*(--[\w-]+)/g)) referenced.add(match[1]) +} +collect(root.toString()) +for (let depth = 0; depth < 8; depth++) { + const before = referenced.size + for (const name of [...referenced]) { + for (const value of [lightTokens.get(name), darkTokens.get(name)]) { + if (typeof value === 'string') collect(value) + } + } + if (referenced.size === before) break +} + +const missingLight = [...referenced].filter(name => !lightTokens.has(name)) +if (missingLight.length) throw new Error(`Tokens missing from light root scope: ${missingLight.join(', ')}`) +const pick = map => new Map([...referenced].map(name => [name, map.get(name) ?? lightTokens.get(name)])) +const scope = (selector, map) => `${selector}{${[...map].map(([property, value]) => `${property}:${value}`).join(';')}}` +const scopedTokens = `${scope('.keyboard-shortcuts-panel', pick(lightTokens))}\n${scope(':root[data-theme=dark] .keyboard-shortcuts-panel', pick(darkTokens))}\n` +const css = `/* REA ${evidenceId}; keyboard-shortcuts-DOrivHwf.css sha256=${moduleSha}; tokens sha256=${tokensSha}. */\n${root.toString()}\n${scopedTokens}` +await fs.writeFile('src/renderer/src/components/source-shortcuts-panel-recovered.css', css) + +const rules = [] +root.walkRules(rule => rules.push(rule.selector)) +console.log(`Recovered ${rules.length} shortcuts rules (${Buffer.byteLength(css)} bytes); scoped ${pick(lightTokens).size} light / ${pick(darkTokens).size} dark tokens`) +console.log(rules.slice(0, 12).join('\n')) +if (rules.length < 20) throw new Error('Unexpectedly few shortcuts rules') diff --git a/scripts/verify-settings-nav.cjs b/scripts/verify-settings-nav.cjs index 23397f0..7754f56 100644 --- a/scripts/verify-settings-nav.cjs +++ b/scripts/verify-settings-nav.cjs @@ -125,10 +125,10 @@ if (!process.versions.electron) { // 原版 4 组;about 组按组件约定渲染为分隔线而不是标题。 check('nav keeps the four recovered groups', [nav.groups.length, nav.dividers], [3, 1]) // 通用(settings) 在 TODO 10 接入后同样可用。 - const expectedDisabled = EXPECTED_TABS.filter(tab => !['settings', 'models', 'appearance'].includes(tab)) + const expectedDisabled = EXPECTED_TABS.filter(tab => !['settings', 'models', 'appearance', 'keyboardShortcuts'].includes(tab)) check('unwired entries stay disabled', nav.tabs.filter((tab, index) => nav.disabled[index] === 'true'), expectedDisabled) check('unwired entries keep the 暂未接入 hint', nav.titles.filter(title => (title ?? '').includes('暂未接入')).length, expectedDisabled.length) - check('settings, models and appearance stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['settings', 'appearance', 'models']) + check('settings, appearance, keyboard shortcuts and models stay enabled', nav.tabs.filter((tab, index) => !nav.disabled[index]), ['settings', 'appearance', 'keyboardShortcuts', 'models']) await step('settings-search-excludes-removed') await read(`(()=>{const input=document.querySelector('.settings-navigation input[type="search"]'); diff --git a/scripts/verify-shortcuts-settings.cjs b/scripts/verify-shortcuts-settings.cjs new file mode 100644 index 0000000..1d3814f --- /dev/null +++ b/scripts/verify-shortcuts-settings.cjs @@ -0,0 +1,288 @@ +// TODO 12 验收:设置 → 快捷键页的 1:1 结构/几何 + 搜索/录制/取消 + 深浅主题 + 真实绑定标注。 +// 真实隔离 YCode(真实 preload/IPC/渲染进程/文件系统)+ 原生设置窗口,视口 900×680(与原版一致)。 +// +// 原版依据(REA ev_5f3148…/ev_f4fd41…/ev_b3e014…/ev_528724…): +// `.keyboard-shortcuts-panel` [232,40,638,620]、toolbar 638×32、search 516×32、恢复默认 110×32、 +// list [232,88,638,572]、header 39、scroll [232,127,638,533];24 行(1–16 高 41、17–23 高 45、末行 44), +// 键帽高 22;前 16 行可编辑并有删除按钮,后 8 行 not-editable 且操作列为「—」。 +const fs = require('node:fs/promises') +const path = require('node:path') +const assert = require('node:assert/strict') + +const EXPECTED_LABELS = ['打开全局搜索', '打开设置', '语音录制开关', '对话内搜索', '全局搜索', '发送消息', + '输入时换行', '新建对话', '停止生成', '上一个任务', '下一个任务', '定位当前任务', '切换左侧栏', '切换右侧栏', + '进入/退出全屏', '唤起/隐藏主窗口', '放大内容字号', '缩小内容字号', '重置内容字号', '唤起选择器', + '唤起斜杠命令', '按住后点击,始终外部打开', '新建标签页', '关闭标签页'] +const EXPECTED_KEYS = ['Ctrl+K', 'Ctrl+,', 'Alt', 'Ctrl+F', 'Ctrl+K', 'Enter', 'Shift+Enter', 'Ctrl+N', 'Esc', + 'Ctrl+[', 'Ctrl+]', 'Ctrl+Shift+E', 'Ctrl+B', 'Ctrl+Shift+B', 'F11', 'Shift+Alt+W', 'Ctrl+"+"', 'Ctrl+"-"', + 'Ctrl+0', '@', '/', 'Ctrl', 'Ctrl+T', 'Ctrl+W'] +const SUPPORTED_IDS = ['global-search-open', 'open-settings', 'global-search', 'send-message', 'newline', + 'new-conversation', 'stop-generation', 'toggle-left-sidebar', 'toggle-right-sidebar', 'font-increase', + 'font-decrease', 'font-reset', 'new-tab', 'close-tab'] + +if (!process.versions.electron) { + ;(async () => { + const directory = await fs.mkdtemp(path.resolve('.tmp/shortcuts-settings-')) + const data = path.join(directory, 'data'), home = path.join(directory, 'home') + await fs.mkdir(data, { recursive: true }) + await fs.mkdir(home, { recursive: true }) + const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_OMP_HOME: home, + YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' } + delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL + const child = require('node:child_process').spawn(require('electron'), + ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '' + child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('SHORTCUTS_SETTINGS_')) process.stdout.write(bytes) }) + child.stderr.on('data', bytes => { output += bytes }) + const timer = setTimeout(() => child.kill(), 300000) + child.once('exit', async code => { + clearTimeout(timer) + await fs.writeFile(path.join(directory, 'process.log'), output) + console.log(JSON.stringify({ directory, exitCode: code })) + if (code) console.error(output.slice(-4000)) + process.exitCode = code ? 1 : 0 + }) + })().catch(error => { console.error(error); process.exitCode = 1 }) +} else { + const { app, BrowserWindow } = require('electron') + const { pathToFileURL } = require('node:url') + const directory = process.env.YCODE_QA_DIRECTORY + app.setPath('home', path.join(directory, 'home')) + const checks = [], snapshots = {}, rendererErrors = [] + let main, settings, phase = 'boot' + const pause = ms => new Promise(resolve => setTimeout(resolve, ms)) + async function read(source, win = main) { + let timer + try { + return await Promise.race([win.webContents.executeJavaScript(source), new Promise((_, reject) => { + timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000) + })]) + } finally { clearTimeout(timer) } + } + async function wait(source, label, win = main) { + for (let index = 0; index < 300; index++) { const value = await read(source, win); if (value) return value; await pause(40) } + throw Error('Timeout: ' + label) + } + async function step(label) { phase = label; await fs.writeFile(path.join(directory, 'phase.txt'), label) } + function check(label, actual, expected) { + const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected) + checks.push({ label, pass, actual, expected: typeof expected === 'function' ? expected.toString() : expected }) + } + const near = (value, target, tolerance = 0.75) => Math.abs(value - target) <= tolerance + function observe(win) { + win.webContents.on('console-message', event => { if (event.level === 'error' || event.level >= 3) rendererErrors.push({ windowId: win.id, message: event.message }) }) + } + async function openSettings(section) { + await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`) + for (let index = 0; index < 200; index++) { + settings = BrowserWindow.getAllWindows().find(win => win !== main) + if (settings && !settings.webContents.isLoading()) break + await pause(25) + } + assert.ok(settings, 'settings window') + await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings) + await pause(350) + return settings + } + async function sendKey(key, code, keyCode, modifiers = 0) { + if (!settings.webContents.debugger.isAttached()) settings.webContents.debugger.attach('1.3') + await settings.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers }) + await settings.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers }) + await pause(120) + } + async function sendMainKey(key, code, keyCode, modifiers = 0) { + if (!main.webContents.debugger.isAttached()) main.webContents.debugger.attach('1.3') + main.showInactive(); main.webContents.focus() + await main.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { type: 'keyDown', key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers }) + await main.webContents.debugger.sendCommand('Input.dispatchKeyEvent', { type: 'keyUp', key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers }) + await pause(150) + } + + const layoutSource = `(()=>{ + const round = value => Math.round(value * 1000) / 1000 + const rect = node => { if (!node) return null; const r = node.getBoundingClientRect() + return { x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height) } } + const panel = document.querySelector('.keyboard-shortcuts-panel') + const rows = [...document.querySelectorAll('.shortcut-item')] + return { + panel: rect(panel), + toolbar: rect(document.querySelector('.keyboard-shortcuts-panel__toolbar')), + search: rect(document.querySelector('.keyboard-shortcuts-panel__search')), + restore: rect(document.querySelector('.keyboard-shortcuts-panel__toolbar .wb-button')), + restoreDisabled: (() => { const node = document.querySelector('.keyboard-shortcuts-panel__toolbar .wb-button'); return node ? node.disabled === true : null })(), + list: rect(document.querySelector('.keyboard-shortcuts-panel__list')), + header: rect(document.querySelector('.keyboard-shortcuts-panel__column-header')), + scroll: rect(document.querySelector('.keyboard-shortcuts-panel__list-scroll')), + headerTexts: [...document.querySelectorAll('.keyboard-shortcuts-panel__column-header span')].map(node => node.textContent.trim()), + rows: rows.map(row => ({ + id: row.dataset.shortcutId, + supported: row.dataset.shortcutSupported, + label: row.querySelector('.shortcut-item__label')?.textContent?.trim(), + keys: [...row.querySelectorAll('.shortcut-item__key')].map(node => node.textContent.trim()), + prefix: row.querySelector('.shortcut-item__prefix')?.textContent?.trim(), + notEditable: row.classList.contains('shortcut-item--not-editable'), + recording: row.classList.contains('shortcut-item--recording'), + hasDelete: !!row.querySelector('.shortcut-item__action-btn--delete'), + placeholder: row.querySelector('.shortcut-item__actions-placeholder')?.textContent?.trim(), + rect: rect(row), + keyRect: rect(row.querySelector('.shortcut-item__key')), + title: row.querySelector('.shortcut-item__binding-tooltip-anchor')?.getAttribute('title'), + rowBackground: getComputedStyle(row).backgroundColor, + labelColor: getComputedStyle(row.querySelector('.shortcut-item__label')).color, + keyBackground: getComputedStyle(row.querySelector('.shortcut-item__key')).backgroundColor + })), + recordingText: document.querySelector('.shortcut-item__recording-text')?.textContent?.trim(), + escCancel: document.querySelector('.shortcut-item__esc-cancel')?.textContent?.trim(), + errorHint: document.querySelector('.shortcut-item__error-hint')?.textContent?.trim(), + empty: document.querySelector('.keyboard-shortcuts-panel__empty')?.textContent?.trim(), + theme: document.documentElement.dataset.theme ?? 'light' + }})()` + + ;(async () => { + await import(pathToFileURL(path.resolve('out/main/index.mjs')).href); await app.whenReady() + for (let index = 0; index < 200; index++) { + main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode') + if (main && !main.webContents.isLoading()) break + await pause(25) + } + assert.ok(main, 'main window'); observe(main) + await wait('!!window.ycode', 'preload api') + + await step('global-shortcuts-main-window') + // CDP modifiers 位掩码:Alt=1、Ctrl=2、Meta=4、Shift=8。 + const collapsedBefore = await read(`localStorage.getItem('ycode:sidebar-collapsed')`) + await sendMainKey('b', 'KeyB', 66, 2) + const collapsedAfter = await wait(`localStorage.getItem('ycode:sidebar-collapsed')==='1'?'1':null`, 'sidebar collapsed by Ctrl+B') + check('Ctrl+B collapses the left sidebar and persists the state', [collapsedBefore, collapsedAfter], ['0', '1']) + await sendMainKey('b', 'KeyB', 66, 2) + check('Ctrl+B expands the left sidebar again', await wait(`localStorage.getItem('ycode:sidebar-collapsed')==='0'?'0':null`, 'sidebar expanded by Ctrl+B'), '0') + await sendMainKey('k', 'KeyK', 75, 2) + const searchState = await wait(`(()=>{const input=document.querySelector('.sidebar__search-input') + if(!input)return null + const active=document.activeElement + return { active: active===input, activeClass: String(active?.className ?? active?.tagName ?? '') }})()`, 'search box visible after Ctrl+K') + snapshots.ctrlK = searchState + check('Ctrl+K shows and focuses the sidebar search box', searchState.active, true) + await sendMainKey('b', 'KeyB', 66, 10) + check('Ctrl+Shift+B opens the right panel', await wait(`document.querySelector('.app-shell')?.dataset.right==='shown'?'shown':null`, 'right panel opened'), 'shown') + await sendMainKey('b', 'KeyB', 66, 10) + check('Ctrl+Shift+B closes the right panel', await wait(`document.querySelector('.app-shell')?.dataset.right==='hidden'?'hidden':null`, 'right panel closed'), 'hidden') + await read(`window.__qaNewTask=0;window.addEventListener('ycode:new-task',()=>{window.__qaNewTask++},{once:true});true`) + await sendMainKey('n', 'KeyN', 78, 2) + check('Ctrl+N dispatches the sidebar 新建任务 path', await wait(`window.__qaNewTask===1?1:null`, 'new task shortcut dispatched'), 1) + + await step('open-shortcuts-settings') + await openSettings('keyboardShortcuts'); observe(settings) + await wait('!!document.querySelector(".keyboard-shortcuts-panel")', 'shortcuts panel', settings) + const nav = await read(`(()=>{const item=document.querySelector('[data-settings-tab="keyboardShortcuts"]') + return { exists: !!item, disabled: item?.getAttribute('aria-disabled'), active: document.querySelector('.settings-navigation__item--active')?.dataset.settingsTab }})()`, settings) + check('快捷键 nav entry is enabled and active', [nav.exists, nav.disabled !== 'true', nav.active], [true, true, 'keyboardShortcuts']) + + const light = await read(layoutSource, settings) + snapshots.light = light + check('panel keeps the recovered 638x620 box at 232,40', + [near(light.panel.x, 232), near(light.panel.y, 40), near(light.panel.width, 638), near(light.panel.height, 620)], [true, true, true, true]) + check('toolbar/search/restore keep the recovered 638x32, 516x32 and 110x32 geometry', + [[near(light.toolbar.width, 638), near(light.toolbar.height, 32)].every(Boolean), + [near(light.search.width, 516), near(light.search.height, 32)].every(Boolean), + [near(light.restore.x, 760), near(light.restore.width, 110), near(light.restore.height, 32)].every(Boolean)], [true, true, true]) + check('全部恢复默认 stays disabled like the original', light.restoreDisabled, true) + check('list/header/scroll keep 638x572, 39 and 638x533 at 232,88/127', + [[near(light.list.x, 232), near(light.list.y, 88), near(light.list.width, 638), near(light.list.height, 572)].every(Boolean), + near(light.header.height, 39), [near(light.scroll.y, 127), near(light.scroll.height, 533)].every(Boolean)], [true, true, true]) + check('column headers read 命令 / 按键绑定 / 操作', light.headerTexts, ['命令', '按键绑定', '操作']) + check('sheet renders the recovered 24 rows in order', light.rows.map(row => row.label), EXPECTED_LABELS) + check('every row keeps the recovered key binding text', light.rows.map(row => row.keys.join('+') || null), EXPECTED_KEYS) + check('row heights follow the recovered 41 / 45 / 44 pattern', + light.rows.map((row, index) => index < 16 ? near(row.rect.height, 41) : index < 23 ? near(row.rect.height, 45) : near(row.rect.height, 44)), + Array.from({ length: 24 }, () => true)) + check('keycaps stay 22px high', light.rows.every(row => near(row.keyRect.height, 22)), true) + check('first 16 rows expose the delete action and stay editable', light.rows.slice(0, 16).every(row => row.hasDelete && !row.notEditable), true) + check('last 8 rows are read-only with an em dash placeholder', light.rows.slice(16).every(row => !row.hasDelete && row.notEditable && row.placeholder === '—'), true) + check('语音录制开关 keeps the plain 长按 prefix before the Alt key', + [light.rows[2].prefix, light.rows[2].keys], ['长按', ['Alt']]) + check('supported rows match the shortcuts YCode really binds', + light.rows.filter(row => row.supported === 'true').map(row => row.id), SUPPORTED_IDS) + check('unsupported rows are labelled as not wired in their tooltip', + light.rows.filter(row => row.supported === 'false').every(row => (row.title ?? '').includes('尚未接入')), true) + + await step('shortcuts-search') + await read(`(()=>{const input=document.querySelector('.keyboard-shortcuts-panel .wb-input__inner') + const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set + setter.call(input,'语音'); input.dispatchEvent(new Event('input',{bubbles:true})); return true})()`, settings) + const filtered = await wait(`(()=>{const rows=[...document.querySelectorAll('.shortcut-item')] + return rows.length===1?rows.map(row=>row.querySelector('.shortcut-item__label').textContent.trim()):null})()`, 'filtered to one row', settings) + check('search 语音 filters the sheet to 语音录制开关', filtered, ['语音录制开关']) + await read(`(()=>{const input=document.querySelector('.keyboard-shortcuts-panel .wb-input__inner') + const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set + setter.call(input,'zzzz'); input.dispatchEvent(new Event('input',{bubbles:true})); return true})()`, settings) + const empty = await wait(`document.querySelector('.keyboard-shortcuts-panel__empty')?.textContent?.trim()`, 'empty state', settings) + check('a query without matches shows the empty state', empty, '未找到匹配的快捷键') + await read(`(()=>{const input=document.querySelector('.keyboard-shortcuts-panel .wb-input__inner') + const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set + setter.call(input,''); input.dispatchEvent(new Event('input',{bubbles:true})); return true})()`, settings) + await wait(`document.querySelectorAll('.shortcut-item').length===24`, 'full sheet restored', settings) + check('clearing the query restores all 24 rows', await read(`document.querySelectorAll('.shortcut-item').length`, settings), 24) + + await step('shortcuts-recording') + await read(`document.querySelector('[data-shortcut-id="global-search-open"] .shortcut-item__binding-tooltip-anchor').click()`, settings) + const recording = await wait(`(()=>{const row=document.querySelector('[data-shortcut-id="global-search-open"]') + if(!row?.classList.contains('shortcut-item--recording'))return null + return { text: row.querySelector('.shortcut-item__recording-text')?.textContent?.trim(), + cancel: row.querySelector('.shortcut-item__esc-cancel')?.textContent?.trim(), + cursor: !!row.querySelector('.shortcut-item__recording-cursor') }})()`, 'recording state', settings) + snapshots.recording = recording + check('clicking a binding enters the recovered 按下快捷键 / ESC 取消 recording state', + [recording.text, recording.cancel, recording.cursor], ['按下快捷键', 'ESC 取消', true]) + await sendKey('Escape', 'Escape', 27) + const cancelled = await wait(`(()=>{const row=document.querySelector('[data-shortcut-id="global-search-open"]') + if(!row||row.classList.contains('shortcut-item--recording'))return null + return row.querySelector('.shortcut-item__key')?.textContent?.trim()})()`, 'recording cancelled', settings) + check('Escape cancels recording and keeps the original Ctrl+K binding', cancelled, 'Ctrl+K') + await read(`document.querySelector('[data-shortcut-id="global-search-open"] .shortcut-item__binding-tooltip-anchor').click()`, settings) + await wait(`!!document.querySelector('.shortcut-item--recording')`, 'recording again', settings) + await sendKey('a', 'KeyA', 65) + const errorHint = await wait(`document.querySelector('.shortcut-item__error-hint')?.textContent?.trim()`, 'edit unsupported hint', settings) + check('capturing a key reports that custom bindings are not wired and keeps the default', + [errorHint, await read(`document.querySelector('[data-shortcut-id="global-search-open"] .shortcut-item__key')?.textContent?.trim()`, settings)], + ['自定义键位尚未接入,已保留默认绑定', 'Ctrl+K']) + + await step('shortcuts-dark-theme') + await openSettings('appearance') + await wait('!!document.querySelector(".appearance-card")', 'appearance panel', settings) + await read(`document.querySelector('[aria-label="深色"].appearance-card').click()`, settings) + await wait(`document.documentElement.dataset.theme==='dark'`, 'dark theme', settings) + await openSettings('keyboardShortcuts') + await wait(`document.documentElement.dataset.theme==='dark'&&!!document.querySelector('.keyboard-shortcuts-panel')`, 'dark shortcuts panel', settings) + const dark = await read(layoutSource, settings) + snapshots.dark = dark + check('dark theme keeps every recovered geometry', [near(dark.panel.width, 638), near(dark.panel.height, 620), + near(dark.list.height, 572), near(dark.header.height, 39)], [true, true, true, true]) + check('dark rows use the recovered 31,31,31 surface with light text', + [dark.rows.every(row => row.rowBackground === 'rgb(31, 31, 31)'), dark.rows.every(row => row.labelColor === 'rgb(242, 243, 245)')], [true, true]) + check('dark keycaps use the recovered 8% white fill', + dark.rows.every(row => /^color\(srgb 1 1 1 \/ 0\.08\)$/.test(row.keyBackground) || row.keyBackground === 'rgba(255, 255, 255, 0.08)'), true) + + await step('shortcuts-restore-light') + await openSettings('appearance') + await wait('!!document.querySelector(".appearance-card")', 'appearance panel again', settings) + await read(`document.querySelector('[aria-label="浅色"].appearance-card').click()`, settings) + await wait(`document.documentElement.dataset.theme==='light'`, 'light theme restored', settings) + await openSettings('keyboardShortcuts') + const restored = await read(layoutSource, settings) + check('selecting 浅色 restores the light sheet', [restored.theme, restored.rows.length, restored.rows[0].rowBackground], ['light', 24, 'rgb(255, 255, 255)']) + + await step('shortcuts-errors') + check('no renderer error logs', rendererErrors, []) + + const result = checks.every(item => item.pass) ? 'PASS' : 'FAIL' + await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result, phase, checks, snapshots }, null, 2)) + console.log('SHORTCUTS_SETTINGS_' + result + ' ' + directory) + for (const failure of checks.filter(item => !item.pass)) console.log('SHORTCUTS_SETTINGS_FAILURE ' + JSON.stringify(failure)) + app.exit(result === 'PASS' ? 0 : 1) + })().catch(async error => { + console.error(error) + try { await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'ERROR', phase, checks, snapshots, error: String(error?.stack ?? error) }, null, 2)) } catch { /* ignore */ } + app.exit(1) + }) +} diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 88dd684..62f9a17 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -260,12 +260,47 @@ export default function App(): React.JSX.Element { setResizing(true) } - // Ctrl+, 打开设置 + // 全局快捷键(TODO 12):与「设置 → 快捷键」中已接入的行保持一致。 + // Ctrl+, 设置;Ctrl+K 聚焦全局搜索;Ctrl+B / Ctrl+Shift+B 切换左/右侧栏; + // Ctrl+N 新建对话;生成中按 Esc 停止。其余键位只在该页面展示,未接入时不宣称生效。 + const shortcutActions = useRef({ + openSettings: (): void => {}, + focusSearch: (): void => {}, + toggleSidebar: (): void => {}, + toggleRightSidebar: (): void => {}, + stopRun: (): void => {} + }) + shortcutActions.current = { + openSettings, + focusSearch: () => { + setSidebarCollapsed(false) + if (narrow.isNarrowForSidebar) setLeftDrawerOpen(true) + window.dispatchEvent(new CustomEvent('ycode:focus-search')) + }, + toggleSidebar: () => { + if (narrow.isNarrowForSidebar) setLeftDrawerOpen((value) => !value) + else setSidebarCollapsed((value) => !value) + }, + toggleRightSidebar: () => setRightVisible((value) => !value), + stopRun: () => { if (useChatStore.getState().running) void useChatStore.getState().cancel() } + } useEffect(() => { - const onKey = (e: KeyboardEvent): void => { - if ((e.ctrlKey || e.metaKey) && e.key === ',') { - e.preventDefault() - openSettings() + const onKey = (event: KeyboardEvent): void => { + if (event.isComposing) return + const actions = shortcutActions.current + if (event.key === 'Escape' && !event.defaultPrevented) { + // 与 WorkBuddy 一致:Esc 只在生成中停止本次运行,不吞掉其他弹层的 Esc。 + if (useChatStore.getState().running) { event.preventDefault(); actions.stopRun() } + return + } + if ((!event.ctrlKey && !event.metaKey) || event.altKey) return + const key = event.key.toLowerCase() + if (key === ',') { event.preventDefault(); actions.openSettings(); return } + if (key === 'k' && !event.shiftKey) { event.preventDefault(); actions.focusSearch(); return } + if (key === 'b') { event.preventDefault(); if (event.shiftKey) actions.toggleRightSidebar(); else actions.toggleSidebar(); return } + if (key === 'n' && !event.shiftKey) { + event.preventDefault() + window.dispatchEvent(new CustomEvent('ycode:new-task')) } } window.addEventListener('keydown', onKey) diff --git a/src/renderer/src/components/SettingsDialog.tsx b/src/renderer/src/components/SettingsDialog.tsx index 03f4c4d..9bcb5ce 100644 --- a/src/renderer/src/components/SettingsDialog.tsx +++ b/src/renderer/src/components/SettingsDialog.tsx @@ -3,6 +3,7 @@ import { useSettingsStore } from '../stores/settings' import ModelSettingsPanel from './ModelSettingsPanel' import GeneralSettingsPanel from './GeneralSettingsPanel' import AppearanceSettingsPanel from './AppearanceSettingsPanel' +import ShortcutsSettingsPanel from './ShortcutsSettingsPanel' import SourceSettingsNavigation from './SourceSettingsNavigation' import { useSharedTheme } from '../useSharedTheme' import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation' @@ -53,7 +54,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi return () => window.removeEventListener('keydown', escape) }, [onClose]) const modal = - { setError(''); setSection(id === 'models' ? 'model' : id) }} onUnavailable={() => setError('该设置尚未接入')} /> {error && {error}} @@ -61,6 +62,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi {section === 'model' && setCreateRequest(0)} onSave={settings.saveModel} onDelete={settings.deleteModel} onTest={(input) => window.ycode.models.test(input)} />} {section === 'workspace' && 工作空间{settings.workspaces.map((space) => void window.ycode.workspace.open(space.id).catch((error) => setError(String(error.message ?? error)))}>{space.name})} void settings.attachWorkspace().catch((error) => setError(String(error.message ?? error)))}>打开已有文件夹} {section === 'appearance' && } + {section === 'keyboardShortcuts' && } {section === 'about' && YCode{settings.appInfo?.version}} diff --git a/src/renderer/src/components/ShortcutsSettingsPanel.tsx b/src/renderer/src/components/ShortcutsSettingsPanel.tsx new file mode 100644 index 0000000..3dcc1bb --- /dev/null +++ b/src/renderer/src/components/ShortcutsSettingsPanel.tsx @@ -0,0 +1,162 @@ +/** + * 设置 → 快捷键:WorkBuddy 5.8.3 原版快捷键页的 1:1 复刻(TODO 12)。 + * + * 结构与几何来自 REA `ev_5f3148…`(浅色)与 `ev_f4fd41…`(深色);搜索、禁用「全部恢复默认」、 + * 点击绑定进入「按下快捷键 / ESC 取消」录制态来自 REA `ev_b3e014…`。样式取自 REA 回收的 + * `keyboard-shortcuts-DOrivHwf.css`(见 source-shortcuts-panel-recovered.css)。 + * + * YCode 侧的真实性边界: + * - 搜索过滤、录制态/取消、深色主题是真实行为; + * - `shortcuts-catalog.ts` 中 supported=true 的 14 行与 YCode 现有绑定一致; + * - 其余行只还原原版展示,title 明确标注「YCode 尚未接入」,编辑后的自定义键位、删除单条键位和 + * 整表「全部恢复默认」尚未接入,按钮保持禁用,不伪造成功。 + */ +import { useEffect, useMemo, useRef, useState } from 'react' +import { SHORTCUT_ROWS, type ShortcutRow } from './shortcuts-catalog' +import './source-shortcuts-panel.css' + +const UNSUPPORTED_HINT = 'YCode 尚未接入该快捷键' +const EDIT_UNSUPPORTED_HINT = '自定义键位尚未接入,已保留默认绑定' + +function TrashIcon(): React.JSX.Element { + return ( + + + + ) +} + +function matches(row: ShortcutRow, needle: string): boolean { + if (!needle) return true + const haystack = `${row.label} ${row.prefix ?? ''} ${row.keys}`.toLocaleLowerCase() + return haystack.includes(needle) +} + +export default function ShortcutsSettingsPanel(): React.JSX.Element { + const [query, setQuery] = useState('') + const [recordingId, setRecordingId] = useState(null) + const [errorId, setErrorId] = useState(null) + const errorTimer = useRef(null) + const needle = query.trim().toLocaleLowerCase() + const rows = useMemo(() => SHORTCUT_ROWS.filter((row) => matches(row, needle)), [needle]) + + const cancelRecording = (): void => { + setRecordingId(null) + } + + const startRecording = (row: ShortcutRow): void => { + if (!row.editable) return + if (errorTimer.current !== null) { + window.clearTimeout(errorTimer.current) + errorTimer.current = null + } + setErrorId(null) + setRecordingId(row.id) + } + + useEffect(() => { + if (!recordingId) return + const onKeyDown = (event: KeyboardEvent): void => { + if (event.isComposing) return + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + cancelRecording() + return + } + // 只按修饰键(Ctrl/Shift/Alt/Meta)时继续等待完整组合键。 + if (['Control', 'Shift', 'Alt', 'Meta'].includes(event.key)) return + event.preventDefault() + event.stopPropagation() + setRecordingId(null) + setErrorId(recordingId) + if (errorTimer.current !== null) window.clearTimeout(errorTimer.current) + errorTimer.current = window.setTimeout(() => { + errorTimer.current = null + setErrorId(null) + }, 2600) + } + document.addEventListener('keydown', onKeyDown, true) + return () => document.removeEventListener('keydown', onKeyDown, true) + }, [recordingId]) + + useEffect(() => () => { + if (errorTimer.current !== null) window.clearTimeout(errorTimer.current) + }, []) + + const panelClass = `keyboard-shortcuts-panel${recordingId ? ' keyboard-shortcuts-panel--recording' : ''}` + return ( + + + + + setQuery(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Escape' && query) { event.preventDefault(); event.stopPropagation(); setQuery('') } }} /> + + + 全部恢复默认 + + + + 命令 + 按键绑定 + 操作 + + + {rows.length ? rows.map((row) => { + const recording = recordingId === row.id + const error = errorId === row.id + const title = row.supported + ? `${row.label}:${row.action ?? row.keys}` + : `${UNSUPPORTED_HINT}(原版绑定:${row.keys})` + return ( + + {row.label} + {recording ? ( + + + + 按下快捷键 + + + ESC 取消 + + {error ? {EDIT_UNSUPPORTED_HINT} : null} + + ) : ( + + {row.editable ? ( + startRecording(row)}> + {row.prefix ? {row.prefix} : null}{row.keys} + + ) : ( + + {row.keys} + + )} + {error ? {EDIT_UNSUPPORTED_HINT} : null} + + )} + + {row.editable ? ( + + + + + + ) : —} + + + ) + }) : 未找到匹配的快捷键} + + + + ) +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index ba064c0..0c225a4 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -151,6 +151,14 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch } catch (error) { setHint(error instanceof Error ? error.message : '无法新建任务') } finally { taskStartLock.current = false } } + // 全局快捷键 Ctrl+N(TODO 12):App 广播,复用「新建任务」按钮的同一条路径。 + const newTaskRef = useRef(newTask) + newTaskRef.current = newTask + useEffect(() => { + const handler = (): void => { void newTaskRef.current() } + window.addEventListener('ycode:new-task', handler) + return () => window.removeEventListener('ycode:new-task', handler) + }, []) const openSpaceMenu = (space: WorkspaceRecord, rect: { left: number; bottom: number; top: number }, moreButton = false): void => { setSpaceMenu({ space, left: rect.left - (moreButton ? 16 : 0), top: rect.bottom + 4, anchorTop: rect.top }) } @@ -164,6 +172,12 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch window.addEventListener('ycode:focus-search', onFocusSearch) return () => window.removeEventListener('ycode:focus-search', onFocusSearch) }, []) + // 搜索框是条件渲染:等提交完成后再聚焦,避免 requestAnimationFrame 先于 DOM 挂载导致焦点丢失。 + useEffect(() => { + if (!searchVisible) return + searchRef.current?.focus() + searchRef.current?.select() + }, [searchVisible]) // 全局「尚未实现」提示(标题栏等外围入口共用同一反馈通道) useEffect(() => { diff --git a/src/renderer/src/components/shortcuts-catalog.ts b/src/renderer/src/components/shortcuts-catalog.ts new file mode 100644 index 0000000..edb6320 --- /dev/null +++ b/src/renderer/src/components/shortcuts-catalog.ts @@ -0,0 +1,57 @@ +/** + * 设置 → 快捷键:WorkBuddy 5.8.3 原版表格的 24 行命令与按键绑定。 + * + * REA 依据(设置窗口 target 6D5A7FEE…,900×680): + * - `ev_5f3148eb51ea4a6e5b6f2ffea5f6e3dfb94e4009ecf2d16173fa676ce7c51b4b`:浅色顶部布局/计算样式/模块 CSS + * - `ev_7266946b025216056fc67f7c6d3c991894e985b6a8486a3153c3508e21771f73`:四段滚动采样(顶部/中/底/回顶) + * - `ev_b3e014e5cff4971d7c5e75fefbbfefb0088db9ce655ef7ff33114cd3c4caead9`:搜索、禁用恢复默认、录制/取消交互帧 + * - `ev_f4fd417445481ef93c34a7e85be45e63327daa2acbccaf169706d7e19bacc531`:深色同页布局与计算样式 + * - `ev_528724699462fa08e2e4119bb8504c0180483c86cd844461cc969422b915650b`:最终浅色顶部稳定态 + * + * 文案与键位读自 REA PNG 帧;几何/样式读自 REA layout JSON。前 16 行在原版可编辑(有删除按钮), + * 后 8 行带 `shortcut-item--not-editable`、键帽不透明度 0.5、操作列显示「—」。 + * + * `supported` 只描述 YCode 当前是否真的绑定了该动作:true 的行按键与实际行为一致; + * false 的行只还原原版展示,通过 title 明确标注「YCode 尚未接入」,不做能力宣称。 + */ +export interface ShortcutRow { + id: string + label: string + /** 原版键帽文本(组合键用 `+` 连接、无空格)。 */ + keys: string + /** 原版把「长按」渲染为键帽前的纯文本(仅语音录制开关一行)。 */ + prefix?: string + editable: boolean + supported: boolean + /** 真实动作说明;仅 supported 行填写。 */ + action?: string +} + +export const SHORTCUT_ROWS: ReadonlyArray = [ + { id: 'global-search-open', label: '打开全局搜索', keys: 'Ctrl+K', editable: true, supported: true, action: '展开侧栏并聚焦任务搜索框' }, + { id: 'open-settings', label: '打开设置', keys: 'Ctrl+,', editable: true, supported: true, action: '打开设置窗口' }, + { id: 'voice-record', label: '语音录制开关', keys: 'Alt', prefix: '长按', editable: true, supported: false }, + { id: 'conversation-search', label: '对话内搜索', keys: 'Ctrl+F', editable: true, supported: false }, + { id: 'global-search', label: '全局搜索', keys: 'Ctrl+K', editable: true, supported: true, action: '展开侧栏并聚焦任务搜索框' }, + { id: 'send-message', label: '发送消息', keys: 'Enter', editable: true, supported: true, action: '输入框内 Enter 发送' }, + { id: 'newline', label: '输入时换行', keys: 'Shift+Enter', editable: true, supported: true, action: '输入框内 Shift+Enter 换行' }, + { id: 'new-conversation', label: '新建对话', keys: 'Ctrl+N', editable: true, supported: true, action: '新建任务并切到对话页' }, + { id: 'stop-generation', label: '停止生成', keys: 'Esc', editable: true, supported: true, action: '生成中按 Esc 停止本次运行' }, + { id: 'previous-task', label: '上一个任务', keys: 'Ctrl+[', editable: true, supported: false }, + { id: 'next-task', label: '下一个任务', keys: 'Ctrl+]', editable: true, supported: false }, + { id: 'locate-task', label: '定位当前任务', keys: 'Ctrl+Shift+E', editable: true, supported: false }, + { id: 'toggle-left-sidebar', label: '切换左侧栏', keys: 'Ctrl+B', editable: true, supported: true, action: '收起/展开左侧栏' }, + { id: 'toggle-right-sidebar', label: '切换右侧栏', keys: 'Ctrl+Shift+B', editable: true, supported: true, action: '收起/展开右侧栏' }, + { id: 'toggle-fullscreen', label: '进入/退出全屏', keys: 'F11', editable: true, supported: false }, + { id: 'toggle-main-window', label: '唤起/隐藏主窗口', keys: 'Shift+Alt+W', editable: true, supported: false }, + { id: 'font-increase', label: '放大内容字号', keys: 'Ctrl+"+"', editable: false, supported: true, action: 'Ctrl+= / Ctrl+Shift+= 提升一档字号' }, + { id: 'font-decrease', label: '缩小内容字号', keys: 'Ctrl+"-"', editable: false, supported: true, action: 'Ctrl+- 降低一档字号' }, + { id: 'font-reset', label: '重置内容字号', keys: 'Ctrl+0', editable: false, supported: true, action: 'Ctrl+0 恢复标准字号' }, + { id: 'mention-picker', label: '唤起选择器', keys: '@', editable: false, supported: false }, + { id: 'slash-command', label: '唤起斜杠命令', keys: '/', editable: false, supported: false }, + { id: 'force-external-open', label: '按住后点击,始终外部打开', keys: 'Ctrl', editable: false, supported: false }, + { id: 'new-tab', label: '新建标签页', keys: 'Ctrl+T', editable: false, supported: true, action: '工作台焦点内新建标签页' }, + { id: 'close-tab', label: '关闭标签页', keys: 'Ctrl+W', editable: false, supported: true, action: '工作台焦点内关闭当前标签页' } +] + +export const SUPPORTED_SHORTCUT_COUNT = SHORTCUT_ROWS.filter((row) => row.supported).length diff --git a/src/renderer/src/components/source-shortcuts-panel-recovered.css b/src/renderer/src/components/source-shortcuts-panel-recovered.css new file mode 100644 index 0000000..6da4fe4 --- /dev/null +++ b/src/renderer/src/components/source-shortcuts-panel-recovered.css @@ -0,0 +1,54 @@ +/* REA ev_5f3148eb51ea4a6e5b6f2ffea5f6e3dfb94e4009ecf2d16173fa676ce7c51b4b; keyboard-shortcuts-DOrivHwf.css sha256=d2c570c8d65db87c1cb9df06b59e6aa191b772330857376b85fd923aed580ae8; tokens sha256=13f2bf82c96e5167a7d7098e36950c6c22c475b3337dbb8bb29ad2fe79cde696. */ +.keyboard-shortcuts-panel { box-sizing: border-box; flex-direction: column; width: 100%; min-width: 0px; height: 100%; padding: 0px; display: flex; position: relative; } +.keyboard-shortcuts-panel__toolbar { align-items: center; gap: var(--wb-spacing-4); margin-bottom: var(--wb-spacing-5); display: flex; } +.keyboard-shortcuts-panel__search { flex: 1 1 0%; min-width: 0px; } +.keyboard-shortcuts-panel__column-header { border-bottom: 1px solid var(--wb-border-subtle); z-index: 1; background: var(--wb-bg-primary); align-items: center; display: flex; position: sticky; top: 0px; } +.keyboard-shortcuts-panel__column-header span { color: var(--wb-color-text-tertiary); font-size: var(--wb-font-spec-body-md-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-md-line-height); letter-spacing: 0px; } +.keyboard-shortcuts-panel__col-command { width: 40%; min-width: 0px; padding: var(--wb-spacing-3) var(--wb-spacing-5); text-align: left; text-overflow: ellipsis; flex-shrink: 0; overflow: hidden; } +.keyboard-shortcuts-panel__col-binding { box-sizing: border-box; width: calc(60% - 112px); min-width: 0px; padding: var(--wb-spacing-3) var(--wb-spacing-5); text-align: left; flex-shrink: 0; overflow: hidden; } +.keyboard-shortcuts-panel__col-action { box-sizing: border-box; width: 112px; padding: var(--wb-spacing-3) var(--wb-spacing-5); text-align: left; flex-shrink: 0; } +.keyboard-shortcuts-panel--readonly .keyboard-shortcuts-panel__col-binding,.keyboard-shortcuts-panel--readonly .shortcut-item__binding { width: 60%; } +.keyboard-shortcuts-panel__list { border-radius: var(--wb-radius-xl); background: var(--wb-bg-primary); border: none; flex-direction: column; flex: 0 1 auto; min-height: 0px; display: flex; overflow: hidden; } +.keyboard-shortcuts-panel__list-scroll { scrollbar-gutter: stable; flex-direction: column; flex: 1 1 0%; min-height: 0px; display: flex; overflow: hidden auto; } +.keyboard-shortcuts-panel__list-scroll::-webkit-scrollbar { width: 6px; } +.keyboard-shortcuts-panel__list-scroll::-webkit-scrollbar-track { background: 0px 0px; } +.keyboard-shortcuts-panel__list-scroll::-webkit-scrollbar-thumb { border-radius: var(--wb-radius-sm); transition: background-color var(--wb-motion-duration-base) var(--wb-motion-easing-standard); background-color: rgba(0, 0, 0, 0); } +.keyboard-shortcuts-panel__list-scroll:hover::-webkit-scrollbar-thumb { background-color: color-mix(in srgb, var(--wb-color-text-secondary) 28%, transparent); } +.keyboard-shortcuts-panel__empty { padding: var(--wb-spacing-10) 0; color: var(--wb-color-text-tertiary); font-size: var(--wb-font-spec-body-md-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-md-line-height); text-align: center; } +.shortcut-item { background: var(--wb-bg-primary); max-width: 100%; transition: background-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard); align-items: center; display: flex; } +.shortcut-item:not(:last-child) { border-bottom: 1px solid var(--wb-border-subtle); } +.shortcut-item:hover:not(.shortcut-item--recording) { background: var(--wb-bg-hover-subtle); } +.shortcut-item--recording { overflow: visible; } +.shortcut-item--not-editable .shortcut-item__key { opacity: 0.5; } +.shortcut-item__label { width: 40%; min-width: 0px; padding: var(--wb-spacing-3) var(--wb-spacing-5); color: var(--wb-color-text-primary); font-size: var(--wb-font-spec-body-md-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-md-line-height); text-overflow: ellipsis; white-space: nowrap; flex-shrink: 0; overflow: hidden; } +.shortcut-item__binding { box-sizing: border-box; width: calc(60% - 112px); min-width: 0px; padding: var(--wb-spacing-3) var(--wb-spacing-5); flex-shrink: 0; align-items: center; gap: 0px; display: flex; overflow: hidden; } +.shortcut-item__binding--recording-layout { flex-direction: column; align-items: flex-start; overflow: visible; } +.shortcut-item__recording-row { align-items: center; gap: var(--wb-spacing-2); display: flex; } +.shortcut-item__binding-tooltip-anchor { align-items: center; display: inline-flex; } +.shortcut-item__binding-tooltip-anchor--clickable { border-radius: var(--wb-radius-sm); cursor: pointer; } +.shortcut-item__actions { justify-content: flex-start; align-items: center; gap: var(--wb-spacing-1); box-sizing: border-box; width: 112px; padding: var(--wb-spacing-3) var(--wb-spacing-5); flex-shrink: 0; display: flex; overflow: visible; } +.shortcut-item__action-btn-group { align-items: center; gap: var(--wb-spacing-3); display: flex; } +.shortcut-item__action-btn-placeholder { flex-shrink: 0; width: 28px; height: 28px; } +.shortcut-item__keys { align-items: center; gap: var(--wb-spacing-2); display: flex; } +.shortcut-item__key { border-radius: var(--wb-radius-md); background: var(--wb-color-bg-primary-default); min-width: 22px; height: 22px; color: var(--wb-color-text-primary); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace; font-size: var(--wb-font-spec-body-sm-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-sm-line-height); white-space: nowrap; justify-content: center; align-items: center; padding: 0px 6px; display: inline-flex; } +.shortcut-item__unassigned,.shortcut-item__recording-hint { color: var(--wb-color-text-disabled); font-size: var(--wb-font-spec-body-sm-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-md-line-height); } +.shortcut-item__error-hint { color: var(--wb-status-error); font-size: var(--wb-font-spec-body-sm-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-sm-line-height); white-space: nowrap; } +.shortcut-item__recording-box { align-items: center; gap: var(--wb-spacing-1); min-width: 120px; min-height: 24px; padding: 0 var(--wb-spacing-3); border: 1px solid var(--wb-border-control); border-radius: var(--wb-radius-md); background: var(--wb-bg-primary); transition: border-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard); display: inline-flex; } +.shortcut-item__recording-box--error { border-color: var(--wb-status-error); } +.shortcut-item__recording-text { color: var(--wb-color-text-primary); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", monospace; font-size: var(--wb-font-spec-body-sm-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-sm-line-height); white-space: nowrap; } +.shortcut-item__recording-text--error { color: var(--wb-status-error); } +.shortcut-item__recording-cursor { background: var(--wb-color-text-primary); flex-shrink: 0; width: 1px; height: 14px; animation: 1s step-end 0s infinite normal none running recording-cursor-blink; display: inline-block; } +.shortcut-item__action-btn.wb-button .wb-button__icon { width: var(--wb-icon-size-md); height: var(--wb-icon-size-md); } +.shortcut-item__action-btn--reset.wb-button .wb-button__icon svg[fill="none"] { fill: none; } +.shortcut-item__action-btn:focus-visible { outline: 2px solid var(--wb-border-focus); outline-offset: var(--wb-spacing-1); } +.shortcut-item__actions-placeholder { width: 28px; height: 28px; color: var(--wb-color-text-secondary); font-size: var(--wb-font-body-size); opacity: 0.6; user-select: none; justify-content: center; align-items: center; display: inline-flex; } +.shortcut-item__esc-cancel { padding: var(--wb-spacing-1) var(--wb-spacing-3); border-radius: var(--wb-radius-sm); color: var(--wb-color-text-tertiary); font-size: var(--wb-font-spec-body-sm-size); font-weight: var(--wb-font-weight-400); line-height: var(--wb-font-spec-body-sm-line-height); cursor: pointer; white-space: nowrap; transition: background-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard), color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard); } +.shortcut-item__esc-cancel:hover { background: var(--wb-bg-hover); color: var(--wb-color-text-primary); } +.shortcut-item__esc-cancel:focus-visible { outline: 2px solid var(--wb-border-focus); outline-offset: var(--wb-spacing-1); } +[data-theme=dark] .shortcut-item__key { background: var(--wb-bg-hover-light); } +@keyframes recording-cursor-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} +.keyboard-shortcuts-panel{--wb-spacing-4:12px;--wb-spacing-5:16px;--wb-border-subtle:var(--wb-palette-gray-3);--wb-bg-primary:var(--wb-palette-white-100);--wb-color-text-tertiary:var(--wb-palette-black-50);--wb-font-spec-body-md-size:var(--wb-font-size-5);--wb-font-weight-400:400;--wb-font-spec-body-md-line-height:var(--wb-font-line-height-5-2);--wb-spacing-3:8px;--wb-radius-xl:12px;--wb-radius-sm:4px;--wb-motion-duration-base:.2s;--wb-motion-easing-standard:cubic-bezier(.4, 0, .2, 1);--wb-color-text-secondary:var(--wb-palette-black-70);--wb-spacing-10:48px;--wb-motion-duration-fast:.15s;--wb-bg-hover-subtle:var(--wb-palette-gray-1);--wb-color-text-primary:var(--wb-palette-black-100);--wb-spacing-2:4px;--wb-spacing-1:2px;--wb-radius-md:6px;--wb-color-bg-primary-default:var(--wb-palette-gray-3);--wb-font-spec-body-sm-size:var(--wb-font-size-3);--wb-font-spec-body-sm-line-height:var(--wb-font-line-height-5);--wb-color-text-disabled:var(--wb-palette-black-30);--wb-status-error:var(--wb-palette-red-8);--wb-border-control:var(--wb-palette-gray-4);--wb-icon-size-md:var(--wb-size-4);--wb-border-focus:var(--wb-color-border-focus);--wb-font-body-size:var(--wb-font-size-5);--wb-bg-hover:color-mix(in srgb, var(--wb-palette-black-100) 5%, transparent);--wb-bg-hover-light:var(--wb-palette-gray-3);--wb-palette-gray-3:#f2f2f2;--wb-palette-white-100:#fff;--wb-palette-black-50:#00000080;--wb-font-size-5:14px;--wb-font-line-height-5-2:22px;--wb-palette-black-70:#000000b3;--wb-palette-gray-1:#fafafa;--wb-bg-tertiary:var(--wb-color-bg-primary-hover);--wb-palette-black-100:#000;--wb-font-size-3:12px;--wb-font-line-height-5:20px;--wb-palette-black-30:#0000004d;--wb-palette-red-8:#f64041;--wb-color-text-error-default:var(--wb-palette-red-8);--wb-palette-gray-4:#ebebeb;--wb-size-4:16px;--wb-color-border-focus:var(--wb-palette-black-75);--wb-color-bg-primary-hover:var(--wb-palette-gray-4);--wb-palette-red-7:#e85c5c;--wb-palette-black-75:#000000bf;--wb-palette-white-70:#ffffffb3} +:root[data-theme=dark] .keyboard-shortcuts-panel{--wb-spacing-4:12px;--wb-spacing-5:16px;--wb-border-subtle:color-mix(in srgb, var(--wb-palette-white-100) 6%, transparent);--wb-bg-primary:var(--wb-palette-gray-3);--wb-color-text-tertiary:#ffffff80;--wb-font-spec-body-md-size:var(--wb-font-size-5);--wb-font-weight-400:400;--wb-font-spec-body-md-line-height:var(--wb-font-line-height-5-2);--wb-spacing-3:8px;--wb-radius-xl:12px;--wb-radius-sm:4px;--wb-motion-duration-base:.2s;--wb-motion-easing-standard:cubic-bezier(.4, 0, .2, 1);--wb-color-text-secondary:#ffffffa6;--wb-spacing-10:48px;--wb-motion-duration-fast:.15s;--wb-bg-hover-subtle:var(--wb-bg-tertiary);--wb-color-text-primary:#fff;--wb-spacing-2:4px;--wb-spacing-1:2px;--wb-radius-md:6px;--wb-color-bg-primary-default:var(--wb-palette-gray-3);--wb-font-spec-body-sm-size:var(--wb-font-size-3);--wb-font-spec-body-sm-line-height:var(--wb-font-line-height-5);--wb-color-text-disabled:#ffffff59;--wb-status-error:var(--wb-color-text-error-default);--wb-border-control:color-mix(in srgb, var(--wb-palette-white-100) 22%, transparent);--wb-icon-size-md:var(--wb-size-4);--wb-border-focus:var(--wb-color-border-focus);--wb-font-body-size:var(--wb-font-size-5);--wb-bg-hover:color-mix(in srgb, var(--wb-palette-white-100) 6%, transparent);--wb-bg-hover-light:color-mix(in srgb, var(--wb-palette-white-100) 8%, transparent);--wb-palette-gray-3:#1f1f1f;--wb-palette-white-100:#fff;--wb-palette-black-50:#00000080;--wb-font-size-5:14px;--wb-font-line-height-5-2:22px;--wb-palette-black-70:#000000b3;--wb-palette-gray-1:#141414;--wb-bg-tertiary:var(--wb-palette-gray-4);--wb-palette-black-100:#000;--wb-font-size-3:12px;--wb-font-line-height-5:20px;--wb-palette-black-30:#0000004d;--wb-palette-red-8:#af191a;--wb-color-text-error-default:var(--wb-palette-red-7);--wb-palette-gray-4:#242424;--wb-size-4:16px;--wb-color-border-focus:var(--wb-palette-white-70);--wb-color-bg-primary-hover:var(--wb-palette-gray-4);--wb-palette-red-7:#a31717;--wb-palette-black-75:#000000bf;--wb-palette-white-70:#ffffffb3} diff --git a/src/renderer/src/components/source-shortcuts-panel.css b/src/renderer/src/components/source-shortcuts-panel.css new file mode 100644 index 0000000..923b923 --- /dev/null +++ b/src/renderer/src/components/source-shortcuts-panel.css @@ -0,0 +1,153 @@ +/* + * 设置 → 快捷键(TODO 12)。 + * 第一段:REA 回收的原版模块样式(keyboard-shortcuts-DOrivHwf.css 的投影)。 + * 第二段:本地适配——原版把输入/按钮基件放在 UI kit 样式表里,这里只补快捷键面板引用到的那几个基件, + * 取值来自 REA `ev_5f3148…` 的同页 computed style(浅色)与 tokens 深色取值。 + */ +@import './source-shortcuts-panel-recovered.css'; + +/* ---- UI kit 基件适配:搜索输入框(原版 .wb-input-wrapper / .wb-input__inner) ---- */ +.keyboard-shortcuts-panel .wb-input-wrapper { + display: inline-flex; + align-items: center; + gap: var(--wb-spacing-2); + box-sizing: border-box; + width: 100%; + height: 32px; + padding: 0 4px 0 8px; + background: var(--wb-bg-primary); + border: 1px solid var(--wb-input-border, #e6e6e6); + border-radius: 8px; + transition: border-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard); +} +.keyboard-shortcuts-panel .wb-input-wrapper:hover { + border-color: var(--wb-input-border-hover, #d9d9d9); +} +.keyboard-shortcuts-panel .wb-input-wrapper:focus-within { + border-color: var(--wb-color-border-focus); +} +.keyboard-shortcuts-panel .wb-input__inner { + flex: auto; + min-width: 0; + height: 100%; + padding: 0; + color: var(--wb-color-text-primary); + font: inherit; + font-size: var(--wb-font-spec-body-md-size); + line-height: var(--wb-font-spec-body-md-line-height); + background: transparent; + border: none; + outline: none; +} +.keyboard-shortcuts-panel .wb-input__inner::placeholder { + color: var(--wb-color-text-tertiary); +} +.keyboard-shortcuts-panel .wb-input__inner::-webkit-search-cancel-button { + display: none; +} +:root[data-theme=dark] .keyboard-shortcuts-panel .wb-input-wrapper { + border-color: color-mix(in srgb, var(--wb-palette-white-100) 15%, transparent); +} + +/* ---- UI kit 基件适配:按钮(原版 .wb-button / .wb-button--medium / --xsmall / --ghost) ---- */ +.keyboard-shortcuts-panel .wb-button { + display: inline-flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + border: none; + outline: none; + font-family: PingFang SC, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-weight: 500; + white-space: nowrap; + user-select: none; + cursor: pointer; + transition: background-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard), + color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard), + border-color var(--wb-motion-duration-fast) var(--wb-motion-easing-standard); +} +.keyboard-shortcuts-panel .wb-button--medium { + height: 32px; + padding: 0 12px; + gap: 4px; + font-size: var(--wb-font-spec-body-md-size); + line-height: var(--wb-font-spec-body-md-line-height); + border-radius: 8px; +} +.keyboard-shortcuts-panel .wb-button--secondary { + color: var(--wb-color-text-primary); + background: var(--wb-bg-primary); + border: 1px solid var(--wb-button-secondary-border, #ebebeb); +} +.keyboard-shortcuts-panel .wb-button--secondary.wb-button--disabled, +.keyboard-shortcuts-panel .wb-button--secondary:disabled { + color: var(--wb-color-text-disabled); + cursor: not-allowed; +} +.keyboard-shortcuts-panel .wb-button--xsmall { + /* REA ev_5f3148…:可编辑行高 41 = 8 + 24 + 8 + 1(行分隔线),删除按钮为 24×24。 */ + width: 24px; + height: 24px; + padding: 0; + gap: 0; + border-radius: 6px; +} +.keyboard-shortcuts-panel .wb-button--xsmall.wb-button--icon-only { + width: 24px; + height: 24px; +} +.keyboard-shortcuts-panel .wb-button--ghost { + color: var(--wb-color-text-secondary); + background: transparent; +} +.keyboard-shortcuts-panel .wb-button--ghost:hover:not(:disabled) { + background: var(--wb-bg-hover); + color: var(--wb-color-text-primary); +} +.keyboard-shortcuts-panel .wb-button--ghost:disabled { + color: var(--wb-color-text-disabled); + cursor: not-allowed; +} +.keyboard-shortcuts-panel .wb-button__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--wb-icon-size-md); + height: var(--wb-icon-size-md); + flex-shrink: 0; +} +.keyboard-shortcuts-panel .wb-button__icon svg { + width: 100%; + height: 100%; + color: currentColor; +} +:root[data-theme=dark] .keyboard-shortcuts-panel .wb-button--secondary { + border-color: var(--wb-border-subtle); +} +/* + * REA ev_f4fd41…(深色同页 layout):`.shortcut-item__label` / `.shortcut-item__key` 的 color 为 + * rgb(242,243,245)。原版设置窗口作用域把 `--wb-color-text-primary` 覆盖成 #f2f3f5,比全局 tokens + * 的 #fff 更具体;这里按同一优先级还原,避免正文变成纯白。 + */ +:root[data-theme=dark] .keyboard-shortcuts-panel { + --wb-color-text-primary: #f2f3f5; +} + +/* ---- 可点击绑定锚点:用 button 承载,去掉浏览器默认外观 ---- */ +.keyboard-shortcuts-panel button.shortcut-item__binding-tooltip-anchor { + padding: 0; + color: inherit; + font: inherit; + background: none; + border: none; +} +.keyboard-shortcuts-panel .shortcut-item__content, +.keyboard-shortcuts-panel .shortcut-item__binding .shortcut-item__error-hint { + margin-left: var(--wb-spacing-3); +} +.keyboard-shortcuts-panel .shortcut-item__prefix { + margin-right: var(--wb-spacing-2); + color: var(--wb-color-text-primary); + font-size: var(--wb-font-spec-body-sm-size); + line-height: var(--wb-font-spec-body-sm-line-height); +}
{error}
{settings.appInfo?.version}