fix(shortcuts): preserve focus and tab layout, block overlay input
This commit is contained in:
1 parent
91423ea1b4
commit
6a82709a2b
71 files changed
+884
-372
No files matched your search
@@ -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: 08c32be1d519b9d5185decf6811dc92939649f6e
|
||||
README.zh.md: 70d8f969a1e3f2e7fd8af90c3d170a9bf60adefa
|
||||
README.md: 23c83c0eadd4aac736cd6ed1d11fd7628da70674
|
||||
README.zh.md: 8d5eb440e701f4c029dbd4d740f85bd8d097fcf6
|
||||
@@ -24,7 +24,7 @@ The original artwork lives in `resources/icon.png` and `resources/icon.svg`; pla
|
||||
|
||||
Shortcut overrides are stored in `app.getPath('userData')/keybindings.json`, separately from `DSH_HOME`. The main process validates and serializes changes before publishing accepted bindings. Failed reads retain the last accepted bindings and block edits, including Restore All; unreadable and future-version files remain unchanged. Development can isolate these preferences with `DSH_DESKTOP_USER_DATA_DIR`; the launcher prints its resolved path. See the [shortcut service](../../packages/client/shortcuts/README.md) for format and conflict semantics.
|
||||
|
||||
The macOS File menu displays the accepted single-key binding, including arrow keys, and routes Close Page or Window through the client page owner. On Windows and macOS, all complete accepted bindings are intercepted before main-document, embedded-frame, and browser-guest 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 dispatches main-document shortcuts through the DOM and forwards accepted embedded-frame bindings to the client resolver. Closing the last window preserves macOS application lifetime; Windows exits the desktop instance and stops its tasks. The [close decision](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md) records this lifecycle choice.
|
||||
The macOS File menu displays the accepted single-key binding, including arrow keys, and routes Close Page or Window through the client page owner. On Windows and macOS, all complete accepted bindings are intercepted before main-document, embedded-frame, and browser-guest input, including editing and terminal input. Recording and input-method composition remain protected. Update overlays block product shortcuts and editing-key delivery to the parent window and its browser guests from creation until the last overlay closes. Every overlay opening or closing invalidates pending chord state. The main process shares one overlay owner between update dialogs and shortcut input through explicit creation and input-state capabilities. 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 dispatches main-document shortcuts through the DOM and forwards accepted embedded-frame bindings to the client resolver. Closing the last window preserves macOS application lifetime; Windows exits the desktop instance and stops its tasks. The [close decision](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.md) records this lifecycle choice.
|
||||
|
||||
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.
|
||||
|
||||
@@ -112,6 +112,7 @@ pnpm run start:desktop
|
||||
|
||||
The Web counterparts are `pnpm run dev:web` and `pnpm run start:web`, documented in the [development guide](../../docs/development.md). Workspace development runs the current CLI and private Desktop Host packages under Electron RunAsNode. Plugin management and recovery use `$DSH_HOME/profiles/desktop`, separate from the disposable workspace runtime. The Host uses runtime module resolution in both development and packaged builds without creating official-package fallback links; developer-installed packages, including links, retain native priority. Use an unpacked application to exercise Electron RunAsNode, bundled pnpm, bundled dsh resources, plugin installation and repair paths.
|
||||
|
||||
The [native/renderer keyboard tests](tests/keyboard.spec.ts) compile in [their own Client test project](../../tsconfig.desktop-keyboard-tests.json), reached by the repository Client typecheck. Its Desktop imports are limited to Cordis-free input, persistence, IPC, browser-guest, and overlay modules.
|
||||
|
||||
### Startup onboarding
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Desktop Host 的 Platform API 请求与更新策略请求使用相同的 `x-clie
|
||||
|
||||
快捷键覆盖保存在 `app.getPath('userData')/keybindings.json`,与 `DSH_HOME` 分离。主进程校验并串行保存修改后才发布已接受键位。读取失败保留上次接受的键位并阻止编辑,包括全部恢复;不可读和未来版本的文件保持不变。开发时可通过 `DSH_DESKTOP_USER_DATA_DIR` 隔离这些偏好,启动器会输出解析后的路径。格式和冲突语义见[快捷键服务](../../packages/client/shortcuts/README.zh.md)。
|
||||
|
||||
macOS“文件”菜单显示已接受的单键绑定(包括方向键),并通过 Client 页面 owner 路由“关闭页面或窗口”。Windows 和 macOS 在主文档、内嵌 frame 和浏览器 guest 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 preload 转发一次。Linux 通过 DOM 分发主文档快捷键,并将已接受的内嵌 frame 绑定转发给 Client 解析器。关闭最后一个窗口后 macOS 保留应用生命周期;Windows 退出桌面实例并停止其任务。[关窗决策](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)记录了这一生命周期选择。
|
||||
macOS“文件”菜单显示已接受的单键绑定(包括方向键),并通过 Client 页面 owner 路由“关闭页面或窗口”。Windows 和 macOS 在主文档、内嵌 frame 和浏览器 guest 输入之前拦截所有已接受的完整绑定,包括编辑和终端输入。录制和输入法组合状态仍受保护。更新蒙层从创建到最后一个蒙层关闭期间阻挡父窗口及其浏览器 guest 的产品快捷键和编辑按键递送。每次打开或关闭蒙层都会作废待完成的组合键状态。主进程通过显式的创建能力和输入状态读取能力,让更新对话框与快捷键输入共享同一个蒙层管理实例。双键组合会将首键的初次按下事件交给页面,且不拦截其松开事件;完整组合及其重复事件会被消费。渲染进程将可配置绑定的分发交给原生适配器。双键组合不注册原生菜单快捷键。已接受的命令通过可信 preload 转发一次。Linux 通过 DOM 分发主文档快捷键,并将已接受的内嵌 frame 绑定转发给 Client 解析器。关闭最后一个窗口后 macOS 保留应用生命周期;Windows 退出桌面实例并停止其任务。[关窗决策](../../.agents/notes/implemented/architecture/2026-09-21-desktop-page-close-shortcuts.zh.md)记录了这一生命周期选择。
|
||||
|
||||
macOS PNG 使用带留白的圆角底板,供传统 ICNS 打包使用,包含最高 1024 像素的表示。它是扁平图标,并非 Icon Composer 文档。Apple 的[应用图标指南](https://developer.apple.com/design/human-interface-guidelines/app-icons)要求向 Icon Composer 提供未遮罩的图层;这些输入需要在 macOS 上单独导出,不能复用已做圆角的 ICNS 图案。发布前须在支持的 macOS 版本中验收 Finder 和 Dock 的显示效果。
|
||||
|
||||
@@ -112,6 +112,7 @@ pnpm run start:desktop
|
||||
|
||||
Web 侧的对应命令是 `pnpm run dev:web` 与 `pnpm run start:web`,见[开发指南](../../docs/development.zh.md)。Workspace 开发使用 Electron RunAsNode 运行当前 CLI 与私有 Desktop Host 包,插件管理和恢复使用 `$DSH_HOME/profiles/desktop`,与一次性工作区运行时分离。Host 在开发与打包构建中都使用 runtime 模块解析,不创建官方包的 fallback 链接;开发者安装的包(包括链接)保留原生优先级。需要验证 Electron RunAsNode、内置 pnpm、内置 dsh 资源、插件安装和修复时,应运行未封装安装器的应用目录。
|
||||
|
||||
[原生输入与渲染进程键盘测试](tests/keyboard.spec.ts)在[独立的 Client 测试项目](../../tsconfig.desktop-keyboard-tests.json)中编译,由仓库 Client 类型检查纳入。它只导入不依赖 Cordis 的 Desktop 输入、持久化、IPC、浏览器 guest 和蒙层模块。
|
||||
|
||||
### 启动引导
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts'
|
||||
* @param userData - Electron-resolved device preference directory.
|
||||
* @param platform - local device platform.
|
||||
* @param updateMenu - rebuild the application menu when the close accelerator or availability changes.
|
||||
* @param overlayInput - Current shell-owned input blocking state for the product window.
|
||||
* @returns menu construction, editor key delivery, window attachment, and teardown operations.
|
||||
*/
|
||||
export function installDesktopShortcuts(
|
||||
getWindow: () => BrowserWindow | undefined, userData: string, platform: ShortcutPlatform, updateMenu: () => void,
|
||||
overlayInput: (window: BrowserWindow) => { readonly revision: number; readonly blocked: boolean },
|
||||
): {
|
||||
fileMenu(labels: { fileMenu: string; closePage: string }): MenuItemConstructorOptions
|
||||
/**
|
||||
@@ -48,7 +50,7 @@ export function installDesktopShortcuts(
|
||||
const sendMenuClose = (): void => {
|
||||
const window = getWindow()
|
||||
if (window === undefined || window.isDestroyed() || !window.isFocused() || !window.isEnabled()
|
||||
|| revision === undefined || recording) return
|
||||
|| revision === undefined || recording || overlayInput(window).blocked) return
|
||||
window.webContents.send(DESKTOP_IPC.shortcutsInput, { kind: 'menu', commandId: 'page.close', revision })
|
||||
}
|
||||
let keys = new Set<string>()
|
||||
@@ -81,10 +83,10 @@ export function installDesktopShortcuts(
|
||||
persistence.setDefinitions(definitions)
|
||||
return persistence.readCurrent()
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.shortcutsEdit, async (event, input: unknown, revision: unknown) => {
|
||||
ipcMain.handle(DESKTOP_IPC.shortcutsEdit, async (event, input: unknown, expectedRevision: unknown) => {
|
||||
assertSender(event)
|
||||
if (typeof revision !== 'string') throw new Error('desktop shortcuts: invalid revision')
|
||||
return persistence.edit(parseShortcutEdit(input), revision as ShortcutRevision)
|
||||
if (typeof expectedRevision !== 'string') throw new Error('desktop shortcuts: invalid revision')
|
||||
return persistence.edit(parseShortcutEdit(input), expectedRevision as ShortcutRevision)
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.shortcutsRecording, (event, active: unknown) => {
|
||||
const window = assertSender(event)
|
||||
@@ -94,7 +96,8 @@ export function installDesktopShortcuts(
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.shortcutsCloseWindow, (event, expected: unknown) => {
|
||||
const window = assertSender(event)
|
||||
if (expected !== revision || revision === undefined || recording || !window.isFocused() || !window.isEnabled()) return
|
||||
if (expected !== revision || revision === undefined || recording || !window.isFocused() || !window.isEnabled()
|
||||
|| overlayInput(window).blocked) return
|
||||
window.close()
|
||||
})
|
||||
function attachInput(window: BrowserWindow, contents: WebContents, guestName?: DesktopBrowserLeaseId): () => void {
|
||||
@@ -103,6 +106,7 @@ export function installDesktopShortcuts(
|
||||
const consumed = new Map<string, 'press' | 'repeat'>()
|
||||
let inputFrame: typeof contents.focusedFrame = null
|
||||
let inputRevision: ShortcutRevision | undefined
|
||||
let overlayRevision = overlayInput(window).revision
|
||||
const resetInput = (): void => {
|
||||
deadKey = false; held.clear(); consumed.clear(); inputFrame = null; inputRevision = undefined
|
||||
if (!contents.isDestroyed()) contents.setIgnoreMenuShortcuts(false)
|
||||
@@ -122,6 +126,10 @@ export function installDesktopShortcuts(
|
||||
if (event.isMainFrame && !event.isSameDocument) clear()
|
||||
}
|
||||
const beforeInput = (event: Electron.Event, input: Input): void => {
|
||||
const overlay = overlayInput(window)
|
||||
if (overlay.revision !== overlayRevision) { resetInput(); overlayRevision = overlay.revision }
|
||||
if (overlay.blocked) { event.preventDefault(); return }
|
||||
if (event.defaultPrevented) { resetInput(); return }
|
||||
if (editingInput === contents) {
|
||||
held.clear()
|
||||
consumed.clear()
|
||||
@@ -216,7 +224,7 @@ export function installDesktopShortcuts(
|
||||
return {
|
||||
sendEditingKey(keyCode, modifiers) {
|
||||
const window = getWindow()
|
||||
if (window === undefined || window.isDestroyed()) return
|
||||
if (window === undefined || window.isDestroyed() || overlayInput(window).blocked) return
|
||||
const contents = [...guestInputs].find(([guest, owner]) => owner.window === window && !guest.isDestroyed() && guest.isFocused())?.[0]
|
||||
?? window.webContents
|
||||
contents.focus()
|
||||
|
||||
@@ -49,6 +49,7 @@ import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.t
|
||||
import { readDesktopRuntime } from './runtime-tree.ts'
|
||||
import { DesktopBrowserGuests } from './browser-guests.ts'
|
||||
import { installDesktopShortcuts } from './keyboard.ts'
|
||||
import { DesktopUpdateOverlays } from './update-overlay.ts'
|
||||
|
||||
let focusPrimaryWindow = (): void => {}
|
||||
let stopForRecovery = async (): Promise<void> => {}
|
||||
@@ -313,7 +314,8 @@ async function main(): Promise<void> {
|
||||
const currentMainWindow = (): BrowserWindow | undefined => mainWindow
|
||||
const ordinaryDialogs = new Set<AbortController>()
|
||||
const currentDialogWindow = (): BrowserWindow | undefined => welcomeWindow ?? mainWindow
|
||||
const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale)
|
||||
const updateOverlays = new DesktopUpdateOverlays()
|
||||
const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale, updateOverlays)
|
||||
const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true
|
||||
const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise<Electron.MessageBoxReturnValue> => {
|
||||
const controller = new AbortController()
|
||||
@@ -569,7 +571,7 @@ async function main(): Promise<void> {
|
||||
installDesktopDirectoryPicker(() => mainWindow)
|
||||
installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents)
|
||||
const shortcuts = installDesktopShortcuts(() => mainWindow, app.getPath('userData'),
|
||||
process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() })
|
||||
process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() }, window => updateOverlays.input(window))
|
||||
app.on('will-quit', () => { shortcuts.dispose() })
|
||||
|
||||
ipcMain.handle(DESKTOP_IPC.boot, async (event) => {
|
||||
@@ -1085,6 +1087,7 @@ async function main(): Promise<void> {
|
||||
}, policyAuth?.request)
|
||||
const policy = mandatoryPolicy
|
||||
mandatoryUI = new DesktopMandatoryUpdateWindow({
|
||||
overlays: updateOverlays,
|
||||
preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale,
|
||||
allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow,
|
||||
policy: () => policy.state, update: () => updates.state,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { DesktopLocale } from './locale.ts'
|
||||
import { assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
|
||||
import { desktopPolicyPage, type DesktopPolicyState } from './mandatory-update-policy.ts'
|
||||
import { MANDATORY_IPC } from './mandatory-update-ipc.ts'
|
||||
import { createUpdateOverlay } from './update-overlay.ts'
|
||||
import type { DesktopUpdateOverlays } from './update-overlay.ts'
|
||||
import { DesktopUpdateAttention } from './update-attention.ts'
|
||||
|
||||
/** A renderer action never carries a URL or authorizes a different version. */
|
||||
@@ -33,6 +33,7 @@ export interface MandatoryUpdateApi {
|
||||
/** Main-process operations owned by the policy client, updater, and application lifecycle. */
|
||||
export interface MandatoryUpdateWindowOptions {
|
||||
readonly preload: string
|
||||
readonly overlays: Pick<DesktopUpdateOverlays, 'create'>
|
||||
readonly locale: DesktopLocale
|
||||
readonly allowedPageOrigins: readonly string[]
|
||||
readonly parent: () => BrowserWindow | undefined
|
||||
@@ -190,7 +191,7 @@ export class DesktopMandatoryUpdateWindow {
|
||||
if (this.window === undefined) {
|
||||
const parent = this.options.parent()
|
||||
if (parent === undefined) return
|
||||
const window = createUpdateOverlay(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false)
|
||||
const window = this.options.overlays.create(parent, this.options.preload, this.options.locale.messages.mandatoryTitle, false)
|
||||
this.window = window
|
||||
window.setMenu(null)
|
||||
window.on('close', (event) => { if (!this.disposed && this.options.policy().blocking) { event.preventDefault(); app.quit() } })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Main-owned update confirmations; closing or replacing a dialog never grants installation permission. */
|
||||
import { ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type MessageBoxOptions, type MessageBoxReturnValue } from 'electron'
|
||||
import type { DesktopLocale } from './locale.ts'
|
||||
import { createUpdateOverlay } from './update-overlay.ts'
|
||||
import type { DesktopUpdateOverlays } from './update-overlay.ts'
|
||||
|
||||
/** Channels available only to the isolated update-dialog document. */
|
||||
export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed: 'dsh-update-dialog:changed', respond: 'dsh-update-dialog:respond' } as const
|
||||
@@ -51,8 +51,10 @@ export class DesktopUpdateDialog {
|
||||
/**
|
||||
* @param preload - Bundled isolated preload.
|
||||
* @param locale - Shell-owned copy or a reader of the current UI language.
|
||||
* @param overlays - Application-owned overlay creation and input tracking.
|
||||
*/
|
||||
constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale)) {
|
||||
constructor(private readonly preload: string, private readonly locale: DesktopLocale | (() => DesktopLocale),
|
||||
private readonly overlays: Pick<DesktopUpdateOverlays, 'create'>) {
|
||||
ipcMain.handle(UPDATE_DIALOG_IPC.status, (event) => { this.owned(event); return this.active?.view ?? null })
|
||||
ipcMain.handle(UPDATE_DIALOG_IPC.respond, (event, revision: unknown, index: unknown) => {
|
||||
this.owned(event)
|
||||
@@ -83,7 +85,7 @@ export class DesktopUpdateDialog {
|
||||
clearTimeout(this.closing)
|
||||
this.closing = undefined
|
||||
const existing = this.window
|
||||
const window = existing ?? createUpdateOverlay(parent, this.preload, options.title ?? locale.messages.updateTitle, false)
|
||||
const window = existing ?? this.overlays.create(parent, this.preload, options.title ?? locale.messages.updateTitle, false)
|
||||
this.window = window
|
||||
this.parent = parent
|
||||
const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message,
|
||||
|
||||
@@ -1,49 +1,69 @@
|
||||
/** Shell-owned modal windows cover the parent's content without replacing its native window controls. */
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
/**
|
||||
* @param parent - Product window whose content is blocked while the overlay is open.
|
||||
* @param preload - Isolated shell-only preload.
|
||||
* @param title - Localized window title.
|
||||
* @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets.
|
||||
* @returns A transparent child that follows its parent's content bounds and releases its listeners on close.
|
||||
*/
|
||||
export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true,
|
||||
...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false,
|
||||
skipTaskbar: true, hasShadow: false, title,
|
||||
webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
|
||||
})
|
||||
// macOS native modals animate the entire viewport as a sheet.
|
||||
const focus = (): void => { if (!window.isDestroyed()) window.focus() }
|
||||
const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() }
|
||||
if (!nativeModal && process.platform === 'darwin') {
|
||||
parent.on('focus', focus)
|
||||
parent.webContents.on('before-input-event', blockInput)
|
||||
window.once('closed', () => {
|
||||
parent.off('focus', focus)
|
||||
parent.webContents.off('before-input-event', blockInput)
|
||||
const unblockedInput = { revision: 0, blocked: false } as const
|
||||
|
||||
/** Tracks application-owned update overlays and their parent input state. */
|
||||
export class DesktopUpdateOverlays {
|
||||
private readonly inputStates = new WeakMap<BrowserWindow, { revision: number; active: number; readonly blocked: boolean }>()
|
||||
|
||||
/**
|
||||
* @param parent - Product window whose input may belong to an update dialog.
|
||||
* @returns Current blocking state; its revision changes whenever an overlay opens or closes.
|
||||
*/
|
||||
input(parent: BrowserWindow): { readonly revision: number; readonly blocked: boolean } {
|
||||
return this.inputStates.get(parent) ?? unblockedInput
|
||||
}
|
||||
|
||||
/**
|
||||
* @param parent - Product window whose content is blocked while the overlay is open.
|
||||
* @param preload - Isolated shell-only preload.
|
||||
* @param title - Localized window title.
|
||||
* @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets.
|
||||
* @returns A transparent child that follows its parent's content bounds and releases its listeners on close.
|
||||
*/
|
||||
create(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
parent, modal: nativeModal || process.platform !== 'darwin', show: false, frame: false, transparent: true,
|
||||
...parent.getContentBounds(), resizable: false, minimizable: false, maximizable: false,
|
||||
skipTaskbar: true, hasShadow: false, title,
|
||||
webPreferences: { preload, contextIsolation: true, sandbox: true, nodeIntegration: false, webSecurity: true },
|
||||
})
|
||||
const inputState = this.inputStates.get(parent) ?? { revision: 0, active: 0, get blocked() { return this.active > 0 } }
|
||||
this.inputStates.set(parent, inputState)
|
||||
inputState.active++; inputState.revision++
|
||||
window.once('closed', () => { inputState.active--; inputState.revision++ })
|
||||
// macOS native modals animate the entire viewport as a sheet.
|
||||
const focus = (): void => { if (!window.isDestroyed()) window.focus() }
|
||||
const blockInput = (event: Electron.Event): void => { event.preventDefault(); focus() }
|
||||
if (!nativeModal && process.platform === 'darwin') {
|
||||
parent.on('focus', focus)
|
||||
// Shell dialogs block input before product shortcut listeners can dispatch it.
|
||||
parent.webContents.prependListener('before-input-event', blockInput)
|
||||
window.once('closed', () => {
|
||||
parent.off('focus', focus)
|
||||
parent.webContents.off('before-input-event', blockInput)
|
||||
})
|
||||
}
|
||||
const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) }
|
||||
parent.on('move', follow)
|
||||
parent.on('resize', follow)
|
||||
let closed = false
|
||||
let blur: string | undefined
|
||||
const unblur = (): void => {
|
||||
if (blur === undefined || parent.isDestroyed()) return
|
||||
void parent.webContents.removeInsertedCSS(blur).catch((error: unknown) => { console.warn('desktop update: could not remove background blur', error) })
|
||||
blur = undefined
|
||||
}
|
||||
void parent.webContents.insertCSS('body { filter: blur(2px) !important; }').then((key) => {
|
||||
blur = key
|
||||
if (closed) unblur()
|
||||
}).catch((error: unknown) => { console.warn('desktop update: could not blur background', error) })
|
||||
window.once('closed', () => { closed = true; unblur() })
|
||||
window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) })
|
||||
window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() })
|
||||
window.setMenu(null)
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
return window
|
||||
}
|
||||
const follow = (): void => { if (!window.isDestroyed()) window.setBounds(parent.getContentBounds()) }
|
||||
parent.on('move', follow)
|
||||
parent.on('resize', follow)
|
||||
let closed = false
|
||||
let blur: string | undefined
|
||||
const unblur = (): void => {
|
||||
if (blur === undefined || parent.isDestroyed()) return
|
||||
void parent.webContents.removeInsertedCSS(blur).catch((error: unknown) => { console.warn('desktop update: could not remove background blur', error) })
|
||||
blur = undefined
|
||||
}
|
||||
void parent.webContents.insertCSS('body { filter: blur(2px) !important; }').then((key) => {
|
||||
blur = key
|
||||
if (closed) unblur()
|
||||
}).catch((error: unknown) => { console.warn('desktop update: could not blur background', error) })
|
||||
window.once('closed', () => { closed = true; unblur() })
|
||||
window.once('closed', () => { parent.off('move', follow); parent.off('resize', follow) })
|
||||
window.once('ready-to-show', () => { if (!window.isDestroyed()) window.show() })
|
||||
window.setMenu(null)
|
||||
window.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||
return window
|
||||
}
|
||||
@@ -7,19 +7,34 @@ import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { BrowserWindow, WebContents, WebFrameMain } from 'electron'
|
||||
import type { DesktopShortcutInput, ShortcutBinding, ShortcutCommandId, ShortcutConfigSnapshot,
|
||||
ShortcutDefinition, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import { ShortcutRegistry } from '../../../packages/client/shortcuts/src/client/registry.ts'
|
||||
import { installKeyboard } from '../../../packages/client/shortcuts/src/client/dom.ts'
|
||||
import { installNativeKeyboard } from '../../../packages/client/shortcuts/src/client/native.ts'
|
||||
import { ShortcutRegistry } from '@deepseek-ai/dsh-client-shortcuts/src/client/registry.ts'
|
||||
import { installKeyboard } from '@deepseek-ai/dsh-client-shortcuts/src/client/dom.ts'
|
||||
import { installNativeKeyboard } from '@deepseek-ai/dsh-client-shortcuts/src/client/native.ts'
|
||||
import type { DesktopBrowserLeaseId, DesktopBrowserReservation } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
|
||||
import { DESKTOP_IPC } from '../src/ipc.ts'
|
||||
|
||||
const ipc = vi.hoisted(() => ({ handle: vi.fn(), removeHandler: vi.fn() }))
|
||||
vi.mock('electron', () => ({ ipcMain: ipc, app: { isPackaged: true }, session: { fromPartition: () => ({
|
||||
const overlays = await vi.hoisted(async () => {
|
||||
const { EventEmitter } = await import('node:events')
|
||||
class Window extends EventEmitter {
|
||||
private destroyed = false
|
||||
readonly webContents = { setWindowOpenHandler: vi.fn() }
|
||||
readonly focus = vi.fn()
|
||||
readonly show = vi.fn()
|
||||
readonly setBounds = vi.fn()
|
||||
setMenu() {}
|
||||
isDestroyed() { return this.destroyed }
|
||||
destroy() { this.destroyed = true; this.emit('closed') }
|
||||
}
|
||||
return { Window }
|
||||
})
|
||||
vi.mock('electron', () => ({ ipcMain: ipc, BrowserWindow: overlays.Window, app: { isPackaged: true }, session: { fromPartition: () => ({
|
||||
setPermissionRequestHandler: vi.fn(), setPermissionCheckHandler: vi.fn(), setDevicePermissionHandler: vi.fn(),
|
||||
setDisplayMediaRequestHandler: vi.fn(), on: vi.fn(), webRequest: { onBeforeRequest: vi.fn() },
|
||||
}) } }))
|
||||
const { installDesktopShortcuts } = await import('../src/keyboard.ts')
|
||||
const { DesktopBrowserGuests } = await import('../src/browser-guests.ts')
|
||||
const { DesktopUpdateOverlays } = await import('../src/update-overlay.ts')
|
||||
afterEach(() => { vi.clearAllMocks() })
|
||||
|
||||
type FrameFixture = { url: WebFrameMain['url']; name: WebFrameMain['name']; parent: FrameFixture | null }
|
||||
@@ -40,6 +55,7 @@ type KeyboardFixture = Omit<ReturnType<typeof installDesktopShortcuts>, 'attach'
|
||||
const installFixture = installDesktopShortcuts as (
|
||||
getWindow: () => WindowFixture | undefined, userData: string,
|
||||
platform: Parameters<typeof installDesktopShortcuts>[2], updateMenu: () => void,
|
||||
overlayInput: (window: WindowFixture) => { readonly revision: number; readonly blocked: boolean },
|
||||
) => KeyboardFixture
|
||||
type GuestsFixture = {
|
||||
acquire(owner: ContentsFixture, workspace: unknown): DesktopBrowserReservation
|
||||
@@ -51,6 +67,16 @@ function desktopDefaults(binding: ShortcutBinding): ShortcutDefinition['defaults
|
||||
return { 'desktop:macos': binding, 'desktop:windows': binding, 'desktop:linux': binding }
|
||||
}
|
||||
|
||||
function browserGuest(reservation: DesktopBrowserReservation) {
|
||||
const frame: FrameFixture = { url: `about:blank#${reservation.lease}`, name: '', parent: null }
|
||||
const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame,
|
||||
getURL: () => frame.url, isDestroyed: () => false, isFocused: vi.fn(() => true),
|
||||
setWindowOpenHandler: vi.fn(), setIgnoreMenuShortcuts: vi.fn(), send: vi.fn(), close: vi.fn(),
|
||||
focus: vi.fn(), sendInputEvent: vi.fn() })
|
||||
onTestFinished(() => { guest.emit('destroyed') })
|
||||
return { frame, guest }
|
||||
}
|
||||
|
||||
async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-keyboard-'))
|
||||
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
@@ -62,7 +88,8 @@ async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') {
|
||||
isFocused: vi.fn(() => true), isEnabled: vi.fn(() => true), close: vi.fn() })
|
||||
let current: WindowFixture | undefined = window
|
||||
const updateMenu = vi.fn()
|
||||
const keyboard = installFixture(() => current, root, platform, updateMenu)
|
||||
const updateOverlays = new DesktopUpdateOverlays()
|
||||
const keyboard = installFixture(() => current, root, platform, updateMenu, window => updateOverlays.input(window as BrowserWindow))
|
||||
keyboard.attach(current)
|
||||
onTestFinished(() => { keyboard.dispose() })
|
||||
const handlers = new Map<string, (event: InvokeFixture, ...args: unknown[]) => unknown>(
|
||||
@@ -72,7 +99,23 @@ async function fixture(platform: 'macos' | 'windows' | 'linux' = 'macos') {
|
||||
const definitions: readonly ShortcutDefinition[] = [
|
||||
{ id: 'sidebar.left.toggle' as ShortcutCommandId, defaults: desktopDefaults({ code: 'KeyB', modifiers: ['primary'] }) },
|
||||
]
|
||||
return { keyboard, window, contents, frame, event, handlers, call, definitions, updateMenu, detach: () => { current = undefined } }
|
||||
return { keyboard, window, updateOverlays, contents, frame, event, handlers, call, definitions, updateMenu,
|
||||
detach: () => { current = undefined } }
|
||||
}
|
||||
|
||||
function updateOverlayFixture(f: Awaited<ReturnType<typeof fixture>>) {
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
onTestFinished(() => { platform.mockRestore() })
|
||||
const parent: WindowFixture & Pick<BrowserWindow, 'getContentBounds'> = Object.assign(f.window, {
|
||||
getContentBounds: () => ({ x: 0, y: 0, width: 900, height: 650 }),
|
||||
})
|
||||
Object.assign(f.contents, { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) })
|
||||
return () => {
|
||||
const overlay = f.updateOverlays.create(parent as BrowserWindow, 'test-overlay-preload', 'Update', false)
|
||||
onTestFinished(() => { if (!overlay.isDestroyed()) overlay.destroy() })
|
||||
expect(vi.spyOn(overlay, 'show')).not.toHaveBeenCalled()
|
||||
return overlay
|
||||
}
|
||||
}
|
||||
|
||||
it('mirrors only successful bindings, suppresses recording menus, and invalidates pre-navigation drafts', async () => {
|
||||
@@ -97,6 +140,115 @@ it('mirrors only successful bindings, suppresses recording menus, and invalidate
|
||||
expect((await f.call<ShortcutSaveResult>(DESKTOP_IPC.shortcutsEdit, { type: 'reset-all' }, saved.snapshot.revision)).status).toBe('stale')
|
||||
})
|
||||
|
||||
it('blocks macOS shortcuts while an update overlay loads and clears held and consumed keys before dismissal', async () => {
|
||||
const f = await fixture()
|
||||
const open = updateOverlayFixture(f)
|
||||
const snapshot = await f.call<ShortcutConfigSnapshot>(DESKTOP_IPC.shortcutsGet, [
|
||||
{ id: 'page.close', defaults: { 'desktop:macos': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } },
|
||||
{ id: 'sidebar.left.toggle', defaults: desktopDefaults({ code: 'KeyK', modifiers: ['primary'] }) },
|
||||
])
|
||||
const menu = f.keyboard.fileMenu({ fileMenu: 'File', closePage: 'Close' }).submenu as Electron.MenuItemConstructorOptions[]
|
||||
const closeMenu = menu[0]!.click as () => void
|
||||
const press = (code: string, type = 'keyDown', meta = false) => {
|
||||
const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true } }
|
||||
f.contents.emit('before-input-event', event, { type, code, key: code.slice(3), modifiers: meta ? ['meta'] : [],
|
||||
meta, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
|
||||
return event.defaultPrevented
|
||||
}
|
||||
f.contents.send.mockClear()
|
||||
expect(press('KeyA')).toBe(false)
|
||||
const first = open()
|
||||
expect(press('KeyB')).toBe(true)
|
||||
expect(press('KeyK', 'keyDown', true)).toBe(true)
|
||||
closeMenu()
|
||||
await f.call(DESKTOP_IPC.shortcutsCloseWindow, snapshot.revision)
|
||||
f.keyboard.sendEditingKey('C', ['control'])
|
||||
expect(f.window.close).not.toHaveBeenCalled()
|
||||
expect(f.contents.sendInputEvent).not.toHaveBeenCalled()
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
first.destroy()
|
||||
expect(press('KeyB')).toBe(false)
|
||||
expect(press('KeyB', 'keyUp')).toBe(false)
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
expect(press('KeyA')).toBe(false)
|
||||
expect(press('KeyB')).toBe(true)
|
||||
expect(f.contents.send).toHaveBeenCalledOnce()
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ code: 'KeyA', secondCode: 'KeyB' }))
|
||||
const second = open()
|
||||
expect(press('KeyB', 'keyUp')).toBe(true)
|
||||
second.destroy()
|
||||
expect(press('KeyB', 'keyUp')).toBe(false)
|
||||
expect(press('KeyK', 'keyDown', true)).toBe(true)
|
||||
expect(f.contents.send).toHaveBeenCalledTimes(2)
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ code: 'KeyK', meta: true }))
|
||||
closeMenu()
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput, expect.objectContaining({ kind: 'menu' }))
|
||||
await f.call(DESKTOP_IPC.shortcutsCloseWindow, snapshot.revision)
|
||||
expect(f.window.close).toHaveBeenCalledOnce()
|
||||
f.keyboard.sendEditingKey('C', ['control'])
|
||||
expect(f.contents.sendInputEvent).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([false, true])('blocks approved browser guest input across update overlays, including guests attached while blocked: %s', async (attachWhileBlocked) => {
|
||||
const f = await fixture()
|
||||
const open = updateOverlayFixture(f)
|
||||
await f.call(DESKTOP_IPC.shortcutsGet, [
|
||||
{ id: 'page.close', defaults: { 'desktop:macos': { code: 'KeyA', secondCode: 'KeyB', modifiers: [] } } },
|
||||
{ id: 'sidebar.left.toggle', defaults: desktopDefaults({ code: 'KeyK', modifiers: ['primary'] }) },
|
||||
])
|
||||
const guests = new DesktopBrowserGuests(() => undefined) as GuestsFixture
|
||||
guests.bind(f.window, (guest, name) => f.keyboard.attachGuest(f.window, guest, name))
|
||||
const reservation = guests.acquire(f.contents, 'session:test')
|
||||
const { frame, guest } = browserGuest(reservation)
|
||||
const attach = () => {
|
||||
const event = { preventDefault: vi.fn() }
|
||||
f.contents.emit('will-attach-webview', event, {}, { src: frame.url, partition: reservation.partition })
|
||||
expect(event.preventDefault).not.toHaveBeenCalled()
|
||||
f.contents.emit('did-attach-webview', {}, guest)
|
||||
guest.emit('dom-ready')
|
||||
}
|
||||
if (!attachWhileBlocked) attach()
|
||||
const first = open()
|
||||
if (attachWhileBlocked) attach()
|
||||
const press = (code: string, type = 'keyDown', meta = false) => {
|
||||
const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true } }
|
||||
guest.emit('before-input-event', event, { type, code, key: code.slice(3), modifiers: meta ? ['meta'] : [],
|
||||
meta, control: false, alt: false, shift: false, isAutoRepeat: false, isComposing: false })
|
||||
return event.defaultPrevented
|
||||
}
|
||||
f.contents.send.mockClear()
|
||||
expect(press('KeyK', 'keyDown', true)).toBe(true)
|
||||
f.keyboard.sendEditingKey('C', ['control'])
|
||||
expect(guest.sendInputEvent).not.toHaveBeenCalled()
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
const second = open()
|
||||
first.destroy()
|
||||
expect(press('KeyA')).toBe(true)
|
||||
expect(press('KeyB')).toBe(true)
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
second.destroy()
|
||||
expect(press('KeyB')).toBe(false)
|
||||
expect(press('KeyB', 'keyUp')).toBe(false)
|
||||
expect(press('KeyA')).toBe(false)
|
||||
open().destroy()
|
||||
expect(press('KeyB')).toBe(false)
|
||||
expect(press('KeyB', 'keyUp')).toBe(false)
|
||||
expect(f.contents.send).not.toHaveBeenCalled()
|
||||
expect(press('KeyA')).toBe(false)
|
||||
expect(press('KeyB')).toBe(true)
|
||||
expect(f.contents.send).toHaveBeenCalledOnce()
|
||||
expect(f.contents.send).toHaveBeenLastCalledWith(DESKTOP_IPC.shortcutsInput,
|
||||
expect.objectContaining({ kind: 'webview', frameName: reservation.lease, code: 'KeyA', secondCode: 'KeyB' }))
|
||||
open().destroy()
|
||||
expect(press('KeyB', 'keyUp')).toBe(false)
|
||||
expect(press('KeyK', 'keyDown', true)).toBe(true)
|
||||
expect(f.contents.send).toHaveBeenCalledTimes(2)
|
||||
f.keyboard.sendEditingKey('C', ['control'])
|
||||
expect(guest.sendInputEvent).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('rejects other windows, subframes, remote/shell pages, and malformed edits', async () => {
|
||||
const f = await fixture()
|
||||
const get = f.handlers.get(DESKTOP_IPC.shortcutsGet)!
|
||||
@@ -563,12 +715,7 @@ it.each(['macos', 'windows', 'linux'] as const)('routes approved %s browser gues
|
||||
const attach = vi.fn((guest: ContentsFixture, name: DesktopBrowserLeaseId) => f.keyboard.attachGuest(f.window, guest, name))
|
||||
guests.bind(f.window, attach)
|
||||
const reservation = guests.acquire(f.contents, 'session:test')
|
||||
const frame: FrameFixture = { url: `about:blank#${reservation.lease}`, name: '', parent: null }
|
||||
const guest = Object.assign(new EventEmitter(), { mainFrame: frame, focusedFrame: frame,
|
||||
getURL: () => frame.url, isDestroyed: () => false, isFocused: vi.fn(() => true),
|
||||
setWindowOpenHandler: vi.fn(), setIgnoreMenuShortcuts: vi.fn(), send: vi.fn(), close: vi.fn(),
|
||||
focus: vi.fn(), sendInputEvent: vi.fn() })
|
||||
onTestFinished(() => { guest.emit('destroyed') })
|
||||
const { frame, guest } = browserGuest(reservation)
|
||||
const rejected = { preventDefault: vi.fn() }
|
||||
f.contents.emit('will-attach-webview', rejected, {}, { src: 'about:blank#unknown', partition: reservation.partition })
|
||||
expect(rejected.preventDefault).toHaveBeenCalledOnce()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import type { BrowserWindow, WebContents, WebFrameMain } from 'electron'
|
||||
import { DesktopMandatoryUpdateWindow, type MandatoryUpdateView } from '../src/mandatory-update-window.ts'
|
||||
import { MANDATORY_IPC } from '../src/mandatory-update-ipc.ts'
|
||||
import { resolveDesktopLocale } from '../src/locale.ts'
|
||||
@@ -13,7 +13,11 @@ vi.mock('electron', () => ({ ipcMain: {
|
||||
handle: (channel: string, handler: (...args: unknown[]) => unknown) => native.handlers.set(channel, handler),
|
||||
removeHandler: (channel: string) => native.handlers.delete(channel),
|
||||
}, app: { quit: native.quit }, shell: { openExternal: native.open }, clipboard: { writeText: native.write, readText: native.read } }))
|
||||
vi.mock('../src/update-overlay.ts', () => ({ createUpdateOverlay: vi.fn(() => window) }))
|
||||
|
||||
type WindowFixture = EventEmitter & Pick<BrowserWindow, 'setMenu' | 'setTitle' | 'loadURL' | 'destroy' | 'isDestroyed'
|
||||
| 'isFocused' | 'isMinimized' | 'focus' | 'show' | 'restore'> & {
|
||||
webContents: EventEmitter & Pick<WebContents, 'send' | 'setWindowOpenHandler'> & { mainFrame: Pick<WebFrameMain, 'url'> }
|
||||
}
|
||||
|
||||
let window: ReturnType<typeof fakeWindow>
|
||||
let ui: DesktopMandatoryUpdateWindow | undefined
|
||||
@@ -32,8 +36,10 @@ function setup(platform: NodeJS.Platform = 'darwin') {
|
||||
let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' }
|
||||
let update: DesktopUpdateState = { phase: 'ready', version: '1.0.1-nightly.1' }
|
||||
const install = vi.fn(async () => update)
|
||||
ui = new DesktopMandatoryUpdateWindow({ preload: 'owned', locale: resolveDesktopLocale('zh'),
|
||||
allowedPageOrigins: ['https://downloads.example.com'], parent: () => window as unknown as BrowserWindow,
|
||||
const createOverlay = (): WindowFixture => window
|
||||
const parent = createOverlay as () => BrowserWindow
|
||||
ui = new DesktopMandatoryUpdateWindow({ overlays: { create: parent }, preload: 'owned', locale: resolveDesktopLocale('zh'),
|
||||
allowedPageOrigins: ['https://downloads.example.com'], parent,
|
||||
policy: () => policy, update: () => update, refresh: async () => {}, download: async () => update, install })
|
||||
ui.sync()
|
||||
const event = { sender: window.webContents, senderFrame: window.webContents.mainFrame }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { DesktopUpdateDialog, UPDATE_DIALOG_IPC } from '../src/update-dialog.ts'
|
||||
import { DesktopUpdateOverlays } from '../src/update-overlay.ts'
|
||||
import { resolveDesktopLocale, type DesktopLocale } from '../src/locale.ts'
|
||||
|
||||
const fixture = await vi.hoisted(async () => {
|
||||
@@ -45,7 +46,7 @@ afterEach(() => {
|
||||
|
||||
function setup(locale: DesktopLocale | (() => DesktopLocale) = resolveDesktopLocale('zh-CN')) {
|
||||
const parent = new fixture.FakeWindow({})
|
||||
dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale)
|
||||
dialogs = new DesktopUpdateDialog('preload-update-dialog.cjs', locale, new DesktopUpdateOverlays())
|
||||
const show = (signal?: AbortSignal) => dialogs!.show(parent as unknown as BrowserWindow, {
|
||||
message: '下载完成', buttons: ['安装并重启'], cancelId: 1, ...(signal === undefined ? {} : { signal }),
|
||||
})
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'
|
||||
import { createUpdateOverlay } from '../src/update-overlay.ts'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { BrowserWindow, BrowserWindowConstructorOptions, WebContents } from 'electron'
|
||||
import { DesktopUpdateOverlays } from '../src/update-overlay.ts'
|
||||
|
||||
const native = vi.hoisted(() => ({ create: vi.fn<(options: BrowserWindowConstructorOptions) => object>() }))
|
||||
vi.mock('electron', () => ({ BrowserWindow: function (options: object) { return native.create(options) } }))
|
||||
|
||||
type ParentFixture = EventEmitter & Pick<BrowserWindow, 'getContentBounds' | 'isDestroyed'> & {
|
||||
webContents: EventEmitter & Pick<WebContents, 'insertCSS' | 'removeInsertedCSS'>
|
||||
}
|
||||
|
||||
it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => {
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
const parent = Object.assign(new EventEmitter(), {
|
||||
const parent: ParentFixture = Object.assign(new EventEmitter(), {
|
||||
getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }),
|
||||
webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }),
|
||||
isDestroyed: () => false,
|
||||
@@ -19,7 +23,7 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu
|
||||
})
|
||||
native.create.mockReturnValue(window)
|
||||
try {
|
||||
createUpdateOverlay(parent as unknown as BrowserWindow, 'owned', 'Update required', false)
|
||||
new DesktopUpdateOverlays().create(parent as BrowserWindow, 'owned', 'Update required', false)
|
||||
expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false }))
|
||||
const event = { preventDefault: vi.fn() }
|
||||
parent.webContents.emit('before-input-event', event)
|
||||
@@ -30,3 +34,44 @@ it('keeps the macOS mandatory overlay stationary and blocks parent keyboard inpu
|
||||
expect(parent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
} finally { platform.mockRestore() }
|
||||
})
|
||||
|
||||
it('blocks each parent until its last owned overlay closes and invalidates each transition', () => {
|
||||
const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
onTestFinished(() => { platform.mockRestore() })
|
||||
const parent = (): ParentFixture => Object.assign(new EventEmitter(), {
|
||||
getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }),
|
||||
webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }),
|
||||
isDestroyed: () => false,
|
||||
})
|
||||
const firstParent = parent() as BrowserWindow
|
||||
const secondParent = parent() as BrowserWindow
|
||||
const overlays = new DesktopUpdateOverlays()
|
||||
const isolated = new DesktopUpdateOverlays()
|
||||
native.create.mockImplementation(() => {
|
||||
const window = Object.assign(new EventEmitter(), {
|
||||
webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(),
|
||||
setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false,
|
||||
})
|
||||
onTestFinished(() => { window.emit('closed') })
|
||||
return window
|
||||
})
|
||||
expect(overlays.input(firstParent)).toEqual({ revision: 0, blocked: false })
|
||||
const first = overlays.create(firstParent, 'owned', 'Update required', false)
|
||||
const second = overlays.create(firstParent, 'owned', 'Confirm installation', false)
|
||||
const other = overlays.create(secondParent, 'owned', 'Update required', false)
|
||||
expect(overlays.input(firstParent)).toMatchObject({ revision: 2, blocked: true })
|
||||
expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true })
|
||||
expect(isolated.input(firstParent)).toEqual({ revision: 0, blocked: false })
|
||||
first.emit('closed')
|
||||
expect(overlays.input(firstParent)).toMatchObject({ revision: 3, blocked: true })
|
||||
expect(firstParent.webContents.listenerCount('before-input-event')).toBe(1)
|
||||
second.emit('closed')
|
||||
expect(overlays.input(firstParent)).toMatchObject({ revision: 4, blocked: false })
|
||||
expect(firstParent.listenerCount('move')).toBe(0)
|
||||
expect(firstParent.listenerCount('resize')).toBe(0)
|
||||
expect(firstParent.listenerCount('focus')).toBe(0)
|
||||
expect(firstParent.webContents.listenerCount('before-input-event')).toBe(0)
|
||||
expect(overlays.input(secondParent)).toMatchObject({ revision: 1, blocked: true })
|
||||
other.emit('closed')
|
||||
expect(overlays.input(secondParent)).toMatchObject({ revision: 2, blocked: false })
|
||||
})
|
||||
@@ -49,9 +49,6 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
|
||||
await trigger.click()
|
||||
const menu = page.getByRole('menu')
|
||||
await menu.waitFor()
|
||||
expect(await menu.getByRole('menuitem').allTextContents()).toEqual([
|
||||
'Pin session', 'Rename', 'Fork session', 'Archive session', 'Export session', 'Copy session ID',
|
||||
])
|
||||
expect(await menu.getByRole('separator').count()).toBe(1)
|
||||
await compareOrRefreshGolden(
|
||||
SESSION_ACTION_EXPECTED,
|
||||
@@ -74,9 +71,12 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
|
||||
await row.hover()
|
||||
await trigger.click()
|
||||
await menu.waitFor()
|
||||
expect(await menu.getByRole('menuitem').allTextContents()).toEqual([
|
||||
'Pin session', 'Rename', 'Fork session', 'Archive session',
|
||||
])
|
||||
const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session']
|
||||
const remainingItems = menu.getByRole('menuitem')
|
||||
expect(await remainingItems.count()).toBe(remainingNames.length)
|
||||
for (const [index, name] of remainingNames.entries()) {
|
||||
expect(await remainingItems.nth(index).and(menu.getByRole('menuitem', { name, exact: true })).count()).toBe(1)
|
||||
}
|
||||
expect(await menu.getByRole('separator').count()).toBe(0)
|
||||
expect(console.pageErrors).toEqual([])
|
||||
await assertFixtureInventory(EXPECTED, [
|
||||
|
||||
@@ -186,15 +186,23 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
|
||||
const blankViewport = page.viewportSize()!
|
||||
try {
|
||||
await blankColumn.locator('[data-sidebar-right-toggle]').click()
|
||||
await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' })
|
||||
await page.setViewportSize({ width: 767, height: blankViewport.height })
|
||||
await expect.poll(() => columns(page)).toEqual([56, 711, 0])
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
// The fullscreen shell keeps its viewport box while its docked content closes.
|
||||
await expect.poll(() => blankColumn.locator('[data-sidebar-right-open]').count()).toBe(1)
|
||||
await expect.poll(() => blankColumn.locator('[data-dockkit-host="dock"]:not([hidden])').evaluate(element => getComputedStyle(element).transform))
|
||||
.toBe('none')
|
||||
await expect.poll(() => blankColumn.locator('[data-sidebar-right-panel]').boundingBox())
|
||||
.toEqual({ x: 0, y: 0, width: 767, height: blankViewport.height })
|
||||
await blankColumn.getByText('Workspace preview is available.', { exact: true }).waitFor()
|
||||
await blankColumn.locator('[data-sidebar-right-toggle]').click()
|
||||
await blankColumn.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' })
|
||||
} finally {
|
||||
await page.setViewportSize(blankViewport)
|
||||
}
|
||||
await expect.poll(() => columns(page)).toEqual([280, blankViewport.width - 280, 0])
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await blankColumn.locator('[data-dockkit-add-tab]').click()
|
||||
await blankColumn.locator('[data-sidebar-right-guide-entry="terminal"]')
|
||||
|
||||
@@ -6,6 +6,55 @@
|
||||
- searchbox "Search shortcuts"
|
||||
- region "Application":
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Close current page or window
|
||||
- button "Remove shortcut for Close current page or window"
|
||||
- button "Edit shortcut for Close current page or window"
|
||||
- button "Close current page or window ⌥ ⌘ W": ⌥ ⌘ W
|
||||
- listitem:
|
||||
- text: Refresh current page
|
||||
- button "Edit shortcut for Refresh current page"
|
||||
- button "Refresh current page No shortcut": No shortcut
|
||||
- listitem:
|
||||
- text: Toggle panel fullscreen
|
||||
- button "Remove shortcut for Toggle panel fullscreen"
|
||||
- button "Edit shortcut for Toggle panel fullscreen"
|
||||
- button "Toggle panel fullscreen ⌥ ⌘ Enter": ⌥ ⌘ Enter
|
||||
- listitem:
|
||||
- text: Split
|
||||
- button "Remove shortcut for Split"
|
||||
- button "Edit shortcut for Split"
|
||||
- button "Split ⌘ \\": ⌘ \
|
||||
- listitem:
|
||||
- text: Archive session
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session ⌥ ⌘ A": ⌥ ⌘ A
|
||||
- listitem:
|
||||
- text: Fork session
|
||||
- button "Remove shortcut for Fork session"
|
||||
- button "Edit shortcut for Fork session"
|
||||
- button "Fork session ⇧ ⌘ F": ⇧ ⌘ F
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
- button "Edit shortcut for New Session"
|
||||
- button "New Session ⌥ ⌘ N": ⌥ ⌘ N
|
||||
- listitem:
|
||||
- text: Rename session
|
||||
- button "Remove shortcut for Rename session"
|
||||
- button "Edit shortcut for Rename session"
|
||||
- button "Rename session ⇧ ⌘ R": ⇧ ⌘ R
|
||||
- listitem:
|
||||
- text: Search sessions
|
||||
- button "Remove shortcut for Search sessions"
|
||||
- button "Edit shortcut for Search sessions"
|
||||
- button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
- button "Edit shortcut for Open settings"
|
||||
- button "Open settings ⌘ ,": ⌘ ,
|
||||
- listitem:
|
||||
- text: Open keyboard shortcuts
|
||||
- button "Remove shortcut for Open keyboard shortcuts"
|
||||
@@ -22,102 +71,53 @@
|
||||
- button "Edit shortcut for Toggle right sidebar"
|
||||
- button "Toggle right sidebar ⇧ ⌘ B": ⇧ ⌘ B
|
||||
- listitem:
|
||||
- text: Split
|
||||
- button "Remove shortcut for Split"
|
||||
- button "Edit shortcut for Split"
|
||||
- button "Split ⌘ \\": ⌘ \
|
||||
- listitem:
|
||||
- text: Toggle panel fullscreen
|
||||
- button "Remove shortcut for Toggle panel fullscreen"
|
||||
- button "Edit shortcut for Toggle panel fullscreen"
|
||||
- button "Toggle panel fullscreen ⌥ ⌘ Enter": ⌥ ⌘ Enter
|
||||
- listitem:
|
||||
- text: Close current page or window
|
||||
- button "Remove shortcut for Close current page or window"
|
||||
- button "Edit shortcut for Close current page or window"
|
||||
- button "Close current page or window ⌥ ⌘ W": ⌥ ⌘ W
|
||||
- listitem:
|
||||
- text: Refresh current page
|
||||
- button "Edit shortcut for Refresh current page"
|
||||
- button "Refresh current page No shortcut": No shortcut
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
- button "Edit shortcut for New Session"
|
||||
- button "New Session ⌥ ⌘ N": ⌥ ⌘ N
|
||||
- listitem:
|
||||
- text: Search sessions
|
||||
- button "Remove shortcut for Search sessions"
|
||||
- button "Edit shortcut for Search sessions"
|
||||
- button "Search sessions ⌥ ⌘ K": ⌥ ⌘ K
|
||||
- text: New terminal
|
||||
- button "Remove shortcut for New terminal"
|
||||
- button "Edit shortcut for New terminal"
|
||||
- 'button "New terminal ⌃ `"': "⌃ `"
|
||||
- listitem:
|
||||
- text: Add workspace
|
||||
- button "Remove shortcut for Add workspace"
|
||||
- button "Edit shortcut for Add workspace"
|
||||
- button "Add workspace ⌥ ⌘ O": ⌥ ⌘ O
|
||||
- listitem:
|
||||
- text: Rename session
|
||||
- button "Remove shortcut for Rename session"
|
||||
- button "Edit shortcut for Rename session"
|
||||
- button "Rename session ⇧ ⌘ R": ⇧ ⌘ R
|
||||
- listitem:
|
||||
- text: Fork session
|
||||
- button "Remove shortcut for Fork session"
|
||||
- button "Edit shortcut for Fork session"
|
||||
- button "Fork session ⇧ ⌘ F": ⇧ ⌘ F
|
||||
- listitem:
|
||||
- text: Archive session
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session ⌥ ⌘ A": ⌥ ⌘ A
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
- button "Edit shortcut for Open settings"
|
||||
- button "Open settings ⌘ ,": ⌘ ,
|
||||
- listitem:
|
||||
- text: Workspace files
|
||||
- button "Remove shortcut for Workspace files"
|
||||
- button "Edit shortcut for Workspace files"
|
||||
- button "Workspace files ⌥ ⌘ P": ⌥ ⌘ P
|
||||
- listitem:
|
||||
- text: New terminal
|
||||
- button "Remove shortcut for New terminal"
|
||||
- button "Edit shortcut for New terminal"
|
||||
- 'button "New terminal ⌃ `"': "⌃ `"
|
||||
- region "Message input":
|
||||
- heading "Message input" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Send message
|
||||
- button "Send message Enter" [disabled]: Enter
|
||||
- text: Use the complementary Queue / Steer action
|
||||
- button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter
|
||||
- listitem:
|
||||
- text: Open reference menu
|
||||
- button "Open reference menu @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: New line
|
||||
- button "New line ⇧ Enter" [disabled]: ⇧ Enter
|
||||
- listitem:
|
||||
- text: Use the complementary Queue / Steer action
|
||||
- button "Use the complementary Queue / Steer action ⌘ Enter" [disabled]: ⌘ Enter
|
||||
- text: Send message
|
||||
- button "Send message Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Open command menu
|
||||
- button "Open command menu /" [disabled]: /
|
||||
- listitem:
|
||||
- text: Open reference menu
|
||||
- button "Open reference menu @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: Stop generating
|
||||
- button "Stop generating Esc Esc" [disabled]: Esc Esc
|
||||
- region "Menus and dialogs":
|
||||
- heading "Menus and dialogs" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Close menu or top dialog
|
||||
- button "Close menu or top dialog Esc" [disabled]: Esc
|
||||
- listitem:
|
||||
- text: Move menu selection
|
||||
- button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
|
||||
- listitem:
|
||||
- text: Select menu item
|
||||
- button "Select menu item Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Close menu or top dialog
|
||||
- button "Close menu or top dialog Esc" [disabled]: Esc
|
||||
- region "Approval area":
|
||||
- heading "Approval area" [level=3]
|
||||
- list:
|
||||
|
||||
@@ -6,6 +6,56 @@
|
||||
- searchbox "Search shortcuts"
|
||||
- region "Application":
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Close current page or window
|
||||
- button "Remove shortcut for Close current page or window"
|
||||
- button "Edit shortcut for Close current page or window"
|
||||
- button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W
|
||||
- listitem:
|
||||
- text: Refresh current page
|
||||
- button "Remove shortcut for Refresh current page"
|
||||
- button "Edit shortcut for Refresh current page"
|
||||
- button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R
|
||||
- listitem:
|
||||
- text: Toggle panel fullscreen
|
||||
- button "Remove shortcut for Toggle panel fullscreen"
|
||||
- button "Edit shortcut for Toggle panel fullscreen"
|
||||
- button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter
|
||||
- listitem:
|
||||
- text: Split
|
||||
- button "Remove shortcut for Split"
|
||||
- button "Edit shortcut for Split"
|
||||
- button "Split Ctrl + \\": Ctrl + \
|
||||
- listitem:
|
||||
- text: Archive session
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session Ctrl + Alt + A": Ctrl + Alt + A
|
||||
- listitem:
|
||||
- text: Fork session
|
||||
- button "Remove shortcut for Fork session"
|
||||
- button "Edit shortcut for Fork session"
|
||||
- button "Fork session Ctrl + Shift + F": Ctrl + Shift + F
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
- button "Edit shortcut for New Session"
|
||||
- button "New Session Ctrl + Alt + N": Ctrl + Alt + N
|
||||
- listitem:
|
||||
- text: Rename session
|
||||
- button "Remove shortcut for Rename session"
|
||||
- button "Edit shortcut for Rename session"
|
||||
- button "Rename session Ctrl + Shift + R": Ctrl + Shift + R
|
||||
- listitem:
|
||||
- text: Search sessions
|
||||
- button "Remove shortcut for Search sessions"
|
||||
- button "Edit shortcut for Search sessions"
|
||||
- button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
- button "Edit shortcut for Open settings"
|
||||
- button "Open settings Ctrl + ,": Ctrl + ,
|
||||
- listitem:
|
||||
- text: Open keyboard shortcuts
|
||||
- button "Remove shortcut for Open keyboard shortcuts"
|
||||
@@ -22,103 +72,53 @@
|
||||
- button "Edit shortcut for Toggle right sidebar"
|
||||
- button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B
|
||||
- listitem:
|
||||
- text: Split
|
||||
- button "Remove shortcut for Split"
|
||||
- button "Edit shortcut for Split"
|
||||
- button "Split Ctrl + \\": Ctrl + \
|
||||
- listitem:
|
||||
- text: Toggle panel fullscreen
|
||||
- button "Remove shortcut for Toggle panel fullscreen"
|
||||
- button "Edit shortcut for Toggle panel fullscreen"
|
||||
- button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter
|
||||
- listitem:
|
||||
- text: Close current page or window
|
||||
- button "Remove shortcut for Close current page or window"
|
||||
- button "Edit shortcut for Close current page or window"
|
||||
- button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W
|
||||
- listitem:
|
||||
- text: Refresh current page
|
||||
- button "Remove shortcut for Refresh current page"
|
||||
- button "Edit shortcut for Refresh current page"
|
||||
- button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R
|
||||
- listitem:
|
||||
- text: New Session
|
||||
- button "Remove shortcut for New Session"
|
||||
- button "Edit shortcut for New Session"
|
||||
- button "New Session Ctrl + Alt + N": Ctrl + Alt + N
|
||||
- listitem:
|
||||
- text: Search sessions
|
||||
- button "Remove shortcut for Search sessions"
|
||||
- button "Edit shortcut for Search sessions"
|
||||
- button "Search sessions Ctrl + Alt + K": Ctrl + Alt + K
|
||||
- text: New terminal
|
||||
- button "Remove shortcut for New terminal"
|
||||
- button "Edit shortcut for New terminal"
|
||||
- 'button "New terminal Ctrl + `"': "Ctrl + `"
|
||||
- listitem:
|
||||
- text: Add workspace
|
||||
- button "Remove shortcut for Add workspace"
|
||||
- button "Edit shortcut for Add workspace"
|
||||
- button "Add workspace Ctrl + Alt + O": Ctrl + Alt + O
|
||||
- listitem:
|
||||
- text: Rename session
|
||||
- button "Remove shortcut for Rename session"
|
||||
- button "Edit shortcut for Rename session"
|
||||
- button "Rename session Ctrl + Shift + R": Ctrl + Shift + R
|
||||
- listitem:
|
||||
- text: Fork session
|
||||
- button "Remove shortcut for Fork session"
|
||||
- button "Edit shortcut for Fork session"
|
||||
- button "Fork session Ctrl + Shift + F": Ctrl + Shift + F
|
||||
- listitem:
|
||||
- text: Archive session
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session Ctrl + Alt + A": Ctrl + Alt + A
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
- button "Edit shortcut for Open settings"
|
||||
- button "Open settings Ctrl + ,": Ctrl + ,
|
||||
- listitem:
|
||||
- text: Workspace files
|
||||
- button "Remove shortcut for Workspace files"
|
||||
- button "Edit shortcut for Workspace files"
|
||||
- button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P
|
||||
- listitem:
|
||||
- text: New terminal
|
||||
- button "Remove shortcut for New terminal"
|
||||
- button "Edit shortcut for New terminal"
|
||||
- 'button "New terminal Ctrl + `"': "Ctrl + `"
|
||||
- region "Message input":
|
||||
- heading "Message input" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Send message
|
||||
- button "Send message Enter" [disabled]: Enter
|
||||
- text: Use the complementary Queue / Steer action
|
||||
- button "Use the complementary Queue / Steer action Ctrl + Enter" [disabled]: Ctrl + Enter
|
||||
- listitem:
|
||||
- text: Open reference menu
|
||||
- button "Open reference menu @" [disabled]: "@"
|
||||
- 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
|
||||
- text: Send message
|
||||
- button "Send message Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Open command menu
|
||||
- button "Open command menu /" [disabled]: /
|
||||
- listitem:
|
||||
- text: Open reference menu
|
||||
- button "Open reference menu @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: Stop generating
|
||||
- button "Stop generating Esc Esc" [disabled]: Esc Esc
|
||||
- region "Menus and dialogs":
|
||||
- heading "Menus and dialogs" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Close menu or top dialog
|
||||
- button "Close menu or top dialog Esc" [disabled]: Esc
|
||||
- listitem:
|
||||
- text: Move menu selection
|
||||
- button "Move menu selection ↑ ↓" [disabled]: ↑ ↓
|
||||
- listitem:
|
||||
- text: Select menu item
|
||||
- button "Select menu item Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Close menu or top dialog
|
||||
- button "Close menu or top dialog Esc" [disabled]: Esc
|
||||
- region "Approval area":
|
||||
- heading "Approval area" [level=3]
|
||||
- list:
|
||||
|
||||
@@ -6,6 +6,55 @@
|
||||
- searchbox "搜索快捷键"
|
||||
- region "应用操作":
|
||||
- list:
|
||||
- listitem:
|
||||
- text: 关闭当前页面/窗口
|
||||
- button "移除关闭当前页面/窗口快捷键"
|
||||
- button "修改关闭当前页面/窗口快捷键"
|
||||
- button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W
|
||||
- listitem:
|
||||
- text: 刷新当前页面
|
||||
- button "修改刷新当前页面快捷键"
|
||||
- button "刷新当前页面 暂无快捷键": 暂无快捷键
|
||||
- listitem:
|
||||
- text: 面板全屏/退出全屏
|
||||
- button "移除面板全屏/退出全屏快捷键"
|
||||
- button "修改面板全屏/退出全屏快捷键"
|
||||
- button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter
|
||||
- listitem:
|
||||
- text: 分栏
|
||||
- button "移除分栏快捷键"
|
||||
- button "修改分栏快捷键"
|
||||
- button "分栏 ⌘ \\": ⌘ \
|
||||
- listitem:
|
||||
- text: 归档会话
|
||||
- button "移除归档会话快捷键"
|
||||
- button "修改归档会话快捷键"
|
||||
- button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A
|
||||
- listitem:
|
||||
- text: 分叉会话
|
||||
- button "移除分叉会话快捷键"
|
||||
- button "修改分叉会话快捷键"
|
||||
- button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F
|
||||
- listitem:
|
||||
- text: 新会话
|
||||
- button "移除新会话快捷键"
|
||||
- button "修改新会话快捷键"
|
||||
- button "新会话 ⌥ ⌘ N": ⌥ ⌘ N
|
||||
- listitem:
|
||||
- text: 重命名会话
|
||||
- button "移除重命名会话快捷键"
|
||||
- button "修改重命名会话快捷键"
|
||||
- button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R
|
||||
- listitem:
|
||||
- text: 搜索会话
|
||||
- button "移除搜索会话快捷键"
|
||||
- button "修改搜索会话快捷键"
|
||||
- button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K
|
||||
- listitem:
|
||||
- text: 打开设置
|
||||
- button "移除打开设置快捷键"
|
||||
- button "修改打开设置快捷键"
|
||||
- button "打开设置 ⌘ ,": ⌘ ,
|
||||
- listitem:
|
||||
- text: 快捷键速查
|
||||
- button "移除快捷键速查快捷键"
|
||||
@@ -22,102 +71,53 @@
|
||||
- button "修改展开/收起右侧栏快捷键"
|
||||
- button "展开/收起右侧栏 ⇧ ⌘ B": ⇧ ⌘ B
|
||||
- listitem:
|
||||
- text: 分栏
|
||||
- button "移除分栏快捷键"
|
||||
- button "修改分栏快捷键"
|
||||
- button "分栏 ⌘ \\": ⌘ \
|
||||
- listitem:
|
||||
- text: 面板全屏/退出全屏
|
||||
- button "移除面板全屏/退出全屏快捷键"
|
||||
- button "修改面板全屏/退出全屏快捷键"
|
||||
- button "面板全屏/退出全屏 ⌥ ⌘ Enter": ⌥ ⌘ Enter
|
||||
- listitem:
|
||||
- text: 关闭当前页面/窗口
|
||||
- button "移除关闭当前页面/窗口快捷键"
|
||||
- button "修改关闭当前页面/窗口快捷键"
|
||||
- button "关闭当前页面/窗口 ⌥ ⌘ W": ⌥ ⌘ W
|
||||
- listitem:
|
||||
- text: 刷新当前页面
|
||||
- button "修改刷新当前页面快捷键"
|
||||
- button "刷新当前页面 暂无快捷键": 暂无快捷键
|
||||
- listitem:
|
||||
- text: 新会话
|
||||
- button "移除新会话快捷键"
|
||||
- button "修改新会话快捷键"
|
||||
- button "新会话 ⌥ ⌘ N": ⌥ ⌘ N
|
||||
- listitem:
|
||||
- text: 搜索会话
|
||||
- button "移除搜索会话快捷键"
|
||||
- button "修改搜索会话快捷键"
|
||||
- button "搜索会话 ⌥ ⌘ K": ⌥ ⌘ K
|
||||
- text: 新建终端
|
||||
- button "移除新建终端快捷键"
|
||||
- button "修改新建终端快捷键"
|
||||
- 'button "新建终端 ⌃ `"': "⌃ `"
|
||||
- listitem:
|
||||
- text: 添加工作区
|
||||
- button "移除添加工作区快捷键"
|
||||
- button "修改添加工作区快捷键"
|
||||
- button "添加工作区 ⌥ ⌘ O": ⌥ ⌘ O
|
||||
- listitem:
|
||||
- text: 重命名会话
|
||||
- button "移除重命名会话快捷键"
|
||||
- button "修改重命名会话快捷键"
|
||||
- button "重命名会话 ⇧ ⌘ R": ⇧ ⌘ R
|
||||
- listitem:
|
||||
- text: 分叉会话
|
||||
- button "移除分叉会话快捷键"
|
||||
- button "修改分叉会话快捷键"
|
||||
- button "分叉会话 ⇧ ⌘ F": ⇧ ⌘ F
|
||||
- listitem:
|
||||
- text: 归档会话
|
||||
- button "移除归档会话快捷键"
|
||||
- button "修改归档会话快捷键"
|
||||
- button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A
|
||||
- listitem:
|
||||
- text: 打开设置
|
||||
- button "移除打开设置快捷键"
|
||||
- button "修改打开设置快捷键"
|
||||
- button "打开设置 ⌘ ,": ⌘ ,
|
||||
- listitem:
|
||||
- text: 工作区文件
|
||||
- button "移除工作区文件快捷键"
|
||||
- button "修改工作区文件快捷键"
|
||||
- button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P
|
||||
- listitem:
|
||||
- text: 新建终端
|
||||
- button "移除新建终端快捷键"
|
||||
- button "修改新建终端快捷键"
|
||||
- 'button "新建终端 ⌃ `"': "⌃ `"
|
||||
- region "消息输入":
|
||||
- heading "消息输入" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: 发送消息
|
||||
- button "发送消息 Enter" [disabled]: Enter
|
||||
- text: 使用互补的 Queue/Steer 方式
|
||||
- button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter
|
||||
- listitem:
|
||||
- text: 打开引用菜单
|
||||
- button "打开引用菜单 @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: 换行
|
||||
- button "换行 ⇧ Enter" [disabled]: ⇧ Enter
|
||||
- listitem:
|
||||
- text: 使用互补的 Queue/Steer 方式
|
||||
- button "使用互补的 Queue/Steer 方式 ⌘ Enter" [disabled]: ⌘ Enter
|
||||
- text: 发送消息
|
||||
- button "发送消息 Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: 打开命令菜单
|
||||
- button "打开命令菜单 /" [disabled]: /
|
||||
- listitem:
|
||||
- text: 打开引用菜单
|
||||
- button "打开引用菜单 @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: 停止生成
|
||||
- button "停止生成 Esc Esc" [disabled]: Esc Esc
|
||||
- region "菜单与弹层":
|
||||
- heading "菜单与弹层" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: 关闭菜单或顶层弹窗
|
||||
- button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc
|
||||
- listitem:
|
||||
- text: 移动菜单选择
|
||||
- button "移动菜单选择 ↑ ↓" [disabled]: ↑ ↓
|
||||
- listitem:
|
||||
- text: 选择菜单项
|
||||
- button "选择菜单项 Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: 关闭菜单或顶层弹窗
|
||||
- button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc
|
||||
- region "审批区域":
|
||||
- heading "审批区域" [level=3]
|
||||
- list:
|
||||
|
||||
@@ -64,11 +64,11 @@ it.each([
|
||||
}, marker)
|
||||
const console = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const deliverPrimary = (code: string) => page.evaluate((input) => {
|
||||
const deliverPrimary = (code: string, alt = false) => page.evaluate((input) => {
|
||||
window.shortcutFixture.deliver(input)
|
||||
}, {
|
||||
kind: 'keyboard', frameName: '', revision: snapshot.revision, code,
|
||||
control: platform === 'windows', meta: platform === 'macos', alt: false, shift: false, repeat: false,
|
||||
control: platform === 'windows', meta: platform === 'macos', alt, shift: false, repeat: false,
|
||||
} satisfies DesktopShortcutInput)
|
||||
const openReference = () => deliverPrimary('Slash')
|
||||
const group = page.getByRole('treeitem').first()
|
||||
@@ -79,14 +79,29 @@ it.each([
|
||||
const composer = page.locator('[data-composer-input]').first()
|
||||
await composer.focus()
|
||||
await page.keyboard.insertText('Desktop focus draft')
|
||||
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
|
||||
const files = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
||||
for (const opening of [{ code: 'KeyP', alt: false }, { code: 'KeyB', alt: true }]) {
|
||||
await composer.focus()
|
||||
await deliverPrimary(opening.code, opening.alt)
|
||||
await panel.waitFor()
|
||||
const focusedAfterOpening = await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => getComputedStyle(pane).outlineStyle)).toBe('none')
|
||||
await deliverPrimary('KeyW')
|
||||
await expect.poll(() => page.evaluate(() => ({
|
||||
closedWindows: window.shortcutFixture.closedWindows,
|
||||
openPanels: document.querySelectorAll('[data-sidebar-right-open]').length,
|
||||
}))).toEqual({ closedWindows: 0, openPanels: 0 })
|
||||
expect(focusedAfterOpening).toBe(true)
|
||||
expect(await composer.innerText()).toBe('Desktop focus draft')
|
||||
}
|
||||
await composer.focus()
|
||||
const selectDraft = () => composer.evaluate((element) => {
|
||||
const text = element.querySelector('[data-lexical-text]')!.firstChild!
|
||||
document.getSelection()!.setBaseAndExtent(text, 0, text, 7)
|
||||
})
|
||||
await selectDraft()
|
||||
await deliverPrimary('KeyP')
|
||||
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
|
||||
const files = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
||||
await files.waitFor()
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop')
|
||||
@@ -96,6 +111,15 @@ it.each([
|
||||
expect(await files.count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => document.activeElement === pane)).toBe(true)
|
||||
expect(await page.evaluate(() => document.getSelection()?.toString())).toBe('Desktop')
|
||||
await composer.click()
|
||||
const pane = panel.locator('[data-dockkit-pane]')
|
||||
await pane.click({ position: { x: 20, y: 2 } })
|
||||
expect(await pane.evaluate(element => ({
|
||||
focused: document.activeElement === element, outline: getComputedStyle(element).outlineStyle,
|
||||
}))).toEqual({ focused: true, outline: 'none' })
|
||||
await page.keyboard.press('Tab')
|
||||
expect(await files.evaluate(element => element === document.activeElement && element.matches(':focus-visible'))).toBe(true)
|
||||
expect(await files.evaluate(element => getComputedStyle(element).outlineStyle)).not.toBe('none')
|
||||
await deliverPrimary('KeyW')
|
||||
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
|
||||
expect(await page.evaluate(() => window.shortcutFixture.closedWindows)).toBe(0)
|
||||
@@ -247,6 +271,18 @@ it.each([
|
||||
await page.keyboard.up('a')
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' })
|
||||
await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0)
|
||||
await page.getByRole('treeitem').nth(1).click()
|
||||
await page.getByText('DONE', { exact: true }).waitFor()
|
||||
await openReference()
|
||||
await dialog.waitFor()
|
||||
await dialog.getByRole('searchbox').focus()
|
||||
await page.evaluate((input) => { window.shortcutFixture.deliver(input) }, {
|
||||
kind: 'keyboard', frameName: '', revision: snapshot.revision, code: 'KeyA',
|
||||
control: platform === 'windows', meta: platform === 'macos', alt: false, shift: true, repeat: false,
|
||||
} satisfies DesktopShortcutInput)
|
||||
await page.getByText('DONE', { exact: true }).waitFor({ state: 'hidden' })
|
||||
await expect.poll(() => page.getByRole('treeitem', { selected: true }).count()).toBe(0)
|
||||
expect(await dialog.isVisible()).toBe(true)
|
||||
expect(console.pageErrors).toEqual([])
|
||||
expect(console.warnings).toEqual([])
|
||||
} finally { await browser.close() }
|
||||
|
||||
@@ -81,11 +81,28 @@ describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => {
|
||||
await page.keyboard.press(`${primary}+Alt+Enter`)
|
||||
expect(await composer.innerText()).toContain('T5 draft remains unsent')
|
||||
const eventCount = agent.session.seq
|
||||
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
|
||||
|
||||
await bind(page, primary, 'Toggle right sidebar')
|
||||
for (const { label, previous } of [
|
||||
{ label: 'Browser', previous: undefined },
|
||||
{ label: 'New terminal', previous: 'Browser' },
|
||||
]) {
|
||||
await bind(page, primary, label, previous)
|
||||
await composer.click()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
const openedPage = label === 'Browser' ? panel.getByPlaceholder('Enter an HTTP(S) address')
|
||||
: panel.locator('[data-sidebar-terminal]')
|
||||
await openedPage.waitFor()
|
||||
const focusedAfterOpening = await panel.locator('[data-dockkit-pane]').evaluate(pane => pane.contains(document.activeElement))
|
||||
await page.keyboard.press(`${primary}+Alt+W`)
|
||||
await expect.poll(() => page.locator('[data-sidebar-right-open]').count(), { message: label }).toBe(0)
|
||||
expect(focusedAfterOpening, label).toBe(true)
|
||||
expect(await composer.innerText()).toBe('T5 draft remains unsent')
|
||||
}
|
||||
|
||||
await bind(page, primary, 'Toggle right sidebar', 'New terminal')
|
||||
await composer.click()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]')
|
||||
await panel.waitFor()
|
||||
expect(await panel.getByRole('button', { name: 'Collapse right sidebar' }).getAttribute('aria-keyshortcuts')).toBe(aria)
|
||||
await panel.getByRole('tab').focus()
|
||||
|
||||
@@ -89,6 +89,8 @@ describe('web e2e: shortcut reference', () => {
|
||||
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 page.keyboard.press('Shift+Tab')
|
||||
expect(await dialog.getByRole('button', { disabled: false }).last().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' })
|
||||
@@ -127,6 +129,8 @@ describe('web e2e: shortcut reference', () => {
|
||||
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')
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
expect(await resetAll.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
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)
|
||||
|
||||
@@ -206,6 +206,33 @@ async function tabTitles(root: Locator): Promise<string[]> {
|
||||
return await root.locator('[data-dockkit-tab-title]').allInnerTexts()
|
||||
}
|
||||
|
||||
/** Compare the rendered glyph and text rather than their containing boxes. */
|
||||
async function expectTitleAlignment(title: Locator): Promise<void> {
|
||||
const geometry = await title.evaluate((node) => {
|
||||
const icon = node.querySelector('svg')
|
||||
const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT)
|
||||
let textBox: DOMRect | undefined
|
||||
while (walker.nextNode()) {
|
||||
if (!walker.currentNode.textContent?.trim()) continue
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(walker.currentNode)
|
||||
const box = range.getBoundingClientRect()
|
||||
if (box.width > 0 && box.height > 0) {
|
||||
textBox = box
|
||||
break
|
||||
}
|
||||
}
|
||||
if (icon === null || textBox === undefined) throw new Error('tab icon or visible title text is missing')
|
||||
const iconBox = icon.getBoundingClientRect()
|
||||
return {
|
||||
gap: textBox.left - iconBox.right,
|
||||
centreOffset: Math.abs(iconBox.y + iconBox.height / 2 - textBox.y - textBox.height / 2),
|
||||
}
|
||||
})
|
||||
expect(geometry.gap).toBeCloseTo(5, 2)
|
||||
expect(geometry.centreOffset).toBeLessThan(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* A rendered width, read once the frame's track transition has settled.
|
||||
*
|
||||
@@ -416,6 +443,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
}
|
||||
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
|
||||
await expectTitleAlignment(column.locator('[data-dockkit-tab-title]'))
|
||||
await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2)
|
||||
expect(await column.locator('[data-sidebar-right-guide-entry="browser"]').count()).toBe(0)
|
||||
await column.locator('[data-sidebar-right-guide-entry="files"]').click()
|
||||
@@ -425,6 +453,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
const addTab = column.locator('[data-dockkit-add-tab]')
|
||||
const filesTab = column.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
||||
await expect.poll(async () => await tabTitles(column)).toEqual(['Files'])
|
||||
await expectTitleAlignment(filesTab.locator('[data-dockkit-tab-title]'))
|
||||
await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
|
||||
expect(await filesTab.locator('[data-dockkit-tab-close]').count()).toBe(1)
|
||||
await expect.poll(async () => await addTab.count()).toBe(1)
|
||||
@@ -963,6 +992,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
const floatOne = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME })
|
||||
await floatByDrag(page, floatOne)
|
||||
await expect.poll(async () => await floats.count()).toBe(1)
|
||||
await expectTitleAlignment(floats.first().locator('[data-dockkit-tab-title]'))
|
||||
const box = await floats.first().boundingBox()
|
||||
if (box === null) throw new Error('float is not rendered')
|
||||
await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 })
|
||||
@@ -972,6 +1002,7 @@ describe('web e2e: shipped right Sidebar', () => {
|
||||
const second = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' })
|
||||
await floatByDrag(page, second)
|
||||
await expect.poll(async () => await floats.count()).toBe(2)
|
||||
await expectTitleAlignment(floats.last().locator('[data-dockkit-tab-title]'))
|
||||
|
||||
// 6. Dock one back: the docked tree takes it, the other float stays. Dock
|
||||
// the TOPMOST float — floats render bottom-to-top, so the newest one
|
||||
|
||||
@@ -78,6 +78,8 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
||||
scaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) })
|
||||
browser = await chromium.launch()
|
||||
const context = await browser.newContext({ viewport: { width: 1680, height: 1000 }, locale: 'en-US', timezoneId: 'Asia/Shanghai' })
|
||||
// These snapshots use the Linux Web shortcut defaults.
|
||||
await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { configurable: true, value: 'Linux x86_64' }) })
|
||||
page = await context.newPage()
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
@@ -473,12 +475,25 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
|
||||
expect(alive(original)).toBe(false)
|
||||
transport.reconnect()
|
||||
await page.context().setOffline(false)
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
const environmentReady = Promise.withResolvers<undefined>()
|
||||
const environmentUrl = new URL('/api/terminal/environment', scaffold.baseUrl).href
|
||||
await page.route(environmentUrl, async (route) => {
|
||||
await environmentReady.promise
|
||||
await route.continue()
|
||||
})
|
||||
const terminal = page.locator('[data-sidebar-terminal]')
|
||||
try {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
// The title starts discovery before the lazy body mounts; keep that request pending through mounting.
|
||||
await terminal.getByRole('status').getByText('Reading terminal environment…', { exact: true }).waitFor()
|
||||
} finally {
|
||||
environmentReady.resolve(undefined)
|
||||
await page.unrouteAll({ behavior: 'wait' })
|
||||
}
|
||||
await expect.poll(() => page.getByRole('alert').innerText()).toContain('no longer exists')
|
||||
expect(handles).toHaveLength(1)
|
||||
const unavailable = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url))
|
||||
const terminal = page.locator('[data-sidebar-terminal]')
|
||||
await compareOrRefreshGolden(unavailable, await terminal.ariaSnapshot(), webSnapshotMode())
|
||||
await terminal.screenshot({ path: `${shots}/unavailable.png`, animations: 'disabled' })
|
||||
const tabCount = await page.locator('[data-dockkit-tab]').count()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/shortcuts/README.md
|
||||
README.md: 3583a31046946a29d3f8ddb1b263d4b676d45dc0
|
||||
README.zh.md: 0f1fef10405509740b2e0ce1f672946c1d898abb
|
||||
README.md: c9c5fe591aed234f70debb0b59e686550d584c0d
|
||||
README.zh.md: 64e1bd9835c025eb1fb0378582fe66071375b72c
|
||||
@@ -45,7 +45,7 @@ Bindings activate after the first read. Failed reads preserve the last accepted
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
Windows and macOS Desktop intercept complete accepted combinations before local handlers and forward them through the trusted preload. Desktop startup requires the native keyboard bridge on every platform. On Windows and macOS, only native input dispatches configurable commands; DOM input continues to feed fixed actions. The adapter intercepts a key release only when it intercepted that key’s initial press. Default and user-saved bindings have identical priority, including over modal controls. Other environments dispatch after local handlers and respect their declared regions. Composition, dead keys, AltGraph, and consumed input pass through; macOS Web can consume a bound Option+Command+N reported as a dead key outside composition without guarding the next input. Web and Linux modal layers block background commands. Owned repeats remain consumed without rerunning the action. The context carries the original DOM input element, including elements inside a shadow root. Owners synchronously resolve their target and retain responsibility for action errors. The pure `./protocol` export has no React, DOM, or Electron dependency and shares validation and serialized persistence between adapters. Explicit overrides displace conflicting new defaults; conflicting explicit overrides are all disabled. Desktop rejects stale revisions and restricts IPC to the current product window and top frame. Rejected adapter calls are logged and returned as save failures; errors while publishing an accepted command catalog propagate to the caller.
|
||||
Windows and macOS Desktop intercept complete accepted combinations before local handlers and forward them through the trusted preload. Desktop startup requires the native keyboard bridge on every platform. On Windows and macOS, 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. `ctx.shortcuts.closeWindow()` owns the Desktop keyboard bridge and sends its current accepted revision; Web calls and bridge request failures reject. The pure `./protocol` export has no React, DOM, or Electron dependency and shares validation and serialized persistence between adapters. Explicit overrides displace conflicting new defaults; conflicting explicit overrides are all disabled. Desktop rejects stale revisions and restricts IPC to the current product window and top frame. Rejected adapter calls are logged and returned as save failures; errors while publishing an accepted command catalog propagate to the caller.
|
||||
|
||||
DOM fixed-input observers receive composition and consumption flags before application dispatch. Consuming an event makes it unavailable to subsequent observers and commands. Focus, pointer, window blur, composition, modal changes, and locally stopped propagation reset pending DOM sequences. Native chords reset on window blur, focused-frame or configuration changes, composition, recording, and modifier release. Electron can omit keyups after native interception; completing a native binding discards held-key state, so each chord activation needs two fresh presses. Releasing a modifier clears held-key state because macOS may omit character keyup events while Command is held. The adapter removes its listeners and modal observer when disposed; one feature's observer failure does not prevent the remaining observers from receiving input.
|
||||
|
||||
@@ -77,7 +77,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Feature owners declare the Windows and macOS Web defaults explicitly: Mod+Slash, Mod+Comma, Mod+Backslash, and Control+Backquote stay two-key combinations; other defaults without Alt use Mod+Alt, and defaults with Alt use Mod+Shift except fullscreen uses Mod+Alt+Enter. macOS Web leaves Refresh current page unbound by default; its button and explicit user bindings remain available. Mod is Command on macOS and Control on Windows. Windows and macOS accept any three or four distinct modifiers in Web and Desktop. Web also accepts the listed combinations and Mod+Shift combinations; browser or system delivery requires platform testing. Linux Web accepts Mod+Slash, Mod+Shift+Comma, and Mod+Shift+Period. Operating-system shortcuts that do not reach the window cannot be intercepted. A chord does not delay or consume its first key while waiting for the second; a native action or focus change can therefore prevent completion. Command registration or disposal invalidates open edit drafts even when saved preferences are unchanged. Desktop rereads its file on every catalog update; there is no file watcher.
|
||||
- Feature owners declare the Windows and macOS Web defaults explicitly: Mod+Slash, Mod+Comma, Mod+Backslash, and Control+Backquote stay two-key combinations; other defaults without Alt use Mod+Alt, and defaults with Alt use Mod+Shift except fullscreen uses Mod+Alt+Enter. macOS Web leaves Refresh current page unbound by default to preserve browser-specific Command+Option+R actions; 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. Command registration or disposal invalidates open edit drafts even when saved preferences are unchanged. Desktop rereads its file on every catalog update; there is no file watcher.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -45,7 +45,7 @@ Windows 和 macOS Desktop 接受一至两个不同的受支持非修饰键,可
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
Windows 和 macOS Desktop 在局部处理器之前拦截已接受的完整组合,通过可信 preload 转发。所有平台的 Desktop 启动都要求原生键盘桥接。Windows 和 macOS 的可配置命令只由原生输入分发;DOM 输入继续交给固定操作。适配器仅拦截初次按下事件已被拦截的按键的松开事件。默认绑定与用户保存的绑定优先级一致,均优先于模态控件。其他环境在局部处理器之后分发,并遵守声明的区域。输入法组合、死键、AltGraph 和已消费的输入继续传递;macOS Web 可在输入法组合之外消费已绑定且报告为死键的 Option+Command+N,不保护后续输入。Web 和 Linux 的模态层阻止后台命令。已接管的重复事件仍被消费,但不重复执行。上下文携带原始 DOM 输入元素,包括 shadow root 内的元素。owner 同步解析目标,并负责操作错误。纯 `./protocol` 导出不依赖 React、DOM 或 Electron,在两个适配器间共享校验和串行持久化。显式覆盖取代冲突的新默认值;互相冲突的显式覆盖全部禁用。Desktop 拒绝过期版本,并将 IPC 限制在当前产品窗口和顶层 frame。适配器调用被拒绝时会记录日志并返回保存失败;发布已接受命令目录时的错误会向调用方抛出。
|
||||
Windows 和 macOS Desktop 在局部处理器之前拦截已接受的完整组合,通过可信 preload 转发。所有平台的 Desktop 启动都要求原生键盘桥接。Windows 和 macOS 的可配置命令只由原生输入分发;DOM 输入继续交给固定操作。适配器仅拦截初次按下事件已被拦截的按键的松开事件。默认绑定与用户保存的绑定优先级一致,均优先于模态控件。其他环境在局部处理器之后分发,并遵守声明的区域。输入法组合、死键、AltGraph 和已消费的输入继续传递;macOS Web 可在输入法组合之外消费已绑定且报告为死键的 Option+Command+N,不保护后续输入。Web 和 Linux 的模态层阻止后台命令。已接管的重复事件仍被消费,但不重复执行。上下文携带原始 DOM 输入元素,包括 shadow root 内的元素。owner 同步解析目标,并负责操作错误。`ctx.shortcuts.closeWindow()` 统一持有 Desktop 键盘桥接,并携带当前已接受的 revision;Web 调用和桥接请求错误均返回失败。纯 `./protocol` 导出不依赖 React、DOM 或 Electron,在两个适配器间共享校验和串行持久化。显式覆盖取代冲突的新默认值;互相冲突的显式覆盖全部禁用。Desktop 拒绝过期版本,并将 IPC 限制在当前产品窗口和顶层 frame。适配器调用被拒绝时会记录日志并返回保存失败;发布已接受命令目录时的错误会向调用方抛出。
|
||||
|
||||
DOM 固定输入观察者在应用分发前收到输入法组合和消费标记。消费事件后,后续观察者和命令不能使用它。焦点、指针、窗口失焦、输入法组合、模态变化和局部停止传播会重置待完成的 DOM 序列。原生双键状态在窗口失焦、焦点 frame 或配置变化、输入法组合、录制和修饰键释放时重置。Electron 在原生拦截后可能省略 keyup;原生绑定完成时立即清空按住状态,因此每次触发双键组合都需要重新按下两个键。释放修饰键会清空按住状态,因为 macOS 在按住 Command 时可能省略字符键的 keyup。适配器释放时移除监听器和模态观察者;单个功能观察者失败不妨碍其他观察者接收输入。
|
||||
|
||||
@@ -77,7 +77,7 @@ Desktop 原生菜单和内嵌 frame 使用与 DOM 输入相同的命令注册表
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 功能 owner 显式声明 Windows 和 macOS Web 的默认键位:Mod+Slash、Mod+Comma、Mod+Backslash 和 Control+Backquote 保持双键;其他不含 Alt 的默认键位使用 Mod+Alt,含 Alt 的使用 Mod+Shift,全屏除外,使用 Mod+Alt+Enter。macOS Web 的“刷新当前页”默认不绑定快捷键,刷新按钮和用户显式绑定仍然可用。Mod 在 macOS 上为 Command,在 Windows 上为 Control。Windows 和 macOS 的 Web、Desktop 均接受任意三个或四个不同修饰键的组合。Web 还接受上述组合及 Mod+Shift 组合;浏览器或系统能否送达按键需在对应平台测试。Linux Web 接受 Mod+Slash、Mod+Shift+Comma 和 Mod+Shift+Period。未送达窗口的操作系统快捷键无法被拦截。双键组合等待第二个键时,不延迟或消费第一个键;原生操作或焦点变化可能使组合无法完成。命令注册或注销会使打开的编辑草稿过期,即使保存的偏好未改变。Desktop 在每次命令目录更新时重新读取文件,不设置文件 watcher。
|
||||
- 功能 owner 显式声明 Windows 和 macOS Web 的默认键位:Mod+Slash、Mod+Comma、Mod+Backslash 和 Control+Backquote 保持双键;其他不含 Alt 的默认键位使用 Mod+Alt,含 Alt 的使用 Mod+Shift,全屏除外,使用 Mod+Alt+Enter。macOS Web 的“刷新当前页”默认不绑定快捷键,以保留浏览器自身的 Command+Option+R 操作;刷新按钮和用户显式绑定仍然可用。Mod 在 macOS 上为 Command,在 Windows 上为 Control。Windows 和 macOS 的 Web、Desktop 均接受任意三个或四个不同修饰键的组合。Web 还接受上述组合及 Mod+Shift 组合;浏览器或系统能否送达按键需在对应平台测试。Linux Web 接受 Mod+Slash、Mod+Shift+Comma 和 Mod+Shift+Period。未送达窗口的操作系统快捷键无法被拦截。双键组合等待第二个键时,不延迟或消费第一个键;原生操作或焦点变化可能使组合无法完成。命令注册或注销会使打开的编辑草稿过期,即使保存的偏好未改变。Desktop 在每次命令目录更新时重新读取文件,不设置文件 watcher。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Main-document keyboard adapter; local controls arbitrate before window bubbling. */
|
||||
import { observeComposition } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ShortcutContext, ShortcutFixedInput } from './types.ts'
|
||||
import type { ShortcutRegistry } from './registry.ts'
|
||||
import type { ShortcutPlatform, ShortcutRuntime } from '../protocol.ts'
|
||||
@@ -30,16 +31,12 @@ export function detectEnvironment(document: Document, navigator: Navigator): {
|
||||
export function installKeyboard(window: Window, shortcuts: Pick<ShortcutRegistry, 'dispatch' | 'runtime' | 'platform'>,
|
||||
fixed?: (input: ShortcutFixedInput) => void, native = false): () => void {
|
||||
const document = window.document
|
||||
const composition = observeComposition(document)
|
||||
let pending = false
|
||||
let pendingTimer: number | undefined
|
||||
const reset = (): void => { fixed?.({ type: 'reset' }) }
|
||||
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 = (): void => { compositionEnded = false }
|
||||
const blur = (): void => { composing = false; compositionEnded = false; deadKey = false; reset() }
|
||||
const blur = (): void => { 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)
|
||||
@@ -74,14 +71,12 @@ export function installKeyboard(window: Window, shortcuts: Pick<ShortcutRegistry
|
||||
const dialogs = document.querySelectorAll<HTMLElement>(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
|
||||
const guarded = composition.guards(event) || deadKey
|
||||
|| 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 }
|
||||
@@ -94,25 +89,24 @@ export function installKeyboard(window: Window, shortcuts: Pick<ShortcutRegistry
|
||||
shortcuts.dispatch({ ...gesture, composing: guarded || (isDead && !commandDeadKey),
|
||||
defaultPrevented: event.defaultPrevented }, context, consume)
|
||||
}
|
||||
document.addEventListener('compositionstart', start, true)
|
||||
document.addEventListener('compositionend', end, true)
|
||||
document.addEventListener('compositionstart', reset, true)
|
||||
document.addEventListener('compositionend', reset, true)
|
||||
document.addEventListener('focusin', reset, true)
|
||||
document.addEventListener('pointerdown', reset, true)
|
||||
window.addEventListener('keydown', capture, true)
|
||||
window.addEventListener('keydown', keydown)
|
||||
window.addEventListener('keyup', release)
|
||||
window.addEventListener('blur', blur)
|
||||
return () => {
|
||||
pending = false
|
||||
window.clearTimeout(pendingTimer)
|
||||
observer?.disconnect()
|
||||
document.removeEventListener('compositionstart', start, true)
|
||||
document.removeEventListener('compositionend', end, true)
|
||||
composition.dispose()
|
||||
document.removeEventListener('compositionstart', reset, true)
|
||||
document.removeEventListener('compositionend', reset, true)
|
||||
document.removeEventListener('focusin', reset, true)
|
||||
document.removeEventListener('pointerdown', reset, true)
|
||||
window.removeEventListener('keydown', capture, true)
|
||||
window.removeEventListener('keydown', keydown)
|
||||
window.removeEventListener('keyup', release)
|
||||
window.removeEventListener('blur', blur)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export default class ShortcutsService extends Service implements Shortcuts {
|
||||
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
|
||||
@@ -44,6 +45,7 @@ export default class ShortcutsService extends Service implements Shortcuts {
|
||||
? (window as Window & { dshDesktop?: { keyboard?: DesktopKeyboardApi } }).dshDesktop?.keyboard : undefined
|
||||
if (environment.runtime === 'desktop' && keyboard === undefined) throw new Error('Desktop keyboard bridge unavailable')
|
||||
super(ctx, 'shortcuts')
|
||||
this.keyboard = keyboard
|
||||
this.runtime = environment.runtime
|
||||
this.platform = environment.platform
|
||||
const config = Config((globalThis as { __DSH_SHORTCUTS_CONFIG__?: unknown }).__DSH_SHORTCUTS_CONFIG__ ?? {})
|
||||
@@ -149,4 +151,9 @@ export default class ShortcutsService extends Service implements Shortcuts {
|
||||
if (this.adapter === undefined) throw new Error('Desktop shortcuts bridge unavailable')
|
||||
await this.adapter.recording(active)
|
||||
}
|
||||
|
||||
async closeWindow(): Promise<void> {
|
||||
if (this.keyboard === undefined) throw new Error('Desktop keyboard bridge unavailable')
|
||||
await this.keyboard.closeWindow(this.config.getSnapshot().revision)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,10 @@ export interface ShortcutCommand {
|
||||
readonly aliases: readonly string[]
|
||||
readonly defaults: Readonly<Partial<Record<ShortcutProfile, ShortcutBinding>>>
|
||||
readonly regions: readonly ShortcutRegion[]
|
||||
/** Modal identifiers in which this command may resolve; others block it. */
|
||||
/**
|
||||
* Allowed modal identifiers for Web/Linux keyboard input and explicit menu actions;
|
||||
* Windows/macOS Desktop keyboard bindings take priority.
|
||||
*/
|
||||
readonly modals: readonly string[]
|
||||
resolve(context: ShortcutContext): ShortcutResolution
|
||||
}
|
||||
@@ -121,6 +124,11 @@ export interface Shortcuts {
|
||||
* @returns completion of the native interception update.
|
||||
*/
|
||||
recording(active: boolean): Promise<void>
|
||||
/**
|
||||
* Request closure of the Desktop window using the currently accepted shortcut revision.
|
||||
* @returns completion of the native request; rejects outside Desktop or when the bridge request fails.
|
||||
*/
|
||||
closeWindow(): Promise<void>
|
||||
/**
|
||||
* Register a command; duplicate ids and overlapping default bindings throw.
|
||||
* @param command - feature-owned labels, defaults, and target resolver.
|
||||
|
||||
@@ -168,6 +168,16 @@ it('preserves composition, late closing keys, 229, AltGraph, and dead-key input'
|
||||
expect(press(input).defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts a shortcut after the composition-closing key is released inside a local control', () => {
|
||||
const { input, run } = mount()
|
||||
fireEvent.compositionStart(input)
|
||||
fireEvent.compositionEnd(input)
|
||||
input.addEventListener('keyup', (event) => { event.stopPropagation() }, { once: true })
|
||||
fireEvent.keyUp(input, { key: 'Enter', code: 'Enter' })
|
||||
expect(press(input).defaultPrevented).toBe(true)
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -47,6 +47,24 @@ function mount() {
|
||||
return { ctx, service, dispose }
|
||||
}
|
||||
|
||||
it('closes the Desktop window with the latest accepted revision and propagates bridge errors', async () => {
|
||||
const f = desktop()
|
||||
const { service } = mount()
|
||||
await vi.waitFor(() => { expect(service.config.getSnapshot().status).toBe('ready') })
|
||||
const latest = snapshot(10)
|
||||
f.publish(latest)
|
||||
await service.closeWindow()
|
||||
expect(f.keyboard.closeWindow).toHaveBeenCalledExactlyOnceWith(latest.revision)
|
||||
const failure = new Error('Window unavailable')
|
||||
f.keyboard.closeWindow.mockRejectedValueOnce(failure)
|
||||
await expect(service.closeWindow()).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('rejects a native window close outside Desktop', async () => {
|
||||
const { service } = mount()
|
||||
await expect(service.closeWindow()).rejects.toThrow('Desktop keyboard bridge unavailable')
|
||||
})
|
||||
|
||||
it('describes registered binding conflicts on Linux Desktop', async () => {
|
||||
desktop()
|
||||
document.documentElement.dataset.platform = 'linux'
|
||||
|
||||
@@ -21,6 +21,9 @@
|
||||
{
|
||||
"path": "../store"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../../util/crypto"
|
||||
},
|
||||
|
||||
@@ -1019,7 +1019,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
|
||||
value={folderDraft ?? ''}
|
||||
aria-label={t('browser.folderName')}
|
||||
placeholder={t('browser.untitledFolder')}
|
||||
autoFocus
|
||||
data-modal-autofocus
|
||||
disabled={creatingFolder}
|
||||
onChange={(event) => { setFolderDraft(event.target.value) }}
|
||||
{...compositionGuard}
|
||||
|
||||
@@ -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-dockkit/README.md
|
||||
README.md: 6520f149094b91e2d84c051da68791169f9f31a9
|
||||
README.zh.md: f62c7d1ae63caede511312b52b4c3b9c2e5d8e0d
|
||||
README.md: deae0abb23d7136d908a15351a8298e6b9a2ce43
|
||||
README.zh.md: 9dcbf6925bd0b5f8d22a0f729d46baa84211f41d
|
||||
@@ -65,7 +65,7 @@ A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOp
|
||||
<a id="interaction-rules-worth-keeping"></a>
|
||||
## Interaction rules worth keeping
|
||||
|
||||
The split control accepts effective shortcut tooltip and ARIA labels from its embedder. A disabled control has a keyboard-focusable wrapper that explains the pane-budget or width restriction. Docked and floating pane containers can receive programmatic focus without entering the normal tab sequence. Tab navigation and selection use unmodified keys and leave composition input to its owner.
|
||||
The split control accepts effective shortcut tooltip and ARIA labels from its embedder. A disabled control has a keyboard-focusable wrapper that explains the pane-budget or width restriction. Docked and floating pane containers can receive programmatic focus without entering the normal tab sequence or drawing a focus outline; their controls retain their own keyboard focus indicators. Tab navigation and selection use unmodified keys and leave composition input to its owner.
|
||||
|
||||
These are not stylistic; each one fixes a defect found in a real browser.
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间
|
||||
<a id="interaction-rules-worth-keeping"></a>
|
||||
## 值得保留的交互规则
|
||||
|
||||
分栏控件接收宿主传入的有效快捷键提示和 ARIA 标签。禁用控件由可键盘聚焦的容器承载,说明分栏数量或宽度限制。停靠与浮动面板容器支持程序设置焦点,不进入正常 Tab 顺序。标签导航和选择只使用无修饰键组合,输入法按键交回其所属输入区域。
|
||||
分栏控件接收宿主传入的有效快捷键提示和 ARIA 标签。禁用控件由可键盘聚焦的容器承载,说明分栏数量或宽度限制。停靠与浮动面板容器支持程序设置焦点,不进入正常 Tab 顺序,也不绘制焦点外框;内部控件保留各自的键盘焦点提示。标签导航和选择只使用无修饰键组合,输入法按键交回其所属输入区域。
|
||||
|
||||
这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。
|
||||
|
||||
|
||||
@@ -215,6 +215,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pane:focus,
|
||||
.float:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* A 38px row whose 28px content band sits at its bottom: chips fill the band,
|
||||
so they end flush with the strip's bottom edge, and the 28px add control, the
|
||||
28px split control, and the embedder's 28px chrome centre on the band's one
|
||||
|
||||
@@ -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: e7d903d5c11acd8c8f83e3e8b6452e233f7e8b44
|
||||
README.zh.md: 3d6149009ded4a31f44a87b5bf2a1776c9660dca
|
||||
README.md: 9c73af31107c079868d5df2e8cb1d93e25562c82
|
||||
README.zh.md: 48115b391961a6e34ea776a4cad3fb49c90f36d6
|
||||
@@ -27,6 +27,8 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
|
||||
|
||||
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. `ShortcutKeys` renders caller-supplied effective keys together in one rounded badge; its optional `className` lets the owner style interaction states. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
|
||||
|
||||
Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls.
|
||||
|
||||
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.
|
||||
|
||||
@@ -27,6 +27,8 @@ kind: "package-library"
|
||||
|
||||
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。`ShortcutKeys` 将调用方提供的有效按键组合显示在同一个圆角标签中;可选的 `className` 供调用方设置交互状态样式。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
|
||||
|
||||
用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
|
||||
|
||||
本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。
|
||||
|
||||
只要 Web 客户端需要标准控件或 agent 输出渲染器,就用这些原子组件拼装功能 UI。它们只经 React 渲染,并从主题取得 `--dsw-*` 设计 token,因此无需导入主题或 slot 系统即可适配任意插件。
|
||||
|
||||
@@ -31,7 +31,8 @@ type ModalProps = ModalBaseProps & (
|
||||
* @param props.title - dialog heading (aria-label in every mode).
|
||||
* @param props.closeLabel - localized accessible close-button label.
|
||||
* @param props.description - optional supporting sentence under the title.
|
||||
* @param props.children - body (inputs, etc.).
|
||||
* @param props.children - dialog body; mark its initial-focus control with
|
||||
* data-modal-autofocus instead of React autoFocus to preserve return focus.
|
||||
* @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
|
||||
|
||||
@@ -73,7 +73,7 @@ export function RiskConfirmation({
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
disabled={disabled}
|
||||
autoFocus
|
||||
data-modal-autofocus
|
||||
onChange={(event) => { onAcknowledgedChange(event.currentTarget.checked) }}
|
||||
/>
|
||||
<span>{acknowledgeLabel}</span>
|
||||
|
||||
@@ -19,6 +19,8 @@ const focusable = 'button:not(:disabled), input:not(:disabled), textarea:not(:di
|
||||
|
||||
/**
|
||||
* Give only the top modal Escape and Tab ownership, then restore its previous focus.
|
||||
* Controls mounted with the dialog use data-modal-autofocus for initial focus;
|
||||
* React autoFocus runs before this layer can capture the invoking control.
|
||||
* Local menus handle their Escape during capture before this bubble listener.
|
||||
* @param dialog - mounted dialog element.
|
||||
* @param open - whether this layer is active.
|
||||
@@ -55,7 +57,7 @@ export function useModalLayer(dialog: RefObject<HTMLElement | null>, open: boole
|
||||
const first = items[0] ?? element
|
||||
const last = items.at(-1) ?? element
|
||||
const atEdge = event.shiftKey ? document.activeElement === first : document.activeElement === last
|
||||
if (!element.contains(document.activeElement) || atEdge) {
|
||||
if (document.activeElement === element || !element.contains(document.activeElement) || atEdge) {
|
||||
event.preventDefault()
|
||||
const target = event.shiftKey ? last : first
|
||||
target.focus()
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isBehindModal } from '../src/useModalLayer.ts'
|
||||
import { Menu } from '../src/Menu.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
function Nested() {
|
||||
function Nested({ withSearch = true }: { withSearch?: boolean }) {
|
||||
const [settings, setSettings] = useState(false)
|
||||
const [reference, setReference] = useState(false)
|
||||
const [menu, setMenu] = useState(false)
|
||||
@@ -17,7 +17,7 @@ function Nested() {
|
||||
<button onClick={() => { setReference(true) }}>Reference</button>
|
||||
</Modal>
|
||||
<Modal open={reference} title="Reference" closeLabel="Close reference" onClose={() => { setReference(false) }}>
|
||||
<input data-modal-autofocus aria-label="Search" />
|
||||
{withSearch && <input data-modal-autofocus aria-label="Search" />}
|
||||
<Menu open={menu} autoFocus anchor={<button onClick={() => { setMenu(true) }}>Menu</button>}
|
||||
items={[{ id: 'item', label: 'Item' }]} onSelect={() => { setMenu(false) }} onClose={() => { setMenu(false) }} />
|
||||
</Modal>
|
||||
@@ -67,6 +67,25 @@ it('keeps Tab within the top dialog and releases listeners after unmount', () =>
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ direction: 'forward', shiftKey: false, target: 'Close reference' },
|
||||
{ direction: 'backward', shiftKey: true, target: 'Menu' },
|
||||
])('moves $direction from the top dialog container into its controls', ({ shiftKey, target }) => {
|
||||
render(<Nested withSearch={false} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
||||
const reference = screen.getByRole('button', { name: 'Reference' })
|
||||
reference.focus()
|
||||
fireEvent.click(reference)
|
||||
const dialog = screen.getByRole('dialog', { name: 'Reference' })
|
||||
dialog.focus()
|
||||
|
||||
expect(fireEvent.keyDown(dialog, { key: 'Tab', shiftKey })).toBe(false)
|
||||
expect(document.activeElement).toBe(screen.getByRole('button', { name: target }))
|
||||
escape()
|
||||
expect(screen.queryByRole('dialog', { name: 'Reference' })).toBeNull()
|
||||
expect(document.activeElement).toBe(reference)
|
||||
})
|
||||
|
||||
it('keeps focus in a headless empty dialog and leaves portaled menu traversal to its owner', () => {
|
||||
render(<Modal open headless title="Empty" onClose={() => {}} />)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
|
||||
@@ -98,6 +98,7 @@ it.each([en, zh])('updates the Settings menu keycaps and accessible combination
|
||||
const props: AccountMenuProps = {
|
||||
...({} as GlobalStandardProps), ...operations, wide: true,
|
||||
useAccount: selector => selector(operations.hooks.account.getSnapshot()),
|
||||
useTheme: selector => selector(operations.hooks.theme.getSnapshot()),
|
||||
openSettings: vi.fn(() => { expect(document.activeElement).toBe(screen.getByRole('button', { name: copy.menu })) }),
|
||||
openOnboarding: vi.fn(),
|
||||
t: key => key in copy ? copy[key as AccountKey] : key,
|
||||
|
||||
@@ -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-settings-general/README.md
|
||||
README.md: d98ddc336852becbe2a6f4b80de487d9a493b995
|
||||
README.zh.md: c89f804b4927cdc1bd90de48879e5f7b610462db
|
||||
README.md: a929dcc90f5803d60a2bf21604912ecf00b23072
|
||||
README.zh.md: 541c97410c16dbfadecfcdbcb3b8a370bde56a23
|
||||
@@ -33,7 +33,7 @@ When the section navigation exceeds the panel's available height, the list scrol
|
||||
|
||||
In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 13px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow.
|
||||
|
||||
Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. Desktop Mod+, and the sidebar control open the same dialog; repeated commands keep it open. Settings and the shortcut reference use body portals at the same modal layer. The last opened dialog appears above the other and owns keyboard focus and Escape; closing it restores its invoking control.
|
||||
Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. The Settings command (`Mod+,` by default in Desktop) and the sidebar control open the same dialog. Repeating either action keeps the dialog open. Settings and the shortcut reference use body portals at the same modal layer. The last opened dialog appears above the other and receives keyboard focus; `Escape` closes the top dialog and restores focus to its invoking control.
|
||||
|
||||
### The General section
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ kind: "package-reference"
|
||||
|
||||
在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、13px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。
|
||||
|
||||
设置的可见状态和分区选择由 shell owner store 保存。 外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。桌面 Mod+, 与侧栏控件打开同一弹窗;重复命令保持其打开。设置和快捷键速查通过 body portal 使用同一模态层级。最后打开的弹窗显示在另一弹窗上方,并拥有键盘焦点和 Esc;关闭后恢复触发控件的焦点。
|
||||
设置的可见状态和分区选择由 shell owner store 保存。外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。设置命令(Desktop 中默认为 `Mod+,`)与侧栏控件打开同一弹窗;重复执行设置命令或点击侧栏控件均保持弹窗打开。设置和快捷键速查通过 body portal 使用同一模态层级。最后打开的弹窗显示在另一弹窗上方并获得键盘焦点;`Escape` 关闭最上层弹窗,焦点返回触发该弹窗的控件。
|
||||
|
||||
### 「通用」分区
|
||||
|
||||
|
||||
@@ -678,7 +678,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
|
||||
className={styles['deleteDialog'] as string}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="outline" autoFocus disabled={deleting} onClick={closeDelete}>
|
||||
<Button variant="outline" data-modal-autofocus disabled={deleting} onClick={closeDelete}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-shortcuts/README.md
|
||||
README.md: 02b17dd2701c6aedfd0f1e270f91f7a9ee2819ed
|
||||
README.zh.md: e6bf7579c21e83b70e5c08f91fc608beb330063b
|
||||
README.md: 0e19b26d7ab5111afb5b7f7297dcb4fd43b6528b
|
||||
README.zh.md: 7307e53a6ea0a8d176121262e5261871885744bc
|
||||
@@ -27,7 +27,7 @@ Browse the commands available in the current window and find them by action, Eng
|
||||
|
||||
The Web app bundle mounts this package together with `shortcuts`. The reference shows only registered commands and existing fixed input actions. Desktop shows the effective device keycaps; commands without a Web default show No shortcut. The Settings entry and Mod+/ open the same single dialog, titled “Keyboard shortcuts” in English and “快捷键” in Chinese. Its 480×600 CSS-pixel card shrinks to fit smaller windows; the header and search remain above an internally scrolling list. The search field receives focus on open. The Settings entry uses a filled capsule button without a dropdown icon and reads the same effective binding for its tooltip and `aria-keyshortcuts`; both are absent without a binding.
|
||||
|
||||
Search matches an ordered subsequence within one label, English alias, or key combination, ignoring case. Each matching command appears once. Application and fixed-action rows remain 42 CSS pixels high during normal display and recording; long command names are truncated. Application rows show command names and binding controls without availability subtitles or reason tooltips, including conflicting and invalid bindings. Commands that cannot execute remain silent. Fixed actions show disabled, dimmed key buttons without a separate status label; they cannot enter recording and do not highlight on hover.
|
||||
Search matches an ordered subsequence within one label, English alias, or key combination, ignoring case. Each matching command appears once. Rows within each group follow command ID order, independent of plugin registration order; search relevance takes precedence and equal matches retain that order. Application and fixed-action rows remain 42 CSS pixels high during normal display and recording; long command names are truncated. Application rows show command names and binding controls without availability subtitles or reason tooltips, including conflicting and invalid bindings. Commands that cannot execute remain silent. Fixed actions show disabled, dimmed key buttons without a separate status label; they cannot enter recording and do not highlight on hover.
|
||||
|
||||
Hover an application row or focus it with the keyboard to reveal its actions. The key badge highlights only while hovered; selecting it or Edit starts the same inline recorder. Unbound commands omit both remove controls. Windows and macOS Desktop record one or two overlapping non-modifier keys with optional modifiers; a third key rejects the draft. Releasing the first key saves the current combination, so sequential A then B records only A. Saving rejects combinations occupied by editable or fixed actions, including overlapping single-key and two-key bindings. The dialog close control exits recording. The recorder keeps the same height as key badges and uses a single border. Other environments retain Escape cancellation and Tab navigation. Saving and cancellation return focus to the dialog. macOS Web can record Option+Command+N reported as a dead key; composition and ordinary accent input remain protected. Remove clears the binding; Restore Default removes only that command’s override. System toasts announce successful writes and failures; a rejected combination marks the recorder with an error border. Release its non-modifier keys to record another combination without clicking; modifiers may remain held. Repeated identical errors leave a visible toast and its dismissal time unchanged; different errors and successful writes replace it immediately. An error can appear again after dismissal. Failed writes retain recorder focus and the draft for Retry Save or another recording. Configuration or command-catalog changes require review before a draft can be retried. Read failures disable editing and identify the active bindings and affected document in a system toast: Web uses the current origin’s localStorage entry `dsh.keybindings.v1`; Desktop uses `userData/keybindings.json`. Back up and repair damaged data before reloading the page or restarting Harness; upgrade Harness for a document written by a newer version. Restore All never overwrites unreadable data. The footer shows Restore All Defaults and the current runtime/platform’s override count, including removed bindings and commands that are not currently mounted. Search does not change the count. The action is disabled without overrides. Its confirmation initially focuses Cancel; cancellation returns focus to Restore All Defaults, including when opened from the recorder. Confirming restores only this profile. A stale revision closes the confirmation and requires another confirmation against the latest configuration; a failed write retains the accepted bindings and allows retry.
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
|
||||
Web 应用 bundle 将此包与 `shortcuts` 一同挂载。速查仅展示已注册命令和现有固定输入操作。桌面端展示设备对应的有效键帽;没有 Web 默认键位的命令显示“暂无快捷键”。设置入口与 Mod+/打开同一个单实例弹窗,中文标题为“快捷键”,英文标题为“Keyboard shortcuts”。卡片尺寸为 480×600 CSS 像素,小窗口中缩小以保持可见;标题和搜索框位于内部滚动列表上方。打开时聚焦搜索框。设置入口使用无下拉图标的填充胶囊按钮,并从同一有效绑定读取提示及 `aria-keyshortcuts`;没有绑定时不显示提示,也不设置该属性。
|
||||
|
||||
搜索在单个操作名称、英文别名或按键组合内按字符顺序模糊匹配,忽略大小写。每个命中命令只出现一次。正常显示和录制时,应用操作行和固定操作行均保持 42 CSS 像素高;过长的命令名称会被截断。应用命令行只显示名称和改键控件,不显示可用性副标题或原因悬浮提示,冲突和无效绑定也不例外。无法执行的命令静默结束。固定操作使用灰色禁用的键位按钮,不额外显示状态文字;按钮不能进入录键,悬停时不高亮。
|
||||
搜索在单个操作名称、英文别名或按键组合内按字符顺序模糊匹配,忽略大小写。每个命中命令只出现一次。各分组内按命令 ID 排序,不受插件注册顺序影响;搜索时优先按相关性排序,同分项保留该顺序。正常显示和录制时,应用操作行和固定操作行均保持 42 CSS 像素高;过长的命令名称会被截断。应用命令行只显示名称和改键控件,不显示可用性副标题或原因悬浮提示,冲突和无效绑定也不例外。无法执行的命令静默结束。固定操作使用灰色禁用的键位按钮,不额外显示状态文字;按钮不能进入录键,悬停时不高亮。
|
||||
|
||||
悬停应用操作行或通过键盘聚焦可显示操作图标。键位按钮仅在自身悬停时加深底色;点击它或编辑图标均进入同一行内录键。未绑定的命令不显示删除图标和“移除”文字按钮。Windows 和 macOS Desktop 可录制一至两个按住时间重叠的非修饰键,可搭配修饰键;第三个键会使草稿被拒绝。释放第一个键即保存当前组合,因此先后按 A、B 只录入 A。保存会拒绝被可编辑或固定操作占用的组合,包括相互重叠的单键与双键绑定。弹窗关闭控件可退出录制。录制框与键位标签保持相同高度,边框为单层。其他环境保留 Escape 取消和 Tab 导航。保存及取消后焦点返回弹窗。macOS Web 可录制被报告为 dead key 的 Option+Command+N;输入法组合和普通重音输入仍受保护。移除清空绑定,恢复默认只删除该命令的覆盖。系统提示反馈保存成功和失败;组合被拒绝时,录键框显示错误边框。松开非修饰键后即可直接录入新组合,无需再次点击,修饰键可继续按住。同一错误重复发生时沿用仍在显示的提示及其消失时间;不同错误和保存成功立即替换提示。提示消失后,同类错误可再次显示。写入失败保留录键框焦点和草稿,可重试保存或直接录入新组合。配置或命令目录变化后,需要核对最新配置才能重试草稿。读取失败时禁用编辑,并通过系统提示说明当前使用的键位及受影响的配置位置:Web 使用当前来源的 localStorage 项 `dsh.keybindings.v1`;Desktop 使用 `userData/keybindings.json`。内容损坏时先备份并修复配置,再刷新页面或重新启动 Harness;配置由更新版本写入时应升级 Harness。“恢复全部默认”不会覆盖无法读取的数据。底部显示“恢复全部默认”和当前运行环境及平台的自定义项数,包括已移除的绑定和当前未挂载的命令。搜索不影响计数。没有自定义项时,该操作禁用。确认框默认聚焦“取消”;取消后焦点返回“恢复全部默认”,从录制框打开确认时也如此。确认后仅恢复当前配置分组。配置版本过期时关闭确认框,需要针对最新配置重新确认;写入失败保留已接受的绑定,允许重试。
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ShortcutsRow({ actions, t, useCatalog }: PropsRuntime<'settings.
|
||||
}
|
||||
|
||||
/**
|
||||
* Render registered commands and fixed local input actions. A query matches within one label, alias, or key field.
|
||||
* Render commands and fixed actions in stable ID order within each group. Search relevance takes precedence.
|
||||
* @param props - root store, effective catalog, and localized copy.
|
||||
* @returns the single reference dialog when open.
|
||||
*/
|
||||
@@ -115,7 +115,7 @@ export function ShortcutReference({
|
||||
group: 'application' as const,
|
||||
})),
|
||||
...fixedCatalog.map(row => ({ ...row, names: [row.id, row.keys.join(' ')] })),
|
||||
]
|
||||
].sort((left, right) => Number(left.id > right.id) - Number(left.id < right.id))
|
||||
const ranked = rankByName(entries.flatMap(row => row.names.map(name => ({ name, label: row.label, row }))), query.trim())
|
||||
const matches = [...new Set(ranked.map(match => match.row))]
|
||||
const modifiedCount = Object.keys(config.document.profiles[`${runtime}:${platform}`] ?? {}).length
|
||||
|
||||
@@ -168,6 +168,34 @@ function referenceFixture({ runtime = 'web', dictionary = en }: { runtime?: 'web
|
||||
return { store, catalog, fixedCatalog, config, edit, view }
|
||||
}
|
||||
|
||||
it('keeps row and tied-search order stable across registration and remount order', () => {
|
||||
const { catalog, fixedCatalog, store } = referenceFixture()
|
||||
const template = catalog.getSnapshot()[0]!
|
||||
const first = { ...template, id: 'first.command' as ShortcutCommandId, label: 'Zulu', aliases: ['action'] }
|
||||
const last = { ...template, id: 'last.command' as ShortcutCommandId, label: 'Alpha', aliases: ['action'] }
|
||||
const fixedFirst: ShortcutFixedCatalogEntry = { id: 'fixed.first' as ShortcutCommandId, label: 'Zulu input',
|
||||
keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'input' }
|
||||
const fixedLast = { ...fixedFirst, id: 'fixed.last' as ShortcutCommandId, label: 'Alpha input' }
|
||||
const applicationRows = () => screen.getAllByRole('button', { name: /^Edit shortcut for/ })
|
||||
.map(button => button.getAttribute('aria-label'))
|
||||
const inputRows = () => within(screen.getByRole('region', { name: en.input })).getAllByRole('listitem')
|
||||
.map(row => row.textContent)
|
||||
for (const reversed of [true, false]) {
|
||||
act(() => {
|
||||
catalog.set(reversed ? [last, first] : [first, last])
|
||||
fixedCatalog.set(reversed ? [fixedLast, fixedFirst] : [fixedFirst, fixedLast])
|
||||
})
|
||||
expect(applicationRows()).toEqual(['Edit shortcut for Zulu', 'Edit shortcut for Alpha'])
|
||||
expect(inputRows()).toEqual(['Zulu inputEnter', 'Alpha inputEnter'])
|
||||
}
|
||||
act(() => { catalog.set([last]); fixedCatalog.set([fixedLast]) })
|
||||
act(() => { catalog.set([last, first]); fixedCatalog.set([fixedLast, fixedFirst]) })
|
||||
expect(applicationRows()).toEqual(['Edit shortcut for Zulu', 'Edit shortcut for Alpha'])
|
||||
expect(inputRows()).toEqual(['Zulu inputEnter', 'Alpha inputEnter'])
|
||||
act(() => { store.actions.search('action') })
|
||||
expect(applicationRows()).toEqual(['Edit shortcut for Zulu', 'Edit shortcut for Alpha'])
|
||||
})
|
||||
|
||||
it.each((['web', 'desktop'] as const).flatMap(runtime => (['invalid', 'future'] as const)
|
||||
.flatMap(error => [{ runtime, error, dictionary: en }, { runtime, error, dictionary: zh }])))
|
||||
('identifies the $runtime document and preserves $error data in the selected locale', ({ runtime, error, dictionary }) => {
|
||||
|
||||
@@ -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-sidebar-right/README.md
|
||||
README.md: 976d8c325962591dd5556cbe25d04dc78aaf80b0
|
||||
README.zh.md: 2ac843ae1a34a7087abac350b9025d940ffcfe15
|
||||
README.md: 7edbd5188f699b578db05441e835a18993ebe29c
|
||||
README.zh.md: 8b9b45f799866c1c17b78151890c2b7eb17d03ac
|
||||
@@ -81,7 +81,7 @@ The store saves each Session's layout, tab identities, selection, split ratios,
|
||||
A tab type registers in two stages, and the shipped guide type goes through exactly the same public path a type from another package does (`ui-sidebar-documentpreview` is the live proof). Both stages sit inside the type's own `ctx.effect`, so the registration lives exactly as long as the plugin that made it.
|
||||
|
||||
1. **The type** — `ctx.sidebarRightTabs.register({ id, kind, patterns?, priority?, canOpen?, title, guide?, keepMounted? })`, a static declaration with no runtime hook, returning a disposer. `id` is this implementation's identity in the tab system, unique across every registration (a package name is the natural value; the shipped guide is `@deepseek-ai/dsh-client-ui-sidebar-right/guide`): a kind is not unique once an extension may take a builtin's over, so the implementation names itself, and a second registration of an `id` throws. A resource type names `patterns`, globs over `dsh-resource://` addresses: one containing `:` matches the whole address (`dsh-resource://file/**`); one without matches the URI's path at any depth, ignoring case (`*.md`), and an address that is not a URI matches no such pattern. A page type — the guide, a file tree — names none and is opened by kind. `canOpen(address)` vetoes a match. `title(address)` is the tab chip's text, captured when the tab opens. `guide` lists entry boxes for the guide page; picking one opens the contributing type as a page. A `kind` carries at most one `builtin` and one `extension` registration (the extension is in force; the builtin resumes when it leaves); any other collision on a kind throws. The `id` is also the key the type's body and title register under, so an extension and the builtin it takes over hold distinct cells and the seat renders the one in force.
|
||||
2. **The body** — `ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` reads `{ sidebar, panel, tab }` through the framework-injected `useTabInfo()`. `sidebar` supplies expansion and fullscreen information; `panel.id` identifies its pane; `tab` contains the record fields, `visible`, `navigation`, `signal`, and `actions`. These are not parallel owner props; the type's own store still uses `useStore`/`actions`. Optional title registrations and guide replacements share this hook; an absent title registration uses the text captured at open time.
|
||||
2. **The body** — `ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` reads `{ sidebar, panel, tab }` through the framework-injected `useTabInfo()`. `sidebar` supplies expansion and fullscreen information; `panel.id` identifies its pane; `tab` contains the record fields, `visible`, `navigation`, `signal`, and `actions`. These are not parallel owner props; the type's own store still uses `useStore`/`actions`. Optional title registrations and guide replacements share this hook; an absent title registration uses the text captured at open time. The title wrapper retains tab and occurrence DOM markers without adding a layout box, so dockkit controls icon spacing and vertical alignment in docked tabs and floating headers.
|
||||
|
||||
Which type opens a resource follows the editor-resolver convention: the types whose `patterns` match are ranked by `priority` band — `extension` (a type from outside the product, the highest, and the default when none is named), `builtin`, `fallback` (plain viewers anything more specific should beat) — then by the length of the matched pattern, then by registration order; `canOpen` removes a candidate. The bands are string literals so a type in another package needs no runtime import from here. `candidates(address)` returns the ranking, `claim(address, kind?)` the decision; naming a `kind` skips its globs but keeps its `canOpen`.
|
||||
|
||||
@@ -107,7 +107,7 @@ A type's `keepMounted: true` retains visited bodies across tab and Session chang
|
||||
|
||||
Tab owners register `registerCloseHandler(kind, handler)` through an effect. A handler synchronously retains any background cleanup before allowing explicit close or replacement. The resource owner tracks completion and retry; the sidebar does not wait. A thrown handler preserves the tab. Collapse, presentation changes and plugin disposal do not invoke close handlers; the tab abort signal identifies occurrence disposal, not an explicit close.
|
||||
|
||||
Close and refresh commands resolve the focused pane and tab occurrence. Page bodies bind their refresh operation through `tab.actions.bindCommands()` and release it on unmount; ended occurrences cannot regain capabilities. Refresh never reloads the application. Shortcut page opens and all splits focus the selected pane after its DOM commits, including singleton reveals and guide replacements. Pane focus preserves existing text selections. If a focused page input or iframe is replaced, focus returns to its visible pane only while no other element owns focus; an outside pointer gesture or explicit blur cancels this restoration. Close checks the captured identity again and uses resource cleanup. After a keyboard or native-menu close, focus moves to a surviving visible pane in the same Session so consecutive close commands continue there. Stale targets and cleanup failures never fall back to closing a window. Modal restrictions follow the [shortcut service](../shortcuts/README.md); Windows and macOS Desktop bindings take priority over modal controls. Closing the sole docked guide with a shortcut or native menu collapses the sidebar and retains the guide. Desktop closes its window only when no sidebar page owns focus; Web leaves the browser open.
|
||||
Close and refresh commands resolve the focused pane and tab occurrence. Page bodies bind their refresh operation through `tab.actions.bindCommands()` and release it on unmount; ended occurrences cannot regain capabilities. Refresh never reloads the application. Shortcut page opens, sidebar expansion, and all splits focus the selected dock or floating pane after its DOM commits, including singleton reveals and guide replacements. Sidebar expansion focuses the active dock pane; collapse preserves focus inside floating panes. Pane focus preserves existing text selections. If a focused page input or iframe is replaced, focus returns to its visible pane only while no other element owns focus; an outside pointer gesture or explicit blur cancels this restoration. Close checks the captured identity again and uses resource cleanup. After a keyboard or native-menu close, focus moves to a surviving visible pane in the same Session so consecutive close commands continue there. Stale targets and cleanup failures never fall back to closing a window. Modal restrictions follow the [shortcut service](../shortcuts/README.md); Windows and macOS Desktop bindings take priority over modal controls. Closing the sole docked guide with a shortcut or native menu collapses the sidebar and retains the guide. Desktop requests window closure through the shortcut service only when no sidebar page owns focus; Web leaves the browser open.
|
||||
|
||||
<a id="the-guide"></a>
|
||||
## The guide
|
||||
|
||||
@@ -81,7 +81,7 @@ store 将每个 Session 的布局、标签身份、选中项、分栏比例、
|
||||
tab 类型分两阶段注册,随包发布的引导类型走的正是别的包的类型走的同一条公开路径(`ui-sidebar-documentpreview` 是活的证明)。两个阶段都在类型自己的 `ctx.effect` 里,因此注册与创建它的插件同生共死。
|
||||
|
||||
1. **类型**——`ctx.sidebarRightTabs.register({ id, kind, patterns?, priority?, canOpen?, title, guide?, keepMounted? })`,一份没有运行时钩子的静态声明,返回 disposer。`id` 是这个实现在 tab 系统里的身份,在全部注册中唯一(包名是天然取值;随包引导页是 `@deepseek-ai/dsh-client-ui-sidebar-right/guide`):一旦 extension 可以接管 builtin 的 kind,kind 就不再唯一,所以实现要自己命名,同一 `id` 的第二次注册会 throw。资源类型给出 `patterns`,即作用于 `dsh-resource://` 地址的 glob:含 `:` 的匹配整个地址(`dsh-resource://file/**`);不含的匹配 URI 路径的任意深度且忽略大小写(`*.md`),不是 URI 的地址不匹配任何这类模式。页类型——引导页、文件树——不给出模式,按 kind 打开。`canOpen(address)` 否决一次命中。`title(address)` 是 tab chip 的文字,在 tab 打开时捕获。`guide` 列出引导页的入口框;选中一个即把贡献它的类型作为页打开。一个 `kind` 最多承载一份 `builtin` 与一份 `extension` 注册(extension 生效;它离开后 builtin 恢复);kind 上的其它任何撞名都 throw。`id` 同时也是该类型正文与标题注册时用的 key,因此 extension 与它接管的 builtin 各占一个格位,席位渲染生效的那个。
|
||||
2. **正文**——`ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` 通过框架注入的 `useTabInfo()` 读取 `{ sidebar, panel, tab }`。`sidebar` 提供开合与全屏信息,`panel.id` 命名所在格,`tab` 包含原记录字段、`visible`、`navigation`、`signal` 和 `actions`。这些字段不再作为平铺owner props传入;类型自己的store仍使用 `useStore`/`actions`。可选标题注册及引导替换共享该hook;未注册标题时使用打开时保存的文本。
|
||||
2. **正文**——`ctx.slots.register({ name: 'sidebar.right.pane.tab', key: definition.id }, Body)` 通过框架注入的 `useTabInfo()` 读取 `{ sidebar, panel, tab }`。`sidebar` 提供开合与全屏信息,`panel.id` 命名所在格,`tab` 包含原记录字段、`visible`、`navigation`、`signal` 和 `actions`。这些字段不再作为平铺owner props传入;类型自己的store仍使用 `useStore`/`actions`。可选标题注册及引导替换共享该hook;未注册标题时使用打开时保存的文本。标题包装保留 tab 与 occurrence 的 DOM 标记,但不引入布局盒,因此停靠 tab 和浮窗标题中的图标间距与垂直对齐由 dockkit 控制。
|
||||
|
||||
由哪个类型打开资源遵循编辑器解析器的惯例:`patterns` 命中的类型先按 `priority` 档排序——`extension`(产品外的类型,最高档,也是未命名时的默认)、`builtin`、`fallback`(任何更具体的类型都应胜过的通用查看器)——再按命中模式的长度,再按注册顺序;`canOpen` 会剔除候选。各档是字符串字面量,因此别的包里的类型不需要从这里做运行时导入。`candidates(address)` 返回排序,`claim(address, kind?)` 返回决定;指定 `kind` 时跳过它的 glob 但保留它的 `canOpen`。
|
||||
|
||||
@@ -107,7 +107,7 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止
|
||||
|
||||
标签页所有者通过 effect 注册 `registerCloseHandler(kind, handler)`。handler 在允许显式关闭或替换前同步保存后台清理任务。资源所有者跟踪完成和重试,侧栏不等待清理。handler 抛错时保留标签页。折叠、展示方式改变和插件卸载不调用关闭 handler;tab abort signal 标识 occurrence 卸载,不代表显式关闭。
|
||||
|
||||
关闭与刷新命令解析聚焦 pane 和 tab occurrence。页面通过 `tab.actions.bindCommands()` 绑定刷新操作,并在卸载时释放;已结束的 occurrence 不能重新获得能力。刷新不会重载应用。通过快捷键打开页面或执行分栏操作后,焦点在 DOM 提交后移至选中分栏,包括显示已有单例页面和替换引导页。分栏获得焦点时保留现有文本选区。聚焦的页面输入框或 iframe 被替换时,仅在没有其他元素持有焦点的情况下将焦点交还其可见分栏;外部指针操作或显式失焦会取消此次恢复。关闭会再次检查捕获的身份并执行资源清理。通过快捷键或原生菜单关闭后,焦点移至同一 Session 中仍可见的分栏,让连续关闭继续作用于右栏。过期目标和清理失败均不会回退为关窗。模态限制遵循[快捷键服务](../shortcuts/README.zh.md);Windows 和 macOS Desktop 绑定优先于模态控件。通过快捷键或原生菜单关闭唯一的停靠引导页时,收起右栏并保留引导页。桌面仅在焦点没有所属的右栏页面时关闭窗口;Web 保留浏览器窗口。
|
||||
关闭与刷新命令解析聚焦 pane 和 tab occurrence。页面通过 `tab.actions.bindCommands()` 绑定刷新操作,并在卸载时释放;已结束的 occurrence 不能重新获得能力。刷新不会重载应用。通过快捷键打开页面、展开侧栏或执行分栏操作后,焦点在 DOM 提交后移至选中的停靠或浮动分栏,包括显示已有单例页面和替换引导页。展开侧栏时聚焦活动停靠分栏;收起时保留浮动分栏内的焦点。分栏获得焦点时保留现有文本选区。聚焦的页面输入框或 iframe 被替换时,仅在没有其他元素持有焦点的情况下将焦点交还其可见分栏;外部指针操作或显式失焦会取消此次恢复。关闭会再次检查捕获的身份并执行资源清理。通过快捷键或原生菜单关闭后,焦点移至同一 Session 中仍可见的分栏,让连续关闭继续作用于右栏。过期目标和清理失败均不会回退为关窗。模态限制遵循[快捷键服务](../shortcuts/README.zh.md);Windows 和 macOS Desktop 绑定优先于模态控件。通过快捷键或原生菜单关闭唯一的停靠引导页时,收起右栏并保留引导页。Desktop 仅在焦点没有所属的右栏页面时通过快捷键服务请求关闭窗口;Web 保留浏览器窗口。
|
||||
|
||||
<a id="the-guide"></a>
|
||||
## 引导页
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
* from another package does — `ui-sidebar-documentpreview` is the live proof.
|
||||
*/
|
||||
import type {} from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { DesktopKeyboardApi } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import { observeSidebarFocus } from './focus.ts'
|
||||
import { registerSidebarShortcuts } from './shortcuts.ts'
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
@@ -134,9 +133,7 @@ export function apply(ctx: ClientContext): void {
|
||||
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-right: dictionaries')
|
||||
ctx.effect(() => registerSidebarShortcuts(ctx.shortcuts, controller, t, () => {
|
||||
const keyboard = (window as Window & { dshDesktop?: { keyboard?: DesktopKeyboardApi } }).dshDesktop?.keyboard
|
||||
if (keyboard === undefined) throw new Error('Desktop keyboard bridge unavailable')
|
||||
void keyboard.closeWindow(ctx.shortcuts.config.getSnapshot().revision).catch((error: unknown) => { console.error('Window close failed', error) })
|
||||
void ctx.shortcuts.closeWindow().catch((error: unknown) => { console.error('Window close failed', error) })
|
||||
}), 'ui-sidebar-right: shortcuts')
|
||||
if (typeof document !== 'undefined') ctx.effect(() => observeSidebarFocus(document), 'ui-sidebar-right: focus')
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@ export interface ISidebarRight {
|
||||
* @returns `true` while expanded; `false` while collapsed to its rail.
|
||||
*/
|
||||
isExpanded(): boolean
|
||||
/** Collapse an expanded column, or expand a collapsed one. Recorded in the sequence. */
|
||||
/** Collapse the column, or expand it and focus its active dock pane. Recorded in the sequence. */
|
||||
toggleExpanded(): void
|
||||
/**
|
||||
* Focus a tab and the pane holding it, raising a floating one. Recorded.
|
||||
@@ -472,10 +472,14 @@ export class SidebarRightController implements ISidebarRight {
|
||||
return this.mountedSurface()?.layout.expanded ?? false
|
||||
}
|
||||
|
||||
/** Collapse an expanded column, or expand a collapsed one. */
|
||||
/** Collapse the column, or expand it and focus its active dock pane after rendering. */
|
||||
toggleExpanded(): void {
|
||||
const { sessionId, actions } = this.require()
|
||||
actions.toggleExpanded(sessionId)
|
||||
const { sessionId, actions, openWithFocus } = this.require()
|
||||
openWithFocus(() => {
|
||||
actions.toggleExpanded(sessionId)
|
||||
const layout = this.mountedSurface()?.layout
|
||||
return layout?.expanded ? activeDockPaneId(layout) : undefined
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -172,6 +172,11 @@
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
}
|
||||
|
||||
/* Keep the title's icon and text in dockkit's flex row while retaining occurrence markers. */
|
||||
.tabTitle {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.tabBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -217,7 +217,7 @@ function TabSlot({
|
||||
}), [tab.id, seat, fullscreen, active, signal, tabActions, useStore, useTabNavigation, shortcuts])
|
||||
const content = renderSlot(seat, {}, { entryKey: definition?.id ?? tab.kind, fallback, hookContext })
|
||||
return seat === 'sidebar.right.pane.tab.title'
|
||||
? <span data-sidebar-right-tab={tab.id} data-sidebar-right-occurrence={id}>{content}</span>
|
||||
? <span className={css.tabTitle} data-sidebar-right-tab={tab.id} data-sidebar-right-occurrence={id}>{content}</span>
|
||||
: <div className={css.tabBody} data-sidebar-right-tab={tab.id} data-sidebar-right-occurrence={id}>{content}</div>
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { Shortcuts, ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { initialShortcutConfig } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import type { GuideInjected, SidebarRightInjected } from '../src/client/index.ts'
|
||||
import { apply as hostApply } from '../src/index.ts'
|
||||
@@ -93,30 +92,25 @@ describe('ui-sidebar-right apply', () => {
|
||||
expect(hostApply).not.toThrow()
|
||||
})
|
||||
|
||||
it('uses the latest shortcut revision for native close and contains bridge rejections', async () => {
|
||||
it('routes native close through the shortcut service and contains bridge rejections', async () => {
|
||||
onTestFinished(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||||
const closeWindow = vi.fn<() => Promise<void>>().mockResolvedValue()
|
||||
vi.stubGlobal('window', { dshDesktop: { keyboard: { closeWindow } } })
|
||||
const commands = new Map<string, ShortcutCommand>()
|
||||
let snapshot = initialShortcutConfig()
|
||||
const h = await boot({ runtime: 'desktop',
|
||||
config: { getSnapshot: () => snapshot, subscribe: () => () => {} },
|
||||
closeWindow,
|
||||
register: (command) => { commands.set(command.id, command); return () => { commands.delete(command.id) } },
|
||||
})
|
||||
onTestFinished(async () => { await h.ctx.fiber.dispose() })
|
||||
const close = commands.get('page.close')!.resolve({ region: 'page', modal: null, target: null })
|
||||
expect(close.status).toBe('handled')
|
||||
if (close.status !== 'handled') throw new Error('Expected native close')
|
||||
snapshot = initialShortcutConfig()
|
||||
close.run()
|
||||
expect(closeWindow).toHaveBeenCalledExactlyOnceWith(snapshot.revision)
|
||||
expect(closeWindow).toHaveBeenCalledExactlyOnceWith()
|
||||
const failure = new Error('Window unavailable')
|
||||
closeWindow.mockRejectedValueOnce(failure)
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
close.run()
|
||||
await vi.waitFor(() => { expect(error).toHaveBeenCalledExactlyOnceWith('Window close failed', failure) })
|
||||
vi.stubGlobal('window', {})
|
||||
expect(() => { close.run() }).toThrow('Desktop keyboard bridge unavailable')
|
||||
})
|
||||
|
||||
it('provides both faces, and registers the guide through the same two-stage path as any other type', async () => {
|
||||
|
||||
@@ -228,8 +228,15 @@ describe('RightbarSeat presentation', () => {
|
||||
alt: false, shift: false, repeat: false, composing: false, defaultPrevented: false },
|
||||
{ target: document.activeElement, region: 'page', modal: null }, vi.fn()) })
|
||||
}
|
||||
const composer = document.createElement('textarea')
|
||||
h.view.container.append(composer)
|
||||
composer.focus()
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
expect(focusedPane()).toBe(h.layout().activePaneId)
|
||||
close()
|
||||
expect(h.layout().expanded).toBe(false)
|
||||
expect(closeWindow).not.toHaveBeenCalled()
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
element(h.view.container, '[data-dockkit-tab]').focus()
|
||||
act(() => { h.controller.openTabFromTarget('browser', h.controller.commandTarget()!) })
|
||||
expect(Object.values(h.layout().tabs).map(tab => tab.kind)).toEqual(['browser'])
|
||||
expect(focusedPane()).toBe(h.layout().activePaneId)
|
||||
@@ -274,6 +281,18 @@ describe('RightbarSeat presentation', () => {
|
||||
expect(document.activeElement?.getAttribute('data-dockkit-pane')).toBe(created)
|
||||
})
|
||||
|
||||
it('focuses the dock on expansion while leaving floating focus alone on collapse', async () => {
|
||||
const h = await mountSeat()
|
||||
const tab = h.open()
|
||||
act(() => { h.controller.float(tab.id) })
|
||||
const floating = element(h.view.container, '[data-dockkit-float]')
|
||||
floating.focus()
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
expect(document.activeElement).toBe(floating)
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
expect(document.activeElement?.getAttribute('data-dockkit-pane')).toBe(dockPaneIds(h.layout())[0])
|
||||
})
|
||||
|
||||
it('retains the focused page when its body moves between docked and floating panes', async () => {
|
||||
const h = await mountSeat()
|
||||
const tab = h.open()
|
||||
|
||||
@@ -1389,7 +1389,7 @@ export function WorkspaceBrowser({
|
||||
className={css.renameInput}
|
||||
value={renameDraft}
|
||||
aria-label={t('field.workspaceName')}
|
||||
autoFocus
|
||||
data-modal-autofocus
|
||||
disabled={renaming}
|
||||
onFocus={(e) => { e.target.select() }}
|
||||
onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }}
|
||||
|
||||
@@ -105,7 +105,7 @@ function RenameForm({ request, renameSession, onSettle, t }: {
|
||||
className={css.renameInput}
|
||||
value={draft}
|
||||
aria-label={t('field.sessionName')}
|
||||
autoFocus
|
||||
data-modal-autofocus
|
||||
disabled={renaming}
|
||||
onFocus={(e) => { e.target.select() }}
|
||||
onChange={(e) => { setDraft(e.target.value); setError(null) }}
|
||||
|
||||
@@ -255,6 +255,36 @@ describe('SessionRenameDialog', () => {
|
||||
expect(document.body.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('restores composer focus and selection after cancelling and accepting a rename', async () => {
|
||||
const renameSession = vi.fn(async () => {})
|
||||
const { ask } = renameDialog(renameSession)
|
||||
render(<textarea aria-label="Composer" defaultValue="Keep this draft" />)
|
||||
const composer = screen.getByRole<HTMLTextAreaElement>('textbox', { name: 'Composer' })
|
||||
composer.focus()
|
||||
composer.setSelectionRange(5, 9)
|
||||
|
||||
ask('one', 'Session title')
|
||||
const firstInput = screen.getByLabelText<HTMLInputElement>('会话名称')
|
||||
expect(document.activeElement).toBe(firstInput)
|
||||
expect([firstInput.selectionStart, firstInput.selectionEnd]).toEqual([0, 'Session title'.length])
|
||||
fireEvent.keyDown(firstInput, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.activeElement).toBe(composer)
|
||||
expect([composer.selectionStart, composer.selectionEnd]).toEqual([5, 9])
|
||||
expect(renameSession).not.toHaveBeenCalled()
|
||||
|
||||
ask('one', 'Session title')
|
||||
const secondInput = screen.getByLabelText<HTMLInputElement>('会话名称')
|
||||
expect(document.activeElement).toBe(secondInput)
|
||||
fireEvent.change(secondInput, { target: { value: 'Renamed session' } })
|
||||
await act(async () => { fireEvent.keyDown(secondInput, { key: 'Enter' }) })
|
||||
expect(renameSession).toHaveBeenCalledWith(sid('one'), 'Renamed session')
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.activeElement).toBe(composer)
|
||||
expect(composer.value).toBe('Keep this draft')
|
||||
expect([composer.selectionStart, composer.selectionEnd]).toEqual([5, 9])
|
||||
})
|
||||
|
||||
it('seeds the draft from the request, renames with the trimmed title, and settles on acceptance', async () => {
|
||||
const pending = Promise.withResolvers<undefined>()
|
||||
const renameSession = vi.fn(() => pending.promise)
|
||||
|
||||
Generated
+3
@@ -2908,6 +2908,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../test-support/client-runtime
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-host-webserver':
|
||||
specifier: workspace:^
|
||||
version: link:../../host/webserver
|
||||
|
||||
@@ -56,9 +56,6 @@
|
||||
"4fb87a7468d8632606fe338680cfcdec41051b543052a0ec14c55862b43ed928": 1,
|
||||
"bb108d5dfeed36030bc4cf80fbbef2ef0cbcc72ab603bad208560b55bbf54875": 1
|
||||
},
|
||||
"apps/desktop/tests/mandatory-update-window.spec.ts": {
|
||||
"326597b335539db945b2e3b7c86638d40849d2077c0a5670d064e45486f4e407": 1
|
||||
},
|
||||
"apps/desktop/tests/preload-windows.client.spec.ts": {
|
||||
"fdbd843bccc38b95f572f49587aacc29b9b0c67390d922ab5404a5935c747487": 1
|
||||
},
|
||||
@@ -76,9 +73,6 @@
|
||||
"apps/desktop/tests/update-http-executor.spec.ts": {
|
||||
"e46a2f88f5bcac6be4336f4a9b106f5cb17c1eec4610b29145dc413254471a94": 1
|
||||
},
|
||||
"apps/desktop/tests/update-overlay.spec.ts": {
|
||||
"4000017f1f3dabd4bdf8eb91120cf4ff0dd09bad89d4683151f4785974d204a5": 1
|
||||
},
|
||||
"apps/desktop/tests/update-schedule.spec.ts": {
|
||||
"d8502ebfa0f7381727d04f3999c1a1e85c27ca770ef336e6cbe9dfd41d6b53bd": 1
|
||||
},
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"packages/client/*/tests/**/*.host.spec.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.desktop-keyboard-tests.json" },
|
||||
{ "path": "./apps/desktop/tsconfig.client.json" },
|
||||
{ "path": "./packages/experimental/client-ui-voice-input" },
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
// The native/renderer integration fixture imports only Cordis-free Desktop modules.
|
||||
"extends": "./tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "lib/desktop-keyboard-test-types",
|
||||
"emitDeclarationOnly": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"files": [
|
||||
"apps/desktop/tests/keyboard.spec.ts",
|
||||
"apps/desktop/src/keyboard.ts",
|
||||
"apps/desktop/src/keybindings.ts",
|
||||
"apps/desktop/src/browser-guests.ts",
|
||||
"apps/desktop/src/ipc.ts",
|
||||
"apps/desktop/src/update-overlay.ts"
|
||||
],
|
||||
"include": [],
|
||||
"references": [
|
||||
{ "path": "./packages/client/shortcuts/tsconfig.client.json" },
|
||||
{ "path": "./packages/client/ui-sidebar-browser/tsconfig.client.json" },
|
||||
{ "path": "./packages/util/atomic-write" }
|
||||
]
|
||||
}
|
||||
+1
-7
@@ -7,13 +7,6 @@
|
||||
"noEmit": true,
|
||||
"rewriteRelativeImportExtensions": false
|
||||
},
|
||||
"files": [
|
||||
// Native keyboard tests exercise the Cordis-free renderer adapters in the same event stream.
|
||||
"packages/client/shortcuts/src/client/registry.ts",
|
||||
"packages/client/shortcuts/src/client/dom.ts",
|
||||
"packages/client/shortcuts/src/client/native.ts",
|
||||
"packages/client/shortcuts/src/client/types.ts"
|
||||
],
|
||||
"include": [
|
||||
"apps/web/product-isolation.ts",
|
||||
"apps/web/tests/connection-recovery.e2e.ts",
|
||||
@@ -180,6 +173,7 @@
|
||||
// suffixes are mutually exclusive, so each aggregate excludes the other's
|
||||
// and the package test glob above needs no per-file entry.
|
||||
"exclude": [
|
||||
"apps/desktop/tests/keyboard.spec.ts",
|
||||
"packages/client/*/src/**",
|
||||
"benchmarks/**/*.client.ts",
|
||||
"benchmarks/**/*.client.tsx",
|
||||
|
||||
Reference in new issue
Block a user