feat(settings): replicate the WorkBuddy keyboard shortcuts panel and wire global bindings (TODO 12)
Recover the WorkBuddy 5.8.3 快捷键 page from REA evidence (keyboard-shortcuts-DOrivHwf.css + tokens): 49 recovered module rules, scoped light/dark tokens, the 24-row 命令/按键绑定/操作 sheet (panel 638x620, header 39, scroll 638x533, rows 41/45/44, keycaps 22) and the recording/ESC-cancel interaction. Wire the panel into SettingsDialog and keep only real bindings honest: 14 of 24 rows are marked supported, the remaining rows stay visually 1:1 but are labelled 尚未接入 and cannot pretend to save custom keys. Add Ctrl+K search focus, Ctrl+B / Ctrl+Shift+B left/right sidebar toggles, Ctrl+N new conversation (App -> Sidebar path) and Esc-to-stop while generating; fix the sidebar search focus race introduced by conditional mounting. Verify with scripts/verify-shortcuts-settings.cjs: 32 assertions covering geometry, row/key order, search filtering, recording/cancel, supported ids, dark theme surfaces and the four new global shortcuts in a real main window. All existing regressions stay green.
This commit is contained in:
1 parent
b36b52831b
commit
19460f0b13
19 files changed
+1008
-12
No files matched your search
@@ -372,11 +372,39 @@
|
|||||||
|
|
||||||
### 12. 快捷键页面与真实功能
|
### 12. 快捷键页面与真实功能
|
||||||
|
|
||||||
- [ ] 拆解页面布局、分组、按键显示、编辑/恢复默认等实际存在的交互。
|
- [x] 拆解页面布局、分组、按键显示、编辑/恢复默认等实际存在的交互。
|
||||||
- [ ] 建立快捷键 → 命令 → 实际动作映射,按 REA 证据确认支持范围。
|
- [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. 个性化页面与真实功能
|
### 13. 个性化页面与真实功能
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
@@ -125,10 +125,10 @@ if (!process.versions.electron) {
|
|||||||
// 原版 4 组;about 组按组件约定渲染为分隔线而不是标题。
|
// 原版 4 组;about 组按组件约定渲染为分隔线而不是标题。
|
||||||
check('nav keeps the four recovered groups', [nav.groups.length, nav.dividers], [3, 1])
|
check('nav keeps the four recovered groups', [nav.groups.length, nav.dividers], [3, 1])
|
||||||
// 通用(settings) 在 TODO 10 接入后同样可用。
|
// 通用(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 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('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 step('settings-search-excludes-removed')
|
||||||
await read(`(()=>{const input=document.querySelector('.settings-navigation input[type="search"]');
|
await read(`(()=>{const input=document.querySelector('.settings-navigation input[type="search"]');
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -260,12 +260,47 @@ export default function App(): React.JSX.Element {
|
|||||||
setResizing(true)
|
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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (event: KeyboardEvent): void => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === ',') {
|
if (event.isComposing) return
|
||||||
e.preventDefault()
|
const actions = shortcutActions.current
|
||||||
openSettings()
|
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)
|
window.addEventListener('keydown', onKey)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useSettingsStore } from '../stores/settings'
|
|||||||
import ModelSettingsPanel from './ModelSettingsPanel'
|
import ModelSettingsPanel from './ModelSettingsPanel'
|
||||||
import GeneralSettingsPanel from './GeneralSettingsPanel'
|
import GeneralSettingsPanel from './GeneralSettingsPanel'
|
||||||
import AppearanceSettingsPanel from './AppearanceSettingsPanel'
|
import AppearanceSettingsPanel from './AppearanceSettingsPanel'
|
||||||
|
import ShortcutsSettingsPanel from './ShortcutsSettingsPanel'
|
||||||
import SourceSettingsNavigation from './SourceSettingsNavigation'
|
import SourceSettingsNavigation from './SourceSettingsNavigation'
|
||||||
import { useSharedTheme } from '../useSharedTheme'
|
import { useSharedTheme } from '../useSharedTheme'
|
||||||
import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation'
|
import { SOURCE_WINDOWS_SETTINGS_TABS } from './SourceSettingsNavigation'
|
||||||
@@ -53,7 +54,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi
|
|||||||
return () => window.removeEventListener('keydown', escape)
|
return () => window.removeEventListener('keydown', escape)
|
||||||
}, [onClose])
|
}, [onClose])
|
||||||
const modal = <section className={`settings-modal${nativeWindow ? ' settings-modal--window' : ''}`} role="dialog" aria-label="设置" aria-modal={!nativeWindow}>
|
const modal = <section className={`settings-modal${nativeWindow ? ' settings-modal--window' : ''}`} role="dialog" aria-label="设置" aria-modal={!nativeWindow}>
|
||||||
<div className="settings-modal__nav"><SourceSettingsNavigation activeTab={section} supportedTabs={['settings', 'models', 'appearance']} visibleTabs={VISIBLE_SETTINGS_TABS}
|
<div className="settings-modal__nav"><SourceSettingsNavigation activeTab={section} supportedTabs={['settings', 'models', 'appearance', 'keyboardShortcuts']} visibleTabs={VISIBLE_SETTINGS_TABS}
|
||||||
onTabChange={(id) => { setError(''); setSection(id === 'models' ? 'model' : id) }} onUnavailable={() => setError('该设置尚未接入')} /></div>
|
onTabChange={(id) => { setError(''); setSection(id === 'models' ? 'model' : id) }} onUnavailable={() => setError('该设置尚未接入')} /></div>
|
||||||
<div className="settings-modal__content"><div className="settings-modal__panel">
|
<div className="settings-modal__content"><div className="settings-modal__panel">
|
||||||
{error && <p role="alert">{error}</p>}
|
{error && <p role="alert">{error}</p>}
|
||||||
@@ -61,6 +62,7 @@ export default function SettingsDialog({ theme, onThemeChange, onClose, nativeWi
|
|||||||
{section === 'model' && <ModelSettingsPanel models={settings.models} createRequest={createRequest} onCreateHandled={() => setCreateRequest(0)} onSave={settings.saveModel} onDelete={settings.deleteModel} onTest={(input) => window.ycode.models.test(input)} />}
|
{section === 'model' && <ModelSettingsPanel models={settings.models} createRequest={createRequest} onCreateHandled={() => setCreateRequest(0)} onSave={settings.saveModel} onDelete={settings.deleteModel} onTest={(input) => window.ycode.models.test(input)} />}
|
||||||
{section === 'workspace' && <div className="settings-window-panel"><h2>工作空间</h2>{settings.workspaces.map((space) => <button className="btn" key={space.id} title={space.root} onClick={() => void window.ycode.workspace.open(space.id).catch((error) => setError(String(error.message ?? error)))}>{space.name}</button>)}<button className="btn" onClick={() => void settings.attachWorkspace().catch((error) => setError(String(error.message ?? error)))}>打开已有文件夹</button></div>}
|
{section === 'workspace' && <div className="settings-window-panel"><h2>工作空间</h2>{settings.workspaces.map((space) => <button className="btn" key={space.id} title={space.root} onClick={() => void window.ycode.workspace.open(space.id).catch((error) => setError(String(error.message ?? error)))}>{space.name}</button>)}<button className="btn" onClick={() => void settings.attachWorkspace().catch((error) => setError(String(error.message ?? error)))}>打开已有文件夹</button></div>}
|
||||||
{section === 'appearance' && <AppearanceSettingsPanel theme={theme} onThemeChange={onThemeChange} />}
|
{section === 'appearance' && <AppearanceSettingsPanel theme={theme} onThemeChange={onThemeChange} />}
|
||||||
|
{section === 'keyboardShortcuts' && <ShortcutsSettingsPanel />}
|
||||||
{section === 'about' && <div className="settings-window-panel"><h2>YCode</h2><p>{settings.appInfo?.version}</p></div>}
|
{section === 'about' && <div className="settings-window-panel"><h2>YCode</h2><p>{settings.appInfo?.version}</p></div>}
|
||||||
</div></div>
|
</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2.75 4.5h10.5M6.25 4.5V3.25c0-.414.336-.75.75-.75h2c.414 0 .75.336.75.75V4.5M4.25 4.5l.5 8.25c.02.414.36.75.775.75h5.45c.415 0 .755-.336.775-.75l.5-8.25M6.75 7v4M9.25 7v4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string | null>(null)
|
||||||
|
const [errorId, setErrorId] = useState<string | null>(null)
|
||||||
|
const errorTimer = useRef<number | null>(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 (
|
||||||
|
<div className={panelClass}>
|
||||||
|
<div className="keyboard-shortcuts-panel__toolbar">
|
||||||
|
<div className="keyboard-shortcuts-panel__search">
|
||||||
|
<div className="wb-input-wrapper">
|
||||||
|
<input className="wb-input__inner" type="search" value={query} placeholder="搜索快捷键"
|
||||||
|
aria-label="搜索快捷键" onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => { if (event.key === 'Escape' && query) { event.preventDefault(); event.stopPropagation(); setQuery('') } }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="wb-button wb-button--medium wb-button--secondary wb-button--disabled" disabled
|
||||||
|
title="暂无自定义键位,恢复默认暂未接入">全部恢复默认</button>
|
||||||
|
</div>
|
||||||
|
<div className="keyboard-shortcuts-panel__list">
|
||||||
|
<div className="keyboard-shortcuts-panel__column-header">
|
||||||
|
<span className="keyboard-shortcuts-panel__col-command">命令</span>
|
||||||
|
<span className="keyboard-shortcuts-panel__col-binding">按键绑定</span>
|
||||||
|
<span className="keyboard-shortcuts-panel__col-action">操作</span>
|
||||||
|
</div>
|
||||||
|
<div className="keyboard-shortcuts-panel__list-scroll">
|
||||||
|
{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 (
|
||||||
|
<div key={row.id} data-shortcut-id={row.id} data-shortcut-supported={row.supported ? 'true' : 'false'}
|
||||||
|
className={`shortcut-item${row.editable ? '' : ' shortcut-item--not-editable'}${recording ? ' shortcut-item--recording' : ''}`}>
|
||||||
|
<div className="shortcut-item__label">{row.label}</div>
|
||||||
|
{recording ? (
|
||||||
|
<div className="shortcut-item__binding shortcut-item__binding--recording-layout">
|
||||||
|
<div className="shortcut-item__recording-row">
|
||||||
|
<span className={`shortcut-item__recording-box${error ? ' shortcut-item__recording-box--error' : ''}`}>
|
||||||
|
<span className={`shortcut-item__recording-text${error ? ' shortcut-item__recording-text--error' : ''}`}>按下快捷键</span>
|
||||||
|
<span className="shortcut-item__recording-cursor" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<button type="button" className="shortcut-item__esc-cancel" onClick={cancelRecording}>ESC 取消</button>
|
||||||
|
</div>
|
||||||
|
{error ? <span className="shortcut-item__error-hint" role="alert">{EDIT_UNSUPPORTED_HINT}</span> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="shortcut-item__binding">
|
||||||
|
{row.editable ? (
|
||||||
|
<button type="button" className="shortcut-item__binding-tooltip-anchor shortcut-item__binding-tooltip-anchor--clickable"
|
||||||
|
title={title} aria-label={`${row.label},当前绑定 ${row.prefix ? `${row.prefix} ` : ''}${row.keys}`}
|
||||||
|
onClick={() => startRecording(row)}>
|
||||||
|
<span className="shortcut-item__keys">{row.prefix ? <span className="shortcut-item__prefix">{row.prefix}</span> : null}<kbd className="shortcut-item__key">{row.keys}</kbd></span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="shortcut-item__binding-tooltip-anchor" title={title}>
|
||||||
|
<span className="shortcut-item__keys"><kbd className="shortcut-item__key">{row.keys}</kbd></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error ? <span className="shortcut-item__error-hint" role="alert">{EDIT_UNSUPPORTED_HINT}</span> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="shortcut-item__actions">
|
||||||
|
{row.editable ? (
|
||||||
|
<div className="shortcut-item__action-btn-group">
|
||||||
|
<button type="button" className="shortcut-item__action-btn shortcut-item__action-btn--delete wb-button wb-button--xsmall wb-button--ghost"
|
||||||
|
aria-disabled="true" disabled title="删除自定义键位暂未接入">
|
||||||
|
<span className="wb-button__icon"><TrashIcon /></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : <span className="shortcut-item__actions-placeholder" aria-hidden="true">—</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}) : <div className="keyboard-shortcuts-panel__empty">未找到匹配的快捷键</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -151,6 +151,14 @@ export default function Sidebar({ collapsed, onToggleCollapse, activeRoute = 'ch
|
|||||||
} catch (error) { setHint(error instanceof Error ? error.message : '无法新建任务') }
|
} catch (error) { setHint(error instanceof Error ? error.message : '无法新建任务') }
|
||||||
finally { taskStartLock.current = false }
|
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 => {
|
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 })
|
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)
|
window.addEventListener('ycode:focus-search', onFocusSearch)
|
||||||
return () => window.removeEventListener('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(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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<ShortcutRow> = [
|
||||||
|
{ 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
|
||||||
@@ -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}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user