fix(shortcuts): stabilize recording, focus, and desktop dispatch
This commit is contained in:
1 parent
365ef73c7c
commit
f815d6c733
129 files changed
+1774
-780
No files matched your search
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md
|
||||
2026-09-20-device-local-shortcut-preferences.md: 4d1dd29208d3bf700ac3a63a8ee58cfff758f60f
|
||||
2026-09-20-device-local-shortcut-preferences.zh.md: 9bf2401a02511eb8839d223f0dd6e574e8161f42
|
||||
2026-09-20-device-local-shortcut-preferences.md: 3da3d3a29c5fd068f3498de78c2de84d3f30fe45
|
||||
2026-09-20-device-local-shortcut-preferences.zh.md: 3c5420e8708bf168a61d3d60e6a6a2add60a9455
|
||||
+2
-2
@@ -12,7 +12,7 @@ Keyboard bindings depend on the receiving device's operating system and browser
|
||||
|
||||
[The shortcut service](../../../../packages/client/shortcuts/README.md) stores only versioned overrides per runtime/platform. Web owns origin-local browser storage; Electron main owns `userData/keybindings.json`, independently of Harness home. Both adapters share validation and transaction logic. Successful persistence publishes one accepted configuration from which command matching, keycaps, and native interception derive.
|
||||
|
||||
Missing commands retain dormant overrides. Explicit overrides win over newly introduced defaults; conflicting explicit overrides are all disabled. Unreadable configuration blocks ordinary edits and preserves the last accepted values. Explicit recovery retains the original content before replacing the document.
|
||||
Missing commands retain dormant overrides. Explicit overrides win over newly introduced defaults; conflicting explicit overrides are all disabled. Unreadable configuration blocks ordinary edits and preserves the last accepted values. Restore All clears only the accepted current profile and cannot overwrite unreadable or future-version data.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -24,4 +24,4 @@ Missing commands retain dormant overrides. Explicit overrides win over newly int
|
||||
|
||||
## Consequences
|
||||
|
||||
Devices and Web/Desktop do not synchronize bindings automatically. Recovery can replace every profile in an unreadable document only after preserving its original bytes. Browser concurrency does not promise lossless merging. Atomic Desktop replacement uses the shared writer's Windows retry behavior without promising fsync durability. Deterministic transaction, browser-storage, and Desktop-file tests cover failure retention, conflicts, stale drafts, and recovery backups; platform keyboard acceptance remains separate from persistence correctness.
|
||||
Devices and Web/Desktop do not synchronize bindings automatically. Repairing unreadable preferences requires correcting the stored document; application edits preserve those bytes. Browser concurrency does not promise lossless merging. Atomic Desktop replacement uses the shared writer's Windows retry behavior without promising fsync durability. Deterministic transaction, browser-storage, and Desktop-file tests cover failure retention, conflicts, stale drafts, and refusal to replace unreadable data; platform keyboard acceptance remains separate from persistence correctness.
|
||||
+2
-2
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
[快捷键服务](../../../../packages/client/shortcuts/README.zh.md) 只保存按运行端/平台区分的版本化覆盖。Web 使用同 origin 的浏览器存储;Electron main 管理 `userData/keybindings.json`,与 Harness home 独立。两端适配器共享校验和事务逻辑。持久化成功后发布一个已接受配置,命令匹配、键帽和原生拦截都由此派生。
|
||||
|
||||
未加载命令保留休眠覆盖。显式覆盖优先于新增默认值,相互冲突的显式覆盖全部禁用。不可读配置阻止普通编辑,并保留上次接受的值。显式恢复先保留原始内容再替换文档。
|
||||
未加载命令保留休眠覆盖。显式覆盖优先于新增默认值,相互冲突的显式覆盖全部禁用。不可读配置阻止普通编辑,并保留上次接受的值。全部恢复只清除已接受的当前平台覆盖,不会覆盖不可读或未来版本的数据。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -24,4 +24,4 @@ Status: implemented
|
||||
|
||||
## 影响
|
||||
|
||||
不同设备以及 Web/Desktop 不自动同步绑定。只有保留原始内容后,恢复流程才能替换不可读文档中的所有平台配置。浏览器并发不承诺无损合并。Desktop 原子替换使用共享写入器的 Windows 重试行为,不承诺 fsync 持久性。确定性的事务、浏览器存储和桌面文件测试覆盖失败保留、冲突、过期草稿和恢复副本;平台键盘验收与持久化正确性分别验证。
|
||||
不同设备以及 Web/Desktop 不自动同步绑定。修复不可读偏好需要更正存储文档;应用内编辑保留这些原始内容。浏览器并发不承诺无损合并。Desktop 原子替换使用共享写入器的 Windows 重试行为,不承诺 fsync 持久性。确定性的事务、浏览器存储和桌面文件测试覆盖失败保留、冲突、过期草稿以及拒绝替换不可读数据;平台键盘验收与持久化正确性分别验证。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: eacf46d7786cea811ba939271221c8715207595a
|
||||
README.zh.md: 9281169290fab4161c58f7d1230e6f34eae22d94
|
||||
README.md: 66e94a8d334f5c48b387bf6f5d7d5a7af60e2d46
|
||||
README.zh.md: 5a765a09c217babe70f9ec4fc224b7442a4ce192
|
||||
@@ -22,9 +22,9 @@ Press F12 (Fn+F12 on media-key keyboards), Command+Option+I on macOS, or Ctrl+Sh
|
||||
|
||||
The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.
|
||||
|
||||
Shortcut overrides are stored in `app.getPath('userData')/keybindings.json`, separately from `DSH_HOME`. The main process validates and serializes changes before publishing accepted bindings. Failed reads retain the last accepted bindings; explicit recovery saves an original-content backup before rebuilding defaults. Development can isolate these preferences with `DSH_DESKTOP_USER_DATA_DIR`; the launcher prints its resolved path. See the [shortcut service](../../packages/client/shortcuts/README.md) for format and conflict semantics.
|
||||
Shortcut overrides are stored in `app.getPath('userData')/keybindings.json`, separately from `DSH_HOME`. The main process validates and serializes changes before publishing accepted bindings. Failed reads retain the last accepted bindings and block edits, including Restore All; unreadable and future-version files remain unchanged. Development can isolate these preferences with `DSH_DESKTOP_USER_DATA_DIR`; the launcher prints its resolved path. See the [shortcut service](../../packages/client/shortcuts/README.md) for format and conflict semantics.
|
||||
|
||||
The product File menu routes Close Page or Window through the client page owner. On Windows and macOS, all complete accepted bindings are intercepted before main-document and embedded-frame input, including editing and terminal input. Recording and input-method composition remain protected. Two-key chords leave the first key’s initial press available to the page and do not intercept its release; complete chords and their repeats are consumed. The renderer leaves configurable binding dispatch to the native adapter. Chords have no native menu accelerator. Accepted commands are forwarded once through the trusted preload. Linux retains its existing dispatch policy. Closing the last window preserves macOS application lifetime; Windows exits the desktop instance and stops its tasks.
|
||||
The macOS File menu displays the accepted single-key binding, including arrow keys, and routes Close Page or Window through the client page owner. On Windows and macOS, all complete accepted bindings are intercepted before main-document and embedded-frame input, including editing and terminal input. Recording and input-method composition remain protected. Two-key chords leave the first key’s initial press available to the page and do not intercept its release; complete chords and their repeats are consumed. The renderer leaves configurable binding dispatch to the native adapter. Chords have no native menu accelerator. Accepted commands are forwarded once through the trusted preload. Linux retains its existing dispatch policy. Closing the last window preserves macOS application lifetime; Windows exits the desktop instance and stops its tasks.
|
||||
|
||||
The macOS PNG uses an inset rounded background for legacy ICNS packaging, with representations up to 1024 pixels. It is a flattened icon, not an Icon Composer document. Apple's [app icon guidance](https://developer.apple.com/design/human-interface-guidelines/app-icons) describes unmasked layers for Icon Composer; those inputs require a separate macOS export and must not reuse the rounded ICNS artwork. Verify Finder and Dock appearance on supported macOS versions before release.
|
||||
|
||||
@@ -68,7 +68,7 @@ The product UI retains Web actions, including "Open In..." through the shared au
|
||||
|
||||
Electron chooses typed English or Chinese shell copy from its application locale and falls back to English. The macOS application bundle declares English and Simplified Chinese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources.
|
||||
|
||||
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
|
||||
Windows uses a 40-DIP caption with native window controls and colors synchronized from the application palette. Localized Application and Edit entries beside the sidebar toggle open native popup menus. They mount only after the application frame publishes its shell overlay seat, and remain absent during startup loading. Application provides Check for Updates and Exit; Edit provides undo, redo, cut, copy, paste, delete, and select all by sending the corresponding keys to the focused editor, independently of custom shortcut bindings. Plugin management uses the main application's Plugins page. No separate native menu row appears on Alt. Other platforms retain their native menus. Editable fields retain keyboard commands and a context menu without shortcut labels; Chromium supplies command availability, and selected read-only text offers Copy.
|
||||
|
||||
On macOS the custom application menu also declares the standard File, Window, and application menus, because replacing Electron's default menu drops Close Window (⌘W), Minimize (⌘M), and Hide (⌘H). Linux keeps the application and Edit menus.
|
||||
|
||||
|
||||
@@ -22,9 +22,9 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie
|
||||
|
||||
设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。
|
||||
|
||||
快捷键覆盖保存在 `app.getPath('userData')/keybindings.json`,与 `DSH_HOME` 分离。主进程校验并串行保存修改后才发布已接受键位。读取失败保留上次接受的键位;显式恢复先保存原始内容副本再重建默认值。开发时可通过 `DSH_DESKTOP_USER_DATA_DIR` 隔离这些偏好,启动器会输出解析后的路径。格式和冲突语义见[快捷键服务](../../packages/client/shortcuts/README.zh.md)。
|
||||
快捷键覆盖保存在 `app.getPath('userData')/keybindings.json`,与 `DSH_HOME` 分离。主进程校验并串行保存修改后才发布已接受键位。读取失败保留上次接受的键位并阻止编辑,包括全部恢复;不可读和未来版本的文件保持不变。开发时可通过 `DSH_DESKTOP_USER_DATA_DIR` 隔离这些偏好,启动器会输出解析后的路径。格式和冲突语义见[快捷键服务](../../packages/client/shortcuts/README.zh.md)。
|
||||
|
||||
产品“文件”菜单通过 Client 页面 owner 路由“关闭页面或窗口”。Windows 和 macOS 在主文档和内嵌 frame 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 preload 转发一次。Linux 保留原有分发策略。关闭最后一个窗口后 macOS 保留应用生命周期;Windows 退出桌面实例并停止其任务。
|
||||
macOS“文件”菜单显示已接受的单键绑定(包括方向键),并通过 Client 页面 owner 路由“关闭页面或窗口”。Windows 和 macOS 在主文档和内嵌 frame 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 preload 转发一次。Linux 保留原有分发策略。关闭最后一个窗口后 macOS 保留应用生命周期;Windows 退出桌面实例并停止其任务。
|
||||
|
||||
macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。
|
||||
|
||||
@@ -68,7 +68,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
|
||||
|
||||
Electron 根据应用语言选择类型化的英文或中文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语和简体中文,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。
|
||||
|
||||
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。 顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离预加载和壳 frame 之间的私有 MessageChannel 传递。
|
||||
Windows 使用 40 DIP 顶栏,保留原生窗口按钮,颜色随应用调色板同步。侧栏开关旁的本地化“应用”和“编辑”入口打开原生弹出菜单。仅当应用框架发布 shell overlay 席位后才挂载菜单,启动加载期间不显示。“应用”提供检查更新和退出;“编辑”向当前编辑器发送对应按键,提供撤销、重做、剪切、复制、粘贴、删除和全选,不受自定义快捷键绑定影响。插件管理使用主应用的“插件”页面。按 Alt 不会出现额外的原生菜单行。其他平台保留原生菜单。可编辑区域保留快捷键和不带快捷键标注的右键菜单;命令可用状态由 Chromium 提供,选中的只读文本提供“复制”命令。
|
||||
|
||||
macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜单,因为替换 Electron 的默认菜单会丢掉 Close Window(⌘W)、Minimize(⌘M)和 Hide(⌘H)。Linux 保留应用菜单和 Edit 菜单。
|
||||
|
||||
@@ -90,7 +90,6 @@ macOS 上自定义应用菜单还会声明标准的 File、Window 和应用菜
|
||||
|
||||
恢复操作等待 Host 关闭后才修改插件启用状态。原生恢复操作在 profile 事务锁内调用共享 app-boot 恢复函数。它禁用第三方 bundle,并将 profile 的 `cordis.patch.yml` 重命名为 `cordis.patch.yml.bak-<timestamp>`(重名时追加序号),无需解析;下次启动创建空 patch。已安装包和已有备份保留。home 级 patch 不变。Electron 控制台记录备份路径(或原文件不存在)以及 home 级 patch 未修改。profile 数据无效、重命名失败或写入失败会作为恢复操作错误报告;已完成的修改保留,Desktop 不会假装恢复成功后重启。Desktop 不提供 profile 重置操作或应急 HTML 文档。
|
||||
|
||||
|
||||
## 开发
|
||||
|
||||
开发环境应用菜单提供“刷新页面”(macOS 为 Cmd+R,其他平台为 Ctrl+R)和“重启应用与 Host”。重启会等待 Host 关闭,再重新启动 Electron 和新的 Host;这两项操作都不会重新构建源码。
|
||||
@@ -377,7 +376,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
|
||||
|
||||
登录和策略请求共用内存 Session,与产品窗口及 updater 隔离;应用重启后需要重新登录。关闭窗口取消登录,导航失败提供本地化重试提示。返回服务后重新查询策略;重定向、Cookie 或 HTTP 422 都不是有效策略决定。取消、登录过期及无效响应均保留已知强更阻塞。固定登录结果写入进程诊断及可选更新日志;登录控制器不记录 Cookie、OAuth 参数或远程错误原文。真实 Harness 网关/API 联调及 macOS 登录验收仍未完成。
|
||||
|
||||
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
|
||||
扁平化的 `40005` 打开壳拥有的模态窗口,并拒绝后续插件修改,不停止现有 Host 任务。服务端标题与详情是可选纯文本,缺失时使用客户端兜底文案;缺少下载地址或地址未获批准时隐藏外部页面操作,不解除阻塞。macOS 强更蒙层原位渐入渐出,在更新状态切换时保留蒙层,并将父窗口焦点和键盘输入重定向到蒙层。Windows 由隔离的应用 preload 在主窗口内挂载 shell 来源的 frame,以蒙层和弹窗覆盖 40 DIP 顶栏下方的内容区域。它阻止背景页面输入,不创建额外的原生窗口;移动和最大化只作用于主窗口。顶栏菜单保留鼠标和键盘操作,顶栏中的 Web 控件仍可触达。共享 DOM 只约束展示,不构成安全边界:产品脚本可以隐藏蒙层,但不能借此清除主进程策略或授权安装。更新操作与状态通过隔离 preload 和壳 frame 之间的私有 MessageChannel 传递。父窗口保持启用,原生移动、缩放、最小化、最大化和关闭控件仍可操作。退出应用会完成清理,不会解除更新要求;Esc 不会关闭覆盖层。批准安装后,安装器接管的退出流程会在 Electron 关闭窗口前释放模态窗口。下载、含准备步骤的文件校验、任务检查和安装确认共用同一弹窗。只有第二次用户批准才允许任务收尾和安装;稍后更新保留阻塞与安装包。仅存在受影响任务时,重启文案才提示正在停止任务。策略不跨应用重启持久化,策略响应也不作废或替换 updater 产物。
|
||||
|
||||
失败时在同一弹窗内保留阻塞、本地化重试提示和折叠诊断。白名单下载页面操作只在恢复状态出现,不与正常下载或安装并列。请求打开浏览器后立即提供复制替代入口,即使系统请求尚未返回;请求成功不证明网页已打开。复制失败时展示完整、只读的地址供手动复制。浏览器与剪贴板结果不覆盖 updater 错误。只有新的有效无需强更响应才解除阻塞;阻塞期间仍可使用顶部菜单检查。
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ export const DESKTOP_IPC = {
|
||||
shortcutsInput: 'dsh-desktop:shortcuts-input',
|
||||
shortcutsCloseWindow: 'dsh-desktop:shortcuts-close-window',
|
||||
shortcutsGet: 'dsh-desktop:shortcuts-get',
|
||||
shortcutsReload: 'dsh-desktop:shortcuts-reload',
|
||||
shortcutsEdit: 'dsh-desktop:shortcuts-edit',
|
||||
shortcutsChanged: 'dsh-desktop:shortcuts-changed',
|
||||
shortcutsRecording: 'dsh-desktop:shortcuts-recording',
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
/** Device-local shortcut preferences in Electron userData; writes share one serialized coordinator. */
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { ShortcutPersistence } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import type { ShortcutConfigSnapshot, ShortcutPlatform } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
|
||||
/**
|
||||
* Open the device configuration with atomic replacement and exclusive recovery backups.
|
||||
* Open the device configuration with atomic replacement.
|
||||
* @param userData - Electron-owned userData directory, never a Renderer-supplied path.
|
||||
* @param platform - local input platform.
|
||||
* @param publish - updates the native binding index and trusted product page after commit.
|
||||
@@ -24,6 +23,5 @@ export function desktopKeybindings(userData: string, platform: ShortcutPlatform,
|
||||
}
|
||||
},
|
||||
write: raw => writeFileAtomic(path, raw, { mode: 0o600, dirMode: 0o700 }),
|
||||
backup: raw => writeFile(`${path}.backup.${randomUUID()}`, raw, { mode: 0o600, flag: 'wx' }),
|
||||
}, 'desktop', platform, false, publish)
|
||||
}
|
||||
@@ -10,13 +10,19 @@ import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts'
|
||||
* @param getWindow - current product window.
|
||||
* @param userData - Electron-resolved device preference directory.
|
||||
* @param platform - local device platform.
|
||||
* @param updateMenu - rebuild the application menu from current accepted bindings.
|
||||
* @returns window attachment and application teardown operations.
|
||||
* @param updateMenu - rebuild the application menu when the close accelerator or availability changes.
|
||||
* @returns menu construction, editor key delivery, window attachment, and teardown operations.
|
||||
*/
|
||||
export function installDesktopShortcuts(
|
||||
getWindow: () => BrowserWindow | undefined, userData: string, platform: ShortcutPlatform, updateMenu: () => void,
|
||||
): {
|
||||
fileMenu(labels: { fileMenu: string; closePage: string }): MenuItemConstructorOptions
|
||||
/**
|
||||
* Send a native Edit action to the editor without matching user shortcuts.
|
||||
* @param keyCode - edit key.
|
||||
* @param modifiers - edit modifiers.
|
||||
*/
|
||||
sendEditingKey(keyCode: string, modifiers: Array<'control'>): void
|
||||
attach(window: BrowserWindow): void
|
||||
dispose(): void
|
||||
} {
|
||||
@@ -24,11 +30,14 @@ export function installDesktopShortcuts(
|
||||
let recording = false
|
||||
let revision: ShortcutRevision | undefined
|
||||
let closeBinding: NormalizedBinding | null = null
|
||||
let editingInput: BrowserWindow['webContents'] | undefined
|
||||
const scopedDesktop = platform === 'windows' || platform === 'macos'
|
||||
const disposers = new Set<() => void>()
|
||||
const embeddedCommands = new Set(['page.close', 'page.refresh', 'pane.split', 'pane.fullscreen.toggle',
|
||||
'sidebar.right.toggle', 'sidebar.left.toggle', 'workspace.files', 'terminal.new', 'shortcuts.open', 'settings.open'])
|
||||
let embeddedKeys = new Set<string>()
|
||||
const closeAccelerator = (): string | undefined => presentBinding(closeBinding, platform).aria
|
||||
?.replace('Meta+', 'Command+').replace(/Arrow(Up|Down|Left|Right)$/u, '$1')
|
||||
const sendMenuClose = (): void => {
|
||||
const window = getWindow()
|
||||
if (window === undefined || window.isDestroyed() || !window.isFocused() || !window.isEnabled()
|
||||
@@ -37,6 +46,8 @@ export function installDesktopShortcuts(
|
||||
}
|
||||
let keys = new Set<string>()
|
||||
const publish = (snapshot: ShortcutConfigSnapshot): void => {
|
||||
const wasEnabled = revision !== undefined
|
||||
const previousAccelerator = closeAccelerator()
|
||||
revision = definitions.length === 0 || snapshot.status === 'loading' ? undefined : snapshot.revision
|
||||
const rows = snapshot.status === 'loading' ? [] : effectiveShortcuts(definitions, snapshot.document, 'desktop', platform)
|
||||
keys = new Set(rows.flatMap(row => row.binding !== null && row.issue === null && row.conflicts.length === 0
|
||||
@@ -46,7 +57,7 @@ export function installDesktopShortcuts(
|
||||
? [bindingKey(row.binding)] : []))
|
||||
const close = rows.find(row => row.id === 'page.close')
|
||||
closeBinding = close?.issue === null && close.conflicts.length === 0 ? close.binding : null
|
||||
updateMenu()
|
||||
if (wasEnabled !== (revision !== undefined) || previousAccelerator !== closeAccelerator()) updateMenu()
|
||||
const window = getWindow()
|
||||
if (window !== undefined && !window.isDestroyed() && window.webContents.mainFrame.url.startsWith('dsh-app://app/')) {
|
||||
window.webContents.send(DESKTOP_IPC.shortcutsChanged, snapshot)
|
||||
@@ -64,9 +75,8 @@ export function installDesktopShortcuts(
|
||||
assertSender(event)
|
||||
definitions = parseShortcutDefinitions(input)
|
||||
persistence.setDefinitions(definitions)
|
||||
return persistence.reload()
|
||||
return persistence.readCurrent()
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.shortcutsReload, async (event) => { assertSender(event); return persistence.reload() })
|
||||
ipcMain.handle(DESKTOP_IPC.shortcutsEdit, async (event, input: unknown, revision: unknown) => {
|
||||
assertSender(event)
|
||||
if (typeof revision !== 'string') throw new Error('desktop shortcuts: invalid revision')
|
||||
@@ -84,8 +94,24 @@ export function installDesktopShortcuts(
|
||||
window.close()
|
||||
})
|
||||
return {
|
||||
sendEditingKey(keyCode, modifiers) {
|
||||
const window = getWindow()
|
||||
if (window === undefined || window.isDestroyed()) return
|
||||
const contents = window.webContents
|
||||
contents.focus()
|
||||
const previous = editingInput
|
||||
editingInput = contents
|
||||
try {
|
||||
// Electron emits before-input-event synchronously for these editor-owned keys.
|
||||
contents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
|
||||
contents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
|
||||
} finally {
|
||||
editingInput = previous
|
||||
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(recording)
|
||||
}
|
||||
},
|
||||
fileMenu: (labels) => {
|
||||
const accelerator = presentBinding(closeBinding, platform).aria?.replace('Meta+', 'Command+')
|
||||
const accelerator = closeAccelerator()
|
||||
return { label: labels.fileMenu, submenu: [{
|
||||
id: 'dsh-page-close', label: labels.closePage, enabled: revision !== undefined,
|
||||
...accelerator === undefined ? {} : { accelerator },
|
||||
@@ -101,9 +127,8 @@ export function installDesktopShortcuts(
|
||||
let inputRevision: ShortcutRevision | undefined
|
||||
const clear = (): void => {
|
||||
definitions = []; keys.clear(); embeddedKeys.clear(); held.clear(); consumed.clear()
|
||||
inputFrame = null; recording = false; deadKey = false; revision = undefined; closeBinding = null
|
||||
inputFrame = null; recording = false; deadKey = false
|
||||
persistence.setDefinitions(null)
|
||||
updateMenu()
|
||||
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false)
|
||||
}
|
||||
const navigation = (event: Electron.Event<Electron.WebContentsDidStartNavigationEventParams>): void => {
|
||||
@@ -113,6 +138,12 @@ export function installDesktopShortcuts(
|
||||
deadKey = false; held.clear(); consumed.clear(); inputFrame = null; contents.setIgnoreMenuShortcuts(false)
|
||||
}
|
||||
const beforeInput = (event: Electron.Event, input: Input): void => {
|
||||
if (editingInput === contents) {
|
||||
held.clear()
|
||||
consumed.clear()
|
||||
contents.setIgnoreMenuShortcuts(true)
|
||||
return
|
||||
}
|
||||
if (!window.isFocused() || !window.isEnabled() || revision === undefined) {
|
||||
contents.setIgnoreMenuShortcuts(false)
|
||||
held.clear()
|
||||
@@ -192,7 +223,7 @@ export function installDesktopShortcuts(
|
||||
dispose() {
|
||||
for (const dispose of disposers) dispose()
|
||||
persistence.dispose()
|
||||
for (const channel of [DESKTOP_IPC.shortcutsGet, DESKTOP_IPC.shortcutsReload,
|
||||
for (const channel of [DESKTOP_IPC.shortcutsGet,
|
||||
DESKTOP_IPC.shortcutsEdit, DESKTOP_IPC.shortcutsRecording, DESKTOP_IPC.shortcutsCloseWindow]) {
|
||||
ipcMain.removeHandler(channel)
|
||||
}
|
||||
|
||||
@@ -800,9 +800,7 @@ async function main(): Promise<void> {
|
||||
label,
|
||||
...(accelerator === undefined ? {} : { accelerator }),
|
||||
click: () => {
|
||||
window.webContents.focus()
|
||||
window.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
|
||||
window.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
|
||||
shortcuts.sendEditingKey(keyCode, modifiers)
|
||||
},
|
||||
})
|
||||
const items: MenuItemConstructorOptions[] = name === 'application' ? applicationItems() : [
|
||||
|
||||
@@ -32,7 +32,6 @@ function createProductApi(): DshDesktopProductApi {
|
||||
},
|
||||
shortcuts: {
|
||||
get: definitions => ipcRenderer.invoke(DESKTOP_IPC.shortcutsGet, definitions) as Promise<ShortcutConfigSnapshot>,
|
||||
reload: () => ipcRenderer.invoke(DESKTOP_IPC.shortcutsReload) as Promise<ShortcutConfigSnapshot>,
|
||||
edit: (edit, revision) => ipcRenderer.invoke(DESKTOP_IPC.shortcutsEdit, edit, revision) as Promise<ShortcutSaveResult>,
|
||||
recording: active => ipcRenderer.invoke(DESKTOP_IPC.shortcutsRecording, active) as Promise<void>,
|
||||
subscribe(listener) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
@@ -12,23 +12,23 @@ it('persists into the supplied userData, reloads null bindings, and retains orig
|
||||
const first = desktopKeybindings(root, 'windows', (value) => { state = value })
|
||||
onTestFinished(() => { first.dispose() })
|
||||
const id = 'test.toggle' as ShortcutCommandId
|
||||
const definitions = [{ id, defaults: { desktop: { code: 'KeyB', modifiers: ['primary'] as const } } }]
|
||||
const definitions = [{ id, defaults: {
|
||||
'desktop:macos': { code: 'KeyB', modifiers: ['primary'] as const },
|
||||
'desktop:windows': { code: 'KeyB', modifiers: ['primary'] as const },
|
||||
'desktop:linux': { code: 'KeyB', modifiers: ['primary'] as const },
|
||||
} }]
|
||||
first.setDefinitions(definitions)
|
||||
await first.reload()
|
||||
await first.readCurrent()
|
||||
expect((await first.edit({ type: 'set', id, binding: null }, state.revision)).status).toBe('saved')
|
||||
const path = join(root, 'keybindings.json')
|
||||
expect(JSON.parse(await readFile(path, 'utf8')) as unknown).toMatchObject({ profiles: { 'desktop:windows': { [id]: null } } })
|
||||
const second = desktopKeybindings(root, 'windows', (value) => { state = value })
|
||||
onTestFinished(() => { second.dispose() })
|
||||
second.setDefinitions(definitions)
|
||||
expect((await second.reload()).document.profiles['desktop:windows']?.[id]).toBeNull()
|
||||
expect((await second.readCurrent()).document.profiles['desktop:windows']?.[id]).toBeNull()
|
||||
const original = '{"schemaVersion":7,"custom":"retain exactly"}\n'
|
||||
await writeFile(path, original)
|
||||
expect((await second.reload()).error).toBe('future')
|
||||
expect((await second.readCurrent()).error).toBe('future')
|
||||
expect((await second.edit({ type: 'reset-all' }, state.revision)).status).toBe('unreadable')
|
||||
expect(await readFile(path, 'utf8')).toBe(original)
|
||||
expect((await second.edit({ type: 'recover' }, state.revision)).status).toBe('saved')
|
||||
const backup = (await readdir(root)).find(file => file.startsWith('keybindings.json.backup.'))!
|
||||
expect(await readFile(join(root, backup), 'utf8')).toBe(original)
|
||||
expect(JSON.parse(await readFile(path, 'utf8')) as unknown).toEqual({ schemaVersion: 2, profiles: {} })
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron'
|
||||
import type { DesktopShortcutInput, ShortcutCommandId, ShortcutConfigSnapshot,
|
||||
import type { DesktopShortcutInput, ShortcutBinding, ShortcutCommandId, ShortcutConfigSnapshot,
|
||||
ShortcutDefinition, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import { ShortcutRegistry } from '../../../packages/client/shortcuts/src/client/registry.ts'
|
||||
import { installKeyboard } from '../../../packages/client/shortcuts/src/client/dom.ts'
|
||||
@@ -17,16 +17,21 @@ vi.mock('electron', () => ({ ipcMain: ipc }))
|
||||
const { installDesktopShortcuts } = await import('../src/keyboard.ts')
|
||||
afterEach(() => { vi.clearAllMocks() })
|
||||
|
||||
function desktopDefaults(binding: ShortcutBinding): ShortcutDefinition['defaults'] {
|
||||
return { 'desktop:macos': binding, 'desktop:windows': binding, 'desktop:linux': binding }
|
||||
}
|
||||
|
||||
async function fixture(platform: 'macos' | 'windows' = 'macos') {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-keyboard-'))
|
||||
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
const frame = { url: 'dsh-app://app/' }
|
||||
const contents = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame,
|
||||
isDestroyed: () => false, send: vi.fn(), setIgnoreMenuShortcuts: vi.fn() })
|
||||
isDestroyed: () => false, send: vi.fn(), setIgnoreMenuShortcuts: vi.fn(), focus: vi.fn(), sendInputEvent: vi.fn() })
|
||||
const window = Object.assign(new EventEmitter(), { webContents: contents, isDestroyed: vi.fn(() => false),
|
||||
isFocused: vi.fn(() => true), isEnabled: vi.fn(() => true), close: vi.fn() })
|
||||
let current: BrowserWindow | undefined = window as unknown as BrowserWindow
|
||||
const keyboard = installDesktopShortcuts(() => current, root, platform, vi.fn())
|
||||
const updateMenu = vi.fn()
|
||||
const keyboard = installDesktopShortcuts(() => current, root, platform, updateMenu)
|
||||
keyboard.attach(current)
|
||||
onTestFinished(() => { keyboard.dispose() })
|
||||
const handlers = new Map<string, (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown>(
|
||||
@@ -34,9 +39,9 @@ async function fixture(platform: 'macos' | 'windows' = 'macos') {
|
||||
const event = { sender: contents, senderFrame: frame } as unknown as IpcMainInvokeEvent
|
||||
const call = async <T>(channel: string, ...args: unknown[]): Promise<T> => await handlers.get(channel)!(event, ...args) as T
|
||||
const definitions: readonly ShortcutDefinition[] = [
|
||||
{ id: 'sidebar.left.toggle' as ShortcutCommandId, defaults: { desktop: { code: 'KeyB', modifiers: ['primary'] } } },
|
||||
{ id: 'sidebar.left.toggle' as ShortcutCommandId, defaults: desktopDefaults({ code: 'KeyB', modifiers: ['primary'] }) },
|
||||
]
|
||||
return { keyboard, window, contents, frame, event, handlers, call, definitions, detach: () => { current = undefined } }
|
||||
return { keyboard, window, contents, frame, event, handlers, call, definitions, updateMenu, detach: () => { current = undefined } }
|
||||
}
|
||||
|
||||
it('mirrors only successful bindings, suppresses recording menus, and invalidates pre-navigation drafts', async () => {
|
||||
@@ -85,8 +90,8 @@ it('routes embedded input once, follows rebindings, and guards native window clo
|
||||
const f = await fixture()
|
||||
const closeItem = () => (f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[])[0]!
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, [
|
||||
{ id: 'page.close', defaults: { desktop: { code: 'KeyW', modifiers: ['primary'] } } },
|
||||
{ id: 'page.refresh', defaults: { desktop: { code: 'KeyR', modifiers: ['primary'] } } },
|
||||
{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) },
|
||||
{ id: 'page.refresh', defaults: desktopDefaults({ code: 'KeyR', modifiers: ['primary'] }) },
|
||||
])
|
||||
expect(closeItem().accelerator).toBe('Command+W')
|
||||
const preventDefault = vi.fn()
|
||||
@@ -126,10 +131,50 @@ it('routes embedded input once, follows rebindings, and guards native window clo
|
||||
expect(f.contents.listenerCount('before-input-event')).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ArrowUp', 'Command+Up'], ['ArrowDown', 'Command+Down'],
|
||||
['ArrowLeft', 'Command+Left'], ['ArrowRight', 'Command+Right'],
|
||||
])('uses the Electron accelerator for a saved %s binding', async (code, accelerator) => {
|
||||
const f = await fixture()
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
|
||||
[{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }])
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'page.close', binding: { code, modifiers: ['primary'] } }, initial.revision)
|
||||
expect(saved.status).toBe('saved')
|
||||
const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[]
|
||||
expect(menu[0]?.accelerator).toBe(accelerator)
|
||||
})
|
||||
|
||||
it('refreshes the native menu only when its accelerator or availability changes', async () => {
|
||||
const f = await fixture()
|
||||
const close = { id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }
|
||||
expect(f.updateMenu).not.toHaveBeenCalled()
|
||||
await f.call(DESKTOP_IPC.shortcutsGet, [close])
|
||||
expect(f.updateMenu).toHaveBeenCalledOnce()
|
||||
const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[]
|
||||
const registered = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, [close, ...f.definitions])
|
||||
const edited = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyJ', modifiers: ['primary'] } }, registered.revision)
|
||||
expect(edited.status).toBe('saved')
|
||||
expect(f.updateMenu).toHaveBeenCalledOnce()
|
||||
f.contents.send.mockClear()
|
||||
const click = menu[0]!.click as () => void
|
||||
click()
|
||||
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput,
|
||||
{ kind: 'menu', commandId: 'page.close', revision: edited.snapshot.revision })
|
||||
await f.call(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'page.close', binding: { code: 'KeyQ', modifiers: ['primary'] } }, edited.snapshot.revision)
|
||||
expect(f.updateMenu).toHaveBeenCalledTimes(2)
|
||||
f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
expect(f.updateMenu).toHaveBeenCalledTimes(3)
|
||||
f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false })
|
||||
expect(f.updateMenu).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('prioritizes %s custom editing bindings before both main and embedded frame handlers', async (platform) => {
|
||||
const f = await fixture(platform)
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
|
||||
[{ id: 'session.new', defaults: { desktop: { code: 'KeyN', modifiers: ['primary'] } } }])
|
||||
[{ id: 'session.new', defaults: desktopDefaults({ code: 'KeyN', modifiers: ['primary'] }) }])
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'session.new', binding: { code: 'KeyC', modifiers: ['primary'] } }, initial.revision)
|
||||
expect(saved.status).toBe('saved')
|
||||
@@ -169,7 +214,7 @@ it.each(['macos', 'windows'] as const)('prioritizes %s custom editing bindings b
|
||||
it.each(['macos', 'windows'] as const)('intercepts complete %s chords once and leaves their first key untouched', async (platform) => {
|
||||
const f = await fixture(platform)
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet,
|
||||
[{ id: 'page.close', defaults: { desktop: { code: 'KeyW', modifiers: ['primary'] } } }])
|
||||
[{ id: 'page.close', defaults: desktopDefaults({ code: 'KeyW', modifiers: ['primary'] }) }])
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'page.close', binding: { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } }, initial.revision)
|
||||
expect(saved.status).toBe('saved')
|
||||
@@ -206,6 +251,72 @@ it.each(['macos', 'windows'] as const)('intercepts complete %s chords once and l
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['C', 'V', 'Z'])('delivers Windows Edit %s to the editor and physical input to its shortcut', async (keyCode) => {
|
||||
const f = await fixture('windows')
|
||||
const initial = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
const saved = await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit,
|
||||
{ type: 'set', id: 'sidebar.left.toggle', binding: { code: `Key${keyCode}`, modifiers: ['control'] } }, initial.revision)
|
||||
const registry = new ShortcutRegistry('desktop', 'windows', saved.snapshot)
|
||||
const run = vi.fn()
|
||||
registry.register({ ...f.definitions[0]!, label: () => 'Toggle sidebar', aliases: [], regions: ['page', 'editable'], modals: [],
|
||||
resolve: () => ({ status: 'handled', run }) })
|
||||
let nativeInput: (input: DesktopShortcutInput) => void = () => {}
|
||||
onTestFinished(installNativeKeyboard(window, {
|
||||
closeWindow: vi.fn(), subscribe: (listener) => { nativeInput = listener; return () => {} },
|
||||
}, registry, () => registry.config.getSnapshot()))
|
||||
onTestFinished(installKeyboard(window, registry, undefined, true))
|
||||
f.contents.send.mockImplementation((channel: string, input: DesktopShortcutInput) => {
|
||||
if (channel === DESKTOP_IPC.shortcutsInput) nativeInput(input)
|
||||
})
|
||||
const editorInput: string[] = []
|
||||
const deliver = (type: 'keyDown' | 'keyUp'): void => {
|
||||
const preventDefault = vi.fn()
|
||||
f.contents.emit('before-input-event', { preventDefault }, { type, code: `Key${keyCode}`, key: keyCode.toLowerCase(),
|
||||
modifiers: ['control'], control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
|
||||
if (!preventDefault.mock.calls.length) {
|
||||
editorInput.push(type)
|
||||
document.body.dispatchEvent(new KeyboardEvent(type === 'keyDown' ? 'keydown' : 'keyup',
|
||||
{ code: `Key${keyCode}`, key: keyCode.toLowerCase(), ctrlKey: true, bubbles: true, cancelable: true }))
|
||||
}
|
||||
}
|
||||
f.contents.sendInputEvent.mockImplementation((input: { type: 'keyDown' | 'keyUp' }) => { deliver(input.type) })
|
||||
f.contents.send.mockClear()
|
||||
|
||||
f.keyboard.sendEditingKey(keyCode, ['control'])
|
||||
|
||||
expect(f.contents.focus).toHaveBeenCalledOnce()
|
||||
expect(editorInput).toEqual(['keyDown', 'keyUp'])
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
expect(f.contents.sendInputEvent.mock.calls).toEqual([
|
||||
[{ type: 'keyDown', keyCode, modifiers: ['control'] }],
|
||||
[{ type: 'keyUp', keyCode, modifiers: ['control'] }],
|
||||
])
|
||||
editorInput.length = 0
|
||||
deliver('keyDown')
|
||||
deliver('keyUp')
|
||||
expect(editorInput).toEqual([])
|
||||
expect(f.contents.send).toHaveBeenCalledExactlyOnceWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ kind: 'keyboard', code: `Key${keyCode}` }))
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores shortcut interception after native Edit delivery fails', async () => {
|
||||
const f = await fixture('windows')
|
||||
await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
f.contents.sendInputEvent.mockImplementationOnce(() => { throw new Error('editor unavailable') })
|
||||
expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).toThrow('editor unavailable')
|
||||
const preventDefault = vi.fn()
|
||||
f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: ['control'],
|
||||
control: true, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
|
||||
expect(preventDefault).toHaveBeenCalledOnce()
|
||||
f.window.isDestroyed.mockReturnValue(true)
|
||||
expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).not.toThrow()
|
||||
f.detach()
|
||||
expect(() => { f.keyboard.sendEditingKey('C', ['control']) }).not.toThrow()
|
||||
expect(f.contents.sendInputEvent).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('intercepts %s standalone custom keys and clears held state when preferences change', async (platform) => {
|
||||
const f = await fixture(platform)
|
||||
let snapshot = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, f.definitions)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
|
||||
import { WINDOWS_TITLEBAR_HEIGHT } from '../src/windows-layout.ts'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
|
||||
@@ -61,6 +61,8 @@ const harness = await vi.hoisted(async () => {
|
||||
getURL: () => this.urls.at(-1) ?? '',
|
||||
mainFrame: { url: '' },
|
||||
getZoomFactor: () => 1,
|
||||
isDestroyed: () => this.destroyed,
|
||||
setIgnoreMenuShortcuts: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
sendInputEvent: vi.fn(),
|
||||
send: vi.fn((channel: string, state: { policy?: { blocking: boolean } }) => {
|
||||
@@ -131,6 +133,7 @@ const harness = await vi.hoisted(async () => {
|
||||
getPreferredSystemLanguages: () => ['en-US'],
|
||||
getVersion: () => '1.0.0',
|
||||
getAppPath: (): string => 'desktop-test-app',
|
||||
getPath: vi.fn<(name: 'userData') => string>(),
|
||||
setAboutPanelOptions: vi.fn<(options: Electron.AboutPanelOptionsOptions) => void>(),
|
||||
requestSingleInstanceLock: () => true,
|
||||
setAsDefaultProtocolClient: vi.fn(),
|
||||
@@ -314,6 +317,9 @@ beforeEach(() => {
|
||||
testAuth.login.mockResolvedValue('cancelled')
|
||||
vi.useFakeTimers()
|
||||
harness.reset()
|
||||
const userData = mkdtempSync(join(tmpdir(), 'dsh-main-user-data-'))
|
||||
onTestFinished(() => { rmSync(userData, { recursive: true, force: true }) })
|
||||
harness.app.getPath.mockReturnValue(userData)
|
||||
harness.dialog.showMessageBox.mockImplementation((options: { title?: string }) => {
|
||||
if (options.title !== en.startupFailed) return Promise.resolve({ response: 1 })
|
||||
harness.dialogShown.resolve()
|
||||
@@ -754,7 +760,7 @@ describe('desktop main startup', () => {
|
||||
await edit
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux'] as const)('adds the standard macOS window commands only on macOS (%s)', async (platform) => {
|
||||
it.each(['darwin', 'linux'] as const)('adds the product File menu and standard window commands only on macOS (%s)', async (platform) => {
|
||||
vi.spyOn(process, 'platform', 'get').mockReturnValue(platform)
|
||||
await import('../src/main.ts')
|
||||
await harness.preparing.promise
|
||||
@@ -765,7 +771,7 @@ describe('desktop main startup', () => {
|
||||
.find(items => items.some(item => item.role === 'editMenu'))
|
||||
if (template === undefined) throw new Error('application menu missing')
|
||||
expect(template.map(describeItem)).toEqual(platform === 'darwin'
|
||||
? ['Desktop test', 'fileMenu', 'editMenu', 'windowMenu']
|
||||
? ['Desktop test', en.fileMenu, 'editMenu', 'windowMenu']
|
||||
: ['Application', 'editMenu'])
|
||||
const application = template[0]!.submenu as MenuItemConstructorOptions[]
|
||||
expect(application.filter(item => item.visible !== false).map(describeItem)).toEqual(platform === 'darwin'
|
||||
|
||||
@@ -138,7 +138,7 @@ it('exposes constrained shortcut operations and releases configuration subscript
|
||||
await import('../src/preload-app.ts')
|
||||
const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi
|
||||
await api.shortcuts.get([])
|
||||
await api.shortcuts.reload()
|
||||
expect(api.shortcuts).not.toHaveProperty('reload')
|
||||
await api.shortcuts.recording(true)
|
||||
expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsGet, [])
|
||||
expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsRecording, true)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/** The shipped composer preserves drafts when an application Enter chord has extra modifiers. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Page } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts'
|
||||
import { launchWebScaffold, watchConsole } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
|
||||
|
||||
it('preserves the composer draft and menu for application Enter combinations', async () => {
|
||||
@@ -20,14 +19,12 @@ it('preserves the composer draft and menu for application Enter combinations', a
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, 'composer-shortcuts')
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
const observations: string[] = []
|
||||
for (const draft of ['unsent draft', '/']) {
|
||||
await writeComposerDraft(page, input, draft)
|
||||
const markup = await input.innerHTML()
|
||||
for (const chord of ['Alt+Enter', 'Meta+Alt+Enter', 'Control+Alt+Enter', 'Control+Meta+Enter', 'Meta+Shift+Enter', 'Control+Shift+Enter']) {
|
||||
await input.press(chord)
|
||||
expect(await input.innerHTML(), `${draft}: ${chord}`).toBe(markup)
|
||||
observations.push(`- ${draft === '/' ? 'Command menu' : 'Plain draft'} / ${chord}: draft unchanged`)
|
||||
}
|
||||
await input.press('Escape')
|
||||
}
|
||||
@@ -35,14 +32,9 @@ it('preserves the composer draft and menu for application Enter combinations', a
|
||||
await input.press('Shift+Enter')
|
||||
await page.keyboard.type('second line')
|
||||
expect(await input.innerText()).toBe('first line\nsecond line')
|
||||
observations.push('- Shift+Enter: two draft lines')
|
||||
expect(events.filter(event => event.type === 'user/message')).toHaveLength(0)
|
||||
observations.push('- Persisted user messages: 0')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
const directory = fileURLToPath(new URL('./expected/composer-shortcuts', import.meta.url))
|
||||
await assertFixtureInventory(directory, ['gestures.expected.md'])
|
||||
await compareOrRefreshGolden(`${directory}/gestures.expected.md`, observations.join('\n'), webSnapshotMode())
|
||||
} catch (error) {
|
||||
if (failurePage !== undefined) await saveFailureShot(failurePage, 'web-e2e-composer-shortcuts')
|
||||
throw error
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
- Plain draft / Alt+Enter: draft unchanged
|
||||
- Plain draft / Meta+Alt+Enter: draft unchanged
|
||||
- Plain draft / Control+Alt+Enter: draft unchanged
|
||||
- Plain draft / Control+Meta+Enter: draft unchanged
|
||||
- Plain draft / Meta+Shift+Enter: draft unchanged
|
||||
- Plain draft / Control+Shift+Enter: draft unchanged
|
||||
- Command menu / Alt+Enter: draft unchanged
|
||||
- Command menu / Meta+Alt+Enter: draft unchanged
|
||||
- Command menu / Control+Alt+Enter: draft unchanged
|
||||
- Command menu / Control+Meta+Enter: draft unchanged
|
||||
- Command menu / Meta+Shift+Enter: draft unchanged
|
||||
- Command menu / Control+Shift+Enter: draft unchanged
|
||||
- Shift+Enter: two draft lines
|
||||
- Persisted user messages: 0
|
||||
@@ -0,0 +1 @@
|
||||
- alert: This session has no completed turn
|
||||
@@ -5,5 +5,5 @@
|
||||
- button "Add workspace"
|
||||
- tree "Sessions":
|
||||
- treeitem "Ungrouped" [expanded]
|
||||
- treeitem "T4 source (1) now" [selected]
|
||||
- treeitem "T4 source 1min"
|
||||
- treeitem "T4 source (1) now"
|
||||
- treeitem "T4 source 1min" [selected]
|
||||
@@ -45,11 +45,6 @@
|
||||
- button "Remove shortcut for Browser"
|
||||
- button "Edit shortcut for Browser"
|
||||
- button "Browser ⌥ ⌘ T": ⌥ ⌘ T
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
- button "Edit shortcut for Open settings"
|
||||
- button "Open settings ⌘ ,": ⌘ ,
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
@@ -80,6 +75,11 @@
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session ⌥ ⌘ A": ⌥ ⌘ A
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
- button "Edit shortcut for Open settings"
|
||||
- button "Open settings ⌘ ,": ⌘ ,
|
||||
- listitem:
|
||||
- text: Workspace files
|
||||
- button "Remove shortcut for Workspace files"
|
||||
@@ -132,3 +132,6 @@
|
||||
- listitem:
|
||||
- text: Reject
|
||||
- button "Reject Esc" [disabled]: Esc
|
||||
- contentinfo:
|
||||
- button "Restore all defaults" [disabled]
|
||||
- text: 0 customized
|
||||
@@ -1,4 +1,5 @@
|
||||
- menu:
|
||||
- menuitem "Pin session"
|
||||
- menuitem "Rename"
|
||||
- menuitem "Fork session"
|
||||
- menuitem "Archive session"
|
||||
@@ -1,135 +0,0 @@
|
||||
- dialog "Keyboard shortcuts":
|
||||
- banner:
|
||||
- heading "Keyboard shortcuts" [level=2]
|
||||
- button "Close keyboard shortcuts"
|
||||
- search "Search shortcuts":
|
||||
- searchbox "Search shortcuts"
|
||||
- region "Application":
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Open keyboard shortcuts
|
||||
- button "Remove shortcut for Open keyboard shortcuts"
|
||||
- button "Edit shortcut for Open keyboard shortcuts"
|
||||
- button "Open keyboard shortcuts Ctrl + /": Ctrl + /
|
||||
- listitem:
|
||||
- text: Toggle left sidebar
|
||||
- button "Remove shortcut for Toggle left sidebar"
|
||||
- button "Edit shortcut for Toggle left sidebar"
|
||||
- button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B
|
||||
- listitem:
|
||||
- text: Toggle right sidebar
|
||||
- button "Remove shortcut for Toggle right sidebar"
|
||||
- button "Edit shortcut for Toggle right sidebar"
|
||||
- button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B
|
||||
- listitem:
|
||||
- text: Split
|
||||
- button "Remove shortcut for Split"
|
||||
- button "Edit shortcut for Split"
|
||||
- button "Split Ctrl + \\": Ctrl + \
|
||||
- listitem:
|
||||
- text: Toggle panel fullscreen
|
||||
- button "Remove shortcut for Toggle panel fullscreen"
|
||||
- button "Edit shortcut for Toggle panel fullscreen"
|
||||
- button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter
|
||||
- listitem:
|
||||
- text: Close current page or window
|
||||
- button "Remove shortcut for Close current page or window"
|
||||
- button "Edit shortcut for Close current page or window"
|
||||
- button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W
|
||||
- listitem:
|
||||
- text: Refresh current page
|
||||
- button "Remove shortcut for Refresh current page"
|
||||
- button "Edit shortcut for Refresh current page"
|
||||
- button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R
|
||||
- listitem:
|
||||
- text: Browser
|
||||
- button "Remove shortcut for Browser"
|
||||
- button "Edit shortcut for Browser"
|
||||
- button "Browser Ctrl + Alt + T": Ctrl + Alt + T
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
- button "Edit shortcut for Open settings"
|
||||
- button "Open settings Ctrl + ,": Ctrl + ,
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
- button "Edit shortcut for New Session"
|
||||
- button "New Session Ctrl + Alt + N": Ctrl + Alt + N
|
||||
- listitem:
|
||||
- text: Search sessions
|
||||
- button "Remove shortcut for Search sessions"
|
||||
- button "Edit shortcut for Search sessions"
|
||||
- button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K
|
||||
- listitem:
|
||||
- text: Add workspace
|
||||
- button "Remove shortcut for Add workspace"
|
||||
- button "Edit shortcut for Add workspace"
|
||||
- button "Add workspace Ctrl + Alt + O": Ctrl + Alt + O
|
||||
- listitem:
|
||||
- text: Rename session
|
||||
- button "Remove shortcut for Rename session"
|
||||
- button "Edit shortcut for Rename session"
|
||||
- button "Rename session Ctrl + Shift + R": Ctrl + Shift + R
|
||||
- listitem:
|
||||
- text: Fork session
|
||||
- button "Remove shortcut for Fork session"
|
||||
- button "Edit shortcut for Fork session"
|
||||
- button "Fork session Ctrl + Shift + F": Ctrl + Shift + F
|
||||
- listitem:
|
||||
- text: Archive session
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session Ctrl + Alt + A": Ctrl + Alt + A
|
||||
- listitem:
|
||||
- text: Workspace files
|
||||
- button "Remove shortcut for Workspace files"
|
||||
- button "Edit shortcut for Workspace files"
|
||||
- button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P
|
||||
- listitem:
|
||||
- text: New terminal
|
||||
- button "Remove shortcut for New terminal"
|
||||
- button "Edit shortcut for New terminal"
|
||||
- 'button "New terminal Ctrl + `"': "Ctrl + `"
|
||||
- region "Message input":
|
||||
- heading "Message input" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Send message
|
||||
- button "Send message Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: New line
|
||||
- button "New line Shift + Enter" [disabled]: Shift + Enter
|
||||
- listitem:
|
||||
- text: Use the complementary Queue / Steer action
|
||||
- button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter
|
||||
- listitem:
|
||||
- text: Open command menu
|
||||
- button "Open command menu /" [disabled]: /
|
||||
- listitem:
|
||||
- text: Open reference menu
|
||||
- button "Open reference menu @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: Stop generating
|
||||
- button "Stop generating Esc Esc" [disabled]: Esc Esc
|
||||
- region "Menus and dialogs":
|
||||
- heading "Menus and dialogs" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Move menu selection
|
||||
- button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
|
||||
- listitem:
|
||||
- text: Select menu item
|
||||
- button "Select menu item Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Close menu or top dialog
|
||||
- button "Close menu or top dialog Esc" [disabled]: Esc
|
||||
- region "Approval area":
|
||||
- heading "Approval area" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Allow once
|
||||
- button "Allow once Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Reject
|
||||
- button "Reject Esc" [disabled]: Esc
|
||||
@@ -5,3 +5,6 @@
|
||||
- search "Search shortcuts":
|
||||
- searchbox "Search shortcuts": abc
|
||||
- status: No matching shortcuts
|
||||
- contentinfo:
|
||||
- button "Restore all defaults"
|
||||
- text: 1 customized
|
||||
@@ -0,0 +1,6 @@
|
||||
- dialog "Restore all default shortcuts?":
|
||||
- heading "Restore all default shortcuts?" [level=2]
|
||||
- button "Close confirmation"
|
||||
- paragraph: Restore the default shortcuts for this platform. All modified or removed shortcuts will be restored. Other platforms are unaffected.
|
||||
- button "Cancel"
|
||||
- button "Restore default"
|
||||
@@ -46,11 +46,6 @@
|
||||
- button "Remove shortcut for Browser"
|
||||
- button "Edit shortcut for Browser"
|
||||
- button "Browser Ctrl + Alt + T": Ctrl + Alt + T
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
- button "Edit shortcut for Open settings"
|
||||
- button "Open settings Ctrl + ,": Ctrl + ,
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
@@ -81,6 +76,11 @@
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session Ctrl + Alt + A": Ctrl + Alt + A
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
- button "Edit shortcut for Open settings"
|
||||
- button "Open settings Ctrl + ,": Ctrl + ,
|
||||
- listitem:
|
||||
- text: Workspace files
|
||||
- button "Remove shortcut for Workspace files"
|
||||
@@ -133,3 +133,6 @@
|
||||
- listitem:
|
||||
- text: Reject
|
||||
- button "Reject Esc" [disabled]: Esc
|
||||
- contentinfo:
|
||||
- button "Restore all defaults" [disabled]
|
||||
- text: 0 customized
|
||||
@@ -5,3 +5,6 @@
|
||||
- search "搜索快捷键":
|
||||
- searchbox "搜索快捷键": abc
|
||||
- status: 没有匹配的快捷键
|
||||
- contentinfo:
|
||||
- button "恢复全部默认"
|
||||
- text: 1 项已自定义
|
||||
@@ -0,0 +1,6 @@
|
||||
- dialog "恢复全部默认快捷键?":
|
||||
- heading "恢复全部默认快捷键?" [level=2]
|
||||
- button "关闭确认"
|
||||
- paragraph: 将恢复当前平台的默认快捷键。你修改或移除的快捷键都会恢复,其他平台不受影响。
|
||||
- button "取消"
|
||||
- button "恢复默认"
|
||||
@@ -45,11 +45,6 @@
|
||||
- button "移除浏览器快捷键"
|
||||
- button "修改浏览器快捷键"
|
||||
- button "浏览器 ⌥ ⌘ T": ⌥ ⌘ T
|
||||
- listitem:
|
||||
- text: 打开设置
|
||||
- button "移除打开设置快捷键"
|
||||
- button "修改打开设置快捷键"
|
||||
- button "打开设置 ⌘ ,": ⌘ ,
|
||||
- listitem:
|
||||
- text: 新会话
|
||||
- button "移除新会话快捷键"
|
||||
@@ -80,6 +75,11 @@
|
||||
- button "移除归档会话快捷键"
|
||||
- button "修改归档会话快捷键"
|
||||
- button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A
|
||||
- listitem:
|
||||
- text: 打开设置
|
||||
- button "移除打开设置快捷键"
|
||||
- button "修改打开设置快捷键"
|
||||
- button "打开设置 ⌘ ,": ⌘ ,
|
||||
- listitem:
|
||||
- text: 工作区文件
|
||||
- button "移除工作区文件快捷键"
|
||||
@@ -132,3 +132,6 @@
|
||||
- listitem:
|
||||
- text: 拒绝
|
||||
- button "拒绝 Esc" [disabled]: Esc
|
||||
- contentinfo:
|
||||
- button "恢复全部默认" [disabled]
|
||||
- text: 0 项已自定义
|
||||
@@ -186,7 +186,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
|
||||
|
||||
const stopButton = page.getByRole('button', { name: 'Stop generating' })
|
||||
await stopButton.hover()
|
||||
await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
|
||||
await page.getByRole('tooltip', { name: 'Stop generating Esc Esc', exact: true }).waitFor()
|
||||
await stopButton.click()
|
||||
await settled
|
||||
expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted')
|
||||
|
||||
@@ -281,7 +281,7 @@ describe('web e2e: queue row actions', () => {
|
||||
|
||||
const stopButton = page.getByRole('button', { name: 'Stop generating' })
|
||||
await stopButton.hover()
|
||||
await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
|
||||
await page.getByRole('tooltip', { name: 'Stop generating Esc Esc', exact: true }).waitFor()
|
||||
if (method === 'button') await stopButton.click()
|
||||
else {
|
||||
await input.focus()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Real application composition and Desktop persistence with a substituted Electron preload transport. */
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rename, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -13,9 +13,8 @@ import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, w
|
||||
|
||||
type FixtureWindow = Window & {
|
||||
desktopShortcutsGet: DesktopShortcutsApi['get']
|
||||
desktopShortcutsReload: DesktopShortcutsApi['reload']
|
||||
desktopShortcutsEdit: DesktopShortcutsApi['edit']
|
||||
shortcutFixture: { deliver(input: DesktopShortcutInput): void; recording: boolean }
|
||||
shortcutFixture: { deliver(input: DesktopShortcutInput): void; recording: boolean; closedWindows: number }
|
||||
}
|
||||
|
||||
const expected = fileURLToPath(new URL('./expected/shortcuts-desktop', import.meta.url))
|
||||
@@ -38,9 +37,8 @@ it.each([
|
||||
const page = await browser.newPage({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
|
||||
await page.exposeFunction('desktopShortcutsGet', async (definitions: unknown) => {
|
||||
persistence.setDefinitions(parseShortcutDefinitions(definitions))
|
||||
return persistence.reload()
|
||||
return persistence.readCurrent()
|
||||
})
|
||||
await page.exposeFunction('desktopShortcutsReload', () => persistence.reload())
|
||||
await page.exposeFunction('desktopShortcutsEdit', (edit: unknown, revision: ShortcutRevision) => persistence.edit(parseShortcutEdit(edit), revision))
|
||||
// Only the Electron transport is substituted; preference storage and all Client plugins are real.
|
||||
await page.addInitScript((device) => {
|
||||
@@ -49,28 +47,60 @@ it.each([
|
||||
if (document.documentElement === null) window.addEventListener('DOMContentLoaded', mark)
|
||||
else mark()
|
||||
const listeners = new Set<(input: DesktopShortcutInput) => void>()
|
||||
const fixture = { recording: false, deliver(input: DesktopShortcutInput) { for (const listener of listeners) listener(input) } }
|
||||
const fixture = { recording: false, closedWindows: 0,
|
||||
deliver(input: DesktopShortcutInput) { for (const listener of listeners) listener(input) },
|
||||
}
|
||||
Object.assign(window, { shortcutFixture: fixture, dshDesktop: { protocolVersion: 1,
|
||||
shortcuts: {
|
||||
get: scope.desktopShortcutsGet, reload: scope.desktopShortcutsReload, edit: scope.desktopShortcutsEdit,
|
||||
get: scope.desktopShortcutsGet, edit: scope.desktopShortcutsEdit,
|
||||
recording: async (active: boolean) => { fixture.recording = active }, subscribe: () => () => {},
|
||||
},
|
||||
keyboard: { subscribe: (listener: (input: DesktopShortcutInput) => void) => {
|
||||
listeners.add(listener); return () => { listeners.delete(listener) }
|
||||
}, closeWindow: async () => {} },
|
||||
}, closeWindow: async () => { fixture.closedWindows++ } },
|
||||
} })
|
||||
}, marker)
|
||||
const console = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const openReference = () => page.evaluate((input) => { (window as unknown as FixtureWindow).shortcutFixture.deliver(input) }, {
|
||||
kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'Slash',
|
||||
const deliverPrimary = (code: string) => page.evaluate((input) => {
|
||||
(window as unknown as FixtureWindow).shortcutFixture.deliver(input)
|
||||
}, {
|
||||
kind: 'keyboard', frameName: '', revision: snapshot.revision, code,
|
||||
control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, repeat: false,
|
||||
} satisfies DesktopShortcutInput)
|
||||
const openReference = () => deliverPrimary('Slash')
|
||||
const group = page.getByRole('treeitem').first()
|
||||
await group.waitFor()
|
||||
if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
|
||||
await page.getByRole('treeitem').nth(1).click()
|
||||
await page.getByText('DONE', { exact: true }).waitFor()
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
await composer.focus()
|
||||
await page.keyboard.insertText('Desktop focus draft')
|
||||
const selectDraft = () => composer.evaluate((element) => {
|
||||
const text = element.querySelector('[data-lexical-text]')!.firstChild!
|
||||
document.getSelection()!.setBaseAndExtent(text, 0, text, 7)
|
||||
})
|
||||
await selectDraft()
|
||||
await deliverPrimary('KeyP')
|
||||
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
|
||||
const files = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
||||
await files.waitFor()
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop')
|
||||
await composer.focus()
|
||||
await selectDraft()
|
||||
await deliverPrimary('KeyP')
|
||||
expect(await files.count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop')
|
||||
await deliverPrimary('KeyW')
|
||||
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
|
||||
expect(await page.evaluate(() => (window as unknown as FixtureWindow).shortcutFixture.closedWindows)).toBe(0)
|
||||
expect(await composer.innerText()).toBe('Desktop focus draft')
|
||||
await composer.focus()
|
||||
await composer.fill('')
|
||||
await expect.poll(() => composer.textContent()).toBe('')
|
||||
await openReference()
|
||||
const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
|
||||
const rowHeights = () => dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height))
|
||||
@@ -98,7 +128,6 @@ it.each([
|
||||
await compareOrRefreshGolden(join(expected, `${platform}-copy-override.expected.md`), await row.ariaSnapshot(), mode)
|
||||
await page.keyboard.press('Escape')
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
await composer.focus()
|
||||
await page.evaluate((input) => { (window as unknown as FixtureWindow).shortcutFixture.deliver(input) }, {
|
||||
kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyC',
|
||||
@@ -126,18 +155,43 @@ it.each([
|
||||
await expect.poll(() => recorder.isEnabled()).toBe(true)
|
||||
await recorder.focus()
|
||||
const accepted = snapshot.document
|
||||
for (const key of ['Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'Shift+Enter', `${primary}+Enter`, 'Slash', 'Shift+Digit2']) {
|
||||
await recorder.click()
|
||||
await page.keyboard.press(key)
|
||||
for (const key of [`${primary}+Slash`, 'Escape', 'Enter', 'ArrowUp', 'ArrowDown', 'Shift+Enter', `${primary}+Enter`, 'Slash', 'Shift+Digit2']) {
|
||||
expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
const keys = key.split('+')
|
||||
for (const held of keys) await page.keyboard.down(held)
|
||||
await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('false')
|
||||
expect(await recorder.textContent()).not.toBe('Press a shortcut')
|
||||
for (const held of keys.toReversed()) await page.keyboard.up(held)
|
||||
await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('true')
|
||||
if (key === `${primary}+Slash`) {
|
||||
await dialog.getByText('Already used by “Open keyboard shortcuts”', { exact: true }).waitFor()
|
||||
}
|
||||
expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
expect(snapshot.document).toEqual(accepted)
|
||||
expect(await dialog.getByRole('listitem').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().height)))
|
||||
.toEqual([42])
|
||||
}
|
||||
await compareOrRefreshGolden(join(expected, `${platform}-fixed-conflict.expected.md`), await dialog.getByRole('group').ariaSnapshot(), mode)
|
||||
await recorder.click()
|
||||
const preferencePath = join(userData, 'keybindings.json')
|
||||
const savedPreferencePath = join(userData, 'keybindings.saved.json')
|
||||
await rename(preferencePath, savedPreferencePath)
|
||||
try {
|
||||
// A directory prevents atomic file replacement on every supported filesystem.
|
||||
await mkdir(preferencePath)
|
||||
await page.keyboard.press('j')
|
||||
await dialog.getByText('Could not save. Your previous shortcuts and current draft are preserved. Please retry.', { exact: true }).waitFor()
|
||||
expect(snapshot.document).toEqual(accepted)
|
||||
expect(await recorder.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(await recorder.evaluate(node => node === document.activeElement)).toBe(true)
|
||||
} finally {
|
||||
await rm(preferencePath, { recursive: true, force: true })
|
||||
await rename(savedPreferencePath, preferencePath)
|
||||
}
|
||||
await page.keyboard.down('b')
|
||||
await page.keyboard.down('a')
|
||||
await expect.poll(() => recorder.getAttribute('aria-invalid')).toBe('false')
|
||||
expect(await recorder.textContent()).toContain('A')
|
||||
expect(await recorder.textContent()).toContain('B')
|
||||
await page.keyboard.up('a')
|
||||
await page.keyboard.up('b')
|
||||
await dialog.getByRole('group').waitFor({ state: 'hidden' })
|
||||
|
||||
@@ -103,8 +103,21 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => {
|
||||
expect(await browserGuide.getAttribute('aria-keyshortcuts')).toBe(aria)
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
await composer.click()
|
||||
const selectedDraft = await composer.evaluate((element) => {
|
||||
const text = element.querySelector('[data-lexical-text]')!.firstChild!
|
||||
const selection = document.getSelection()!
|
||||
selection.setBaseAndExtent(text, 0, text, text.textContent!.length)
|
||||
return selection.toString()
|
||||
})
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await panel.getByPlaceholder('Enter an HTTP(S) address').waitFor()
|
||||
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe(selectedDraft)
|
||||
await panel.getByPlaceholder('Enter an HTTP(S) address').focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
await page.keyboard.press(`${primary}+Alt+W`)
|
||||
await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(1)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2)
|
||||
await expect.poll(() => panel.getByRole('button', { name: 'Disable sandbox restrictions', exact: true }).isEnabled()).toBe(true)
|
||||
@@ -117,8 +130,10 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => {
|
||||
await composer.click()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).waitFor()
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
|
||||
await bind(page, primary, 'Toggle panel fullscreen', 'Workspace files')
|
||||
await composer.click()
|
||||
|
||||
@@ -6,10 +6,12 @@ import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
captureStableAria, compareOrRefreshGolden, launchWebScaffold, readPersistedEvents,
|
||||
seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace } from './support.ts'
|
||||
|
||||
const root = fileURLToPath(new URL('./expected/shortcuts-workspace', import.meta.url))
|
||||
const seed = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
|
||||
@@ -97,12 +99,18 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
|
||||
await expect.poll(async () => (await readPersistedEvents(scaffold, childId)).some(event => event.type === 'session/title' && event.data.title === 'T4 source (1)')).toBe(true)
|
||||
const copied = await readPersistedEvents(scaffold, childId)
|
||||
expect(copied.slice(0, lastEnd + 1)).toEqual(original.slice(0, lastEnd + 1))
|
||||
await page.getByRole('treeitem', { selected: true }).filter({ hasText: 'T4 source (1)' }).waitFor()
|
||||
const sourceRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source', { exact: true }) })
|
||||
const childRow = page.getByRole('treeitem').filter({ has: page.getByText('T4 source (1)', { exact: true }) })
|
||||
await childRow.waitFor()
|
||||
expect(await sourceRow.getAttribute('aria-selected')).toBe('true')
|
||||
expect(await childRow.getAttribute('aria-selected')).toBe('false')
|
||||
await compareOrRefreshGolden(join(root, 'fork.expected.md'),
|
||||
await captureStableAria(page, '[data-slot="sidebar.workspaces"]', scaffold.workspaceCwd), mode)
|
||||
|
||||
await childRow.click()
|
||||
await expect.poll(() => childRow.getAttribute('aria-selected')).toBe('true')
|
||||
await bind('Archive session')
|
||||
await page.getByRole('treeitem').filter({ has: page.getByText('T4 source', { exact: true }) }).hover()
|
||||
await sourceRow.hover()
|
||||
await page.getByRole('button', { name: 'Session actions for T4 source', exact: true }).click()
|
||||
await page.getByRole('menu').waitFor()
|
||||
await compareOrRefreshGolden(join(root, 'session-menu.expected.md'),
|
||||
@@ -113,6 +121,7 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
|
||||
await page.getByRole('treeitem', { selected: true }).filter({ hasText: 'T4 source (1)' }).waitFor()
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await expect.poll(() => scaffold.ctx.workspaceRegistry.archivedSessionIds).toContain(childId)
|
||||
expect(scaffold.ctx.workspaceRegistry.archivedSessionIds).not.toContain(sourceId)
|
||||
expect((await readPersistedEvents(scaffold, sourceId)).length).toBeGreaterThan(0)
|
||||
|
||||
await bind('Add workspace')
|
||||
@@ -154,14 +163,77 @@ describe.skipIf(mode === 'record')('web e2e: workspace shortcuts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ platform: 'Win32', primary: 'Control', file: 'windows-defaults', custom: 'Control+Alt+Shift+Meta+J' },
|
||||
it.skipIf(mode === 'record')('explains a first-turn fork refusal and retries from the completed prefix', async () => {
|
||||
const scaffold = await launchWebScaffold({ developerTools: false })
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
browser = await chromium.launch()
|
||||
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
|
||||
await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) })
|
||||
const page = await context.newPage()
|
||||
const tripwire = watchConsole(page)
|
||||
const forkWarnings: string[] = []
|
||||
page.on('console', (message) => { if (message.text().startsWith('session fork rejected:')) forkWarnings.push(message.text()) })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const before = new Set(scaffold.ctx.agents.list().map(agent => agent.id))
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const source = scaffold.ctx.agents.list().find(agent => !before.has(agent.id))
|
||||
if (source === undefined) throw new Error('The workspace did not create a Session')
|
||||
source.session.append('turn/start', { turn: 1 })
|
||||
const user = source.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'The first turn has not ended.' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
source.session.append('session/title', { title: 'First turn', messageSeqs: [user.seq], source: { kind: 'fallback' } })
|
||||
await scaffold.ctx.sessions.flush(source.session)
|
||||
await page.getByText('The first turn has not ended.', { exact: true }).waitFor()
|
||||
const selected = page.getByRole('treeitem', { selected: true })
|
||||
await selected.filter({ hasText: 'First turn' }).waitFor()
|
||||
await page.keyboard.press('Meta+Slash')
|
||||
const reference = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
|
||||
await reference.getByRole('button', { name: 'Edit shortcut for Fork session', exact: true }).click()
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await reference.getByRole('group').waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
await reference.waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
const unavailable = page.getByRole('alert').filter({ hasText: 'This session has no completed turn' })
|
||||
await unavailable.waitFor()
|
||||
await compareOrRefreshGolden(join(root, 'fork-unavailable.expected.md'),
|
||||
await unavailable.ariaSnapshot(), mode)
|
||||
expect(await selected.textContent()).toContain('First turn')
|
||||
expect(scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession === source.id)).toHaveLength(0)
|
||||
expect(forkWarnings).toEqual([])
|
||||
|
||||
source.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const completed = source.session.snapshotEvents()
|
||||
source.session.append('turn/start', { turn: 2 })
|
||||
source.session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'The second turn has not ended.' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
await scaffold.ctx.sessions.flush(source.session)
|
||||
await page.getByText('The second turn has not ended.', { exact: true }).waitFor()
|
||||
await page.keyboard.press('Meta+Shift+Comma')
|
||||
await expect.poll(() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)).toBeDefined()
|
||||
const child = scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === source.id)!
|
||||
await selected.filter({ hasText: 'First turn (1)' }).waitFor()
|
||||
expect((await readPersistedEvents(scaffold, child.id)).slice(0, completed.length)).toEqual(completed)
|
||||
await page.getByText('The second turn has not ended.', { exact: true }).waitFor({ state: 'hidden' })
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await scaffold.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(mode === 'record').each([
|
||||
{ platform: 'Win32', primary: 'Control', file: '../shortcuts/en-US', custom: 'Control+Alt+Shift+Meta+J' },
|
||||
{ platform: 'MacIntel', primary: 'Meta', file: 'macos-defaults', custom: 'Meta+Alt+Shift+J' },
|
||||
])('runs $platform defaults over recorded history and restores them after a multi-modifier binding', async ({ platform, primary, file, custom }) => {
|
||||
const scaffold = await launchWebScaffold({ developerTools: false })
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
await seedSession(scaffold, await readFile(seed, 'utf8'), SessionId('windows-shortcuts-source'))
|
||||
await seedSession(scaffold, await readFile(seed, 'utf8'), SessionId('shortcut-defaults-source'))
|
||||
browser = await chromium.launch()
|
||||
const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } })
|
||||
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
|
||||
|
||||
@@ -37,7 +37,9 @@ describe('web e2e: shortcut reference', () => {
|
||||
await dialog.waitFor()
|
||||
const referenceNode = await dialog.elementHandle()
|
||||
expect(await page.getByRole('dialog').count()).toBe(2)
|
||||
expect(await dialog.locator('footer').count()).toBe(0)
|
||||
expect(await dialog.locator('footer').count()).toBe(1)
|
||||
const resetAll = dialog.getByRole('button', { name: locale === 'zh-CN' ? '恢复全部默认' : 'Restore all defaults', exact: true })
|
||||
expect(await resetAll.isDisabled()).toBe(true)
|
||||
expect(await dialog.boundingBox()).toMatchObject({ width: 480, height: 600 })
|
||||
expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 432, height: 36 })
|
||||
const rows = dialog.getByRole('listitem')
|
||||
@@ -206,6 +208,31 @@ describe('web e2e: shortcut reference', () => {
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
await viewButton.hover()
|
||||
expect(await page.getByRole('tooltip').textContent()).toBe(viewHint)
|
||||
await viewButton.click()
|
||||
await page.getByRole('searchbox').fill('abc')
|
||||
expect(await dialog.getByRole('listitem').count()).toBe(0)
|
||||
expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '1 项已自定义' : '1 customized')
|
||||
await resetAll.click()
|
||||
const confirm = page.getByRole('dialog', { name: locale === 'zh-CN' ? '恢复全部默认快捷键?' : 'Restore all default shortcuts?', exact: true })
|
||||
const cancel = confirm.getByRole('button', { name: locale === 'zh-CN' ? '取消' : 'Cancel', exact: true })
|
||||
expect(await cancel.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await compareOrRefreshGolden(join(expected, `${locale}-reset-confirm.expected.md`), await confirm.ariaSnapshot(), mode)
|
||||
await cancel.click()
|
||||
expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '1 项已自定义' : '1 customized')
|
||||
await resetAll.click()
|
||||
await confirm.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click()
|
||||
await confirm.waitFor({ state: 'hidden' })
|
||||
expect(await resetAll.isDisabled()).toBe(true)
|
||||
expect(await dialog.locator('footer').textContent()).toContain(locale === 'zh-CN' ? '0 项已自定义' : '0 customized')
|
||||
expect(await page.getByRole('searchbox').evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
const sidebar = page.getByRole('button', { name: locale === 'zh-CN' ? '收起侧边栏' : 'Collapse sidebar', exact: true })
|
||||
expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B')
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await sidebar.waitFor()
|
||||
expect(await sidebar.getAttribute('aria-keyshortcuts')).toBe(key === 'Meta' ? 'Alt+Meta+B' : 'Control+Alt+B')
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
} finally { await context.close() }
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/web-client.md
|
||||
web-client.md: 80d233b634f57bb150d8eb6604428557e1e16033
|
||||
web-client.zh.md: f14cdbe9b1d5605b90bd595a384ef7ac9c0d7286
|
||||
web-client.md: f07094d64993fd00c27f3c485ea5bd89bb92d5be
|
||||
web-client.zh.md: 5a0069abb44cbd373eb3a4e31a9d7354347e95cf
|
||||
@@ -4,7 +4,7 @@ English | [中文](web-client.zh.md)
|
||||
|
||||
The Web Client is a browser-side Cordis application assembled from independently loaded plugins. Its architecture has four reusable foundations: [Client Modules](client-modules.md) loads the plugin graph, the [API Gateway](../api-gateway.md) provides typed Host communication, [Slots](slots.md) composes React UI, and [Conversation](conversation.md) turns a Session history window into target-owned views. This page connects those systems and defines where Client models and feature packages belong.
|
||||
|
||||
[Keyboard shortcuts](../../packages/client/shortcuts/README.md) owns window-local command registration and physical-key dispatch; [the shortcut reference](../../packages/client/ui-shortcuts/README.md) presents available commands and local input actions. Command owners share their existing actions with mouse controls. The shared modal primitive arbitrates top-layer Escape and restores focus.
|
||||
[Keyboard shortcuts](../../packages/client/shortcuts/README.md) owns window-local command registration and physical-key dispatch; [the shortcut reference](../../packages/client/ui-shortcuts/README.md) presents available commands and local input actions. Command owners declare each runtime/platform default and share their existing actions with mouse controls. The shared modal primitive arbitrates top-layer Escape and restores focus.
|
||||
|
||||
## Layers and ownership
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Web Client 是由独立加载插件组装而成的浏览器侧 Cordis 应用。它有四个可复用底座:[Client Modules](client-modules.zh.md) 加载插件图,[API Gateway](../api-gateway.zh.md) 提供类型化 Host 通信,[Slots](slots.zh.md) 组合 React UI,[Conversation](conversation.zh.md) 把 Session 历史窗口变成各 target 自有的视图。本文串联这些系统,并规定 Client model 与功能包各自所在的位置。
|
||||
|
||||
[快捷键](../../packages/client/shortcuts/README.zh.md)负责窗口内命令注册和物理键分发;[快捷键速查](../../packages/client/ui-shortcuts/README.zh.md)展示可用命令及局部输入操作。命令 owner 与鼠标控件共用既有操作。共用模态组件裁决顶层 Esc 并恢复焦点。
|
||||
[快捷键](../../packages/client/shortcuts/README.zh.md)负责窗口内命令注册和物理键分发;[快捷键速查](../../packages/client/ui-shortcuts/README.zh.md)展示可用命令及局部输入操作。命令 owner 声明各运行端/平台的默认键位,并与鼠标控件共用既有操作。共用模态组件裁决顶层 Esc 并恢复焦点。
|
||||
|
||||
## 分层与所有权
|
||||
|
||||
|
||||
@@ -222,8 +222,10 @@
|
||||
|
||||
- id: shortcuts
|
||||
name: '@deepseek-ai/dsh-client-shortcuts'
|
||||
|
||||
- id: ui-shortcuts
|
||||
name: '@deepseek-ai/dsh-client-ui-shortcuts'
|
||||
|
||||
- id: ui-layout
|
||||
name: '@deepseek-ai/dsh-client-ui-layout'
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/shortcuts/README.md
|
||||
README.md: f6a26dcda404bc31c8e70a9ff65439fce4954497
|
||||
README.zh.md: 71070d39a5cccc4a04aabda1119c6745b6744e28
|
||||
README.md: 8627f6d32ca5a0bb80e8f29f7aca7515643818bc
|
||||
README.zh.md: 2093065e41c225391b9e263fef5b20a3184bdbfe
|
||||
@@ -11,7 +11,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Customize application keyboard commands for each device. Desktop and Web choose separate defaults for the receiving device. Custom bindings survive reloads on the same device. Commands disappear when their owning plugin unloads, while their saved overrides remain available for reinstallation.
|
||||
|
||||
Windows uses ` + ` between modifiers and the first ordinary key. Two simultaneous ordinary keys appear side by side, such as `F G`, on both platforms, including tooltips and the shortcut reference.
|
||||
Windows uses ` + ` between modifiers and the first ordinary key. Two simultaneous ordinary keys appear side by side, such as `F G`, on Windows and macOS Desktop, including tooltips and the shortcut reference.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -27,15 +27,15 @@ Windows uses ` + ` between modifiers and the first ordinary key. Two simultaneou
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The Web app bundle mounts this package automatically. Feature plugins register commands through `ctx.shortcuts` inside `ctx.effect()`. The service accepts physical `code` values, expands `primary` to the device modifier, and rejects duplicate ids and default combinations across supported platforms. Labels, keycaps, modified flags, conflict status, and owner-provided unavailable reasons share one observable catalog. Availability updates leave saved bindings unchanged; command disposal also releases its availability subscription.
|
||||
The Web app bundle mounts this package automatically. Feature plugins register commands through `ctx.shortcuts` inside `ctx.effect()`. Each command declares defaults for explicit `desktop:macos`, `desktop:windows`, `desktop:linux`, `web:macos`, `web:windows`, and `web:linux` profiles; an omitted profile is unbound. The service accepts physical `code` values, expands `primary` to the device modifier, and rejects duplicate ids and default combinations across supported profiles. Labels, keycaps, modified flags, conflict status, and owner-provided unavailable reasons share one observable catalog. Availability updates leave saved bindings unchanged; command disposal also releases its availability subscription.
|
||||
|
||||
Feature plugins contribute read-only sequences through `registerFixed()` and observe locally arbitrated input through `observeFixedInput()`. Fixed rows follow the owning registration and locale. Their physical combinations participate in conflict checks and cannot be assigned to editable commands; fixed actions have no saved overrides. The Host's `stopSequenceMs` setting controls the maximum double-Escape interval, defaults to 500 milliseconds, and accepts integers from 1 to 2,147,483,646 so expiry stays within the browser timer limit; pages adopt the validated value on load.
|
||||
Feature plugins contribute read-only sequences through `registerFixed()` and observe locally arbitrated input through `observeFixedInput()`. Fixed rows follow the owning registration and locale. Each action declares at least one physical combination and has no saved overrides. These combinations participate in conflict checks and cannot be assigned to editable commands. The Host's `stopSequenceMs` setting controls the maximum double-Escape interval, defaults to 500 milliseconds, and accepts integers from 1 to 2,147,483,646 so expiry stays within the browser timer limit; pages adopt the validated value on load.
|
||||
|
||||
Preferences store only overrides: a missing command inherits its default, `null` clears its binding, and restoring a default removes its override. Restore All affects only the current runtime/platform profile. Web uses origin-local `dsh.keybindings.v1` storage; Desktop uses Electron's device-local `userData/keybindings.json`, independently of Harness home and workspace settings. The schema version is independent of Session data. macOS and Windows Desktop read version 1 without rewriting it and save version 2 on the next successful edit; version 2 adds optional `secondCode`. Web and Linux retain their existing binding restrictions.
|
||||
|
||||
Windows and macOS Desktop accept one or two distinct supported non-modifier keys, with zero to four modifiers. Two-key bindings require overlapping presses in either order; sequential presses do not match. Modifier-only bindings are rejected. A single key and a pair containing that key conflict when their modifiers match, including keys reserved by fixed actions. Previously saved bindings that overlap a mounted fixed action remain visible but cannot execute. All effective bindings, including Command+C and Ctrl+C, take priority over native actions, editors, terminals, embedded pages, and modal controls. Recording and input-method composition remain protected. A pair takes priority only once both keys are held; the first key retains its normal behavior and may insert a character.
|
||||
|
||||
Bindings activate after the first read. Failed reads preserve the last accepted configuration, or use defaults when none exists. Ordinary edits remain disabled until a successful retry or explicit recovery. Recovery retains the original bytes before replacing the whole document with defaults. Write failures preserve active bindings and drafts. Another browser tab's update invalidates open drafts; saving requires reviewing the latest configuration. Browser writes reread first, but simultaneous cross-tab writes remain last-writer-wins.
|
||||
Bindings activate after the first read. Failed reads preserve the last accepted configuration, or use defaults when none exists. Edits, including Restore All, remain disabled while preferences are unreadable. Repair the stored document and reopen the application; reset operations never replace unreadable or future-version data. Write failures preserve active bindings and drafts. Another browser tab's update invalidates open drafts; saving requires reviewing the latest configuration. Browser writes reread first, but simultaneous cross-tab writes remain last-writer-wins.
|
||||
|
||||
-----
|
||||
|
||||
@@ -45,7 +45,7 @@ Bindings activate after the first read. Failed reads preserve the last accepted
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
Windows and macOS Desktop intercept complete accepted combinations before local handlers and forward them through the trusted preload. When the native keyboard bridge is present, only native input dispatches configurable commands; DOM input continues to feed fixed actions. The adapter intercepts a key release only when it intercepted that key’s initial press. Default and user-saved bindings have identical priority, including over modal controls. Other environments dispatch after local handlers and respect their declared regions. Composition, dead keys, AltGraph, and consumed input pass through; macOS Web can consume a bound Option+Command+N reported as a dead key outside composition without guarding the next input. Web and Linux modal layers block background commands. Owned repeats remain consumed without rerunning the action. The context carries the original DOM input element, including elements inside a shadow root. Owners synchronously resolve their target and retain responsibility for action errors. The pure `./protocol` export has no React, DOM, or Electron dependency and shares validation and serialized persistence between adapters. Explicit overrides displace conflicting new defaults; conflicting explicit overrides are all disabled. Desktop rejects stale revisions and restricts IPC to the current product window and top frame.
|
||||
Windows and macOS Desktop intercept complete accepted combinations before local handlers and forward them through the trusted preload. When the native keyboard bridge is present, only native input dispatches configurable commands; DOM input continues to feed fixed actions. The adapter intercepts a key release only when it intercepted that key’s initial press. Default and user-saved bindings have identical priority, including over modal controls. Other environments dispatch after local handlers and respect their declared regions. Composition, dead keys, AltGraph, and consumed input pass through; macOS Web can consume a bound Option+Command+N reported as a dead key outside composition without guarding the next input. Web and Linux modal layers block background commands. Owned repeats remain consumed without rerunning the action. The context carries the original DOM input element, including elements inside a shadow root. Owners synchronously resolve their target and retain responsibility for action errors. The pure `./protocol` export has no React, DOM, or Electron dependency and shares validation and serialized persistence between adapters. Explicit overrides displace conflicting new defaults; conflicting explicit overrides are all disabled. Desktop rejects stale revisions and restricts IPC to the current product window and top frame. Rejected adapter calls are logged and returned as save failures; errors while publishing an accepted command catalog propagate to the caller.
|
||||
|
||||
DOM fixed-input observers receive composition and consumption flags before application dispatch. Consuming an event makes it unavailable to subsequent observers and commands. Focus, pointer, window blur, composition, modal changes, and locally stopped propagation reset pending DOM sequences and chords. Native chords reset on window blur, focused-frame or configuration changes, composition, recording, and modifier release. Electron can omit keyups after native interception; completing a native binding discards held-key state, so each chord activation needs two fresh presses. Releasing a modifier clears held-key state because macOS may omit character keyup events while Command is held. The adapter removes its listeners and modal observer when disposed; one feature's observer failure does not prevent the remaining observers from receiving input.
|
||||
|
||||
@@ -77,7 +77,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Windows and macOS Web derive defaults from Desktop: Mod+Slash, Mod+Comma, Mod+Backslash, and Control+Backquote stay two-key combinations; other defaults without Alt use Mod+Alt, and defaults with Alt use Mod+Shift except fullscreen uses Mod+Alt+Enter. macOS Web leaves Refresh current page unbound by default; its button and explicit user bindings remain available. Mod is Command on macOS and Control on Windows. Windows and macOS accept any three or four distinct modifiers in Web and Desktop. Web also accepts the listed combinations and Mod+Shift combinations; browser or system delivery requires platform testing. Linux Web accepts Mod+Slash, Mod+Shift+Comma, and Mod+Shift+Period. Operating-system shortcuts that do not reach the window cannot be intercepted. A chord does not delay or consume its first key while waiting for the second; a native action or focus change can therefore prevent completion. Desktop files are reread on startup or explicit retry; there is no file watcher.
|
||||
- Feature owners declare the Windows and macOS Web defaults explicitly: Mod+Slash, Mod+Comma, Mod+Backslash, and Control+Backquote stay two-key combinations; other defaults without Alt use Mod+Alt, and defaults with Alt use Mod+Shift except fullscreen uses Mod+Alt+Enter. macOS Web leaves Refresh current page unbound by default; its button and explicit user bindings remain available. Mod is Command on macOS and Control on Windows. Windows and macOS accept any three or four distinct modifiers in Web and Desktop. Web also accepts the listed combinations and Mod+Shift combinations; browser or system delivery requires platform testing. Linux Web accepts Mod+Slash, Mod+Shift+Comma, and Mod+Shift+Period. Operating-system shortcuts that do not reach the window cannot be intercepted. A chord does not delay or consume its first key while waiting for the second; a native action or focus change can therefore prevent completion. Desktop files are reread when the command catalog connects; there is no file watcher.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -11,7 +11,7 @@ kind: "package-reference"
|
||||
|
||||
为每台设备自定义应用键盘命令。桌面端和 Web 按接收输入的设备选择不同默认键位。自定义键位在同一设备重新加载后保留。命令随所属插件卸载而移除,保存的覆盖仍保留供重新安装使用。
|
||||
|
||||
Windows 在修饰键之间及第一个普通键之前使用 ` + `。两端同时按下的两个普通键均并排显示,如 `F G`,提示和快捷键速查保持一致。
|
||||
Windows 在修饰键之间及第一个普通键之前使用 ` + `。Windows 和 macOS Desktop 上同时按下的两个普通键均并排显示,如 `F G`,提示和快捷键速查保持一致。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -27,15 +27,15 @@ Windows 在修饰键之间及第一个普通键之前使用 ` + `。两端同时
|
||||
<a id="use-this-package"></a>
|
||||
## 使用此包
|
||||
|
||||
Web 应用 bundle 自动挂载此包。功能插件在 `ctx.effect()` 中通过 `ctx.shortcuts` 注册命令。服务接收物理 `code`,按设备展开 `primary`,并拒绝重复 ID 和任一支持平台上的默认键位冲突。同一可观察目录提供名称、键帽、已修改标记、冲突状态以及功能 owner 发布的不可用原因。可用性更新不改变已保存键位;命令释放时也解除其可用性订阅。
|
||||
Web 应用 bundle 自动挂载此包。功能插件在 `ctx.effect()` 中通过 `ctx.shortcuts` 注册命令。每个命令为 `desktop:macos`、`desktop:windows`、`desktop:linux`、`web:macos`、`web:windows` 和 `web:linux` 显式声明默认值;省略的配置不绑定按键。服务接收物理 `code`,按设备展开 `primary`,并拒绝重复 ID 和任一支持配置中的默认键位冲突。同一可观察目录提供名称、键帽、已修改标记、冲突状态以及功能 owner 发布的不可用原因。可用性更新不改变已保存键位;命令释放时也解除其可用性订阅。
|
||||
|
||||
功能插件通过 `registerFixed()` 贡献只读序列,通过 `observeFixedInput()` 观察局部控件处理后的输入。固定操作行随所属注册和语言变化。其物理按键组合参与冲突校验,不能分配给可编辑命令;固定操作不保存覆盖配置。Host 的 `stopSequenceMs` 配置控制连续两次 Esc 的最大间隔,默认为 500 毫秒,只接受 1 至 2,147,483,646 的整数,确保过期计时不超过浏览器定时器上限;页面加载时采用已校验的值。
|
||||
功能插件通过 `registerFixed()` 贡献只读序列,通过 `observeFixedInput()` 观察局部控件处理后的输入。固定操作行随所属注册和语言变化。每个操作至少声明一个物理按键组合,不保存覆盖配置。这些组合参与冲突校验,不能分配给可编辑命令。Host 的 `stopSequenceMs` 配置控制连续两次 Esc 的最大间隔,默认为 500 毫秒,只接受 1 至 2,147,483,646 的整数,确保过期计时不超过浏览器定时器上限;页面加载时采用已校验的值。
|
||||
|
||||
偏好只保存覆盖:缺少命令表示继承默认,`null` 表示清除绑定,恢复默认则删除覆盖。全部恢复只影响当前运行端/平台配置。Web 使用同 origin 的 `dsh.keybindings.v1` 存储;Desktop 使用 Electron 设备本地的 `userData/keybindings.json`,与 Harness home 和工作区设置独立。其 schema 版本与 Session 数据无关。 macOS 和 Windows Desktop 读取版本 1 时不重写,在下次成功编辑时保存为版本 2;版本 2 增加可选的 `secondCode`。Web 和 Linux 保留原有绑定限制。
|
||||
|
||||
Windows 和 macOS Desktop 接受一至两个不同的受支持非修饰键,可搭配零至四个修饰键。双键绑定要求按住时间重叠,顺序不限;先后按键不匹配。仅含修饰键的绑定会被拒绝。同一修饰键集合下,单键与包含该键的双键组合冲突,包括固定操作占用的按键。已保存的绑定若与当前挂载的固定操作冲突,会保留显示但不能执行。包括 Command+C 和 Ctrl+C 在内的所有有效绑定优先于原生操作、编辑器、终端、内嵌页面和模态控件。录制和输入法组合状态仍受保护。只有两个键都按住时,双键组合才优先接管;第一个键仍正常处理,可能输入一个字符。
|
||||
|
||||
首次读取完成后激活键位。读取失败保留上次接受的配置;没有已接受配置时使用默认值。成功重试或显式恢复之前,普通编辑保持禁用。恢复先保留原始内容,再用默认值替换整个文档。写入失败保留有效键位和草稿。其他浏览器标签页的更新使打开的草稿过期,保存前需要核对最新配置。浏览器写入前重新读取,但同时跨标签页写入仍采用最后一次成功写入。
|
||||
首次读取完成后激活键位。读取失败保留上次接受的配置;没有已接受配置时使用默认值。偏好不可读时,编辑和全部恢复均保持禁用。修复存储文档后重新打开应用;重置操作不会替换不可读或未来版本的数据。写入失败保留有效键位和草稿。其他浏览器标签页的更新使打开的草稿过期,保存前需要核对最新配置。浏览器写入前重新读取,但同时跨标签页写入仍采用最后一次成功写入。
|
||||
|
||||
-----
|
||||
|
||||
@@ -45,7 +45,7 @@ Windows 和 macOS Desktop 接受一至两个不同的受支持非修饰键,可
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
Windows 和 macOS Desktop 在局部处理器之前拦截已接受的完整组合,通过可信 preload 转发。原生键盘桥接存在时,可配置命令只由原生输入分发;DOM 输入继续交给固定操作。适配器仅拦截初次按下事件已被拦截的按键的松开事件。默认绑定与用户保存的绑定优先级一致,均优先于模态控件。其他环境在局部处理器之后分发,并遵守声明的区域。输入法组合、死键、AltGraph 和已消费的输入继续传递;macOS Web 可在输入法组合之外消费已绑定且报告为死键的 Option+Command+N,不保护后续输入。Web 和 Linux 的模态层阻止后台命令。已接管的重复事件仍被消费,但不重复执行。上下文携带原始 DOM 输入元素,包括 shadow root 内的元素。owner 同步解析目标,并负责操作错误。纯 `./protocol` 导出不依赖 React、DOM 或 Electron,在两个适配器间共享校验和串行持久化。显式覆盖取代冲突的新默认值;互相冲突的显式覆盖全部禁用。Desktop 拒绝过期版本,并将 IPC 限制在当前产品窗口和顶层 frame。
|
||||
Windows 和 macOS Desktop 在局部处理器之前拦截已接受的完整组合,通过可信 preload 转发。原生键盘桥接存在时,可配置命令只由原生输入分发;DOM 输入继续交给固定操作。适配器仅拦截初次按下事件已被拦截的按键的松开事件。默认绑定与用户保存的绑定优先级一致,均优先于模态控件。其他环境在局部处理器之后分发,并遵守声明的区域。输入法组合、死键、AltGraph 和已消费的输入继续传递;macOS Web 可在输入法组合之外消费已绑定且报告为死键的 Option+Command+N,不保护后续输入。Web 和 Linux 的模态层阻止后台命令。已接管的重复事件仍被消费,但不重复执行。上下文携带原始 DOM 输入元素,包括 shadow root 内的元素。owner 同步解析目标,并负责操作错误。纯 `./protocol` 导出不依赖 React、DOM 或 Electron,在两个适配器间共享校验和串行持久化。显式覆盖取代冲突的新默认值;互相冲突的显式覆盖全部禁用。Desktop 拒绝过期版本,并将 IPC 限制在当前产品窗口和顶层 frame。适配器调用被拒绝时会记录日志并返回保存失败;发布已接受命令目录时的错误会向调用方抛出。
|
||||
|
||||
DOM 固定输入观察者在应用分发前收到输入法组合和消费标记。消费事件后,后续观察者和命令不能使用它。焦点、指针、窗口失焦、输入法组合、模态变化和局部停止传播会重置待完成的 DOM 序列和双键状态。原生双键状态在窗口失焦、焦点 frame 或配置变化、输入法组合、录制和修饰键释放时重置。Electron 在原生拦截后可能省略 keyup;原生绑定完成时立即清空按住状态,因此每次触发双键组合都需要重新按下两个键。释放修饰键会清空按住状态,因为 macOS 在按住 Command 时可能省略字符键的 keyup。适配器释放时移除监听器和模态观察者;单个功能观察者失败不妨碍其他观察者接收输入。
|
||||
|
||||
@@ -77,7 +77,7 @@ Desktop 原生菜单和内嵌 frame 使用与 DOM 输入相同的命令注册表
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Windows 和 macOS Web 从 Desktop 派生默认键位:Mod+Slash、Mod+Comma、Mod+Backslash 和 Control+Backquote 保持双键;其他不含 Alt 的默认键位使用 Mod+Alt,含 Alt 的使用 Mod+Shift,全屏除外,使用 Mod+Alt+Enter。macOS Web 的“刷新当前页”默认不绑定快捷键,刷新按钮和用户显式绑定仍然可用。Mod 在 macOS 上为 Command,在 Windows 上为 Control。Windows 和 macOS 的 Web、Desktop 均接受任意三个或四个不同修饰键的组合。Web 还接受上述组合及 Mod+Shift 组合;浏览器或系统能否送达按键需在对应平台测试。Linux Web 接受 Mod+Slash、Mod+Shift+Comma 和 Mod+Shift+Period。未送达窗口的操作系统快捷键无法被拦截。双键组合等待第二个键时,不延迟或消费第一个键;原生操作或焦点变化可能使组合无法完成。Desktop 文件在启动或显式重试时重新读取,不设置文件 watcher。
|
||||
- 功能 owner 显式声明 Windows 和 macOS Web 的默认键位:Mod+Slash、Mod+Comma、Mod+Backslash 和 Control+Backquote 保持双键;其他不含 Alt 的默认键位使用 Mod+Alt,含 Alt 的使用 Mod+Shift,全屏除外,使用 Mod+Alt+Enter。macOS Web 的“刷新当前页”默认不绑定快捷键,刷新按钮和用户显式绑定仍然可用。Mod 在 macOS 上为 Command,在 Windows 上为 Control。Windows 和 macOS 的 Web、Desktop 均接受任意三个或四个不同修饰键的组合。Web 还接受上述组合及 Mod+Shift 组合;浏览器或系统能否送达按键需在对应平台测试。Linux Web 接受 Mod+Slash、Mod+Shift+Comma 和 Mod+Shift+Period。未送达窗口的操作系统快捷键无法被拦截。双键组合等待第二个键时,不延迟或消费第一个键;原生操作或焦点变化可能使组合无法完成。Desktop 文件在命令目录连接时重新读取,不设置文件 watcher。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export type ShortcutCommandId = Branded<'ShortcutCommandId'>
|
||||
export type ShortcutPlatform = 'macos' | 'windows' | 'linux'
|
||||
/** Application shell selecting the default bindings. */
|
||||
export type ShortcutRuntime = 'desktop' | 'web'
|
||||
/** Runtime and operating system selecting one explicit default and preference profile. */
|
||||
export type ShortcutProfile = `${ShortcutRuntime}:${ShortcutPlatform}`
|
||||
/** Logical primary expands to Meta on macOS and Control elsewhere. */
|
||||
export type ShortcutModifier = 'primary' | 'control' | 'alt' | 'shift' | 'meta'
|
||||
/** One or two distinct physical keys held together, plus an exact set of modifiers. */
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { ShortcutRegistry } from './registry.ts'
|
||||
import { detectEnvironment, installKeyboard } from './dom.ts'
|
||||
import { bindingIssue, bindingKey, initialShortcutConfig, normalizeBinding, overlappingBindings, presentBinding } from '../protocol.ts'
|
||||
import type { DesktopKeyboardApi, DesktopShortcutsApi } from '../protocol.ts'
|
||||
import type { DesktopKeyboardApi, DesktopShortcutsApi, ShortcutSaveResult } from '../protocol.ts'
|
||||
import { desktopShortcutStorage, webShortcutStorage } from './storage.ts'
|
||||
import type { Shortcuts } from './types.ts'
|
||||
import type { ShortcutFixedInput } from './types.ts'
|
||||
@@ -140,22 +140,15 @@ export default class ShortcutsService extends Service implements Shortcuts {
|
||||
*/
|
||||
async edit(...args: Parameters<Shortcuts['edit']>): ReturnType<Shortcuts['edit']> {
|
||||
if (this.adapter === undefined) return { status: 'unreadable', snapshot: this.config.getSnapshot() }
|
||||
let result: ShortcutSaveResult
|
||||
try {
|
||||
const result = await this.adapter.edit(...args)
|
||||
if (this.active && result.snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(result.snapshot)
|
||||
return result
|
||||
} catch (_error) {
|
||||
result = await this.adapter.edit(...args)
|
||||
} catch (error) {
|
||||
if (this.active) console.error('Shortcut preference save failed', error)
|
||||
return { status: 'write-failed', snapshot: this.config.getSnapshot() }
|
||||
}
|
||||
}
|
||||
|
||||
async reload(): ReturnType<Shortcuts['reload']> {
|
||||
try {
|
||||
if (this.adapter === undefined) throw new Error('Desktop shortcuts bridge unavailable')
|
||||
const snapshot = await this.adapter.reload()
|
||||
if (this.active && this.connected && snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(snapshot)
|
||||
} catch (_error) { this.failRead() }
|
||||
return this.config.getSnapshot()
|
||||
if (this.active && result.snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(result.snapshot)
|
||||
return result
|
||||
}
|
||||
|
||||
async recording(active: boolean): Promise<void> {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/** Explicit browser and Desktop preference adapters; Desktop never falls back to browser storage. */
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import { ShortcutPersistence } from '../protocol.ts'
|
||||
import type { DesktopShortcutsApi, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform } from '../protocol.ts'
|
||||
|
||||
/** Browser-profile and origin-local key; recovery copies use a unique suffix. */
|
||||
/** Browser-profile and origin-local preference key. */
|
||||
export const SHORTCUT_STORAGE_KEY = 'dsh.keybindings.v1'
|
||||
|
||||
/**
|
||||
@@ -18,15 +17,14 @@ export function webShortcutStorage(window: Window, platform: ShortcutPlatform,
|
||||
const persistence = new ShortcutPersistence({
|
||||
read: () => window.localStorage.getItem(SHORTCUT_STORAGE_KEY),
|
||||
write: (raw) => { window.localStorage.setItem(SHORTCUT_STORAGE_KEY, raw) },
|
||||
backup: (raw) => { window.localStorage.setItem(`${SHORTCUT_STORAGE_KEY}.backup.${randomUUID()}`, raw) },
|
||||
}, 'web', platform, true, publish)
|
||||
const changed = (event: StorageEvent): void => {
|
||||
if (event.key === null || event.key === SHORTCUT_STORAGE_KEY) void persistence.reload()
|
||||
if (event.key === null || event.key === SHORTCUT_STORAGE_KEY) void persistence.readCurrent()
|
||||
}
|
||||
window.addEventListener('storage', changed)
|
||||
return {
|
||||
get: (definitions: readonly ShortcutDefinition[]) => { persistence.setDefinitions(definitions); return persistence.reload() },
|
||||
reload: () => persistence.reload(), edit: (edit, revision) => persistence.edit(edit, revision),
|
||||
get: (definitions: readonly ShortcutDefinition[]) => { persistence.setDefinitions(definitions); return persistence.readCurrent() },
|
||||
edit: (edit, revision) => persistence.edit(edit, revision),
|
||||
subscribe: () => () => {}, recording: async () => {},
|
||||
dispose: () => { window.removeEventListener('storage', changed); persistence.dispose() },
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Command contributions and immutable catalog values. */
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { BindingIssue, NormalizedBinding, ShortcutConfigSnapshot, ShortcutEdit, ShortcutRevision, ShortcutSaveResult, ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutRuntime } from '../protocol.ts'
|
||||
import type { BindingIssue, NormalizedBinding, ShortcutConfigSnapshot, ShortcutEdit, ShortcutRevision, ShortcutSaveResult, ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutProfile, ShortcutRuntime } from '../protocol.ts'
|
||||
|
||||
/** Local input owner resolved before an application command. */
|
||||
export type ShortcutRegion = 'page' | 'editable' | 'terminal'
|
||||
@@ -21,7 +21,7 @@ export interface ShortcutCommand {
|
||||
readonly id: ShortcutCommandId
|
||||
readonly label: () => string
|
||||
readonly aliases: readonly string[]
|
||||
readonly defaults: Readonly<Partial<Record<ShortcutRuntime, ShortcutBinding>>>
|
||||
readonly defaults: Readonly<Partial<Record<ShortcutProfile, ShortcutBinding>>>
|
||||
readonly regions: readonly ShortcutRegion[]
|
||||
/** Modal identifiers in which this command may resolve; others block it. */
|
||||
readonly modals: readonly string[]
|
||||
@@ -67,8 +67,8 @@ export interface ShortcutFixedCommand {
|
||||
readonly id: ShortcutCommandId
|
||||
readonly label: () => string
|
||||
readonly keys: readonly string[]
|
||||
/** Physical combinations reserved by this action, including individual steps of a sequence. */
|
||||
readonly bindings: readonly ShortcutBinding[]
|
||||
/** One or more physical combinations reserved by this action, including individual steps of a sequence. */
|
||||
readonly bindings: readonly [ShortcutBinding, ...ShortcutBinding[]]
|
||||
readonly group: 'input' | 'menus' | 'approval'
|
||||
}
|
||||
/** Localized fixed-action row; its key sequence is not an editable binding. */
|
||||
@@ -119,16 +119,12 @@ export interface Shortcuts {
|
||||
}
|
||||
/**
|
||||
* Save a reviewed edit; failures preserve both accepted preferences and the caller's draft.
|
||||
* @param edit - set, clear, reset, or explicit recovery operation.
|
||||
* @param edit - set, clear, reset, or reset-all operation.
|
||||
* @param revision - accepted revision reviewed by the user.
|
||||
* @returns persistence outcome and current accepted configuration.
|
||||
* @throws when publishing the accepted command catalog fails.
|
||||
*/
|
||||
edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise<ShortcutSaveResult>
|
||||
/**
|
||||
* Retry reading local preferences without discarding the last accepted configuration.
|
||||
* @returns accepted configuration with visible read diagnostics.
|
||||
*/
|
||||
reload(): Promise<ShortcutConfigSnapshot>
|
||||
/**
|
||||
* Suppress native menu accelerators while the recording layer owns keyboard input; reject if Desktop cannot acknowledge it.
|
||||
* @param active - whether the recording layer is mounted.
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
/** Validated preference documents and deterministic conflict resolution, without browser dependencies. */
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { bindingKey, isWebBindingAllowed, normalizeBinding } from './binding.ts'
|
||||
import type { NormalizedBinding, ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutRuntime } from './binding.ts'
|
||||
import type { NormalizedBinding, ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutProfile, ShortcutRuntime } from './binding.ts'
|
||||
|
||||
/** Overrides are platform-local; absent commands inherit defaults and null explicitly unbinds. */
|
||||
export interface ShortcutDocument {
|
||||
readonly schemaVersion: 1 | 2
|
||||
readonly profiles: Readonly<Partial<Record<`${ShortcutRuntime}:${ShortcutPlatform}`, Readonly<Record<string, ShortcutBinding | null>>>>>
|
||||
readonly profiles: Readonly<Partial<Record<ShortcutProfile, Readonly<Record<string, ShortcutBinding | null>>>>>
|
||||
}
|
||||
/** Serializable command definitions accepted from the trusted product frame. */
|
||||
export interface ShortcutDefinition {
|
||||
readonly id: ShortcutCommandId
|
||||
readonly defaults: Readonly<Partial<Record<ShortcutRuntime, ShortcutBinding>>>
|
||||
/** Fixed actions reserve these combinations instead of exposing an editable default. */
|
||||
readonly fixed?: readonly ShortcutBinding[]
|
||||
readonly defaults: Readonly<Partial<Record<ShortcutProfile, ShortcutBinding>>>
|
||||
/** Fixed actions reserve one or more combinations instead of exposing an editable default. */
|
||||
readonly fixed?: readonly [ShortcutBinding, ...ShortcutBinding[]]
|
||||
}
|
||||
/** Validation failures select localized copy in the UI. */
|
||||
export type BindingIssue = 'reserved' | 'unsupported-browser' | 'modifier-required' | 'unsupported-key'
|
||||
@@ -26,7 +27,7 @@ export interface EffectiveShortcut {
|
||||
}
|
||||
/** One revision-checked preference edit, limited to the current runtime and platform. */
|
||||
export type ShortcutEdit = { type: 'set'; id: ShortcutCommandId; binding: ShortcutBinding | null }
|
||||
| { type: 'reset'; id: ShortcutCommandId } | { type: 'reset-all' } | { type: 'recover' }
|
||||
| { type: 'reset'; id: ShortcutCommandId } | { type: 'reset-all' }
|
||||
|
||||
const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
const commandPattern = /^[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$/u
|
||||
@@ -104,26 +105,15 @@ export function bindingIssue(binding: NormalizedBinding, runtime: ShortcutRuntim
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Web defaults on Windows and macOS with primary modifiers and a Control+Backquote terminal binding.
|
||||
* @param definition - command identity and defaults; macOS Web leaves page.refresh unbound.
|
||||
* Select the command owner's explicit default for one device profile.
|
||||
* @param definition - command identity and per-profile defaults.
|
||||
* @param runtime - receiving shell.
|
||||
* @param platform - receiving device.
|
||||
* @returns the default physical binding, or undefined for an unbound action.
|
||||
* @returns the declared physical binding, or undefined for an unbound action.
|
||||
*/
|
||||
export function resolveShortcutDefault(definition: ShortcutDefinition, runtime: ShortcutRuntime,
|
||||
platform: ShortcutPlatform): ShortcutBinding | undefined {
|
||||
const { id, defaults } = definition
|
||||
if (runtime === 'web' && platform === 'macos' && id === 'page.refresh') return undefined
|
||||
if (runtime === 'web' && (platform === 'windows' || platform === 'macos') && defaults.desktop !== undefined) {
|
||||
const code = defaults.desktop.code
|
||||
const primary = platform === 'macos' ? 'meta' : 'control'
|
||||
if (code === 'Backquote') return { code, modifiers: ['control'] }
|
||||
if (defaults.desktop.modifiers.includes('alt')) {
|
||||
return { code, modifiers: code === 'Enter' ? [primary, 'alt'] : [primary, 'shift'] }
|
||||
}
|
||||
return { code, modifiers: ['Slash', 'Comma', 'Backslash'].includes(code) ? [primary] : [primary, 'alt'] }
|
||||
}
|
||||
return defaults[runtime]
|
||||
return definition.defaults[`${runtime}:${platform}`]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,15 +166,19 @@ export function overlappingBindings(left: NormalizedBinding, right: NormalizedBi
|
||||
export function editShortcutDocument(document: ShortcutDocument, edit: ShortcutEdit,
|
||||
runtime: ShortcutRuntime, platform: ShortcutPlatform): ShortcutDocument {
|
||||
const schemaVersion = runtime === 'desktop' && (platform === 'macos' || platform === 'windows') ? 2 : document.schemaVersion
|
||||
if (edit.type === 'recover') return { schemaVersion, profiles: {} }
|
||||
const profile = `${runtime}:${platform}` as const
|
||||
const overrides = { ...document.profiles[profile] }
|
||||
if (edit.type === 'set') overrides[edit.id] = edit.binding
|
||||
if (edit.type === 'reset') {
|
||||
const { [edit.id]: _removed, ...remaining } = overrides
|
||||
return { schemaVersion, profiles: { ...document.profiles, [profile]: remaining } }
|
||||
let overrides = { ...document.profiles[profile] }
|
||||
switch (edit.type) {
|
||||
case 'set': overrides[edit.id] = edit.binding; break
|
||||
case 'reset': {
|
||||
const { [edit.id]: _removed, ...remaining } = overrides
|
||||
overrides = remaining
|
||||
break
|
||||
}
|
||||
case 'reset-all': overrides = {}; break
|
||||
default: return assertNever(edit, 'shortcut edit')
|
||||
}
|
||||
return { schemaVersion, profiles: { ...document.profiles, [profile]: edit.type === 'reset-all' ? {} : overrides } }
|
||||
return { schemaVersion, profiles: { ...document.profiles, [profile]: overrides } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,7 +188,7 @@ export function editShortcutDocument(document: ShortcutDocument, edit: ShortcutE
|
||||
*/
|
||||
export function parseShortcutEdit(value: unknown): ShortcutEdit {
|
||||
if (!record(value)) throw new Error('Invalid shortcut edit')
|
||||
if ((value.type === 'reset-all' || value.type === 'recover') && Object.keys(value).length === 1) return { type: value.type }
|
||||
if (value.type === 'reset-all' && Object.keys(value).length === 1) return { type: value.type }
|
||||
if (typeof value.id !== 'string' || !commandPattern.test(value.id)) throw new Error('Invalid shortcut command')
|
||||
if (value.type === 'reset' && Object.keys(value).length === 2) return { type: 'reset', id: value.id as ShortcutCommandId }
|
||||
if (value.type === 'set' && Object.keys(value).length === 3) return { type: 'set', id: value.id as ShortcutCommandId, binding: parseBinding(value.binding) }
|
||||
@@ -218,8 +212,8 @@ export function parseShortcutDefinitions(value: unknown): readonly ShortcutDefin
|
||||
if (!Array.isArray(entry.fixed) || entry.fixed.length === 0 || Object.keys(entry.defaults).length > 0) throw new Error('Invalid fixed shortcut definition')
|
||||
for (const binding of entry.fixed) if (parseBinding(binding) === null) throw new Error('Invalid fixed shortcut binding')
|
||||
}
|
||||
for (const [runtime, candidate] of Object.entries(entry.defaults)) {
|
||||
if (runtime !== 'desktop' && runtime !== 'web') throw new Error('Invalid shortcut runtime')
|
||||
for (const [profile, candidate] of Object.entries(entry.defaults)) {
|
||||
if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile)) throw new Error('Invalid shortcut profile')
|
||||
const binding = parseBinding(candidate)
|
||||
if (binding === null) throw new Error('Invalid shortcut default')
|
||||
}
|
||||
|
||||
@@ -24,16 +24,14 @@ export interface ShortcutSaveResult {
|
||||
readonly issue?: BindingIssue
|
||||
readonly conflicts?: readonly ShortcutCommandId[]
|
||||
}
|
||||
/** Storage adapter owns path/origin isolation and retention of recovery backups. */
|
||||
/** Storage adapter owns path/origin isolation and complete replacement on successful writes. */
|
||||
export interface ShortcutStorage {
|
||||
read(): string | null | Promise<string | null>
|
||||
write(raw: string): void | Promise<void>
|
||||
backup(raw: string): void | Promise<void>
|
||||
}
|
||||
/** Restricted Desktop preload API; file paths and accelerators never cross from Renderer. */
|
||||
export interface DesktopShortcutsApi {
|
||||
get(definitions: readonly ShortcutDefinition[]): Promise<ShortcutConfigSnapshot>
|
||||
reload(): Promise<ShortcutConfigSnapshot>
|
||||
edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise<ShortcutSaveResult>
|
||||
subscribe(listener: (snapshot: ShortcutConfigSnapshot) => void): () => void
|
||||
recording(active: boolean): Promise<void>
|
||||
@@ -76,29 +74,27 @@ export class ShortcutPersistence {
|
||||
* Read the current file; failures retain the last accepted document and disable ordinary writes.
|
||||
* @returns the accepted snapshot or diagnostic snapshot.
|
||||
*/
|
||||
reload(): Promise<ShortcutConfigSnapshot> {
|
||||
readCurrent(): Promise<ShortcutConfigSnapshot> {
|
||||
return this.serialize(() => this.read())
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the draft revision, validate the complete candidate, then persist before publishing.
|
||||
* Recovery first retains the original bytes; a failed backup prevents replacement.
|
||||
* @param edit - constrained preference operation.
|
||||
* @param revision - state against which the user reviewed the edit.
|
||||
* @returns a classified outcome and the currently accepted snapshot.
|
||||
*/
|
||||
edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise<ShortcutSaveResult> {
|
||||
return this.serialize(async () => {
|
||||
if (this.rereadBeforeWrite || edit.type === 'recover') await this.read()
|
||||
if (this.rereadBeforeWrite) await this.read()
|
||||
const result = (status: ShortcutSaveResult['status']): ShortcutSaveResult => ({ status, snapshot: this.snapshot })
|
||||
if (!this.active || this.definitions === null || this.snapshot.status === 'loading') return result('not-ready')
|
||||
if (revision !== this.snapshot.revision) return result('stale')
|
||||
if (this.snapshot.status === 'unreadable' && edit.type !== 'recover') return result('unreadable')
|
||||
if (edit.type === 'recover' && (this.raw === undefined || this.snapshot.error === 'read')) return result('unreadable')
|
||||
if (this.snapshot.status === 'unreadable') return result('unreadable')
|
||||
if ((edit.type === 'set' || edit.type === 'reset') && !this.definitions.some(row => row.id === edit.id && row.fixed === undefined)) return result('not-ready')
|
||||
const document = editShortcutDocument(this.snapshot.document, edit, this.runtime, this.platform)
|
||||
const rows = effectiveShortcuts(this.definitions, document, this.runtime, this.platform)
|
||||
const invalid = rows.find(row => (edit.type === 'reset-all' || edit.type === 'recover' || row.id === edit.id)
|
||||
const invalid = rows.find(row => (edit.type === 'reset-all' || row.id === edit.id)
|
||||
&& (row.issue !== null || row.conflicts.length > 0))
|
||||
// An explicit edit must not silently disable another command's default binding.
|
||||
const displaced = edit.type === 'set' ? rows.find(row => row.conflicts.includes(edit.id)) : undefined
|
||||
@@ -106,7 +102,6 @@ export class ShortcutPersistence {
|
||||
...(invalid?.issue ? { issue: invalid.issue } : {}),
|
||||
conflicts: invalid?.conflicts.length ? invalid.conflicts : displaced === undefined ? [] : [displaced.id] }
|
||||
try {
|
||||
if (edit.type === 'recover' && this.raw !== null && this.raw !== undefined) await this.storage.backup(this.raw)
|
||||
const raw = `${JSON.stringify(document, null, 2)}\n`
|
||||
await this.storage.write(raw)
|
||||
this.raw = raw
|
||||
|
||||
@@ -6,8 +6,16 @@ import type { ShortcutCommandId, ShortcutDefinition, ShortcutDocument } from '..
|
||||
const id = (value: string) => value as ShortcutCommandId
|
||||
const primary = (code: string) => ({ code, modifiers: ['primary'] as const })
|
||||
const definitions: readonly ShortcutDefinition[] = [
|
||||
{ id: id('test.one'), defaults: { desktop: primary('KeyB') } },
|
||||
{ id: id('test.two'), defaults: { desktop: primary('KeyJ') } },
|
||||
{ id: id('test.one'), defaults: {
|
||||
'desktop:macos': primary('KeyB'),
|
||||
'desktop:windows': primary('KeyB'),
|
||||
'desktop:linux': primary('KeyB'),
|
||||
} },
|
||||
{ id: id('test.two'), defaults: {
|
||||
'desktop:macos': primary('KeyJ'),
|
||||
'desktop:windows': primary('KeyJ'),
|
||||
'desktop:linux': primary('KeyJ'),
|
||||
} },
|
||||
]
|
||||
|
||||
describe('preference document validation and conflict resolution', () => {
|
||||
@@ -103,84 +111,61 @@ describe('preference document validation and conflict resolution', () => {
|
||||
expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: ['control', 'alt', 'shift'] }, 'linux'), 'web', 'linux')).toBe('unsupported-browser')
|
||||
})
|
||||
|
||||
it('leaves macOS Web refresh unbound by default while preserving overrides and other platform defaults', () => {
|
||||
const refresh = { id: id('page.refresh'), defaults: { desktop: primary('KeyR') } }
|
||||
const entries = [...definitions, refresh]
|
||||
it('selects each owner-declared profile without inheriting or rewriting another profile', () => {
|
||||
const entry: ShortcutDefinition = { id: id('page.refresh'), defaults: {
|
||||
'desktop:macos': primary('KeyR'), 'desktop:windows': primary('KeyR'),
|
||||
'web:macos': { code: 'KeyP', modifiers: ['primary', 'shift'] },
|
||||
'web:windows': { code: 'KeyJ', modifiers: ['primary', 'alt'] },
|
||||
} }
|
||||
const document: ShortcutDocument = { schemaVersion: 1, profiles: {} }
|
||||
const binding = (runtime: 'web' | 'desktop', platform: 'macos' | 'windows', source = document) =>
|
||||
effectiveShortcuts(entries, source, runtime, platform).at(-1)?.binding
|
||||
expect(binding('web', 'macos')).toBeNull()
|
||||
expect(binding('web', 'windows')).toEqual({ code: 'KeyR', modifiers: ['control', 'alt'] })
|
||||
expect(binding('desktop', 'macos')).toEqual({ code: 'KeyR', modifiers: ['meta'] })
|
||||
expect(binding('desktop', 'windows')).toEqual({ code: 'KeyR', modifiers: ['control'] })
|
||||
const custom = editShortcutDocument(document, { type: 'set', id: refresh.id,
|
||||
binding: { code: 'KeyR', modifiers: ['primary', 'alt'] } }, 'web', 'macos')
|
||||
expect(binding('web', 'macos', custom)).toEqual({ code: 'KeyR', modifiers: ['alt', 'meta'] })
|
||||
expect(binding('web', 'macos', editShortcutDocument(custom, { type: 'reset', id: refresh.id }, 'web', 'macos'))).toBeNull()
|
||||
expect(() => parseShortcutDefinitions(entries)).not.toThrow()
|
||||
const effective = (runtime: 'desktop' | 'web', platform: 'macos' | 'windows' | 'linux', source = document) =>
|
||||
effectiveShortcuts([entry], source, runtime, platform)[0]?.binding
|
||||
expect(effective('desktop', 'macos')).toEqual({ code: 'KeyR', modifiers: ['meta'] })
|
||||
expect(effective('desktop', 'windows')).toEqual({ code: 'KeyR', modifiers: ['control'] })
|
||||
expect(effective('web', 'macos')).toEqual({ code: 'KeyP', modifiers: ['shift', 'meta'] })
|
||||
expect(effective('web', 'windows')).toEqual({ code: 'KeyJ', modifiers: ['control', 'alt'] })
|
||||
expect(effective('desktop', 'linux')).toBeNull()
|
||||
expect(effective('web', 'linux')).toBeNull()
|
||||
const override = editShortcutDocument(document, { type: 'set', id: entry.id, binding: null }, 'web', 'macos')
|
||||
expect(effective('web', 'macos', override)).toBeNull()
|
||||
const reset = editShortcutDocument(override, { type: 'reset', id: entry.id }, 'web', 'macos')
|
||||
expect(effective('web', 'macos', reset)).toEqual({ code: 'KeyP', modifiers: ['shift', 'meta'] })
|
||||
expect(() => parseShortcutDefinitions([entry])).not.toThrow()
|
||||
expect(() => parseShortcutDefinitions([entry, { ...entry, id: id('other.refresh') }])).toThrow('Conflicting')
|
||||
})
|
||||
|
||||
it('maps Windows and macOS Web defaults to three-key combinations while preserving exceptions and Linux defaults', () => {
|
||||
const entries: ShortcutDefinition[] = [
|
||||
...definitions,
|
||||
{ id: id('test.alt'), defaults: { desktop: { code: 'KeyB', modifiers: ['primary', 'alt'] } } },
|
||||
{ id: id('test.archive'), defaults: { desktop: { code: 'KeyA', modifiers: ['primary', 'shift'] } } },
|
||||
{ id: id('test.reference'), defaults: { desktop: primary('Slash') } },
|
||||
{ id: id('test.settings'), defaults: { desktop: primary('Comma') } },
|
||||
{ id: id('test.split'), defaults: { desktop: primary('Backslash') } },
|
||||
{ id: id('test.terminal'), defaults: { desktop: primary('Backquote') } },
|
||||
...['KeyR', 'KeyF', 'KeyO', 'Enter'].map(code => ({
|
||||
id: id(`test.alt.${code}`), defaults: { desktop: { code, modifiers: ['primary', 'alt'] as const } },
|
||||
})),
|
||||
]
|
||||
const document: ShortcutDocument = { schemaVersion: 1, profiles: {} }
|
||||
const rows = effectiveShortcuts(entries, document, 'web', 'windows')
|
||||
expect(rows.map(row => row.binding)).toEqual([
|
||||
{ code: 'KeyB', modifiers: ['control', 'alt'] }, { code: 'KeyJ', modifiers: ['control', 'alt'] },
|
||||
{ code: 'KeyB', modifiers: ['control', 'shift'] }, { code: 'KeyA', modifiers: ['control', 'alt'] },
|
||||
{ code: 'Slash', modifiers: ['control'] }, { code: 'Comma', modifiers: ['control'] },
|
||||
{ code: 'Backslash', modifiers: ['control'] },
|
||||
{ code: 'Backquote', modifiers: ['control'] },
|
||||
...['KeyR', 'KeyF', 'KeyO'].map(code => ({ code, modifiers: ['control', 'shift'] })),
|
||||
{ code: 'Enter', modifiers: ['control', 'alt'] },
|
||||
])
|
||||
expect(rows.every(row => row.issue === null && row.conflicts.length === 0)).toBe(true)
|
||||
expect(effectiveShortcuts(entries, document, 'web', 'linux').every(row => row.binding === null)).toBe(true)
|
||||
const macRows = effectiveShortcuts(entries, document, 'web', 'macos')
|
||||
expect(macRows.map(row => row.binding)).toEqual([
|
||||
{ code: 'KeyB', modifiers: ['alt', 'meta'] }, { code: 'KeyJ', modifiers: ['alt', 'meta'] },
|
||||
{ code: 'KeyB', modifiers: ['shift', 'meta'] }, { code: 'KeyA', modifiers: ['alt', 'meta'] },
|
||||
{ code: 'Slash', modifiers: ['meta'] }, { code: 'Comma', modifiers: ['meta'] },
|
||||
{ code: 'Backslash', modifiers: ['meta'] }, { code: 'Backquote', modifiers: ['control'] },
|
||||
...['KeyR', 'KeyF', 'KeyO'].map(code => ({ code, modifiers: ['shift', 'meta'] })),
|
||||
{ code: 'Enter', modifiers: ['alt', 'meta'] },
|
||||
])
|
||||
expect(macRows.every(row => row.issue === null && row.conflicts.length === 0)).toBe(true)
|
||||
expect(() => parseShortcutDefinitions(entries)).not.toThrow()
|
||||
const overridden = editShortcutDocument(document, { type: 'set', id: id('test.one'), binding: null }, 'web', 'macos')
|
||||
expect(effectiveShortcuts(entries, overridden, 'web', 'macos')[0]).toMatchObject({ modified: true, binding: null })
|
||||
for (const modifiers of [['meta', 'alt'], ['meta', 'shift']] as const) {
|
||||
expect(bindingIssue(normalizeBinding({ code: 'KeyX', modifiers }, 'macos'), 'web', 'macos')).toBeNull()
|
||||
it('keeps browser admission separate from per-profile default selection', () => {
|
||||
for (const platform of ['macos', 'windows'] as const) {
|
||||
const primary = platform === 'macos' ? 'meta' : 'control'
|
||||
for (const other of ['alt', 'shift'] as const) {
|
||||
expect(bindingIssue(normalizeBinding({ code: 'KeyX', modifiers: [primary, other] }, platform), 'web', platform)).toBeNull()
|
||||
}
|
||||
}
|
||||
expect(bindingIssue(normalizeBinding(primary('Backquote'), 'macos'), 'web', 'macos')).toBe('unsupported-browser')
|
||||
expect(bindingIssue(normalizeBinding({ code: 'KeyB', modifiers: ['alt'] }, 'macos'), 'web', 'macos')).toBe('reserved')
|
||||
for (const modifiers of [['control', 'alt'], ['control', 'shift']] as const) {
|
||||
expect(bindingIssue(normalizeBinding({ code: 'KeyX', modifiers }, 'windows'), 'web', 'windows')).toBeNull()
|
||||
}
|
||||
expect(bindingIssue(normalizeBinding(primary('KeyN'), 'windows'), 'web', 'windows')).toBe('unsupported-browser')
|
||||
expect(() => parseShortcutDefinitions([...definitions,
|
||||
{ id: id('test.collision'), defaults: { desktop: { code: 'KeyB', modifiers: ['primary', 'shift'] } } },
|
||||
])).toThrow('Conflicting')
|
||||
})
|
||||
|
||||
it('validates IPC edits and active definitions rather than trusting renderer fields', () => {
|
||||
expect(parseShortcutDefinitions(definitions)).toEqual(definitions)
|
||||
expect(parseShortcutEdit({ type: 'set', id: 'test.one', binding: null })).toEqual({ type: 'set', id: 'test.one', binding: null })
|
||||
expect(parseShortcutEdit({ type: 'reset', id: 'test.one' }).type).toBe('reset')
|
||||
expect(parseShortcutEdit({ type: 'recover' }).type).toBe('recover')
|
||||
for (const value of [null, {}, { type: 'reset', id: '../x' }, { type: 'reset-all', path: '/tmp' }, { type: 'other', id: 'test.one' }]) expect(() => parseShortcutEdit(value)).toThrow()
|
||||
for (const value of [null, [{ ...definitions[0], defaults: { desktop: null } }], [...definitions, definitions[0]],
|
||||
[{ ...definitions[0], defaults: { other: primary('KeyJ') } }], [{ ...definitions[0], defaults: { web: primary('KeyN') } }],
|
||||
[{ ...definitions[0], defaults: { desktop: primary('KeyC') } }]]) expect(() => parseShortcutDefinitions(value)).toThrow()
|
||||
expect(parseShortcutEdit({ type: 'reset-all' }).type).toBe('reset-all')
|
||||
for (const value of [null, {}, { type: 'recover' }, { type: 'reset', id: '../x' }, { type: 'reset-all', path: '/tmp' }, { type: 'other', id: 'test.one' }]) expect(() => parseShortcutEdit(value)).toThrow()
|
||||
for (const value of [null, [{ ...definitions[0], defaults: {
|
||||
'desktop:macos': null,
|
||||
'desktop:windows': null,
|
||||
'desktop:linux': null,
|
||||
} }], [...definitions, definitions[0]],
|
||||
[{ ...definitions[0], defaults: { other: primary('KeyJ') } }], [{ ...definitions[0], defaults: {
|
||||
'web:macos': primary('KeyN'),
|
||||
'web:windows': primary('KeyN'),
|
||||
'web:linux': primary('KeyN'),
|
||||
} }],
|
||||
[{ ...definitions[0], defaults: {
|
||||
'desktop:macos': primary('KeyC'),
|
||||
'desktop:windows': primary('KeyC'),
|
||||
'desktop:linux': primary('KeyC'),
|
||||
} }]]) expect(() => parseShortcutDefinitions(value)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -132,10 +132,10 @@ it('canonicalizes unordered pairs, rejects invalid main keys, and omits unsuppor
|
||||
it.each(['macos', 'windows'] as const)('migrates %s preferences on write and rejects both directions of single/chord conflicts', async (platform) => {
|
||||
let raw = JSON.stringify({ schemaVersion: 1, profiles: { 'web:macos': { 'dormant.web': null } } })
|
||||
const original = raw
|
||||
const store = new ShortcutPersistence({ read: () => raw, write: (next) => { raw = next }, backup() {} }, 'desktop', platform, false, () => {})
|
||||
const store = new ShortcutPersistence({ read: () => raw, write: (next) => { raw = next } }, 'desktop', platform, false, () => {})
|
||||
onTestFinished(() => { store.dispose() })
|
||||
store.setDefinitions(definitions)
|
||||
let state = await store.reload()
|
||||
let state = await store.readCurrent()
|
||||
expect(raw).toBe(original)
|
||||
const saved = await store.edit({ type: 'set', id, binding: pair }, state.revision)
|
||||
expect(saved.status).toBe('saved')
|
||||
@@ -158,11 +158,11 @@ it.each(['macos', 'windows'] as const)('rejects direct %s writes that overlap fi
|
||||
const fixedId = 'menu.dismiss' as ShortcutCommandId
|
||||
const catalog = parseShortcutDefinitions([...definitions,
|
||||
{ id: fixedId, defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }])
|
||||
const storage = { read: () => null, write: vi.fn(), backup() {} }
|
||||
const storage = { read: () => null, write: vi.fn() }
|
||||
const store = new ShortcutPersistence(storage, 'desktop', platform, false, () => {})
|
||||
onTestFinished(() => { store.dispose() })
|
||||
store.setDefinitions(catalog)
|
||||
const state = await store.reload()
|
||||
const state = await store.readCurrent()
|
||||
for (const binding of [{ code: 'Escape', modifiers: [] }, { code: 'KeyA', secondCode: 'Escape', modifiers: [] }] as const) {
|
||||
expect(await store.edit({ type: 'set', id, binding }, state.revision)).toMatchObject({ status: 'conflict', conflicts: [fixedId] })
|
||||
expect(effectiveShortcuts(catalog, { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { [id]: binding } } },
|
||||
@@ -172,12 +172,16 @@ it.each(['macos', 'windows'] as const)('rejects direct %s writes that overlap fi
|
||||
expect((await store.edit({ type: 'set', id: fixedId, binding: null }, state.revision)).status).toBe('not-ready')
|
||||
expect((await store.edit({ type: 'set', id, binding: { code: 'Escape', modifiers: ['control', 'alt'] } }, state.revision)).status).toBe('saved')
|
||||
store.setDefinitions(definitions)
|
||||
expect((await store.edit({ type: 'set', id, binding: { code: 'Escape', modifiers: [] } }, (await store.reload()).revision)).status).toBe('saved')
|
||||
expect((await store.edit({ type: 'set', id, binding: { code: 'Escape', modifiers: [] } }, (await store.readCurrent()).revision)).status).toBe('saved')
|
||||
})
|
||||
|
||||
it('rejects malformed fixed reservations at catalog IPC ingress', () => {
|
||||
for (const fixed of [[], null, [null], [{ code: 'MetaLeft', modifiers: [] }], [{ code: 'Escape', modifiers: [], extra: true }]]) {
|
||||
expect(() => parseShortcutDefinitions([{ id, defaults: {}, fixed }])).toThrow()
|
||||
}
|
||||
expect(() => parseShortcutDefinitions([{ id, defaults: { desktop: pair }, fixed: [{ code: 'Escape', modifiers: [] }] }])).toThrow()
|
||||
expect(() => parseShortcutDefinitions([{ id, defaults: {
|
||||
'desktop:macos': pair,
|
||||
'desktop:windows': pair,
|
||||
'desktop:linux': pair,
|
||||
}, fixed: [{ code: 'Escape', modifiers: [] }] }])).toThrow()
|
||||
})
|
||||
@@ -13,7 +13,14 @@ function mount(runtime: 'desktop' | 'web' = 'web', platform: 'macos' | 'windows'
|
||||
const registry = new ShortcutRegistry(runtime, platform)
|
||||
const run = vi.fn()
|
||||
registry.register({ id: 'shortcuts.open' as ShortcutCommandId, label: () => 'Shortcuts', aliases: [],
|
||||
defaults: { desktop: { code: 'Slash', modifiers: ['primary'] }, web: { code: 'Slash', modifiers: ['primary'] } },
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'Slash', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'Slash', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'Slash', modifiers: ['primary'] },
|
||||
'web:macos': { code: 'Slash', modifiers: ['primary'] },
|
||||
'web:windows': { code: 'Slash', modifiers: ['primary'] },
|
||||
'web:linux': { code: 'Slash', modifiers: ['primary'] },
|
||||
},
|
||||
regions: ['page', 'editable'], modals: ['shortcuts'], resolve: () => ({ status: 'handled', run }) })
|
||||
dispose = installKeyboard(window, registry)
|
||||
const input = document.createElement('input'); document.body.append(input); input.focus()
|
||||
@@ -47,7 +54,12 @@ function mountMacNewSession() {
|
||||
const fixture = mount('web', 'macos')
|
||||
const newSession = vi.fn()
|
||||
const unregister = fixture.registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New session', aliases: [],
|
||||
defaults: { desktop: { code: 'KeyN', modifiers: ['primary'] } }, regions: ['page', 'editable'], modals: [],
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'KeyN', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'KeyN', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'KeyN', modifiers: ['primary'] },
|
||||
'web:macos': { code: 'KeyN', modifiers: ['primary', 'alt'] },
|
||||
}, regions: ['page', 'editable'], modals: [],
|
||||
resolve: () => ({ status: 'handled', run: newSession }) })
|
||||
return { ...fixture, newSession, unregister }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { installKeyboard } from '../src/client/dom.ts'
|
||||
import ShortcutsService from '../src/client/index.ts'
|
||||
import { ShortcutRegistry } from '../src/client/registry.ts'
|
||||
import type { ShortcutFixedInput } from '../src/client/types.ts'
|
||||
import type { ShortcutFixedCommand, ShortcutFixedInput } from '../src/client/types.ts'
|
||||
import type { ShortcutCommandId } from '../src/protocol.ts'
|
||||
|
||||
let dispose: (() => void) | undefined
|
||||
@@ -96,7 +96,7 @@ it('releases listeners, modal observation, and pending local-input resets on dis
|
||||
it('publishes fixed reservations and removes them with their owning registration', () => {
|
||||
const registry = new ShortcutRegistry('web', 'windows')
|
||||
let label = 'Stop reply'
|
||||
const command = { id: 'response.stop' as ShortcutCommandId, label: () => label, keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' as const }
|
||||
const command: ShortcutFixedCommand = { id: 'response.stop' as ShortcutCommandId, label: () => label, keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' }
|
||||
const off = registry.registerFixed(command)
|
||||
expect(registry.fixedCatalog.getSnapshot()).toEqual([{ id: 'response.stop', label, keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' }])
|
||||
expect(registry.definitions()).toEqual([{ id: 'response.stop', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }])
|
||||
|
||||
@@ -11,7 +11,11 @@ it.each(['macos', 'windows'] as const)('prioritizes %s custom native bindings ac
|
||||
const registry = new ShortcutRegistry('desktop', platform)
|
||||
const run = vi.fn()
|
||||
registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New', aliases: [],
|
||||
defaults: { desktop: { code: 'KeyN', modifiers: ['primary'] } }, regions: ['page', 'editable'], modals: [],
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'KeyN', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'KeyN', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'KeyN', modifiers: ['primary'] },
|
||||
}, regions: ['page', 'editable'], modals: [],
|
||||
resolve: () => ({ status: 'handled', run }) })
|
||||
registry.configure({ ...registry.config.getSnapshot(), revision: 'custom' as never,
|
||||
document: { schemaVersion: 1, profiles: { [`desktop:${platform}`]: { 'session.new': { code: 'KeyC', modifiers: ['primary'] } } } } })
|
||||
@@ -48,7 +52,11 @@ it('rejects old configuration and iframe identities, consumes repeats, and invok
|
||||
const registry = new ShortcutRegistry('desktop', 'macos')
|
||||
const run = vi.fn()
|
||||
const offCommand = registry.register({ id: 'page.close' as ShortcutCommandId, label: () => 'Close', aliases: [],
|
||||
defaults: { desktop: { code: 'KeyW', modifiers: ['primary'] } }, regions: ['page'], modals: [],
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'KeyW', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'KeyW', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'KeyW', modifiers: ['primary'] },
|
||||
}, regions: ['page'], modals: [],
|
||||
resolve: () => ({ status: 'handled', run }) })
|
||||
let deliver: (input: DesktopShortcutInput) => void = () => {}
|
||||
const release = vi.fn()
|
||||
|
||||
@@ -5,8 +5,16 @@ import type { ShortcutCommandId, ShortcutConfigSnapshot, ShortcutDefinition, Sho
|
||||
const one = 'test.one' as ShortcutCommandId
|
||||
const two = 'test.two' as ShortcutCommandId
|
||||
const binding = { code: 'KeyJ', modifiers: ['primary'] as const }
|
||||
const definitions: readonly ShortcutDefinition[] = [{ id: one, defaults: { desktop: { ...binding, code: 'KeyB' } } },
|
||||
{ id: two, defaults: { desktop: { ...binding, code: 'Comma' } } }]
|
||||
const definitions: readonly ShortcutDefinition[] = [{ id: one, defaults: {
|
||||
'desktop:macos': { ...binding, code: 'KeyB' },
|
||||
'desktop:windows': { ...binding, code: 'KeyB' },
|
||||
'desktop:linux': { ...binding, code: 'KeyB' },
|
||||
} },
|
||||
{ id: two, defaults: {
|
||||
'desktop:macos': { ...binding, code: 'Comma' },
|
||||
'desktop:windows': { ...binding, code: 'Comma' },
|
||||
'desktop:linux': { ...binding, code: 'Comma' },
|
||||
} }]
|
||||
const edit: ShortcutEdit = { type: 'set', id: one, binding }
|
||||
|
||||
function fixture(reread = false) {
|
||||
@@ -14,7 +22,7 @@ function fixture(reread = false) {
|
||||
let state!: ShortcutConfigSnapshot
|
||||
const publish = vi.fn((snapshot: ShortcutConfigSnapshot) => { state = snapshot })
|
||||
const storage = { read: vi.fn(async () => raw),
|
||||
write: vi.fn(async (next: string) => { raw = next }), backup: vi.fn(async (_raw: string) => {}) }
|
||||
write: vi.fn(async (next: string) => { raw = next }) }
|
||||
const store = new ShortcutPersistence(storage, 'desktop', 'windows', reread, publish)
|
||||
store.setDefinitions(definitions)
|
||||
return { store, storage, publish, state: () => state, raw: () => raw, external: (value: string) => { raw = value } }
|
||||
@@ -23,7 +31,7 @@ function fixture(reread = false) {
|
||||
describe('serialized preference persistence', () => {
|
||||
it('publishes a save once and only after durable completion; rejects the competing old revision', async () => {
|
||||
const f = fixture()
|
||||
const initial = await f.store.reload()
|
||||
const initial = await f.store.readCurrent()
|
||||
let release!: () => void
|
||||
let started!: () => void
|
||||
const writing = new Promise<void>((resolve) => { started = resolve })
|
||||
@@ -42,7 +50,7 @@ describe('serialized preference persistence', () => {
|
||||
})
|
||||
|
||||
it('keeps the previous configuration and revision on write failure, allowing the same draft to retry', async () => {
|
||||
const f = fixture(); const initial = await f.store.reload()
|
||||
const f = fixture(); const initial = await f.store.readCurrent()
|
||||
f.storage.write.mockRejectedValueOnce(new Error('disk full'))
|
||||
expect((await f.store.edit(edit, initial.revision)).status).toBe('write-failed')
|
||||
expect(f.state()).toBe(initial)
|
||||
@@ -50,39 +58,35 @@ describe('serialized preference persistence', () => {
|
||||
expect((await f.store.edit(edit, initial.revision)).status).toBe('saved')
|
||||
expect(JSON.parse(f.raw()!) as unknown).toMatchObject({ profiles: { 'desktop:windows': { [one]: binding } } })
|
||||
const restarted = fixture(); restarted.external(f.raw()!)
|
||||
expect((await restarted.store.reload()).document).toEqual(f.state().document)
|
||||
expect((await restarted.store.readCurrent()).document).toEqual(f.state().document)
|
||||
})
|
||||
|
||||
it.each(['{broken', '{"schemaVersion":3,"profiles":{}}'])('retains accepted bindings and original bytes after reading %s', async (raw) => {
|
||||
const f = fixture(); await f.store.reload(); await f.store.edit(edit, f.state().revision)
|
||||
const f = fixture(); await f.store.readCurrent(); await f.store.edit(edit, f.state().revision)
|
||||
const accepted = f.state().document
|
||||
f.external(raw)
|
||||
const bad = await f.store.reload()
|
||||
const bad = await f.store.readCurrent()
|
||||
expect(bad.status).toBe('unreadable'); expect(bad.document).toBe(accepted)
|
||||
for (const operation of [edit, { type: 'reset-all' }, { type: 'reset', id: one }] as ShortcutEdit[]) {
|
||||
expect((await f.store.edit(operation, bad.revision)).status).toBe('unreadable')
|
||||
}
|
||||
expect(f.raw()).toBe(raw)
|
||||
f.storage.backup.mockRejectedValueOnce(new Error('backup failed'))
|
||||
expect((await f.store.edit({ type: 'recover' }, bad.revision)).status).toBe('write-failed')
|
||||
expect(f.raw()).toBe(raw)
|
||||
expect((await f.store.edit({ type: 'recover' }, bad.revision)).status).toBe('saved')
|
||||
expect(f.storage.backup).toHaveBeenLastCalledWith(raw)
|
||||
expect(f.state().document.profiles).toEqual({})
|
||||
|
||||
})
|
||||
|
||||
it('uses defaults on the first bad read, reports access failure, and refuses recovery without original bytes', async () => {
|
||||
it('uses defaults on the first bad read and prevents writes until the source becomes readable', async () => {
|
||||
const f = fixture(); f.storage.read.mockRejectedValueOnce(new Error('denied'))
|
||||
expect((await f.store.reload()).usingDefaults).toBe(true)
|
||||
expect((await f.store.readCurrent()).usingDefaults).toBe(true)
|
||||
f.storage.read.mockRejectedValueOnce(new Error('still denied'))
|
||||
expect((await f.store.edit({ type: 'recover' }, f.state().revision)).status).toBe('unreadable')
|
||||
expect((await f.store.readCurrent()).error).toBe('read')
|
||||
expect((await f.store.edit({ type: 'reset-all' }, f.state().revision)).status).toBe('unreadable')
|
||||
expect(f.storage.write).not.toHaveBeenCalled()
|
||||
f.external('{'); expect((await f.store.reload()).error).toBe('invalid')
|
||||
f.external('{"schemaVersion":1,"profiles":{}}'); expect((await f.store.reload()).status).toBe('ready')
|
||||
f.external('{'); expect((await f.store.readCurrent()).error).toBe('invalid')
|
||||
f.external('{"schemaVersion":1,"profiles":{}}'); expect((await f.store.readCurrent()).status).toBe('ready')
|
||||
})
|
||||
|
||||
it('rereads before a browser write and requires an explicit review of external updates', async () => {
|
||||
const f = fixture(true); const initial = await f.store.reload()
|
||||
const f = fixture(true); const initial = await f.store.readCurrent()
|
||||
f.external('{"schemaVersion":1,"profiles":{"web:macos":{"sleeping.command":null}}}')
|
||||
expect((await f.store.edit(edit, initial.revision)).status).toBe('stale')
|
||||
expect(f.storage.write).not.toHaveBeenCalled()
|
||||
@@ -91,7 +95,7 @@ describe('serialized preference persistence', () => {
|
||||
})
|
||||
|
||||
it('accepts Windows single keys and editing combinations but rejects explicit/default conflicts', async () => {
|
||||
const f = fixture(); await f.store.reload()
|
||||
const f = fixture(); await f.store.readCurrent()
|
||||
expect((await f.store.edit({ ...edit, binding: { code: 'KeyC', modifiers: ['control'] } }, f.state().revision)).status).toBe('saved')
|
||||
expect((await f.store.edit({ ...edit, binding: { code: 'KeyC', modifiers: [] } }, f.state().revision)).status).toBe('saved')
|
||||
expect((await f.store.edit({ ...edit, binding: { ...binding, code: 'Comma' } }, f.state().revision)).status).toBe('conflict')
|
||||
@@ -105,19 +109,19 @@ describe('serialized preference persistence', () => {
|
||||
it('rejects early/unknown/stale-owner edits and does not publish a late read after disposal', async () => {
|
||||
const f = fixture()
|
||||
expect((await f.store.edit(edit, f.state().revision)).status).toBe('not-ready')
|
||||
await f.store.reload()
|
||||
await f.store.readCurrent()
|
||||
expect((await f.store.edit({ ...edit, id: 'missing.command' as ShortcutCommandId }, f.state().revision)).status).toBe('not-ready')
|
||||
f.store.setDefinitions(null)
|
||||
expect((await f.store.edit(edit, f.state().revision)).status).toBe('not-ready')
|
||||
f.store.dispose(); f.publish.mockClear(); f.external('{')
|
||||
await f.store.reload()
|
||||
await f.store.readCurrent()
|
||||
expect(f.publish).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
it('continues transactions after a rejected unsupported code and contains subscriber failures', async () => {
|
||||
const f = fixture(); await f.store.reload()
|
||||
const f = fixture(); await f.store.readCurrent()
|
||||
await expect(f.store.edit({ ...edit, binding: { ...binding, code: 'Unidentified' } }, f.state().revision)).rejects.toThrow('Unsupported')
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
@@ -125,6 +129,6 @@ it('continues transactions after a rejected unsupported code and contains subscr
|
||||
const result = await f.store.edit(edit, f.state().revision)
|
||||
expect(result.status).toBe('saved')
|
||||
expect(error).toHaveBeenCalledWith('Shortcut configuration subscriber failed:', expect.any(Error))
|
||||
expect((await f.store.reload()).document).toEqual(result.snapshot.document)
|
||||
expect((await f.store.readCurrent()).document).toEqual(result.snapshot.document)
|
||||
} finally { error.mockRestore() }
|
||||
})
|
||||
@@ -10,7 +10,11 @@ const gesture: ShortcutGesture = { code: 'KeyB', control: false, alt: false, shi
|
||||
repeat: false, composing: false, defaultPrevented: false }
|
||||
function command(overrides: Partial<ShortcutCommand> = {}): ShortcutCommand {
|
||||
return { id: 'test.toggle' as ShortcutCommandId, label: () => 'Toggle', aliases: ['toggle'],
|
||||
defaults: { desktop: { code: 'KeyB', modifiers: ['primary'] } }, regions: ['page', 'editable'], modals: [],
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'KeyB', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'KeyB', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'KeyB', modifiers: ['primary'] },
|
||||
}, regions: ['page', 'editable'], modals: [],
|
||||
resolve: () => ({ status: 'handled', run: () => {} }), ...overrides }
|
||||
}
|
||||
|
||||
@@ -61,11 +65,23 @@ describe('physical key registry', () => {
|
||||
const off = registry.register(command())
|
||||
expect(() => registry.register(command())).toThrow('Duplicate')
|
||||
expect(() => registry.register(command({ id: 'other.toggle' as ShortcutCommandId,
|
||||
defaults: { desktop: { code: 'KeyB', modifiers: ['control'] } } }))).toThrow('windows')
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'KeyB', modifiers: ['control'] },
|
||||
'desktop:windows': { code: 'KeyB', modifiers: ['control'] },
|
||||
'desktop:linux': { code: 'KeyB', modifiers: ['control'] },
|
||||
} }))).toThrow('windows')
|
||||
expect(() => registry.register(command({ id: 'web.new' as ShortcutCommandId,
|
||||
defaults: { web: { code: 'KeyN', modifiers: ['primary'] } } }))).toThrow('Unsupported Web')
|
||||
defaults: {
|
||||
'web:macos': { code: 'KeyN', modifiers: ['primary'] },
|
||||
'web:windows': { code: 'KeyN', modifiers: ['primary'] },
|
||||
'web:linux': { code: 'KeyN', modifiers: ['primary'] },
|
||||
} }))).toThrow('Unsupported Web')
|
||||
expect(() => registry.register(command({ id: 'system.copy' as ShortcutCommandId,
|
||||
defaults: { desktop: { code: 'KeyC', modifiers: ['primary'] } } }))).toThrow('Reserved')
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'KeyC', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'KeyC', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'KeyC', modifiers: ['primary'] },
|
||||
} }))).toThrow('Reserved')
|
||||
expect(registry.catalog.getSnapshot()).toHaveLength(1)
|
||||
const changed = vi.fn()
|
||||
registry.catalog.subscribe(changed)
|
||||
@@ -108,7 +124,7 @@ describe('physical key registry', () => {
|
||||
it('publishes stable effective rows with Windows Web three-key defaults', () => {
|
||||
const registry = new ShortcutRegistry('web', 'windows')
|
||||
let label = 'Settings'
|
||||
registry.register(command({ label: () => label }))
|
||||
registry.register(command({ label: () => label, defaults: { 'web:windows': { code: 'KeyB', modifiers: ['primary', 'alt'] } } }))
|
||||
const first = registry.catalog.getSnapshot()
|
||||
expect(registry.catalog.getSnapshot()).toBe(first)
|
||||
expect(first[0]?.keys).toEqual(['Ctrl', '+', 'Alt', '+', 'B'])
|
||||
|
||||
@@ -27,7 +27,6 @@ function desktop() {
|
||||
const unsubscribe = vi.fn()
|
||||
const api = {
|
||||
get: vi.fn<DesktopShortcutsApi['get']>().mockResolvedValue(snapshot(1)),
|
||||
reload: vi.fn<DesktopShortcutsApi['reload']>().mockResolvedValue(snapshot(2)),
|
||||
edit: vi.fn<DesktopShortcutsApi['edit']>().mockResolvedValue({ status: 'saved', snapshot: snapshot(3) }),
|
||||
recording: vi.fn<DesktopShortcutsApi['recording']>().mockResolvedValue(),
|
||||
subscribe: vi.fn<DesktopShortcutsApi['subscribe']>((listener) => { publish = listener; return unsubscribe }),
|
||||
@@ -44,25 +43,26 @@ function mount() {
|
||||
return { ctx, service, dispose }
|
||||
}
|
||||
const command = { id: 'test.toggle' as ShortcutCommandId, label: () => 'Toggle', aliases: [],
|
||||
defaults: { desktop: { code: 'KeyB', modifiers: ['primary'] as const } }, regions: ['page'] as const, modals: [],
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'KeyB', modifiers: ['primary'] as const },
|
||||
'desktop:windows': { code: 'KeyB', modifiers: ['primary'] as const },
|
||||
'desktop:linux': { code: 'KeyB', modifiers: ['primary'] as const },
|
||||
}, regions: ['page'] as const, modals: [],
|
||||
resolve: () => ({ status: 'handled' as const, run() {} }) }
|
||||
|
||||
it('waits for the Desktop handshake and ignores older get, reload, edit, and broadcast replies', async () => {
|
||||
it('waits for the Desktop handshake and ignores older get, edit, and broadcast replies', async () => {
|
||||
const f = desktop(), first = deferred<ShortcutConfigSnapshot>()
|
||||
f.api.get.mockReturnValueOnce(first.promise)
|
||||
const { service } = mount()
|
||||
f.publish(snapshot(20))
|
||||
await service.reload()
|
||||
expect(service.config.getSnapshot().status).toBe('loading')
|
||||
const off = service.register(command)
|
||||
await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') })
|
||||
f.publish(snapshot(10)); first.resolve(snapshot(1)); await first.promise
|
||||
expect(service.config.getSnapshot().sequence).toBe(10)
|
||||
f.publish(snapshot(9)); await service.reload()
|
||||
f.publish(snapshot(9))
|
||||
await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision)
|
||||
expect(service.config.getSnapshot().sequence).toBe(10)
|
||||
f.api.reload.mockResolvedValueOnce(snapshot(11))
|
||||
expect((await service.reload()).sequence).toBe(11)
|
||||
f.api.edit.mockResolvedValueOnce({ status: 'saved', snapshot: snapshot(12) })
|
||||
await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision)
|
||||
expect(service.config.getSnapshot().sequence).toBe(12)
|
||||
@@ -77,7 +77,6 @@ it('reports unavailable Desktop storage without reading or writing localStorage'
|
||||
const { service } = mount()
|
||||
expect(service.config.getSnapshot().status).toBe('unreadable')
|
||||
expect((await service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision)).status).toBe('unreadable')
|
||||
expect((await service.reload()).error).toBe('read')
|
||||
await expect(service.recording(true)).rejects.toThrow('bridge unavailable')
|
||||
expect(read).not.toHaveBeenCalled(); expect(write).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -85,12 +84,13 @@ it('reports unavailable Desktop storage without reading or writing localStorage'
|
||||
it('retains accepted bindings when the Desktop bridge rejects reads and writes', async () => {
|
||||
const f = desktop(); const { service } = mount()
|
||||
await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') })
|
||||
f.api.edit.mockRejectedValueOnce(new Error('IPC disconnected'))
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const failure = new Error('IPC disconnected')
|
||||
f.api.edit.mockRejectedValueOnce(failure)
|
||||
const accepted = service.config.getSnapshot()
|
||||
expect((await service.edit({ type: 'reset-all' }, accepted.revision)).status).toBe('write-failed')
|
||||
expect(service.config.getSnapshot()).toBe(accepted)
|
||||
f.api.reload.mockRejectedValueOnce(new Error('read failed'))
|
||||
expect((await service.reload()).error).toBe('read')
|
||||
expect(error).toHaveBeenCalledExactlyOnceWith('Shortcut preference save failed', failure)
|
||||
f.api.get.mockRejectedValueOnce(new Error('handshake failed'))
|
||||
service.register(command)
|
||||
await vi.waitFor(() => { expect(service.config.getSnapshot().error).toBe('read') })
|
||||
@@ -98,15 +98,15 @@ it('retains accepted bindings when the Desktop bridge rejects reads and writes',
|
||||
|
||||
it.each(['resolve', 'reject'] as const)('suppresses %s completions after disposal and releases the bridge subscription', async (outcome) => {
|
||||
const f = desktop(), pending = deferred<ShortcutConfigSnapshot>()
|
||||
f.api.get.mockReturnValue(pending.promise); f.api.reload.mockReturnValue(pending.promise)
|
||||
f.api.get.mockReturnValue(pending.promise)
|
||||
f.api.edit.mockImplementation(async () => ({ status: 'saved', snapshot: await pending.promise }))
|
||||
const { service, dispose } = mount()
|
||||
const off = service.register(command), reload = service.reload(), edit = service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision)
|
||||
const off = service.register(command), edit = service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision)
|
||||
const accepted = service.config.getSnapshot()
|
||||
await dispose(); off(); f.publish(snapshot(50))
|
||||
if (outcome === 'resolve') pending.resolve(snapshot(30))
|
||||
else pending.reject(new Error('window closed'))
|
||||
await Promise.all([reload, edit])
|
||||
await edit
|
||||
expect(service.config.getSnapshot()).toBe(accepted)
|
||||
expect(f.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -116,8 +116,12 @@ it('persists Web edits and presents normalized bindings through the public servi
|
||||
await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') })
|
||||
expect(service.describeBinding(null)).toEqual({ binding: null, index: null, keys: [], issue: null, conflicts: [] })
|
||||
expect(service.describeBinding({ code: 'Slash', modifiers: ['primary'] })).toMatchObject({ issue: null, index: 'control+Slash' })
|
||||
const off = service.register({ ...command, defaults: { web: { code: 'Slash', modifiers: ['primary'] } } })
|
||||
await service.reload()
|
||||
const off = service.register({ ...command, defaults: {
|
||||
'web:macos': { code: 'Slash', modifiers: ['primary'] },
|
||||
'web:windows': { code: 'Slash', modifiers: ['primary'] },
|
||||
'web:linux': { code: 'Slash', modifiers: ['primary'] },
|
||||
} })
|
||||
await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') })
|
||||
const changed = vi.fn(); service.catalog.subscribe(changed)
|
||||
ctx.locale.setLocale('zh'); expect(changed).toHaveBeenCalled()
|
||||
expect((await service.edit({ type: 'set', id: command.id, binding: null }, service.config.getSnapshot().revision)).status).toBe('saved')
|
||||
@@ -137,3 +141,15 @@ it('publishes fixed reservations to Desktop and reports their conflicts while th
|
||||
expect(service.describeBinding({ code: 'Escape', modifiers: [] }).conflicts).toEqual([])
|
||||
expect(f.api.get).toHaveBeenLastCalledWith([])
|
||||
})
|
||||
|
||||
it('propagates catalog publication failures after a successful preference save', async () => {
|
||||
const f = desktop()
|
||||
const { service } = mount()
|
||||
const label = vi.fn(() => 'Toggle')
|
||||
service.register({ ...command, label })
|
||||
await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') })
|
||||
label.mockImplementationOnce(() => { throw new Error('Command label unavailable') })
|
||||
await expect(service.edit({ type: 'reset-all' }, service.config.getSnapshot().revision))
|
||||
.rejects.toThrow('Command label unavailable')
|
||||
expect(f.api.edit).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -5,7 +5,11 @@ import type { ShortcutCommandId, ShortcutConfigSnapshot } from '../src/protocol.
|
||||
|
||||
afterEach(() => { localStorage.clear(); vi.restoreAllMocks() })
|
||||
const id = 'shortcuts.open' as ShortcutCommandId
|
||||
const definitions = [{ id, defaults: { web: { code: 'Slash', modifiers: ['primary'] as const } } }]
|
||||
const definitions = [{ id, defaults: {
|
||||
'web:macos': { code: 'Slash', modifiers: ['primary'] as const },
|
||||
'web:windows': { code: 'Slash', modifiers: ['primary'] as const },
|
||||
'web:linux': { code: 'Slash', modifiers: ['primary'] as const },
|
||||
} }]
|
||||
|
||||
it('syncs same-origin updates, rejects stale drafts, and releases the storage listener', async () => {
|
||||
let latest!: ShortcutConfigSnapshot
|
||||
@@ -33,7 +37,7 @@ it('syncs same-origin updates, rejects stale drafts, and releases the storage li
|
||||
} finally { adapter.dispose() }
|
||||
})
|
||||
|
||||
it('retains drafts after quota failure and backs up future data only on explicit recovery', async () => {
|
||||
it('retains drafts after quota failure and refuses to replace future data', async () => {
|
||||
let latest!: ShortcutConfigSnapshot
|
||||
const adapter = webShortcutStorage(window, 'windows', (value) => { latest = value })
|
||||
try {
|
||||
@@ -44,10 +48,9 @@ it('retains drafts after quota failure and backs up future data only on explicit
|
||||
write.mockRestore()
|
||||
const raw = '{"schemaVersion":9}'
|
||||
localStorage.setItem(SHORTCUT_STORAGE_KEY, raw)
|
||||
await adapter.reload()
|
||||
await adapter.get(definitions)
|
||||
expect((await adapter.edit({ type: 'reset-all' }, latest.revision)).status).toBe('unreadable')
|
||||
expect((await adapter.edit({ type: 'recover' }, latest.revision)).status).toBe('saved')
|
||||
const backup = Object.keys(localStorage).find(key => key.startsWith(`${SHORTCUT_STORAGE_KEY}.backup.`))!
|
||||
expect(localStorage.getItem(backup)).toBe(raw)
|
||||
expect(localStorage.getItem(SHORTCUT_STORAGE_KEY)).toBe(raw)
|
||||
|
||||
} finally { adapter.dispose() }
|
||||
})
|
||||
@@ -26,6 +26,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/values"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../util/values"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 3719bac54b7629d0c8ec9efb2c767f361bcd8f61
|
||||
README.zh.md: 7256ffc7b34c464aab2dafc70ec71d8c6db821f6
|
||||
README.md: 1cba7ffd9ec211b34f822afd3032263b2879b8e1
|
||||
README.zh.md: 350634cc2c7510af1203fe3eed286f707aa398ba
|
||||
@@ -27,7 +27,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
`UiConversation.events` is the single registry for event Definitions, and `UiConversation.views` is the single registry for target snapshot builders. Both registries reject duplicate keys, preserve registration order, return idempotent disposers, and rebuild existing bindings when their contribution roster changes. `UiConversation.binding(bindingOrSessionId)` returns one identity-stable Conversation binding for the current Session Controller binding. It does not open another event source. A View Definition may declare `toolCallFocus` to translate a tool-call id into its focus identity. Conversation supplies an Inspect callback only while such a target has a visible View entry; Chat consumes this callback without selecting a target.
|
||||
|
||||
`ctx.uiConversation.binding(binding).timeline` exposes the same immutable loaded Turn/Step timeline that target builders receive. Turn and Step lifecycle events publish synchronously, including when no View is active; unloading the Session binding detaches its source subscription. Consumers capture the selected Session and event sequence before an asynchronous command. The `historyStart` source publishes the oldest loaded event sequence even when a page contains no Turn or Step changes, so history consumers can detect pagination progress without reading raw events.
|
||||
`ctx.uiConversation.binding(binding).timeline` exposes the same immutable loaded Turn/Step timeline that target builders receive. Turn and Step lifecycle events publish synchronously, including when no View is active; unloading the Session binding detaches its source subscription. Consumers bind cancellation to the current Session and turn.
|
||||
|
||||
The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its outer `type` distinguishes durable events from Client-only transient events, while its inner `event` always exposes `type`, `seq`, `time`, and `data`; Definitions receive that inner `SessionEventLike`. Replacement windows may include both entry variants, while historical prepends carry durable entries and live appends may carry either. Every Definition uses the same `match` and `update` methods for both event forms, while `start` receives only a durable event and the assembler rejects a transient start. Definitions that do not consume Assistant deltas return `null` for `assistant/live-chunk`. Replacement windows and revision gaps rebuild from the complete loaded window; contiguous append, prepend, and Assistant-settlement revisions use incremental assembly. Settlement removes only the named attempt's transient matches, applies its optional durable entry, and replays the affected Contexts and dependents without replacing unrelated target nodes. The assembler owns Context matching, Turn/Step locations, target node materialization, target activity, and stable target sources. `ConversationSnapshot` contains only target-neutral views and active-target facts; Session lifecycle state remains in `SessionSnapshot`.
|
||||
|
||||
@@ -52,7 +52,7 @@ With Developer tools off, the shell hides only Trajectory; other plugin-contribu
|
||||
|
||||
The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
|
||||
|
||||
`SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work.
|
||||
`SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). Background editor updates retain the draft selection without changing the document selection or reclaiming focus from another control; explicit Composer focus restores its selection. This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work.
|
||||
|
||||
Claimed commands retain their identity and highlight when only their arguments and trailing separator are deleted; editing the command name releases the claim. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty.
|
||||
|
||||
@@ -82,7 +82,7 @@ File chips and editable skill references share a whole-reference hover backgroun
|
||||
|
||||
When another writer owns the Session, the send-error toast asks the user to quit other running DSH instances and retry.
|
||||
|
||||
Two independent Escape presses in the focused Chat or Composer stop its current running turn and preserve queued messages. The interval comes from the shortcuts plugin’s `stopSequenceMs` configuration (500 ms by default). A menu, approval, modal, terminal, embedded webpage, composition, repeated key, changed input region, Session or turn breaks the sequence. The shortcut uses the same scoped cancellation as the Stop button. Its fixed-action registration reserves plain Escape against editable shortcuts.
|
||||
Two independent Escape presses in the focused Chat or Composer stop its current running turn and preserve queued messages. The interval comes from the shortcuts plugin’s `stopSequenceMs` configuration (500 ms by default). A menu, approval, modal, terminal, embedded webpage, composition, repeated key, changed input region, Session or turn breaks the sequence. The shortcut uses the same scoped cancellation as the Stop button. Its fixed-action registration reserves plain Escape against editable shortcuts and supplies the `Esc Esc` sequence shown in the Stop button’s hover and keyboard-focus tooltip.
|
||||
|
||||
<a id="temporary-composer-entries"></a>
|
||||
## Temporary composer entries
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
|
||||
`UiConversation.events` 是 event Definition 的唯一 registry,`UiConversation.views` 是 target snapshot builder 的唯一 registry。两者都拒绝重复 key、保持注册顺序、返回幂等 disposer,并在 contribution roster 变化时重建现有 binding。`UiConversation.binding(bindingOrSessionId)` 为当前 Session Controller binding 返回 identity 稳定的 Conversation binding,不会另开事件源。 View Definition 可以声明 `toolCallFocus`,将工具调用 id 转换为自身的焦点标识。仅当此目标拥有可见的 View 条目时,Conversation 才提供 Inspect 回调;Chat 直接使用回调,不选择目标。
|
||||
|
||||
`ctx.uiConversation.binding(binding).timeline` 暴露与目标构建器相同的不可变已加载轮次/步骤时间线。轮次和步骤生命周期事件同步发布,即使没有活动视图也是如此;卸载会话绑定会解除源订阅。消费者在异步命令开始前捕获所选会话和事件序号。 `historyStart` 源发布最早已加载事件的序号,即使该页没有轮次或步骤变化也是如此,历史消费者可据此识别分页进度而无需读取原始事件。
|
||||
`ctx.uiConversation.binding(binding).timeline` 暴露与目标构建器相同的不可变已加载轮次/步骤时间线。轮次和步骤生命周期事件同步发布,即使没有活动视图也是如此;卸载会话绑定会解除源订阅。消费者将取消操作绑定到当前会话和轮次。
|
||||
|
||||
适配器把每个 `SessionEventLikeEntry` 直接交给 assembler。外层 `type` 区分持久事件与 Client-only transient event,内部 `event` 则统一公开 `type`、`seq`、`time` 与 `data`;Definition 接收这个内部 `SessionEventLike`。replacement window 可以包含两种 entry,历史 prepend 携带持久 entry,实时 append 则可以携带任一种。两种事件都使用 Definition 的同一组 `match` 与 `update` 方法,`start` 只接收持久 event,assembler 会拒绝 transient start。不消费 Assistant delta 的 Definition 对 `assistant/live-chunk` 返回 `null`。replace window 或 revision 断档从完整已加载窗口重建;连续 revision 的 append、prepend 与 Assistant settlement 使用增量组装。settlement 只删除具名 attempt 的 transient match,应用可选持久 entry,并重放受影响的 Context 及其 dependent,不替换无关 target node。assembler 拥有 Context 匹配、Turn/Step location、target node 物化、target activity 和稳定 target source。`ConversationSnapshot` 只包含与 target 无关的 View 与 active-target 事实;Session lifecycle 状态仍属于 `SessionSnapshot`。
|
||||
|
||||
@@ -52,7 +52,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
|
||||
|
||||
输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
|
||||
|
||||
`SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。
|
||||
`SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。编辑器后台更新会保留草稿选区,不修改文档选区或从其他控件夺回焦点;显式聚焦 Composer 时恢复其选区。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。
|
||||
|
||||
已认领的命令在仅删除参数和末尾分隔空格时保留身份与高亮,改动命令名才会释放认领。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。
|
||||
|
||||
@@ -82,7 +82,7 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切
|
||||
|
||||
当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。
|
||||
|
||||
在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。固定操作注册使普通 Esc 不能分配给可编辑快捷键。
|
||||
在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。固定操作注册使普通 Esc 不能分配给可编辑快捷键,并为 Stop 按钮的悬停和键盘聚焦提示提供 `Esc Esc` 序列。
|
||||
|
||||
<a id="temporary-composer-entries"></a>
|
||||
## 临时 composer entry
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { ShortcutCommandId, ShortcutFixedCommand } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { UiConversation } from './conversation/assembly.ts'
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
@@ -232,12 +232,18 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
// Stop failure is published through Session promptError.
|
||||
})
|
||||
}
|
||||
const stopShortcut = createSnapshotStore<readonly string[]>([])
|
||||
ctx.inject(['shortcuts'], (scope) => {
|
||||
scope.effect(() => installStopShortcut(scope.shortcuts, sessions, uiConversation, ctx.uiSession, stop),
|
||||
'ui-conversation: fixed stop input')
|
||||
scope.effect(() => scope.shortcuts.registerFixed({
|
||||
id: 'response.stop' as ShortcutCommandId, label: () => t('input.stop'), keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input',
|
||||
}), 'ui-conversation: fixed stop reference')
|
||||
scope.effect(() => {
|
||||
const command: ShortcutFixedCommand = {
|
||||
id: 'response.stop' as ShortcutCommandId, label: () => t('input.stop'), keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input',
|
||||
}
|
||||
const dispose = scope.shortcuts.registerFixed(command)
|
||||
stopShortcut.set(command.keys)
|
||||
return () => { stopShortcut.set([]); dispose() }
|
||||
}, 'ui-conversation: fixed stop reference')
|
||||
})
|
||||
|
||||
const inputHub = new InputHub(ctx, t)
|
||||
@@ -417,6 +423,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
toggleCommandMenu: undefined,
|
||||
stop: undefined,
|
||||
hooks: {
|
||||
stopShortcut,
|
||||
busyEnter: submissionPolicy.busyEnter,
|
||||
fileUploads: ABSENT_FILE_UPLOADS,
|
||||
notices: ABSENT_NOTICES,
|
||||
@@ -492,6 +499,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
},
|
||||
stop: () => { stop(sessionId) },
|
||||
hooks: {
|
||||
stopShortcut,
|
||||
busyEnter: submissionPolicy.busyEnter,
|
||||
fileUploads: conversation.fileUploads,
|
||||
notices: shell.notices,
|
||||
|
||||
@@ -391,6 +391,8 @@ export interface ComposerBarInjected {
|
||||
toggleCommandMenu: ((selection: EditSelection) => void) | undefined
|
||||
stop: (() => void) | undefined
|
||||
hooks: {
|
||||
/** Readable sequence while the fixed Stop command is registered. */
|
||||
stopShortcut: ObservableSnapshot<readonly string[]>
|
||||
/**
|
||||
* Live busy-state submission preference: the delivery mode plain Enter
|
||||
* and the primary Send button use while the addressed agent is busy.
|
||||
|
||||
@@ -30,8 +30,6 @@ export interface ConversationBinding {
|
||||
readonly snapshot: ObservableSnapshot<ConversationSnapshot>
|
||||
/** Loaded Turn/Step timeline; lifecycle events publish synchronously even without an active View. */
|
||||
readonly timeline: ObservableSnapshot<ConversationTimelineSnapshot>
|
||||
/** First loaded event sequence, published synchronously after history replacement or paging. */
|
||||
readonly historyStart: ObservableSnapshot<number | undefined>
|
||||
/**
|
||||
* Add one selected target to the Session's monotonic active set.
|
||||
* @param target - registered or subsequently registered Conversation target.
|
||||
@@ -52,7 +50,6 @@ export interface ConversationBinding {
|
||||
class BoundConversation implements ConversationBinding {
|
||||
readonly snapshot: SnapshotStore<ConversationSnapshot>
|
||||
readonly timeline: SnapshotStore<ConversationTimelineSnapshot>
|
||||
readonly historyStart = createSnapshotStore<number | undefined>(undefined)
|
||||
private readonly viewStore: ConversationViewSnapshotStore
|
||||
private readonly targetSources = new Map<string, ObservableSnapshot<unknown>>()
|
||||
private revision = -1
|
||||
@@ -106,7 +103,6 @@ class BoundConversation implements ConversationBinding {
|
||||
private replace(window: SessionEventWindow): void {
|
||||
this.revision = window.revision
|
||||
this.publish(this.assembler.replaceWindow(window.entries, window.hasMore))
|
||||
this.historyStart.set(window.entries[0]?.event.seq)
|
||||
}
|
||||
|
||||
private accept(window: SessionEventWindow): void {
|
||||
@@ -119,7 +115,6 @@ class BoundConversation implements ConversationBinding {
|
||||
switch (window.change.kind) {
|
||||
case 'prepend':
|
||||
this.publish(this.assembler.prepend(window.change.entries, window.hasMore))
|
||||
this.historyStart.set(window.entries[0]?.event.seq)
|
||||
return
|
||||
case 'append': {
|
||||
let publication: ConversationPublication = 'none'
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { LexicalEditor, NodeKey } from 'lexical'
|
||||
import {
|
||||
$addUpdateTag, $createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection,
|
||||
CLEAR_HISTORY_COMMAND, createEditor, HISTORY_MERGE_TAG, PASTE_TAG,
|
||||
BLUR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_CRITICAL, createEditor, HISTORY_MERGE_TAG, PASTE_TAG,
|
||||
RootNode, SELECTION_CHANGE_COMMAND, SKIP_DOM_SELECTION_TAG,
|
||||
} from 'lexical'
|
||||
import { registerPlainText } from '@lexical/plain-text'
|
||||
import { createEmptyHistoryState, registerHistory } from '@lexical/history'
|
||||
@@ -65,8 +66,23 @@ export class DraftEditorRuntime {
|
||||
* @returns unregister callback that also detaches the editor root.
|
||||
*/
|
||||
register(): () => void {
|
||||
// A retained draft selection must not move keyboard focus back from another control.
|
||||
const preserveExternalSelection = (): false => {
|
||||
const root = this.editor.getRootElement()
|
||||
if (root !== null && !root.contains(root.ownerDocument.activeElement)) {
|
||||
$addUpdateTag(SKIP_DOM_SELECTION_TAG)
|
||||
}
|
||||
return false
|
||||
}
|
||||
const unregister = mergeRegister(
|
||||
registerPlainText(this.editor),
|
||||
this.editor.registerCommand(BLUR_COMMAND, () => {
|
||||
// Finish this batch before an explicit focus can restore its updated selection.
|
||||
this.editor.update(preserveExternalSelection, { discrete: true })
|
||||
return false
|
||||
}, COMMAND_PRIORITY_CRITICAL),
|
||||
this.editor.registerCommand(SELECTION_CHANGE_COMMAND, preserveExternalSelection, COMMAND_PRIORITY_CRITICAL),
|
||||
this.editor.registerNodeTransform(RootNode, preserveExternalSelection),
|
||||
registerReferenceActivation(this.editor, (source, reference) =>
|
||||
this.deps.openReference(source, reference)),
|
||||
registerHistory(this.editor, createEmptyHistoryState(), HISTORY_MERGE_DELAY_MS),
|
||||
|
||||
@@ -9,6 +9,7 @@ const PLAN_NEXT_ACTION_EN = 'describe your task to generate plan'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'shortcut.hint': '{label} {keys}',
|
||||
'hint.plan': PLAN_NEXT_ACTION_ZH,
|
||||
'hint.goal': '输入目标,智能体将持续执行',
|
||||
'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
|
||||
@@ -363,6 +364,7 @@ export type ConversationKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'shortcut.hint': '{label} {keys}',
|
||||
'hint.plan': PLAN_NEXT_ACTION_EN,
|
||||
'hint.goal': 'describe the objective for a long-running task',
|
||||
'hint.goal.active': 'goal active — edit / pause / resume / clear',
|
||||
|
||||
@@ -46,7 +46,7 @@ export const InputBar = memo(function InputBar({
|
||||
useSession, useInput, inputActions, keyboard, addFiles, removeAttachment, resolveDraftAttachments,
|
||||
retryFileUpload,
|
||||
toggleCommandMenu, stop, t,
|
||||
renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher,
|
||||
renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, useStopShortcut,
|
||||
useProjection, sessionId, variant, disabled: inert = false, blocked,
|
||||
workspacePickerOpen = false, onRequestWorkspace,
|
||||
placeholder, accessory,
|
||||
@@ -54,6 +54,7 @@ export const InputBar = memo(function InputBar({
|
||||
const input = useInput(s => s)
|
||||
const notice = useNotices(s => s)
|
||||
const busyEnter = useBusyEnter(s => s)
|
||||
const stopKeys = useStopShortcut(keys => keys)
|
||||
void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms
|
||||
const commandMenuOpen = useMenuLauncher(source => source === 'command')
|
||||
const [activity, setActivity] = useState(false)
|
||||
@@ -310,6 +311,7 @@ export const InputBar = memo(function InputBar({
|
||||
: running && steeringAvailable && !disabled && !uploadsPending && plainMessageDraft
|
||||
? t(primarySubmitMode === 'steer' ? 'input.send.steer' : 'input.send.queue')
|
||||
: t('input.send')
|
||||
const stopHint = stopKeys.length ? t('shortcut.hint', { label: t('input.stop'), keys: stopKeys.join(' ') }) : t('input.stop')
|
||||
const onPrimary = (): void => {
|
||||
if (primaryStops) {
|
||||
stop?.()
|
||||
@@ -457,7 +459,7 @@ export const InputBar = memo(function InputBar({
|
||||
{renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
|
||||
</div>}
|
||||
{interruptible && (
|
||||
<Tooltip label={t('input.stop')} side="top" delayMs={500} disabled={stop === undefined}>
|
||||
<Tooltip label={stopHint} side="top" delayMs={500} disabled={stop === undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.primary}
|
||||
@@ -472,7 +474,7 @@ export const InputBar = memo(function InputBar({
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label={primaryLabel} side="top" delayMs={500} disabled={primaryDisabled}>
|
||||
<Tooltip label={primaryStops ? stopHint : primaryLabel} side="top" delayMs={500} disabled={primaryDisabled}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.primary}
|
||||
|
||||
@@ -143,18 +143,6 @@ async function bootRegistries(): Promise<{
|
||||
}
|
||||
|
||||
describe('Conversation registries', () => {
|
||||
it('publishes history progress even when an older page has no turn lifecycle events', async () => {
|
||||
const { uiConversation, binding } = await bootRegistries()
|
||||
const events = binding.eventSource as MutableSessionEventSource
|
||||
const conversation = uiConversation.binding(binding)
|
||||
events.replace([{ type: 'event', event: { type: 'session/title', seq: SessionSeq(20), time: 0, data: { title: 'tail', messageSeqs: [], source: { kind: 'user' } } } }], true)
|
||||
const timeline = conversation.timeline.getSnapshot()
|
||||
expect(conversation.historyStart.getSnapshot()).toBe(20)
|
||||
events.prepend([{ type: 'event', event: { type: 'session/title', seq: SessionSeq(10), time: 0, data: { title: 'older', messageSeqs: [], source: { kind: 'user' } } } }], true)
|
||||
expect(conversation.historyStart.getSnapshot()).toBe(10)
|
||||
expect(conversation.timeline.getSnapshot()).toBe(timeline)
|
||||
})
|
||||
|
||||
it('publishes lifecycle timeline without an active view and retains the captured completed turn', async () => {
|
||||
const { uiConversation, binding } = await bootRegistries()
|
||||
const conversation = uiConversation.binding(binding)
|
||||
@@ -162,7 +150,6 @@ describe('Conversation registries', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = conversation.timeline.subscribe(listener)
|
||||
const initial = conversation.timeline.getSnapshot()
|
||||
expect(conversation.historyStart.getSnapshot()).toBeUndefined()
|
||||
expect(initial.turnOrder).toEqual([])
|
||||
source.append({ type: 'event', event: {
|
||||
type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 },
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Composer selection survives focus transfers and background editor reconciliation. */
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { $getRoot, $getSelection, $isRangeSelection, SELECTION_CHANGE_COMMAND, UNDO_COMMAND } from 'lexical'
|
||||
import { DraftEditorRuntime } from '../src/client/input/editor/runtime.ts'
|
||||
import { focusDraftEditor } from '../src/client/input/editor/view-binding.ts'
|
||||
|
||||
function fixture(start = 1, end = 4) {
|
||||
// jsdom has no Range geometry; these cases observe selection and focus, not scrolling.
|
||||
const geometry = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect')
|
||||
Object.defineProperty(Range.prototype, 'getBoundingClientRect', { configurable: true, value: () => new DOMRect() })
|
||||
onTestFinished(() => {
|
||||
if (geometry === undefined) Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
|
||||
else Object.defineProperty(Range.prototype, 'getBoundingClientRect', geometry)
|
||||
})
|
||||
const root = document.createElement('div')
|
||||
root.setAttribute('contenteditable', 'true')
|
||||
root.tabIndex = 0
|
||||
const outside = document.createElement('button')
|
||||
document.body.append(root, outside)
|
||||
onTestFinished(() => { root.remove(); outside.remove() })
|
||||
// jsdom collapses document Selection on every focus(); Chromium retains the
|
||||
// editor range here. Keep native focus events and activeElement changes.
|
||||
for (const element of [root, outside]) {
|
||||
const focus = element.focus.bind(element)
|
||||
const mocked = vi.spyOn(element, 'focus').mockImplementation((options) => {
|
||||
const selection = document.getSelection()!
|
||||
const range = selection.rangeCount === 0 ? undefined : selection.getRangeAt(0).cloneRange()
|
||||
focus(options)
|
||||
if (range !== undefined) {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
})
|
||||
onTestFinished(() => { mocked.mockRestore() })
|
||||
}
|
||||
const onUpdate = vi.fn()
|
||||
const runtime: DraftEditorRuntime = new DraftEditorRuntime({
|
||||
onUpdate: () => { runtime.refreshProjection(); onUpdate() },
|
||||
openReference: () => false, activeClaimToken: () => null,
|
||||
lexicon: () => new Map(), resolveLexicon: () => undefined,
|
||||
})
|
||||
const dispose = runtime.register()
|
||||
const { editor } = runtime
|
||||
onTestFinished(() => { dispose(); editor.setRootElement(null) })
|
||||
editor.setRootElement(root)
|
||||
runtime.setDraft('alpha beta')
|
||||
root.focus()
|
||||
editor.update(() => { $getRoot().getAllTextNodes()[0]!.select(start, end) }, { discrete: true })
|
||||
const selection = () => editor.getEditorState().read(() => {
|
||||
const selected = $getSelection()
|
||||
if (!$isRangeSelection(selected)) throw new Error('Expected a Composer range selection')
|
||||
return { anchorKey: selected.anchor.key, anchor: selected.anchor.offset,
|
||||
focusKey: selected.focus.key, focus: selected.focus.offset, text: selected.getTextContent() }
|
||||
})
|
||||
const domSelection = () => {
|
||||
const selected = document.getSelection()!
|
||||
return { anchorNode: selected.anchorNode, anchor: selected.anchorOffset,
|
||||
focusNode: selected.focusNode, focus: selected.focusOffset, text: selected.toString() }
|
||||
}
|
||||
const focusComposer = () => {
|
||||
const reveal = vi.fn()
|
||||
outside.addEventListener('click', () => { focusDraftEditor(editor, reveal) }, { once: true })
|
||||
outside.click()
|
||||
editor.read(() => {})
|
||||
return reveal
|
||||
}
|
||||
return { runtime, editor, root, outside, onUpdate, dispose, selection, domSelection, focusComposer }
|
||||
}
|
||||
|
||||
it.each([
|
||||
['range', 1, 4], ['caret', 3, 3],
|
||||
] as const)('retains the %s while another control owns focus', (_kind, start, end) => {
|
||||
const f = fixture(start, end)
|
||||
const internal = f.selection()
|
||||
const dom = f.domSelection()
|
||||
expect(internal).toMatchObject({ anchor: start, focus: end })
|
||||
expect(dom).toMatchObject({ anchor: start, focus: end })
|
||||
|
||||
f.outside.focus()
|
||||
f.editor.read(() => {})
|
||||
expect(document.activeElement).toBe(f.outside)
|
||||
expect(f.selection()).toEqual(internal)
|
||||
expect(f.domSelection()).toEqual(dom)
|
||||
|
||||
f.editor.update(() => { $getRoot().getFirstDescendant()!.markDirty() }, { discrete: true })
|
||||
expect(document.activeElement).toBe(f.outside)
|
||||
expect(f.selection()).toEqual(internal)
|
||||
expect(f.domSelection()).toEqual(dom)
|
||||
|
||||
f.editor.update(() => {
|
||||
const selected = $getSelection()
|
||||
if (!$isRangeSelection(selected)) throw new Error('Expected a Composer range selection')
|
||||
selected.dirty = true
|
||||
f.editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined)
|
||||
}, { discrete: true })
|
||||
expect(document.activeElement).toBe(f.outside)
|
||||
expect(f.selection()).toEqual(internal)
|
||||
expect(f.domSelection()).toEqual(dom)
|
||||
})
|
||||
|
||||
it('keeps background insertion and undo outside the editor until focus is explicitly restored', () => {
|
||||
const f = fixture()
|
||||
const selected = f.selection()
|
||||
f.outside.focus()
|
||||
f.editor.read(() => {})
|
||||
|
||||
f.runtime.paste('X')
|
||||
expect(f.root.textContent).toBe('aXa beta')
|
||||
expect(f.selection()).toMatchObject({ anchor: 2, focus: 2 })
|
||||
expect(document.activeElement).toBe(f.outside)
|
||||
f.editor.dispatchCommand(UNDO_COMMAND, undefined)
|
||||
f.editor.read(() => {})
|
||||
expect(f.root.textContent).toBe('alpha beta')
|
||||
expect(f.selection()).toEqual(selected)
|
||||
expect(document.activeElement).toBe(f.outside)
|
||||
|
||||
const reveal = f.focusComposer()
|
||||
expect(document.activeElement).toBe(f.root)
|
||||
expect(f.domSelection()).toMatchObject({ anchor: 1, focus: 4, text: 'lph' })
|
||||
expect(f.selection()).toEqual(selected)
|
||||
expect(reveal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores a pending selection when blur and explicit focus share one update batch', () => {
|
||||
const f = fixture()
|
||||
f.editor.update(() => {
|
||||
const text = $getRoot().getAllTextNodes()[0]!
|
||||
text.markDirty()
|
||||
text.select(6, 6)
|
||||
})
|
||||
f.outside.focus()
|
||||
const reveal = f.focusComposer()
|
||||
expect(document.activeElement).toBe(f.root)
|
||||
expect(f.selection()).toMatchObject({ anchor: 6, focus: 6 })
|
||||
expect(f.domSelection()).toMatchObject({ anchor: 6, focus: 6, text: '' })
|
||||
expect(reveal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('removes focus preservation and update subscriptions when registration is disposed', () => {
|
||||
const f = fixture()
|
||||
f.dispose()
|
||||
expect(f.editor.getRootElement()).toBeNull()
|
||||
f.onUpdate.mockClear()
|
||||
f.editor.setRootElement(f.root)
|
||||
f.root.focus()
|
||||
f.editor.update(() => { $getRoot().getAllTextNodes()[0]!.select(1, 4) }, { discrete: true })
|
||||
f.outside.focus()
|
||||
f.editor.update(() => { $getRoot().getFirstDescendant()!.markDirty() }, { discrete: true })
|
||||
expect(document.activeElement).toBe(f.root)
|
||||
expect(f.onUpdate).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -156,6 +156,7 @@ function bench(over?: BenchOptions) {
|
||||
const removeAttachment = vi.fn((id: DraftAttachmentId) => { shell.removeAttachment(id) })
|
||||
const menuLauncher = createSnapshotStore<string | null>(over?.commandMenuOpen === true ? 'command' : null)
|
||||
const busyEnter = createSnapshotStore<'queue' | 'steer'>(over?.busyEnter ?? 'queue')
|
||||
const stopShortcut = createSnapshotStore<readonly string[]>(['Esc', 'Esc'])
|
||||
const slotCalls: { key: string; owner: unknown }[] = []
|
||||
const renderSlot = ((key: string, owner: object) => {
|
||||
slotCalls.push({ key, owner })
|
||||
@@ -204,6 +205,7 @@ function bench(over?: BenchOptions) {
|
||||
}),
|
||||
toggleCommandMenu: over?.toggleCommandMenu ?? vi.fn(),
|
||||
useBusyEnter: bindSnapshotSelector(busyEnter),
|
||||
useStopShortcut: bindSnapshotSelector(stopShortcut),
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
useMenuLauncher: bindSnapshotSelector(menuLauncher),
|
||||
@@ -246,7 +248,7 @@ function bench(over?: BenchOptions) {
|
||||
const interruptButton = view.container.querySelector<HTMLButtonElement>('button[aria-label="停止生成"]')
|
||||
return {
|
||||
view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeAttachment, slotCalls,
|
||||
menuLauncher, busyEnter,
|
||||
menuLauncher, busyEnter, stopShortcut,
|
||||
steerQueue: over?.steerQueue,
|
||||
get placeholder() { return placeholderOf(view.container) },
|
||||
get inputDisabled() { return textarea.getAttribute('aria-disabled') === 'true' },
|
||||
@@ -839,13 +841,36 @@ describe('Enter semantics', () => {
|
||||
})
|
||||
|
||||
describe('running and lock semantics', () => {
|
||||
it.each([
|
||||
{ messages: zh, label: '停止生成', trigger: 'hover' },
|
||||
{ messages: en, label: 'Stop generating', trigger: 'focus' },
|
||||
])('shows the registered Stop sequence on $trigger and removes it when unavailable', ({ messages, label, trigger }) => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { view, stopShortcut } = bench({
|
||||
running: true,
|
||||
subagent: { address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }, parentAvailable: true },
|
||||
t: makeTranslate(messages, {}),
|
||||
})
|
||||
const stop = view.getByRole('button', { name: label })
|
||||
if (trigger === 'hover') fireEvent.mouseEnter(stop)
|
||||
else fireEvent.focus(stop)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(view.getByRole('tooltip').textContent).toBe(`${label} Esc Esc`)
|
||||
act(() => { stopShortcut.set([]) })
|
||||
expect(view.getByRole('tooltip').textContent).toBe(label)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('dismisses the Stop tooltip when an empty composer becomes idle', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { button, view, session } = bench({ running: true })
|
||||
fireEvent.mouseEnter(button)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(view.getByRole('tooltip').textContent).toBe('停止生成')
|
||||
expect(view.getByRole('tooltip').textContent).toBe('停止生成 Esc Esc')
|
||||
|
||||
// Disabling a hovered native button need not deliver mouseleave.
|
||||
act(() => { session.set(snapshotOf({ running: false })) })
|
||||
|
||||
@@ -83,6 +83,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled
|
||||
})),
|
||||
toggleCommandMenu: vi.fn(),
|
||||
useBusyEnter: bindSnapshotSelector(createSnapshotStore<'queue' | 'steer'>('queue')),
|
||||
useStopShortcut: bindSnapshotSelector(createSnapshotStore<readonly string[]>([])),
|
||||
useNotices: bindSnapshotSelector(shell.notices),
|
||||
useLexicon: bindSnapshotSelector(shell.lexicon),
|
||||
useMenuLauncher: bindSnapshotSelector(createSnapshotStore<string | null>(null)),
|
||||
|
||||
@@ -191,6 +191,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo
|
||||
useMenuLauncher: bindSnapshotSelector(controller.launcher),
|
||||
renderSlot: (() => null) as InputBarProps['renderSlot'],
|
||||
stop: vi.fn(),
|
||||
useStopShortcut: bindSnapshotSelector(createSnapshotStore<readonly string[]>([])),
|
||||
t: makeTranslate(zh, commonZh),
|
||||
variant: 'composer',
|
||||
}
|
||||
|
||||
@@ -289,6 +289,7 @@ function mount(
|
||||
resolveDraftAttachments={() => []}
|
||||
toggleCommandMenu={vi.fn()}
|
||||
useBusyEnter={bindSnapshotSelector(createSnapshotStore<'queue' | 'steer'>('queue'))}
|
||||
useStopShortcut={bindSnapshotSelector(createSnapshotStore<readonly string[]>([]))}
|
||||
useNotices={bindSnapshotSelector(wiring.notices)}
|
||||
useLexicon={bindSnapshotSelector(wiring.lexicon)}
|
||||
useMenuLauncher={bindSnapshotSelector(createSnapshotStore<string | null>(null))}
|
||||
|
||||
@@ -182,7 +182,13 @@ export function apply(ctx: ClientContext): void {
|
||||
}, AppFrame)
|
||||
const disposeShortcut = ctx.shortcuts.register({
|
||||
id: 'sidebar.left.toggle' as ShortcutCommandId, label: () => t('toggle'), aliases: ['sidebar', 'toggle left sidebar'],
|
||||
defaults: { desktop: { code: 'KeyB', modifiers: ['primary'] } },
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'KeyB', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'KeyB', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'KeyB', modifiers: ['primary'] },
|
||||
'web:macos': { code: 'KeyB', modifiers: ['primary', 'alt'] },
|
||||
'web:windows': { code: 'KeyB', modifiers: ['primary', 'alt'] },
|
||||
},
|
||||
regions: ['page', 'editable'], modals: [],
|
||||
resolve: () => ({ status: 'handled', run: () => { layout.toggleSidebar() } }),
|
||||
})
|
||||
|
||||
@@ -58,7 +58,13 @@ export function apply(ctx: ClientContext): void {
|
||||
? t('shortcut.busy') : target() === undefined ? t('shortcut.unavailable') : null
|
||||
ctx.effect(() => ctx.shortcuts.register({
|
||||
id: 'workspace.openLocal' as ShortcutCommandId, label: () => t('open.tooltip'), aliases: ['open workspace locally', 'open in app'],
|
||||
defaults: { desktop: { code: 'KeyO', modifiers: ['primary', 'alt'] } },
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'KeyO', modifiers: ['primary', 'alt'] },
|
||||
'desktop:windows': { code: 'KeyO', modifiers: ['primary', 'alt'] },
|
||||
'desktop:linux': { code: 'KeyO', modifiers: ['primary', 'alt'] },
|
||||
'web:macos': { code: 'KeyO', modifiers: ['primary', 'shift'] },
|
||||
'web:windows': { code: 'KeyO', modifiers: ['primary', 'shift'] },
|
||||
},
|
||||
regions: ['page', 'editable'], modals: [],
|
||||
availability: {
|
||||
getSnapshot: unavailable,
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 8672f59d96bb76386564d27325dd40e56856282c
|
||||
README.zh.md: 1b241de7fcf432e186a4496bd6abfa82be48e74b
|
||||
README.md: b587aedd58ec3612b37ce7fdc8bf4ce6f9ecb9f8
|
||||
README.zh.md: 98bd69dfb74767c14166e395cecdaeba25b748e3
|
||||
@@ -25,7 +25,7 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` renders caller-supplied effective keys together in one rounded badge; its optional `className` lets the owner style interaction states. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
|
||||
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` renders caller-supplied effective keys together in one rounded badge; its optional `className` lets the owner style interaction states. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
|
||||
|
||||
This package is a Web-shell build input. Its static ESM retains third-party imports and styles for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)).
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-library"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
锚点中嵌套的 `Tooltip` 在显示悬停或焦点标签时隐藏该预览;嵌套提示释放、禁用或卸载后,仍处于打开状态的预览会恢复。`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 将调用方提供的有效按键组合显示在同一个圆角标签中;可选的 `className` 供调用方设置交互状态样式。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
|
||||
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 将调用方提供的有效按键组合显示在同一个圆角标签中;可选的 `className` 供调用方设置交互状态样式。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
|
||||
|
||||
本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。
|
||||
|
||||
@@ -79,7 +79,7 @@ kind: "package-library"
|
||||
|
||||
### 控件与图标
|
||||
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。
|
||||
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。锚点中嵌套的 `Tooltip` 在显示悬停或焦点标签时隐藏该预览;嵌套提示释放、禁用或卸载后,仍处于打开状态的预览会恢复。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 HomeLine truncated
|
||||
|
||||
### 渲染 agent 输出
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts'
|
||||
export { HoverCard } from './HoverCard.tsx'
|
||||
export { ShortcutKeys } from './ShortcutKeys.tsx'
|
||||
export { observeComposition } from './keyboard-composition.ts'
|
||||
export { useModalLayer } from './useModalLayer.ts'
|
||||
export { isBehindModal, useModalLayer } from './useModalLayer.ts'
|
||||
export { Modal } from './Modal.tsx'
|
||||
export { OnboardingSurface } from './OnboardingSurface.tsx'
|
||||
export { RiskConfirmation } from './RiskConfirmation.tsx'
|
||||
|
||||
@@ -177,7 +177,13 @@ export function apply(ctx: ClientContext): void {
|
||||
const shellStore: typeof shellHandle = { ...shellHandle, create: () => shellInstance }
|
||||
const disposeCommand = ctx.shortcuts.register({
|
||||
id: 'settings.open' as ShortcutCommandId, label: () => t('shortcut.open'), aliases: ['settings', 'preferences'],
|
||||
defaults: { desktop: { code: 'Comma', modifiers: ['primary'] } },
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'Comma', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'Comma', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'Comma', modifiers: ['primary'] },
|
||||
'web:macos': { code: 'Comma', modifiers: ['primary'] },
|
||||
'web:windows': { code: 'Comma', modifiers: ['primary'] },
|
||||
},
|
||||
regions: ['page', 'editable', 'terminal'], modals: ['settings'],
|
||||
resolve: () => ({ status: 'handled', run: () => { shellInstance.actions.open() } }),
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-shortcuts/README.md
|
||||
README.md: ae4d6052cfcbb8e1593c204a08bee111b8b00b50
|
||||
README.zh.md: 5b24645e5b84238639734997a4a3d7cc9de92663
|
||||
README.md: c2ba1c6933059240a376b09c0bd70b81be62a00d
|
||||
README.zh.md: 1e39df1c97aaedbb29fcceb70d74401ea89ce2ca
|
||||
@@ -29,7 +29,7 @@ The Web app bundle mounts this package together with `shortcuts`. The reference
|
||||
|
||||
Search matches an ordered subsequence within one label, English alias, or key combination, ignoring case. Each matching command appears once. Application and fixed-action rows remain 42 CSS pixels high during normal display and recording; long command names are truncated. Application rows show command names and binding controls without availability subtitles or reason tooltips, including conflicting and invalid bindings. Commands that cannot execute remain silent. Fixed actions show disabled, dimmed key buttons without a separate status label; they cannot enter recording and do not highlight on hover.
|
||||
|
||||
Hover an application row or focus it with the keyboard to reveal its actions. The key badge highlights only while hovered; selecting it or Edit starts the same inline recorder. Unbound commands omit both remove controls. Windows and macOS Desktop record one or two overlapping non-modifier keys with optional modifiers; a third key rejects the draft. Releasing the first key saves the current combination, so sequential A then B records only A. Saving rejects combinations occupied by editable or fixed actions, including overlapping single-key and two-key bindings. The dialog close control exits recording. The recorder keeps the same height as key badges and uses a single border. Other environments retain Escape cancellation and Tab navigation. Saving and cancellation return focus to the dialog. macOS Web can record Option+Command+N reported as a dead key; composition and ordinary accent input remain protected. Remove clears the binding; Restore Default removes only that command’s override. System toasts announce successful writes and failures; a rejected combination marks the recorder with an error border. Repeated identical errors leave a visible toast and its dismissal time unchanged; different errors and successful writes replace it immediately. An error can appear again after dismissal. Failed writes retain the draft for Retry Save. External configuration changes require review before a draft can be retried. Read failures disable editing and announce the active configuration through a system toast. The dialog has no footer actions.
|
||||
Hover an application row or focus it with the keyboard to reveal its actions. The key badge highlights only while hovered; selecting it or Edit starts the same inline recorder. Unbound commands omit both remove controls. Windows and macOS Desktop record one or two overlapping non-modifier keys with optional modifiers; a third key rejects the draft. Releasing the first key saves the current combination, so sequential A then B records only A. Saving rejects combinations occupied by editable or fixed actions, including overlapping single-key and two-key bindings. The dialog close control exits recording. The recorder keeps the same height as key badges and uses a single border. Other environments retain Escape cancellation and Tab navigation. Saving and cancellation return focus to the dialog. macOS Web can record Option+Command+N reported as a dead key; composition and ordinary accent input remain protected. Remove clears the binding; Restore Default removes only that command’s override. System toasts announce successful writes and failures; a rejected combination marks the recorder with an error border. Release its non-modifier keys to record another combination without clicking; modifiers may remain held. Repeated identical errors leave a visible toast and its dismissal time unchanged; different errors and successful writes replace it immediately. An error can appear again after dismissal. Failed writes retain recorder focus and the draft for Retry Save or another recording. External configuration changes require review before a draft can be retried. Read failures disable editing and announce the active configuration through a system toast. The footer shows Restore All Defaults and the current runtime/platform’s override count, including removed bindings and commands that are not currently mounted. Search does not change the count. The action is disabled without overrides. Its confirmation initially focuses Cancel; cancellation returns focus to Restore All Defaults, including when opened from the recorder. Confirming restores only this profile. A stale revision closes the confirmation and requires another confirmation against the latest configuration; a failed write retains the accepted bindings and allows retry.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ Web 应用 bundle 将此包与 `shortcuts` 一同挂载。速查仅展示已注
|
||||
|
||||
搜索在单个操作名称、英文别名或按键组合内按字符顺序模糊匹配,忽略大小写。每个命中命令只出现一次。正常显示和录制时,应用操作行和固定操作行均保持 42 CSS 像素高;过长的命令名称会被截断。应用命令行只显示名称和改键控件,不显示可用性副标题或原因悬浮提示,冲突和无效绑定也不例外。无法执行的命令静默结束。固定操作使用灰色禁用的键位按钮,不额外显示状态文字;按钮不能进入录键,悬停时不高亮。
|
||||
|
||||
悬停应用操作行或通过键盘聚焦可显示操作图标。键位按钮仅在自身悬停时加深底色;点击它或编辑图标均进入同一行内录键。未绑定的命令不显示删除图标和“移除”文字按钮。Windows 和 macOS Desktop 可录制一至两个按住时间重叠的非修饰键,可搭配修饰键;第三个键会使草稿被拒绝。释放第一个键即保存当前组合,因此先后按 A、B 只录入 A。保存会拒绝被可编辑或固定操作占用的组合,包括相互重叠的单键与双键绑定。弹窗关闭控件可退出录制。录制框与键位标签保持相同高度,边框为单层。其他环境保留 Escape 取消和 Tab 导航。保存及取消后焦点返回弹窗。macOS Web 可录制被报告为 dead key 的 Option+Command+N;输入法组合和普通重音输入仍受保护。移除清空绑定,恢复默认只删除该命令的覆盖。系统提示反馈保存成功和失败;组合被拒绝时,录键框显示错误边框。同一错误重复发生时沿用仍在显示的提示及其消失时间;不同错误和保存成功立即替换提示。提示消失后,同类错误可再次显示。写入失败保留草稿以便重试保存。外部配置变化后,需要核对最新配置才能重试草稿。读取失败时禁用编辑,并通过系统提示说明当前使用的配置。弹窗没有底部操作区。
|
||||
悬停应用操作行或通过键盘聚焦可显示操作图标。键位按钮仅在自身悬停时加深底色;点击它或编辑图标均进入同一行内录键。未绑定的命令不显示删除图标和“移除”文字按钮。Windows 和 macOS Desktop 可录制一至两个按住时间重叠的非修饰键,可搭配修饰键;第三个键会使草稿被拒绝。释放第一个键即保存当前组合,因此先后按 A、B 只录入 A。保存会拒绝被可编辑或固定操作占用的组合,包括相互重叠的单键与双键绑定。弹窗关闭控件可退出录制。录制框与键位标签保持相同高度,边框为单层。其他环境保留 Escape 取消和 Tab 导航。保存及取消后焦点返回弹窗。macOS Web 可录制被报告为 dead key 的 Option+Command+N;输入法组合和普通重音输入仍受保护。移除清空绑定,恢复默认只删除该命令的覆盖。系统提示反馈保存成功和失败;组合被拒绝时,录键框显示错误边框。松开非修饰键后即可直接录入新组合,无需再次点击,修饰键可继续按住。同一错误重复发生时沿用仍在显示的提示及其消失时间;不同错误和保存成功立即替换提示。提示消失后,同类错误可再次显示。写入失败保留录键框焦点和草稿,可重试保存或直接录入新组合。外部配置变化后,需要核对最新配置才能重试草稿。读取失败时禁用编辑,并通过系统提示说明当前使用的配置。底部显示“恢复全部默认”和当前运行环境及平台的自定义项数,包括已移除的绑定和当前未挂载的命令。搜索不影响计数。没有自定义项时,该操作禁用。确认框默认聚焦“取消”;取消后焦点返回“恢复全部默认”,从录制框打开确认时也如此。确认后仅恢复当前配置分组。配置版本过期时关闭确认框,需要针对最新配置重新确认;写入失败保留已接受的绑定,允许重试。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ type EditorProps = InjectFace<EditorInjected> & PropsLocale<'shortcuts'> & {
|
||||
/**
|
||||
* Save a released physical combination against the configuration the user reviewed.
|
||||
* @param props - command, accepted snapshots, and storage/feedback callbacks.
|
||||
* @returns inline command controls; failures retain the draft.
|
||||
* @returns inline command controls; failures retain the draft and allow another recording.
|
||||
*/
|
||||
export function ShortcutEditor({ target, onClose, onSaved, onError, useCatalog, useConfig, useFixedCatalog,
|
||||
edit, recording, describeBinding, runtime, platform, t }:
|
||||
@@ -81,11 +81,10 @@ EditorProps) {
|
||||
const composition = observeComposition(document)
|
||||
let pending: ShortcutBinding | null = null
|
||||
let dead = false
|
||||
let finished = false
|
||||
let invalid = false
|
||||
let blocked = false
|
||||
const held = new Set<string>()
|
||||
const reset = (): void => { pending = null; held.clear(); invalid = false; dead = false }
|
||||
restart.current = () => { reset(); finished = false }
|
||||
const reset = (): void => { pending = null; held.clear(); blocked = false; dead = false }
|
||||
restart.current = reset
|
||||
const down = (event: KeyboardEvent): void => {
|
||||
if (composition.guards(event) || event.getModifierState('AltGraph')) { if (desktopChords) reset(); return }
|
||||
if ((!desktopChords || document.activeElement !== recorder.current) && event.key === 'Escape' && !event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey) {
|
||||
@@ -102,32 +101,34 @@ EditorProps) {
|
||||
const recordTab = desktopChords || (platform === 'macos' || platform === 'windows') && modifiers.length >= 3
|
||||
if (document.activeElement !== recorder.current || (event.key === 'Tab' && !recordTab)) return
|
||||
event.preventDefault(); event.stopPropagation()
|
||||
if (writing.current || finished || event.repeat) return
|
||||
if (writing.current || event.repeat) return
|
||||
if (/^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(event.code)) {
|
||||
if (desktopChords && pending !== null) { reset(); setCandidate(null); setCaptured(false) }
|
||||
return
|
||||
}
|
||||
if (desktopChords) {
|
||||
held.add(event.code)
|
||||
if (blocked) return
|
||||
if (held.size > 2) {
|
||||
invalid = true; pending = null; handlers.current.report(t('too-many-keys')); return
|
||||
blocked = true; pending = null; handlers.current.report(t('too-many-keys')); return
|
||||
}
|
||||
if (invalid) return
|
||||
}
|
||||
try {
|
||||
const codes: [string, ...string[]] = [event.code, ...desktopChords ? [...held].filter(value => value !== event.code) : []]
|
||||
pending = describeBinding({ code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), modifiers }).binding
|
||||
setCandidate(pending); setCaptured(desktopChords); setMessage(''); setRetry(null)
|
||||
} catch (_error) { invalid = desktopChords; pending = null; handlers.current.report(t('unsupported-key')) }
|
||||
} catch (_error) { blocked = desktopChords; pending = null; handlers.current.report(t('unsupported-key')) }
|
||||
}
|
||||
const up = (event: KeyboardEvent): void => {
|
||||
held.delete(event.code)
|
||||
if (desktopChords && document.activeElement === recorder.current) { event.preventDefault(); event.stopPropagation() }
|
||||
const binding = pending !== null && (pending.code === event.code || pending.secondCode === event.code
|
||||
|| pending.modifiers.some(modifier => modifier === event.code.replace(/(Left|Right)$/u, '').toLowerCase())) ? pending : null
|
||||
if (binding !== null) { pending = null; blocked = desktopChords }
|
||||
// macOS can omit the character keyup while Command is held.
|
||||
if (pending === null || (pending.code !== event.code && pending.secondCode !== event.code
|
||||
&& !pending.modifiers.some(modifier => modifier === event.code.replace(/(Left|Right)$/u, '').toLowerCase()))) return
|
||||
if (desktopChords && (held.size === 0 || platform === 'macos' && /^Meta(Left|Right)$/u.test(event.code))) { held.clear(); blocked = false }
|
||||
if (binding === null) return
|
||||
if (desktopChords && document.activeElement !== recorder.current) { reset(); return }
|
||||
const binding = pending; reset(); finished = desktopChords
|
||||
handlers.current.capture(binding, targetId)
|
||||
}
|
||||
const blur = (): void => { reset(); if (desktopChords) { setCandidate(null); setCaptured(false) } }
|
||||
@@ -161,9 +162,9 @@ EditorProps) {
|
||||
<button type="button" className={css.inlineAction} disabled={readonly} onClick={() => { void save({ type: 'reset', id: target.id }) }}>{t('reset')}</button>
|
||||
{target.binding !== null && <button type="button" className={css.inlineAction} disabled={readonly}
|
||||
onClick={() => { void save({ type: 'set', id: target.id, binding: null }) }}>{t('clear')}</button>}
|
||||
<button ref={recorder} type="button" className={clsx(css.recorder, message && css.invalid)} disabled={busy || !nativeReady}
|
||||
<button ref={recorder} type="button" className={clsx(css.recorder, message && css.invalid)} disabled={!nativeReady} aria-disabled={busy || !nativeReady}
|
||||
aria-label={t('record')} aria-invalid={message !== ''} aria-describedby={descriptionId}
|
||||
onClick={() => { restart.current(); setCaptured(false); setMessage(''); setRetry(null) }}>
|
||||
onClick={() => { if (!writing.current) { restart.current(); setCaptured(false); setMessage(''); setRetry(null) } }}>
|
||||
{captured && message === '' ? <ShortcutKeys keys={describeBinding(candidate).keys} className={css.recorded} /> : t('record')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,34 @@
|
||||
overscroll-behavior: contain;
|
||||
padding: 0 24px 18px;
|
||||
}
|
||||
.footer {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 60px;
|
||||
padding: 0 24px;
|
||||
border-top: 0.5px solid var(--dsw-alias-border-l2);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
.resetAll {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.resetAll:enabled:hover { color: var(--dsw-alias-label-secondary); }
|
||||
.resetAll:focus-visible { outline: 1px solid var(--dsw-alias-label-tertiary); outline-offset: 2px; }
|
||||
.resetAll:disabled { cursor: default; opacity: 0.45; }
|
||||
.modifiedCount { color: var(--dsw-alias-label-tertiary); }
|
||||
.group {
|
||||
margin: 18px 0 4px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
@@ -176,4 +204,4 @@ p.hint { margin: 6px 0; }
|
||||
.toastError { color: var(--dsw-alias-state-warn-secondary); }
|
||||
.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
|
||||
@media (hover: none) { .rowActions { opacity: 1; } }
|
||||
.button:disabled, .iconButton:disabled, .keyButton:disabled, .inlineAction:disabled, .recorder:disabled { cursor: default; opacity: 0.45; }
|
||||
.button:disabled, .iconButton:disabled, .keyButton:disabled, .inlineAction:disabled, .recorder[aria-disabled="true"] { cursor: default; opacity: 0.45; }
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Searchable editable shortcut reference and its General Settings row. */
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { IconCloseOutlineRegular, Modal, ShortcutKeys, Tooltip, Toast, rankByName } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Button, IconCloseOutlineRegular, IconRefreshOutlineRegular, Modal, ShortcutKeys, Tooltip, Toast, isBehindModal, rankByName } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ObservableSnapshot, PropsStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ShortcutCatalogEntry, ShortcutPlatform, Shortcuts } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
@@ -56,6 +56,7 @@ export function ShortcutReference({
|
||||
const config = useConfig(value => value)
|
||||
const contributedFixed = useFixedCatalog(value => value)
|
||||
const [target, setTarget] = useState<ShortcutCatalogEntry | null>(null)
|
||||
const [resetRevision, setResetRevision] = useState<typeof config.revision | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [toast, setToast] = useState<{ text: string; error: boolean; seq: number } | null>(null)
|
||||
const mounted = useRef(true)
|
||||
@@ -77,9 +78,11 @@ export function ShortcutReference({
|
||||
}
|
||||
const closeReference = (): void => {
|
||||
if (busy) return
|
||||
if (target !== null) closeEditor()
|
||||
if (resetRevision !== null) setResetRevision(null)
|
||||
else if (target !== null) closeEditor()
|
||||
else actions.close()
|
||||
}
|
||||
useEffect(() => { if (!open) setResetRevision(null) }, [open])
|
||||
const persist: typeof edit = async (...args) => {
|
||||
setBusy(true)
|
||||
const result = await edit(...args)
|
||||
@@ -91,11 +94,19 @@ export function ShortcutReference({
|
||||
if (!mounted.current) return
|
||||
notify(result.status === 'saved' ? t('saved') : shortcutFailure(result, catalog, t), result.status !== 'saved')
|
||||
}
|
||||
const resetAll = async (): Promise<void> => {
|
||||
if (resetRevision === null) return
|
||||
const result = await persist({ type: 'reset-all' }, resetRevision)
|
||||
if (!mounted.current) return
|
||||
if (result.status === 'saved' || result.status === 'stale') setResetRevision(null)
|
||||
notify(result.status === 'saved' ? t('reset-saved')
|
||||
: result.status === 'write-failed' ? t('reset-failed') : shortcutFailure(result, catalog, t), result.status !== 'saved')
|
||||
}
|
||||
const editorProps = { useCatalog, useConfig, useFixedCatalog, platform, runtime, edit: persist, recording, describeBinding, t,
|
||||
onClose: closeEditor,
|
||||
onSaved: () => { notify(t('saved')); closeEditor() },
|
||||
onError: (message: string) => { notify(message, true) } }
|
||||
useLayoutEffect(() => { if (open) search.current?.focus() }, [open, focusRequest])
|
||||
useLayoutEffect(() => { if (open && !isBehindModal(search.current)) search.current?.focus() }, [open, focusRequest])
|
||||
const fixed = fixedCommands(t, describeBinding).map(({ id, label, keys, group }) => ({ id, label: label(), keys, group }))
|
||||
const entries = [
|
||||
...catalog.map(row => ({
|
||||
@@ -108,6 +119,10 @@ export function ShortcutReference({
|
||||
]
|
||||
const ranked = rankByName(entries.flatMap(row => row.names.map(name => ({ name, label: row.label, row }))), query.trim())
|
||||
const matches = [...new Set(ranked.map(match => match.row))]
|
||||
const modifiedCount = Object.keys(config.document.profiles[`${runtime}:${platform}`] ?? {}).length
|
||||
useLayoutEffect(() => {
|
||||
if (open && resetRevision === null && modifiedCount === 0 && document.activeElement === document.body) search.current?.focus()
|
||||
}, [open, resetRevision, modifiedCount])
|
||||
return <><Modal open={open} onClose={closeReference} title={t('title')} headless
|
||||
shortcutModal="shortcuts" className={css.dialog as string}>
|
||||
<header className={css.header}>
|
||||
@@ -162,7 +177,20 @@ export function ShortcutReference({
|
||||
})}
|
||||
{matches.length === 0 && <p className={css.hint} role="status">{t('empty')}</p>}
|
||||
</div>
|
||||
<footer className={css.footer}>
|
||||
<button type="button" className={css.resetAll} disabled={busy || config.status !== 'ready' || modifiedCount === 0}
|
||||
onClick={(event) => { event.currentTarget.focus(); setTarget(null); setResetRevision(config.revision) }}>
|
||||
<IconRefreshOutlineRegular size={16} />{t('reset-all')}
|
||||
</button>
|
||||
<span className={css.modifiedCount}>{t('modified-count', { count: modifiedCount })}</span>
|
||||
</footer>
|
||||
</Modal>
|
||||
<Modal open={open && resetRevision !== null} title={t('reset-title')} description={t('reset-description')}
|
||||
closeLabel={t('close-confirmation')} onClose={() => { if (!busy) setResetRevision(null) }}
|
||||
footer={<>
|
||||
<Button data-modal-autofocus disabled={busy} onClick={() => { setResetRevision(null) }}>{t('cancel')}</Button>
|
||||
<Button variant="primary" disabled={busy || config.status !== 'ready'} onClick={() => { void resetAll() }}>{t('reset')}</Button>
|
||||
</>} />
|
||||
{toast !== null && <Toast key={toast.seq} text={toast.text} onDone={dismissToast}
|
||||
icon={<ShortcutIcon kind={toast.error ? 'error' : 'success'} className={toast.error ? css.toastError : css.toastSuccess} />} />}
|
||||
</>
|
||||
|
||||
@@ -45,7 +45,14 @@ export function apply(ctx: Context): void {
|
||||
ctx.slots.inject('shell.overlay', () => {
|
||||
const disposeCommand = ctx.shortcuts.register({
|
||||
id: 'shortcuts.open' as ShortcutCommandId, label: () => t('open'), aliases: ['shortcuts', 'keyboard shortcuts'],
|
||||
defaults: { desktop: { code: 'Slash', modifiers: ['primary'] }, web: { code: 'Slash', modifiers: ['primary'] } },
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'Slash', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'Slash', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'Slash', modifiers: ['primary'] },
|
||||
'web:macos': { code: 'Slash', modifiers: ['primary'] },
|
||||
'web:windows': { code: 'Slash', modifiers: ['primary'] },
|
||||
'web:linux': { code: 'Slash', modifiers: ['primary'] },
|
||||
},
|
||||
regions: ['page', 'editable', 'terminal'], modals: ['settings', 'shortcuts'],
|
||||
resolve: () => ({ status: 'handled', run: () => { instance.actions.open() } }),
|
||||
})
|
||||
|
||||
@@ -5,11 +5,16 @@ export const zh = {
|
||||
'web-help': '浏览器可用组合:Mod+/、Mod+Shift+,、Mod+Shift+.。Mod 在 Mac 上为 Command,其他系统为 Ctrl。',
|
||||
'unsupported-key': '暂不支持这个按键。', reserved: '此组合由系统或文本编辑操作保留。',
|
||||
'modifier-required': '请同时按下 Command、Ctrl 或 Alt 修饰键。',
|
||||
'too-many-keys': '最多同时按下两个非修饰键。请点击录制按钮重试。',
|
||||
'too-many-keys': '最多同时按下两个非修饰键。请松开后重试。',
|
||||
'macos-web-help': '可设置 Command+/、Command+,、Command+反斜杠、Control+反引号、Command+Option+按键或 Command+Shift+按键。也支持三个或四个不同修饰键的组合。浏览器或系统占用的组合可能无法送达页面。',
|
||||
'windows-web-help': '可设置 Ctrl+/、Ctrl+,、Ctrl+Alt+按键或 Ctrl+Shift+按键。也支持三个或四个不同修饰键的组合。浏览器或系统占用的组合可能无法送达页面。',
|
||||
'unsupported-browser': '此浏览器暂不支持该组合。', conflict: '已被「{commands}」占用',
|
||||
saved: '已修改', clear: '移除', 'retry-save': '重试保存', reset: '恢复默认',
|
||||
'reset-all': '恢复全部默认', 'modified-count': '{count} 项已自定义',
|
||||
'reset-title': '恢复全部默认快捷键?',
|
||||
'reset-description': '将恢复当前平台的默认快捷键。你修改或移除的快捷键都会恢复,其他平台不受影响。',
|
||||
cancel: '取消', 'reset-saved': '已恢复默认快捷键',
|
||||
'close-confirmation': '关闭确认', 'reset-failed': '恢复默认失败。原快捷键已保留,请重试。',
|
||||
review: '已核对最新配置',
|
||||
stale: '配置已在其他页面或窗口中更新。请核对最新键位后再保存。',
|
||||
'write-failed': '保存失败。原快捷键和当前草稿已保留,请重试。',
|
||||
@@ -32,11 +37,16 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
'web-help': 'Browser combinations: Mod+/, Mod+Shift+,, Mod+Shift+.. Mod is Command on Mac and Ctrl elsewhere.',
|
||||
'unsupported-key': 'This key is not supported.', reserved: 'This combination is reserved for system or text editing actions.',
|
||||
'modifier-required': 'Include Command, Ctrl, or Alt in the combination.',
|
||||
'too-many-keys': 'Hold at most two non-modifier keys. Click the recorder to try again.',
|
||||
'too-many-keys': 'Hold at most two non-modifier keys. Release the keys to try again.',
|
||||
'macos-web-help': 'Use Command+/, Command+,, Command+Backslash, Control+Backquote, Command+Option+key, or Command+Shift+key. Combinations with three or four distinct modifiers are also supported. Browser or system shortcuts may not reach the page.',
|
||||
'windows-web-help': 'Use Ctrl+/, Ctrl+,, Ctrl+Alt+key, or Ctrl+Shift+key. Combinations with three or four distinct modifiers are also supported. Browser or system shortcuts may not reach the page.',
|
||||
'unsupported-browser': 'This browser does not support this combination yet.', conflict: 'Already used by “{commands}”',
|
||||
saved: 'Modified', clear: 'Remove', 'retry-save': 'Retry save', reset: 'Restore default',
|
||||
'reset-all': 'Restore all defaults', 'modified-count': '{count} customized',
|
||||
'reset-title': 'Restore all default shortcuts?',
|
||||
'reset-description': 'Restore the default shortcuts for this platform. All modified or removed shortcuts will be restored. Other platforms are unaffected.',
|
||||
cancel: 'Cancel', 'reset-saved': 'Default shortcuts restored',
|
||||
'close-confirmation': 'Close confirmation', 'reset-failed': 'Could not restore defaults. Your shortcuts are unchanged. Please retry.',
|
||||
review: 'I have reviewed the latest configuration',
|
||||
stale: 'Configuration changed in another page or window. Review the latest bindings before saving.',
|
||||
'write-failed': 'Could not save. Your previous shortcuts and current draft are preserved. Please retry.',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, onTestFinished } from 'vitest'
|
||||
import { describe, expect, onTestFinished, vi } from 'vitest'
|
||||
import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
|
||||
import type {} from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { createSettingsShellStore } from '../../ui-settings-general/src/client/shell-store.ts'
|
||||
@@ -19,7 +19,7 @@ describe('assembled shortcut command owners', () => {
|
||||
const overlay = client.ctx.slots.entries('shell.overlay').find(entry => entry.options.id === 'shortcuts')!
|
||||
const injected = (overlay.inject as unknown as () => ReferenceInjected)()
|
||||
expect(injected.hooks.catalog).toBe(shortcuts.catalog)
|
||||
await shortcuts.reload()
|
||||
await vi.waitFor(() => { expect(shortcuts.config.getSnapshot().status).toBe('ready') })
|
||||
expect(injected.describeBinding(null).keys).toEqual([])
|
||||
await injected.recording(false)
|
||||
const shortcut = shortcuts.catalog.getSnapshot().find(row => row.id === 'shortcuts.open')!
|
||||
|
||||
@@ -22,8 +22,10 @@ async function mount(options: {
|
||||
const platform = options.platform ?? 'macos'
|
||||
const runtime = options.runtime ?? 'desktop'
|
||||
const registry = new ShortcutRegistry(runtime, platform)
|
||||
registry.register({ id, label: () => 'Toggle sidebar', aliases: [], defaults: { desktop: { code: 'KeyB', modifiers: ['primary'] } },
|
||||
regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run() {} }) })
|
||||
registry.register({ id, label: () => 'Toggle sidebar', aliases: [], defaults: {
|
||||
[`${runtime}:${platform}`]: { code: 'KeyB', modifiers: runtime === 'desktop' ? ['primary'] : ['primary', 'alt'] },
|
||||
},
|
||||
regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run() {} }) })
|
||||
const describeBinding: Parameters<typeof ShortcutEditor>[0]['describeBinding'] = (binding) => {
|
||||
const normalized = binding === null ? null : normalizeBinding(binding, platform)
|
||||
return { binding: normalized, index: normalized === null ? null : bindingKey(normalized),
|
||||
@@ -36,9 +38,9 @@ async function mount(options: {
|
||||
}
|
||||
for (const command of fixedCommands(makeTranslate(en), describeBinding)) registry.registerFixed(command)
|
||||
let raw: string | null = null
|
||||
const storage = { read: vi.fn(async () => raw), write: vi.fn(async (next: string) => { raw = next }), backup: vi.fn(async () => {}) }
|
||||
const storage = { read: vi.fn(async () => raw), write: vi.fn(async (next: string) => { raw = next }) }
|
||||
const persistence = new ShortcutPersistence(storage, runtime, platform, false, (value) => { registry.configure(value) })
|
||||
persistence.setDefinitions(registry.definitions()); await persistence.reload()
|
||||
persistence.setDefinitions(registry.definitions()); await persistence.readCurrent()
|
||||
options.setup?.(registry)
|
||||
const onClose = vi.fn(), onSaved = vi.fn(), onError = vi.fn(), recording = vi.fn(options.recording ?? (async () => {}))
|
||||
const view = render(<ShortcutEditor target={registry.catalog.getSnapshot()[0]!}
|
||||
@@ -151,6 +153,21 @@ it('ignores modifier-only input, repeat, IME, dead keys, and AltGraph', async ()
|
||||
expect(f.storage.write).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('ignores the accent character after a %s dead key is released', async (platform) => {
|
||||
const f = await mount({ platform })
|
||||
const recorder = screen.getByRole('button', { name: en.record })
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(recorder, { code: 'KeyE', key: 'Dead', altKey: true })
|
||||
fireEvent.keyUp(recorder, { code: 'KeyE', altKey: true })
|
||||
fireEvent.keyUp(recorder, { code: 'AltLeft', key: 'Alt' })
|
||||
fireEvent.keyDown(recorder, { code: 'KeyA', key: 'á' })
|
||||
fireEvent.keyUp(recorder, { code: 'KeyA' })
|
||||
})
|
||||
expect(f.storage.write).not.toHaveBeenCalled()
|
||||
expect(f.onSaved).not.toHaveBeenCalled()
|
||||
expect(f.onError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires review after an external update and cancels Escape outside the recorder', async () => {
|
||||
const f = await mount()
|
||||
const recorder = screen.getByRole('button', { name: en.record }); recorder.focus()
|
||||
@@ -235,7 +252,7 @@ it('blocks dismissal during a write and ignores completion after unmount', async
|
||||
const reply = new Promise<ShortcutSaveResult>((resolve) => { settle = resolve })
|
||||
const f = await mount({ result: () => reply })
|
||||
press('KeyJ')
|
||||
expect(screen.getByRole('button', { name: en.record }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: en.record }).getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(f.onClose).not.toHaveBeenCalled()
|
||||
f.view.unmount()
|
||||
@@ -322,12 +339,13 @@ it.each(['macos', 'windows'] as const)('rejects %s fixed-action keys and their o
|
||||
{ code: 'Enter', key: 'Enter', metaKey: true }, { code: 'ArrowUp', key: 'ArrowUp' },
|
||||
{ code: 'ArrowDown', key: 'ArrowDown' }, { code: 'Slash', key: '/' }, { code: 'Digit2', key: '@', shiftKey: true },
|
||||
]) {
|
||||
fireEvent.click(recorder); recorder.focus()
|
||||
const errors = f.onError.mock.calls.length
|
||||
fireEvent.keyDown(recorder, input); fireEvent.keyUp(recorder, input)
|
||||
expect(f.onError).toHaveBeenCalledTimes(errors + 1)
|
||||
expect(document.activeElement).toBe(recorder)
|
||||
expect(recorder.getAttribute('aria-invalid')).toBe('true')
|
||||
expect(f.onError).toHaveBeenLastCalledWith(expect.stringContaining('Already used by'))
|
||||
}
|
||||
fireEvent.click(recorder)
|
||||
fireEvent.keyDown(recorder, { code: 'KeyA', key: 'a' })
|
||||
fireEvent.keyDown(recorder, { code: 'Escape', key: 'Escape' })
|
||||
fireEvent.keyUp(recorder, { code: 'Escape' })
|
||||
@@ -351,16 +369,99 @@ it.each(['macos', 'windows'] as const)('records only the first released key for
|
||||
expect(f.storage.write).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects three held ordinary keys until the recorder is restarted', async () => {
|
||||
const f = await mount()
|
||||
it.each(['macos', 'windows'] as const)('accepts a new %s combination after all keys of a rejected three-key draft are released', async (platform) => {
|
||||
const f = await mount({ platform })
|
||||
const recorder = screen.getByRole('button', { name: en.record }); recorder.focus()
|
||||
for (const code of ['KeyA', 'KeyB', 'KeyC']) fireEvent.keyDown(recorder, { code, key: code.slice(3) })
|
||||
for (const code of ['KeyA', 'KeyB', 'KeyC']) fireEvent.keyUp(recorder, { code })
|
||||
expect(screen.getByText(en['too-many-keys'])).toBeTruthy()
|
||||
expect(f.storage.write).not.toHaveBeenCalled()
|
||||
fireEvent.click(recorder)
|
||||
press('KeyJ', { metaKey: false })
|
||||
expect(document.activeElement).toBe(recorder)
|
||||
fireEvent.keyDown(recorder, { code: 'KeyJ', key: 'j' })
|
||||
fireEvent.keyUp(recorder, { code: 'KeyJ' })
|
||||
await waitFor(() => { expect(f.onSaved).toHaveBeenCalledOnce() })
|
||||
expect(f.registry.catalog.getSnapshot()[0]?.binding).toEqual({ code: 'KeyJ', modifiers: [] })
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('retries a %s command conflict while continuing to hold the modifier', async (platform) => {
|
||||
const f = await mount({ platform, setup: (registry) => {
|
||||
registry.register({ id: 'other.toggle' as ShortcutCommandId, label: () => 'Other action', aliases: [],
|
||||
defaults: { [`desktop:${platform}`]: { code: 'KeyK', modifiers: ['primary'] } },
|
||||
regions: ['page'], modals: [], resolve: () => ({ status: 'pass' }) })
|
||||
} })
|
||||
const recorder = screen.getByRole('button', { name: en.record })
|
||||
const modifiers = platform === 'macos' ? { metaKey: true } : { ctrlKey: true }
|
||||
fireEvent.keyDown(recorder, { code: 'KeyK', key: 'k', ...modifiers })
|
||||
fireEvent.keyUp(recorder, { code: 'KeyK', ...modifiers })
|
||||
expect(f.onError).toHaveBeenCalledWith('Already used by “Other action”')
|
||||
expect(f.storage.write).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(recorder)
|
||||
fireEvent.keyDown(recorder, { code: 'KeyJ', key: 'j', ...modifiers })
|
||||
expect(recorder.getAttribute('aria-invalid')).toBe('false')
|
||||
fireEvent.keyUp(recorder, { code: 'KeyJ', ...modifiers })
|
||||
await waitFor(() => { expect(f.onSaved).toHaveBeenCalledOnce() })
|
||||
expect(f.registry.catalog.getSnapshot()[0]?.binding).toEqual({ code: 'KeyJ', modifiers: [platform === 'macos' ? 'meta' : 'control'] })
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('waits for the remaining %s chord keys before accepting a retry', async (platform) => {
|
||||
const f = await mount({ platform })
|
||||
const recorder = screen.getByRole('button', { name: en.record })
|
||||
fireEvent.keyDown(recorder, { code: 'KeyA', key: 'a' })
|
||||
fireEvent.keyDown(recorder, { code: 'Escape', key: 'Escape' })
|
||||
fireEvent.keyUp(recorder, { code: 'Escape' })
|
||||
expect(f.onError).toHaveBeenCalledOnce()
|
||||
fireEvent.keyDown(recorder, { code: 'KeyA', key: 'a', repeat: true })
|
||||
fireEvent.keyDown(recorder, { code: 'KeyJ', key: 'j' })
|
||||
fireEvent.keyUp(recorder, { code: 'KeyA' })
|
||||
fireEvent.keyDown(recorder, { code: 'KeyK', key: 'k' })
|
||||
fireEvent.keyUp(recorder, { code: 'KeyJ' })
|
||||
fireEvent.keyUp(recorder, { code: 'KeyK' })
|
||||
expect(f.onError).toHaveBeenCalledOnce()
|
||||
expect(f.storage.write).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(recorder, { code: 'KeyJ', key: 'j' })
|
||||
fireEvent.keyUp(recorder, { code: 'KeyJ' })
|
||||
await waitFor(() => { expect(f.onSaved).toHaveBeenCalledOnce() })
|
||||
expect(f.registry.catalog.getSnapshot()[0]?.binding).toEqual({ code: 'KeyJ', modifiers: [] })
|
||||
})
|
||||
|
||||
it('retries unsupported keys and macOS conflicts whose character keyup is omitted', async () => {
|
||||
const f = await mount()
|
||||
const recorder = screen.getByRole('button', { name: en.record })
|
||||
fireEvent.keyDown(recorder, { code: 'Unidentified', key: 'Unidentified', metaKey: true })
|
||||
expect(f.onError).toHaveBeenLastCalledWith(en['unsupported-key'])
|
||||
fireEvent.keyUp(recorder, { code: 'MetaLeft', key: 'Meta' })
|
||||
fireEvent.keyDown(recorder, { code: 'Enter', key: 'Enter', metaKey: true })
|
||||
fireEvent.keyUp(recorder, { code: 'MetaLeft', key: 'Meta' })
|
||||
expect(f.onError).toHaveBeenCalledTimes(2)
|
||||
expect(f.onError).toHaveBeenLastCalledWith(expect.stringContaining('Already used by'))
|
||||
fireEvent.keyDown(recorder, { code: 'KeyJ', key: 'j', metaKey: true })
|
||||
fireEvent.keyUp(recorder, { code: 'MetaLeft', key: 'Meta' })
|
||||
await waitFor(() => { expect(f.onSaved).toHaveBeenCalledOnce() })
|
||||
expect(f.registry.catalog.getSnapshot()[0]?.binding).toEqual({ code: 'KeyJ', modifiers: ['meta'] })
|
||||
})
|
||||
|
||||
it('keeps recorder focus during a write and accepts another combination after failure', async () => {
|
||||
const f = await mount()
|
||||
let reject!: (error: Error) => void
|
||||
const write = new Promise<void>((_resolve, rejectWrite) => { reject = rejectWrite })
|
||||
f.storage.write.mockImplementationOnce(() => write)
|
||||
const recorder = screen.getByRole('button', { name: en.record })
|
||||
fireEvent.keyDown(recorder, { code: 'KeyJ', key: 'j', metaKey: true })
|
||||
fireEvent.keyUp(recorder, { code: 'KeyJ' })
|
||||
expect(recorder.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(recorder.hasAttribute('disabled')).toBe(false)
|
||||
fireEvent.click(recorder)
|
||||
fireEvent.keyDown(recorder, { code: 'KeyK', key: 'k', metaKey: true })
|
||||
fireEvent.keyUp(recorder, { code: 'KeyK' })
|
||||
await waitFor(() => { expect(f.storage.write).toHaveBeenCalledOnce() })
|
||||
expect(recorder.textContent).toContain('J')
|
||||
await act(async () => { reject(new Error('disk full')); await write.catch(() => {}) })
|
||||
await screen.findByText(en['write-failed'])
|
||||
expect(document.activeElement).toBe(recorder)
|
||||
fireEvent.keyDown(recorder, { code: 'KeyK', key: 'k', metaKey: true })
|
||||
fireEvent.keyUp(recorder, { code: 'KeyK' })
|
||||
await waitFor(() => { expect(f.onSaved).toHaveBeenCalledOnce() })
|
||||
expect(f.registry.catalog.getSnapshot()[0]?.binding).toEqual({ code: 'KeyK', modifiers: ['meta'] })
|
||||
})
|
||||
|
||||
it.each(['focus', 'composition'] as const)('abandons an unfinished Desktop recording on %s changes', async (change) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ShortcutCatalogEntry, ShortcutCommandId, ShortcutFixedCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
@@ -79,7 +79,7 @@ it.each(['macos', 'windows'] as const)('shows the reference, filters labels and
|
||||
})
|
||||
|
||||
|
||||
it('saves individual edits and reports configuration failures without footer actions', async () => {
|
||||
it('saves individual edits and disables changes when configuration cannot be read', async () => {
|
||||
const store = createShortcutsStore().create()
|
||||
const catalog = createSnapshotStore<readonly ShortcutCatalogEntry[]>([
|
||||
{ id: 'shortcuts.open' as ShortcutCommandId, label: 'Open shortcuts', aliases: [], binding: null, modified: true,
|
||||
@@ -94,7 +94,7 @@ it('saves individual edits and reports configuration failures without footer act
|
||||
describeBinding, t: makeTranslate(en) } as Parameters<typeof ShortcutReference>[0]
|
||||
store.actions.open()
|
||||
render(<ShortcutReference {...props} />)
|
||||
expect(screen.getByRole('dialog').querySelector('footer')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: en['reset-all'] }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.queryByText('Unavailable')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: 'Edit shortcut for Open shortcuts' }).hasAttribute('disabled')).toBe(true)
|
||||
act(() => { config.set({ ...config.getSnapshot(), status: 'ready' }) })
|
||||
@@ -110,7 +110,7 @@ it('saves individual edits and reports configuration failures without footer act
|
||||
act(() => { config.set({ ...config.getSnapshot(), error: 'future', usingDefaults: false }) })
|
||||
expect(screen.getByText(`${en.future} ${en['using-accepted']}`)).toBeTruthy()
|
||||
expect(screen.getByRole('dialog').contains(screen.getByRole('alert'))).toBe(false)
|
||||
expect(screen.getByRole('dialog').querySelector('footer')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: en['reset-all'] }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: 'Edit shortcut for Open shortcuts' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -281,3 +281,129 @@ it('blocks reference dismissal during a removal and ignores its completion after
|
||||
await act(async () => { settle({ status: 'saved', snapshot: f.config.getSnapshot() }); await reply })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('counts current-profile overrides even when hidden by search or an unloaded command, and cancels without saving', () => {
|
||||
const f = referenceFixture()
|
||||
const restore = screen.getByRole('button', { name: en['reset-all'] })
|
||||
expect(restore.hasAttribute('disabled')).toBe(true)
|
||||
act(() => { f.config.set({ ...f.config.getSnapshot(), document: { schemaVersion: 2, profiles: {
|
||||
'desktop:macos': { 'settings.open': null },
|
||||
} } }) })
|
||||
expect(restore.hasAttribute('disabled')).toBe(true)
|
||||
act(() => {
|
||||
f.config.set({ ...f.config.getSnapshot(), document: { schemaVersion: 2, profiles: {
|
||||
'web:macos': { 'settings.open': null, 'unloaded.command': { code: 'KeyI', modifiers: ['meta', 'alt'] } },
|
||||
'web:windows': { 'settings.open': null },
|
||||
} } })
|
||||
f.store.actions.search('no matching command')
|
||||
})
|
||||
expect(screen.getByText('2 customized')).toBeTruthy()
|
||||
expect(screen.queryAllByRole('listitem')).toHaveLength(0)
|
||||
restore.focus(); fireEvent.click(restore)
|
||||
const confirmation = screen.getByRole('dialog', { name: en['reset-title'] })
|
||||
expect(confirmation.textContent).toContain(en['reset-description'])
|
||||
const cancel = within(confirmation).getByRole('button', { name: en.cancel })
|
||||
expect(document.activeElement).toBe(cancel)
|
||||
act(() => { f.store.actions.open() })
|
||||
expect(document.activeElement).toBe(cancel)
|
||||
fireEvent.click(cancel)
|
||||
expect(screen.queryByRole('dialog', { name: en['reset-title'] })).toBeNull()
|
||||
expect(document.activeElement).toBe(restore)
|
||||
expect(f.edit).not.toHaveBeenCalled()
|
||||
fireEvent.click(restore)
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(screen.getAllByRole('dialog')).toHaveLength(1)
|
||||
expect(f.store.getSnapshot().open).toBe(true)
|
||||
})
|
||||
|
||||
it('restores focus to Reset All when clicking it does not move focus out of the recorder', () => {
|
||||
const f = referenceFixture()
|
||||
act(() => { f.config.set({ ...f.config.getSnapshot(), document: { schemaVersion: 1, profiles: {
|
||||
'web:macos': { 'settings.open': null },
|
||||
} } }) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' }))
|
||||
const recorder = screen.getByRole('button', { name: en.record })
|
||||
recorder.focus()
|
||||
const restore = screen.getByRole('button', { name: en['reset-all'] })
|
||||
// Safari mouse clicks do not focus buttons; fireEvent keeps the recorder focused until the handler runs.
|
||||
fireEvent.click(restore)
|
||||
expect(recorder.isConnected).toBe(false)
|
||||
const confirmation = screen.getByRole('dialog', { name: en['reset-title'] })
|
||||
const cancel = within(confirmation).getByRole('button', { name: en.cancel })
|
||||
expect(document.activeElement).toBe(cancel)
|
||||
fireEvent.click(cancel)
|
||||
expect(document.activeElement).toBe(restore)
|
||||
expect(f.edit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores defaults after confirmation and retains accepted configuration when the write fails', async () => {
|
||||
const f = referenceFixture()
|
||||
act(() => { f.config.set({ ...f.config.getSnapshot(), document: { schemaVersion: 1, profiles: {
|
||||
'web:macos': { 'settings.open': null },
|
||||
} } }) })
|
||||
const original = f.config.getSnapshot()
|
||||
let settle!: (result: Awaited<ReturnType<typeof f.edit>>) => void
|
||||
f.edit.mockReturnValueOnce(new Promise((resolve) => { settle = resolve }))
|
||||
const restore = screen.getByRole('button', { name: en['reset-all'] })
|
||||
restore.focus(); fireEvent.click(restore)
|
||||
const confirmation = screen.getByRole('dialog', { name: en['reset-title'] })
|
||||
const confirm = within(confirmation).getByRole('button', { name: en.reset })
|
||||
fireEvent.click(confirm)
|
||||
expect(f.edit).toHaveBeenCalledWith({ type: 'reset-all' }, original.revision)
|
||||
expect(confirm.hasAttribute('disabled')).toBe(true)
|
||||
expect(within(confirmation).getByRole('button', { name: en.cancel }).hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.keyDown(confirmation, { key: 'Escape' })
|
||||
expect(screen.getByRole('dialog', { name: en['reset-title'] })).toBeTruthy()
|
||||
await act(async () => { settle({ status: 'write-failed', snapshot: original }) })
|
||||
expect(screen.getByRole('alert').textContent).toBe(en['reset-failed'])
|
||||
expect(f.config.getSnapshot()).toBe(original)
|
||||
expect(screen.getByText('1 customized')).toBeTruthy()
|
||||
f.edit.mockImplementationOnce(async () => {
|
||||
const snapshot = { ...original, document: { schemaVersion: 1 as const, profiles: {} } }
|
||||
f.config.set(snapshot)
|
||||
return { status: 'saved', snapshot }
|
||||
})
|
||||
await act(async () => { confirm.click() })
|
||||
expect(screen.queryByRole('dialog', { name: en['reset-title'] })).toBeNull()
|
||||
expect(screen.getByRole('alert').textContent).toBe(en['reset-saved'])
|
||||
expect(screen.getByText('0 customized')).toBeTruthy()
|
||||
expect(restore.hasAttribute('disabled')).toBe(true)
|
||||
expect(document.activeElement).toBe(screen.getByRole('searchbox'))
|
||||
})
|
||||
|
||||
it('requires a new confirmation when another window changes the configuration', async () => {
|
||||
const f = referenceFixture()
|
||||
act(() => { f.config.set({ ...f.config.getSnapshot(), document: { schemaVersion: 1, profiles: {
|
||||
'web:macos': { 'settings.open': null },
|
||||
} } }) })
|
||||
const original = f.config.getSnapshot()
|
||||
fireEvent.click(screen.getByRole('button', { name: en['reset-all'] }))
|
||||
const updated = { ...original, revision: initialShortcutConfig().revision }
|
||||
act(() => { f.config.set(updated) })
|
||||
f.edit.mockResolvedValueOnce({ status: 'stale', snapshot: updated })
|
||||
await act(async () => {
|
||||
within(screen.getByRole('dialog', { name: en['reset-title'] })).getByRole('button', { name: en.reset }).click()
|
||||
})
|
||||
expect(f.edit).toHaveBeenCalledWith({ type: 'reset-all' }, original.revision)
|
||||
expect(f.edit).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('dialog', { name: en['reset-title'] })).toBeNull()
|
||||
expect(screen.getByRole('alert').textContent).toBe(en.stale)
|
||||
fireEvent.click(screen.getByRole('button', { name: en['reset-all'] }))
|
||||
await act(async () => {
|
||||
within(screen.getByRole('dialog', { name: en['reset-title'] })).getByRole('button', { name: en.reset }).click()
|
||||
})
|
||||
expect(f.edit).toHaveBeenLastCalledWith({ type: 'reset-all' }, updated.revision)
|
||||
})
|
||||
|
||||
it('keeps an active recorder focused when another window resets the profile', () => {
|
||||
const f = referenceFixture()
|
||||
act(() => { f.config.set({ ...f.config.getSnapshot(), document: { schemaVersion: 1, profiles: {
|
||||
'web:macos': { 'settings.open': null },
|
||||
} } }) })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit shortcut for Open settings' }))
|
||||
const recorder = screen.getByRole('button', { name: en.record })
|
||||
recorder.focus()
|
||||
act(() => { f.config.set({ ...initialShortcutConfig(), status: 'ready' }) })
|
||||
expect(document.activeElement).toBe(recorder)
|
||||
expect(screen.getByRole('group', { name: 'Open settings' })).toBeTruthy()
|
||||
})
|
||||
@@ -46,7 +46,13 @@ export function apply(ctx: Context): void {
|
||||
ctx.inject(['shortcuts'], (ctx) => {
|
||||
ctx.effect(() => ctx.shortcuts.register({
|
||||
id: 'browser.new' as ShortcutCommandId, label: () => t('guide.title'), aliases: ['browser', 'new browser tab'],
|
||||
defaults: { desktop: { code: 'KeyT', modifiers: ['primary'] } },
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'KeyT', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'KeyT', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'KeyT', modifiers: ['primary'] },
|
||||
'web:macos': { code: 'KeyT', modifiers: ['primary', 'alt'] },
|
||||
'web:windows': { code: 'KeyT', modifiers: ['primary', 'alt'] },
|
||||
},
|
||||
regions: ['page', 'editable', 'terminal'], modals: [],
|
||||
availability: {
|
||||
getSnapshot: () => ctx.sidebarRight.commandTarget() === undefined ? t('shortcut.noSession') : null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Locale-owned Browser tab copy. */
|
||||
export const zh = {
|
||||
'shortcut.hint': '{label} {keys}',
|
||||
'type.label': '浏览器',
|
||||
'guide.title': '浏览器',
|
||||
'guide.description': '浏览网页',
|
||||
@@ -33,6 +34,7 @@ export type SidebarBrowserKey = keyof typeof zh
|
||||
|
||||
/** English dictionary with the same keys. */
|
||||
export const en = {
|
||||
'shortcut.hint': '{label} {keys}',
|
||||
'type.label': 'Browser',
|
||||
'guide.title': 'Browser',
|
||||
'guide.description': 'Browse web pages',
|
||||
|
||||
@@ -81,7 +81,7 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
|
||||
<form className={css.toolbar} onSubmit={submit}>
|
||||
<button type="button" className={css.tool} aria-label={t('back')} title={t('back')} disabled={!frame.canGoBack} onClick={() => { goBack(tab.id) }}><IconChevronLeftOutlineRegular /></button>
|
||||
<button type="button" className={css.tool} aria-label={t('forward')} title={t('forward')} disabled={!frame.canGoForward} onClick={() => { goForward(tab.id) }}><IconChevronRightOutlineRegular /></button>
|
||||
<Tooltip label={[t('reload'), ...(tab.refreshShortcut?.keys ?? [])].join(' ')} side="bottom" delayMs={500}>
|
||||
<Tooltip label={tab.refreshShortcut?.keys.length ? t('shortcut.hint', { label: t('reload'), keys: tab.refreshShortcut.keys.join(' ') }) : t('reload')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.tool} aria-label={t('reload')} aria-keyshortcuts={tab.refreshShortcut?.aria} disabled={target === undefined || mountEpoch === 0} onClick={() => { reload(tab.id) }}><IconRefreshOutlineRegular /></button>
|
||||
</Tooltip>
|
||||
<div className={css.addressBox}>
|
||||
|
||||
@@ -308,7 +308,7 @@ export function TextPreview({
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Tooltip label={[t('reload'), ...(tab.refreshShortcut?.keys ?? [])].join(' ')} side="bottom" delayMs={500}>
|
||||
<Tooltip label={tab.refreshShortcut?.keys.length ? t('shortcut.hint', { label: t('reload'), keys: tab.refreshShortcut.keys.join(' ') }) : t('reload')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tool}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
/** Simplified Chinese dictionary and key-set source of truth. */
|
||||
export const zh = {
|
||||
'shortcut.hint': '{label} {keys}',
|
||||
loading: '正在读取…',
|
||||
loadMore: '加载更多',
|
||||
changed: '文件已更新,当前显示为旧内容',
|
||||
@@ -37,6 +38,7 @@ export type SidebarDocumentPreviewKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked against the Chinese key set. */
|
||||
export const en = {
|
||||
'shortcut.hint': '{label} {keys}',
|
||||
loading: 'Reading…',
|
||||
loadMore: 'Load more',
|
||||
changed: 'The file has changed, showing the previous content.',
|
||||
|
||||
@@ -198,7 +198,7 @@ export function FilesBody({
|
||||
{state.autoRefresh ? <IconPauseOutlineRegular /> : <IconPlayOutlineRegular />}
|
||||
</button>
|
||||
</span>
|
||||
<Tooltip label={[t('reload'), ...(tab.refreshShortcut?.keys ?? [])].join(' ')} side="bottom" delayMs={500}>
|
||||
<Tooltip label={tab.refreshShortcut?.keys.length ? t('shortcut.hint', { label: t('reload'), keys: tab.refreshShortcut.keys.join(' ') }) : t('reload')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tool}
|
||||
|
||||
@@ -46,7 +46,13 @@ export function apply(ctx: ClientContext): void {
|
||||
ctx.inject(['shortcuts'], (ctx) => {
|
||||
ctx.effect(() => ctx.shortcuts.register({
|
||||
id: 'workspace.files' as ShortcutCommandId, label: () => t('guide.title'), aliases: ['workspace files', 'files'],
|
||||
defaults: { desktop: { code: 'KeyP', modifiers: ['primary'] } },
|
||||
defaults: {
|
||||
'desktop:macos': { code: 'KeyP', modifiers: ['primary'] },
|
||||
'desktop:windows': { code: 'KeyP', modifiers: ['primary'] },
|
||||
'desktop:linux': { code: 'KeyP', modifiers: ['primary'] },
|
||||
'web:macos': { code: 'KeyP', modifiers: ['primary', 'alt'] },
|
||||
'web:windows': { code: 'KeyP', modifiers: ['primary', 'alt'] },
|
||||
},
|
||||
regions: ['page', 'editable', 'terminal'], modals: [],
|
||||
availability: {
|
||||
getSnapshot: () => ctx.sidebarRight.commandTarget() === undefined ? t('shortcut.noSession') : null,
|
||||
|
||||
@@ -20,6 +20,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/** Simplified Chinese dictionary and key-set source of truth. */
|
||||
export const zh = {
|
||||
'shortcut.hint': '{label} {keys}',
|
||||
'shortcut.noSession': '请先选择会话',
|
||||
'type.label': '文件',
|
||||
'guide.title': '工作区文件',
|
||||
@@ -44,6 +45,7 @@ export type SidebarFilesKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked against the Chinese key set. */
|
||||
export const en = {
|
||||
'shortcut.hint': '{label} {keys}',
|
||||
'shortcut.noSession': 'Select a session first',
|
||||
'type.label': 'Files',
|
||||
'guide.title': 'Workspace files',
|
||||
|
||||
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user