From efdf8e5b6d1e57230f33135a05dce56db1a453b1 Mon Sep 17 00:00:00 2001 From: Xinyu Yang Date: Mon, 21 Sep 2026 16:04:59 +0800 Subject: [PATCH 01/10] feat(shortcuts): add configurable Web and desktop shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the keyboard registry, device-local preferences, and searchable shortcut editor. Support Web routing and native Desktop single-key and two-key bindings. Co-authored-by: 神河一号 - 烈焰流光 <1115886391@qq.com> --- ...evice-local-shortcut-preferences.i18n.yaml | 6 + ...09-20-device-local-shortcut-preferences.md | 27 ++ ...20-device-local-shortcut-preferences.zh.md | 27 ++ apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 5 +- apps/desktop/README.zh.md | 4 + apps/desktop/package.json | 4 +- apps/desktop/scripts/dev.ts | 3 +- apps/desktop/src/ipc.ts | 10 + apps/desktop/src/keybindings.ts | 29 ++ apps/desktop/src/keyboard.ts | 201 +++++++++ apps/desktop/src/locale.ts | 4 + apps/desktop/src/main.ts | 17 +- apps/desktop/src/preload-app.ts | 30 +- apps/desktop/tests/keybindings.spec.ts | 34 ++ apps/desktop/tests/keyboard.spec.ts | 383 +++++++++++++++++ apps/desktop/tests/preload-app.spec.ts | 65 ++- apps/desktop/tsconfig.host.json | 6 + apps/web/tests/composer-shortcuts.e2e.ts | 56 +++ .../composer-shortcuts/gestures.expected.md | 14 + .../settings-chrome/dialog-en.expected.md | 3 + .../settings-chrome/dialog.expected.md | 3 + .../expected/shortcuts/edit-stale.expected.md | 5 + .../shortcuts/en-US-conflict.expected.md | 1 + .../shortcuts/en-US-empty.expected.md | 7 + .../shortcuts/en-US-inline.expected.md | 5 + .../shortcuts/en-US-saved.expected.md | 1 + .../expected/shortcuts/en-US.expected.md | 53 +++ .../shortcuts/zh-CN-conflict.expected.md | 1 + .../shortcuts/zh-CN-empty.expected.md | 7 + .../shortcuts/zh-CN-inline.expected.md | 5 + .../shortcuts/zh-CN-saved.expected.md | 1 + .../expected/shortcuts/zh-CN.expected.md | 53 +++ apps/web/tests/shortcuts.e2e.ts | 283 +++++++++++++ apps/web/tsconfig.json | 2 + docs/subsystems/web-client.md | 2 + docs/subsystems/web-client.zh.md | 2 + packages/bundle/web-app/cordis.patch.yml | 4 + packages/bundle/web-app/package.json | 4 +- packages/client/README.md | 2 + packages/client/README.zh.md | 2 + packages/client/shortcuts/README.i18n.yaml | 6 + packages/client/shortcuts/README.md | 92 +++++ packages/client/shortcuts/README.zh.md | 92 +++++ packages/client/shortcuts/package.json | 66 +++ packages/client/shortcuts/src/binding.ts | 110 +++++ packages/client/shortcuts/src/client/dom.ts | 140 +++++++ packages/client/shortcuts/src/client/index.ts | 174 ++++++++ .../client/shortcuts/src/client/native.ts | 35 ++ .../client/shortcuts/src/client/registry.ts | 186 +++++++++ .../client/shortcuts/src/client/storage.ts | 42 ++ packages/client/shortcuts/src/client/types.ts | 152 +++++++ packages/client/shortcuts/src/config.ts | 13 + .../client/shortcuts/src/configuration.ts | 241 +++++++++++ packages/client/shortcuts/src/index.ts | 17 + packages/client/shortcuts/src/native.ts | 29 ++ packages/client/shortcuts/src/persistence.ts | 149 +++++++ packages/client/shortcuts/src/protocol.ts | 5 + .../shortcuts/tests/config.host.spec.ts | 17 + .../tests/configuration.client.spec.ts | 186 +++++++++ .../tests/desktop-chords.client.spec.ts | 183 +++++++++ .../client/shortcuts/tests/dom.client.spec.ts | 189 +++++++++ .../tests/fixed-input.client.spec.ts | 150 +++++++ .../shortcuts/tests/native.client.spec.ts | 119 ++++++ .../tests/persistence.client.spec.ts | 130 ++++++ .../shortcuts/tests/registry.client.spec.ts | 180 ++++++++ .../shortcuts/tests/service.client.spec.ts | 139 +++++++ .../shortcuts/tests/storage.client.spec.ts | 53 +++ .../client/shortcuts/tsconfig.client.json | 34 ++ packages/client/shortcuts/tsconfig.host.json | 34 ++ packages/client/shortcuts/tsconfig.json | 11 + packages/client/shortcuts/tsdown.config.ts | 3 + packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/input/editor/keymap.ts | 9 +- .../tests/keymap-routing.client.spec.tsx | 44 +- packages/client/ui-layout/README.i18n.yaml | 4 +- packages/client/ui-layout/README.md | 2 + packages/client/ui-layout/README.zh.md | 2 + packages/client/ui-layout/package.json | 6 +- packages/client/ui-layout/src/client/index.ts | 19 +- .../ui-layout/src/client/shortcut-locales.ts | 4 + .../ui-layout/tests/apply.client.spec.ts | 4 +- packages/client/ui-layout/tsconfig.json | 3 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + .../client/ui-primitives/src/Menu.module.css | 13 + packages/client/ui-primitives/src/Menu.tsx | 26 +- .../client/ui-primitives/src/Modal.module.css | 1 + packages/client/ui-primitives/src/Modal.tsx | 21 +- .../ui-primitives/src/ShortcutKeys.module.css | 16 + .../client/ui-primitives/src/ShortcutKeys.tsx | 12 + .../client/ui-primitives/src/Toast.module.css | 2 +- packages/client/ui-primitives/src/index.ts | 3 + .../ui-primitives/src/keyboard-composition.ts | 36 ++ .../client/ui-primitives/src/useModalLayer.ts | 77 ++++ .../ui-primitives/tests/atoms.client.spec.tsx | 4 +- .../tests/modal-layer.client.spec.tsx | 125 ++++++ .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 + .../client/ui-settings-general/README.zh.md | 2 + .../client/ui-settings-general/package.json | 6 +- .../src/client/SettingsRoot.tsx | 75 ++-- .../ui-settings-general/src/client/index.ts | 47 ++- .../ui-settings-general/src/client/locales.ts | 4 + .../src/client/shell-contract.ts | 10 +- .../src/client/shell-store.ts | 26 ++ .../tests/apply.client.spec.ts | 2 +- .../tests/settings-root.client.spec.tsx | 17 +- .../tests/shell.client.spec.ts | 2 +- .../ui-settings-general/tsconfig.client.json | 3 +- packages/client/ui-shortcuts/README.i18n.yaml | 6 + packages/client/ui-shortcuts/README.md | 84 ++++ packages/client/ui-shortcuts/README.zh.md | 84 ++++ packages/client/ui-shortcuts/package.json | 68 +++ .../client/ui-shortcuts/src/client/Editor.tsx | 176 ++++++++ .../client/ui-shortcuts/src/client/Icons.tsx | 17 + .../src/client/Reference.module.css | 179 ++++++++ .../ui-shortcuts/src/client/Reference.tsx | 169 ++++++++ .../ui-shortcuts/src/client/feedback.ts | 17 + .../client/ui-shortcuts/src/client/fixed.ts | 25 ++ .../client/ui-shortcuts/src/client/index.ts | 58 +++ .../client/ui-shortcuts/src/client/locales.ts | 54 +++ .../client/ui-shortcuts/src/client/store.ts | 20 + .../client/ui-shortcuts/src/css-modules.d.ts | 6 + packages/client/ui-shortcuts/src/index.ts | 4 + .../tests/commands.client.spec.ts | 65 +++ .../ui-shortcuts/tests/editor.client.spec.tsx | 386 ++++++++++++++++++ .../tests/reference.client.spec.tsx | 283 +++++++++++++ packages/client/ui-shortcuts/tsconfig.json | 42 ++ packages/client/ui-shortcuts/tsdown.config.ts | 3 + packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 4 +- packages/client/ui-sidebar/README.zh.md | 4 +- packages/client/ui-sidebar/package.json | 6 +- .../src/client/HeaderLeadingControls.tsx | 11 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 32 +- .../ui-sidebar/src/client/contract/slots.ts | 3 +- .../client/ui-sidebar/src/client/index.ts | 4 +- .../client/ui-sidebar/src/client/locales.ts | 2 + .../ui-sidebar/tests/apply.client.spec.tsx | 4 +- .../tests/panel-list.client.spec.tsx | 2 + .../tests/pointer-scrollbars.client.spec.tsx | 2 +- .../tests/sidebar-root.client.spec.tsx | 37 +- .../tests/sidebar-snapshot.client.spec.tsx | 2 + packages/client/ui-sidebar/tsconfig.json | 3 + packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../ui-theme/src/styles/design-platform.css | 2 + scripts/check-workspace-constraints.spec.ts | 13 + scripts/check-workspace-constraints.ts | 2 + scripts/gen-cordis-catalog.ts | 1 + .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 3 + tsconfig.client.json | 2 + tsconfig.host.json | 10 +- 158 files changed, 6747 insertions(+), 159 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md create mode 100644 .agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md create mode 100644 apps/desktop/src/keybindings.ts create mode 100644 apps/desktop/src/keyboard.ts create mode 100644 apps/desktop/tests/keybindings.spec.ts create mode 100644 apps/desktop/tests/keyboard.spec.ts create mode 100644 apps/web/tests/composer-shortcuts.e2e.ts create mode 100644 apps/web/tests/expected/composer-shortcuts/gestures.expected.md create mode 100644 apps/web/tests/expected/shortcuts/edit-stale.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-conflict.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-empty.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-inline.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US-saved.expected.md create mode 100644 apps/web/tests/expected/shortcuts/en-US.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md create mode 100644 apps/web/tests/expected/shortcuts/zh-CN.expected.md create mode 100644 apps/web/tests/shortcuts.e2e.ts create mode 100644 packages/client/shortcuts/README.i18n.yaml create mode 100644 packages/client/shortcuts/README.md create mode 100644 packages/client/shortcuts/README.zh.md create mode 100644 packages/client/shortcuts/package.json create mode 100644 packages/client/shortcuts/src/binding.ts create mode 100644 packages/client/shortcuts/src/client/dom.ts create mode 100644 packages/client/shortcuts/src/client/index.ts create mode 100644 packages/client/shortcuts/src/client/native.ts create mode 100644 packages/client/shortcuts/src/client/registry.ts create mode 100644 packages/client/shortcuts/src/client/storage.ts create mode 100644 packages/client/shortcuts/src/client/types.ts create mode 100644 packages/client/shortcuts/src/config.ts create mode 100644 packages/client/shortcuts/src/configuration.ts create mode 100644 packages/client/shortcuts/src/index.ts create mode 100644 packages/client/shortcuts/src/native.ts create mode 100644 packages/client/shortcuts/src/persistence.ts create mode 100644 packages/client/shortcuts/src/protocol.ts create mode 100644 packages/client/shortcuts/tests/config.host.spec.ts create mode 100644 packages/client/shortcuts/tests/configuration.client.spec.ts create mode 100644 packages/client/shortcuts/tests/desktop-chords.client.spec.ts create mode 100644 packages/client/shortcuts/tests/dom.client.spec.ts create mode 100644 packages/client/shortcuts/tests/fixed-input.client.spec.ts create mode 100644 packages/client/shortcuts/tests/native.client.spec.ts create mode 100644 packages/client/shortcuts/tests/persistence.client.spec.ts create mode 100644 packages/client/shortcuts/tests/registry.client.spec.ts create mode 100644 packages/client/shortcuts/tests/service.client.spec.ts create mode 100644 packages/client/shortcuts/tests/storage.client.spec.ts create mode 100644 packages/client/shortcuts/tsconfig.client.json create mode 100644 packages/client/shortcuts/tsconfig.host.json create mode 100644 packages/client/shortcuts/tsconfig.json create mode 100644 packages/client/shortcuts/tsdown.config.ts create mode 100644 packages/client/ui-layout/src/client/shortcut-locales.ts create mode 100644 packages/client/ui-primitives/src/ShortcutKeys.module.css create mode 100644 packages/client/ui-primitives/src/ShortcutKeys.tsx create mode 100644 packages/client/ui-primitives/src/keyboard-composition.ts create mode 100644 packages/client/ui-primitives/src/useModalLayer.ts create mode 100644 packages/client/ui-primitives/tests/modal-layer.client.spec.tsx create mode 100644 packages/client/ui-settings-general/src/client/shell-store.ts create mode 100644 packages/client/ui-shortcuts/README.i18n.yaml create mode 100644 packages/client/ui-shortcuts/README.md create mode 100644 packages/client/ui-shortcuts/README.zh.md create mode 100644 packages/client/ui-shortcuts/package.json create mode 100644 packages/client/ui-shortcuts/src/client/Editor.tsx create mode 100644 packages/client/ui-shortcuts/src/client/Icons.tsx create mode 100644 packages/client/ui-shortcuts/src/client/Reference.module.css create mode 100644 packages/client/ui-shortcuts/src/client/Reference.tsx create mode 100644 packages/client/ui-shortcuts/src/client/feedback.ts create mode 100644 packages/client/ui-shortcuts/src/client/fixed.ts create mode 100644 packages/client/ui-shortcuts/src/client/index.ts create mode 100644 packages/client/ui-shortcuts/src/client/locales.ts create mode 100644 packages/client/ui-shortcuts/src/client/store.ts create mode 100644 packages/client/ui-shortcuts/src/css-modules.d.ts create mode 100644 packages/client/ui-shortcuts/src/index.ts create mode 100644 packages/client/ui-shortcuts/tests/commands.client.spec.ts create mode 100644 packages/client/ui-shortcuts/tests/editor.client.spec.tsx create mode 100644 packages/client/ui-shortcuts/tests/reference.client.spec.tsx create mode 100644 packages/client/ui-shortcuts/tsconfig.json create mode 100644 packages/client/ui-shortcuts/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml new file mode 100644 index 0000000000..d53d4f901d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md new file mode 100644 index 0000000000..4d1dd29208 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.md @@ -0,0 +1,27 @@ +# Agent Note: Keep shortcut preferences device-local and commit before activation + +Status: implemented + +English | [中文](2026-09-20-device-local-shortcut-preferences.zh.md) + +## Problem + +Keyboard bindings depend on the receiving device's operating system and browser restrictions. A Host can serve several devices, while a local save can fail after a user records a new combination. Activating an unpersisted binding makes the interface disagree with the next application launch. Rewriting unreadable or future-version preferences can destroy choices an older client cannot interpret. + +## Decision + +[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. + +## Alternatives considered + +**Host settings.** A server's operating system and settings scope do not identify the visitor's device or browser restrictions. Host storage would also couple remote visitors' preferences. + +**Optimistic activation.** Updating bindings before persistence makes a failed save appear successful and leaves restart behavior inconsistent with the visible controls. + +**Cross-tab atomic merging.** localStorage has no compare-and-swap transaction. Web rereads before each operation and rejects known stale drafts, but accepts last-writer-wins for simultaneous writes. Electron main serializes edits and rejects stale revisions. + +## 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. diff --git a/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md new file mode 100644 index 0000000000..9bf2401a02 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-device-local-shortcut-preferences.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 快捷键偏好按设备保存,提交后才激活 + +Status: implemented + +[English](2026-09-20-device-local-shortcut-preferences.md) | 中文 + +## 问题 + +快捷键取决于接收输入设备的操作系统和浏览器限制。一个 Host 可服务多个设备,而用户录入新组合后,本地保存仍可能失败。激活未持久化的绑定会使界面与下一次启动不一致。重写不可读或未来版本的偏好可能破坏旧客户端无法解释的选择。 + +## 决策 + +[快捷键服务](../../../../packages/client/shortcuts/README.zh.md) 只保存按运行端/平台区分的版本化覆盖。Web 使用同 origin 的浏览器存储;Electron main 管理 `userData/keybindings.json`,与 Harness home 独立。两端适配器共享校验和事务逻辑。持久化成功后发布一个已接受配置,命令匹配、键帽和原生拦截都由此派生。 + +未加载命令保留休眠覆盖。显式覆盖优先于新增默认值,相互冲突的显式覆盖全部禁用。不可读配置阻止普通编辑,并保留上次接受的值。显式恢复先保留原始内容再替换文档。 + +## 考虑过的替代方案 + +**Host 设置。** 服务端操作系统和设置范围不能标识访问者设备或浏览器限制,Host 存储还会耦合远程访问者的偏好。 + +**乐观激活。** 在持久化之前更新绑定会让保存失败看起来像成功,并使重启行为与可见控件不一致。 + +**跨标签页原子合并。** localStorage 没有比较并交换事务。Web 每次操作前重新读取并拒绝已知过期草稿,但同时写入采用最后成功写入生效。Electron main 串行处理编辑并拒绝旧 revision。 + +## 影响 + +不同设备以及 Web/Desktop 不自动同步绑定。只有保留原始内容后,恢复流程才能替换不可读文档中的所有平台配置。浏览器并发不承诺无损合并。Desktop 原子替换使用共享写入器的 Windows 重试行为,不承诺 fsync 持久性。确定性的事务、浏览器存储和桌面文件测试覆盖失败保留、冲突、过期草稿和恢复副本;平台键盘验收与持久化正确性分别验证。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index d74baaa4b8..d1cd28ec72 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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: 711e65fb5feaea601800218801d138a0df9b3f97 -README.zh.md: 183d195a9af485c9ffe5a3e5bf65e3a459582b93 +README.md: eacf46d7786cea811ba939271221c8715207595a +README.zh.md: 9281169290fab4161c58f7d1230e6f34eae22d94 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 711e65fb5f..eacf46d778 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -22,6 +22,10 @@ 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. + +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 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. ### Bundled workspace dependencies @@ -86,7 +90,6 @@ Native dialog details include at most 1,200 UTF-16 code units and eight diagnost Recovery waits for Host shutdown before changing plugin activation. The native recovery action calls the shared app-boot recovery function under the profile transaction lock. It disables third-party bundles and renames the profile’s `cordis.patch.yml` to `cordis.patch.yml.bak-` (with an ordinal on collisions) without parsing it; the next startup creates an empty patch. Installed packages and earlier backups remain. The home-level patch is unchanged. The Electron console records the backup path (or its absence) and the unchanged home-level patch. Invalid profile data, rename failures, or write failures are reported as recovery-operation errors; completed changes remain, and Desktop does not restart as though recovery succeeded. Desktop has no profile-reset action or emergency HTML document. - ## Develop The development application menu offers Reload Page (Cmd+R on macOS, Ctrl+R elsewhere) and Restart App and Host. Restart waits for Host shutdown before relaunching Electron and starting a new Host; neither action rebuilds source files. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 183d195a9a..9281169290 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -22,6 +22,10 @@ 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)。 + +产品“文件”菜单通过 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 的显示效果。 ### 内置工作区依赖 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 294606fd72..24becc3720 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -69,6 +69,8 @@ "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", "vite": "^6.0.0", - "@deepseek-ai/dsh-client-ui-theme": "workspace:^" + "@deepseek-ai/dsh-client-ui-theme": "workspace:^", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^", + "@deepseek-ai/dsh-atomic-write": "workspace:^" } } diff --git a/apps/desktop/scripts/dev.ts b/apps/desktop/scripts/dev.ts index 74840eae03..36d57325dc 100644 --- a/apps/desktop/scripts/dev.ts +++ b/apps/desktop/scripts/dev.ts @@ -63,7 +63,7 @@ async function launchElectron(): Promise { const rendererPort = debugPort('DSH_DESKTOP_RENDERER_DEBUG_PORT', 9222) const hostPort = debugPort('DSH_DESKTOP_HOST_INSPECT_PORT', 9230) const home = resolve(process.env.DSH_HOME ?? join(DEVELOPMENT_ROOT, 'home')) - const userData = join(DEVELOPMENT_ROOT, 'electron-user-data') + const userData = resolve(process.env.DSH_DESKTOP_USER_DATA_DIR ?? join(DEVELOPMENT_ROOT, 'electron-user-data')) const environment: NodeJS.ProcessEnv = { ...process.env, DSH_HOME: home, @@ -72,6 +72,7 @@ async function launchElectron(): Promise { ELECTRON_ENABLE_LOGGING: process.env.ELECTRON_ENABLE_LOGGING ?? '1', } console.log(`desktop development: DSH_HOME=${home}`) + console.log(`desktop development: userData=${userData}`) console.log(`desktop development: inspectors main=${String(mainPort)}, renderer=${String(rendererPort)}, host=${String(hostPort)}`) if (process.platform === 'darwin') { const executable = prepareDevelopmentApp({ electron, appRoot: APP_ROOT, directory: DEVELOPMENT_ROOT, home, userData, diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index cf03b7e6c2..8fe0d1d34c 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,10 +1,18 @@ /** Typed preload operations exposed only by the Electron shell. */ +import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol' import type { IpcMainInvokeEvent } from 'electron' import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' /** IPC channel names kept private to the desktop application bundle. */ 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', boot: 'dsh-desktop:boot', enterWorkspace: 'dsh-desktop:enter-workspace', bootFailed: 'dsh-desktop:boot-failed', @@ -62,6 +70,8 @@ export interface DesktopUpdatePresentation { export interface DshDesktopProductApi { readonly protocolVersion: 1 readonly browser: DesktopBrowserBridge + readonly keyboard: DesktopKeyboardApi + readonly shortcuts: DesktopShortcutsApi readonly updates: { status(): Promise open(): Promise diff --git a/apps/desktop/src/keybindings.ts b/apps/desktop/src/keybindings.ts new file mode 100644 index 0000000000..c26e7c6c4b --- /dev/null +++ b/apps/desktop/src/keybindings.ts @@ -0,0 +1,29 @@ +/** Device-local shortcut preferences in Electron userData; writes share one serialized coordinator. */ +import { readFile, writeFile } 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. + * @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. + * @returns the single-writer transaction coordinator. + */ +export function desktopKeybindings(userData: string, platform: ShortcutPlatform, + publish: (snapshot: ShortcutConfigSnapshot) => void): ShortcutPersistence { + const path = join(userData, 'keybindings.json') + return new ShortcutPersistence({ + read: async () => { + try { return await readFile(path, 'utf8') } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null + throw error + } + }, + write: raw => writeFileAtomic(path, raw, { mode: 0o600, dirMode: 0o700 }), + backup: raw => writeFile(`${path}.backup.${randomUUID()}`, raw, { mode: 0o600, flag: 'wx' }), + }, 'desktop', platform, false, publish) +} diff --git a/apps/desktop/src/keyboard.ts b/apps/desktop/src/keyboard.ts new file mode 100644 index 0000000000..8e513829b1 --- /dev/null +++ b/apps/desktop/src/keyboard.ts @@ -0,0 +1,201 @@ +/** Product-window preference IPC and native menu interception during physical-key dispatch/recording. */ +import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type Input, type MenuItemConstructorOptions } from 'electron' +import { bindingKey, effectiveShortcuts, presentBinding, parseShortcutDefinitions, parseShortcutEdit } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import type { NormalizedBinding, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform, ShortcutRevision } from '@deepseek-ai/dsh-client-shortcuts/protocol' +import { desktopKeybindings } from './keybindings.ts' +import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts' + +/** + * Install application-owned configuration handlers and attach each product window's input lifecycle. + * @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. + */ +export function installDesktopShortcuts( + getWindow: () => BrowserWindow | undefined, userData: string, platform: ShortcutPlatform, updateMenu: () => void, +): { + fileMenu(labels: { fileMenu: string; closePage: string }): MenuItemConstructorOptions + attach(window: BrowserWindow): void + dispose(): void +} { + let definitions: readonly ShortcutDefinition[] = [] + let recording = false + let revision: ShortcutRevision | undefined + let closeBinding: NormalizedBinding | null = null + 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() + const sendMenuClose = (): void => { + const window = getWindow() + if (window === undefined || window.isDestroyed() || !window.isFocused() || !window.isEnabled() + || revision === undefined || recording) return + window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: 'menu', commandId: 'page.close', revision }) + } + let keys = new Set() + const publish = (snapshot: ShortcutConfigSnapshot): void => { + 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 + ? [bindingKey(row.binding)] : [])) + embeddedKeys = new Set(rows.flatMap(row => row.binding !== null && row.issue === null + && row.conflicts.length === 0 && embeddedCommands.has(row.id) + ? [bindingKey(row.binding)] : [])) + const close = rows.find(row => row.id === 'page.close') + closeBinding = close?.issue === null && close.conflicts.length === 0 ? close.binding : null + updateMenu() + const window = getWindow() + if (window !== undefined && !window.isDestroyed() && window.webContents.mainFrame.url.startsWith('dsh-app://app/')) { + window.webContents.send(DESKTOP_IPC.shortcutsChanged, snapshot) + } + } + const persistence = desktopKeybindings(userData, platform, publish) + const assertSender = (event: IpcMainInvokeEvent): BrowserWindow => { + const window = getWindow() + if (window === undefined || window.isDestroyed() || event.sender !== window.webContents + || event.senderFrame !== window.webContents.mainFrame) throw new Error('desktop shortcuts: rejected sender') + assertDesktopSender(event, ['app']) + return window + } + ipcMain.handle(DESKTOP_IPC.shortcutsGet, async (event, input: unknown) => { + assertSender(event) + definitions = parseShortcutDefinitions(input) + persistence.setDefinitions(definitions) + return persistence.reload() + }) + 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') + return persistence.edit(parseShortcutEdit(input), revision as ShortcutRevision) + }) + ipcMain.handle(DESKTOP_IPC.shortcutsRecording, (event, active: unknown) => { + const window = assertSender(event) + if (typeof active !== 'boolean') throw new Error('desktop shortcuts: invalid recording state') + recording = active + window.webContents.setIgnoreMenuShortcuts(active) + }) + ipcMain.handle(DESKTOP_IPC.shortcutsCloseWindow, (event, expected: unknown) => { + const window = assertSender(event) + if (expected !== revision || revision === undefined || recording || !window.isFocused() || !window.isEnabled()) return + window.close() + }) + return { + fileMenu: (labels) => { + const accelerator = presentBinding(closeBinding, platform).aria?.replace('Meta+', 'Command+') + return { label: labels.fileMenu, submenu: [{ + id: 'dsh-page-close', label: labels.closePage, enabled: revision !== undefined, + ...accelerator === undefined ? {} : { accelerator }, + click: sendMenuClose, + }] } + }, + attach(window) { + const contents = window.webContents + let deadKey = false + const held = new Set() + const consumed = new Map() + let inputFrame: typeof contents.focusedFrame = null + 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 + persistence.setDefinitions(null) + updateMenu() + if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false) + } + const navigation = (event: Electron.Event): void => { + if (event.isMainFrame && !event.isSameDocument) clear() + } + const blur = (): void => { + deadKey = false; held.clear(); consumed.clear(); inputFrame = null; contents.setIgnoreMenuShortcuts(false) + } + const beforeInput = (event: Electron.Event, input: Input): void => { + if (!window.isFocused() || !window.isEnabled() || revision === undefined) { + contents.setIgnoreMenuShortcuts(false) + held.clear() + consumed.clear() + return + } + const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(modifier => input[modifier]) + const key = bindingKey({ code: input.code, modifiers }) + const match = keys.has(key) + const menuMatch = closeBinding !== null && closeBinding.secondCode === undefined && modifiers.join('+') === closeBinding.modifiers.join('+') + && input.key.toUpperCase() === (closeBinding.code.startsWith('Key') ? closeBinding.code.slice(3) : input.code === closeBinding.code ? input.key.toUpperCase() : '') + contents.setIgnoreMenuShortcuts(recording || match || menuMatch) + const frame = contents.focusedFrame + const composing = input.isComposing || input.key === 'Dead' || deadKey || input.modifiers.includes('altgr') + if (input.type === 'keyDown') deadKey = input.key === 'Dead' + if (recording || composing || frame === null) { held.clear(); consumed.clear(); return } + let binding: NormalizedBinding = { code: input.code, modifiers } + let priority = false + if (scopedDesktop) { + if (frame !== inputFrame || inputRevision !== revision) { held.clear(); inputFrame = frame; inputRevision = revision } + const modifierKey = /^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(input.code) + if (input.type === 'keyUp') { + // A chord's first key reached the renderer, so its release must reach the same input handlers. + if (consumed.get(input.code) === 'press') event.preventDefault() + consumed.delete(input.code) + held.delete(input.code) + if (modifierKey) held.clear() + return + } + if (!input.isAutoRepeat) consumed.delete(input.code) + if (modifierKey) { held.clear(); return } + if (input.isAutoRepeat && consumed.has(input.code) && !match) { event.preventDefault(); return } + if (input.isAutoRepeat && !held.has(input.code) && !match) return + held.add(input.code) + const codes: [string, ...string[]] = [input.code, ...[...held].filter(value => value !== input.code)] + codes.sort() + const pair = { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), modifiers } + priority = keys.has(key) + if (codes.length === 2 && keys.has(bindingKey(pair))) { binding = pair; priority = true } + } + const main = frame === contents.mainFrame + if (!priority && (main || !embeddedKeys.has(key))) return + event.preventDefault() + if (input.type !== 'keyDown') return + if (scopedDesktop) { + if (!input.isAutoRepeat) { + if (binding.secondCode !== undefined) { + consumed.set(binding.code, 'repeat') + consumed.set(binding.secondCode, 'repeat') + } + consumed.set(input.code, 'press') + } + // Electron can omit both keyups after interception; completed presses cannot seed another chord. + held.clear() + } + let embedding = frame + while (!main && embedding.parent !== null && embedding.parent !== contents.mainFrame) embedding = embedding.parent + contents.send(DESKTOP_IPC.shortcutsInput, { kind: main ? 'keyboard' : 'iframe', revision, frameName: main ? '' : embedding.name, + code: binding.code, ...(binding.secondCode === undefined ? {} : { secondCode: binding.secondCode }), + repeat: input.isAutoRepeat, control: input.control, alt: input.alt, shift: input.shift, meta: input.meta }) + } + const dispose = (): void => { + contents.off('did-start-navigation', navigation) + contents.off('before-input-event', beforeInput) + window.off('blur', blur) + window.off('closed', closed) + disposers.delete(dispose) + if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false) + } + const closed = (): void => { clear(); dispose() } + contents.on('did-start-navigation', navigation) + contents.on('before-input-event', beforeInput) + window.on('closed', closed) + window.on('blur', blur) + disposers.add(dispose) + }, + dispose() { + for (const dispose of disposers) dispose() + persistence.dispose() + for (const channel of [DESKTOP_IPC.shortcutsGet, DESKTOP_IPC.shortcutsReload, + DESKTOP_IPC.shortcutsEdit, DESKTOP_IPC.shortcutsRecording, DESKTOP_IPC.shortcutsCloseWindow]) { + ipcMain.removeHandler(channel) + } + }, + } +} diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index d5aa25917c..1e5b2b98ac 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -2,6 +2,8 @@ export const en = { application: 'Application', + fileMenu: 'File', + closePage: 'Close Page or Window', aboutMenu: 'About DeepSeek Harness', hideApplication: 'Hide DeepSeek Harness', hideOtherApplications: 'Hide Others', @@ -129,6 +131,8 @@ export type DesktopMessages = { readonly [Key in keyof typeof en]: string } export const zh = { application: '应用', + fileMenu: '文件', + closePage: '关闭页面或窗口', aboutMenu: '关于 DeepSeek Harness', hideApplication: '隐藏 DeepSeek Harness', hideOtherApplications: '隐藏其他', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 46ce1f45c1..fae9e657fd 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -47,6 +47,7 @@ import { DesktopPolicyTestAuth } from './policy-test-auth.ts' import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts' import { readDesktopRuntime } from './runtime-tree.ts' import { DesktopBrowserGuests } from './browser-guests.ts' +import { installDesktopShortcuts } from './keyboard.ts' let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} @@ -511,6 +512,9 @@ async function main(): Promise { installDesktopDirectoryPicker(() => mainWindow) installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents) + const shortcuts = installDesktopShortcuts(() => mainWindow, app.getPath('userData'), + process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() }) + app.on('will-quit', () => { shortcuts.dispose() }) ipcMain.handle(DESKTOP_IPC.boot, async (event) => { assertDesktopSender(event, ['app']) @@ -592,7 +596,7 @@ async function main(): Promise { locale = current platformView.notifyLocaleChanged() windowsLanguage = locale.id - installMenu() + refreshApplicationMenu() }) ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => { assertProductSender(event) @@ -744,7 +748,7 @@ async function main(): Promise { // its standard menus and application hide commands declared explicitly. // Keep app.name stable: Electron derives its default userData directory from it. const darwin = process.platform === 'darwin' - const platformMenus: MenuItemConstructorOptions[] = darwin + const platformMenus = (): MenuItemConstructorOptions[] => darwin ? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }] : [{ role: 'editMenu' }] const hideCommands: MenuItemConstructorOptions[] = darwin @@ -774,13 +778,13 @@ async function main(): Promise { { role: 'toggleDevTools', visible: false }, { role: 'toggleDevTools', visible: false, accelerator: 'F12' }, ] - const installMenu = (): void => { + const refreshApplicationMenu = (): void => { Menu.setApplicationMenu(Menu.buildFromTemplate(process.platform === 'win32' ? devToolsItems : [{ label: darwin ? app.name : currentDesktopLocale().messages.application, submenu: [...applicationItems(), ...devToolsItems], - }, ...platformMenus])) + }, ...platformMenus()])) } - installMenu() + refreshApplicationMenu() if (process.platform === 'win32') { ipcMain.handle(DESKTOP_IPC.windowsMenu, (event, name: unknown, x: unknown, y: unknown) => { @@ -835,6 +839,7 @@ async function main(): Promise { const window = createWindow(appPreload, false, true) mainWindow = window browserGuests.bind(window) + shortcuts.attach(window) window.on('focus', automaticCheck) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => { @@ -925,7 +930,7 @@ async function main(): Promise { if (isQuitting() || backend.state.phase !== 'ready') return locale = resolveDesktopStartupLocale(state.localePreference, systemLanguages) windowsLanguage = locale.id - installMenu() + refreshApplicationMenu() if (!enteredWorkspace && needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey })) { await showWelcome() } else { diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 13bbc20690..01961e08b6 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,5 +1,6 @@ /** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ +import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol' import { contextBridge, ipcRenderer, webUtils } from 'electron' import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' import { PLATFORM_IPC } from './platform-ipc.ts' @@ -13,6 +14,33 @@ function createProductApi(): DshDesktopProductApi { return { protocolVersion: 1, browser: createDesktopBrowserBridge(), + keyboard: { + closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise, + subscribe: (listener) => { + const handle = (_event: Electron.IpcRendererEvent, input: DesktopShortcutInput): void => { + if (input.kind === 'iframe') { + const element = document.activeElement + if (!(element instanceof HTMLIFrameElement) || !element.isConnected + || !element.matches('iframe[data-sidebar-browser-frame], iframe[data-html-preview]')) return + if (input.frameName === '' || element.name !== input.frameName) return + } + listener(input) + } + ipcRenderer.on(DESKTOP_IPC.shortcutsInput, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.shortcutsInput, handle) } + }, + }, + shortcuts: { + get: definitions => ipcRenderer.invoke(DESKTOP_IPC.shortcutsGet, definitions) as Promise, + reload: () => ipcRenderer.invoke(DESKTOP_IPC.shortcutsReload) as Promise, + edit: (edit, revision) => ipcRenderer.invoke(DESKTOP_IPC.shortcutsEdit, edit, revision) as Promise, + recording: active => ipcRenderer.invoke(DESKTOP_IPC.shortcutsRecording, active) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, snapshot: ShortcutConfigSnapshot): void => { listener(snapshot) } + ipcRenderer.on(DESKTOP_IPC.shortcutsChanged, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.shortcutsChanged, handle) } + }, + }, updates: { status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise, open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, @@ -60,7 +88,7 @@ markDocumentPlatform() syncWindowFullscreen() syncNativeTheme() // Main-process IPC also verifies the owning window and top frame. -contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? createProductApi() : { protocolVersion: 1 }) +contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' && process.isMainFrame ? createProductApi() : { protocolVersion: 1 }) if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { contextBridge.exposeInMainWorld('__DSH_LOCALE__', { diff --git a/apps/desktop/tests/keybindings.spec.ts b/apps/desktop/tests/keybindings.spec.ts new file mode 100644 index 0000000000..93b3f7276b --- /dev/null +++ b/apps/desktop/tests/keybindings.spec.ts @@ -0,0 +1,34 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished } from 'vitest' +import { desktopKeybindings } from '../src/keybindings.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot } from '@deepseek-ai/dsh-client-shortcuts/protocol' + +it('persists into the supplied userData, reloads null bindings, and retains original future-version bytes', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-keybindings-')) + onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) + let state!: ShortcutConfigSnapshot + 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 } } }] + first.setDefinitions(definitions) + await first.reload() + 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() + const original = '{"schemaVersion":7,"custom":"retain exactly"}\n' + await writeFile(path, original) + expect((await second.reload()).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: {} }) +}) diff --git a/apps/desktop/tests/keyboard.spec.ts b/apps/desktop/tests/keyboard.spec.ts new file mode 100644 index 0000000000..58406e5c44 --- /dev/null +++ b/apps/desktop/tests/keyboard.spec.ts @@ -0,0 +1,383 @@ +// @vitest-environment jsdom +import { EventEmitter } from 'node:events' +import { mkdtemp, rm } from 'node:fs/promises' +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, + 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' +import { installNativeKeyboard } from '../../../packages/client/shortcuts/src/client/native.ts' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const ipc = vi.hoisted(() => ({ handle: vi.fn(), removeHandler: vi.fn() })) +vi.mock('electron', () => ({ ipcMain: ipc })) +const { installDesktopShortcuts } = await import('../src/keyboard.ts') +afterEach(() => { vi.clearAllMocks() }) + +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() }) + 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()) + keyboard.attach(current) + onTestFinished(() => { keyboard.dispose() }) + const handlers = new Map unknown>( + ipc.handle.mock.calls.map(([channel, handler]) => [channel, handler])) + const event = { sender: contents, senderFrame: frame } as unknown as IpcMainInvokeEvent + const call = async (channel: string, ...args: unknown[]): Promise => await handlers.get(channel)!(event, ...args) as T + const definitions: readonly ShortcutDefinition[] = [ + { id: 'sidebar.left.toggle' as ShortcutCommandId, defaults: { desktop: { code: 'KeyB', modifiers: ['primary'] } } }, + ] + return { keyboard, window, contents, frame, event, handlers, call, definitions, detach: () => { current = undefined } } +} + +it('mirrors only successful bindings, suppresses recording menus, and invalidates pre-navigation drafts', async () => { + const f = await fixture() + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const press = (code: string) => f.contents.emit('before-input-event', { preventDefault: vi.fn() }, + { type: 'keyDown', modifiers: [], code, key: code.replace('Key', ''), meta: true, control: false, alt: false, shift: false }) + press('KeyB'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + press('KeyJ'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyJ', modifiers: ['primary'] } }, initial.revision) + expect(saved.status).toBe('saved') + press('KeyB'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) + press('KeyJ'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + expect((await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, initial.revision)).status).toBe('stale') + await f.call(DESKTOP_IPC.shortcutsRecording, true) + press('KeyW'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + f.window.isFocused.mockReturnValue(false) + press('KeyW'); expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) + f.contents.emit('did-start-navigation', { isMainFrame: true, isSameDocument: false }, 'dsh-app://app/', false, true) + expect((await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('not-ready') + await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + expect((await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('stale') +}) + +it('rejects other windows, subframes, remote/shell pages, and malformed edits', async () => { + const f = await fixture() + const get = f.handlers.get(DESKTOP_IPC.shortcutsGet)! + for (const event of [{ ...f.event, sender: {} }, { ...f.event, senderFrame: null }, { ...f.event, senderFrame: {} }]) { + await expect(get(event as IpcMainInvokeEvent, f.definitions)).rejects.toThrow('rejected sender') + } + for (const url of ['dsh-app://shell/', 'https://example.com/', 'http://localhost/']) { + f.frame.url = url + await expect(get(f.event, f.definitions)).rejects.toThrow('unowned renderer') + } + f.frame.url = 'dsh-app://app/' + const ready = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + await expect(f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all', path: '/tmp' }, ready.revision)).rejects.toThrow('Invalid') + await expect(f.call(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, 1)).rejects.toThrow('revision') + await expect(f.call(DESKTOP_IPC.shortcutsRecording, 'true')).rejects.toThrow('recording') + f.window.isDestroyed.mockReturnValue(true) + await expect(get(f.event, f.definitions)).rejects.toThrow('rejected sender') + f.detach(); await expect(get(f.event, f.definitions)).rejects.toThrow('rejected sender') +}) + +it('routes embedded input once, follows rebindings, and guards native window closure by revision and focus', async () => { + const f = await fixture() + const closeItem = () => (f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[])[0]! + const initial = await f.call(DESKTOP_IPC.shortcutsGet, [ + { id: 'page.close', defaults: { desktop: { code: 'KeyW', modifiers: ['primary'] } } }, + { id: 'page.refresh', defaults: { desktop: { code: 'KeyR', modifiers: ['primary'] } } }, + ]) + expect(closeItem().accelerator).toBe('Command+W') + const preventDefault = vi.fn() + const input = { modifiers: [], type: 'keyDown', code: 'KeyW', key: 'w', meta: true, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false } + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: 'keyboard', code: 'KeyW' })) + preventDefault.mockClear(); f.contents.send.mockClear() + Object.assign(f.contents, { focusedFrame: { name: 'embedded', parent: f.frame } }) + f.contents.emit('before-input-event', { preventDefault }, input) + expect(preventDefault).toHaveBeenCalledTimes(1) + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'iframe', revision: initial.revision, frameName: 'embedded', code: 'KeyW' })) + f.contents.emit('before-input-event', { preventDefault }, { ...input, type: 'keyUp' }) + expect(f.contents.send).toHaveBeenCalledTimes(1) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'page.close', binding: { code: 'KeyJ', modifiers: ['primary'] } }, initial.revision) + expect(closeItem().accelerator).toBe('Command+J') + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, input) + expect(f.contents.send).not.toHaveBeenCalled() + f.contents.emit('before-input-event', { preventDefault }, { ...input, code: 'KeyZ', key: 'j' }) + expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true) + expect(f.contents.send).not.toHaveBeenCalled() + await f.call(DESKTOP_IPC.shortcutsCloseWindow, initial.revision) + expect(f.window.close).not.toHaveBeenCalled() + f.window.isEnabled.mockReturnValue(false) + await f.call(DESKTOP_IPC.shortcutsCloseWindow, saved.snapshot.revision) + expect(f.window.close).not.toHaveBeenCalled() + f.window.isEnabled.mockReturnValue(true) + await f.call(DESKTOP_IPC.shortcutsCloseWindow, saved.snapshot.revision) + expect(f.window.close).toHaveBeenCalledTimes(1) + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'page.close', binding: null }, saved.snapshot.revision) + expect(closeItem().accelerator).toBeUndefined() + f.keyboard.dispose() + expect(f.contents.listenerCount('before-input-event')).toBe(0) +}) + +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(DESKTOP_IPC.shortcutsGet, + [{ id: 'session.new', defaults: { desktop: { code: 'KeyN', modifiers: ['primary'] } } }]) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'session.new', binding: { code: 'KeyC', modifiers: ['primary'] } }, initial.revision) + expect(saved.status).toBe('saved') + const input = { modifiers: [], type: 'keyDown', code: 'KeyC', key: 'c', meta: platform === 'macos', + control: platform === 'windows', alt: false, shift: false, isAutoRepeat: false, isComposing: false } + const preventDefault = vi.fn() + const press = (changes = {}) => f.contents.emit('before-input-event', { preventDefault }, { ...input, ...changes }) + f.contents.send.mockClear() + press() + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'keyboard', code: 'KeyC' })) + press({ type: 'keyUp' }) + expect(preventDefault).toHaveBeenCalledTimes(2) + expect(f.contents.send).toHaveBeenCalledOnce() + Object.assign(f.contents, { focusedFrame: { name: 'embedded', parent: f.frame } }) + press() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'iframe', frameName: 'embedded' })) + await f.call(DESKTOP_IPC.shortcutsRecording, true) + preventDefault.mockClear(); f.contents.send.mockClear() + press() + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + await f.call(DESKTOP_IPC.shortcutsRecording, false) + press({ isComposing: true }) + press({ modifiers: ['altgr'] }) + press({ key: 'Dead' }) + press() + expect(preventDefault).not.toHaveBeenCalled() + await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', id: 'session.new', binding: null }, saved.snapshot.revision) + f.contents.send.mockClear() + press() + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + expect(f.contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false) +}) + +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(DESKTOP_IPC.shortcutsGet, + [{ id: 'page.close', defaults: { desktop: { code: 'KeyW', modifiers: ['primary'] } } }]) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'page.close', binding: { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } }, initial.revision) + expect(saved.status).toBe('saved') + const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[] + expect(menu[0]?.accelerator).toBeUndefined() + const preventDefault = vi.fn() + const press = (code: string, type = 'keyDown', isAutoRepeat = false) => { + f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3), type, modifiers: [], + control: false, alt: false, shift: false, meta: false, isAutoRepeat, isComposing: false }) + } + for (const embedded of [false, true]) { + Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame }) + f.contents.send.mockClear(); preventDefault.mockClear() + press('KeyA'); press('KeyA', 'keyUp'); press('KeyB'); press('KeyB', 'keyUp') + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() + press('KeyB') + expect(preventDefault).not.toHaveBeenCalled() + press('KeyA') + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: embedded ? 'iframe' : 'keyboard', code: 'KeyA', secondCode: 'KeyB', repeat: false })) + press('KeyA', 'keyUp'); press('KeyB', 'keyUp') + } + for (const reset of [() => { f.window.emit('blur') }, + () => { press('MetaLeft', 'keyUp') }, + () => { Object.assign(f.contents, { focusedFrame: f.frame }) }]) { + press('KeyA'); reset(); f.contents.send.mockClear(); press('KeyB') + expect(f.contents.send).not.toHaveBeenCalled() + press('KeyA', 'keyUp'); press('KeyB', 'keyUp') + } + f.contents.send.mockClear() + press('KeyC'); press('KeyA'); press('KeyB') + expect(f.contents.send).not.toHaveBeenCalled() +}) + +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(DESKTOP_IPC.shortcutsGet, f.definitions) + for (const code of ['KeyA', 'F1', 'ArrowLeft', 'Escape', 'Tab', 'Enter']) { + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code, modifiers: [] } }, snapshot.revision) + expect(saved.status).toBe('saved'); snapshot = saved.snapshot + const preventDefault = vi.fn() + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code, key: code, modifiers: [], + control: false, alt: false, shift: false, meta: false, isAutoRepeat: false, isComposing: false }) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ code, revision: snapshot.revision })) + } +}) + +it.each(['macos', 'windows'] as const)('keeps %s native interception identical for inherited, saved and reset bindings', async (platform) => { + const f = await fixture(platform) + let snapshot = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + for (const operation of [null, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyB', modifiers: ['primary'] } }, + { type: 'reset', id: 'sidebar.left.toggle' }]) { + if (operation !== null) { + const result = await f.call(DESKTOP_IPC.shortcutsEdit, operation, snapshot.revision) + expect(result.status).toBe('saved'); snapshot = result.snapshot + } + for (const embedded of [false, true]) { + Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame }) + const preventDefault = vi.fn() + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: [], + control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, + expect.objectContaining({ kind: embedded ? 'iframe' : 'keyboard', code: 'KeyB', revision: snapshot.revision })) + } + } +}) + +it('consumes a held single-key repeat after focus reset without treating it as a fresh press', async () => { + const f = await fixture('macos') + await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const preventDefault = vi.fn() + const input = { type: 'keyDown', code: 'KeyB', key: 'b', modifiers: [], + control: false, meta: true, alt: false, shift: false, isAutoRepeat: false, isComposing: false } + f.contents.emit('before-input-event', { preventDefault }, input) + f.window.emit('blur') + f.contents.send.mockClear(); preventDefault.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { ...input, isAutoRepeat: true }) + expect(preventDefault).toHaveBeenCalledOnce() + expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ code: 'KeyB', repeat: true })) +}) + +it('delivers a fresh unbound key release after macOS omitted the previous Command character release', async () => { + const f = await fixture('macos') + await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const preventDefault = vi.fn() + const press = (code: string, type: string, meta: boolean): void => { + f.contents.emit('before-input-event', { preventDefault }, { type, code, key: code, modifiers: [], + control: false, meta, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + } + press('KeyB', 'keyDown', true) + expect(preventDefault).toHaveBeenCalledOnce() + press('MetaLeft', 'keyUp', false) + preventDefault.mockClear() + press('KeyB', 'keyDown', false); press('KeyB', 'keyUp', false) + expect(preventDefault).not.toHaveBeenCalled() +}) + +it.each(['macos', 'windows'] as const)('releases %s native input when a saved binding overlaps a mounted fixed action', async (platform) => { + const f = await fixture(platform) + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'Escape', modifiers: [] } }, initial.revision) + expect(saved.status).toBe('saved') + await f.call(DESKTOP_IPC.shortcutsGet, [...f.definitions, + { id: 'menu.dismiss', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + const preventDefault = vi.fn() + f.contents.send.mockClear() + f.contents.emit('before-input-event', { preventDefault }, { type: 'keyDown', code: 'Escape', key: 'Escape', modifiers: [], + control: false, meta: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false }) + expect(preventDefault).not.toHaveBeenCalled() + expect(f.contents.send).not.toHaveBeenCalled() +}) + +it.each(['macos', 'windows'] as const)('keeps %s chord presses singular across native interception and renderer key delivery', async (platform) => { + const f = await fixture(platform) + const initial = await f.call(DESKTOP_IPC.shortcutsGet, f.definitions) + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, + { type: 'set', id: 'sidebar.left.toggle', binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, initial.revision) + const registry = new ShortcutRegistry('desktop', platform, saved.snapshot) + const run = vi.fn() + registry.register({ ...f.definitions[0]!, label: () => 'Toggle sidebar', aliases: [], regions: ['page'], modals: [], + resolve: () => ({ status: 'handled', run }) }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + onTestFinished(installNativeKeyboard(window, { closeWindow: vi.fn(), subscribe: (listener) => { deliver = 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) deliver(input) + }) + const rendererReleases: string[] = [] + const press = (code: string, type: 'keyDown' | 'keyUp', repeat = false): void => { + const preventDefault = vi.fn() + f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3).toLowerCase(), type, modifiers: [], + control: false, meta: false, alt: false, shift: false, isAutoRepeat: repeat, isComposing: false }) + // Electron only delivers input to the document when the main process did not intercept it. + if (!preventDefault.mock.calls.length) { + if (type === 'keyUp') rendererReleases.push(code) + document.body.dispatchEvent(new KeyboardEvent(type === 'keyDown' ? 'keydown' : 'keyup', + { code, key: code.slice(3).toLowerCase(), repeat, bubbles: true, cancelable: true })) + } + } + for (const [index, [first, second]] of ([['KeyF', 'KeyG'], ['KeyG', 'KeyF']] as const).entries()) { + press(first, 'keyDown'); press(first, 'keyUp'); press(second, 'keyDown'); press(second, 'keyUp') + expect(run).toHaveBeenCalledTimes(index) + press(first, 'keyDown'); press(second, 'keyDown'); press(second, 'keyDown', true); press(first, 'keyDown', true) + expect(run).toHaveBeenCalledTimes(index + 1) + if (index === 0) { press(first, 'keyUp'); press(second, 'keyUp') } + else { press(second, 'keyUp'); press(first, 'keyUp') } + press(second, 'keyDown'); press(second, 'keyUp'); press(first, 'keyDown'); press(first, 'keyUp') + expect(run).toHaveBeenCalledTimes(index + 1) + } + expect(rendererReleases.filter(code => code === 'KeyF')).toHaveLength(5) + expect(rendererReleases.filter(code => code === 'KeyG')).toHaveLength(5) +}) + +it.each(['macos', 'windows'] as const)('requires fresh %s chord presses when Electron omits intercepted key releases', async (platform) => { + const f = await fixture(platform) + const bindings = [ + { id: 'sidebar.left.toggle', binding: { code: 'KeyJ', secondCode: 'KeyK', modifiers: [] } }, + { id: 'sidebar.right.toggle', binding: { code: 'KeyF', secondCode: 'KeyG', modifiers: [] } }, + { id: 'shortcuts.open', binding: { code: 'KeyV', modifiers: [] } }, + ] + let snapshot = await f.call(DESKTOP_IPC.shortcutsGet, + bindings.map(({ id }) => ({ id, defaults: {} }))) + for (const binding of bindings) { + const saved = await f.call(DESKTOP_IPC.shortcutsEdit, { type: 'set', ...binding }, snapshot.revision) + expect(saved.status).toBe('saved') + snapshot = saved.snapshot + } + const press = (code: string, type = 'keyDown', repeat = false): boolean => { + const preventDefault = vi.fn() + f.contents.emit('before-input-event', { preventDefault }, { code, key: code.slice(3).toLowerCase(), type, modifiers: [], + control: false, meta: false, alt: false, shift: false, isAutoRepeat: repeat, isComposing: false }) + return preventDefault.mock.calls.length > 0 + } + for (const embedded of [false, true]) { + Object.assign(f.contents, { focusedFrame: embedded ? { name: 'browser', parent: f.frame } : f.frame }) + f.contents.send.mockClear() + expect(press('KeyJ')).toBe(false) + expect(press('KeyK')).toBe(true) + expect(f.contents.send).toHaveBeenCalledOnce() + // Electron on macOS can omit both releases after intercepting the second keydown. + for (const code of ['KeyJ', 'KeyK']) { + expect(press(code)).toBe(false) + expect(press(code, 'keyUp')).toBe(false) + } + expect(f.contents.send).toHaveBeenCalledOnce() + expect(press('KeyK')).toBe(false) + expect(press('KeyJ')).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(2) + expect(press('KeyJ', 'keyDown', true)).toBe(true) + expect(press('KeyK', 'keyDown', true)).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(2) + expect(press('KeyF')).toBe(false) + expect(press('KeyG')).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(3) + expect(press('KeyV')).toBe(true) + expect(press('KeyJ')).toBe(false) + expect(press('KeyK')).toBe(true) + expect(f.contents.send).toHaveBeenCalledTimes(5) + } +}) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index 16c94456a4..77f9e0bf39 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -1,5 +1,6 @@ +// @vitest-environment jsdom import { JSDOM } from 'jsdom' -import { afterEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts' import { syncWindowsAppearance } from '../src/preload-windows.ts' import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts' @@ -15,7 +16,8 @@ vi.mock('../src/preload-theme.ts', () => ({ syncNativeTheme: vi.fn() })) vi.mock('../src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) vi.mock('../src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() })) -afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() }) +beforeEach(() => { vi.stubGlobal('process', { ...process, isMainFrame: true }) }) +afterEach(() => { document.body.replaceChildren(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.clearAllMocks(); vi.resetModules() }) it('limits product documents to update status and a native confirmation action', async () => { vi.stubGlobal('location', new URL('dsh-app://app/index.html')) @@ -121,7 +123,7 @@ it('moves welcome-entry focus to the document without changing keyboard tab orde }) it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only in the Windows app document (%s)', async (platform) => { - vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + vi.stubGlobal('process', { ...process, platform }) for (const url of ['dsh-app://app/', 'dsh-app://shell/mandatory-update.html', 'https://example.com/']) { vi.resetModules() vi.mocked(installMandatoryUpdateOverlay).mockClear() @@ -130,3 +132,60 @@ it.each(['win32', 'darwin'] as const)('installs the embedded mandatory UI only i expect(installMandatoryUpdateOverlay).toHaveBeenCalledTimes(platform === 'win32' && url === 'dsh-app://app/' ? 1 : 0) } }) + +it('exposes constrained shortcut operations and releases configuration subscriptions', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + 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() + await api.shortcuts.recording(true) + expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsGet, []) + expect(electron.ipcRenderer.invoke).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsRecording, true) + const listener = vi.fn() + const off = api.shortcuts.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsChanged)![1] as + (event: unknown, state: unknown) => void + handler({}, { status: 'ready' }) + expect(listener).toHaveBeenCalledWith({ status: 'ready' }) + off(); expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsChanged, handler) +}) + +it('withholds the product API from same-origin child frames', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + vi.stubGlobal('process', { ...process, isMainFrame: false }) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) +}) + +it('forwards only the focused product iframe and releases native input subscriptions', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + const listener = vi.fn() + const off = api.keyboard.subscribe(listener) + const handler = electron.ipcRenderer.on.mock.calls.find(([name]) => name === DESKTOP_IPC.shortcutsInput)![1] as + (event: unknown, state: unknown) => void + const input = { kind: 'iframe', frameName: 'preview', revision: 'current' } + handler({}, input) + const frame = document.createElement('iframe') + document.body.append(frame) + frame.name = 'preview' + frame.focus() + handler({}, input) + expect(listener).not.toHaveBeenCalled() + frame.setAttribute('data-html-preview', '') + handler({}, { ...input, frameName: 'stale' }) + handler({}, { ...input, frameName: '' }) + expect(listener).not.toHaveBeenCalled() + handler({}, input) + expect(listener).toHaveBeenCalledExactlyOnceWith(input) + frame.remove() + handler({}, input) + expect(listener).toHaveBeenCalledTimes(1) + const menu = { kind: 'menu', commandId: 'page.close', revision: 'current' } + handler({}, menu) + expect(listener).toHaveBeenLastCalledWith(menu) + off() + expect(electron.ipcRenderer.off).toHaveBeenCalledWith(DESKTOP_IPC.shortcutsInput, handler) +}) diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json index 37a5be420d..370ee0a448 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -22,6 +22,12 @@ }, { "path": "../../packages/client/ui-sidebar-browser/tsconfig.host.json" + }, + { + "path": "../../packages/client/shortcuts/tsconfig.host.json" + }, + { + "path": "../../packages/util/atomic-write" } ], "exclude": [ diff --git a/apps/web/tests/composer-shortcuts.e2e.ts b/apps/web/tests/composer-shortcuts.e2e.ts new file mode 100644 index 0000000000..daed38c834 --- /dev/null +++ b/apps/web/tests/composer-shortcuts.e2e.ts @@ -0,0 +1,56 @@ +/** 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 { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' + +it('preserves the composer draft and menu for application Enter combinations', async () => { + const scaffold = await launchWebScaffold({}) + const events: SessionEvent[] = [] + const off = scaffold.ctx.on('session/event', (_session, event) => { events.push(event) }) + try { + const browser = await chromium.launch() + let failurePage: Page | undefined + try { + const page = await newEnglishPage(browser) + failurePage = page + const tripwire = watchConsole(page) + 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') + } + await writeComposerDraft(page, input, 'first line') + 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 + } finally { + await browser.close() + } + } finally { + off() + await scaffold.close() + } +}) diff --git a/apps/web/tests/expected/composer-shortcuts/gestures.expected.md b/apps/web/tests/expected/composer-shortcuts/gestures.expected.md new file mode 100644 index 0000000000..0f708fa40e --- /dev/null +++ b/apps/web/tests/expected/composer-shortcuts/gestures.expected.md @@ -0,0 +1,14 @@ +- 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 diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 3f945d9cce..7e8776fa0c 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -24,6 +24,9 @@ - button "Detailed" - text: Developer tools Show tools and information for debugging and troubleshooting - switch "Developer tools" + - text: Keyboard shortcuts + - paragraph: Browse available shortcuts and input actions + - button "View shortcuts" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - button "Queue" - text: "Current version: {{version}}" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index c0a1b2ed35..37d224d2a2 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -24,6 +24,9 @@ - button "详细" - text: 开发者工具 显示用于调试和排查问题的工具与信息 - switch "开发者工具" + - text: 快捷键 + - paragraph: 查看当前可用的快捷键和输入操作 + - button "查看快捷键" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - button "排队发送" - text: 当前版本:{{version}} diff --git a/apps/web/tests/expected/shortcuts/edit-stale.expected.md b/apps/web/tests/expected/shortcuts/edit-stale.expected.md new file mode 100644 index 0000000000..179cabd6bf --- /dev/null +++ b/apps/web/tests/expected/shortcuts/edit-stale.expected.md @@ -0,0 +1,5 @@ +- group "Toggle left sidebar": + - button "Restore default" [disabled] + - button "Press a shortcut" + - text: Configuration changed in another page or window. Review the latest bindings before saving. Configuration changed in another page or window. Review the latest bindings before saving. + - button "I have reviewed the latest configuration" diff --git a/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md b/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md new file mode 100644 index 0000000000..f359af0fc7 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-conflict.expected.md @@ -0,0 +1 @@ +- alert: Already used by “Open keyboard shortcuts” diff --git a/apps/web/tests/expected/shortcuts/en-US-empty.expected.md b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md new file mode 100644 index 0000000000..b61f3d9475 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-empty.expected.md @@ -0,0 +1,7 @@ +- dialog "Keyboard shortcuts": + - banner: + - heading "Keyboard shortcuts" [level=2] + - button "Close keyboard shortcuts" + - search "Search shortcuts": + - searchbox "Search shortcuts": abc + - status: No matching shortcuts diff --git a/apps/web/tests/expected/shortcuts/en-US-inline.expected.md b/apps/web/tests/expected/shortcuts/en-US-inline.expected.md new file mode 100644 index 0000000000..5361718dc6 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-inline.expected.md @@ -0,0 +1,5 @@ +- group "Toggle left sidebar": + - button "Restore default" + - button "Remove" + - button "Press a shortcut" + - text: 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. diff --git a/apps/web/tests/expected/shortcuts/en-US-saved.expected.md b/apps/web/tests/expected/shortcuts/en-US-saved.expected.md new file mode 100644 index 0000000000..be3933029d --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US-saved.expected.md @@ -0,0 +1 @@ +- alert: Modified diff --git a/apps/web/tests/expected/shortcuts/en-US.expected.md b/apps/web/tests/expected/shortcuts/en-US.expected.md new file mode 100644 index 0000000000..54208a048a --- /dev/null +++ b/apps/web/tests/expected/shortcuts/en-US.expected.md @@ -0,0 +1,53 @@ +- 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: Open settings + - button "Remove shortcut for Open settings" + - button "Edit shortcut for Open settings" + - button "Open settings 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]: "@" + - 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 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md new file mode 100644 index 0000000000..91f6c75f5f --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-conflict.expected.md @@ -0,0 +1 @@ +- alert: 已被「快捷键速查」占用 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md new file mode 100644 index 0000000000..e8169b6f92 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-empty.expected.md @@ -0,0 +1,7 @@ +- dialog "快捷键": + - banner: + - heading "快捷键" [level=2] + - button "关闭快捷键" + - search "搜索快捷键": + - searchbox "搜索快捷键": abc + - status: 没有匹配的快捷键 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md new file mode 100644 index 0000000000..72256d0b99 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-inline.expected.md @@ -0,0 +1,5 @@ +- group "展开/收起左侧栏": + - button "恢复默认" + - button "移除" + - button "按下快捷键" + - text: 可设置 Command+/、Command+,、Command+反斜杠、Control+反引号、Command+Option+按键或 Command+Shift+按键。也支持三个或四个不同修饰键的组合。浏览器或系统占用的组合可能无法送达页面。 diff --git a/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md b/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md new file mode 100644 index 0000000000..bc0fb851da --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN-saved.expected.md @@ -0,0 +1 @@ +- alert: 已修改 diff --git a/apps/web/tests/expected/shortcuts/zh-CN.expected.md b/apps/web/tests/expected/shortcuts/zh-CN.expected.md new file mode 100644 index 0000000000..d08f73b752 --- /dev/null +++ b/apps/web/tests/expected/shortcuts/zh-CN.expected.md @@ -0,0 +1,53 @@ +- dialog "快捷键": + - banner: + - heading "快捷键" [level=2] + - button "关闭快捷键" + - search "搜索快捷键": + - searchbox "搜索快捷键" + - region "应用操作": + - list: + - listitem: + - text: 快捷键速查 + - button "移除快捷键速查快捷键" + - button "修改快捷键速查快捷键" + - button "快捷键速查 ⌘ /": ⌘ / + - listitem: + - text: 展开/收起左侧栏 + - button "移除展开/收起左侧栏快捷键" + - button "修改展开/收起左侧栏快捷键" + - button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B + - listitem: + - text: 打开设置 + - button "移除打开设置快捷键" + - button "修改打开设置快捷键" + - button "打开设置 ⌘ ,": ⌘ , + - region "消息输入": + - heading "消息输入" [level=3] + - list: + - listitem: + - text: 发送消息 + - button "发送消息 Enter" [disabled]: Enter + - listitem: + - text: 换行 + - button "换行 ⇧ Enter" [disabled]: ⇧ Enter + - listitem: + - text: 使用互补的 Queue/Steer 方式 + - button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter + - listitem: + - text: 打开命令菜单 + - button "打开命令菜单 /" [disabled]: / + - listitem: + - text: 打开引用菜单 + - button "打开引用菜单 @" [disabled]: "@" + - region "菜单与弹层": + - heading "菜单与弹层" [level=3] + - list: + - listitem: + - text: 移动菜单选择 + - button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓ + - listitem: + - text: 选择菜单项 + - button "选择菜单项 Enter" [disabled]: Enter + - listitem: + - text: 关闭菜单或顶层弹窗 + - button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc diff --git a/apps/web/tests/shortcuts.e2e.ts b/apps/web/tests/shortcuts.e2e.ts new file mode 100644 index 0000000000..13d38eda7c --- /dev/null +++ b/apps/web/tests/shortcuts.e2e.ts @@ -0,0 +1,283 @@ +/** Real Web composition: keyboard routing, inline editing, and localized system feedback. */ +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { chromium, type Browser } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' + +const expected = fileURLToPath(new URL('./expected/shortcuts', import.meta.url)) +const mode = webSnapshotMode() + +describe('web e2e: shortcut reference', () => { + let scaffold: WebScaffold + let browser: Browser + beforeAll(async () => { + scaffold = await launchWebScaffold({ developerTools: false }) + browser = await chromium.launch() + }, 120_000) + afterAll(async () => { await browser?.close(); await scaffold?.close() }) + + it.each([ + { locale: 'zh-CN', platform: 'MacIntel', title: '快捷键', settings: '设置', view: '查看快捷键', search: '搜索快捷键', key: 'Meta' }, + { locale: 'en-US', platform: 'Win32', title: 'Keyboard shortcuts', settings: 'Settings', view: 'View shortcuts', search: 'Search shortcuts', key: 'Control' }, + ])('opens, searches, and restores nested focus in $locale', async ({ locale, platform, title, settings, view, search, key }) => { + const referenceKey = platform === 'Win32' ? 'Control+Slash' : 'Meta+Slash' + const context = await browser.newContext({ locale, colorScheme: 'light', viewport: { width: 1440, height: 1000 } }) + try { + // Branch coverage for device labels; native Windows input is separately verified on Windows. + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + const page = await context.newPage() + const console = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const trigger = page.getByRole('button', { name: settings, exact: true }) + await trigger.click() + await page.getByRole('button', { name: view, exact: true }).click() + const dialog = page.getByRole('dialog', { name: title, exact: true }) + 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.boundingBox()).toMatchObject({ width: 480, height: 600 }) + expect(await dialog.getByRole('search').boundingBox()).toMatchObject({ width: 432, height: 36 }) + const rows = dialog.getByRole('listitem') + expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height)))) + .toEqual(new Set([42])) + expect(await rows.evaluateAll(items => items.every(row => row.getBoundingClientRect().height >= 42 + && row.scrollWidth <= row.clientWidth))).toBe(true) + expect(await page.getByRole('searchbox', { name: search }).evaluate(element => element === document.activeElement)).toBe(true) + await compareOrRefreshGolden(join(expected, `${locale}.expected.md`), + await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode) + const fixedButtons = dialog.getByRole('button', { disabled: true }) + const fixedButton = fixedButtons.first() + expect(await fixedButton.evaluate(element => getComputedStyle(element).opacity)).toBe('0.45') + const fixedBackground = await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor) + await fixedButton.hover() + expect(await fixedButton.locator('span').evaluate(element => getComputedStyle(element).backgroundColor)).toBe(fixedBackground) + const editLabel = locale === 'zh-CN' ? '修改展开/收起左侧栏快捷键' : 'Edit shortcut for Toggle left sidebar' + const removeLabel = locale === 'zh-CN' ? '移除展开/收起左侧栏快捷键' : 'Remove shortcut for Toggle left sidebar' + const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: editLabel, exact: true }) }) + const editButton = page.getByRole('button', { name: editLabel, exact: true }) + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') + const boundRow = dialog.getByRole('listitem').filter({ has: page.getByText( + locale === 'zh-CN' ? '快捷键速查' : 'Open keyboard shortcuts', { exact: true }, + ) }) + const badge = boundRow.getByRole('button').last() + const badgeColor = () => badge.locator('span').evaluate(element => getComputedStyle(element).backgroundColor) + const restingColor = await badgeColor() + await boundRow.locator(':scope > span').first().hover() + expect(await badgeColor()).toBe(restingColor) + await badge.hover() + expect(await badgeColor()).toBe('rgba(38, 49, 72, 0.14)') + await boundRow.getByRole('button').first().hover() + expect(await badgeColor()).toBe(restingColor) + await badge.click() + await boundRow.getByRole('group').waitFor() + expect(new Set(await rows.evaluateAll(items => items.map(row => row.getBoundingClientRect().height)))) + .toEqual(new Set([42])) + expect(await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1) + await page.keyboard.press('Escape') + await boundRow.getByRole('group').waitFor({ state: 'hidden' }) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + const boundActionsOpacity = () => boundRow.getByRole('button').first().evaluate(element => getComputedStyle(element.parentElement!).opacity) + expect(await boundActionsOpacity()).toBe('0') + expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + await badge.click() + await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() + await boundRow.getByRole('group').waitFor({ state: 'hidden' }) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + expect(await boundActionsOpacity()).toBe('0') + await row.hover() + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('1') + expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(1) + await editButton.click() + const inline = dialog.getByRole('group', { name: locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar', exact: true }) + const recorder = inline.getByRole('button', { name: locale === 'zh-CN' ? '按下快捷键' : 'Press a shortcut', exact: true }) + expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(1) + expect(await page.getByRole('dialog').count()).toBe(2) + expect(await recorder.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)') + await compareOrRefreshGolden(join(expected, `${locale}-inline.expected.md`), + await captureStableAria(page, '[data-shortcut-modal="shortcut-edit"]', scaffold.workspaceCwd), mode) + await page.keyboard.press(referenceKey) + const errorToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已被「快捷键速查」占用' : 'Already used by “Open keyboard shortcuts”' }) + await errorToast.waitFor() + expect(await errorToast.evaluate(element => ({ + background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color, + }))) + .toEqual({ background: 'rgb(53, 54, 56)', color: 'rgb(255, 255, 255)' }) + expect(await errorToast.evaluate(element => element.closest('[role="dialog"]') === null)).toBe(true) + expect(await recorder.evaluate(element => ({ + color: getComputedStyle(element).borderColor, width: getComputedStyle(element).borderWidth, + }))) + .toEqual({ color: 'rgb(236, 19, 19)', width: '1px' }) + await compareOrRefreshGolden(join(expected, `${locale}-conflict.expected.md`), await errorToast.ariaSnapshot(), mode) + await page.keyboard.press('i') + await page.getByRole('alert').filter({ hasText: locale === 'zh-CN' + ? '请同时按下 Command、Ctrl 或 Alt 修饰键。' : 'Include Command, Ctrl, or Alt in the combination.' }).waitFor() + await page.keyboard.press(`${key}+Shift+.`) + await inline.waitFor({ state: 'hidden' }) + expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + expect(await editButton.evaluate(element => element.matches(':focus-visible'))).toBe(false) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') + const successToast = page.getByRole('alert').filter({ hasText: locale === 'zh-CN' ? '已修改' : 'Modified' }) + await successToast.waitFor() + await compareOrRefreshGolden(join(expected, `${locale}-saved.expected.md`), await successToast.ariaSnapshot(), mode) + await page.emulateMedia({ colorScheme: 'dark' }) + await expect.poll(() => successToast.evaluate(element => ({ + background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color, + }))).toEqual({ background: 'rgb(67, 69, 74)', color: 'rgb(255, 255, 255)' }) + await page.emulateMedia({ colorScheme: 'light' }) + await expect.poll(() => successToast.evaluate(element => getComputedStyle(element).backgroundColor)).toBe('rgb(53, 54, 56)') + await page.getByRole('button', { name: removeLabel, exact: true }).click() + const unbound = row.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }) + await unbound.waitFor() + expect(await unbound.evaluate(element => getComputedStyle(element).color)).toBe('rgb(129, 133, 140)') + expect(await row.getByRole('button', { name: removeLabel, exact: true }).count()).toBe(0) + await unbound.click() + await inline.waitFor() + expect(await inline.getByRole('button', { name: locale === 'zh-CN' ? '移除' : 'Remove', exact: true }).count()).toBe(0) + await page.keyboard.press('Escape') + await inline.waitFor({ state: 'hidden' }) + expect(await dialog.evaluate(element => element === document.activeElement)).toBe(true) + await dialog.getByRole('heading', { name: title, exact: true }).hover() + expect(await editButton.evaluate(element => getComputedStyle(element.parentElement!).opacity)).toBe('0') + expect(await page.getByRole('dialog').count()).toBe(2) + for (const query of ['abc', 'sendEnter']) { + await page.getByRole('searchbox').fill(query) + expect(await dialog.getByRole('listitem').count()).toBe(0) + expect(await dialog.getByRole('status').textContent()).toBe(locale === 'zh-CN' ? '没有匹配的快捷键' : 'No matching shortcuts') + if (query === 'abc') await compareOrRefreshGolden(join(expected, `${locale}-empty.expected.md`), + await captureStableAria(page, '[data-shortcut-modal="shortcuts"]', scaffold.workspaceCwd), mode) + } + for (const query of [locale === 'zh-CN' ? '左侧栏' : 'toggle left', 'tgllft', 'toggle left sidebar']) { + await page.getByRole('searchbox').fill(query) + expect(await dialog.getByRole('listitem').count()).toBe(1) + expect(await dialog.getByRole('listitem').textContent()).toContain(locale === 'zh-CN' ? '展开/收起左侧栏' : 'Toggle left sidebar') + } + await page.keyboard.press(referenceKey) + expect(await referenceNode!.evaluate(element => element.isConnected)).toBe(true) + expect(await page.getByRole('dialog').count()).toBe(2) + expect(await page.getByRole('searchbox').inputValue()).toBe('toggle left sidebar') + await page.keyboard.press('Escape') + expect(await page.getByRole('dialog').count()).toBe(1) + expect(await page.getByRole('button', { name: view, exact: true }).evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press('Escape') + expect(await page.getByRole('dialog').count()).toBe(0) + expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true) + await page.keyboard.press(referenceKey) + await dialog.waitFor() + await page.keyboard.press(`${key}+/`) + expect(await page.getByRole('dialog').count()).toBe(1) + expect(await page.getByRole('searchbox').inputValue()).toBe('') + await page.setViewportSize({ width: 420, height: 500 }) + const compactBounds = (await dialog.boundingBox())! + expect(compactBounds.x).toBeGreaterThanOrEqual(24) + expect(compactBounds.x + compactBounds.width).toBeLessThanOrEqual(396) + expect(compactBounds.y + compactBounds.height).toBeLessThanOrEqual(476) + await dialog.getByRole('region').last().scrollIntoViewIfNeeded() + expect(await dialog.getByRole('heading', { name: title }).isVisible()).toBe(true) + await page.keyboard.press('Escape') + // Observe preventDefault without invoking destructive browser defaults in the test runner. + expect(await page.evaluate(modifier => ['KeyN', 'KeyP', 'KeyO', 'KeyR', 'KeyW', 'KeyB'].map((code) => { + const event = new KeyboardEvent('keydown', { code, ctrlKey: modifier === 'Control', metaKey: modifier === 'Meta', bubbles: true, cancelable: true }) + document.body.dispatchEvent(event) + return event.defaultPrevented + }), key)).toEqual(Array(6).fill(false)) + await page.setViewportSize({ width: 1440, height: 1000 }) + await trigger.click() + const viewButton = page.getByRole('button', { name: view, exact: true }) + const viewHint = `${view} ${key === 'Meta' ? '⌘ /' : 'Ctrl + /'}` + await viewButton.hover() + expect(await page.getByRole('tooltip').textContent()).toBe(viewHint) + await viewButton.click() + await boundRow.getByRole('button').first().click() + await boundRow.getByText(locale === 'zh-CN' ? '暂无快捷键' : 'No shortcut', { exact: true }).waitFor() + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await viewButton.hover() + expect(await page.getByRole('tooltip').count()).toBe(0) + expect(await viewButton.getAttribute('aria-keyshortcuts')).toBeNull() + await viewButton.click() + await badge.click() + await boundRow.getByRole('button', { name: locale === 'zh-CN' ? '恢复默认' : 'Restore default', exact: true }).click() + await boundRow.getByRole('group').waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + await viewButton.hover() + expect(await page.getByRole('tooltip').textContent()).toBe(viewHint) + expect(console.pageErrors).toEqual([]) + expect(console.warnings).toEqual([]) + } finally { await context.close() } + }) + + it.each([ + { platform: 'Win32', modifier: 'Control', defaultBinding: 'Control+Alt+B' }, + { platform: 'MacIntel', modifier: 'Meta', defaultBinding: 'Alt+Meta+B' }, + { platform: 'Linux x86_64', modifier: 'Control', defaultBinding: null }, + ])('records, rejects conflicts, persists across reload, and synchronizes stale drafts between tabs on $platform', async ({ platform, modifier, defaultBinding }) => { + const context = await browser.newContext({ locale: 'en-US', viewport: { width: 1440, height: 1000 } }) + try { + await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform) + const page = await context.newPage() + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + const referenceKey = `${modifier}+Slash` + const open = async () => { + await page.keyboard.press(referenceKey) + await page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true }).waitFor() + } + await open() + await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + const editor = page.getByRole('group', { name: 'Toggle left sidebar', exact: true }) + await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus() + await page.keyboard.press(referenceKey) + await page.getByRole('alert').filter({ hasText: 'Already used by “Open keyboard shortcuts”' }).waitFor() + expect(await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).getAttribute('aria-invalid')).toBe('true') + await page.keyboard.press(`${modifier}+Shift+.`) + await editor.waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + const sidebarKey = `${modifier}+Shift+.` + expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts')) + .toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.') + await page.keyboard.press(sidebarKey) + await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor() + await page.reload({ waitUntil: 'load' }) + await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() + const toggleAfterReload = page.getByRole('button', { name: /^(Open|Collapse) sidebar$/ }) + await expect.poll(() => toggleAfterReload.getAttribute('aria-keyshortcuts')) + .toBe(modifier === 'Meta' ? 'Shift+Meta+.' : 'Control+Shift+.') + const wasOpen = await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).isVisible() + await page.keyboard.press(sidebarKey) + await page.getByRole('button', { name: wasOpen ? 'Open sidebar' : 'Collapse sidebar', exact: true }).waitFor() + if (wasOpen) await page.getByRole('button', { name: 'Open sidebar', exact: true }).click() + await open() + await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus() + const other = await context.newPage() + await other.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await other.getByRole('button', { name: 'Settings', exact: true }).waitFor() + await other.keyboard.press(referenceKey) + await other.getByRole('button', { name: 'Remove shortcut for Toggle left sidebar', exact: true }).click() + await editor.getByText('Configuration changed in another page or window. Review the latest bindings before saving.', { exact: true }).waitFor() + await page.bringToFront() + await editor.getByRole('button', { name: 'Press a shortcut', exact: true }).focus() + await page.keyboard.press(`${modifier}+Shift+,`) + expect(await editor.getByRole('button', { name: 'Retry save', exact: true }).count()).toBe(0) + await compareOrRefreshGolden(join(expected, 'edit-stale.expected.md'), + await captureStableAria(page, '[data-shortcut-modal="shortcut-edit"]', scaffold.workspaceCwd), mode) + await editor.getByRole('button', { name: 'I have reviewed the latest configuration', exact: true }).click() + await editor.getByRole('button', { name: 'Retry save', exact: true }).click() + await editor.waitFor({ state: 'hidden' }) + await page.getByRole('button', { name: 'Edit shortcut for Toggle left sidebar', exact: true }).click() + await editor.getByRole('button', { name: 'Restore default', exact: true }).click() + await editor.waitFor({ state: 'hidden' }) + await page.keyboard.press('Escape') + expect(await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).getAttribute('aria-keyshortcuts')) + .toBe(defaultBinding) + const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('dsh.keybindings.v1')!) as unknown) as { profiles: Record } + expect(Object.values(stored.profiles)).toEqual([{}]) + } finally { await context.close() } + }) + +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 5747556002..8c8598af5a 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,6 +24,8 @@ "exclude": [ "tests/connection-recovery.e2e.ts", "tests/server-restart.e2e.ts", + "tests/shortcuts.e2e.ts", + "tests/composer-shortcuts.e2e.ts", "tests/default-product-isolation.e2e.ts", "tests/diff-context.e2e.ts", "tests/scaffold.ts", diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 80d233b634..b30a4b1d76 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -4,6 +4,8 @@ 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. + ## Layers and ownership | Layer | Main owners | Responsibility | diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index f14cdbe9b1..b407f1f699 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -4,6 +4,8 @@ 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 并恢复焦点。 + ## 分层与所有权 | 层 | 主要 owner | 职责 | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 5ed86cceea..42c6ae5eea 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -220,6 +220,10 @@ - id: locale name: '@deepseek-ai/dsh-client-locale' + - 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' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index ed64aaaaa5..451dde0d96 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -172,7 +172,9 @@ "commander": "^15.0.0", "open": "^11.0.0", "@deepseek-ai/dsh-api-account-controller": "workspace:^", - "@deepseek-ai/dsh-client-ui-settings-account": "workspace:^" + "@deepseek-ai/dsh-client-ui-settings-account": "workspace:^", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^", + "@deepseek-ai/dsh-client-ui-shortcuts": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/README.md b/packages/client/README.md index 8a5f789a4e..a7e4228fac 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -30,6 +30,8 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`modules/`](modules/README.md) | Loads browser-side client modules | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery | `ctx.connection` | | [`file-upload/`](file-upload/README.md) | Sends raw Blob and byte-stream request bodies outside the page thread | `ctx.fileUpload` | +| [`shortcuts/`](shortcuts/README.md) | Registers application keyboard commands | `ctx.shortcuts` | +| [`ui-shortcuts/`](ui-shortcuts/README.md) | Presents the keyboard shortcut reference | — | | [`store/`](store/README.md) | Provides React-free observable and snapshot-store primitives | — | | [`hmr/`](hmr/README.md) | Refreshes client plugins during development | — | | [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries | `ctx.locale` | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index ae5dbb237f..d5c33dc99d 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -30,6 +30,8 @@ kind: "package-group" | [`modules/`](modules/README.zh.md) | 加载浏览器侧客户端模块 | `ctx.clientModules` / `ctx.modules` | | [`connection/`](connection/README.zh.md) | 维护浏览器与宿主之间的 RPC 通信与事件投递 | `ctx.connection` | | [`file-upload/`](file-upload/README.zh.md) | 在页面线程之外发送原始 Blob 与字节流请求体 | `ctx.fileUpload` | +| [`shortcuts/`](shortcuts/README.zh.md) | 注册应用键盘命令 | `ctx.shortcuts` | +| [`ui-shortcuts/`](ui-shortcuts/README.zh.md) | 展示快捷键速查 | — | | [`store/`](store/README.zh.md) | 提供不依赖 React 的 observable 与快照存储原语 | — | | [`hmr/`](hmr/README.zh.md) | 在开发期间刷新客户端插件 | — | | [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典 | `ctx.locale` | diff --git a/packages/client/shortcuts/README.i18n.yaml b/packages/client/shortcuts/README.i18n.yaml new file mode 100644 index 0000000000..d4e3af4e1f --- /dev/null +++ b/packages/client/shortcuts/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 diff --git a/packages/client/shortcuts/README.md b/packages/client/shortcuts/README.md new file mode 100644 index 0000000000..f6a26dcda4 --- /dev/null +++ b/packages/client/shortcuts/README.md @@ -0,0 +1,92 @@ +--- +description: "Customize application keyboard commands for each device" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-shortcuts + +English | [中文](README.zh.md) + +## Summary + +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. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## 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. + +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. + +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. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +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. + +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. + +Desktop native menus and embedded frames use the same command registry as DOM input. Preload matches the focused iframe name against the embedding frame reported by Electron; the client rejects messages from an older configuration. Native menu selection is explicit and works without a bound accelerator. Two-key chords have no native menu accelerator; their labels remain visible in the shortcut reference. Terminal Ctrl+W/R remains local input outside Windows and macOS Desktop. Explicit native-menu actions retain their modal checks; keyboard bindings use the device dispatch policy. + +
+ +----- + + +## Further Exploration + +- [Web Client](../../../docs/subsystems/web-client.md) +- [UI primitives](../ui-primitives/README.md) +- [Web app bundle](../../bundle/web-app/README.md) + +----- + + +## Model Experience + +None, as the package is a browser-side UI plugin layer that registers nothing model-facing. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + + + +- 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. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. The catalog and binding index are derived together from the private registry; there is no independently maintained runtime relationship to inspect. diff --git a/packages/client/shortcuts/README.zh.md b/packages/client/shortcuts/README.zh.md new file mode 100644 index 0000000000..71070d39a5 --- /dev/null +++ b/packages/client/shortcuts/README.zh.md @@ -0,0 +1,92 @@ +--- +description: "为每台设备自定义应用键盘命令" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-client-shortcuts + +[English](README.md) | 中文 + +## 概述 + +为每台设备自定义应用键盘命令。桌面端和 Web 按接收输入的设备选择不同默认键位。自定义键位在同一设备重新加载后保留。命令随所属插件卸载而移除,保存的覆盖仍保留供重新安装使用。 + +Windows 在修饰键之间及第一个普通键之前使用 ` + `。两端同时按下的两个普通键均并排显示,如 `F G`,提示和快捷键速查保持一致。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步阅读](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用此包 + +Web 应用 bundle 自动挂载此包。功能插件在 `ctx.effect()` 中通过 `ctx.shortcuts` 注册命令。服务接收物理 `code`,按设备展开 `primary`,并拒绝重复 ID 和任一支持平台上的默认键位冲突。同一可观察目录提供名称、键帽、已修改标记、冲突状态以及功能 owner 发布的不可用原因。可用性更新不改变已保存键位;命令释放时也解除其可用性订阅。 + +功能插件通过 `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 在内的所有有效绑定优先于原生操作、编辑器、终端、内嵌页面和模态控件。录制和输入法组合状态仍受保护。只有两个键都按住时,双键组合才优先接管;第一个键仍正常处理,可能输入一个字符。 + +首次读取完成后激活键位。读取失败保留上次接受的配置;没有已接受配置时使用默认值。成功重试或显式恢复之前,普通编辑保持禁用。恢复先保留原始内容,再用默认值替换整个文档。写入失败保留有效键位和草稿。其他浏览器标签页的更新使打开的草稿过期,保存前需要核对最新配置。浏览器写入前重新读取,但同时跨标签页写入仍采用最后一次成功写入。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +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。适配器释放时移除监听器和模态观察者;单个功能观察者失败不妨碍其他观察者接收输入。 + +Desktop 原生菜单和内嵌 frame 使用与 DOM 输入相同的命令注册表。Preload 将获得焦点的 iframe 名称与 Electron 报告的嵌入 frame 匹配;Client 拒绝旧配置消息。原生菜单选择是显式操作,不依赖快捷键绑定。双键组合没有原生菜单快捷键,其键位仍显示在快捷键速查中。Windows 和 macOS Desktop 之外的终端 Ctrl+W/R 保留为局部输入。显式原生菜单操作保留模态检查;键盘绑定使用对应设备的分发策略。 + +
+ +----- + + +## 进一步阅读 + +- [Web Client](../../../docs/subsystems/web-client.zh.md) +- [UI primitives](../ui-primitives/README.zh.md) +- [Web app bundle](../../bundle/web-app/README.zh.md) + +----- + + +## 模型体验 + +无;此包是浏览器侧 UI 插件,不注册面向模型的内容。 + +#### KV Cache 影响 + +无;此包不组装或发送模型提供方请求。 + +## 已知限制与延期工作 + + + +- 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。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
+ +**Runtime invariant:** 不发布配套检查模块。目录和键位索引由私有注册表同步派生,没有独立维护、需要检查的运行时关系。 diff --git a/packages/client/shortcuts/package.json b/packages/client/shortcuts/package.json new file mode 100644 index 0000000000..dd004ba3b5 --- /dev/null +++ b/packages/client/shortcuts/package.json @@ -0,0 +1,66 @@ +{ + "name": "@deepseek-ai/dsh-client-shortcuts", + "description": "Application keyboard command registry and physical-key routing", + "version": "0.1.6-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/shortcuts" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./protocol": { + "types": "./lib/types/protocol.d.ts", + "default": "./lib/protocol.js" + } + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/protocol.js", + "lib/types/**/*.d.ts" + ], + "dependencies": { + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" + } +} diff --git a/packages/client/shortcuts/src/binding.ts b/packages/client/shortcuts/src/binding.ts new file mode 100644 index 0000000000..d5e23a54ed --- /dev/null +++ b/packages/client/shortcuts/src/binding.ts @@ -0,0 +1,110 @@ +/** Physical-key protocol shared by browser commands and desktop adapters; no DOM or runtime state. */ +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Stable command identity owned by the registering feature. */ +export type ShortcutCommandId = Branded<'ShortcutCommandId'> +/** Operating system of the device receiving input. */ +export type ShortcutPlatform = 'macos' | 'windows' | 'linux' +/** Application shell selecting the default bindings. */ +export type ShortcutRuntime = 'desktop' | 'web' +/** 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. */ +export interface ShortcutBinding { + readonly code: string + readonly secondCode?: string + readonly modifiers: readonly ShortcutModifier[] +} +/** Normalized modifier order is also the keycap order. */ +export interface NormalizedBinding { + readonly code: string + readonly secondCode?: string + readonly modifiers: readonly ('control' | 'alt' | 'shift' | 'meta')[] +} + +const keyNames: Readonly> = { + Slash: '/', Comma: ',', Period: '.', Backslash: '\\', Backquote: '`', Minus: '-', Equal: '=', + BracketLeft: '[', BracketRight: ']', Semicolon: ';', Quote: "'", Enter: 'Enter', + Escape: 'Esc', Space: 'Space', Tab: 'Tab', Backspace: 'Backspace', Delete: 'Delete', + ArrowUp: '↑', ArrowDown: '↓', ArrowLeft: '←', ArrowRight: '→', +} +const modifierOrder = ['control', 'alt', 'shift', 'meta'] as const + +/** + * Expand logical modifiers, deduplicate, and validate the physical code. + * @param binding - declared binding. + * @param platform - receiving device platform. + * @returns canonical binding; unsupported codes throw during registration. + */ +export function normalizeBinding(binding: ShortcutBinding, platform: ShortcutPlatform): NormalizedBinding { + const codes: [string, ...string[]] = [binding.code, ...binding.secondCode === undefined ? [] : [binding.secondCode]] + codes.sort() + for (const code of codes) { + if (!/^(Key[A-Z]|Digit[0-9]|F([1-9]|1[0-9]|2[0-4]))$/u.test(code) + && !Object.hasOwn(keyNames, code)) throw new Error(`Unsupported shortcut code: ${code}`) + } + if (codes.length === 2 && codes[0] === codes[1]) throw new Error('Shortcut keys must be distinct') + const modifiers = new Set(binding.modifiers.map(value => value === 'primary' + ? platform === 'macos' ? 'meta' : 'control' : value)) + return { code: codes[0], ...(codes[1] === undefined ? {} : { secondCode: codes[1] }), + modifiers: modifierOrder.filter(value => modifiers.has(value)) } +} + +/** + * Produce an exact-match index from a normalized binding. + * @param binding - normalized physical key and modifiers. + * @returns stable index used for both matching and conflict checks. + */ +export function bindingKey(binding: NormalizedBinding): string { + const codes = binding.secondCode === undefined ? [binding.code] : [binding.code, binding.secondCode].sort() + return [...binding.modifiers, ...codes].join('+') +} + +/** + * Format keycaps and ARIA; Windows separates modifiers with plus signs, while chord keys remain adjacent. + * @param binding - normalized binding, or null for an unbound command. + * @param platform - receiving device platform. + * @returns visible keycaps; two-key chords omit ARIA shortcuts, which only support one non-modifier key. + */ +export function presentBinding(binding: NormalizedBinding | null, platform: ShortcutPlatform): { + keys: readonly string[] + aria: string | undefined +} { + if (binding === null) return { keys: [], aria: undefined } + const key = keyNames[binding.code] ?? binding.code.replace(/^(Key|Digit)/u, '') + const symbols = platform === 'macos' + ? { control: '⌃', alt: '⌥', shift: '⇧', meta: '⌘' } + : { control: 'Ctrl', alt: 'Alt', shift: 'Shift', meta: 'Meta' } + const ariaNames = { control: 'Control', alt: 'Alt', shift: 'Shift', meta: 'Meta' } + const ariaKey = binding.code === 'Space' ? 'Space' : binding.code === 'Escape' ? 'Escape' + : binding.code.startsWith('Arrow') ? binding.code : key + const second = binding.secondCode === undefined ? [] + : [keyNames[binding.secondCode] ?? binding.secondCode.replace(/^(Key|Digit)/u, '')] + const keys = [...binding.modifiers.map(value => symbols[value]), key] + return { keys: [...platform === 'windows' ? keys.flatMap((label, index) => index === 0 ? [label] : ['+', label]) : keys, ...second], + aria: binding.secondCode === undefined ? [...binding.modifiers.map(value => ariaNames[value]), ariaKey].join('+') : undefined } +} + +/** + * Check Web combinations: Windows and macOS also admit any three or four modifiers; Linux retains the limited set. + * @param binding - normalized candidate. + * @param platform - receiving device platform. + * @returns whether this combination is admitted; admission does not guarantee browser or system delivery. + */ +export function isWebBindingAllowed(binding: NormalizedBinding, platform: ShortcutPlatform): boolean { + if (binding.secondCode !== undefined) return false + if (platform === 'windows' || platform === 'macos') { + if (binding.modifiers.length >= 3) return true + const primary = platform === 'macos' ? 'meta' : 'control' + if (binding.modifiers.length === 1 + && ((['Comma', 'Backslash'].includes(binding.code) && binding.modifiers[0] === primary) + || (binding.code === 'Backquote' && binding.modifiers[0] === 'control'))) return true + if (binding.modifiers.length === 2 && binding.modifiers.includes(primary) + && (binding.modifiers.includes('alt') || binding.modifiers.includes('shift'))) return true + } + return [ + { code: 'Slash', modifiers: ['primary'] as const }, + { code: 'Comma', modifiers: ['primary', 'shift'] as const }, + { code: 'Period', modifiers: ['primary', 'shift'] as const }, + ].some(candidate => bindingKey(binding) === bindingKey(normalizeBinding(candidate, platform))) +} diff --git a/packages/client/shortcuts/src/client/dom.ts b/packages/client/shortcuts/src/client/dom.ts new file mode 100644 index 0000000000..18bfd81460 --- /dev/null +++ b/packages/client/shortcuts/src/client/dom.ts @@ -0,0 +1,140 @@ +/** Main-document keyboard adapter; local controls arbitrate before window bubbling. */ +import type { Shortcuts, ShortcutContext, ShortcutFixedInput } from './types.ts' +import type { ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' + +/** + * Detect the visiting device, never the server operating system. + * @param document - product document, marked by Electron preload when present. + * @param navigator - browser device identification. + * @returns explicit runtime and platform for default resolution. + */ +export function detectEnvironment(document: Document, navigator: Navigator): { + runtime: ShortcutRuntime + platform: ShortcutPlatform +} { + const desktop = document.documentElement.dataset.platform + const device = desktop ?? navigator.platform + return { runtime: desktop === undefined ? 'web' : 'desktop', + platform: /darwin|mac|iphone|ipad/iu.test(device) ? 'macos' : /win/iu.test(device) ? 'windows' : 'linux' } +} + +/** + * Install document composition tracking and application dispatch after local handlers. + * @param window - input window owned by the client plugin. + * @param shortcuts - command registry for this window. + * @param fixed - optional fixed-sequence consumer after local controls. + * @param native - native input owns configurable bindings; DOM delivery only feeds fixed actions. + * @returns disposer releasing every listener. + */ +export function installKeyboard(window: Window, shortcuts: Pick & Partial>, + fixed?: (input: ShortcutFixedInput) => void, native = false): () => void { + const document = window.document + let pending = false + let pendingTimer: number | undefined + const chords = !native && shortcuts.runtime === 'desktop' && (shortcuts.platform === 'macos' || shortcuts.platform === 'windows') + const held = new Set() + const reset = (): void => { held.clear(); fixed?.({ type: 'reset' }) } + const offConfig = chords ? shortcuts.config?.subscribe(reset) : undefined + let composing = false + let compositionEnded = false + let deadKey = false + const start = (): void => { composing = true; reset() } + const end = (): void => { composing = false; compositionEnded = true; reset() } + const release = (event: KeyboardEvent): void => { + compositionEnded = false + held.delete(event.code) + // Command can suppress character keyup delivery on macOS. + if (/^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(event.code)) held.clear() + } + const blur = (): void => { composing = false; compositionEnded = false; deadKey = false; reset() } + const modalSelector = '[role="dialog"][aria-modal="true"], [role="menu"]' + const containsModal = (node: Node): boolean => node instanceof Element + && (node.matches(modalSelector) || node.querySelector(modalSelector) !== null) + const changedModals = (records: MutationRecord[]): void => { + if (records.some(record => record.type === 'attributes' + ? record.oldValue === 'dialog' || record.oldValue === 'true' || containsModal(record.target) + : [...record.addedNodes, ...record.removedNodes].some(containsModal))) reset() + } + const observer = fixed === undefined && !chords ? undefined : new MutationObserver(changedModals) + observer?.observe(document.documentElement, { childList: true, subtree: true, + attributes: true, attributeFilter: ['role', 'aria-modal'], attributeOldValue: true }) + const capture = (): void => { + if (pending) reset() + window.clearTimeout(pendingTimer) + if (observer !== undefined) changedModals(observer.takeRecords()) + pending = true + // Native event listeners can yield a microtask checkpoint before bubbling. + pendingTimer = window.setTimeout(() => { + pending = false + pendingTimer = undefined + reset() + }, 0) + } + const keydown = (event: KeyboardEvent): void => { + window.clearTimeout(pendingTimer) + pendingTimer = undefined + pending = false + const target = event.composedPath().find(value => value instanceof Element) + const element = target instanceof Element ? target : document.activeElement + const region = element?.closest('.xterm') ? 'terminal' + : element?.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') ? 'editable' : 'page' + const dialogs = document.querySelectorAll(modalSelector) + const top = [...dialogs].at(-1) + const context: ShortcutContext = { region, modal: top === undefined ? null : top.dataset.shortcutModal ?? 'other', target: element } + // oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing. + const guarded = composing || compositionEnded || deadKey || event.isComposing || event.keyCode === 229 + || event.getModifierState('AltGraph') + const isDead = event.key === 'Dead' + // macOS can report Option+Command+N as Dead outside input-method composition. + const commandDeadKey = isDead && shortcuts.runtime === 'web' && shortcuts.platform === 'macos' + && event.code === 'KeyN' && event.metaKey && event.altKey && !event.ctrlKey && !event.shiftKey + compositionEnded = false + deadKey = isDead + const gesture = { code: event.code, control: event.ctrlKey, alt: event.altKey, shift: event.shiftKey, + meta: event.metaKey, repeat: event.repeat, composing: guarded || isDead, defaultPrevented: event.defaultPrevented } + const consume = (): void => { + event.preventDefault() + if (commandDeadKey) deadKey = false + } + fixed?.({ type: 'keydown', gesture, context, consume }) + if (native) return + let secondCode: string | undefined + const code = event.code + if (chords) { + if (gesture.composing || event.defaultPrevented || /^(Control|Alt|Shift|Meta)(Left|Right)$/u.test(code)) { + held.clear() + return + } + // A repeated key after focus/composition reset cannot restore an abandoned chord. + if (event.repeat && !held.has(code)) return + held.add(code) + if (held.size === 2) secondCode = [...held].find(value => value !== event.code) + } + shortcuts.dispatch({ ...gesture, composing: guarded || (isDead && !commandDeadKey), + code, ...(secondCode === undefined ? {} : { secondCode }), + defaultPrevented: event.defaultPrevented }, context, consume) + } + document.addEventListener('compositionstart', start, true) + document.addEventListener('compositionend', end, true) + document.addEventListener('focusin', reset, true) + document.addEventListener('pointerdown', reset, true) + window.addEventListener('keydown', capture, true) + window.addEventListener('keydown', keydown) + window.addEventListener('keyup', release, chords) + window.addEventListener('blur', blur) + return () => { + pending = false + offConfig?.() + held.clear() + window.clearTimeout(pendingTimer) + observer?.disconnect() + document.removeEventListener('compositionstart', start, true) + document.removeEventListener('compositionend', end, true) + document.removeEventListener('focusin', reset, true) + document.removeEventListener('pointerdown', reset, true) + window.removeEventListener('keydown', capture, true) + window.removeEventListener('keydown', keydown) + window.removeEventListener('keyup', release, chords) + window.removeEventListener('blur', blur) + } +} diff --git a/packages/client/shortcuts/src/client/index.ts b/packages/client/shortcuts/src/client/index.ts new file mode 100644 index 0000000000..6afd7cd6a4 --- /dev/null +++ b/packages/client/shortcuts/src/client/index.ts @@ -0,0 +1,174 @@ +/** Browser command service, with one keyboard adapter per plugin lifetime. */ +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' +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 { desktopShortcutStorage, webShortcutStorage } from './storage.ts' +import type { Shortcuts } from './types.ts' +import type { ShortcutFixedInput } from './types.ts' +import { installNativeKeyboard } from './native.ts' +import { Config } from '../config.ts' + +export type { ShortcutCatalogEntry, ShortcutCommand, ShortcutContext, ShortcutDispatch, ShortcutGesture, Shortcuts } from './types.ts' +export type { ShortcutFixedInput, ShortcutFixedCommand, ShortcutFixedCatalogEntry } from './types.ts' +export type { ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Window-local application commands and effective keycap catalog. */ + shortcuts: Shortcuts + } +} + +/** Cordis provider for the physical-key registry and its main-document adapter. */ +export default class ShortcutsService extends Service implements Shortcuts { + static inject = ['locale'] + readonly runtime: Shortcuts['runtime'] + readonly platform: Shortcuts['platform'] + readonly catalog: Shortcuts['catalog'] + readonly config: Shortcuts['config'] + readonly fixedCatalog: Shortcuts['fixedCatalog'] + readonly stopSequenceMs: number + private readonly fixedListeners = new Set<(input: ShortcutFixedInput) => void>() + private readonly adapter: DesktopShortcutsApi | undefined + private readonly keyboard: DesktopKeyboardApi | undefined + private active = true + private connected = false + private readonly registry: ShortcutRegistry + + constructor(ctx: Context) { + super(ctx, 'shortcuts') + const environment = detectEnvironment(document, navigator) + this.runtime = environment.runtime + this.platform = environment.platform + const config = Config((globalThis as { __DSH_SHORTCUTS_CONFIG__?: unknown }).__DSH_SHORTCUTS_CONFIG__ ?? {}) + this.stopSequenceMs = config.stopSequenceMs + this.registry = new ShortcutRegistry(this.runtime, this.platform, initialShortcutConfig()) + this.catalog = this.registry.catalog + this.config = this.registry.config + this.fixedCatalog = this.registry.fixedCatalog + const publish = (snapshot: ReturnType): void => { + if (this.active && this.connected && snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(snapshot) + } + const web = this.runtime === 'web' ? webShortcutStorage(window, this.platform, publish) : undefined + this.adapter = web ?? desktopShortcutStorage(window) + this.keyboard = this.runtime === 'desktop' + ? (window as Window & { dshDesktop?: { keyboard?: DesktopKeyboardApi } }).dshDesktop?.keyboard : undefined + const keyboard = this.keyboard + if (keyboard !== undefined) ctx.effect(() => installNativeKeyboard(window, keyboard, this.registry, + () => this.config.getSnapshot(), () => { this.fixedInput({ type: 'reset' }) }), 'shortcuts: native keyboard') + ctx.effect(() => { + const off = this.adapter?.subscribe(publish) + return () => { this.active = false; off?.(); web?.dispose() } + }, 'shortcuts: preferences') + this.syncDefinitions() + ctx.effect(() => { + const off = installKeyboard(window, this.registry, (input) => { this.fixedInput(input) }, + keyboard !== undefined && (this.platform === 'macos' || this.platform === 'windows')) + return () => { off(); this.fixedListeners.clear() } + }, 'shortcuts: keyboard') + ctx.effect(() => ctx.locale.subscribe(() => { this.registry.refreshLabels() }), 'shortcuts: locale') + } + + closeWindow(): void { + if (this.keyboard === undefined) throw new Error('Desktop keyboard bridge unavailable') + void this.keyboard.closeWindow(this.config.getSnapshot().revision).catch((error: unknown) => { console.error('Window close failed', error) }) + } + + register(command: Parameters[0]): () => void { + const off = this.registry.register(command) + this.syncDefinitions() + return () => { off(); this.syncDefinitions() } + } + + registerFixed(command: Parameters[0]): () => void { + const off = this.registry.registerFixed(command) + this.syncDefinitions() + return () => { off(); this.syncDefinitions() } + } + + observeFixedInput(listener: (input: ShortcutFixedInput) => void): () => void { + this.fixedListeners.add(listener) + return () => { this.fixedListeners.delete(listener) } + } + + private fixedInput(input: ShortcutFixedInput): void { + let consumed = false + for (const listener of [...this.fixedListeners]) { + if (!this.fixedListeners.has(listener)) continue + try { + listener(input.type === 'reset' ? input : { ...input, + gesture: { ...input.gesture, defaultPrevented: input.gesture.defaultPrevented || consumed }, + consume: () => { consumed = true; input.consume() } }) + } catch (error) { + console.error('Fixed shortcut handler failed', error) + } + } + } + + describeBinding(binding: Parameters[0]): ReturnType { + const normalized = binding === null ? null : normalizeBinding(binding, this.platform) + return { binding: normalized, index: normalized === null ? null : bindingKey(normalized), + keys: presentBinding(normalized, this.platform).keys, + issue: normalized === null ? null : bindingIssue(normalized, this.runtime, this.platform), + conflicts: normalized === null ? [] : [...this.catalog.getSnapshot().filter(row => row.binding !== null + && overlappingBindings(row.binding, normalized)).map(row => row.id), + ...this.fixedCatalog.getSnapshot().filter(row => row.bindings.some(binding => overlappingBindings(binding, normalized))) + .map(row => row.id)] } + } + + private syncDefinitions(): void { + if (!this.active) return + if (this.adapter === undefined) { this.failRead(); return } + void this.adapter.get(this.registry.definitions()).then((snapshot) => { + this.connected = true + if (this.active && snapshot.sequence >= this.config.getSnapshot().sequence) this.registry.configure(snapshot) + }, () => { this.failRead() }) + } + + private failRead(): void { + if (this.active) this.registry.configure({ ...this.config.getSnapshot(), status: 'unreadable', error: 'read' }) + } + + /** + * Persist one reviewed operation while retaining accepted bindings on failure. + * @param args - edit and expected revision supplied by the editor. + * @returns classified save outcome and accepted snapshot. + */ + async edit(...args: Parameters): ReturnType { + if (this.adapter === undefined) return { status: 'unreadable', snapshot: this.config.getSnapshot() } + 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) { + return { status: 'write-failed', snapshot: this.config.getSnapshot() } + } + } + + async reload(): ReturnType { + 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() + } + + async recording(active: boolean): Promise { + if (this.adapter === undefined) throw new Error('Desktop shortcuts bridge unavailable') + await this.adapter.recording(active) + } + + /** + * Dispatch input through the window registry. + * @param args - gesture, current input owner, and synchronous consumer. + * @returns the command consumption result. + */ + dispatch(...args: Parameters): ReturnType { + return this.registry.dispatch(...args) + } +} diff --git a/packages/client/shortcuts/src/client/native.ts b/packages/client/shortcuts/src/client/native.ts new file mode 100644 index 0000000000..4907a0700c --- /dev/null +++ b/packages/client/shortcuts/src/client/native.ts @@ -0,0 +1,35 @@ +/** Desktop gestures use live focus, modal state, and verified iframe ownership. */ +import type { DesktopKeyboardApi, ShortcutConfigSnapshot } from '../protocol.ts' +import type { ShortcutRegistry } from './registry.ts' + +/** + * Route native menu, main-frame, and embedded-frame input through the shared command registry. + * @param window - trusted product document. + * @param keyboard - top-frame preload capability. + * @param registry - window-local command owner. + * @param snapshot - latest accepted configuration. + * @param reset - clears pending fixed sequences when native input bypasses DOM delivery. + * @returns disposer releasing native input. + */ +export function installNativeKeyboard(window: Window, keyboard: DesktopKeyboardApi, registry: ShortcutRegistry, + snapshot: () => ShortcutConfigSnapshot, reset?: () => void): () => void { + return keyboard.subscribe((input) => { + if (input.revision !== snapshot().revision) return + reset?.() + let target = window.document.activeElement + while (target?.shadowRoot?.activeElement != null) target = target.shadowRoot.activeElement + const top = [...window.document.querySelectorAll('[role="dialog"][aria-modal="true"], [role="menu"]')].at(-1) + const region = target?.closest('.xterm') ? 'terminal' as const + : target?.matches('input, textarea, select, [contenteditable="true"], [contenteditable=""]') ? 'editable' as const : 'page' as const + const context = { target, region, modal: top?.dataset.shortcutModal ?? (top === undefined ? null : 'other') } + if (input.kind === 'menu') { registry.invoke(input.commandId, context); return } + if (input.kind === 'keyboard') { + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, context, () => {}) + return + } + if (!(target instanceof HTMLIFrameElement) || !target.isConnected + || !target.matches('iframe[data-sidebar-browser-frame], iframe[data-html-preview]') + || input.frameName === '' || target.name !== input.frameName) return + registry.dispatch({ ...input, composing: false, defaultPrevented: false }, { ...context, source: 'iframe' }, () => {}) + }) +} diff --git a/packages/client/shortcuts/src/client/registry.ts b/packages/client/shortcuts/src/client/registry.ts new file mode 100644 index 0000000000..1f02be2b37 --- /dev/null +++ b/packages/client/shortcuts/src/client/registry.ts @@ -0,0 +1,186 @@ +/** Command registration, normalized default bindings, and synchronous dispatch. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindingIssue, bindingKey, effectiveShortcuts, initialShortcutConfig, isWebBindingAllowed, normalizeBinding, presentBinding, resolveShortcutDefault } from '../protocol.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutPlatform, ShortcutRuntime } from '../protocol.ts' +import type { ShortcutCatalogEntry, ShortcutCommand, ShortcutContext, ShortcutDispatch, ShortcutGesture, + ShortcutFixedCommand, ShortcutFixedCatalogEntry } from './types.ts' + +/** Application command registry; adapters own event listeners, feature plugins own actions. */ +export class ShortcutRegistry { + private readonly commands = new Map() + private readonly fixedCommands = new Map() + private readonly conflicts = new Map() + private readonly bindings = new Map() + private readonly state + /** Effective localized rows derived from the accepted configuration. */ + readonly catalog + /** Accepted preferences and visible read diagnostics. */ + readonly config + /** Fixed local operations whose owning plugins are mounted. */ + readonly fixedCatalog = createSnapshotStore([]) + + constructor(readonly runtime: ShortcutRuntime, readonly platform: ShortcutPlatform, + config: ShortcutConfigSnapshot = { ...initialShortcutConfig(), status: 'ready' }) { + this.state = createSnapshotStore<{ catalog: readonly ShortcutCatalogEntry[]; config: ShortcutConfigSnapshot }>({ catalog: [], config }) + this.catalog = { getSnapshot: () => this.state.getSnapshot().catalog, + subscribe: (listener: () => void) => this.state.subscribe(listener) } + this.config = { getSnapshot: () => this.state.getSnapshot().config, + subscribe: (listener: () => void) => this.state.subscribe(listener) } + } + + /** + * Return the serializable active catalog for storage validation. + * @returns definitions without callbacks or localized labels. + */ + definitions(): readonly ShortcutDefinition[] { + return [...[...this.commands.values()].map(({ id, defaults }) => ({ id, defaults })), + ...[...this.fixedCommands.values()].map(({ id, bindings }) => ({ id, defaults: {}, fixed: bindings }))] + } + + /** + * Register a read-only input action whose keys cannot be assigned to editable commands. + * @param command - owner-localized action and readable sequence. + * @returns idempotent disposer removing its reference row. + */ + registerFixed(command: ShortcutFixedCommand): () => void { + if (this.fixedCommands.has(command.id) || this.commands.has(command.id)) throw new Error(`Duplicate shortcut command: ${command.id}`) + for (const binding of command.bindings) normalizeBinding(binding, this.platform) + this.fixedCommands.set(command.id, command) + this.refreshLabels() + return () => { + if (this.fixedCommands.get(command.id) !== command) return + this.fixedCommands.delete(command.id) + this.refreshLabels() + } + } + + private refreshFixedLabels(): void { + this.fixedCatalog.set([...this.fixedCommands.values()].map(command => ({ + id: command.id, label: command.label(), keys: command.keys, group: command.group, + bindings: command.bindings.map(binding => normalizeBinding(binding, this.platform)), + }))) + } + + /** + * Publish accepted preferences and all derived labels atomically. + * @param config - storage owner's latest accepted snapshot. + */ + configure(config: ShortcutConfigSnapshot): void { + const current = this.config.getSnapshot() + if (config.revision === current.revision && config.status === current.status && config.error === current.error) return + this.refreshLabels(config) + } + + /** + * Register atomically after checking defaults for all supported platforms and shells. + * @param command - feature-owned command definition. + * @returns idempotent disposer removing both matching and catalog entries. + */ + register(command: ShortcutCommand): () => void { + if (this.commands.has(command.id) || this.fixedCommands.has(command.id)) throw new Error(`Duplicate shortcut command: ${command.id}`) + for (const runtime of ['desktop', 'web'] as const) { + for (const platform of ['macos', 'windows', 'linux'] as const) { + const candidate = resolveShortcutDefault(command, runtime, platform) + if (candidate === undefined) continue + const binding = normalizeBinding(candidate, platform) + if (runtime === 'web' && !isWebBindingAllowed(binding, platform)) { + throw new Error(`Unsupported Web shortcut: ${command.id}`) + } + if (bindingIssue(binding, runtime, platform) !== null) throw new Error(`Reserved shortcut default: ${command.id}`) + for (const existing of this.commands.values()) { + const other = resolveShortcutDefault(existing, runtime, platform) + if (other !== undefined && bindingKey(binding) === bindingKey(normalizeBinding(other, platform))) { + throw new Error(`Conflicting shortcut defaults: ${command.id} and ${existing.id} (${runtime}:${platform})`) + } + } + } + } + this.commands.set(command.id, command) + const availability = command.availability + const unsubscribe = availability?.subscribe(() => { + const previous = this.catalog.getSnapshot() + const row = previous.find(value => value.id === command.id) + const unavailableReason = availability.getSnapshot() + if (row === undefined || row.unavailableReason === unavailableReason) return + this.state.set({ config: this.config.getSnapshot(), + catalog: previous.map(value => value === row ? { ...value, unavailableReason } : value) }) + }) + this.refreshLabels() + return () => { + if (this.commands.get(command.id) !== command) return + unsubscribe?.() + this.commands.delete(command.id) + this.refreshLabels() + } + } + + /** + * Recompute effective bindings when preferences, commands, or locale change. + * @param config - accepted configuration, defaulting to the current snapshot. + */ + refreshLabels(config: ShortcutConfigSnapshot = this.config.getSnapshot()): void { + this.bindings.clear() + this.conflicts.clear() + const rows = effectiveShortcuts(this.definitions(), config.document, this.runtime, this.platform) + const catalog = rows.map((row) => { + const command = this.commands.get(row.id) as ShortcutCommand + const enabled = config.status !== 'loading' && row.issue === null && row.conflicts.length === 0 + if (enabled && row.binding !== null) this.bindings.set(bindingKey(row.binding), command) + if (config.status !== 'loading' && row.issue === null && row.conflicts.length > 0 && row.binding !== null) { + this.conflicts.set(bindingKey(row.binding), command) + } + return { ...row, label: command.label(), aliases: command.aliases, + unavailableReason: command.availability?.getSnapshot() ?? null, + ...presentBinding(row.binding, this.platform), + aria: enabled ? presentBinding(row.binding, this.platform).aria : undefined } + }) + this.state.set({ config, catalog }) + this.refreshFixedLabels() + } + + /** + * Invoke a native menu selection independently of its optional key binding. + * @param id - registered product command. + * @param context - live input owner and modal state. + */ + invoke(id: ShortcutCommandId, context: ShortcutContext): void { + const command = this.commands.get(id) + if (command === undefined || (context.modal !== null && !command.modals.includes(context.modal))) return + const result = command.resolve({ ...context, source: 'menu' }) + if (result.status === 'handled') result.run() + } + + /** + * Windows/macOS Desktop bindings override local regions and modal controls independently of their configuration source. + * @param gesture - normalized DOM/native input facts. + * @param context - synchronous input and modal owner. + * @param consume - adapter's preventDefault, called before business execution. + * @returns handled, blocked with a reason, or pass for local/system input. + */ + dispatch(gesture: ShortcutGesture, context: ShortcutContext, consume: () => void): ShortcutDispatch { + if (gesture.defaultPrevented || gesture.composing) return { status: 'pass' } + const modifiers = (['control', 'alt', 'shift', 'meta'] as const).filter(value => gesture[value]) + const pairKey = bindingKey({ code: gesture.code, modifiers, + ...(gesture.secondCode === undefined ? {} : { secondCode: gesture.secondCode }) }) + const key = this.bindings.has(pairKey) || this.conflicts.has(pairKey) ? pairKey : bindingKey({ code: gesture.code, modifiers }) + const command = this.bindings.get(key) ?? this.conflicts.get(key) + const priority = this.runtime === 'desktop' && (this.platform === 'windows' || this.platform === 'macos') + if (command === undefined || (!priority && !command.regions.includes(context.region))) return { status: 'pass' } + if (!priority && context.region === 'terminal' && gesture.control && !gesture.meta && !gesture.alt && !gesture.shift + && (gesture.code === 'KeyW' || gesture.code === 'KeyR')) return { status: 'pass' } + if (!this.bindings.has(key)) { + consume() + return { status: 'blocked', commandId: command.id, reason: 'conflict' } + } + if (!priority && context.modal !== null && !command.modals.includes(context.modal)) { + consume() + return { status: 'blocked', commandId: command.id, reason: 'modal' } + } + const resolution = command.resolve(context) + if (resolution.status === 'pass') return resolution + consume() + if (resolution.status === 'blocked') return { ...resolution, commandId: command.id } + if (!gesture.repeat) resolution.run() + return { status: 'handled', commandId: command.id } + } +} diff --git a/packages/client/shortcuts/src/client/storage.ts b/packages/client/shortcuts/src/client/storage.ts new file mode 100644 index 0000000000..3982c6daa4 --- /dev/null +++ b/packages/client/shortcuts/src/client/storage.ts @@ -0,0 +1,42 @@ +/** 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. */ +export const SHORTCUT_STORAGE_KEY = 'dsh.keybindings.v1' + +/** + * Connect localStorage and same-origin external updates to the shared transaction coordinator. + * @param window - owning browser window. + * @param platform - visiting device platform. + * @param publish - accepts complete configuration snapshots. + * @returns adapter and lifecycle disposal. + */ +export function webShortcutStorage(window: Window, platform: ShortcutPlatform, + publish: (snapshot: ShortcutConfigSnapshot) => void): DesktopShortcutsApi & { dispose(): void } { + 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() + } + 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), + subscribe: () => () => {}, recording: async () => {}, + dispose: () => { window.removeEventListener('storage', changed); persistence.dispose() }, + } +} + +/** + * Read the origin-scoped preload capability; a missing bridge is an explicit configuration failure. + * @param window - product window. + * @returns the restricted Desktop API, or undefined while the preload is unavailable. + */ +export function desktopShortcutStorage(window: Window): DesktopShortcutsApi | undefined { + return (window as Window & { dshDesktop?: { shortcuts?: DesktopShortcutsApi } }).dshDesktop?.shortcuts +} diff --git a/packages/client/shortcuts/src/client/types.ts b/packages/client/shortcuts/src/client/types.ts new file mode 100644 index 0000000000..645a3c453e --- /dev/null +++ b/packages/client/shortcuts/src/client/types.ts @@ -0,0 +1,152 @@ +/** 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' + +/** Local input owner resolved before an application command. */ +export type ShortcutRegion = 'page' | 'editable' | 'terminal' +/** Synchronous DOM context; owners capture business targets in resolve(). */ +export interface ShortcutContext { + readonly source?: 'keyboard' | 'menu' | 'iframe' + readonly region: ShortcutRegion + readonly modal: string | null + /** Original input element, or null when no document target is available. */ + readonly target: Element | null +} +/** A resolved action captures its target; failures remain the owner's responsibility. */ +export type ShortcutResolution = { status: 'handled'; run(): void } + | { status: 'blocked'; reason: string } + | { status: 'pass' } +/** One command's labels, defaults, input ownership, and target resolver. */ +export interface ShortcutCommand { + readonly id: ShortcutCommandId + readonly label: () => string + readonly aliases: readonly string[] + readonly defaults: Readonly>> + readonly regions: readonly ShortcutRegion[] + /** Modal identifiers in which this command may resolve; others block it. */ + readonly modals: readonly string[] + /** Current localized refusal reason; resolving an action still rechecks its target. */ + readonly availability?: ObservableSnapshot + resolve(context: ShortcutContext): ShortcutResolution +} +/** JSON-compatible catalog row, shared by reference, tooltips, and controls. */ +export interface ShortcutCatalogEntry { + readonly id: ShortcutCommandId + readonly label: string + readonly aliases: readonly string[] + readonly keys: readonly string[] + readonly aria: string | undefined + readonly binding: NormalizedBinding | null + readonly modified: boolean + readonly conflicts: readonly ShortcutCommandId[] + readonly issue: BindingIssue | null + readonly unavailableReason: string | null +} +/** Keyboard adapter input, with composition and local consumption already resolved. */ +export interface ShortcutGesture { + readonly code: string + /** Second physical key currently held with code; matching ignores pair order. */ + readonly secondCode?: string + readonly control: boolean + readonly alt: boolean + readonly shift: boolean + readonly meta: boolean + readonly repeat: boolean + readonly composing: boolean + readonly defaultPrevented: boolean +} +/** Local fixed input delivered after controls, or a sequence-invalidating interaction. */ +export type ShortcutFixedInput = { + readonly type: 'keydown' + readonly gesture: ShortcutGesture + readonly context: ShortcutContext + consume(): void +} | { readonly type: 'reset' } +/** Read-only input action contributed while its owning feature is mounted. */ +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[] + readonly group: 'input' | 'menus' | 'approval' +} +/** Localized fixed-action row; its key sequence is not an editable binding. */ +export interface ShortcutFixedCatalogEntry { + readonly id: ShortcutCommandId + readonly label: string + readonly keys: readonly string[] + readonly bindings: readonly NormalizedBinding[] + readonly group: ShortcutFixedCommand['group'] +} +/** Dispatch consumption is independent of later business-operation success. */ +export type ShortcutDispatch = { status: 'handled'; commandId: ShortcutCommandId } + | { status: 'blocked'; commandId: ShortcutCommandId; reason: string } + | { status: 'pass' } +/** Feature-facing keyboard registry. */ +export interface Shortcuts { + /** Request native close after the page owner resolves a desktop fallback. */ + closeWindow(): void + readonly runtime: ShortcutRuntime + readonly platform: ShortcutPlatform + readonly catalog: ObservableSnapshot + readonly config: ObservableSnapshot + readonly fixedCatalog: ObservableSnapshot + readonly stopSequenceMs: number + /** + * Contribute one fixed action and reserve its keys against editable bindings. + * @param command - owner-localized name, group, readable keys, and reserved physical combinations. + * @returns idempotent disposer removing the fixed row. + */ + registerFixed(command: ShortcutFixedCommand): () => void + /** + * Observe locally arbitrated input and interaction resets for fixed sequences. + * @param listener - owner handler; composing or consumed input must not trigger actions. + * @returns disposer releasing the handler. + */ + observeFixedInput(listener: (input: ShortcutFixedInput) => void): () => void + /** + * Describe a candidate using the device's physical-key and reservation rules; invalid codes throw. + * @param binding - candidate combination, or null for an unbound command. + * @returns canonical binding, matching index, visible keys, rejection reason, and overlapping editable/fixed command IDs. + */ + describeBinding(binding: ShortcutBinding | null): { + binding: NormalizedBinding | null + index: string | null + keys: readonly string[] + issue: BindingIssue | null + conflicts: readonly ShortcutCommandId[] + } + /** + * Save a reviewed edit; failures preserve both accepted preferences and the caller's draft. + * @param edit - set, clear, reset, or explicit recovery operation. + * @param revision - accepted revision reviewed by the user. + * @returns persistence outcome and current accepted configuration. + */ + edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise + /** + * Retry reading local preferences without discarding the last accepted configuration. + * @returns accepted configuration with visible read diagnostics. + */ + reload(): Promise + /** + * 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. + * @returns completion of the native interception update. + */ + recording(active: boolean): Promise + /** + * Register a command; duplicate ids and normalized defaults throw. + * @param command - feature-owned labels, defaults, and target resolver. + * @returns disposer removing the binding and catalog entry. + */ + register(command: ShortcutCommand): () => void + /** + * Resolve and consume synchronously; call consume before executing an action. + * @param gesture - key, modifiers, repeat, and local input guards. + * @param context - current input and modal owner. + * @param consume - adapter callback consuming the original event. + * @returns handled, blocked with an owner reason, or pass for local input. + */ + dispatch(gesture: ShortcutGesture, context: ShortcutContext, consume: () => void): ShortcutDispatch +} diff --git a/packages/client/shortcuts/src/config.ts b/packages/client/shortcuts/src/config.ts new file mode 100644 index 0000000000..ca8c838d0c --- /dev/null +++ b/packages/client/shortcuts/src/config.ts @@ -0,0 +1,13 @@ +/** Window-local timing accepted by the Host and browser keyboard service. */ +import z from '@deepseek-ai/schemastery' + +/** Fixed shortcut sequence settings. */ +export interface Config { + /** Maximum interval between independent Escape presses for stopping a reply, in milliseconds. */ + stopSequenceMs: number +} + +/** Validated deployment settings for fixed keyboard sequences. */ +export const Config: z, Config> = z.object({ + stopSequenceMs: z.natural().min(1).max(2_147_483_646).default(500), +}) diff --git a/packages/client/shortcuts/src/configuration.ts b/packages/client/shortcuts/src/configuration.ts new file mode 100644 index 0000000000..84ebefbf01 --- /dev/null +++ b/packages/client/shortcuts/src/configuration.ts @@ -0,0 +1,241 @@ +/** Validated preference documents and deterministic conflict resolution, without browser dependencies. */ +import { bindingKey, isWebBindingAllowed, normalizeBinding } from './binding.ts' +import type { NormalizedBinding, ShortcutBinding, ShortcutCommandId, ShortcutPlatform, 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>>>> +} +/** Serializable command definitions accepted from the trusted product frame. */ +export interface ShortcutDefinition { + readonly id: ShortcutCommandId + readonly defaults: Readonly>> + /** Fixed actions reserve these combinations instead of exposing an editable default. */ + readonly fixed?: readonly ShortcutBinding[] +} +/** Validation failures select localized copy in the UI. */ +export type BindingIssue = 'reserved' | 'unsupported-browser' | 'modifier-required' | 'unsupported-key' +/** A conflicting row keeps its requested binding visible but cannot execute it. */ +export interface EffectiveShortcut { + readonly id: ShortcutCommandId + readonly binding: NormalizedBinding | null + readonly modified: boolean + readonly conflicts: readonly ShortcutCommandId[] + readonly issue: BindingIssue | null +} +/** 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' } + +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) +const commandPattern = /^[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)+$/u + +/** + * Validate JSON binding fields before normalization; unknown fields are rejected to prevent lossy rewrites. + * @param value - file or IPC input. + * @returns a binding with a supported physical code, or null for explicit removal. + */ +export function parseBinding(value: unknown): ShortcutBinding | null { + if (value === null) return null + if (!record(value) || Object.keys(value).some(key => key !== 'code' && key !== 'secondCode' && key !== 'modifiers') + || typeof value.code !== 'string' || !Array.isArray(value.modifiers) + || (Object.hasOwn(value, 'secondCode') && typeof value.secondCode !== 'string') + || !value.modifiers.every(modifier => typeof modifier === 'string' && ['primary', 'control', 'alt', 'shift', 'meta'].includes(modifier))) { + throw new Error('Invalid shortcut binding') + } + const binding = value as unknown as ShortcutBinding + normalizeBinding(binding, 'windows') + return binding +} + +/** + * Decode the complete document while preserving dormant command overrides. + * @param raw - stored JSON, or null for a missing document. + * @returns the accepted document or a classified read failure. + */ +export function parseShortcutDocument(raw: string | null): ShortcutDocument | 'invalid' | 'future' { + if (raw === null) return { schemaVersion: 1, profiles: {} } + try { + const value: unknown = JSON.parse(raw) + if (!record(value)) return 'invalid' + if (typeof value.schemaVersion === 'number' && value.schemaVersion > 2) return 'future' + if ((value.schemaVersion !== 1 && value.schemaVersion !== 2) || !record(value.profiles) + || Object.keys(value).some(key => key !== 'schemaVersion' && key !== 'profiles')) return 'invalid' + for (const [profile, overrides] of Object.entries(value.profiles)) { + if (!/^(desktop|web):(macos|windows|linux)$/u.test(profile) || !record(overrides)) return 'invalid' + for (const [id, binding] of Object.entries(overrides)) { + if (!commandPattern.test(id)) return 'invalid' + const parsed = parseBinding(binding) + if (value.schemaVersion === 1 && parsed?.secondCode !== undefined) return 'invalid' + } + } + return value as unknown as ShortcutDocument + } catch (_error) { + // Invalid JSON and invalid binding fields both preserve the original document. + return 'invalid' + } +} + +/** + * Check system, editor, and browser reservations using expanded physical modifiers. + * @param binding - normalized candidate. + * @param runtime - receiving application shell. + * @param platform - receiving device. + * @returns the rejection reason, or null when this combination is allowed. + */ +export function bindingIssue(binding: NormalizedBinding, runtime: ShortcutRuntime, platform: ShortcutPlatform): BindingIssue | null { + const { code, modifiers } = binding + if (runtime === 'desktop' && (platform === 'windows' || platform === 'macos')) return null + if (binding.secondCode !== undefined) return 'unsupported-key' + if (modifiers.length === 0 || modifiers.every(modifier => modifier === 'shift')) return 'modifier-required' + if ((platform === 'windows' || platform === 'macos') && modifiers.length >= 3) return null + if (runtime === 'web' && (platform === 'windows' || platform === 'macos') && isWebBindingAllowed(binding, platform)) return null + const primary = modifiers.includes(platform === 'macos' ? 'meta' : 'control') + if (['Escape', 'Tab', 'Space', 'Backspace', 'Delete', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(code) + || (code === 'Enter' && !modifiers.includes('alt')) + || (primary && ['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyQ', 'KeyH'].includes(code)) + || (primary && code === 'KeyA' && !modifiers.includes('shift')) + || (platform !== 'macos' && (modifiers.includes('meta') || (modifiers.includes('alt') && ['F4', 'F2'].includes(code)))) + || (platform === 'macos' && modifiers.includes('control') && modifiers.includes('meta')) + || (platform === 'macos' && modifiers.includes('alt') && !primary)) return 'reserved' + if (runtime === 'web' && !isWebBindingAllowed(binding, platform)) return 'unsupported-browser' + return null +} + +/** + * 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. + * @param runtime - receiving shell. + * @param platform - receiving device. + * @returns the default 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] +} + +/** + * Resolve overrides and conflicts independently of registration order. Explicit overrides displace defaults. + * @param definitions - active commands. + * @param document - accepted preferences. + * @param runtime - receiving shell. + * @param platform - receiving device. + * @returns every active command, including unavailable conflicting bindings. + */ +export function effectiveShortcuts(definitions: readonly ShortcutDefinition[], document: ShortcutDocument, + runtime: ShortcutRuntime, platform: ShortcutPlatform): readonly EffectiveShortcut[] { + const overrides = document.profiles[`${runtime}:${platform}`] ?? {} + const fixed = definitions.flatMap(row => row.fixed?.map(binding => ({ id: row.id, binding: normalizeBinding(binding, platform) })) ?? []) + const rows = definitions.filter(row => row.fixed === undefined).map(({ id, defaults }) => { + const modified = Object.hasOwn(overrides, id) + const candidate = modified ? overrides[id] : resolveShortcutDefault({ id, defaults }, runtime, platform) + const binding = candidate == null ? null : normalizeBinding(candidate, platform) + return { id, binding, modified, issue: binding === null ? null : bindingIssue(binding, runtime, platform) } + }) + return rows.map((row) => { + const binding = row.binding + return { ...row, conflicts: binding === null ? [] : [...new Set([...rows.filter(other => other.id !== row.id + && other.binding !== null && other.issue === null && overlappingBindings(other.binding, binding) + && (!row.modified || other.modified)).map(other => other.id), + ...fixed.filter(other => overlappingBindings(other.binding, binding)).map(other => other.id)])] } + }) +} + +/** + * Detect identical combinations or a single key contained in a two-key chord. + * @param left - normalized candidate. + * @param right - normalized occupied binding. + * @returns whether both bindings require the same modifiers and overlap. + */ +export function overlappingBindings(left: NormalizedBinding, right: NormalizedBinding): boolean { + if (left.modifiers.join('+') !== right.modifiers.join('+')) return false + if (left.secondCode !== undefined && right.secondCode !== undefined) return bindingKey(left) === bindingKey(right) + return [left.code, left.secondCode].some(code => code !== undefined && (code === right.code || code === right.secondCode)) +} + +/** + * Apply an edit without modifying other profiles or dormant overrides. + * @param document - accepted document. + * @param edit - validated operation. + * @param runtime - current shell. + * @param platform - current device. + * @returns the candidate document, pending conflict checks and durable storage. + */ +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 } } + } + return { schemaVersion, profiles: { ...document.profiles, [profile]: edit.type === 'reset-all' ? {} : overrides } } +} + +/** + * Validate a preference edit at the Desktop IPC boundary. + * @param value - untrusted renderer request. + * @returns the constrained operation; malformed requests throw. + */ +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 (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) } + throw new Error('Invalid shortcut edit') +} + +/** + * Validate the trusted product's serializable command catalog at IPC ingress. + * @param value - renderer-supplied active command definitions. + * @returns validated definitions; duplicate IDs/defaults and unsupported combinations throw. + */ +export function parseShortcutDefinitions(value: unknown): readonly ShortcutDefinition[] { + if (!Array.isArray(value)) throw new Error('Invalid shortcut catalog') + const ids = new Set() + const keys = new Set() + for (const entry of value) { + if (!record(entry) || typeof entry.id !== 'string' || !commandPattern.test(entry.id) || ids.has(entry.id) + || !record(entry.defaults) || Object.keys(entry).some(key => key !== 'id' && key !== 'defaults' && key !== 'fixed')) throw new Error('Invalid shortcut definition') + ids.add(entry.id) + if (Object.hasOwn(entry, 'fixed')) { + 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') + const binding = parseBinding(candidate) + if (binding === null) throw new Error('Invalid shortcut default') + } + } + const definitions = value as ShortcutDefinition[] + for (const entry of definitions) { + for (const runtime of ['desktop', 'web'] as const) { + for (const platform of ['macos', 'windows', 'linux'] as const) { + const binding = resolveShortcutDefault(entry, runtime, platform) + if (binding === undefined) continue + const normalized = normalizeBinding(binding, platform) + const key = `${runtime}:${platform}:${bindingKey(normalized)}` + if (keys.has(key) || bindingIssue(normalized, runtime, platform) !== null) throw new Error('Conflicting or reserved shortcut default') + keys.add(key) + } + } + } + return definitions +} diff --git a/packages/client/shortcuts/src/index.ts b/packages/client/shortcuts/src/index.ts new file mode 100644 index 0000000000..b65d6c51c2 --- /dev/null +++ b/packages/client/shortcuts/src/index.ts @@ -0,0 +1,17 @@ +/** Host configuration for window-local keyboard sequences. */ +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-host-webserver' +import type { Config } from './config.ts' + +export { Config } from './config.ts' + +/** + * Embed validated keyboard settings in product pages. + * @param ctx - Host context serving browser pages. + * @param config - sequence timing adopted when the page loads. + */ +export function apply(ctx: Context, config: Config): void { + ctx.on('webserver/index-inject', (table) => { + table.push({ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: config }) + }) +} diff --git a/packages/client/shortcuts/src/native.ts b/packages/client/shortcuts/src/native.ts new file mode 100644 index 0000000000..9b5628b05e --- /dev/null +++ b/packages/client/shortcuts/src/native.ts @@ -0,0 +1,29 @@ +/** Trusted product-window input exchanged with Electron's top-frame preload. */ +import type { ShortcutRevision } from './persistence.ts' +import type { ShortcutCommandId } from './binding.ts' + +/** Native input carries the accepted configuration identity; iframe names identify the focused embedding element. */ +export type DesktopShortcutInput = { + readonly revision: ShortcutRevision +} & ({ readonly kind: 'menu'; readonly commandId: ShortcutCommandId } + | { readonly kind: 'keyboard' | 'iframe' + readonly frameName: string + readonly code: string + readonly secondCode?: string + readonly control: boolean + readonly alt: boolean + readonly shift: boolean + readonly meta: boolean + readonly repeat: boolean }) + +/** Window-scoped capabilities exposed only to the product main frame. */ +export interface DesktopKeyboardApi { + /** Subscribe to verified native input. @param listener - current document consumer. @returns listener disposer. */ + subscribe(listener: (input: DesktopShortcutInput) => void): () => void + /** + * Close the owning window if its configuration is still current. + * @param revision - accepted configuration identity. + * @returns IPC completion. + */ + closeWindow(revision: ShortcutRevision): Promise +} diff --git a/packages/client/shortcuts/src/persistence.ts b/packages/client/shortcuts/src/persistence.ts new file mode 100644 index 0000000000..d0e5ac283a --- /dev/null +++ b/packages/client/shortcuts/src/persistence.ts @@ -0,0 +1,149 @@ +/** Serialized preference transactions; storage owners publish only accepted writes or read diagnostics. */ +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { Branded } from '@deepseek-ai/dsh-brand' +import { editShortcutDocument, effectiveShortcuts, parseShortcutDocument } from './configuration.ts' +import type { BindingIssue, ShortcutDefinition, ShortcutDocument, ShortcutEdit } from './configuration.ts' +import type { ShortcutCommandId, ShortcutPlatform, ShortcutRuntime } from './binding.ts' + +/** Opaque accepted-state identity, invalidated by configuration and catalog changes. */ +export type ShortcutRevision = Branded<'ShortcutRevision'> +/** Read state retains the last accepted document on failure; loading does not enable commands. */ +export interface ShortcutConfigSnapshot { + readonly revision: ShortcutRevision + /** Monotonic within one storage-owner lifetime; clients discard out-of-order IPC replies. */ + readonly sequence: number + readonly document: ShortcutDocument + readonly status: 'loading' | 'ready' | 'unreadable' + readonly error: 'read' | 'invalid' | 'future' | null + readonly usingDefaults: boolean +} +/** Save failures never replace effective preferences. */ +export interface ShortcutSaveResult { + readonly status: 'saved' | 'stale' | 'unreadable' | 'write-failed' | 'not-ready' | 'conflict' + readonly snapshot: ShortcutConfigSnapshot + readonly issue?: BindingIssue + readonly conflicts?: readonly ShortcutCommandId[] +} +/** Storage adapter owns path/origin isolation and retention of recovery backups. */ +export interface ShortcutStorage { + read(): string | null | Promise + write(raw: string): void | Promise + backup(raw: string): void | Promise +} +/** Restricted Desktop preload API; file paths and accelerators never cross from Renderer. */ +export interface DesktopShortcutsApi { + get(definitions: readonly ShortcutDefinition[]): Promise + reload(): Promise + edit(edit: ShortcutEdit, revision: ShortcutRevision): Promise + subscribe(listener: (snapshot: ShortcutConfigSnapshot) => void): () => void + recording(active: boolean): Promise +} + +/** + * Create a disabled initial snapshot for asynchronous adapter startup. + * @returns a fresh configuration with no accepted persisted state. + */ +export function initialShortcutConfig(): ShortcutConfigSnapshot { + return { revision: randomUUID() as ShortcutRevision, sequence: 0, document: { schemaVersion: 1, profiles: {} }, + status: 'loading', error: null, usingDefaults: true } +} + +/** Single-writer coordinator shared by localStorage and Electron's atomic file adapter. */ +export class ShortcutPersistence { + private snapshot = initialShortcutConfig() + private raw: string | null | undefined + private queue: Promise = Promise.resolve() + private definitions: readonly ShortcutDefinition[] | null = null + private active = true + + constructor(private readonly storage: ShortcutStorage, private readonly runtime: ShortcutRuntime, + private readonly platform: ShortcutPlatform, private readonly rereadBeforeWrite: boolean, + private readonly publish: (snapshot: ShortcutConfigSnapshot) => void) {} + + /** + * Install or revoke a product catalog and invalidate drafts from its previous lifetime. + * @param definitions - current trusted definitions, or null while the product is not ready. + */ + setDefinitions(definitions: readonly ShortcutDefinition[] | null): void { + this.definitions = definitions + this.accept({ ...this.snapshot }) + } + + /** Stop accepting edits or publishing late completions. */ + dispose(): void { this.active = false } + + /** + * Read the current file; failures retain the last accepted document and disable ordinary writes. + * @returns the accepted snapshot or diagnostic snapshot. + */ + reload(): Promise { + 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 { + return this.serialize(async () => { + if (this.rereadBeforeWrite || edit.type === 'recover') 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 ((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) + && (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 + if (invalid !== undefined || displaced !== undefined) return { ...result('conflict'), + ...(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 + this.accept({ ...this.snapshot, document, status: 'ready', error: null, usingDefaults: false }) + return result('saved') + } catch (_error) { + // The adapter retains the previous complete document when a write fails. + return result('write-failed') + } + }) + } + + private serialize(operation: () => Promise): Promise { + const next = this.queue.then(operation) + this.queue = next.catch(() => undefined) + return next + } + + private accept(snapshot: ShortcutConfigSnapshot): void { + this.snapshot = { ...snapshot, sequence: this.snapshot.sequence + 1, revision: randomUUID() as ShortcutRevision } + if (!this.active) return + try { this.publish(this.snapshot) } catch (error) { console.error('Shortcut configuration subscriber failed:', error) } + } + + private async read(): Promise { + let raw: string | null + try { + raw = await this.storage.read() + } catch (_error) { + if (this.snapshot.error !== 'read') this.accept({ ...this.snapshot, status: 'unreadable', error: 'read' }) + return this.snapshot + } + if (raw === this.raw && this.snapshot.error !== 'read') return this.snapshot + this.raw = raw + const document = parseShortcutDocument(raw) + if (typeof document === 'string') this.accept({ ...this.snapshot, status: 'unreadable', error: document }) + else this.accept({ ...this.snapshot, document, status: 'ready', error: null, usingDefaults: raw === null }) + return this.snapshot + } +} diff --git a/packages/client/shortcuts/src/protocol.ts b/packages/client/shortcuts/src/protocol.ts new file mode 100644 index 0000000000..fad8ab60bd --- /dev/null +++ b/packages/client/shortcuts/src/protocol.ts @@ -0,0 +1,5 @@ +/** Physical-key and versioned preference protocol shared by Client and Desktop main. */ +export * from './binding.ts' +export * from './configuration.ts' +export * from './persistence.ts' +export * from './native.ts' diff --git a/packages/client/shortcuts/tests/config.host.spec.ts b/packages/client/shortcuts/tests/config.host.spec.ts new file mode 100644 index 0000000000..13ad7bc00a --- /dev/null +++ b/packages/client/shortcuts/tests/config.host.spec.ts @@ -0,0 +1,17 @@ +import { Context } from '@deepseek-ai/cordis' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { expect, it } from 'vitest' +import { Config, apply } from '../src/index.ts' + +it('validates positive sequence timing and publishes it through the product page injection', async () => { + expect(Config({}).stopSequenceMs).toBe(500) + expect(Config({ stopSequenceMs: 2_147_483_646 }).stopSequenceMs).toBe(2_147_483_646) + for (const stopSequenceMs of [0, -1, 1.5, 2_147_483_647, Infinity, NaN]) expect(() => Config({ stopSequenceMs })).toThrow() + const ctx = new Context() + try { + apply(ctx, Config({ stopSequenceMs: 800 })) + const table: IndexInjection[] = [] + await ctx.parallel('webserver/index-inject', table) + expect(table).toEqual([{ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: { stopSequenceMs: 800 } }]) + } finally { await ctx.fiber.dispose() } +}) diff --git a/packages/client/shortcuts/tests/configuration.client.spec.ts b/packages/client/shortcuts/tests/configuration.client.spec.ts new file mode 100644 index 0000000000..a7678d4486 --- /dev/null +++ b/packages/client/shortcuts/tests/configuration.client.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest' +import { bindingIssue, editShortcutDocument, effectiveShortcuts, normalizeBinding, parseBinding, parseShortcutDefinitions, + parseShortcutDocument, parseShortcutEdit } from '../src/protocol.ts' +import type { ShortcutCommandId, ShortcutDefinition, ShortcutDocument } from '../src/protocol.ts' + +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') } }, +] + +describe('preference document validation and conflict resolution', () => { + it('distinguishes inherited, cleared, reset, and dormant preferences without touching other profiles', () => { + const document: ShortcutDocument = { schemaVersion: 1, profiles: { + 'desktop:macos': { 'dormant.command': primary('KeyU'), 'test.one': null }, + 'web:windows': { 'test.one': primary('Slash') }, + } } + expect(parseShortcutDocument(JSON.stringify(document))).toEqual(document) + expect(effectiveShortcuts(definitions, document, 'desktop', 'macos')[0]).toMatchObject({ modified: true, binding: null }) + const reset = editShortcutDocument(document, { type: 'reset', id: id('test.one') }, 'desktop', 'macos') + expect(reset.profiles['desktop:macos']).toEqual({ 'dormant.command': primary('KeyU') }) + expect(effectiveShortcuts(definitions, reset, 'desktop', 'macos')[0]).toMatchObject({ modified: false, binding: { code: 'KeyB' } }) + expect(editShortcutDocument(document, { type: 'reset-all' }, 'desktop', 'macos').profiles) + .toEqual({ 'desktop:macos': {}, 'web:windows': document.profiles['web:windows'] }) + }) + + it.each(['{', 'null', '[]', '{"schemaVersion":0,"profiles":{}}', '{"schemaVersion":1,"profiles":{"alien":{}}}', + '{"schemaVersion":1,"profiles":{"web:macos":{"bad":null}}}', '{"schemaVersion":1,"profiles":{},"extra":1}', + '{"schemaVersion":1,"profiles":{"web:macos":{"test.one":{"code":"KeyB","modifiers":["bogus"]}}}}'])('retains invalid documents: %s', (raw) => { + expect(parseShortcutDocument(raw)).toBe('invalid') + }) + it('refuses future versions without interpreting their fields', () => { + expect(parseShortcutDocument('{"schemaVersion":3,"profiles":17}')).toBe('future') + expect(parseShortcutDocument(null)).toEqual({ schemaVersion: 1, profiles: {} }) + expect(() => parseBinding({ code: 'KeyB', modifiers: ['primary'], extra: true })).toThrow() + expect(() => parseBinding({ code: 'Unknown', modifiers: [] })).toThrow() + }) + + it('lets explicit overrides displace new defaults, but disables both conflicting explicit overrides', () => { + const document: ShortcutDocument = { schemaVersion: 1, profiles: { 'desktop:windows': { 'test.one': primary('KeyJ') } } } + const rows = effectiveShortcuts(definitions, document, 'desktop', 'windows') + expect(rows.map(row => row.conflicts)).toEqual([[], [id('test.one')]]) + const both = editShortcutDocument(document, { type: 'set', id: id('test.two'), binding: { code: 'KeyJ', modifiers: ['control'] } }, 'desktop', 'windows') + expect(effectiveShortcuts([...definitions].reverse(), both, 'desktop', 'windows').map(row => row.conflicts)) + .toEqual([[id('test.one')], [id('test.two')]]) + }) + + it('keeps Linux desktop and browser reservations', () => { + const platform = 'linux' + const check = (code: string, modifiers: readonly ('primary' | 'control' | 'alt' | 'shift' | 'meta')[], runtime: 'web' | 'desktop' = 'desktop') => + bindingIssue(normalizeBinding({ code, modifiers }, platform), runtime, platform) + for (const code of ['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyQ', 'Tab', 'Escape', 'Space', 'Enter']) expect(check(code, ['primary'])).toBe('reserved') + expect(check('KeyA', ['primary', 'shift'])).toBeNull() + expect(check('KeyA', ['primary'])).toBe('reserved') + expect(check('KeyJ', [])).toBe('modifier-required') + expect(check('KeyJ', ['shift'])).toBe('modifier-required') + expect(check('KeyJ', ['primary'])).toBeNull() + for (const code of ['KeyN', 'KeyP', 'KeyW', 'KeyR', 'KeyO', 'KeyJ']) expect(check(code, ['primary'], 'web')).toBe('unsupported-browser') + for (const code of ['Comma', 'Period']) expect(check(code, ['primary', 'shift'], 'web')).toBeNull() + expect(check('Slash', ['primary'], 'web')).toBeNull() + expect(check('F4', ['alt'], 'desktop')).toBe('reserved') + expect(check('KeyL', ['meta', 'control'], 'desktop')).toBe('reserved') + }) + + it.each(['macos', 'windows'] as const)('accepts %s desktop editing and system combinations, and single keys', (platform) => { + for (const [code, modifiers] of [ + ...['KeyC', 'KeyV', 'KeyX', 'KeyZ', 'KeyY', 'KeyA', 'KeyQ', 'KeyH', 'Tab', 'Space'].map(code => [code, ['primary']] as const), + ['KeyC', ['control']], ['KeyX', ['control']], ['KeyN', ['shift']], ['Tab', ['control']], ['Tab', ['shift']], + ['Escape', ['alt']], ['F4', ['alt']], ['KeyL', ['meta']], ['Delete', ['control', 'alt']], + ['KeyL', ['control', 'meta']], ['ArrowLeft', ['alt']], + ] as const) expect(bindingIssue(normalizeBinding({ code, modifiers }, platform), 'desktop', platform)).toBeNull() + expect(bindingIssue(normalizeBinding({ code: 'KeyC', modifiers: [] }, platform), 'desktop', platform)).toBeNull() + expect(bindingIssue(normalizeBinding(primary('KeyC'), platform), 'web', platform)).toBe('reserved') + let document: ShortcutDocument = { schemaVersion: 1, profiles: {} } + for (const entry of definitions) document = editShortcutDocument(document, { type: 'set', id: entry.id, binding: primary('KeyC') }, 'desktop', platform) + expect(effectiveShortcuts(definitions, document, 'desktop', platform).map(row => row.conflicts)) + .toEqual([[id('test.two')], [id('test.one')]]) + }) + + it.each([ + ['web', 'macos'], ['web', 'windows'], ['desktop', 'macos'], ['desktop', 'windows'], + ] as const)('accepts three and four distinct modifiers in %s on %s and still detects conflicts', (runtime, platform) => { + for (const modifiers of [ + ['control', 'alt', 'shift'], ['control', 'alt', 'meta'], ['control', 'shift', 'meta'], + ['alt', 'shift', 'meta'], ['control', 'alt', 'shift', 'meta'], + ] as const) { + for (const code of ['KeyN', 'KeyR', 'KeyX', 'Tab', 'F4']) { + expect(bindingIssue(normalizeBinding({ code, modifiers }, platform), runtime, platform)).toBeNull() + } + } + const binding = { code: 'KeyX', modifiers: ['primary', 'alt', 'shift'] as const } + let document: ShortcutDocument = { schemaVersion: 1, profiles: {} } + for (const entry of definitions) document = editShortcutDocument(document, { type: 'set', id: entry.id, binding }, runtime, platform) + expect(effectiveShortcuts(definitions, document, runtime, platform).map(row => row.conflicts)) + .toEqual([[id('test.two')], [id('test.one')]]) + expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: [] }, platform), runtime, platform)).toBe(runtime === 'desktop' ? null : 'modifier-required') + }) + + it('counts expanded distinct modifiers and keeps Linux reservations', () => { + expect(normalizeBinding({ code: 'KeyX', modifiers: ['primary', 'meta', 'shift'] }, 'macos').modifiers).toEqual(['shift', 'meta']) + expect(bindingIssue(normalizeBinding({ code: 'KeyN', modifiers: ['primary', 'control', 'control'] }, 'windows'), 'web', 'windows')).toBe('unsupported-browser') + 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] + 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() + }) + + 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() + } + 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() + }) +}) diff --git a/packages/client/shortcuts/tests/desktop-chords.client.spec.ts b/packages/client/shortcuts/tests/desktop-chords.client.spec.ts new file mode 100644 index 0000000000..39d998a640 --- /dev/null +++ b/packages/client/shortcuts/tests/desktop-chords.client.spec.ts @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +/** Desktop chords use overlapping physical presses and preserve local input ownership. */ +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { fireEvent } from '@testing-library/react' +import { installKeyboard } from '../src/client/dom.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import { bindingIssue, bindingKey, effectiveShortcuts, normalizeBinding, parseBinding, parseShortcutDocument, + parseShortcutDefinitions, presentBinding, ShortcutPersistence } from '../src/protocol.ts' +import type { ShortcutBinding, ShortcutCommandId, ShortcutPlatform, ShortcutRuntime } from '../src/protocol.ts' + +const id = 'test.action' as ShortcutCommandId +const other = 'test.other' as ShortcutCommandId +const pair = { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } as const +const definitions = [{ id, defaults: {} }, { id: other, defaults: {} }] +afterEach(() => { document.body.replaceChildren() }) + +function mount(binding: ShortcutBinding, platform: ShortcutPlatform = 'macos', runtime: ShortcutRuntime = 'desktop') { + const registry = new ShortcutRegistry(runtime, platform) + const run = vi.fn() + registry.register({ id, defaults: {}, label: () => 'Action', aliases: [], regions: ['page', 'editable', 'terminal'], + modals: ['settings'], resolve: () => ({ status: 'handled', run }) }) + registry.configure({ ...registry.config.getSnapshot(), revision: 'custom' as never, + document: { schemaVersion: 2, profiles: { [`${runtime}:${platform}`]: { [id]: binding } } } }) + const off = installKeyboard(window, registry) + onTestFinished(off) + const down = (code: string, extra: KeyboardEventInit = {}, target: Element = document.body) => { + const event = new KeyboardEvent('keydown', { code, key: code.replace('Key', ''), bubbles: true, cancelable: true, ...extra }) + target.dispatchEvent(event) + return event.defaultPrevented + } + const up = (code: string) => { fireEvent.keyUp(document.body, { code }) } + return { registry, run, down, up, off } +} + +it.each(['macos', 'windows'] as const)('runs %s overlapping chords in either order, never sequential presses or repeats', (platform) => { + const f = mount(pair, platform) + expect(f.down('KeyA')).toBe(false) + f.up('KeyA') + expect(f.down('KeyB')).toBe(false) + f.up('KeyB') + expect(f.run).not.toHaveBeenCalled() + expect(f.down('KeyB')).toBe(false) + expect(f.down('KeyA')).toBe(true) + expect(f.down('KeyB', { repeat: true })).toBe(true) + expect(f.run).toHaveBeenCalledOnce() + f.up('KeyA'); f.up('KeyB') + f.down('KeyA'); expect(f.down('KeyB')).toBe(true) + expect(f.run).toHaveBeenCalledTimes(2) + f.off() + f.up('KeyA'); f.up('KeyB'); f.down('KeyA'); f.down('KeyB') + expect(f.run).toHaveBeenCalledTimes(2) +}) + +it.each(['macos', 'windows'] as const)('executes %s single keys and exact four-modifier chords', (platform) => { + for (const code of ['KeyA', 'F1', 'ArrowLeft', 'Escape', 'Tab', 'Enter']) { + const f = mount({ code, modifiers: [] }, platform) + expect(f.down(code)).toBe(true) + f.down(code, { repeat: true }); f.up(code) + expect(f.run).toHaveBeenCalledOnce() + f.off() + } + const f = mount({ ...pair, modifiers: ['control', 'alt', 'shift', 'meta'] }, platform) + const keys = { ctrlKey: true, altKey: true, shiftKey: true, metaKey: true } + f.down('KeyA', keys); expect(f.down('KeyB', keys)).toBe(true) + expect(f.run).toHaveBeenCalledOnce() + f.up('MetaLeft') + expect(f.down('KeyB', { repeat: true })).toBe(false) + expect(f.run).toHaveBeenCalledOnce() +}) + + +it.each(['blur', 'focus', 'pointer', 'composition', 'modal', 'configuration', 'consumed', 'stopped'] as const)( + 'abandons partial chords after %s', (change) => { + const f = mount(pair) + f.down('KeyA') + switch (change) { + case 'blur': fireEvent.blur(window); break + case 'focus': fireEvent.focusIn(document.body); break + case 'pointer': fireEvent.pointerDown(document.body); break + case 'composition': fireEvent.compositionStart(document.body); fireEvent.compositionEnd(document.body); break + case 'modal': { + const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog); dialog.remove(); break + } + case 'configuration': f.registry.configure({ ...f.registry.config.getSnapshot(), revision: 'next' as never }); break + case 'consumed': { + document.body.addEventListener('keydown', (event) => { event.preventDefault() }, { once: true }) + f.down('KeyX'); break + } + case 'stopped': { + document.body.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + f.down('KeyX'); break + } + } + expect(f.down('KeyB')).toBe(false) + expect(f.run).not.toHaveBeenCalled() + }) + +it('does not form a two-key binding from three held ordinary keys', () => { + const f = mount(pair) + f.down('KeyC'); f.down('KeyA'); f.down('KeyB') + expect(f.run).not.toHaveBeenCalled() + f.up('KeyC'); f.down('KeyA', { repeat: true }) + expect(f.run).not.toHaveBeenCalled() +}) + +it.each([['web', 'macos'], ['web', 'windows'], ['web', 'linux'], ['desktop', 'linux']] as const)( + 'does not enable bare keys or chords in %s on %s', (runtime, platform) => { + for (const binding of [pair, { code: 'KeyA', modifiers: [] }, { ...pair, modifiers: ['control', 'alt', 'shift', 'meta'] }] as const) { + expect(bindingIssue(normalizeBinding(binding, platform), runtime, platform)).not.toBeNull() + const f = mount(binding, platform, runtime) + f.down('KeyA'); f.down('KeyB') + expect(f.run).not.toHaveBeenCalled() + f.off() + } + }) + +it('canonicalizes unordered pairs, rejects invalid main keys, and omits unsupported ARIA accelerators', () => { + const reversed = normalizeBinding({ ...pair, code: 'KeyB', secondCode: 'KeyA' }, 'macos') + expect(reversed).toEqual(pair) + expect(bindingKey(reversed)).toBe('KeyA+KeyB') + expect(presentBinding(reversed, 'macos')).toEqual({ keys: ['A', 'B'], aria: undefined }) + expect(presentBinding(reversed, 'windows')).toEqual({ keys: ['A', 'B'], aria: undefined }) + expect(presentBinding({ ...reversed, modifiers: ['control', 'shift'] }, 'windows').keys) + .toEqual(['Ctrl', '+', 'Shift', '+', 'A', 'B']) + for (const input of [ + { ...pair, secondCode: 'KeyA' }, { ...pair, secondCode: 'ShiftLeft' }, { ...pair, thirdCode: 'KeyC' }, + { ...pair, secondCode: ['KeyB', 'KeyC'] }, { code: 'MetaLeft', modifiers: ['control'] }, { modifiers: ['meta'] }, + ]) expect(() => parseBinding(input)).toThrow() +}) + +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, () => {}) + onTestFinished(() => { store.dispose() }) + store.setDefinitions(definitions) + let state = await store.reload() + expect(raw).toBe(original) + const saved = await store.edit({ type: 'set', id, binding: pair }, state.revision) + expect(saved.status).toBe('saved') + expect(JSON.parse(raw)).toEqual({ schemaVersion: 2, profiles: { 'web:macos': { 'dormant.web': null }, [`desktop:${platform}`]: { [id]: pair } } }) + expect(parseShortcutDocument(raw)).toEqual(saved.snapshot.document) + expect(parseShortcutDocument(raw.replace('"schemaVersion": 2', '"schemaVersion": 1'))).toBe('invalid') + state = saved.snapshot + for (const code of ['KeyA', 'KeyB']) { + const rejected = await store.edit({ type: 'set', id: other, binding: { code, modifiers: [] } }, state.revision) + expect(rejected).toMatchObject({ status: 'conflict', conflicts: [id] }) + } + const cleared = await store.edit({ type: 'reset-all' }, state.revision) + const single = await store.edit({ type: 'set', id, binding: { code: 'KeyB', modifiers: [] } }, cleared.snapshot.revision) + expect((await store.edit({ type: 'set', id: other, binding: pair }, single.snapshot.revision)).status).toBe('conflict') + const overlapping = { schemaVersion: 2 as const, profiles: { [`desktop:${platform}`]: { [id]: pair, [other]: { code: 'KeyA', modifiers: [] } } } } + expect(effectiveShortcuts(definitions, overlapping, 'desktop', platform).map(row => row.conflicts)).toEqual([[other], [id]]) +}) + +it.each(['macos', 'windows'] as const)('rejects direct %s writes that overlap fixed actions and disables previously saved conflicts', async (platform) => { + 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 store = new ShortcutPersistence(storage, 'desktop', platform, false, () => {}) + onTestFinished(() => { store.dispose() }) + store.setDefinitions(catalog) + const state = await store.reload() + 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 } } }, + 'desktop', platform).find(row => row.id === id)?.conflicts).toEqual([fixedId]) + } + expect(storage.write).not.toHaveBeenCalled() + 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') +}) + +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() +}) diff --git a/packages/client/shortcuts/tests/dom.client.spec.ts b/packages/client/shortcuts/tests/dom.client.spec.ts new file mode 100644 index 0000000000..278c960118 --- /dev/null +++ b/packages/client/shortcuts/tests/dom.client.spec.ts @@ -0,0 +1,189 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { fireEvent } from '@testing-library/dom' +import { installKeyboard, detectEnvironment } from '../src/client/dom.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { ShortcutFixedInput } from '../src/client/types.ts' +import type { ShortcutCommandId } from '../src/protocol.ts' + +let dispose: (() => void) | undefined +afterEach(() => { dispose?.(); document.body.replaceChildren(); vi.restoreAllMocks() }) + +function mount(runtime: 'desktop' | 'web' = 'web', platform: 'macos' | 'windows' = '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'] } }, + regions: ['page', 'editable'], modals: ['shortcuts'], resolve: () => ({ status: 'handled', run }) }) + dispose = installKeyboard(window, registry) + const input = document.createElement('input'); document.body.append(input); input.focus() + return { registry, input, run } +} +const press = (target: EventTarget, extra: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { key: '/', code: 'Slash', ctrlKey: true, bubbles: true, cancelable: true, ...extra }) + target.dispatchEvent(event) + return event +} + +it.each(['macos', 'windows'] as const)('leaves configurable %s shortcuts to native input while retaining DOM fixed-action observation', (platform) => { + const { registry, input, run } = mount('desktop', platform) + dispose?.() + const fixed = vi.fn<(input: ShortcutFixedInput) => void>() + dispose = installKeyboard(window, registry, fixed, true) + expect(press(input, { ctrlKey: platform === 'windows', metaKey: platform === 'macos' }).defaultPrevented).toBe(false) + registry.configure({ ...registry.config.getSnapshot(), revision: 'pair' as never, + document: { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { + 'shortcuts.open': { code: 'KeyF', secondCode: 'KeyG', modifiers: [] }, + } } } }) + press(input, { code: 'KeyF', key: 'f', ctrlKey: false }) + press(input, { code: 'KeyG', key: 'g', ctrlKey: false }) + expect(run).not.toHaveBeenCalled() + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { code: 'KeyG' } }) +}) + +const optionCommandN = { key: 'Dead', code: 'KeyN', ctrlKey: false, altKey: true, metaKey: true } as const +const commandSlash = { ctrlKey: false, metaKey: true } as const +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: [], + resolve: () => ({ status: 'handled', run: newSession }) }) + return { ...fixture, newSession, unregister } +} + +it('consumes bound macOS Web Option+Command+N without leaving dead-key state on repeats or subsequent commands', () => { + const { input, newSession, run } = mountMacNewSession() + expect(press(input, optionCommandN).defaultPrevented).toBe(true) + expect(press(input, { ...optionCommandN, repeat: true }).defaultPrevented).toBe(true) + expect(newSession).toHaveBeenCalledOnce() + expect(press(input, commandSlash).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + expect(press(input, optionCommandN).defaultPrevented).toBe(true) + expect(newSession).toHaveBeenCalledTimes(2) +}) + +it('preserves an unbound Dead key and the following input', () => { + const { input, unregister, newSession, run } = mountMacNewSession() + unregister() + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + expect(press(input, commandSlash).defaultPrevented).toBe(false) + expect(press(input, commandSlash).defaultPrevented).toBe(true) + expect(newSession).not.toHaveBeenCalled() + expect(run).toHaveBeenCalledOnce() +}) + +it('keeps real composition, composition-ending input, legacy IME, AltGraph and accent input guarded for Option+Command+N', () => { + const { input, newSession } = mountMacNewSession() + fireEvent.compositionStart(input) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + fireEvent.compositionEnd(input) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + for (const extra of [ + { isComposing: true }, + { keyCode: 229 }, + { metaKey: false }, { code: 'KeyE' }, { ctrlKey: true }, { shiftKey: true }, + ]) { + fireEvent.blur(window) + expect(press(input, { ...optionCommandN, ...extra }).defaultPrevented).toBe(false) + } + fireEvent.blur(window) + const altGraph = new KeyboardEvent('keydown', { ...optionCommandN, bubbles: true, cancelable: true }) + vi.spyOn(altGraph, 'getModifierState').mockReturnValue(true) + input.dispatchEvent(altGraph) + expect(altGraph.defaultPrevented).toBe(false) + fireEvent.blur(window) + expect(press(input, { ...optionCommandN, metaKey: false }).defaultPrevented).toBe(false) + expect(press(input, optionCommandN).defaultPrevented).toBe(false) + expect(newSession).not.toHaveBeenCalled() +}) + +it.each([['desktop', 'macos'], ['web', 'windows']] as const)('keeps Dead key protection in %s on %s', (runtime, platform) => { + const { input, run } = mount(runtime, platform) + const keys = { metaKey: platform === 'macos', ctrlKey: platform === 'windows' } + expect(press(input, { ...keys, key: 'Dead' }).defaultPrevented).toBe(false) + expect(press(input, keys).defaultPrevented).toBe(false) + expect(run).not.toHaveBeenCalled() + expect(press(input, keys).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() +}) + +it('detects the client OS and separates Web from the preload marker', () => { + expect(detectEnvironment(document, { platform: 'MacIntel' } as Navigator)).toEqual({ runtime: 'web', platform: 'macos' }) + expect(detectEnvironment(document, { platform: 'Win32' } as Navigator)).toEqual({ runtime: 'web', platform: 'windows' }) + const html = document.documentElement + const previous = html.dataset.platform + try { + html.dataset.platform = 'linux' + expect(detectEnvironment(document, navigator)).toEqual({ runtime: 'desktop', platform: 'linux' }) + } finally { + if (previous === undefined) delete html.dataset.platform + else html.dataset.platform = previous + } +}) + +it('passes browser navigation/editing and local consumption; disposes the listener', () => { + const { input, run } = mount() + for (const key of ['N', 'P', 'O', 'R', 'W', 'C', 'V', 'Z']) expect(press(input, { code: `Key${key}` }).defaultPrevented).toBe(false) + const local = (event: Event) => { event.preventDefault() } + input.addEventListener('keydown', local) + expect(press(input).defaultPrevented).toBe(true) + expect(run).not.toHaveBeenCalled() + input.removeEventListener('keydown', local) + expect(press(input).defaultPrevented).toBe(true) + expect(press(input, { repeat: true }).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + dispose?.() + expect(press(input).defaultPrevented).toBe(false) +}) + +it('preserves composition, late closing keys, 229, AltGraph, and dead-key input', () => { + const { input, run } = mount() + fireEvent.compositionStart(input) + expect(press(input).defaultPrevented).toBe(false) + fireEvent.compositionEnd(input) + expect(press(input).defaultPrevented).toBe(false) + expect(press(input, { isComposing: true }).defaultPrevented).toBe(false) + // oxlint-disable-next-line typescript/no-deprecated -- Exercise legacy IME input. + expect(press(input, { keyCode: 229 }).defaultPrevented).toBe(false) + expect(press(input, { key: 'Dead' }).defaultPrevented).toBe(false) + const event = new KeyboardEvent('keydown', { code: 'Slash', ctrlKey: true, bubbles: true, cancelable: true }) + vi.spyOn(event, 'getModifierState').mockReturnValue(true) + input.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + expect(run).not.toHaveBeenCalled() + fireEvent.keyUp(input) + expect(press(input).defaultPrevented).toBe(true) +}) + +it('blocks background commands during dialogs and gives terminal input ownership', () => { + const { input, run } = mount() + const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog) + expect(press(input).defaultPrevented).toBe(true) + expect(run).not.toHaveBeenCalled() + dialog.dataset.shortcutModal = 'shortcuts' + expect(press(input).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledOnce() + const terminal = document.createElement('div'); terminal.className = 'xterm'; terminal.append(input); document.body.append(terminal) + expect(press(input).defaultPrevented).toBe(false) + fireEvent.blur(window) +}) + +it('routes body focus and window-delivered input through the same application command', () => { + const { input, run } = mount() + input.remove() + expect(press(document.body).defaultPrevented).toBe(true) + expect(press(window).defaultPrevented).toBe(true) + expect(run).toHaveBeenCalledTimes(2) +}) + +it('passes the original input element to a feature target resolver', () => { + const { registry, input } = mount() + const dispatch = vi.spyOn(registry, 'dispatch') + press(input) + expect(dispatch).toHaveBeenLastCalledWith(expect.anything(), { region: 'editable', modal: null, target: input }, expect.any(Function)) + input.remove() + press(window) + expect(dispatch).toHaveBeenLastCalledWith(expect.anything(), { region: 'page', modal: null, target: document.body }, expect.any(Function)) +}) diff --git a/packages/client/shortcuts/tests/fixed-input.client.spec.ts b/packages/client/shortcuts/tests/fixed-input.client.spec.ts new file mode 100644 index 0000000000..25d735b471 --- /dev/null +++ b/packages/client/shortcuts/tests/fixed-input.client.spec.ts @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { fireEvent } from '@testing-library/dom' +import { Context } from '@deepseek-ai/cordis' +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 { ShortcutCommandId } from '../src/protocol.ts' + +let dispose: (() => void) | undefined +afterEach(() => { dispose?.(); document.body.replaceChildren(); vi.restoreAllMocks(); vi.useRealTimers(); localStorage.clear() }) +const press = (target: EventTarget, extra: KeyboardEventInit = {}) => { + const event = new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', bubbles: true, cancelable: true, ...extra }) + target.dispatchEvent(event) + return event +} +function mount() { + const dispatch = vi.fn(() => ({ status: 'pass' as const })) + const fixed = vi.fn<(input: ShortcutFixedInput) => void>() + const input = document.createElement('input') + document.body.append(input) + input.focus() + dispose = installKeyboard(window, { dispatch, runtime: 'web', platform: 'windows' }, fixed) + return { input, dispatch, fixed } +} + +it('runs fixed handlers after local ownership and preserves their consumption for application dispatch', () => { + const { input, dispatch, fixed } = mount() + input.addEventListener('keydown', (event) => { event.preventDefault() }, { once: true }) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { defaultPrevented: true } }) + fixed.mockImplementation((event) => { if (event.type === 'keydown') event.consume() }) + expect(press(input).defaultPrevented).toBe(true) + expect(dispatch).toHaveBeenLastCalledWith(expect.objectContaining({ defaultPrevented: true }), expect.anything(), expect.any(Function)) + fireEvent.compositionStart(input) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { composing: true } }) + fireEvent.compositionEnd(input) + press(input) + expect(fixed.mock.lastCall?.[0]).toMatchObject({ type: 'keydown', gesture: { composing: true } }) +}) + +it('resets sequences when local controls stop propagation, including two inputs in the same task', async () => { + vi.useFakeTimers() + const { input, fixed } = mount() + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + expect(fixed).not.toHaveBeenCalled() + press(input) + expect(fixed.mock.calls.map(([event]) => event.type)).toEqual(['reset', 'keydown']) + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + await vi.runOnlyPendingTimersAsync() + expect(fixed).toHaveBeenLastCalledWith({ type: 'reset' }) +}) + +it('resets on focus, pointer, blur, and modal transitions even when focus stays unchanged', async () => { + const { input, fixed } = mount() + fireEvent.focusIn(input); fireEvent.pointerDown(input); fireEvent.blur(window) + expect(fixed.mock.calls.map(([event]) => event.type)).toEqual(['reset', 'reset', 'reset']) + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + const wrapper = document.createElement('div'); wrapper.append(dialog); document.body.append(wrapper) + await Promise.resolve() + expect(fixed).toHaveBeenCalledTimes(4) + dialog.setAttribute('aria-modal', 'false') + await Promise.resolve() + expect(fixed).toHaveBeenCalledTimes(5) + dialog.setAttribute('aria-modal', 'true') + press(input) + expect(fixed.mock.calls.slice(-2).map(([event]) => event.type)).toEqual(['reset', 'keydown']) + wrapper.remove() + await Promise.resolve() + expect(fixed).toHaveBeenLastCalledWith({ type: 'reset' }) + fixed.mockClear() + document.body.append(document.createTextNode('stream')) + await Promise.resolve() + expect(fixed).not.toHaveBeenCalled() +}) + +it('releases listeners, modal observation, and pending local-input resets on disposal', async () => { + vi.useFakeTimers() + const { input, fixed } = mount() + input.addEventListener('keydown', (event) => { event.stopPropagation() }, { once: true }) + press(input) + dispose?.() + fireEvent.focusIn(input); fireEvent.pointerDown(input); fireEvent.blur(window); press(input) + const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true') + document.body.append(dialog) + await vi.runOnlyPendingTimersAsync() + expect(fixed).not.toHaveBeenCalled() +}) + +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 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: [] }] }]) + expect(() => registry.registerFixed(command)).toThrow('Duplicate') + expect(() => registry.register({ ...command, aliases: [], defaults: {}, regions: [], modals: [], resolve: () => ({ status: 'pass' }) })).toThrow('Duplicate') + label = '停止回复'; registry.refreshLabels() + expect(registry.fixedCatalog.getSnapshot()[0]?.label).toBe(label) + off(); off() + expect(registry.fixedCatalog.getSnapshot()).toEqual([]) + expect(registry.definitions()).toEqual([]) +}) + +it('contains observer failures and makes prior consumption visible to later observers', async () => { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const service = new ShortcutsService(ctx) + const offFixed = service.registerFixed({ id: 'response.stop' as ShortcutCommandId, label: () => 'Stop', keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input' }) + expect(service.stopSequenceMs).toBe(500) + expect(service.fixedCatalog.getSnapshot()).toHaveLength(1) + service.observeFixedInput(() => { throw new Error('owner failure') }) + const off = service.observeFixedInput((event) => { if (event.type === 'keydown') event.consume() }) + const last = vi.fn<(input: ShortcutFixedInput) => void>() + service.observeFixedInput(last) + expect(press(document.body).defaultPrevented).toBe(true) + expect(last.mock.lastCall?.[0]).toMatchObject({ gesture: { defaultPrevented: true } }) + expect(error).toHaveBeenCalled() + off(); offFixed() + expect(press(document.body).defaultPrevented).toBe(false) + await ctx.fiber.dispose() + last.mockClear(); press(document.body) + expect(last).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) + +it('delivers resets and skips an observer removed earlier in the same dispatch', async () => { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + try { + const service = new ShortcutsService(ctx) + let off = (): void => {} + const first = vi.fn(() => { off() }) + service.observeFixedInput(first) + const removed = vi.fn() + off = service.observeFixedInput(removed) + fireEvent.focusIn(document.body) + expect(first).toHaveBeenLastCalledWith({ type: 'reset' }) + expect(removed).not.toHaveBeenCalled() + } finally { await ctx.fiber.dispose() } +}) diff --git a/packages/client/shortcuts/tests/native.client.spec.ts b/packages/client/shortcuts/tests/native.client.spec.ts new file mode 100644 index 0000000000..8db1d539ee --- /dev/null +++ b/packages/client/shortcuts/tests/native.client.spec.ts @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +/** Trusted native inputs preserve current configuration, frame identity, and modal arbitration. */ +import { afterEach, expect, it, vi } from 'vitest' +import { installNativeKeyboard } from '../src/client/native.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { DesktopShortcutInput, ShortcutCommandId } from '../src/protocol.ts' + +afterEach(() => { document.body.replaceChildren() }) + +it.each(['macos', 'windows'] as const)('prioritizes %s custom native bindings across editable and terminal regions and modals with revision checks', (platform) => { + 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: [], + 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'] } } } } }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const reset = vi.fn() + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot(), reset) + const input: DesktopShortcutInput = { kind: 'keyboard', frameName: '', revision: registry.config.getSnapshot().revision, + code: 'KeyC', control: platform === 'windows', alt: false, shift: false, meta: platform === 'macos', repeat: false } + const target = document.createElement('textarea') + const terminal = document.createElement('div') + terminal.className = 'xterm'; terminal.append(target); document.body.append(terminal) + target.focus() + deliver(input) + expect(run).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + deliver({ ...input, repeat: true }) + deliver({ ...input, revision: 'old' as typeof input.revision }) + expect(run).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(2) + terminal.className = '' + deliver(input) + expect(run).toHaveBeenCalledTimes(2) + terminal.setAttribute('role', 'dialog'); terminal.setAttribute('aria-modal', 'true') + deliver(input) + expect(run).toHaveBeenCalledTimes(3) + terminal.remove() + deliver(input) + expect(run).toHaveBeenCalledTimes(4) + off() +}) + +it('rejects old configuration and iframe identities, consumes repeats, and invokes unbound menu actions', () => { + 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: [], + resolve: () => ({ status: 'handled', run }) }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const release = vi.fn() + const off = installNativeKeyboard(window, { + subscribe: (listener) => { deliver = listener; return release }, closeWindow: vi.fn() }, registry, + () => registry.config.getSnapshot()) + const revision = registry.config.getSnapshot().revision + const input = { kind: 'iframe' as const, revision, frameName: 'current', code: 'KeyW', meta: true, + control: false, alt: false, shift: false, repeat: false } + deliver(input) + const frame = document.createElement('iframe') + document.body.append(frame) + frame.focus() + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.name = 'current' + deliver(input) + expect(run).not.toHaveBeenCalled() + frame.setAttribute('data-html-preview', '') + deliver({ ...input, revision: 'old' as typeof revision }) + deliver({ ...input, repeat: true }) + expect(run).not.toHaveBeenCalled() + deliver(input) + expect(run).toHaveBeenCalledTimes(1) + const modal = document.createElement('div') + modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true') + document.body.append(modal) + deliver(input) + deliver({ kind: 'menu', revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(2) + modal.remove() + registry.configure({ ...registry.config.getSnapshot(), revision: 'unbound' as typeof revision, + document: { schemaVersion: 1, profiles: { 'desktop:macos': { 'page.close': null } } } }) + deliver({ ...input, revision: 'unbound' as typeof revision }) + expect(run).toHaveBeenCalledTimes(2) + deliver({ kind: 'menu', revision: 'unbound' as typeof revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(3) + offCommand() + deliver({ kind: 'menu', revision: 'unbound' as typeof revision, commandId: 'page.close' as ShortcutCommandId }) + expect(run).toHaveBeenCalledTimes(3) + off() + expect(release).toHaveBeenCalledTimes(1) +}) + +it.each(['macos', 'windows'] as const)('runs a custom %s chord across text fields and verified embedded pages, once per press', (platform) => { + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + registry.register({ id: 'session.new' as ShortcutCommandId, label: () => 'New', aliases: [], defaults: {}, + regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run }) }) + registry.configure({ ...registry.config.getSnapshot(), revision: 'chord' as never, + document: { schemaVersion: 2, profiles: { [`desktop:${platform}`]: { 'session.new': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } } } }) + let deliver: (input: DesktopShortcutInput) => void = () => {} + const off = installNativeKeyboard(window, { subscribe: (listener) => { deliver = listener; return () => {} }, closeWindow: vi.fn() }, + registry, () => registry.config.getSnapshot()) + try { + const field = document.createElement('textarea'); document.body.append(field); field.focus() + const input: DesktopShortcutInput = { kind: 'keyboard', frameName: '', revision: registry.config.getSnapshot().revision, + code: 'KeyA', secondCode: 'KeyB', control: false, alt: false, shift: false, meta: false, repeat: false } + deliver(input); deliver({ ...input, repeat: true }) + expect(run).toHaveBeenCalledOnce() + const frame = document.createElement('iframe'); frame.name = 'browser'; frame.setAttribute('data-sidebar-browser-frame', '') + document.body.append(frame); frame.focus() + deliver({ ...input, kind: 'iframe', frameName: 'browser' }) + expect(run).toHaveBeenCalledTimes(2) + deliver({ ...input, kind: 'iframe', frameName: 'old' }) + expect(run).toHaveBeenCalledTimes(2) + } finally { off() } +}) diff --git a/packages/client/shortcuts/tests/persistence.client.spec.ts b/packages/client/shortcuts/tests/persistence.client.spec.ts new file mode 100644 index 0000000000..dc835edcfb --- /dev/null +++ b/packages/client/shortcuts/tests/persistence.client.spec.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from 'vitest' +import { ShortcutPersistence } from '../src/protocol.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot, ShortcutDefinition, ShortcutEdit } from '../src/protocol.ts' + +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 edit: ShortcutEdit = { type: 'set', id: one, binding } + +function fixture(reread = false) { + let raw: string | null = null + 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) => {}) } + 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 } } +} + +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() + let release!: () => void + let started!: () => void + const writing = new Promise((resolve) => { started = resolve }) + const barrier = new Promise((resolve) => { release = resolve }) + f.storage.write.mockImplementationOnce(async () => { started(); await barrier }) + const first = f.store.edit(edit, initial.revision) + const second = f.store.edit({ type: 'reset-all' }, initial.revision) + await writing + f.publish.mockClear() + expect(f.state()).toBe(initial) + release() + expect((await first).status).toBe('saved') + expect((await second).status).toBe('stale') + expect(f.publish).toHaveBeenCalledOnce() + expect(f.state().document.profiles['desktop:windows']?.[one]).toEqual(binding) + }) + + 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() + 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) + expect(f.raw()).toBeNull() + 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) + }) + + 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 accepted = f.state().document + f.external(raw) + const bad = await f.store.reload() + 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 () => { + const f = fixture(); f.storage.read.mockRejectedValueOnce(new Error('denied')) + expect((await f.store.reload()).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(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') + }) + + 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() + 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() + expect((await f.store.edit(edit, f.state().revision)).status).toBe('saved') + expect(f.state().document.profiles['web:macos']).toEqual({ 'sleeping.command': null }) + }) + + it('accepts Windows single keys and editing combinations but rejects explicit/default conflicts', async () => { + const f = fixture(); await f.store.reload() + 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') + await f.store.edit(edit, f.state().revision) + expect((await f.store.edit({ ...edit, id: two }, f.state().revision)).conflicts).toEqual([one]) + expect((await f.store.edit({ type: 'set', id: two, binding: { ...binding, code: 'KeyB' } }, f.state().revision)).status).toBe('saved') + expect((await f.store.edit({ type: 'reset', id: one }, f.state().revision)).status).toBe('conflict') + expect((await f.store.edit({ type: 'reset-all' }, f.state().revision)).status).toBe('saved') + }) + + 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() + 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() + 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() + await expect(f.store.edit({ ...edit, binding: { ...binding, code: 'Unidentified' } }, f.state().revision)).rejects.toThrow('Unsupported') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + f.publish.mockImplementationOnce(() => { throw new Error('subscriber failed') }) + 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) + } finally { error.mockRestore() } +}) diff --git a/packages/client/shortcuts/tests/registry.client.spec.ts b/packages/client/shortcuts/tests/registry.client.spec.ts new file mode 100644 index 0000000000..7d601bbc36 --- /dev/null +++ b/packages/client/shortcuts/tests/registry.client.spec.ts @@ -0,0 +1,180 @@ +import { describe, expect, it, vi } from 'vitest' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { bindingKey, initialShortcutConfig, normalizeBinding, presentBinding } from '../src/protocol.ts' +import type { ShortcutCommandId } from '../src/protocol.ts' +import { ShortcutRegistry } from '../src/client/registry.ts' +import type { ShortcutCommand, ShortcutGesture } from '../src/client/types.ts' + +const context = { region: 'page', modal: null, target: null } as const +const gesture: ShortcutGesture = { code: 'KeyB', control: false, alt: false, shift: false, meta: true, + repeat: false, composing: false, defaultPrevented: false } +function command(overrides: Partial = {}): ShortcutCommand { + return { id: 'test.toggle' as ShortcutCommandId, label: () => 'Toggle', aliases: ['toggle'], + defaults: { desktop: { code: 'KeyB', modifiers: ['primary'] } }, regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run: () => {} }), ...overrides } +} + +describe('physical key registry', () => { + it('publishes owner availability without changing saved bindings and releases its subscription', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const availability = createSnapshotStore('No completed turn') + const unsubscribe = vi.fn() + let announce = (): void => {} + const source = { getSnapshot: () => availability.getSnapshot(), subscribe: (listener: () => void) => { + announce = listener + listener() + const off = availability.subscribe(listener) + return () => { unsubscribe(); off() } + } } + const off = registry.register(command({ availability: source })) + const offOther = registry.register(command({ id: 'other.unbound' as ShortcutCommandId, defaults: {} })) + const other = registry.catalog.getSnapshot()[1] + const config = registry.config.getSnapshot() + expect(registry.catalog.getSnapshot()[0]).toMatchObject({ unavailableReason: 'No completed turn', keys: ['⌘', 'B'] }) + availability.set(null) + expect(registry.catalog.getSnapshot()[0]?.unavailableReason).toBeNull() + expect(registry.catalog.getSnapshot()[1]).toBe(other) + const rows = registry.catalog.getSnapshot() + announce() + expect(registry.catalog.getSnapshot()).toBe(rows) + expect(registry.config.getSnapshot()).toBe(config) + off(); off(); offOther() + const changed = vi.fn() + registry.catalog.subscribe(changed) + availability.set('No session') + expect(unsubscribe).toHaveBeenCalledOnce() + expect(changed).not.toHaveBeenCalled() + expect(registry.catalog.getSnapshot()).toEqual([]) + }) + it('normalizes primary/control together on Windows and presents platform-specific keys', () => { + const binding = { code: 'KeyB', modifiers: ['primary', 'control', 'shift'] as const } + expect(bindingKey(normalizeBinding(binding, 'windows'))).toBe('control+shift+KeyB') + expect(presentBinding(normalizeBinding(binding, 'macos'), 'macos')).toEqual({ keys: ['⌃', '⇧', '⌘', 'B'], aria: 'Control+Shift+Meta+B' }) + expect(presentBinding(normalizeBinding({ code: 'Slash', modifiers: ['primary'] }, 'windows'), 'windows')) + .toEqual({ keys: ['Ctrl', '+', '/'], aria: 'Control+/' }) + expect(presentBinding(null, 'linux')).toEqual({ keys: [], aria: undefined }) + expect(() => normalizeBinding({ code: 'Unidentified', modifiers: [] }, 'macos')).toThrow('Unsupported') + }) + + it('rejects duplicate ids, cross-platform default conflicts, and unverified Web combinations atomically', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + 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') + expect(() => registry.register(command({ id: 'web.new' as ShortcutCommandId, + defaults: { web: { code: 'KeyN', modifiers: ['primary'] } } }))).toThrow('Unsupported Web') + expect(() => registry.register(command({ id: 'system.copy' as ShortcutCommandId, + defaults: { desktop: { code: 'KeyC', modifiers: ['primary'] } } }))).toThrow('Reserved') + expect(registry.catalog.getSnapshot()).toHaveLength(1) + const changed = vi.fn() + registry.catalog.subscribe(changed) + off(); off() + expect(changed).toHaveBeenCalledOnce() + expect(registry.catalog.getSnapshot()).toEqual([]) + expect(registry.dispatch(gesture, context, vi.fn())).toEqual({ status: 'pass' }) + }) + + it('consumes before executing, ignores repeats, and matches every modifier exactly', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const consumed = vi.fn() + const run = vi.fn(() => { expect(consumed).toHaveBeenCalled() }) + registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch(gesture, context, consumed).status).toBe('handled') + expect(registry.dispatch({ ...gesture, repeat: true }, context, consumed).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() + expect(consumed).toHaveBeenCalledTimes(2) + const passed = [{ shift: true }, { alt: true }, { control: true }, { meta: false }, { composing: true }, { defaultPrevented: true }] + for (const extra of passed) { + expect(registry.dispatch({ ...gesture, ...extra }, context, consumed).status).toBe('pass') + } + expect(consumed).toHaveBeenCalledTimes(2) + }) + + it('prioritizes desktop bindings in terminals and modals while retaining owner refusal reasons', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + const consumed = vi.fn() + const resolve = vi.fn(() => ({ status: 'blocked', reason: 'No target' })) + registry.register(command({ resolve })) + expect(registry.dispatch(gesture, { ...context, modal: 'settings' }, consumed)).toMatchObject({ status: 'blocked', reason: 'No target' }) + expect(resolve).toHaveBeenCalledOnce() + expect(registry.dispatch(gesture, { region: 'terminal', modal: null, target: null }, consumed).status).toBe('blocked') + expect(registry.dispatch(gesture, context, consumed)).toMatchObject({ status: 'blocked', reason: 'No target' }) + resolve.mockReturnValue({ status: 'pass' }) + expect(registry.dispatch(gesture, context, consumed).status).toBe('pass') + expect(consumed).toHaveBeenCalledTimes(3) + }) + + 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 })) + const first = registry.catalog.getSnapshot() + expect(registry.catalog.getSnapshot()).toBe(first) + expect(first[0]?.keys).toEqual(['Ctrl', '+', 'Alt', '+', 'B']) + label = '设置' + registry.refreshLabels() + expect(registry.catalog.getSnapshot()[0]?.label).toBe('设置') + expect(registry.dispatch({ ...gesture, control: true, meta: false }, context, vi.fn()).status).toBe('pass') + }) +}) + +it.each(['Space', 'Escape', 'ArrowUp'])('uses standard ARIA names for %s', (code) => { + expect(presentBinding(normalizeBinding({ code, modifiers: [] }, 'windows'), 'windows').aria).toBe(code) +}) + + +it('keeps commands disabled during startup and publishes config/catalog together before dispatch changes', () => { + const config = initialShortcutConfig() + const registry = new ShortcutRegistry('desktop', 'macos', config) + const run = vi.fn() + registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('pass') + const snapshot = { ...config, status: 'ready' as const, document: { schemaVersion: 1 as const, + profiles: { 'desktop:macos': { 'test.toggle': { code: 'KeyJ', modifiers: ['primary'] as const } } } } } + registry.catalog.subscribe(() => { + expect(registry.config.getSnapshot()).toBe(snapshot) + expect(registry.catalog.getSnapshot()[0]?.keys).toEqual(['⌘', 'J']) + }) + registry.configure(snapshot) + expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('pass') + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('handled') + expect(run).toHaveBeenCalledOnce() +}) + +it('blocks conflicting dormant overrides when their plugins return, then releases every row on unload', () => { + const registry = new ShortcutRegistry('desktop', 'macos') + registry.configure({ ...initialShortcutConfig(), status: 'ready', document: { schemaVersion: 1, + profiles: { 'desktop:macos': { 'test.toggle': { code: 'KeyJ', modifiers: ['primary'] }, 'other.toggle': { code: 'KeyJ', modifiers: ['primary'] } } } } }) + const run = vi.fn() + const off = registry.register(command({ resolve: () => ({ status: 'handled', run }) })) + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('handled') + const other = registry.register(command({ id: 'other.toggle' as ShortcutCommandId, defaults: {}, resolve: () => ({ status: 'handled', run }) })) + const consumed = vi.fn() + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, consumed).status).toBe('blocked') + expect(consumed).toHaveBeenCalledOnce() + expect(run).toHaveBeenCalledOnce() + other(); off() + expect(registry.catalog.getSnapshot()).toEqual([]) + expect(registry.dispatch({ ...gesture, code: 'KeyJ' }, context, vi.fn()).status).toBe('pass') +}) + +it.each(['macos', 'windows'] as const)('keeps %s priority identical before saving, after saving the same default, and after resetting', (platform) => { + const registry = new ShortcutRegistry('desktop', platform) + const run = vi.fn() + registry.register(command({ regions: ['page'], modals: [], resolve: () => ({ status: 'handled', run }) })) + for (const [index, modified] of [false, true, false].entries()) { + registry.configure({ ...registry.config.getSnapshot(), revision: `revision-${index}` as never, + document: { schemaVersion: 2, profiles: modified ? { + [`desktop:${platform}`]: { 'test.toggle': { code: 'KeyB', modifiers: ['primary'] } }, + } : {} } }) + expect(registry.catalog.getSnapshot()[0]?.modified).toBe(modified) + const input = { ...gesture, meta: platform === 'macos', control: platform === 'windows' } + for (const region of ['page', 'editable', 'terminal'] as const) { + for (const modal of [null, 'settings']) { + expect(registry.dispatch(input, { region, modal, target: null }, vi.fn()).status).toBe('handled') + } + } + } + expect(run).toHaveBeenCalledTimes(18) +}) diff --git a/packages/client/shortcuts/tests/service.client.spec.ts b/packages/client/shortcuts/tests/service.client.spec.ts new file mode 100644 index 0000000000..35ada6a7dd --- /dev/null +++ b/packages/client/shortcuts/tests/service.client.spec.ts @@ -0,0 +1,139 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import ShortcutsService from '../src/client/index.ts' +import { initialShortcutConfig } from '../src/protocol.ts' +import type { DesktopShortcutsApi, ShortcutConfigSnapshot, ShortcutCommandId } from '../src/protocol.ts' + +const disposers: (() => Promise)[] = [] +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + delete document.documentElement.dataset.platform + Reflect.deleteProperty(window, 'dshDesktop') + localStorage.clear() + vi.restoreAllMocks() +}) +const snapshot = (sequence: number): ShortcutConfigSnapshot => ({ ...initialShortcutConfig(), sequence, status: 'ready' }) +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: Error) => void + const promise = new Promise((yes, no) => { resolve = yes; reject = no }) + return { promise, resolve, reject } +} +function desktop() { + document.documentElement.dataset.platform = 'darwin' + let publish!: (value: ShortcutConfigSnapshot) => void + const unsubscribe = vi.fn() + const api = { + get: vi.fn().mockResolvedValue(snapshot(1)), + reload: vi.fn().mockResolvedValue(snapshot(2)), + edit: vi.fn().mockResolvedValue({ status: 'saved', snapshot: snapshot(3) }), + recording: vi.fn().mockResolvedValue(), + subscribe: vi.fn((listener) => { publish = listener; return unsubscribe }), + } + Object.defineProperty(window, 'dshDesktop', { configurable: true, value: { shortcuts: api } }) + return { api, publish: (value: ShortcutConfigSnapshot) => { publish(value) }, unsubscribe } +} +function mount() { + const ctx = new Context() + ctx.provide('locale', new LocaleRuntime(ctx)) + const service = new ShortcutsService(ctx) + const dispose = async () => { await ctx.fiber.dispose() } + disposers.push(dispose) + 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: [], + resolve: () => ({ status: 'handled' as const, run() {} }) } + +it('waits for the Desktop handshake and ignores older get, reload, edit, and broadcast replies', async () => { + const f = desktop(), first = deferred() + 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() + 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) + await service.recording(true); expect(f.api.recording).toHaveBeenCalledWith(true) + off() + expect(f.api.get).toHaveBeenLastCalledWith([]) +}) + +it('reports unavailable Desktop storage without reading or writing localStorage', async () => { + document.documentElement.dataset.platform = 'darwin' + const read = vi.spyOn(Storage.prototype, 'getItem'), write = vi.spyOn(Storage.prototype, 'setItem') + 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() +}) + +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 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') + f.api.get.mockRejectedValueOnce(new Error('handshake failed')) + service.register(command) + await vi.waitFor(() => { expect(service.config.getSnapshot().error).toBe('read') }) +}) + +it.each(['resolve', 'reject'] as const)('suppresses %s completions after disposal and releases the bridge subscription', async (outcome) => { + const f = desktop(), pending = deferred() + f.api.get.mockReturnValue(pending.promise); f.api.reload.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 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]) + expect(service.config.getSnapshot()).toBe(accepted) + expect(f.unsubscribe).toHaveBeenCalledOnce() +}) + +it('persists Web edits and presents normalized bindings through the public service', async () => { + const { service, ctx } = mount() + 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 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') + expect(service.catalog.getSnapshot()[0]?.keys).toEqual([]) + await service.recording(true); off() +}) + +it('publishes fixed reservations to Desktop and reports their conflicts while their owner is mounted', () => { + const f = desktop(), { service } = mount() + const off = service.registerFixed({ id: 'menu.close' as ShortcutCommandId, label: () => 'Close', keys: ['Esc'], + bindings: [{ code: 'Escape', modifiers: [] }], group: 'menus' }) + expect(f.api.get).toHaveBeenLastCalledWith([{ id: 'menu.close', defaults: {}, fixed: [{ code: 'Escape', modifiers: [] }] }]) + expect(service.describeBinding({ code: 'Escape', modifiers: [] }).conflicts).toEqual(['menu.close']) + expect(service.describeBinding({ code: 'KeyA', secondCode: 'Escape', modifiers: [] }).conflicts).toEqual(['menu.close']) + expect(service.describeBinding({ code: 'Escape', modifiers: ['alt'] }).conflicts).toEqual([]) + off() + expect(service.describeBinding({ code: 'Escape', modifiers: [] }).conflicts).toEqual([]) + expect(f.api.get).toHaveBeenLastCalledWith([]) +}) diff --git a/packages/client/shortcuts/tests/storage.client.spec.ts b/packages/client/shortcuts/tests/storage.client.spec.ts new file mode 100644 index 0000000000..194f003dc2 --- /dev/null +++ b/packages/client/shortcuts/tests/storage.client.spec.ts @@ -0,0 +1,53 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { webShortcutStorage, SHORTCUT_STORAGE_KEY } from '../src/client/storage.ts' +import type { ShortcutCommandId, ShortcutConfigSnapshot } from '../src/protocol.ts' + +afterEach(() => { localStorage.clear(); vi.restoreAllMocks() }) +const id = 'shortcuts.open' as ShortcutCommandId +const definitions = [{ id, defaults: { web: { code: 'Slash', modifiers: ['primary'] as const } } }] + +it('syncs same-origin updates, rejects stale drafts, and releases the storage listener', async () => { + let latest!: ShortcutConfigSnapshot + const publish = vi.fn((value: ShortcutConfigSnapshot) => { latest = value }) + const adapter = webShortcutStorage(window, 'macos', publish) + try { + const initial = await adapter.get(definitions) + adapter.subscribe(publish)(); await adapter.recording(true) + publish.mockClear() + window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated' })) + await Promise.resolve() + expect(publish).not.toHaveBeenCalled() + window.dispatchEvent(new StorageEvent('storage', { key: null })) + localStorage.setItem(SHORTCUT_STORAGE_KEY, JSON.stringify({ schemaVersion: 1, profiles: { 'web:macos': { [id]: null } } })) + window.dispatchEvent(new StorageEvent('storage', { key: SHORTCUT_STORAGE_KEY })) + await vi.waitFor(() => { expect(latest.document.profiles['web:macos']?.[id]).toBeNull() }) + expect((await adapter.edit({ type: 'reset', id }, initial.revision)).status).toBe('stale') + expect((await adapter.edit({ type: 'reset', id }, latest.revision)).status).toBe('saved') + expect(JSON.parse(localStorage.getItem(SHORTCUT_STORAGE_KEY)!) as unknown).toMatchObject({ profiles: { 'web:macos': {} } }) + adapter.dispose(); publish.mockClear() + localStorage.removeItem(SHORTCUT_STORAGE_KEY) + window.dispatchEvent(new StorageEvent('storage', { key: null })) + await Promise.resolve() + expect(publish).not.toHaveBeenCalled() + } finally { adapter.dispose() } +}) + +it('retains drafts after quota failure and backs up future data only on explicit recovery', async () => { + let latest!: ShortcutConfigSnapshot + const adapter = webShortcutStorage(window, 'windows', (value) => { latest = value }) + try { + await adapter.get(definitions) + const write = vi.spyOn(Storage.prototype, 'setItem').mockImplementationOnce(() => { throw new Error('quota') }) + expect((await adapter.edit({ type: 'set', id, binding: null }, latest.revision)).status).toBe('write-failed') + expect(localStorage.getItem(SHORTCUT_STORAGE_KEY)).toBeNull() + write.mockRestore() + const raw = '{"schemaVersion":9}' + localStorage.setItem(SHORTCUT_STORAGE_KEY, raw) + await adapter.reload() + 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) + } finally { adapter.dispose() } +}) diff --git a/packages/client/shortcuts/tsconfig.client.json b/packages/client/shortcuts/tsconfig.client.json new file mode 100644 index 0000000000..18bedea616 --- /dev/null +++ b/packages/client/shortcuts/tsconfig.client.json @@ -0,0 +1,34 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../locale" + }, + { + "path": "../store" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../../../vendor/schemastery" + } + ], + "exclude": [ + "src/index.ts" + ] +} diff --git a/packages/client/shortcuts/tsconfig.host.json b/packages/client/shortcuts/tsconfig.host.json new file mode 100644 index 0000000000..073390c4ed --- /dev/null +++ b/packages/client/shortcuts/tsconfig.host.json @@ -0,0 +1,34 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "include": [ + "src/protocol.ts", + "src/native.ts", + "src/binding.ts", + "src/configuration.ts", + "src/persistence.ts", + "src/index.ts", + "src/config.ts" + ], + "references": [ + { + "path": "../../util/brand" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../host/webserver" + } + ] +} diff --git a/packages/client/shortcuts/tsconfig.json b/packages/client/shortcuts/tsconfig.json new file mode 100644 index 0000000000..2eca820546 --- /dev/null +++ b/packages/client/shortcuts/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.host.json" + }, + { + "path": "./tsconfig.client.json" + } + ] +} diff --git a/packages/client/shortcuts/tsdown.config.ts b/packages/client/shortcuts/tsdown.config.ts new file mode 100644 index 0000000000..66ffe581c0 --- /dev/null +++ b/packages/client/shortcuts/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-shortcuts', ['lib/types/index.js', 'lib/types/protocol.js']) diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 33c857da49..04c4e4d08b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -68,6 +68,8 @@ Active transcripts in the main occurrence expose content-width drag handles in t The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. +Plain Enter uses the configured delivery mode, exactly Ctrl+Enter or Cmd+Enter uses its complement, and Shift+Enter inserts a line break. Enter with Alt, AltGraph, both Ctrl and Cmd, or Shift plus Ctrl/Cmd leaves the draft and command menu unchanged and keeps the DOM event available to application shortcuts. + Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 8dc52d4a5f..754fa0cd46 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -68,6 +68,8 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 +普通 Enter 使用已配置的投递模式,严格的 Ctrl+Enter 或 Cmd+Enter 使用互补模式,Shift+Enter 插入换行。带 Alt、AltGraph、同时带 Ctrl 与 Cmd,或 Shift 与 Ctrl/Cmd 的 Enter 保持草稿和指令菜单不变,并将 DOM 事件留给应用快捷键。 + 默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index 7291f2c25a..e105a1d6c0 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -32,7 +32,7 @@ export interface ComposerKeymapHandlers { dismissPopup(): void /** Whether Enter may submit right now (locked/busy states refuse). */ canSubmit(): boolean - /** The Enter gesture after every guard passed; `accelerated` = Ctrl/Cmd held. */ + /** Plain Enter submits; exactly Ctrl+Enter or Cmd+Enter selects accelerated delivery. */ submit(accelerated: boolean): void /** * Pasted files with directory metadata supplied by the clipboard entry API. @@ -133,8 +133,11 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer return false }, COMMAND_PRIORITY_CRITICAL), editor.registerCommand(KEY_ENTER_COMMAND, (event) => { - // Shift+Enter is the native line break UNCONDITIONALLY — decided before - // the IME guard so a composition-closing Shift+Enter still breaks the line. + // Returning true stops Lexical's fallback line break without consuming + // the DOM event that application shortcuts need. + if (event !== null && (event.altKey || event.getModifierState('AltGraph') + || (event.ctrlKey && event.metaKey) || (event.shiftKey && (event.ctrlKey || event.metaKey)))) return true + // Plain Shift+Enter keeps its native line break, including during composition. if (event?.shiftKey === true) return false if (event !== null && isComposingEvent(event, recentlyComposing)) { // The IME consumes this Enter (candidate pick); neither submit nor diff --git a/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx b/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx index a242269c51..5b4fe94838 100644 --- a/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx +++ b/packages/client/ui-conversation/tests/keymap-routing.client.spec.tsx @@ -6,7 +6,7 @@ */ import { describe, expect, it, onTestFinished, vi } from 'vitest' import { fireEvent } from '@testing-library/react' -import { createEditor } from 'lexical' +import { $createParagraphNode, $createTextNode, $getRoot, createEditor } from 'lexical' import { registerPlainText } from '@lexical/plain-text' import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts' @@ -67,6 +67,48 @@ describe('keymap keydown routing', () => { expect(submit).toHaveBeenCalledWith(true) }) + it.each([ + { altKey: true }, + { altKey: true, metaKey: true }, + { altKey: true, ctrlKey: true }, + { ctrlKey: true, metaKey: true }, + { shiftKey: true, metaKey: true }, + { shiftKey: true, ctrlKey: true }, + { shiftKey: true, altKey: true }, + ])('leaves modified Enter %j available to application commands', async (modifiers) => { + const editor = createEditor({ namespace: 'modified-enter', onError: (error) => { throw error } }) + const root = document.createElement('div') + root.contentEditable = 'true' + document.body.appendChild(root) + onTestFinished(() => { editor.setRootElement(null); root.remove() }) + editor.setRootElement(root) + onTestFinished(registerPlainText(editor)) + const submit = vi.fn() + const arbitrate = vi.fn(() => 'pass' as const) + onTestFinished(registerComposerKeymap(editor, { + arbitrate, space: () => false, dismissPopup: () => {}, canSubmit: () => true, + submit, intakeFiles: () => {}, pasteText: () => {}, + })) + editor.update(() => { + const paragraph = $createParagraphNode().append($createTextNode('unsent draft')) + $getRoot().append(paragraph) + paragraph.selectEnd() + }, { discrete: true }) + const applicationKeydown = vi.fn() + document.addEventListener('keydown', applicationKeydown) + onTestFinished(() => { document.removeEventListener('keydown', applicationKeydown) }) + + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, ...modifiers }) + root.dispatchEvent(event) + await Promise.resolve() + + expect(submit).not.toHaveBeenCalled() + expect(arbitrate).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + expect(applicationKeydown).toHaveBeenCalledOnce() + expect(editor.getEditorState().read(() => $getRoot().getTextContent())).toBe('unsent draft') + }) + it('routes Tab through arbitration and passes when unconsumed', () => { const editor = createEditor({ namespace: 'keymap-routing', onError: (e) => { throw e } }) const root = document.createElement('div') diff --git a/packages/client/ui-layout/README.i18n.yaml b/packages/client/ui-layout/README.i18n.yaml index 57e2a79c7a..0df14e8b41 100644 --- a/packages/client/ui-layout/README.i18n.yaml +++ b/packages/client/ui-layout/README.i18n.yaml @@ -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-layout/README.md -README.md: b26b182f5ea2c727411910cc5f85819b9dfbcb2c -README.zh.md: 58ef066ab05389898dc912500cd6b37f1a07824d +README.md: 0a5f5fd4abe4759de11de2a0fb3d238431ea9b2f +README.zh.md: 8faf9d306139d828cc95c760e7cd6637141c8dd9 diff --git a/packages/client/ui-layout/README.md b/packages/client/ui-layout/README.md index b26b182f5e..0a5f5fd4ab 100644 --- a/packages/client/ui-layout/README.md +++ b/packages/client/ui-layout/README.md @@ -25,6 +25,8 @@ This package provides the Web GUI's three-column AppFrame, edge-column widths, a ## Use this package +Desktop Mod+B toggles the left sidebar through the same layout action used by its controls. The shortcut command is unavailable behind modal dialogs and yields to terminal input. + The root slot composes the sidebar, main content, and right column. The sidebar spans 264–420px, defaults to 280px, and retains a 56px rail when collapsed; below 1024px it collapses automatically, and opening the right panel collapses a manually expanded sidebar. The right panel first opens at 45% of the viewport, then retains the user's pixel preference, capped at 70%. To protect 400px for the center, the frame first reduces the right panel to 300px, then reports insufficient room so its occupant closes it, and only then compresses the center further. Dragging has no transition delay; the right handle is absent while closed or fullscreen. Global panels occupy the root-scoped `main` keyed slot; `conversation` is the reserved key for the Conversation. `ctx.layout.selectPanel(id)` selects a registered panel, and `null` selects the Conversation without changing the current Session. No global panel is registered by the shipped composition. diff --git a/packages/client/ui-layout/README.zh.md b/packages/client/ui-layout/README.zh.md index 58ef066ab0..8faf9d3061 100644 --- a/packages/client/ui-layout/README.zh.md +++ b/packages/client/ui-layout/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +桌面 Mod+B 通过与控件共用的布局操作切换左侧栏。模态弹窗打开时此快捷键不可用;终端输入优先。 + 本插件在 root slot 中组合侧边栏、主内容和右栏。侧边栏宽度为 264~420px,默认为 280px,收起后保留 56px 控制栏;窗口宽度低于 1024px 时自动收起,打开右侧面板也会收起手动展开的侧边栏。右侧面板首次打开时使用视口宽度的 45%,之后保留用户的像素宽度偏好,上限为 70%。为给中栏保留 400px,框架先将右侧面板缩减至 300px,再报告空间不足,使占用方将其关闭,最后才进一步压缩中栏。拖动没有过渡延迟;右侧手柄在关闭或全屏时不显示。 全局面板占据 root 作用域的 `main` keyed slot;`conversation` 是为会话界面保留的 key。`ctx.layout.selectPanel(id)` 选中已注册面板,`null` 则选中会话界面,但不改变当前会话。默认组合不注册任何全局面板。 diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index cb915838a5..a4abc5560c 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -31,7 +31,8 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session", - "@deepseek-ai/dsh-client-ui-theme" + "@deepseek-ai/dsh-client-ui-theme", + "@deepseek-ai/dsh-client-shortcuts" ], "platform": "web" } @@ -54,7 +55,8 @@ "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-shortcuts": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 045fcb8a39..3937335913 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -17,6 +17,8 @@ import type { PanelInfo } from './service.ts' import { AppFrame } from './AppFrame.tsx' import { createLayoutStore } from './stores.ts' import { LayoutController } from './service.ts' +import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client' +import { en, zh } from './shortcut-locales.ts' import { ThemePresenter } from './theme-presenter.ts' // Contract exports only (export-convergence rule: cross-package consumers @@ -38,6 +40,11 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Layout keyboard command labels. */ + 'shortcuts.layout': keyof typeof zh + } + interface GlobalStandardProps { /** Subscribe to the selected main panel independently of parent renders. */ usePanelInfo: UsePanelInfo @@ -133,7 +140,7 @@ export interface RightbarOwnerProps { } /** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */ -export const inject = ['slots', 'theme', 'locale'] +export const inject = ['slots', 'theme', 'locale', 'shortcuts'] /** * Client plugin body: provide ctx.layout, then one register() call — AppFrame @@ -142,6 +149,9 @@ export const inject = ['slots', 'theme', 'locale'] * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register('shortcuts.layout', { zh, en }), 'layout: command labels') + const t = ctx.locale.bind('shortcuts.layout') + ctx.effect(() => { const handle = createLayoutStore() const instance = handle.create() @@ -170,9 +180,16 @@ export function apply(ctx: ClientContext): void { }, store, }, 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'] } }, + regions: ['page', 'editable'], modals: [], + resolve: () => ({ status: 'handled', run: () => { layout.toggleSidebar() } }), + }) const disposePanels = ctx.slots.subscribe('main', retainMainPanels) retainMainPanels() return () => { + disposeShortcut() layout.dispose() disposePanels() disposeRegistration() diff --git a/packages/client/ui-layout/src/client/shortcut-locales.ts b/packages/client/ui-layout/src/client/shortcut-locales.ts new file mode 100644 index 0000000000..7c90e1ae9a --- /dev/null +++ b/packages/client/ui-layout/src/client/shortcut-locales.ts @@ -0,0 +1,4 @@ +/** Layout command labels. */ +export const zh = { toggle: '展开/收起左侧栏' } +/** English labels for the same layout commands. */ +export const en: Record = { toggle: 'Toggle left sidebar' } diff --git a/packages/client/ui-layout/tests/apply.client.spec.ts b/packages/client/ui-layout/tests/apply.client.spec.ts index 0e29721622..47f1353faa 100644 --- a/packages/client/ui-layout/tests/apply.client.spec.ts +++ b/packages/client/ui-layout/tests/apply.client.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment jsdom +import ShortcutsService from '../../shortcuts/src/client/index.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Context, type Fiber } from '@deepseek-ai/cordis' import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' @@ -58,6 +59,7 @@ async function bench() { // Theme registers its Appearance settings row and requires the connection // seam for persistence; model this bench as a remote, memory-only browser. ctx.provide('locale', new LocaleRuntime(ctx)) + await ctx.plugin(ShortcutsService).await() ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) // ui-theme's Appearance row binds a durable scope through these two. ctx.provide('remote', { $on: () => () => {} } as never) @@ -77,7 +79,7 @@ async function bench() { describe('ui-layout client apply', () => { it('declares its service dependencies', () => { - expect(inject).toEqual(['slots', 'theme', 'locale']) + expect(inject).toEqual(['slots', 'theme', 'locale', 'shortcuts']) }) it('provides ctx.layout and declares the five root-scoped frame slots', async () => { diff --git a/packages/client/ui-layout/tsconfig.json b/packages/client/ui-layout/tsconfig.json index 8893b1800d..b2daaaf054 100644 --- a/packages/client/ui-layout/tsconfig.json +++ b/packages/client/ui-layout/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../ui-session" + }, + { + "path": "../shortcuts/tsconfig.client.json" } ] } diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 919fec4a84..8a0fc9c33e 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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: df8c96e9fa4572b6371f8406ecd385b1eb80078e -README.zh.md: ef3cdeb70bbb7619f9e95bd24b5a1b97e9c159da +README.md: 3c95cc7346269da717985949247dbb2e4f5b2f29 +README.zh.md: 32fda5b09b902c9aed1314f4cf16fa67794b8a0d diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index df8c96e9fa..3c95cc7346 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -25,6 +25,8 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out ## Use this package +`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` 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. + 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)). Compose feature UI from these atoms whenever the web client needs a standard control or an agent-output renderer. They render through React only and take `--dsw-*` design tokens from the theme, so they fit any plugin without importing the theme or the slot system. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index ef3cdeb70b..32fda5b09b 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -25,6 +25,8 @@ kind: "package-library" ## 使用本包 +`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 将调用方提供的有效按键组合显示在同一个圆角标签中;可选的 `className` 供调用方设置交互状态样式。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。 + 本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。 只要 Web 客户端需要标准控件或 agent 输出渲染器,就用这些原子组件拼装功能 UI。它们只经 React 渲染,并从主题取得 `--dsw-*` 设计 token,因此无需导入主题或 slot 系统即可适配任意插件。 diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 190591d13a..6884a05ded 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -199,6 +199,19 @@ white-space: nowrap; } +.shortcut { + flex: none; + margin-inline-start: auto; +} + +.shortcut .shortcutKeys { + height: auto; + padding: 0; + border-radius: 0; + background: transparent; + color: var(--dsw-alias-label-tertiary); +} + .check { flex: none; color: var(--dsw-alias-label-primary); diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index 80198b0697..50d92fda06 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -5,6 +5,9 @@ import clsx from 'clsx' import { IconCheckOutlineRegular } from './icons/index.tsx' import { overlayTopMargin } from './overlay-top-margin.ts' import { usePointerGrace } from './pointer-grace.ts' +import { isBehindModal } from './useModalLayer.ts' +import { observeComposition } from './keyboard-composition.ts' +import { ShortcutKeys } from './ShortcutKeys.tsx' import css from './Menu.module.css' /** Selectable row (optionally with a nested submenu). */ @@ -12,6 +15,8 @@ export interface MenuItem { id: string label: ReactNode disabled?: boolean + /** Effective binding supplied by the command owner; omitted for unbound actions. */ + shortcut?: { keys: readonly string[]; aria?: string | undefined } /** Leading icon (figma .Menu_cell gap 8). */ icon?: ReactNode /** Destructive row: error-colored text/icon and danger hover fill. */ @@ -300,6 +305,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI walkIndex.current = null return } + const composition = observeComposition(document) const onPointerDown = (e: PointerEvent) => { if (!(e.target instanceof Node)) return // The portaled list is outside the anchor subtree; check both. @@ -308,12 +314,15 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI onClose() } const onKeyDown = (e: KeyboardEvent) => { + if (composition.guards(e) || isBehindModal(rootRef.current) || e.defaultPrevented || e.ctrlKey || e.altKey || e.metaKey) return // Where the keyboard is, computed once: the menu owns it when it holds a // row or sits on its anchor region. const focused = document.activeElement const insideList = listRef.current?.contains(focused) === true const anchored = rootRef.current?.contains(focused) === true || insideList - if (e.key === 'Escape') { + if (e.key === 'Escape' && !e.shiftKey) { + e.preventDefault() + if (e.repeat) return // Closing hands the keyboard back when the menu had it — and, as this // primitive always did for autoFocus menus, when it held the keyboard // and lost it again (a row that unmounted under it). @@ -382,11 +391,16 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI if (document.activeElement instanceof HTMLIFrameElement) onClose() } document.addEventListener('pointerdown', onPointerDown) - document.addEventListener('keydown', onKeyDown) + const onEscape = (event: KeyboardEvent): void => { if (event.key === 'Escape') onKeyDown(event) } + const onOtherKey = (event: KeyboardEvent): void => { if (event.key !== 'Escape') onKeyDown(event) } + document.addEventListener('keydown', onOtherKey) + document.addEventListener('keydown', onEscape, true) window.addEventListener('blur', onWindowBlur) return () => { + composition.dispose() document.removeEventListener('pointerdown', onPointerDown) - document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keydown', onOtherKey) + document.removeEventListener('keydown', onEscape, true) window.removeEventListener('blur', onWindowBlur) } }, [open, onClose, autoFocus]) @@ -425,6 +439,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI role="menuitem" className={clsx(css.item, selected && (selection === 'fill' ? css.selectedFill : css.selected), entry.danger === true && css.danger)} disabled={entry.disabled} + aria-keyshortcuts={entry.shortcut?.aria} aria-haspopup={hasSub ? 'menu' : undefined} aria-expanded={hasSub ? subOpen : undefined} onFocus={hasSub ? () => { setOpenSubmenuId(entry.id) } : undefined} @@ -438,6 +453,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI > {entry.icon !== undefined && {entry.icon}} {entry.label} + {entry.shortcut !== undefined && } {/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */} {selected && selection === 'check' && } @@ -450,10 +466,12 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI role="menuitem" className={css.item} disabled={sub.disabled} - onClick={() => { onSelect?.(sub.id) }} + aria-keyshortcuts={sub.shortcut?.aria} + onClick={() => { onSelect?.(sub.id); refocusAfterSelection() }} > {sub.icon !== undefined && {sub.icon}} {sub.label} + {sub.shortcut !== undefined && } ))} diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css index 8611494214..977bc6372c 100644 --- a/packages/client/ui-primitives/src/Modal.module.css +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -4,6 +4,7 @@ Cards size against this padding box: consumers cap growth with `max-height: 100%`, never their own viewport calc. */ .root { + pointer-events: auto; position: fixed; inset: 0; z-index: 1000; diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 58438873a8..430e44adfa 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -1,8 +1,9 @@ -import { useEffect } from 'react' +import { useRef } from 'react' import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' import { IconCloseOutlineRegular } from './icons/index.tsx' +import { useModalLayer } from './useModalLayer.ts' import css from './Modal.module.css' interface ModalBaseProps { @@ -14,6 +15,7 @@ interface ModalBaseProps { footer?: ReactNode className?: string contentClassName?: string + shortcutModal?: string } type ModalProps = ModalBaseProps & ( @@ -32,21 +34,17 @@ type ModalProps = ModalBaseProps & ( * @param props.children - body (inputs, etc.). * @param props.footer - action row (Cancel / Create). * @param props.contentClassName - optional class for a scrollable content region. + * @param props.shortcutModal - command scope allowed by shortcut owners; unnamed + * dialogs block application commands unless their owner allows the "other" scope. * @param props.headless - render children directly in the card (no default * header/close/body chrome); mask, card, Escape, and aria-label remain. * @returns null when closed; otherwise the overlay tree. */ export function Modal({ - open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, + open, onClose, title, closeLabel, description, children, footer, className, contentClassName, headless = false, shortcutModal, }: ModalProps) { - useEffect(() => { - if (!open) return - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose() - } - document.addEventListener('keydown', onKeyDown) - return () => { document.removeEventListener('keydown', onKeyDown) } - }, [open, onClose]) + const dialog = useRef(null) + useModalLayer(dialog, open, onClose) if (!open) return null @@ -54,6 +52,9 @@ export function Modal({