Files
ycode/scripts/verify-shortcuts-settings.cjs
T
xiaohan.yuan 19460f0b13 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.
2026-10-17 09:57:30 +08:00

289 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
})
}