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:
xiaohan.yuan committed 2026-10-17 09:57:30 +08:00
1 parent b36b52831b
commit 19460f0b13
19 files changed
+1008 -12

No files matched your search

+288
View File
@@ -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)
})
}