feat(shortcuts): add focused panel and page commands
Route file, terminal, browser, split, fullscreen, close, and refresh commands to the focused panel. Connect native Desktop page and window actions to the same command owners. Co-authored-by: 神河一号 - 烈焰流光 <[email protected]>
This commit is contained in:
1 parent
a532a687a6
commit
f54744e35b
98 files changed
+2071
-161
No files matched your search
@@ -749,7 +749,7 @@ async function main(): Promise<void> {
|
||||
// Keep app.name stable: Electron derives its default userData directory from it.
|
||||
const darwin = process.platform === 'darwin'
|
||||
const platformMenus = (): MenuItemConstructorOptions[] => darwin
|
||||
? [{ role: 'fileMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }]
|
||||
? [shortcuts.fileMenu(currentDesktopLocale().messages), { role: 'editMenu' }, { role: 'windowMenu' }]
|
||||
: [{ role: 'editMenu' }]
|
||||
const hideCommands: MenuItemConstructorOptions[] = darwin
|
||||
? [{ role: 'hide', label: currentDesktopLocale().messages.hideApplication },
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
- tablist:
|
||||
- tab "Browser Close":
|
||||
- text: Browser
|
||||
- button "Close"
|
||||
- tab "Browser Close" [selected]:
|
||||
- text: Browser
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split"
|
||||
- button "Fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- button "Back" [disabled]
|
||||
- button "Forward" [disabled]
|
||||
- button "Reload" [disabled]
|
||||
- textbox "Enter an HTTP(S) address"
|
||||
- button "Open in system browser" [disabled]
|
||||
- button "Disable sandbox restrictions"
|
||||
- text: Enter an HTTP(S) address to start browsing
|
||||
@@ -0,0 +1,9 @@
|
||||
- tablist:
|
||||
- tab "Start" [selected]
|
||||
- button "Split"
|
||||
- button "Exit fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- button "Workspace files Browse files in this session's workspace"
|
||||
- button "New terminal Run commands in the Session workspace"
|
||||
- button "Choose shell"
|
||||
- button "Browser Browse web pages"
|
||||
@@ -0,0 +1,23 @@
|
||||
- tablist:
|
||||
- tab "Files Close" [selected]:
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- text: {{cwd}}/workspace
|
||||
- button "Reload"
|
||||
- list:
|
||||
- listitem:
|
||||
- button "t6-refresh.txt"
|
||||
- tablist:
|
||||
- tab "Files Close":
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- tab "bash Close" [selected]:
|
||||
- text: bash
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- button "Fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- textbox "Terminal"
|
||||
@@ -0,0 +1,2 @@
|
||||
- menu:
|
||||
- menuitem "Close"
|
||||
@@ -0,0 +1,25 @@
|
||||
- tablist:
|
||||
- tab "Files Close" [selected]:
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- text: {{cwd}}/workspace
|
||||
- button "Reload"
|
||||
- list:
|
||||
- listitem: Empty directory
|
||||
- tablist:
|
||||
- tab "Files Close":
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- tab "bash Close":
|
||||
- text: bash
|
||||
- button "Close"
|
||||
- tab "bash Close" [selected]:
|
||||
- text: bash
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- button "Exit fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- textbox "Terminal"
|
||||
@@ -0,0 +1,18 @@
|
||||
- tablist:
|
||||
- tab "Browser Close":
|
||||
- text: Browser
|
||||
- button "Close"
|
||||
- tab "Browser Close" [selected]:
|
||||
- text: Browser
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split"
|
||||
- button "Fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- button "Back" [disabled]
|
||||
- button "Forward" [disabled]
|
||||
- button "Reload" [disabled]
|
||||
- textbox "Enter an HTTP(S) address"
|
||||
- button "Open in system browser" [disabled]
|
||||
- button "Disable sandbox restrictions"
|
||||
- text: Enter an HTTP(S) address to start browsing
|
||||
@@ -0,0 +1,9 @@
|
||||
- tablist:
|
||||
- tab "Start" [selected]
|
||||
- button "Split"
|
||||
- button "Exit fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- button "Workspace files Browse files in this session's workspace"
|
||||
- button "New terminal Run commands in the Session workspace"
|
||||
- button "Choose shell"
|
||||
- button "Browser Browse web pages"
|
||||
@@ -0,0 +1,23 @@
|
||||
- tablist:
|
||||
- tab "Files Close" [selected]:
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- text: {{cwd}}/workspace
|
||||
- button "Reload"
|
||||
- list:
|
||||
- listitem:
|
||||
- button "t6-refresh.txt"
|
||||
- tablist:
|
||||
- tab "Files Close":
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- tab "bash Close" [selected]:
|
||||
- text: bash
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- button "Fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- textbox "Terminal"
|
||||
@@ -0,0 +1,2 @@
|
||||
- menu:
|
||||
- menuitem "Close"
|
||||
@@ -0,0 +1,25 @@
|
||||
- tablist:
|
||||
- tab "Files Close" [selected]:
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- text: {{cwd}}/workspace
|
||||
- button "Reload"
|
||||
- list:
|
||||
- listitem: Empty directory
|
||||
- tablist:
|
||||
- tab "Files Close":
|
||||
- text: Files
|
||||
- button "Close"
|
||||
- tab "bash Close":
|
||||
- text: bash
|
||||
- button "Close"
|
||||
- tab "bash Close" [selected]:
|
||||
- text: bash
|
||||
- button "Close"
|
||||
- button "New tab"
|
||||
- button "Split" [disabled]
|
||||
- button "Exit fullscreen"
|
||||
- button "Collapse right sidebar"
|
||||
- textbox "Terminal"
|
||||
@@ -16,6 +16,35 @@
|
||||
- button "Remove shortcut for Toggle left sidebar"
|
||||
- button "Edit shortcut for Toggle left sidebar"
|
||||
- button "Toggle left sidebar ⌥ ⌘ B": ⌥ ⌘ B
|
||||
- listitem:
|
||||
- text: Toggle right sidebar
|
||||
- button "Remove shortcut for Toggle right sidebar"
|
||||
- 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: Browser
|
||||
- button "Remove shortcut for Browser"
|
||||
- button "Edit shortcut for Browser"
|
||||
- button "Browser ⌥ ⌘ T": ⌥ ⌘ T
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
@@ -51,6 +80,16 @@
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session ⌥ ⌘ A": ⌥ ⌘ A
|
||||
- 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:
|
||||
|
||||
@@ -16,6 +16,36 @@
|
||||
- button "Remove shortcut for Toggle left sidebar"
|
||||
- button "Edit shortcut for Toggle left sidebar"
|
||||
- button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B
|
||||
- listitem:
|
||||
- text: Toggle right sidebar
|
||||
- button "Remove shortcut for Toggle right sidebar"
|
||||
- button "Edit shortcut for Toggle right sidebar"
|
||||
- button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B
|
||||
- listitem:
|
||||
- text: Split
|
||||
- button "Remove shortcut for Split"
|
||||
- button "Edit shortcut for Split"
|
||||
- button "Split Ctrl + \\": Ctrl + \
|
||||
- listitem:
|
||||
- text: Toggle panel fullscreen
|
||||
- button "Remove shortcut for Toggle panel fullscreen"
|
||||
- button "Edit shortcut for Toggle panel fullscreen"
|
||||
- button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter
|
||||
- listitem:
|
||||
- text: Close current page or window
|
||||
- button "Remove shortcut for Close current page or window"
|
||||
- button "Edit shortcut for Close current page or window"
|
||||
- button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W
|
||||
- listitem:
|
||||
- text: Refresh current page
|
||||
- button "Remove shortcut for Refresh current page"
|
||||
- button "Edit shortcut for Refresh current page"
|
||||
- button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R
|
||||
- listitem:
|
||||
- text: Browser
|
||||
- button "Remove shortcut for Browser"
|
||||
- button "Edit shortcut for Browser"
|
||||
- button "Browser Ctrl + Alt + T": Ctrl + Alt + T
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
@@ -51,6 +81,16 @@
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session Ctrl + Alt + A": Ctrl + Alt + A
|
||||
- listitem:
|
||||
- text: Workspace files
|
||||
- button "Remove shortcut for Workspace files"
|
||||
- button "Edit shortcut for Workspace files"
|
||||
- button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P
|
||||
- listitem:
|
||||
- text: New terminal
|
||||
- button "Remove shortcut for New terminal"
|
||||
- button "Edit shortcut for New terminal"
|
||||
- 'button "New terminal Ctrl + `"': "Ctrl + `"
|
||||
- region "Message input":
|
||||
- heading "Message input" [level=3]
|
||||
- list:
|
||||
|
||||
@@ -16,6 +16,36 @@
|
||||
- button "Remove shortcut for Toggle left sidebar"
|
||||
- button "Edit shortcut for Toggle left sidebar"
|
||||
- button "Toggle left sidebar Ctrl + Alt + B": Ctrl + Alt + B
|
||||
- listitem:
|
||||
- text: Toggle right sidebar
|
||||
- button "Remove shortcut for Toggle right sidebar"
|
||||
- button "Edit shortcut for Toggle right sidebar"
|
||||
- button "Toggle right sidebar Ctrl + Shift + B": Ctrl + Shift + B
|
||||
- listitem:
|
||||
- text: Split
|
||||
- button "Remove shortcut for Split"
|
||||
- button "Edit shortcut for Split"
|
||||
- button "Split Ctrl + \\": Ctrl + \
|
||||
- listitem:
|
||||
- text: Toggle panel fullscreen
|
||||
- button "Remove shortcut for Toggle panel fullscreen"
|
||||
- button "Edit shortcut for Toggle panel fullscreen"
|
||||
- button "Toggle panel fullscreen Ctrl + Alt + Enter": Ctrl + Alt + Enter
|
||||
- listitem:
|
||||
- text: Close current page or window
|
||||
- button "Remove shortcut for Close current page or window"
|
||||
- button "Edit shortcut for Close current page or window"
|
||||
- button "Close current page or window Ctrl + Alt + W": Ctrl + Alt + W
|
||||
- listitem:
|
||||
- text: Refresh current page
|
||||
- button "Remove shortcut for Refresh current page"
|
||||
- button "Edit shortcut for Refresh current page"
|
||||
- button "Refresh current page Ctrl + Alt + R": Ctrl + Alt + R
|
||||
- listitem:
|
||||
- text: Browser
|
||||
- button "Remove shortcut for Browser"
|
||||
- button "Edit shortcut for Browser"
|
||||
- button "Browser Ctrl + Alt + T": Ctrl + Alt + T
|
||||
- listitem:
|
||||
- text: Open settings
|
||||
- button "Remove shortcut for Open settings"
|
||||
@@ -51,6 +81,16 @@
|
||||
- button "Remove shortcut for Archive session"
|
||||
- button "Edit shortcut for Archive session"
|
||||
- button "Archive session Ctrl + Alt + A": Ctrl + Alt + A
|
||||
- listitem:
|
||||
- text: Workspace files
|
||||
- button "Remove shortcut for Workspace files"
|
||||
- button "Edit shortcut for Workspace files"
|
||||
- button "Workspace files Ctrl + Alt + P": Ctrl + Alt + P
|
||||
- listitem:
|
||||
- text: New terminal
|
||||
- button "Remove shortcut for New terminal"
|
||||
- button "Edit shortcut for New terminal"
|
||||
- 'button "New terminal Ctrl + `"': "Ctrl + `"
|
||||
- region "Message input":
|
||||
- heading "Message input" [level=3]
|
||||
- list:
|
||||
|
||||
@@ -16,6 +16,35 @@
|
||||
- button "移除展开/收起左侧栏快捷键"
|
||||
- button "修改展开/收起左侧栏快捷键"
|
||||
- button "展开/收起左侧栏 ⌥ ⌘ B": ⌥ ⌘ B
|
||||
- listitem:
|
||||
- text: 展开/收起右侧栏
|
||||
- button "移除展开/收起右侧栏快捷键"
|
||||
- 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 "浏览器 ⌥ ⌘ T": ⌥ ⌘ T
|
||||
- listitem:
|
||||
- text: 打开设置
|
||||
- button "移除打开设置快捷键"
|
||||
@@ -51,6 +80,16 @@
|
||||
- button "移除归档会话快捷键"
|
||||
- button "修改归档会话快捷键"
|
||||
- button "归档会话 ⌥ ⌘ A": ⌥ ⌘ A
|
||||
- listitem:
|
||||
- text: 工作区文件
|
||||
- button "移除工作区文件快捷键"
|
||||
- button "修改工作区文件快捷键"
|
||||
- button "工作区文件 ⌥ ⌘ P": ⌥ ⌘ P
|
||||
- listitem:
|
||||
- text: 新建终端
|
||||
- button "移除新建终端快捷键"
|
||||
- button "修改新建终端快捷键"
|
||||
- 'button "新建终端 ⌃ `"': "⌃ `"
|
||||
- region "消息输入":
|
||||
- heading "消息输入" [level=3]
|
||||
- list:
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/** Sidebar commands through the shipped Loader, real sidebar/file/terminal owners, and Web keybinding editor. */
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspace } from './support.ts'
|
||||
|
||||
const expected = fileURLToPath(new URL('./expected/shortcuts-panels', import.meta.url))
|
||||
const mode = webSnapshotMode()
|
||||
|
||||
/** Reassign a spare Web combination through the actual editor, leaving browser-reserved defaults untouched. */
|
||||
async function bind(page: Page, primary: string, label: string, previous?: string): Promise<void> {
|
||||
await page.keyboard.press(`${primary}+/`)
|
||||
const dialog = page.getByRole('dialog', { name: 'Keyboard shortcuts', exact: true })
|
||||
await dialog.waitFor()
|
||||
if (label === 'Split' || label === 'Toggle panel fullscreen') {
|
||||
const row = dialog.getByRole('listitem').filter({ has: page.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }) })
|
||||
expect(await row.getByText(label, { exact: true }).count()).toBe(1)
|
||||
expect(await row.getByText('Focus a right sidebar pane first', { exact: true }).count()).toBe(0)
|
||||
expect(await row.getByText('Unavailable', { exact: true }).count()).toBe(0)
|
||||
}
|
||||
if (previous !== undefined) {
|
||||
const remove = dialog.getByRole('button', { name: `Remove shortcut for ${previous}`, exact: true })
|
||||
await remove.click()
|
||||
await remove.waitFor({ state: 'hidden' })
|
||||
}
|
||||
await dialog.getByRole('button', { name: `Edit shortcut for ${label}`, exact: true }).click()
|
||||
const recorder = dialog.getByRole('group', { name: label, exact: true })
|
||||
await recorder.waitFor()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await recorder.waitFor({ state: 'hidden' })
|
||||
await page.keyboard.press('Escape')
|
||||
await dialog.waitFor({ state: 'detached' })
|
||||
}
|
||||
|
||||
// The fixture owns a POSIX shell; Windows has a separate physical-input acceptance run.
|
||||
describe.skipIf(process.platform === 'win32')('Web sidebar shortcuts', () => {
|
||||
let browser: Browser
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch()
|
||||
}, 120_000)
|
||||
afterAll(async () => { await browser?.close() })
|
||||
|
||||
it.each([
|
||||
{ platform: 'MacIntel', primary: 'Meta', aria: 'Shift+Meta+,', file: 'macos' },
|
||||
{ platform: 'Win32', primary: 'Control', aria: 'Control+Shift+,', file: 'windows-labels' },
|
||||
].flatMap(platform => (['left', 'right'] as const).map(closeFirst => ({ ...platform, closeFirst }))))('targets panes and closes $closeFirst first with $file bindings', async ({ platform, primary, aria, file, closeFirst }) => {
|
||||
const scaffold: WebScaffold = await launchWebScaffold({ extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)) })
|
||||
const context = await browser.newContext({ locale: 'en-US', timezoneId: 'Asia/Shanghai', viewport: { width: 1680, height: 1000 } })
|
||||
try {
|
||||
// Device-label/DOM branch evidence only; physical Windows input has its own acceptance checklist.
|
||||
await context.addInitScript((value) => { Object.defineProperty(navigator, 'platform', { value }) }, platform)
|
||||
const page = await context.newPage()
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
const before = new Set(scaffold.ctx.agents.list().map(agent => agent.id))
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const agent = scaffold.ctx.agents.list().find(agent => !before.has(agent.id))
|
||||
if (agent === undefined) throw new Error('The workspace did not create a Session')
|
||||
// A settled fixture activates the ordinary Session UI; this scenario issues no model requests.
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'Prepare panel actions.' }], source: { kind: 'user' } }), { surfaceOp: 'append' })
|
||||
agent.session.append('step/start', { turn: 1, step: 1 })
|
||||
agent.session.append('assistant/message', { turn: 1, step: 1, stream: [], message: createMessage({ role: 'assistant', content: [{ type: 'text', text: 'Panel actions are ready.' }], source: { kind: 'model', provider: 'fixture', model: 'fixture' } }) }, { surfaceOp: 'append' })
|
||||
agent.session.append('step/end', { turn: 1, step: 1 })
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await scaffold.ctx.sessions.flush(agent.session)
|
||||
await page.getByText('Panel actions are ready.', { exact: true }).waitFor()
|
||||
const composer = page.locator('[data-composer-input]')
|
||||
await composer.click()
|
||||
await page.keyboard.insertText('T5 draft remains unsent')
|
||||
await page.keyboard.press(`${primary}+Alt+Enter`)
|
||||
expect(await composer.innerText()).toContain('T5 draft remains unsent')
|
||||
const eventCount = agent.session.seq
|
||||
|
||||
await bind(page, primary, 'Toggle right sidebar')
|
||||
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()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await panel.waitFor()
|
||||
|
||||
const terminalGuide = panel.locator('[data-sidebar-right-guide-entry="terminal"]').getByRole('button').first()
|
||||
await terminalGuide.hover()
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
await page.mouse.move(10, 10)
|
||||
await terminalGuide.focus()
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
|
||||
await bind(page, primary, 'Browser', 'Toggle right sidebar')
|
||||
const browserGuide = panel.locator('[data-sidebar-right-guide-entry="browser"]')
|
||||
await browserGuide.hover()
|
||||
await browserGuide.focus()
|
||||
expect(await browserGuide.getAttribute('aria-keyshortcuts')).toBe(aria)
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
await composer.click()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await panel.getByPlaceholder('Enter an HTTP(S) address').waitFor()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.getByRole('tab', { name: /Browser/ }).count()).toBe(2)
|
||||
await expect.poll(() => panel.getByRole('button', { name: 'Disable sandbox restrictions', exact: true }).isEnabled()).toBe(true)
|
||||
await compareOrRefreshGolden(join(expected, `${file}-browser.expected.md`),
|
||||
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
|
||||
await panel.locator('[data-dockkit-tab-close]').last().click()
|
||||
await panel.locator('[data-dockkit-tab-close]').click()
|
||||
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
|
||||
await bind(page, primary, 'Workspace files', 'Browser')
|
||||
await composer.click()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).waitFor()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1)
|
||||
|
||||
await bind(page, primary, 'Toggle panel fullscreen', 'Workspace files')
|
||||
await composer.click()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await page.locator('[data-sidebar-right-panel="fullscreen"]').count()).toBe(0)
|
||||
const filesTab = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
||||
await filesTab.focus()
|
||||
await expect.poll(() => filesTab.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await page.locator('[data-sidebar-right-panel="fullscreen"]').waitFor()
|
||||
expect(await composer.innerText()).toContain('T5 draft remains unsent')
|
||||
expect(agent.session.seq).toBe(eventCount)
|
||||
|
||||
await bind(page, primary, 'Split', 'Toggle panel fullscreen')
|
||||
await panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2)
|
||||
const panes = panel.locator('[data-dockkit-pane]')
|
||||
const left = panes.nth(0)
|
||||
const right = panes.nth(1)
|
||||
const disabled = right.locator('[data-dockkit-split-button]')
|
||||
expect(await disabled.isDisabled()).toBe(true)
|
||||
await disabled.locator('..').focus()
|
||||
await page.getByRole('tooltip', { name: 'Two panes is the limit', exact: true }).waitFor()
|
||||
|
||||
await bind(page, primary, 'Workspace files', 'Split')
|
||||
await right.getByRole('tab').focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(2)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).count()).toBe(1)
|
||||
|
||||
await bind(page, primary, 'New terminal', 'Workspace files')
|
||||
await right.locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await right.locator('[data-sidebar-terminal]').waitFor()
|
||||
await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-')
|
||||
await right.locator('.xterm-helper-textarea').focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => right.getByRole('tab').count()).toBe(3)
|
||||
await expect.poll(() => right.locator('.xterm-rows').innerText()).toContain('bash-')
|
||||
expect(await left.getByRole('tab').count()).toBe(1)
|
||||
await compareOrRefreshGolden(join(expected, `${file}.expected.md`),
|
||||
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
|
||||
await bind(page, primary, 'Refresh current page', 'New terminal')
|
||||
await right.locator('.xterm-helper-textarea').focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await right.getByRole('tab').count()).toBe(3)
|
||||
await left.getByRole('tab', { name: /Files/ }).click()
|
||||
await writeFile(join(scaffold.workspaceCwd, 'workspace', 't6-refresh.txt'), 'page refresh evidence\n')
|
||||
await left.getByRole('tab', { name: /Files/ }).focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await left.getByText('t6-refresh.txt', { exact: true }).waitFor()
|
||||
expect(await left.getByRole('button', { name: 'Reload', exact: true }).getAttribute('aria-keyshortcuts')).toBe(aria)
|
||||
await bind(page, primary, 'Close current page or window', 'Refresh current page')
|
||||
await right.getByRole('tab').last().focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => right.getByRole('tab').count()).toBe(2)
|
||||
expect(await left.getByRole('tab').count()).toBe(1)
|
||||
await panel.getByRole('button', { name: 'Exit fullscreen', exact: true }).click()
|
||||
await composer.click()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
expect(await right.getByRole('tab').count()).toBe(2)
|
||||
await compareOrRefreshGolden(join(expected, `${file}-page-actions.expected.md`),
|
||||
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
|
||||
const menuTab = left.getByRole('tab', { name: /Files/ })
|
||||
await menuTab.locator('[data-dockkit-tab-close]').focus()
|
||||
await page.getByRole('tooltip').filter({ hasText: 'Close' }).waitFor()
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await menuTab.click({ button: 'right' })
|
||||
await page.getByRole('menu').waitFor()
|
||||
expect(await page.getByRole('tooltip').count()).toBe(0)
|
||||
expect(await page.getByRole('menuitem').getAttribute('aria-keyshortcuts')).toBeNull()
|
||||
if (attempt === 0) {
|
||||
await compareOrRefreshGolden(join(expected, `${file}-tab-menu.expected.md`),
|
||||
await captureStableAria(page, '[data-dockkit-tab-menu]', scaffold.workspaceCwd), mode)
|
||||
} else {
|
||||
await page.getByRole('menuitem').focus()
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
await page.getByRole('menu').waitFor({ state: 'detached' })
|
||||
expect(await left.getByRole('tab').count()).toBe(1)
|
||||
expect(await right.getByRole('tab').count()).toBe(2)
|
||||
}
|
||||
expect(await menuTab.evaluate(tab => tab === document.activeElement)).toBe(true)
|
||||
await (closeFirst === 'right' ? right : left).getByRole('tab').last().focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.getByRole('tab').count()).toBe(2)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.getByRole('tab').count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
|
||||
await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click()
|
||||
await panel.getByRole('button', { name: 'Fullscreen', exact: true }).click()
|
||||
await panel.locator('[data-dockkit-split-button]').click()
|
||||
await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2)
|
||||
await panel.getByRole('tab').nth(closeFirst === 'left' ? 0 : 1).focus()
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => panel.getByRole('tab').count()).toBe(1)
|
||||
expect(await panel.locator('[data-dockkit-pane]').evaluate(pane => pane === document.activeElement)).toBe(true)
|
||||
await compareOrRefreshGolden(join(expected, `${file}-guide-close.expected.md`),
|
||||
await captureStableAria(page, '[data-sidebar-right-panel]', scaffold.workspaceCwd), mode)
|
||||
await page.keyboard.press(`${primary}+Shift+,`)
|
||||
await expect.poll(() => page.locator('[data-sidebar-right-open]').count()).toBe(0)
|
||||
expect(await composer.innerText()).toContain('T5 draft remains unsent')
|
||||
expect(agent.session.seq).toBe(eventCount)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} finally { try { await context.close() } finally { await scaffold.close() } }
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,7 @@
|
||||
"exclude": [
|
||||
"tests/shortcuts-desktop.e2e.ts",
|
||||
"tests/shortcuts-workspace.e2e.ts",
|
||||
"tests/shortcuts-panels.e2e.ts",
|
||||
"tests/shortcuts.e2e.ts",
|
||||
"tests/composer-shortcuts.e2e.ts",
|
||||
"tests/default-product-isolation.e2e.ts",
|
||||
|
||||
@@ -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: 613cdbcb105108eb848160f1cb6bc8ee28d39fac
|
||||
README.zh.md: 77b256f01dd46aea5144e9f8da644fd39330f187
|
||||
README.md: 6520f149094b91e2d84c051da68791169f9f31a9
|
||||
README.zh.md: f62c7d1ae63caede511312b52b4c3b9c2e5d8e0d
|
||||
@@ -39,6 +39,8 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a
|
||||
|
||||
**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
|
||||
|
||||
Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab. Composition and held-key repeats do not dismiss it.
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
## Embedding it
|
||||
|
||||
@@ -63,6 +65,8 @@ 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.
|
||||
|
||||
These are not stylistic; each one fixes a defect found in a real browser.
|
||||
|
||||
- **Capture the pointer** when a gesture starts. Without it any scroll container the pointer crosses can claim the gesture, which the browser reports as a cancelled pointer and an abandoned drag. Capture is hardening — the window listeners carry the gesture either way, so an environment without the API still works.
|
||||
@@ -104,6 +108,8 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
None.
|
||||
|
||||
The embedder supplies effective close keycaps and ARIA combinations for tab and floating close controls. Close controls expose the same tooltip on hover and keyboard focus. Tab close tooltips stay hidden while their pane's context menu is open.
|
||||
|
||||
</details>
|
||||
|
||||
**Runtime invariant:** No companion is published. The engine is pure functions over plain data and the components report intents only; the operation sequence's invertibility and the settle rule are asserted directly by this package's engine specs, and no cordis service is provided or observed.
|
||||
@@ -39,6 +39,8 @@ kind: "package-reference"
|
||||
|
||||
**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
|
||||
|
||||
标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签。输入法组合输入和长按重复事件不关闭菜单。
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
## 如何嵌入
|
||||
|
||||
@@ -63,6 +65,8 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间
|
||||
<a id="interaction-rules-worth-keeping"></a>
|
||||
## 值得保留的交互规则
|
||||
|
||||
分栏控件接收宿主传入的有效快捷键提示和 ARIA 标签。禁用控件由可键盘聚焦的容器承载,说明分栏数量或宽度限制。停靠与浮动面板容器支持程序设置焦点,不进入正常 Tab 顺序。标签导航和选择只使用无修饰键组合,输入法按键交回其所属输入区域。
|
||||
|
||||
这些不是风格偏好;每一条都修复了在真实浏览器里发现的缺陷。
|
||||
|
||||
- **手势开始时捕获指针。** 不捕获的话,指针经过的任何滚动容器都可能接管手势,浏览器会将其报告为指针取消和拖动中止。捕获是加固——无论如何都由 window 监听器承载手势,所以没有该 API 的环境照样可用。
|
||||
@@ -104,6 +108,8 @@ Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间
|
||||
|
||||
无。
|
||||
|
||||
嵌入方为标签和浮动面板的关闭控件提供有效关闭键帽及 ARIA 组合。关闭控件在悬停和键盘聚焦时显示相同 Tooltip。分栏的上下文菜单打开时,该分栏的标签关闭 Tooltip 保持隐藏。
|
||||
|
||||
</details>
|
||||
|
||||
**运行时不变量:** 不发布 companion。引擎是作用于纯数据的纯函数,组件只上报意图;操作序列的可逆性与 settle 规则由本包的引擎 spec 直接断言,不提供也不观察任何 Cordis 服务。
|
||||
@@ -130,8 +130,8 @@ export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canC
|
||||
</button>
|
||||
</Tooltip>
|
||||
{(canCloseTab?.(tab.id) ?? true) && (
|
||||
<Tooltip label={labels.closeFloat} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.iconButton} aria-label={labels.closeFloat}
|
||||
<Tooltip label={labels.closeTabTooltip ?? labels.closeFloat} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.iconButton} aria-label={labels.closeTabTooltip ?? labels.closeFloat}
|
||||
data-dockkit-float-close={paneId} onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={() => { intents.closeTab(tab.id) }}>
|
||||
<IconCloseOutlineRegular />
|
||||
@@ -160,6 +160,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle,
|
||||
key={paneId}
|
||||
className={css.float}
|
||||
data-dockkit-float={paneId}
|
||||
tabIndex={-1}
|
||||
data-dockkit-float-active={state.activePaneId === paneId || undefined}
|
||||
style={{
|
||||
left: live.x,
|
||||
|
||||
@@ -79,7 +79,7 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
|
||||
<div className={clsx(css.tabCell, floating && css.floatingCell)} hidden={!selected}
|
||||
data-dockkit-host={floating ? 'float' : 'dock'} data-dockkit-column={floating ? undefined : column}
|
||||
style={{ gridColumn: floating ? 1 : column * 2 + 1, gridRow: 1, order: floating ? depth : 0 }}>
|
||||
<section ref={host} className={clsx(css.tabHost, floating ? css.float : css.pane)}
|
||||
<section ref={host} tabIndex={-1} className={clsx(css.tabHost, floating ? css.float : css.pane)}
|
||||
aria-hidden={!visible || undefined}
|
||||
data-dockkit-content={tab.id}
|
||||
data-dockkit-pane={!floating && selected ? pane.id : undefined}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* released clear of the surface. A menu that would hold no item at all renders
|
||||
* no popup, so a secondary press on a chip with nothing to offer shows nothing.
|
||||
* Presentational — it renders what its props supply and dismisses itself on
|
||||
* outside presses.
|
||||
* outside presses or an unmodified Escape, returning menu focus to its tab.
|
||||
*
|
||||
* It renders in a portal, positioned against the control that opened it. The tab
|
||||
* strip clips its overflow on purpose (so it never becomes a scroll container
|
||||
@@ -17,6 +17,7 @@
|
||||
import { Children, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { observeComposition } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { DockLabels } from '../contract/adapter.ts'
|
||||
import css from './dockkit.module.css'
|
||||
|
||||
@@ -64,6 +65,20 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP
|
||||
const menu = self.current
|
||||
/* v8 ignore next -- the ref is attached by effect time: the menu renders unconditionally. */
|
||||
if (menu === null) return undefined
|
||||
const document = menu.ownerDocument
|
||||
const composition = observeComposition(document)
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (composition.guards(event) || event.defaultPrevented || event.key !== 'Escape'
|
||||
|| event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) return
|
||||
const top = [...document.querySelectorAll('[role="dialog"][aria-modal="true"], [role="menu"]')].at(-1)
|
||||
if (top !== menu) return
|
||||
event.preventDefault()
|
||||
if (event.repeat) return
|
||||
const restoreFocus = menu.contains(document.activeElement)
|
||||
onDismiss()
|
||||
if (restoreFocus) anchor.focus()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
// A press anywhere but inside the menu dismisses it; one with no element
|
||||
// target (dispatched to the window itself) counts as outside.
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
@@ -73,8 +88,12 @@ export function TabMenu({ labels, anchor, onClose, onDismiss, extras }: TabMenuP
|
||||
// Capture phase: a press on a tab chip starts a drag on its own handler,
|
||||
// so the menu must be gone before that handler runs.
|
||||
window.addEventListener('pointerdown', onPointerDown, true)
|
||||
return () => { window.removeEventListener('pointerdown', onPointerDown, true) }
|
||||
}, [onDismiss, hasItems])
|
||||
return () => {
|
||||
composition.dispose()
|
||||
document.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('pointerdown', onPointerDown, true)
|
||||
}
|
||||
}, [anchor, onDismiss, hasItems])
|
||||
|
||||
if (!hasItems) return null
|
||||
return createPortal(
|
||||
|
||||
@@ -288,6 +288,7 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
onKeyDown={(event) => {
|
||||
// Keys on the chip's nested close control are that control's.
|
||||
if (event.target !== event.currentTarget) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || event.nativeEvent.isComposing) return
|
||||
const next = chipToFocus(event.key, pane.tabs, tabId)
|
||||
if (next !== undefined) {
|
||||
event.preventDefault()
|
||||
@@ -307,21 +308,24 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
>
|
||||
<TabTitle>{callbacks.renderTabTitle?.(tab) ?? tab.title}</TabTitle>
|
||||
{closable && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.tabClose}
|
||||
aria-label={callbacks.labels.closeTab}
|
||||
data-dockkit-tab-close={tabId}
|
||||
// A nested control stops its own press: otherwise the press
|
||||
// starts a drag, captures the pointer, and this click never lands.
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onCloseTab(tabId)
|
||||
}}
|
||||
>
|
||||
<IconCloseFillRegular size={14} />
|
||||
</button>
|
||||
<Tooltip disabled={menu !== undefined} label={callbacks.labels.closeTabTooltip ?? callbacks.labels.closeTab} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tabClose}
|
||||
aria-label={callbacks.labels.closeTab}
|
||||
aria-keyshortcuts={callbacks.labels.closeTabShortcut}
|
||||
data-dockkit-tab-close={tabId}
|
||||
// A nested control stops its own press: otherwise the press
|
||||
// starts a drag, captures the pointer, and this click never lands.
|
||||
onPointerDown={(event) => { event.stopPropagation() }}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onCloseTab(tabId)
|
||||
}}
|
||||
>
|
||||
<IconCloseFillRegular size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{menu?.tabId === tabId && (
|
||||
<TabMenu
|
||||
@@ -356,24 +360,27 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
)}
|
||||
<div className={css.stripFill} data-dockkit-strip-fill />
|
||||
{!(callbacks.hideSplitWhenBlocked && block !== undefined) && (
|
||||
<Tooltip label={callbacks.labels.splitPane} side="bottom" delayMs={500} disabled={block !== undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={callbacks.labels.splitPane}
|
||||
// Disabled buttons fire no hover events, so the blocked reason
|
||||
// stays a native title.
|
||||
title={block === undefined ? undefined : splitBlockedTitle(callbacks.labels, block)}
|
||||
disabled={block !== undefined}
|
||||
data-dockkit-split-button={pane.id}
|
||||
data-dockkit-split-blocked={block}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onSplitPane(pane.id)
|
||||
}}
|
||||
<Tooltip label={block === undefined ? callbacks.labels.splitPaneTooltip ?? callbacks.labels.splitPane : splitBlockedTitle(callbacks.labels, block)} side="bottom" delayMs={500}>
|
||||
<span
|
||||
tabIndex={block === undefined ? undefined : 0}
|
||||
aria-label={block === undefined ? undefined : splitBlockedTitle(callbacks.labels, block)}
|
||||
>
|
||||
<SplitGlyph />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={callbacks.labels.splitPane}
|
||||
aria-keyshortcuts={callbacks.labels.splitPaneShortcut}
|
||||
disabled={block !== undefined}
|
||||
data-dockkit-split-button={pane.id}
|
||||
data-dockkit-split-blocked={block}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
callbacks.onSplitPane(pane.id)
|
||||
}}
|
||||
>
|
||||
<SplitGlyph />
|
||||
</button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* The embedder's surface-wide controls, in the top-right pane only: the
|
||||
@@ -416,6 +423,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
<section
|
||||
className={css.pane}
|
||||
data-dockkit-pane={pane.id}
|
||||
tabIndex={-1}
|
||||
data-dockkit-pane-active={state.activePaneId === pane.id || undefined}
|
||||
onClick={() => {
|
||||
if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id)
|
||||
|
||||
@@ -19,12 +19,20 @@ export interface DockLabels {
|
||||
readonly emptyPane: string
|
||||
/** The split control, while splitting is allowed. */
|
||||
readonly splitPane: string
|
||||
/** Effective localized action and shortcut tooltip, when supplied. */
|
||||
readonly splitPaneTooltip?: string
|
||||
/** ARIA combination from the effective binding, when bound. */
|
||||
readonly splitPaneShortcut?: string | undefined
|
||||
/** The split control, once the pane budget is spent. */
|
||||
readonly splitPaneDisabled: string
|
||||
/** The split control, while the pane is too narrow for two working halves. */
|
||||
readonly splitPaneNarrow: string
|
||||
/** Destroy a tab: the chip's close control and the menu's close item. */
|
||||
readonly closeTab: string
|
||||
/** Effective close label and keycaps supplied by the embedder. */
|
||||
readonly closeTabTooltip?: string
|
||||
/** Effective close combination, when bound. */
|
||||
readonly closeTabShortcut?: string | undefined
|
||||
/** The strip's add control, which seats the embedder's seeded tab. */
|
||||
readonly addTab: string
|
||||
/** Send a floating panel back into the docked tree. */
|
||||
|
||||
@@ -256,7 +256,7 @@ describe('DockSurface', () => {
|
||||
renderSurface(controller, intents, false)
|
||||
const disabled = screen.getByRole('button', { name: TEST_LABELS.splitPane })
|
||||
expect(disabled.hasAttribute('disabled')).toBe(true)
|
||||
expect(disabled.getAttribute('title')).toBe(TEST_LABELS.splitPaneDisabled)
|
||||
expect(disabled.parentElement?.getAttribute('aria-label')).toBe(TEST_LABELS.splitPaneDisabled)
|
||||
expect(disabled.getAttribute('data-dockkit-split-blocked')).toBe('budget')
|
||||
})
|
||||
|
||||
@@ -336,7 +336,7 @@ describe('DockSurface', () => {
|
||||
expect(narrowButton).toBeNull()
|
||||
} else {
|
||||
expect(narrowButton?.hasAttribute('disabled')).toBe(true)
|
||||
expect(narrowButton?.getAttribute('title')).toBe(TEST_LABELS.splitPaneNarrow)
|
||||
expect(narrowButton?.parentElement?.getAttribute('aria-label')).toBe(TEST_LABELS.splitPaneNarrow)
|
||||
expect(narrowButton?.getAttribute('data-dockkit-split-blocked')).toBe('width')
|
||||
}
|
||||
})
|
||||
@@ -815,6 +815,64 @@ describe('DockSurface', () => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['tab', 'item'])('Escape dismisses a reopened menu from the %s without closing its tab', (focus) => {
|
||||
const intents = spyIntents()
|
||||
const { unmount } = renderSurface(seededController(), intents)
|
||||
const tab = screen.getByRole('tab')
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
fireEvent.contextMenu(tab)
|
||||
const target = focus === 'tab' ? tab : screen.getByRole('menuitem')
|
||||
target.focus()
|
||||
expect(fireEvent.keyDown(target, { key: 'Escape', code: 'Escape' })).toBe(false)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(document.activeElement).toBe(tab)
|
||||
expect(intents.closeTab).not.toHaveBeenCalled()
|
||||
}
|
||||
unmount()
|
||||
expect(fireEvent.keyDown(document.body, { key: 'Escape', code: 'Escape' })).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the menu during composition, modified Escape and key repeat', () => {
|
||||
renderSurface(seededController(), spyIntents())
|
||||
const tab = screen.getByRole('tab')
|
||||
fireEvent.contextMenu(tab)
|
||||
for (const modifier of ['ctrlKey', 'altKey', 'metaKey', 'shiftKey', 'isComposing']) {
|
||||
expect(fireEvent.keyDown(tab, { key: 'Escape', [modifier]: true })).toBe(true)
|
||||
expect(screen.getByRole('menu')).toBeDefined()
|
||||
}
|
||||
fireEvent.compositionStart(tab)
|
||||
fireEvent.keyDown(tab, { key: 'Escape' })
|
||||
expect(screen.getByRole('menu')).toBeDefined()
|
||||
fireEvent.compositionEnd(tab)
|
||||
fireEvent.keyDown(tab, { key: 'Escape' })
|
||||
expect(screen.getByRole('menu')).toBeDefined()
|
||||
fireEvent.keyUp(tab, { key: 'Escape' })
|
||||
expect(fireEvent.keyDown(tab, { key: 'Escape', repeat: true })).toBe(false)
|
||||
expect(screen.getByRole('menu')).toBeDefined()
|
||||
fireEvent.keyDown(tab, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('yields Escape to a foreground modal and consumes it before a parent handler', () => {
|
||||
renderSurface(seededController(), spyIntents())
|
||||
const tab = screen.getByRole('tab')
|
||||
fireEvent.contextMenu(tab)
|
||||
const modal = document.createElement('div')
|
||||
modal.setAttribute('role', 'dialog')
|
||||
modal.setAttribute('aria-modal', 'true')
|
||||
document.body.append(modal)
|
||||
onTestFinished(() => { modal.remove() })
|
||||
expect(fireEvent.keyDown(modal, { key: 'Escape' })).toBe(true)
|
||||
expect(screen.getByRole('menu')).toBeDefined()
|
||||
modal.remove()
|
||||
const parent = vi.fn((event: KeyboardEvent) => { expect(event.defaultPrevented).toBe(true) })
|
||||
document.addEventListener('keydown', parent)
|
||||
onTestFinished(() => { document.removeEventListener('keydown', parent) })
|
||||
fireEvent.keyDown(tab, { key: 'Escape' })
|
||||
expect(parent).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('hangs the menu below the chip\'s left edge, or from its right edge when it would run off the viewport', () => {
|
||||
const controller = seededController()
|
||||
let left = 10
|
||||
@@ -1118,6 +1176,18 @@ describe('keyboard tabs', () => {
|
||||
expect(intents.focusTab).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('leaves modified and composing keys available to their owners', () => {
|
||||
const { intents, chip } = threeChips()
|
||||
const tab = chip('a.txt')
|
||||
tab.focus()
|
||||
for (const modifiers of [{ ctrlKey: true, altKey: true }, { metaKey: true, altKey: true }, { shiftKey: true }, { isComposing: true }]) {
|
||||
expect(fireEvent.keyDown(tab, { key: 'Enter', ...modifiers })).toBe(true)
|
||||
expect(fireEvent.keyDown(tab, { key: 'ArrowRight', ...modifiers })).toBe(true)
|
||||
expect(document.activeElement).toBe(tab)
|
||||
}
|
||||
expect(intents.focusTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records nothing for Enter on the active pane\'s selected chip, and leaves keys on the close control alone', () => {
|
||||
const { intents, chip } = threeChips()
|
||||
fireEvent.keyDown(chip('b.txt'), { key: 'Enter' })
|
||||
@@ -1483,7 +1553,7 @@ describe('surface chrome', () => {
|
||||
const role = (child: Element): string => {
|
||||
if (child.hasAttribute('data-dockkit-strip-tabs')) return 'tabs'
|
||||
if (child.hasAttribute('data-dockkit-add-tab')) return 'add'
|
||||
if (child.hasAttribute('data-dockkit-split-button')) return 'split'
|
||||
if (child.hasAttribute('data-dockkit-split-button') || child.querySelector('[data-dockkit-split-button]')) return 'split'
|
||||
if (child.hasAttribute('data-dockkit-strip-chrome')) return 'chrome'
|
||||
return 'fill'
|
||||
}
|
||||
|
||||
@@ -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-browser/README.md
|
||||
README.md: 52ebeddd088a8427b1bf058442b6fab6a768de37
|
||||
README.zh.md: 93df01dbe13bfa4a5aa318a1fd3a9e9786cd3c67
|
||||
README.md: 33ef2d8e4dd82a67f826b4b446604ac88d3458ea
|
||||
README.zh.md: 1d822796ed33cc6ac27c6d75e27a7d44039c7711
|
||||
@@ -42,6 +42,8 @@ The package has no plugin configuration. A Web profile enables the shipped entry
|
||||
|
||||
Client plugins can open a tab through `ctx.sidebarRight.openTab('browser', { params: { url } })`. The optional URL passes the same validation as address-bar input before navigation.
|
||||
|
||||
The `browser.new` command opens a separate Browser page in the focused dock pane, replacing a guide and retaining existing content pages. From the conversation or a floating content page, it uses the active dock pane. Desktop defaults to Cmd+T on macOS and Ctrl+T on Windows; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves the command unbound. The guide button displays the effective shortcut inline without a duplicate tooltip.
|
||||
|
||||
The toolbar provides Back, Forward, Reload, Go, and Open in system browser. Web also offers a per-tab sandbox toggle; disabling it is temporary and displays a warning. Desktop shows the observed page title. After a restart, Browser shows the saved title and URL; Restore or Reload opens that address only when requested.
|
||||
|
||||
-----
|
||||
@@ -68,6 +70,8 @@ Each tab's `BrowserController` owns address validation, commands and explicit re
|
||||
|
||||
Desktop's main process approves guest leases and enforces attachment, navigation and permission policy. Preload exposes only scoped Browser operations. Shared declarations use the standard `/types` export with `import type`; the Host and Client compile through separate tsconfig files. Desktop Browser tabs declare `keepMounted`, so Sidebar preserves their DOM across tab changes, Session switches, collapse and floating.
|
||||
|
||||
The page refresh shortcut calls the same reload operation as the toolbar. Its tooltip and ARIA key combination follow the effective binding. Desktop can route that shortcut from the embedded page; Web leaves browser-reserved combinations unchanged.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
@@ -42,6 +42,8 @@ Browser 在 Web profile 中默认禁用,在 Desktop 中默认启用。Web 用
|
||||
|
||||
Client 插件可以调用 `ctx.sidebarRight.openTab('browser', { params: { url } })` 打开 tab。可选 URL 会在导航前接受与地址栏输入相同的校验。
|
||||
|
||||
命令 `browser.new` 在焦点停靠分栏打开独立浏览器页,替换开始页并保留已有内容页。从聊天区或浮动内容页触发时,使用活动停靠分栏。桌面默认键在 macOS 上为 Cmd+T,在 Windows 上为 Ctrl+T;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。开始页按钮内显示有效快捷键,不额外弹出重复提示。
|
||||
|
||||
工具栏提供后退、前进、刷新、前往和在系统浏览器中打开。Web 还提供逐 tab sandbox 开关;关闭它是临时选择,并会显示警告。Desktop 显示观察到的页面标题。重启后,Browser 展示保存的标题和 URL;只有点击恢复或刷新才打开该地址。
|
||||
|
||||
-----
|
||||
@@ -68,6 +70,8 @@ Web 记录 toolbar 提交和 typed tab 打开。导航状态机把每个受控 r
|
||||
|
||||
Desktop 主进程批准 guest 租约,并执行挂载、导航和权限策略。preload 只暴露限定范围的 Browser 操作。共享声明通过标准 `/types` 出口配合 `import type` 引入;Host 与 Client 使用独立 tsconfig 编译。Desktop Browser tab 声明 `keepMounted`,Sidebar 因而在切 tab、切 Session、收起与浮动期间保留其 DOM。
|
||||
|
||||
页面刷新快捷键调用工具栏使用的同一重载操作。其 Tooltip 和 ARIA 组合随有效绑定更新。桌面可从内嵌页面路由该快捷键;Web 保留浏览器专用组合。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Static Browser tab type and guide declaration. */
|
||||
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { IconGlobeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
@@ -19,7 +20,7 @@ export function browserDefinition(t: TranslateNS<'sidebarBrowser'>): SidebarRigh
|
||||
priority: 'builtin',
|
||||
title: () => t('type.label'),
|
||||
guide: [{
|
||||
id: 'new', order: 30, title: () => t('guide.title'),
|
||||
id: 'new', commandId: 'browser.new' as ShortcutCommandId, order: 30, title: () => t('guide.title'),
|
||||
description: () => t('guide.description'), icon: IconGlobeOutlineRegular,
|
||||
}],
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Register the HTTP(S) Browser tab type in the right Sidebar. */
|
||||
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -42,6 +43,22 @@ export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs']
|
||||
export function apply(ctx: Context): void {
|
||||
const namespace = 'sidebarBrowser'
|
||||
const t = ctx.locale.bind(namespace)
|
||||
ctx.inject(['shortcuts'], (ctx) => {
|
||||
ctx.effect(() => ctx.shortcuts.register({
|
||||
id: 'browser.new' as ShortcutCommandId, label: () => t('guide.title'), aliases: ['browser', 'new browser tab'],
|
||||
defaults: { desktop: { code: 'KeyT', modifiers: ['primary'] } },
|
||||
regions: ['page', 'editable', 'terminal'], modals: [],
|
||||
availability: {
|
||||
getSnapshot: () => ctx.sidebarRight.commandTarget() === undefined ? t('shortcut.noSession') : null,
|
||||
subscribe: listener => ctx.sidebarRight.interaction.subscribe(listener),
|
||||
},
|
||||
resolve: ({ target: element }) => {
|
||||
const target = ctx.sidebarRight.commandTarget(element)
|
||||
if (target === undefined) return { status: 'blocked', reason: t('shortcut.noSession') }
|
||||
return { status: 'handled', run: () => { ctx.sidebarRight.openTabFromTarget('browser', target) } }
|
||||
},
|
||||
}), 'ui-sidebar-browser: shortcut')
|
||||
})
|
||||
const store = createBrowserStore()
|
||||
const openTabs = ctx.sidebarRight.openTabs
|
||||
const carrier = (globalThis as typeof globalThis & {
|
||||
|
||||
@@ -3,6 +3,7 @@ export const zh = {
|
||||
'type.label': '浏览器',
|
||||
'guide.title': '浏览器',
|
||||
'guide.description': '浏览网页',
|
||||
'shortcut.noSession': '请先打开一个会话',
|
||||
'address.placeholder': '输入 HTTP(S) 地址',
|
||||
'address.changed': 'URL 已变化',
|
||||
back: '后退',
|
||||
@@ -35,6 +36,7 @@ export const en = {
|
||||
'type.label': 'Browser',
|
||||
'guide.title': 'Browser',
|
||||
'guide.description': 'Browse web pages',
|
||||
'shortcut.noSession': 'Open a session first',
|
||||
'address.placeholder': 'Enter an HTTP(S) address',
|
||||
'address.changed': 'URL changed',
|
||||
back: 'Back',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/** Common browser chrome; a presentation adapter attaches the page inside its content container. */
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { FormEvent, ReactNode } from 'react'
|
||||
import {
|
||||
Button,
|
||||
IconChevronLeftOutlineRegular,
|
||||
IconChevronRightOutlineRegular,
|
||||
IconLinkOutlineRegular,
|
||||
IconRefreshOutlineRegular,
|
||||
IconRefreshOutlineRegular, Tooltip,
|
||||
IconRightUpOutlineRegular,
|
||||
SHIELD_OUTLINE_PATH,
|
||||
ICON_REGULAR_STROKE,
|
||||
@@ -47,6 +47,7 @@ function useBrowserDraft(url: string | undefined, revision: number): readonly [s
|
||||
export function BrowserBody(props: BrowserBodyProps): ReactNode {
|
||||
const { mount, loadUrl, restore, goBack, goForward, reload, setSandbox, useBrowserState, useStore, useTabInfo, t } = props
|
||||
const { tab } = useTabInfo()
|
||||
useEffect(() => tab.actions.bindCommands({ refresh: () => { reload(tab.id) } }), [tab.actions, tab.id, reload])
|
||||
const saved = useStore(state => state.byTab[tab.id])
|
||||
const initial = useRef(saved)
|
||||
const initialUrl = useRef(tab.navigation.params?.url)
|
||||
@@ -80,7 +81,9 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
|
||||
<form className={css.toolbar} onSubmit={submit}>
|
||||
<button type="button" className={css.tool} aria-label={t('back')} title={t('back')} disabled={!frame.canGoBack} onClick={() => { goBack(tab.id) }}><IconChevronLeftOutlineRegular /></button>
|
||||
<button type="button" className={css.tool} aria-label={t('forward')} title={t('forward')} disabled={!frame.canGoForward} onClick={() => { goForward(tab.id) }}><IconChevronRightOutlineRegular /></button>
|
||||
<button type="button" className={css.tool} aria-label={t('reload')} title={t('reload')} disabled={target === undefined || mountEpoch === 0} onClick={() => { reload(tab.id) }}><IconRefreshOutlineRegular /></button>
|
||||
<Tooltip label={[t('reload'), ...(tab.refreshShortcut?.keys ?? [])].join(' ')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.tool} aria-label={t('reload')} aria-keyshortcuts={tab.refreshShortcut?.aria} disabled={target === undefined || mountEpoch === 0} onClick={() => { reload(tab.id) }}><IconRefreshOutlineRegular /></button>
|
||||
</Tooltip>
|
||||
<div className={css.addressBox}>
|
||||
<input
|
||||
className={[css.address, unknown ? css.addressUnknown : ''].join(' ')}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Browser type, Slot, locale, and HMR disposal through the real registries. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ShortcutRegistry } from '@deepseek-ai/dsh-client-shortcuts/src/client/registry.ts'
|
||||
import type { ShortcutCommand, ShortcutPlatform } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
|
||||
@@ -30,7 +32,7 @@ interface Recorded {
|
||||
component: unknown
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
async function boot(platform: ShortcutPlatform = 'macos', runtime: 'desktop' | 'web' = 'desktop') {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
const tabs = new SidebarRightTabRegistry(ctx)
|
||||
@@ -51,15 +53,20 @@ async function boot() {
|
||||
return () => { dictionaries.delete(namespace) }
|
||||
}),
|
||||
}
|
||||
ctx.provide('sidebarRightTabs', tabs as never)
|
||||
const openTabs = createSnapshotStore<readonly { sessionId: string; tabId: TabId }[]>([])
|
||||
ctx.provide('sidebarRight', { openTabs } as never)
|
||||
const target = { sessionId: 'session', paneId: 'pane' }
|
||||
const sidebar = { openTabs, commandTarget: vi.fn<() => typeof target | undefined>(() => target),
|
||||
openTabFromTarget: vi.fn(), interaction: { subscribe: vi.fn<(listener: () => void) => () => void>(() => () => {}) } }
|
||||
const registry = new ShortcutRegistry(runtime, platform)
|
||||
ctx.provide('sidebarRight', sidebar as never)
|
||||
ctx.provide('shortcuts', { register: (command: ShortcutCommand) => registry.register(command) } as never)
|
||||
ctx.provide('sidebarRightTabs', tabs as never)
|
||||
ctx.provide('workspaces', { list: createSnapshotStore({ phase: 'ready', items: [] }) } as never)
|
||||
ctx.provide('slots', slots as never)
|
||||
ctx.provide('locale', locale as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { tabs, registered, dictionaries, fiber, openTabs }
|
||||
return { tabs, registered, dictionaries, fiber, openTabs, registry, sidebar, target }
|
||||
}
|
||||
|
||||
describe('ui-sidebar-browser apply', () => {
|
||||
@@ -135,6 +142,33 @@ describe('ui-sidebar-browser apply', () => {
|
||||
expect(typeof browser.mount).toBe('function')
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('opens the resolved Browser target with the %s default and releases the shortcut', async (platform) => {
|
||||
const h = await boot(platform)
|
||||
try {
|
||||
expect(h.registry.catalog.getSnapshot()[0]?.keys).toEqual(platform === 'macos' ? ['⌘', 'T'] : ['Ctrl', '+', 'T'])
|
||||
expect(h.tabs.get(BROWSER_KIND)?.guide?.[0]?.commandId).toBe('browser.new')
|
||||
const gesture = { code: 'KeyT', control: platform === 'windows', meta: platform === 'macos',
|
||||
alt: false, shift: false, repeat: false, composing: false, defaultPrevented: false }
|
||||
const context = { region: 'page' as const, modal: null, target: null }
|
||||
const consume = vi.fn()
|
||||
expect(h.registry.dispatch(gesture, context, consume).status).toBe('handled')
|
||||
expect(h.sidebar.openTabFromTarget).toHaveBeenCalledExactlyOnceWith('browser', h.target)
|
||||
h.registry.dispatch({ ...gesture, repeat: true }, context, consume)
|
||||
expect(h.sidebar.openTabFromTarget).toHaveBeenCalledOnce()
|
||||
h.sidebar.commandTarget.mockReturnValue(undefined)
|
||||
h.sidebar.interaction.subscribe.mock.calls[0]?.[0]()
|
||||
expect(h.registry.dispatch(gesture, context, consume).status).toBe('blocked')
|
||||
expect(h.registry.catalog.getSnapshot()[0]?.unavailableReason).toBe('shortcut.noSession')
|
||||
} finally { await h.fiber.dispose() }
|
||||
expect(h.registry.catalog.getSnapshot()).toEqual([])
|
||||
})
|
||||
|
||||
it('publishes the macOS Web Browser default', async () => {
|
||||
const h = await boot('macos', 'web')
|
||||
try { expect(h.registry.catalog.getSnapshot()[0]?.binding).toEqual({ code: 'KeyT', modifiers: ['alt', 'meta'] }) }
|
||||
finally { await h.fiber.dispose() }
|
||||
})
|
||||
|
||||
it('removes every registration when the plugin is disposed', async () => {
|
||||
const { tabs, registered, dictionaries, fiber } = await boot()
|
||||
await fiber.dispose()
|
||||
|
||||
@@ -46,7 +46,7 @@ function mountBrowser(navigation?: { readonly url?: string },
|
||||
const injected = createBrowserControllers(store.actions, options.createPage ?? createIframePage, () => true)
|
||||
controllers.push(injected)
|
||||
const { keyedHooks, ...commands } = injected
|
||||
const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn() }
|
||||
const tabActions = { bindCommands: vi.fn(() => vi.fn()), openResource: vi.fn(), openTab: vi.fn(), close: vi.fn() }
|
||||
const props: Pick<BrowserBodyProps, 'sessionId' | 'useTabInfo' | 'useStore' | 'actions' | 't' | 'useBrowserState'>
|
||||
& Omit<BrowserInjected, 'keyedHooks'> = {
|
||||
sessionId: SESSION,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
{ "path": "../../api/workspace-controller/tsconfig.client.json" },
|
||||
{ "path": "../../util/brand" },
|
||||
{ "path": "../locale/tsconfig.client.json" },
|
||||
{ "path": "../shortcuts/tsconfig.client.json" },
|
||||
{ "path": "../store" },
|
||||
{ "path": "../ui-dockkit" },
|
||||
{ "path": "../ui-primitives" },
|
||||
|
||||
@@ -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-documentpreview/README.md
|
||||
README.md: 510e43fb723039b918672a58ec2bd26cf3b6023a
|
||||
README.zh.md: 5bd9fba7c7f60f7f477afb82b0e88a5dd8b5ac30
|
||||
README.md: cb8c9e889790216cf3ed11f8eda17a00ee80f347
|
||||
README.zh.md: 93477f2c7205736cc5cd08ef300da940c67f7f79
|
||||
@@ -48,6 +48,8 @@ After all text pages load, Markdown images use the authenticated `/api/file` rou
|
||||
|
||||
Developer tools selects the HTML preview policy in both Web and desktop. The renderer receives `interactivePreview` from plugin assembly. Off uses a DOMPurify-sanitized complete static document in an iframe with no sandbox permissions: CSP blocks scripts, external resources, connections, forms, and nested frames; all `href` and `xlink:href` attributes, refresh directives, and declarative shadow roots are removed before reparsing. Inline styles and data images remain visible, and related files are not read. On uses the scripted Blob preview described below. A mode change unmounts the previous frame and aborts its pending related-file reads. Static preview releases CSS/JS resource subscriptions and keeps the root file watched. Other document formats retain their own policies.
|
||||
|
||||
Both HTML modes set the initial iframe name to `dsh-sidebar-html-<tab-id>` for Desktop shortcut routing. This correlation does not grant the preview access to the parent document.
|
||||
|
||||
The body reads its record, navigation and lifetime through `useTabInfo().tab`. `useResource<'file'>(tab.contentId)` supplies metadata; ordinary inject callbacks supply content reads:
|
||||
|
||||
- The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; loaded content remains while metadata is unavailable.
|
||||
@@ -108,6 +110,8 @@ Office registration, loading, caching, and font notices live in `src/client/offi
|
||||
|
||||
The Office Remote returns converted PDFs as native `Uint8Array` values through Connection's multipart binary transport. Renderers borrow retained bytes read-only and copy them before Worker transfer.
|
||||
|
||||
The page refresh shortcut reuses the selected preview’s reload operation, including its request retirement and scroll retention. The header reload tooltip and ARIA combination follow the effective shortcut binding.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="navigation"></a>
|
||||
|
||||
@@ -48,6 +48,8 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
|
||||
|
||||
Web 和桌面端均通过开发者工具选择 HTML 预览策略。渲染器从插件组装层接收 `interactivePreview`。关闭时,将经 DOMPurify 清理的完整静态文档放入不授予沙箱权限的 iframe:CSP 禁止脚本、外部资源、连接、表单和嵌套框架;所有 `href` 和 `xlink:href` 属性、刷新指令及声明式 Shadow DOM 均在重新解析前被移除。行内样式和 data 图片仍可显示,不读取关联文件。开启时使用下述支持脚本的 Blob 预览。切换模式会卸载之前的框架并中止其待处理关联文件读取。静态预览释放 CSS/JS 的 Resource 订阅,根文件继续监听。其他文档格式保持各自策略。
|
||||
|
||||
两种 HTML 模式均将 iframe 初始名称设为 `dsh-sidebar-html-<tab-id>`,供 Desktop 快捷键路由关联目标。这种关联不授予预览访问父文档的权限。
|
||||
|
||||
正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取:
|
||||
|
||||
- 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;元数据不可用时保留已加载内容。
|
||||
@@ -108,6 +110,8 @@ Office 注册、加载、缓存和字体提示位于 `src/client/office/`。注
|
||||
|
||||
Office Remote 通过 Connection 的 multipart 二进制传输返回原生 `Uint8Array` PDF。渲染器以只读方式借用保留的字节,并在传给 Worker 前复制。
|
||||
|
||||
页面刷新快捷键复用所选预览的重载操作,包括旧请求失效与滚动位置保留。头部重载 Tooltip 和 ARIA 组合随有效快捷键绑定更新。
|
||||
|
||||
</details>
|
||||
|
||||
<a id="navigation"></a>
|
||||
|
||||
@@ -172,6 +172,7 @@ export function TextPreview({
|
||||
else if (mode === 'bytes-complete') reloadAll(tab.id, file, signal, observedVersion)
|
||||
else rendererReload()
|
||||
}, [canRead, mode, reloadPages, reloadAll, rendererReload, tab.id, file, signal, observedVersion])
|
||||
useEffect(() => tab.actions.bindCommands({ refresh: reload }), [tab.actions, reload])
|
||||
useEffect(() => {
|
||||
if (state?.autoRefresh && changed && current !== undefined && !current.loading && meta.status === 'live') reload()
|
||||
}, [state?.autoRefresh, changed, current?.loading, meta.status, reload])
|
||||
@@ -307,12 +308,13 @@ export function TextPreview({
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<Tooltip label={t('reload')} side="bottom" delayMs={500}>
|
||||
<Tooltip label={[t('reload'), ...(tab.refreshShortcut?.keys ?? [])].join(' ')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tool}
|
||||
aria-label={t('reload')}
|
||||
data-textpreview-tool="reload"
|
||||
aria-keyshortcuts={tab.refreshShortcut?.aria}
|
||||
onClick={reload}
|
||||
>
|
||||
<IconRefreshOutlineRegular />
|
||||
|
||||
@@ -27,12 +27,13 @@ export interface HtmlBodyInjected {
|
||||
type FrameInput = Pick<HtmlBodyProps, 'resourceAddress' | 'readRelated' | 'addResource' | 'setResources'> & {
|
||||
readonly data: Uint8Array<ArrayBuffer>
|
||||
readonly signal: AbortSignal
|
||||
readonly frameName: string
|
||||
}
|
||||
|
||||
type FrameState = Pick<FrameInput, 'data' | 'readRelated'> & { readonly url: string | undefined }
|
||||
|
||||
/** One mounted file owns its root Blob; replacing content also replaces the browsing context. */
|
||||
function HtmlFrame({ data, resourceAddress, readRelated, addResource, setResources, signal, t }: FrameInput & { t: HtmlBodyProps['t'] }): ReactNode {
|
||||
function HtmlFrame({ data, resourceAddress, readRelated, addResource, setResources, signal, frameName, t }: FrameInput & { t: HtmlBodyProps['t'] }): ReactNode {
|
||||
const [frame, setFrame] = useState<FrameState>()
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
@@ -65,7 +66,7 @@ function HtmlFrame({ data, resourceAddress, readRelated, addResource, setResourc
|
||||
return <LoadingIndicator className={clsx(css.status, css.opening)} label={t('loading')} />
|
||||
}
|
||||
if (frame.url === undefined) return <p className={css.status} role="alert">{t('failed')}</p>
|
||||
return <iframe key={frame.url} className={css.frame} src={frame.url} sandbox="allow-scripts" title={t('frame')} data-html-preview />
|
||||
return <iframe key={frame.url} name={frameName} className={css.frame} src={frame.url} sandbox="allow-scripts" title={t('frame')} data-html-preview />
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,13 +83,14 @@ export function HtmlBody({
|
||||
if (!interactivePreview) setResources([])
|
||||
}, [interactivePreview, setResources])
|
||||
if (content.kind !== 'bytes') return null
|
||||
if (!interactivePreview) return <BasicHtmlFrame data={content.data} t={t} />
|
||||
const frameName = `dsh-sidebar-html-${tab.id}`
|
||||
if (!interactivePreview) return <BasicHtmlFrame data={content.data} frameName={frameName} t={t} />
|
||||
return <HtmlFrame key={resourceAddress} data={content.data} resourceAddress={resourceAddress}
|
||||
readRelated={readRelated} signal={tab.signal} addResource={addResource} setResources={setResources} t={t} />
|
||||
readRelated={readRelated} signal={tab.signal} frameName={frameName} addResource={addResource} setResources={setResources} t={t} />
|
||||
}
|
||||
|
||||
/** Static preview mounts a separate browsing context so a mode change retires running scripts. */
|
||||
function BasicHtmlFrame({ data, t }: Pick<FrameInput, 'data'> & { t: HtmlBodyProps['t'] }): ReactNode {
|
||||
function BasicHtmlFrame({ data, frameName, t }: Pick<FrameInput, 'data' | 'frameName'> & { t: HtmlBodyProps['t'] }): ReactNode {
|
||||
const html = useMemo(() => {
|
||||
try {
|
||||
return createBasicHtmlDocument(data)
|
||||
@@ -98,5 +100,5 @@ function BasicHtmlFrame({ data, t }: Pick<FrameInput, 'data'> & { t: HtmlBodyPro
|
||||
}
|
||||
}, [data])
|
||||
if (html === undefined) return <p className={css.status} role="alert">{t('failed')}</p>
|
||||
return <iframe className={css.frame} srcDoc={html} sandbox="" title={t('frame')} data-html-preview />
|
||||
return <iframe name={frameName} className={css.frame} srcDoc={html} sandbox="" title={t('frame')} data-html-preview />
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type { WorkspaceFilesReadRemote } from '../src/client/rpc.ts'
|
||||
import { PLAIN_BODY_ID } from '../src/client/text/index.ts'
|
||||
|
||||
const SESSION = 'documents' as SessionId
|
||||
const SHORTCUT_CATALOG: readonly never[] = []
|
||||
let runtime: SlotTestRuntime | undefined
|
||||
let animations: PropertyDescriptor | undefined
|
||||
|
||||
@@ -40,6 +41,7 @@ async function boot() {
|
||||
runtime = rt
|
||||
rt.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) } } as never)
|
||||
rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
|
||||
rt.ctx.provide('shortcuts', { register: () => () => {}, catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} } } as never)
|
||||
const locale = new LocaleRuntime(rt.ctx)
|
||||
rt.ctx.provide('locale', locale)
|
||||
rt.slots.installLocale(locale)
|
||||
|
||||
@@ -138,7 +138,7 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
|
||||
const useResource = vi.fn<() => ResourceSnapshot<WorkspaceFileStat>>(() => current.snapshot)
|
||||
const controller = new AbortController()
|
||||
onTestFinished(() => { controller.abort() })
|
||||
const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() }
|
||||
const tabActions = { bindCommands: vi.fn(() => vi.fn()), openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() }
|
||||
const definitions = [textBodyDefinition(() => t('viewer.text'))]
|
||||
// The document seat renders the plain body; the file-handoff seats render a
|
||||
// marker carrying what the owner handed them.
|
||||
|
||||
@@ -50,7 +50,7 @@ function props(text = '<p>hello</p>'): HtmlBodyProps {
|
||||
content: { kind: 'bytes', data: utf8(text) },
|
||||
wrap: false,
|
||||
sessionId: 'html' as SessionId,
|
||||
useTabInfo: () => ({ tab: { signal } }),
|
||||
useTabInfo: () => ({ tab: { id: TAB_ID, signal } }),
|
||||
readRelated: vi.fn(),
|
||||
addResource: vi.fn(),
|
||||
setResources: vi.fn(),
|
||||
@@ -139,6 +139,7 @@ describe('HtmlBody', () => {
|
||||
const basic = { ...initial, useInteractivePreview: ((select: (enabled: boolean) => unknown) => select(false)) as HtmlBodyProps['useInteractivePreview'] }
|
||||
const view = render(<HtmlBody {...basic} />)
|
||||
const frame = screen.getByTitle(en.frame)
|
||||
expect(frame.getAttribute('name')).toBe(`dsh-sidebar-html-${TAB_ID}`)
|
||||
expect(frame.getAttribute('sandbox')).toBe('')
|
||||
expect(frame.getAttribute('srcdoc')).toContain("default-src 'none'")
|
||||
expect(frame.getAttribute('srcdoc')).not.toContain('<script')
|
||||
@@ -148,6 +149,7 @@ describe('HtmlBody', () => {
|
||||
view.rerender(<HtmlBody {...scripted} />)
|
||||
const advanced = await screen.findByTitle(en.frame)
|
||||
expect(advanced).not.toBe(frame)
|
||||
expect(advanced.getAttribute('name')).toBe(`dsh-sidebar-html-${TAB_ID}`)
|
||||
expect(advanced.getAttribute('sandbox')).toBe('allow-scripts')
|
||||
view.rerender(<HtmlBody {...basic} />)
|
||||
expect(advanced.isConnected).toBe(false)
|
||||
|
||||
@@ -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-files/README.md
|
||||
README.md: 0340991f16a61b80cd616982c6a6bea6ff60beac
|
||||
README.zh.md: 016c74d801a3582785cb600f493eaeb92f7567b9
|
||||
README.md: b6a911a294a139cd78480c5af97046a9d9e90955
|
||||
README.zh.md: 69c7e6b21a11a48e3e9ead833047a3fb2b9362b4
|
||||
@@ -24,6 +24,8 @@ Browse a Session's workspace tree and open files in Sidebar previews. The root a
|
||||
<a id="what-it-registers"></a>
|
||||
## What it registers
|
||||
|
||||
The `workspace.files` command opens or focuses the file page in the focused pane; from the conversation it uses the current Session's active dock pane. A guide gives way to the file page, and repeated opens retain one file page per pane. Desktop defaults to Mod+P. The guide entry displays the effective shortcut; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves this command unbound by default.
|
||||
|
||||
- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph the shared folder icon) that opens the type.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The shared [`PathLabel`](../ui-primitives/README.md#component-catalog) displays the root path with subdued directories and a primary final segment. A clipped path retains its trailing characters with a left-edge fade; hovering reveals the full path. The reload control stays at its right.
|
||||
- **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a shared `FileTypeIcon` folder glyph at 16px before the type's label. The tree's own rows never draw this sheet.
|
||||
@@ -70,6 +72,8 @@ None; directory listings travel over the Remote and assemble no model request.
|
||||
|
||||
None.
|
||||
|
||||
The page refresh shortcut refreshes the focused file tree through its ordinary directory reader. The reload control displays the effective binding on hover and keyboard focus.
|
||||
|
||||
</details>
|
||||
|
||||
**Runtime invariant:** No companion is published. The tab-owned face keeps directory reads and watches private, and writes displayed state through its Slot store; tab cancellation releases both. The package exposes no independent observation for a runtime comparison.
|
||||
@@ -24,6 +24,8 @@ kind: "package-reference"
|
||||
<a id="what-it-registers"></a>
|
||||
## 注册了什么
|
||||
|
||||
命令 `workspace.files` 在焦点分栏内打开或聚焦文件页;从聊天区触发时使用当前会话的活动停靠分栏。文件页替换开始页,同一分栏内重复打开仍保留一个文件页。桌面默认键为 Mod+P。开始页入口显示有效快捷键;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。
|
||||
|
||||
- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标是共享的文件夹图标)。
|
||||
- **正文**:以该 id 为键的 `sidebar.right.pane.tab` slot:strip 下的一行标题行,然后是树。共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 显示根路径,目录使用弱化颜色,最后一段使用主色。路径过长时保留尾部字符并在左侧渐隐;悬停显示完整路径。重新读取控件位于右端。
|
||||
- **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` slot:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。
|
||||
@@ -70,6 +72,8 @@ kind: "package-reference"
|
||||
|
||||
无。
|
||||
|
||||
页面刷新快捷键通过常规目录读取器刷新聚焦的文件树。重新读取控件在悬停和键盘聚焦时显示有效绑定。
|
||||
|
||||
</details>
|
||||
|
||||
**运行时不变量:** 不发布 companion。tab 持有的 face 内部管理目录读取与监听,并通过 Slot store 写入显示状态;tab 取消时释放二者。本包未暴露可用于运行时比对的独立观测源。
|
||||
@@ -31,7 +31,8 @@
|
||||
"@deepseek-ai/dsh-api-workspace-files",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
||||
"@deepseek-ai/dsh-client-ui-session",
|
||||
"@deepseek-ai/dsh-api-remotes"
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-shortcuts"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -65,7 +66,8 @@
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"react": "^18.2.0",
|
||||
"clsx": "^2.0.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -14,7 +14,7 @@ import clsx from 'clsx'
|
||||
import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { PropsLocale, PropsRuntime, PropsStore, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import {
|
||||
FileTypeIcon, IconFolderCloseRegular, IconFolderOpenRegular, IconRefreshOutlineRegular, classifyFileType,
|
||||
FileTypeIcon, IconFolderCloseRegular, IconFolderOpenRegular, IconRefreshOutlineRegular, Tooltip, classifyFileType,
|
||||
IconPauseOutlineRegular, IconPlayOutlineRegular, PathLabel,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
@@ -138,6 +138,7 @@ export function FilesBody({
|
||||
useTabInfo, sessionId, useSessions, useStore, actions, start, refresh, setAutoRefresh, toggle, t,
|
||||
}: FilesBodyProps): ReactNode {
|
||||
const { tab } = useTabInfo()
|
||||
useEffect(() => tab.actions.bindCommands({ refresh: () => { refresh(tab.id) } }), [tab.actions, tab.id, refresh])
|
||||
const { signal, actions: tabActions } = tab
|
||||
const cwd = useSessions(sessions => sessions.byId[sessionId]?.cwd)
|
||||
const state = useStore(store => store.byTab[tab.id])
|
||||
@@ -197,16 +198,18 @@ export function FilesBody({
|
||||
{state.autoRefresh ? <IconPauseOutlineRegular /> : <IconPlayOutlineRegular />}
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tool}
|
||||
aria-label={t('reload')}
|
||||
title={t('reload')}
|
||||
data-files-reload
|
||||
onClick={reload}
|
||||
>
|
||||
<IconRefreshOutlineRegular />
|
||||
</button>
|
||||
<Tooltip label={[t('reload'), ...(tab.refreshShortcut?.keys ?? [])].join(' ')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.tool}
|
||||
aria-label={t('reload')}
|
||||
aria-keyshortcuts={tab.refreshShortcut?.aria}
|
||||
data-files-reload
|
||||
onClick={reload}
|
||||
>
|
||||
<IconRefreshOutlineRegular />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* it as an entry box, and the tree opens files through `tabActions.openResource`
|
||||
* for the `dsh-resource://file` viewers to claim.
|
||||
*/
|
||||
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from './locales.ts'
|
||||
@@ -34,6 +35,7 @@ export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTab
|
||||
title: () => t('type.label'),
|
||||
guide: [{
|
||||
id: 'workspace',
|
||||
commandId: 'workspace.files' as ShortcutCommandId,
|
||||
order: 10,
|
||||
title: () => t('guide.title'),
|
||||
description: () => t('guide.description'),
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* it draws (`FilesBody.tsx`, `FilesTitle.tsx`), what it says (`locales.ts`),
|
||||
* and this module, which only wires them together.
|
||||
*/
|
||||
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -34,7 +35,7 @@ const NS = 'sidebarFiles'
|
||||
* Required browser services: the tab registry, the keyed seat, the Remote
|
||||
* carrier and its namespace, and copy.
|
||||
*/
|
||||
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.workspaceFiles']
|
||||
export const inject = ['slots', 'locale', 'sidebarRightTabs', 'sidebarRight', 'remote', 'remote.workspaceFiles']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the type, its dictionaries, its body, and its chip title.
|
||||
@@ -42,6 +43,22 @@ export const inject = ['slots', 'locale', 'sidebarRightTabs', 'remote', 'remote.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const t = ctx.locale.bind(NS)
|
||||
ctx.inject(['shortcuts'], (ctx) => {
|
||||
ctx.effect(() => ctx.shortcuts.register({
|
||||
id: 'workspace.files' as ShortcutCommandId, label: () => t('guide.title'), aliases: ['workspace files', 'files'],
|
||||
defaults: { desktop: { code: 'KeyP', modifiers: ['primary'] } },
|
||||
regions: ['page', 'editable', 'terminal'], modals: [],
|
||||
availability: {
|
||||
getSnapshot: () => ctx.sidebarRight.commandTarget() === undefined ? t('shortcut.noSession') : null,
|
||||
subscribe: listener => ctx.sidebarRight.interaction.subscribe(listener),
|
||||
},
|
||||
resolve: ({ target: element }) => {
|
||||
const target = ctx.sidebarRight.commandTarget(element)
|
||||
if (target === undefined) return { status: 'blocked', reason: t('shortcut.noSession') }
|
||||
return { status: 'handled', run: () => { ctx.sidebarRight.openTabFromTarget('files', target) } }
|
||||
},
|
||||
}), 'ui-sidebar-files: shortcut')
|
||||
})
|
||||
ctx.effect(() => ctx.sidebarRightTabs.register(filesDefinition(t)), 'ui-sidebar-files: files type')
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-files: dictionaries')
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/** Simplified Chinese dictionary and key-set source of truth. */
|
||||
export const zh = {
|
||||
'shortcut.noSession': '请先选择会话',
|
||||
'type.label': '文件',
|
||||
'guide.title': '工作区文件',
|
||||
'guide.description': '浏览会话工作区的文件',
|
||||
@@ -43,6 +44,7 @@ export type SidebarFilesKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked against the Chinese key set. */
|
||||
export const en = {
|
||||
'shortcut.noSession': 'Select a session first',
|
||||
'type.label': 'Files',
|
||||
'guide.title': 'Workspace files',
|
||||
'guide.description': 'Browse files in this session\'s workspace',
|
||||
|
||||
@@ -48,6 +48,7 @@ async function boot() {
|
||||
}),
|
||||
}
|
||||
const workspaceFiles = { list: vi.fn() }
|
||||
ctx.provide('sidebarRight', {} as never)
|
||||
ctx.provide('sidebarRightTabs', tabs as never)
|
||||
ctx.provide('slots', slots as never)
|
||||
ctx.provide('locale', locale as never)
|
||||
|
||||
@@ -40,6 +40,7 @@ type FilesStoreInstance = ReturnType<ReturnType<typeof createFilesStore>['create
|
||||
|
||||
/** The owner's tab actions as recording mocks. */
|
||||
interface MockedTabActions {
|
||||
readonly bindCommands: Mock<SidebarRightTabActions['bindCommands']>
|
||||
readonly openResource: Mock<SidebarRightTabActions['openResource']>
|
||||
readonly openTab: Mock<SidebarRightTabActions['openTab']>
|
||||
readonly close: Mock<SidebarRightTabActions['close']>
|
||||
@@ -68,6 +69,7 @@ function harness(cwd: string | null) {
|
||||
await script.dispose()
|
||||
})
|
||||
const tabActions: MockedTabActions = {
|
||||
bindCommands: vi.fn(() => vi.fn()),
|
||||
openResource: vi.fn<SidebarRightTabActions['openResource']>(),
|
||||
openTab: vi.fn<SidebarRightTabActions['openTab']>(),
|
||||
close: vi.fn<SidebarRightTabActions['close']>(),
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../api/workspace-files/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../shortcuts/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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: 14014cb0986222a73e962e390c2675dd7354df1c
|
||||
README.zh.md: 7686d7afa8b164bf780b4ce6f927d8829e7b3a96
|
||||
README.md: b824b891d79656346bf85b578ba4b94c70196c49
|
||||
README.zh.md: 9af026384167f03c4ca6708153603f5b28e7201e
|
||||
@@ -52,6 +52,8 @@ The panel has no header row. Its two controls — the presentation switch and th
|
||||
<a id="the-expand-button"></a>
|
||||
## The expand button
|
||||
|
||||
The shortcut reference includes right-sidebar toggle, split and panel fullscreen commands. The same effective binding appears on the corresponding controls. Split and fullscreen require focus within a visible dock pane; conversation focus leaves them unavailable. Split remains visible when unavailable; its focusable tooltip explains the pane limit or width requirement. Files, browser, and terminal providers contribute their own commands. Guide buttons display effective keycaps inline without duplicate tooltips.
|
||||
|
||||
While the panel is hidden, one button in the conversation header's corner seat (`conversation.session.header.corner`, past the utilities' right edge and level with the Session log control) is the way back in. Its glyph is the left sidebar's collapse icon mirrored. It shares the panel's store (the slot runtime allows one handle across two same-scope seats); while the panel is shown it renders nothing, and the corner seat collapses with it. A collapsed Sidebar therefore costs the conversation nothing: no rail, no width, and the transcript's scrollbar stays at the column's edge. Without a session there is no button and no panel.
|
||||
|
||||
The panel takes the conversation's ground colour and content font sizes rather than a raised layer of its own: it is a column of the page, not a card over it.
|
||||
@@ -92,12 +94,14 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r
|
||||
|
||||
`preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference.
|
||||
|
||||
`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. `mounted` is the observable of the session whose seat is on screen, `undefined` while a global panel replaces the Conversation or no session is selected. A component that opens content from its own mount effect reads it through a bound hook and acts once it is defined: the frame mounts the Conversation column ahead of this seat, and the seat publishes its binding from a passive effect of the same commit, so an effect that assumed a bound seat would run first and throw. The plan review's automatic open reads it this way.
|
||||
`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. `mounted` is the observable of the session whose seat is on screen, `undefined` while a global panel replaces the Conversation or no session is selected. A component that opens content from its own mount effect reads it through a bound hook and acts once it is defined: the frame mounts the Conversation column ahead of this seat, and the seat publishes its binding from a passive effect of the same commit, so an effect that assumed a bound seat would run first and throw. The plan review's automatic open reads it this way.
|
||||
|
||||
`focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the mounted Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection.
|
||||
|
||||
<a id="the-tab-domain"></a>
|
||||
## The Tab domain
|
||||
|
||||
The Tab domain retains navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store, reconciles restored records immediately, and follows its commits. Reconstructing a Session store replaces its adoption, releasing the old subscription and cleanup reference. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles and excludes background Sessions; foreground floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller.
|
||||
The Tab domain retains a branded occurrence id, navigation, an abort signal, and bound actions per (Session, tab id). A private assembly callback adopts each Session's store, reconciles restored records immediately, and follows its commits. Reconstructing a Session store replaces its adoption, releasing the old subscription and cleanup reference. Only record removal or plugin unload aborts the signal; closing the sidebar and switching Sessions retain records, while undo restores a new occurrence. `useTabInfo()` composes framework-bound store and navigation hooks without manual component subscriptions or render-time record creation. `tab.actions` always target their own Session; `tab.visible` distinguishes bodies from titles and excludes background Sessions; foreground floating tabs remain visible when the sidebar closes. `adopt` is absent from the public controller.
|
||||
|
||||
A type's `keepMounted: true` retains visited bodies across tab and Session changes, collapse, and docking. Each View supplies its stable hold callback through owner props, so rebuilding Session injection bindings does not release a body. Unvisited bodies are not mounted eagerly.
|
||||
|
||||
@@ -106,13 +110,13 @@ Tab owners register `registerCloseHandler(kind, handler)` through an effect. A h
|
||||
<a id="the-guide"></a>
|
||||
## The guide
|
||||
|
||||
Each `guide` entry has an `id` unique within its provider. The guide renders `sidebar.right.tab.guide.entry` by the active definition id, forwarding the entry id, resolved title and optional description, plus the framework `useTabInfo` hook. A provider can register a custom card under its definition id; otherwise the standard icon/title button renders. The sidebar owns width and placement, while custom components own their markup and interaction. Entry identity remains stable across sorting and unrelated registrations.
|
||||
Each `guide` entry has an `id` unique within its provider. The guide renders `sidebar.right.tab.guide.entry` by the active definition id, forwarding the entry id, resolved title and optional description, plus the framework `useTabInfo` hook. A provider can register a custom card under its definition id; otherwise the standard icon/title button renders. The sidebar owns width and placement, while custom components own their markup and interaction. Entry identity remains stable across sorting and unrelated registrations. Shortcut labels align at the right, with space reserved for the terminal shell menu.
|
||||
|
||||
A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness.
|
||||
|
||||
Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly; zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule.
|
||||
|
||||
The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a default capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
|
||||
The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a default capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are disabled with a focusable reason; closing back to one pane enables them.
|
||||
|
||||
<a id="copy"></a>
|
||||
## Copy
|
||||
@@ -148,6 +152,8 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
None.
|
||||
|
||||
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. 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.
|
||||
|
||||
</details>
|
||||
|
||||
**Runtime invariant:** No companion is published. The two services (`sidebarRight`, `sidebarRightTabs`) are provided through `ctx.reflect.provide` inside one effect and torn down with it; the seat's binding and the Tab domain's occurrence lifetimes are asserted directly by this package's specs, and no independent observation exists to diverge from them.
|
||||
@@ -52,6 +52,8 @@ kind: "package-reference"
|
||||
<a id="the-expand-button"></a>
|
||||
## 展开按钮
|
||||
|
||||
快捷键速查包含右侧栏切换、分栏和面板全屏命令,对应控件显示同一份有效键位。分栏与全屏要求焦点位于可见的停靠分栏内;焦点在聊天区时不可用。不可分栏时控件仍可见,可聚焦的提示说明分栏数量上限或宽度要求。文件、浏览器和终端提供者分别注册自己的命令。开始页按钮内显示有效键帽,不额外弹出重复提示。
|
||||
|
||||
面板隐藏时,会话 header 角落席位里的一个按钮(`conversation.session.header.corner`,在工具组右缘之外,与 Session 日志控件齐平)是回去的路。它的图形是左侧 sidebar 折叠图标的镜像。它与面板共用一个存储(slot 运行时允许两个同作用域席位共用一个 handle);面板显示时它什么也不渲染,角落席位随之收起。于是折叠的 Sidebar 不花会话区任何代价:没有轨条、没有宽度,转录的滚动条留在列的边缘。没有会话就没有按钮也没有面板。
|
||||
|
||||
面板取会话区的底色与正文字号,而不是自成一层浮起的表面:它是页面的一列,不是压在页面上的卡片。
|
||||
@@ -92,12 +94,14 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包
|
||||
|
||||
`preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。
|
||||
|
||||
`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;形态切换是面板自己的控件,不属于这个接口。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。`mounted` 是「席位正在屏幕上的那个会话」的可观察值,全局面板替代 Conversation 或没有选中会话时为 `undefined`。要在自己的挂载 effect 里打开内容的组件通过绑定的 hook 读取它,并在它有值后再行动:框架把 Conversation 列挂载在这个席位之前,而席位在同一次提交的 passive effect 里才发布绑定,一个假定席位已绑定的 effect 会先运行并 throw。计划审阅的自动打开就是这样读取它的。
|
||||
`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。`mounted` 是「席位正在屏幕上的那个会话」的可观察值,全局面板替代 Conversation 或没有选中会话时为 `undefined`。要在自己的挂载 effect 里打开内容的组件通过绑定的 hook 读取它,并在它有值后再行动:框架把 Conversation 列挂载在这个席位之前,而席位在同一次提交的 passive effect 里才发布绑定,一个假定席位已绑定的 effect 会先运行并 throw。计划审阅的自动打开就是这样读取它的。
|
||||
|
||||
`focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用已挂载会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。
|
||||
|
||||
<a id="the-tab-domain"></a>
|
||||
## Tab 域
|
||||
|
||||
Tab 域按(Session,Tab id)保留导航、中止信号与绑定动作。私有装配回调采用各 Session 的 store,立即对齐恢复的记录,再跟随其提交。同一 Session 的 store 重建会替换其 `adopt` 注册,同时释放旧订阅与清理引用。记录消失或插件卸载才中止 signal,收起和切会话不销毁记录;undo 恢复的是新 occurrence。`useTabInfo()` 组合框架绑定的 store 与导航 hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,并排除后台 Session;前台浮窗不受整栏收起影响。`adopt` 不在公开控制器上。
|
||||
Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止信号与绑定动作。私有装配回调采用各 Session 的 store,立即对齐恢复的记录,再跟随其提交。同一 Session 的 store 重建会替换其 `adopt` 注册,同时释放旧订阅与清理引用。记录消失或插件卸载才中止 signal,收起和切会话不销毁记录;undo 恢复的是新 occurrence。`useTabInfo()` 组合框架绑定的 store 与导航 hook,不在组件中手写订阅或在渲染时创建记录。`tab.actions` 始终作用于自己的会话;`tab.visible` 区分正文与标题,并排除后台 Session;前台浮窗不受整栏收起影响。`adopt` 不在公开控制器上。
|
||||
|
||||
类型声明 `keepMounted: true` 后,已访问正文会在切 tab、切 Session、收起和停靠切换期间保留。每个 View 通过 owner props 提供自身稳定的持有回调,因此 Session injection binding 重建不会释放正文。未访问的正文不会提前挂载。
|
||||
|
||||
@@ -106,13 +110,13 @@ Tab 域按(Session,Tab id)保留导航、中止信号与绑定动作。私
|
||||
<a id="the-guide"></a>
|
||||
## 引导页
|
||||
|
||||
每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。排序或其他注册变化不会改变入口身份。
|
||||
每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。排序或其他注册变化不会改变入口身份。快捷键标签右对齐,并为终端 Shell 菜单预留空间。
|
||||
|
||||
终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。
|
||||
|
||||
默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面;没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。
|
||||
|
||||
引导 tab 是一枚弱化的罗盘,下方是各已注册类型贡献的每个 `guide` 条目一个入口胶囊,按 `order` 排列并在正文中居中;引导页自己没有文字。胶囊显示条目的图标——条目没注册图标时用引导页自己更浅的立方体占位符——和标题;列出的条目不超过四个时,注册了 `description` 的条目在标题下显示它,更长的列表则去掉所有描述。点击默认胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定在 20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
|
||||
引导 tab 是一枚弱化的罗盘,下方是各已注册类型贡献的每个 `guide` 条目一个入口胶囊,按 `order` 排列并在正文中居中;引导页自己没有文字。胶囊显示条目的图标——条目没注册图标时用引导页自己更浅的立方体占位符——和标题;列出的条目不超过四个时,注册了 `description` 的条目在标题下显示它,更长的列表则去掉所有描述。点击默认胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定在 20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时禁用分栏控件并提供可聚焦的原因提示;关闭回单格后重新启用。
|
||||
|
||||
<a id="copy"></a>
|
||||
## 文案
|
||||
@@ -148,6 +152,8 @@ Tab 域按(Session,Tab id)保留导航、中止信号与绑定动作。私
|
||||
|
||||
无。
|
||||
|
||||
关闭与刷新命令解析聚焦 pane 和 tab occurrence。页面通过 `tab.actions.bindCommands()` 绑定刷新操作,并在卸载时释放;已结束的 occurrence 不能重新获得能力。刷新不会重载应用。关闭会再次检查捕获的身份并执行资源清理。通过快捷键或原生菜单关闭后,焦点移至同一 Session 中仍可见的分栏,让连续关闭继续作用于右栏。过期目标和清理失败均不会回退为关窗。模态限制遵循[快捷键服务](../shortcuts/README.zh.md);Windows 和 macOS Desktop 绑定优先于模态控件。通过快捷键或原生菜单关闭唯一的停靠引导页时,收起右栏并保留引导页。桌面仅在焦点没有所属的右栏页面时关闭窗口;Web 保留浏览器窗口。
|
||||
|
||||
</details>
|
||||
|
||||
**运行时不变量:** 不发布 companion。两个服务(`sidebarRight`、`sidebarRightTabs`)在同一个 effect 内经 `ctx.reflect.provide` 提供并随之拆除;席位绑定与 Tab 域 occurrence 的生命周期由本包的 spec 直接断言,不存在会与之分歧的独立观察。
|
||||
@@ -32,7 +32,8 @@
|
||||
"@deepseek-ai/dsh-client-resources",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-layout",
|
||||
"@deepseek-ai/dsh-client-ui-session"
|
||||
"@deepseek-ai/dsh-client-ui-session",
|
||||
"@deepseek-ai/dsh-client-shortcuts"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -69,7 +70,9 @@
|
||||
"picomatch": "^4.0.4",
|
||||
"clsx": "^2.0.0",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.3",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
* registering into one already depends on it for the declaration. The types
|
||||
* therefore live with their declarer.
|
||||
*/
|
||||
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { RightbarOwnerProps } from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
// The locale plugin's own merge carries the shared `common` vocabulary that the
|
||||
@@ -129,8 +130,20 @@ export interface SidebarRightTabPlacement {
|
||||
readonly replaceTab?: boolean
|
||||
}
|
||||
|
||||
/** Page-owned operations registered for a mounted tab body. */
|
||||
export interface SidebarRightTabCommands {
|
||||
/** Refresh this page through its existing resource owner. */
|
||||
readonly refresh?: () => void
|
||||
}
|
||||
|
||||
/** The actions one tab may take on itself; each acts on the session the tab is in. */
|
||||
export interface SidebarRightTabActions {
|
||||
/**
|
||||
* Bind page operations until the body unmounts or the tab lifetime ends.
|
||||
* @param commands - operations supported by this page; omitted operations are unavailable.
|
||||
* @returns disposer that cannot remove a newer body's registration.
|
||||
*/
|
||||
bindCommands(commands: SidebarRightTabCommands): () => void
|
||||
/**
|
||||
* Open a resource from this tab; see `ISidebarRight.openResource`.
|
||||
* @param address - a `dsh-resource://` address.
|
||||
@@ -156,6 +169,8 @@ export interface SidebarRightTabInfo {
|
||||
}
|
||||
readonly panel: { readonly id: PaneId }
|
||||
readonly tab: TabRecord & {
|
||||
/** Effective refresh binding for this tab’s controls. */
|
||||
readonly refreshShortcut?: ShortcutCatalogEntry | undefined
|
||||
/** Only the foreground Session is visible. Docked bodies also require expansion and selection; its floats survive collapse. */
|
||||
readonly visible: boolean
|
||||
readonly navigation: SidebarRightTabNavigation
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Live DOM ownership for docked and floating sidebar pages. */
|
||||
import type { LayoutState, PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { TabOccurrence } from './tab-domain.ts'
|
||||
|
||||
/** A page captured from the currently mounted Session and its current occurrence. */
|
||||
export interface SidebarRightTarget {
|
||||
readonly sessionId: SessionId
|
||||
readonly paneId: PaneId
|
||||
readonly host: 'dock' | 'float'
|
||||
readonly tabId: TabId | undefined
|
||||
readonly occurrence: TabOccurrence | undefined
|
||||
readonly navigationRevision: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Read pane and tab identity from live owner markup, including an embedding iframe.
|
||||
* @param element - focused or pointer-activated element in the product document.
|
||||
* @param sessionId - Session currently drawn by the sidebar.
|
||||
* @param layout - current committed layout for that Session.
|
||||
* @param occurrence - current occurrence lookup for a committed tab.
|
||||
* @returns the captured page, or undefined for stale, hidden, or outside elements.
|
||||
*/
|
||||
export function sidebarTargetFromElement(
|
||||
element: Element | null,
|
||||
sessionId: SessionId,
|
||||
layout: LayoutState,
|
||||
occurrence: (tabId: TabId) => TabOccurrence,
|
||||
): SidebarRightTarget | undefined {
|
||||
if (element === null || !element.isConnected) return undefined
|
||||
const owner = element.closest<HTMLElement>('[data-sidebar-right-session]')
|
||||
if (owner?.dataset.sidebarRightSession !== sessionId) return undefined
|
||||
const container = element.closest<HTMLElement>('[data-dockkit-pane], [data-dockkit-float]')
|
||||
const paneId = (container?.dataset.dockkitPane ?? container?.dataset.dockkitFloat) as PaneId | undefined
|
||||
if (paneId === undefined) return undefined
|
||||
const pane = layout.nodes[paneId]
|
||||
if (pane?.kind !== 'pane' || (pane.host === 'dock' && !layout.expanded)) return undefined
|
||||
const tabElement = element.closest<HTMLElement>('[data-dockkit-tab], [data-sidebar-right-tab]')
|
||||
const tabId = (tabElement?.dataset.dockkitTab ?? tabElement?.dataset.sidebarRightTab ?? pane.activeTabId) as TabId | undefined
|
||||
if (tabId !== undefined && (!pane.tabs.includes(tabId) || layout.tabs[tabId] === undefined)) return undefined
|
||||
const held = tabId === undefined ? undefined : occurrence(tabId)
|
||||
const marker = element.closest<HTMLElement>('[data-sidebar-right-occurrence]')
|
||||
?? tabElement?.querySelector<HTMLElement>('[data-sidebar-right-occurrence]')
|
||||
if (marker !== null && marker !== undefined && marker.dataset.sidebarRightOccurrence !== held?.id) return undefined
|
||||
return { sessionId, paneId, host: pane.host, tabId, occurrence: held,
|
||||
navigationRevision: held?.navigation.getSnapshot().revision }
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe input ownership without retaining a historical pane selection.
|
||||
* @param document - product document whose sidebar owns the listener lifetime.
|
||||
* @param changed - refresh command availability from live focus and layout.
|
||||
* @returns disposer for every document/window listener.
|
||||
*/
|
||||
export function observeSidebarFocus(document: Document, changed: () => void): () => void {
|
||||
let active = true
|
||||
const pointer = (event: PointerEvent): void => {
|
||||
const element = event.composedPath().find(value => value instanceof Element)
|
||||
if (!(element instanceof Element)) return
|
||||
const pane = element.closest<HTMLElement>('[data-sidebar-right-session] [data-dockkit-pane], [data-sidebar-right-session] [data-dockkit-float]')
|
||||
const control = element.closest('button, input, textarea, select, a, [contenteditable], [tabindex], iframe')
|
||||
if (pane !== null && (control === null || control === pane)) {
|
||||
pane.focus({ preventScroll: true })
|
||||
}
|
||||
changed()
|
||||
}
|
||||
const blur = (): void => { queueMicrotask(() => { if (active) changed() }) }
|
||||
document.addEventListener('focusin', changed)
|
||||
document.addEventListener('focusout', changed)
|
||||
document.addEventListener('pointerdown', pointer, true)
|
||||
document.defaultView?.addEventListener('blur', blur)
|
||||
document.defaultView?.addEventListener('focus', changed)
|
||||
return () => {
|
||||
active = false
|
||||
document.removeEventListener('focusin', changed)
|
||||
document.removeEventListener('focusout', changed)
|
||||
document.removeEventListener('pointerdown', pointer, true)
|
||||
document.defaultView?.removeEventListener('blur', blur)
|
||||
document.defaultView?.removeEventListener('focus', changed)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@
|
||||
* guide registers through those stages unmodified, exactly as a type shipped
|
||||
* from another package does — `ui-sidebar-documentpreview` is the live proof.
|
||||
*/
|
||||
import type {} from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { observeSidebarFocus } from './focus.ts'
|
||||
import { registerSidebarShortcuts } from './shortcuts.ts'
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-client-resources/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
@@ -44,6 +47,7 @@ import { guideTabInfoFactory, tabInfoFactory } from './tab-info.ts'
|
||||
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import { defaultSeed } from './contract/seed.ts'
|
||||
|
||||
export type { SidebarRightTarget } from './focus.ts'
|
||||
export type { RightbarSeatProps, SidebarRightInjected, SidebarRightPresentation } from './shell/SidebarRight.tsx'
|
||||
export type { GuideBodyProps, GuideInjected } from './tabs/guide/GuideBody.tsx'
|
||||
export type { ExpandButtonProps } from './shell/ExpandButton.tsx'
|
||||
@@ -66,7 +70,7 @@ export type {
|
||||
} from './contract/params.ts'
|
||||
// The layout ids and rectangle the navigation face takes, so a caller needs no import from the kit.
|
||||
export type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
export type { PinResource, SidebarRightNavigator, TabOccurrence } from './tab-domain.ts'
|
||||
export type { PinResource, SidebarRightNavigator, TabOccurrence, SidebarRightOccurrenceId } from './tab-domain.ts'
|
||||
export type { SidebarRightKey } from './locales.ts'
|
||||
export type { OpenContentIntent } from './stores.ts'
|
||||
export type { SidebarRightOpenTab } from './tab-inventory.ts'
|
||||
@@ -75,7 +79,7 @@ export type { SidebarRightOpenTab } from './tab-inventory.ts'
|
||||
const NS = 'sidebarRight'
|
||||
|
||||
/** Required browser services: the slot registry, the frame's panel actions, copy, and the resource model. */
|
||||
export const inject = ['slots', 'layout', 'locale', 'resources', 'sessions', 'uiSession']
|
||||
export const inject = ['slots', 'layout', 'locale', 'resources', 'sessions', 'uiSession', 'shortcuts']
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -128,6 +132,10 @@ export function apply(ctx: ClientContext): void {
|
||||
}, 'ui-sidebar-right: service faces')
|
||||
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-sidebar-right: dictionaries')
|
||||
ctx.inject(['shortcuts'], (ctx) => {
|
||||
ctx.effect(() => registerSidebarShortcuts(ctx.shortcuts, controller, t), 'ui-sidebar-right: shortcuts')
|
||||
})
|
||||
if (typeof document !== 'undefined') ctx.effect(() => observeSidebarFocus(document, () => { controller.refreshInteraction() }), 'ui-sidebar-right: focus')
|
||||
|
||||
ctx.effect(() => {
|
||||
const handle = createSidebarRightStore(() => defaultSeed(tabs))
|
||||
@@ -156,8 +164,14 @@ export function apply(ctx: ClientContext): void {
|
||||
else layout.closeRightbar()
|
||||
},
|
||||
bindService: binding => controller.bind(binding),
|
||||
refreshInteraction: () => { controller.refreshInteraction() },
|
||||
splitPane: (paneId) => { controller.split(paneId) },
|
||||
toggleFullscreen: () => { const target = controller.commandTarget(); if (target !== undefined) controller.toggleFullscreen(target) },
|
||||
openTab: (kind, options) => { controller.openTab(kind, options) },
|
||||
hooks: { tabTypes: { subscribe: listener => tabs.subscribe(listener), getSnapshot: () => tabs.entries() } },
|
||||
hooks: {
|
||||
shortcuts: ctx.shortcuts.catalog,
|
||||
tabTypes: { subscribe: listener => tabs.subscribe(listener), getSnapshot: () => tabs.entries() },
|
||||
},
|
||||
}
|
||||
|
||||
const disposeTypes = [tabs.register(guideDefinition(t))]
|
||||
@@ -198,11 +212,15 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'conversation.session.header.corner',
|
||||
locale: NS,
|
||||
store,
|
||||
inject: () => ({ hooks: { shortcuts: ctx.shortcuts.catalog } }),
|
||||
}, ExpandButton))
|
||||
// Stage two for the guide: it declares the chain child it hosts and reads
|
||||
// the registry's entry boxes, which an ordinary type has no reason to do.
|
||||
const guideInjected: GuideInjected = {
|
||||
hooks: { guideEntries: { subscribe: listener => tabs.subscribe(listener), getSnapshot: () => tabs.guide() } },
|
||||
hooks: {
|
||||
shortcuts: ctx.shortcuts.catalog,
|
||||
guideEntries: { subscribe: listener => tabs.subscribe(listener), getSnapshot: () => tabs.guide() },
|
||||
},
|
||||
}
|
||||
const disposeGuide = ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab',
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* handed over from here. This is a projection of the dictionary, not a second
|
||||
* home for copy: the strings live in `locales.ts`.
|
||||
*/
|
||||
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { DockLabels } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
|
||||
@@ -14,15 +15,21 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
* Called during render, so a language change reaches the kit with the next one —
|
||||
* the kit caches no copy to invalidate.
|
||||
* @param t - namespace-bound translate.
|
||||
* @param split - effective split binding, when available.
|
||||
* @param close - effective page-close binding, when available.
|
||||
* @returns every string the kit renders.
|
||||
*/
|
||||
export function dockLabels(t: TranslateNS<'sidebarRight'>): DockLabels {
|
||||
export function dockLabels(t: TranslateNS<'sidebarRight'>, split?: ShortcutCatalogEntry, close?: ShortcutCatalogEntry): DockLabels {
|
||||
return {
|
||||
emptyPane: t('dock.emptyPane'),
|
||||
splitPane: t('dock.splitPane'),
|
||||
splitPaneTooltip: [t('dock.splitPane'), ...(split?.keys ?? [])].join(' '),
|
||||
splitPaneShortcut: split?.aria,
|
||||
splitPaneDisabled: t('dock.splitPaneDisabled'),
|
||||
splitPaneNarrow: t('dock.splitPaneNarrow'),
|
||||
closeTab: t('dock.closeTab'),
|
||||
closeTabTooltip: [t('dock.closeTab'), ...(close?.keys ?? [])].join(' '),
|
||||
closeTabShortcut: close?.aria,
|
||||
addTab: t('dock.addTab'),
|
||||
dockFloat: t('dock.dockFloat'),
|
||||
closeFloat: t('dock.closeFloat'),
|
||||
|
||||
@@ -8,6 +8,19 @@
|
||||
|
||||
/** Simplified Chinese dictionary and key-set source of truth. */
|
||||
export const zh = {
|
||||
'command.close': '关闭当前页面/窗口',
|
||||
'command.refresh': '刷新当前页面',
|
||||
'command.noRefresh': '当前页面不支持刷新',
|
||||
'command.toggle': '展开/收起右侧栏',
|
||||
'command.fullscreen': '面板全屏/退出全屏',
|
||||
'command.noSession': '请先选择会话',
|
||||
'command.noFocus': '请先聚焦右侧面板',
|
||||
'command.stale': '页面已切换,请重新聚焦',
|
||||
'command.collapsed': '请先展开右侧栏',
|
||||
'command.float': '浮动面板不支持此操作',
|
||||
'command.empty': '请先打开页面',
|
||||
'command.budget': '已达两格上限',
|
||||
'command.width': '栏宽不足,拖宽侧边栏后再分栏',
|
||||
'chrome.expand': '打开侧边栏',
|
||||
'chrome.expandAria': '打开右侧边栏',
|
||||
'chrome.collapse': '收起侧边栏',
|
||||
@@ -36,6 +49,19 @@ export type SidebarRightKey = keyof typeof zh
|
||||
|
||||
/** English dictionary, checked against the Chinese key set. */
|
||||
export const en = {
|
||||
'command.close': 'Close current page or window',
|
||||
'command.refresh': 'Refresh current page',
|
||||
'command.noRefresh': 'This page cannot be refreshed',
|
||||
'command.toggle': 'Toggle right sidebar',
|
||||
'command.fullscreen': 'Toggle panel fullscreen',
|
||||
'command.noSession': 'Select a session first',
|
||||
'command.noFocus': 'Focus a right sidebar pane first',
|
||||
'command.stale': 'The page changed; focus it again',
|
||||
'command.collapsed': 'Expand the right sidebar first',
|
||||
'command.float': 'This action is unavailable in a floating panel',
|
||||
'command.empty': 'Open a page first',
|
||||
'command.budget': 'Two panes is the limit',
|
||||
'command.width': 'Not enough width to split, widen the sidebar',
|
||||
'chrome.expand': 'Open sidebar',
|
||||
'chrome.expandAria': 'Open right sidebar',
|
||||
'chrome.collapse': 'Collapse sidebar',
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
* The registration adopts Session stores and injects the mounted seat binding;
|
||||
* callers use the service's navigation methods.
|
||||
*/
|
||||
import { sidebarTargetFromElement, type SidebarRightTarget } from './focus.ts'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
@@ -84,6 +85,7 @@ export function createSidebarRightController(tabs: SidebarRightTabRegistry, pin:
|
||||
const surface = store.getSnapshot().bySession[sessionId]
|
||||
inventory.update(sessionId, Object.values(surface?.layout.tabs ?? {}))
|
||||
if (surface !== undefined) controller.tabDomain.sync(sessionId, surface.layout)
|
||||
controller.refreshInteraction()
|
||||
}
|
||||
const adoption: Adoption = { store, unsubscribe: store.subscribe(sync) }
|
||||
adopted.set(sessionId, adoption)
|
||||
@@ -113,6 +115,10 @@ export interface SidebarRightBinding {
|
||||
* whether two working halves would fit. Unmeasured panes fit.
|
||||
*/
|
||||
readonly canSplitPane: (paneId: PaneId) => boolean
|
||||
/** Commit a keyboard/menu close and retain focus on a surviving visible pane. */
|
||||
readonly closeWithFocus: (paneId: PaneId, close: () => void) => void
|
||||
/** Narrow viewports present an expanded panel fullscreen. */
|
||||
readonly autoFullscreen?: boolean
|
||||
}
|
||||
|
||||
/** Where an open lands; every field is optional and the defaults are the common case. */
|
||||
@@ -233,6 +239,11 @@ export class SidebarRightController implements ISidebarRight {
|
||||
/** The mounted seat's session; see {@link ISidebarRight.mounted}. */
|
||||
readonly mounted: ObservableSnapshot<SessionId | undefined> = this.mountedSession
|
||||
private binding: SidebarRightBinding | undefined
|
||||
/** Invalidates owner-derived command availability after layout or focus changes. */
|
||||
readonly interaction = createSnapshotStore(0)
|
||||
|
||||
/** Notify subscribers that live input ownership or measured room changed. */
|
||||
refreshInteraction(): void { this.interaction.set(this.interaction.getSnapshot() + 1) }
|
||||
private readonly closeHandlers = new Map<string, SidebarRightCloseHandler>()
|
||||
|
||||
/**
|
||||
@@ -288,12 +299,14 @@ export class SidebarRightController implements ISidebarRight {
|
||||
bind(binding: SidebarRightBinding): () => void {
|
||||
this.binding = binding
|
||||
this.publishMounted()
|
||||
this.refreshInteraction()
|
||||
return () => {
|
||||
// A newer seat may already have taken over; only the binding that is
|
||||
// still ours may be cleared.
|
||||
if (this.binding !== binding) return
|
||||
this.binding = undefined
|
||||
this.publishMounted()
|
||||
this.refreshInteraction()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +494,130 @@ export class SidebarRightController implements ISidebarRight {
|
||||
actions.focusTab(sessionId, tabId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the page owning current DOM focus; outside focus never uses layout history.
|
||||
* @param element - explicit input target, including an embedding iframe; defaults to live document focus.
|
||||
* @returns current focused page identity, or undefined outside a visible sidebar page.
|
||||
*/
|
||||
focusedTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined {
|
||||
const binding = this.binding
|
||||
const surface = this.mountedSurface()
|
||||
return binding === undefined || surface === undefined ? undefined
|
||||
: sidebarTargetFromElement(element, binding.sessionId, surface.layout,
|
||||
tabId => this.tabDomain.occurrence(binding.sessionId, { id: tabId }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose a focused sidebar pane, or the mounted Session's active dock pane for an outside open.
|
||||
* @param element - live command input target; stale sidebar markup never falls back to another pane.
|
||||
* @returns captured target, or undefined without a mounted Session.
|
||||
*/
|
||||
commandTarget(element: Element | null = document.activeElement): SidebarRightTarget | undefined {
|
||||
const focused = this.focusedTarget(element)
|
||||
if (focused !== undefined || element?.closest('[data-sidebar-right-session]')) return focused
|
||||
const binding = this.binding
|
||||
const layout = this.mountedSurface()?.layout
|
||||
if (binding === undefined || layout === undefined) return undefined
|
||||
const pane = getPane(layout, activeDockPaneId(layout))
|
||||
const tabId = pane.activeTabId
|
||||
const held = tabId === undefined ? undefined : this.tabDomain.occurrence(binding.sessionId, { id: tabId })
|
||||
return { sessionId: binding.sessionId, paneId: pane.id, host: pane.host, tabId,
|
||||
occurrence: held, navigationRevision: held?.navigation.getSnapshot().revision }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open from a captured pane using the guide's replacement and floating placement rules.
|
||||
* @param kind - registered page kind.
|
||||
* @param target - captured live pane and optional page.
|
||||
*/
|
||||
openTabFromTarget(kind: string, target: SidebarRightTarget): void {
|
||||
if (!this.isTargetCurrent(target)) return
|
||||
const tab = target.tabId === undefined ? undefined : this.mountedSurface()?.layout.tabs[target.tabId]
|
||||
if (target.host === 'float' && tab?.kind === kind && this.tabs.get(kind)?.multiple !== true) {
|
||||
this.require().actions.setExpanded(target.sessionId, true)
|
||||
this.focus(tab.id)
|
||||
return
|
||||
}
|
||||
this.openTab(kind, {
|
||||
...target.host === 'dock' ? { paneId: target.paneId } : {},
|
||||
...tab?.kind === 'guide' ? { replaceTab: tab.id } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Test explicit close eligibility without using the last selected page.
|
||||
* @param target - captured focused page.
|
||||
* @returns whether this current page can be removed or its sole guide pane collapsed.
|
||||
*/
|
||||
canCloseTarget(target: SidebarRightTarget): boolean {
|
||||
return this.isTargetCurrent(target) && target.tabId !== undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a captured page through its resource cleanup handler, or collapse the sole docked guide.
|
||||
* Cleanup errors propagate and preserve the page.
|
||||
* @param target - page captured while resolving the command.
|
||||
* @returns closed after removal or collapse, unavailable without a page, or stale after identity changes.
|
||||
*/
|
||||
closeTarget(target: SidebarRightTarget): 'closed' | 'unavailable' | 'stale' {
|
||||
if (!this.isTargetCurrent(target)) return 'stale'
|
||||
const surface = this.mountedSurface()
|
||||
if (surface === undefined || target.tabId === undefined) return 'unavailable'
|
||||
const tabId = target.tabId
|
||||
const binding = this.require()
|
||||
binding.closeWithFocus(target.paneId, () => {
|
||||
if (canCloseTab(surface, tabId)) this.close(tabId)
|
||||
else binding.actions.setExpanded(target.sessionId, false)
|
||||
})
|
||||
return 'closed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a captured target before acting, including reopened records and intervening navigation.
|
||||
* @param target - identity captured while resolving the input.
|
||||
* @returns whether the mounted Session, pane, tab lifetime and navigation still match.
|
||||
*/
|
||||
isTargetCurrent(target: SidebarRightTarget): boolean {
|
||||
if (this.binding?.sessionId !== target.sessionId) return false
|
||||
const layout = this.mountedSurface()?.layout
|
||||
const pane = layout?.nodes[target.paneId]
|
||||
if (pane?.kind !== 'pane' || pane.host !== target.host) return false
|
||||
if (target.tabId === undefined) return pane.activeTabId === undefined
|
||||
if (!pane.tabs.includes(target.tabId) || layout?.tabs[target.tabId] === undefined) return false
|
||||
const held = this.tabDomain.occurrence(target.sessionId, { id: target.tabId })
|
||||
return held === target.occurrence && !held.signal.aborted
|
||||
&& held.navigation.getSnapshot().revision === target.navigationRevision
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain the same geometry and pane-budget rule used by the split control.
|
||||
* @param target - captured command or button target.
|
||||
* @returns a localizable block discriminator, or undefined when splitting is available.
|
||||
*/
|
||||
splitBlock(target: SidebarRightTarget): 'stale' | 'collapsed' | 'float' | 'empty' | 'budget' | 'width' | undefined {
|
||||
const surface = this.mountedSurface()
|
||||
if (surface === undefined || !this.isTargetCurrent(target)) return 'stale'
|
||||
const { layout } = surface
|
||||
if (target.host === 'float') return 'float'
|
||||
if (!layout.expanded) return 'collapsed'
|
||||
if (getPane(layout, target.paneId).tabs.length === 0) return 'empty'
|
||||
if (!canSplit(layout) || dockPaneIds(layout).length >= 2) return 'budget'
|
||||
if (!this.require().canSplitPane(target.paneId)) return 'width'
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the right panel's display mode through the same action as its chrome button.
|
||||
* @param target - captured dock pane; floating and stale targets are unchanged.
|
||||
*/
|
||||
toggleFullscreen(target: SidebarRightTarget): void {
|
||||
if (!this.isTargetCurrent(target) || target.host === 'float' || !this.isExpanded()) return
|
||||
const { sessionId, actions, autoFullscreen } = this.require()
|
||||
const fullscreen = autoFullscreen === true || this.mountedSurface()?.layout.mode === 'fullscreen'
|
||||
if (fullscreen && autoFullscreen === true) actions.setExpanded(sessionId, false)
|
||||
actions.setMode(sessionId, fullscreen ? 'push' : 'fullscreen')
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a docked pane to its right when the budget and the room rule allow.
|
||||
* @param paneId - the pane to split; defaults to the active docked pane.
|
||||
@@ -547,7 +684,8 @@ export class SidebarRightController implements ISidebarRight {
|
||||
/** The mounted session's surface; `undefined` without a seat or before its first open. */
|
||||
private mountedSurface(): SurfaceState | undefined {
|
||||
const { binding } = this
|
||||
return binding === undefined ? undefined : binding.surfaces[binding.sessionId]
|
||||
return binding === undefined ? undefined
|
||||
: this.adopted.get(binding.sessionId)?.store.getSnapshot().bySession[binding.sessionId] ?? binding.surfaces[binding.sessionId]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
* The glyph is the left sidebar's collapse icon mirrored: the same affordance,
|
||||
* on the other edge.
|
||||
*/
|
||||
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { InjectFace, HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ReactNode } from 'react'
|
||||
import { IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -25,19 +27,22 @@ export type ExpandButtonProps =
|
||||
& PropsRuntime<'conversation.session.header.corner'>
|
||||
& PropsStore<ReturnType<typeof createSidebarRightStore>>
|
||||
& PropsLocale<'sidebarRight'>
|
||||
& InjectFace<{ hooks: { shortcuts: HostObservable<readonly ShortcutCatalogEntry[]> } }>
|
||||
|
||||
/** The expand control while the panel is collapsed; nothing while it is shown. */
|
||||
export function ExpandButton({ sessionId, useStore, actions, t }: ExpandButtonProps): ReactNode {
|
||||
export function ExpandButton({ sessionId, useStore, actions, t, useShortcuts }: ExpandButtonProps): ReactNode {
|
||||
// A session with no surface yet is collapsed: the panel seat materializes the
|
||||
// surface on its own mount, and until then there is nothing expanded.
|
||||
const shortcut = useShortcuts(entries => entries.find(entry => entry.id === 'sidebar.right.toggle'))
|
||||
const expanded = useStore(state => state.bySession[sessionId]?.layout.expanded ?? false)
|
||||
if (expanded) return null
|
||||
return (
|
||||
<Tooltip label={t('chrome.expand')} side="bottom" delayMs={500}>
|
||||
<Tooltip label={[t('chrome.expand'), ...(shortcut?.keys ?? [])].join(' ')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.button}
|
||||
aria-label={t('chrome.expandAria')}
|
||||
aria-keyshortcuts={shortcut?.aria}
|
||||
data-sidebar-right-expand
|
||||
onClick={() => { actions.setExpanded(sessionId, true) }}
|
||||
>
|
||||
|
||||
@@ -171,3 +171,12 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
}
|
||||
|
||||
.tabBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -29,6 +29,7 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { CSSProperties, ReactNode, RefObject } from 'react'
|
||||
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {
|
||||
HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
|
||||
@@ -48,6 +49,7 @@ import type { TabOccurrence } from '../tab-domain.ts'
|
||||
import type { SidebarRightTabNavigation } from '../contract/slots.ts'
|
||||
import type { TabHookContext } from '../tab-info.ts'
|
||||
import css from './SidebarRight.module.css'
|
||||
import { closeWithPaneFocus } from './close-focus.ts'
|
||||
|
||||
/** The store share the seat receives. */
|
||||
type Store = PropsStore<ReturnType<typeof createSidebarRightStore>>
|
||||
@@ -91,6 +93,9 @@ export interface SidebarRightInjected {
|
||||
surfaces: Readonly<Record<string, SurfaceState>>
|
||||
/** The room rule's verdict for a docked pane, as the kit last measured it. */
|
||||
canSplitPane: (paneId: PaneId) => boolean
|
||||
/** Commit a keyboard/menu close and retain focus on a surviving visible pane. */
|
||||
closeWithFocus: (paneId: PaneId, close: () => void) => void
|
||||
autoFullscreen?: boolean
|
||||
}) => () => void
|
||||
/**
|
||||
* The navigation face's `openTab`, for the strip's add control: a new tab is
|
||||
@@ -99,7 +104,14 @@ export interface SidebarRightInjected {
|
||||
readonly openTab: (kind: string, options?: SidebarRightOpenTabOptions) => void
|
||||
/** Close through the resource owner's cleanup handler. */
|
||||
readonly closeTab: (tabId: TabId) => void
|
||||
/** Publish updated room measurements for command availability. */
|
||||
readonly refreshInteraction: () => void
|
||||
/** Split through the same controller as keyboard commands. */
|
||||
readonly splitPane: (paneId: PaneId) => void
|
||||
/** Toggle the dock panel using its current display mode. */
|
||||
readonly toggleFullscreen: () => void
|
||||
readonly hooks: {
|
||||
readonly shortcuts: HostObservable<readonly ShortcutCatalogEntry[]>
|
||||
readonly tabTypes: HostObservable<readonly SidebarRightTabDefinition[]>
|
||||
}
|
||||
readonly keyedHooks: {
|
||||
@@ -126,6 +138,9 @@ interface PanelProps {
|
||||
readonly renderSlot: Children['renderSlot']
|
||||
readonly openTab: SidebarRightInjected['openTab']
|
||||
readonly closeTab: SidebarRightInjected['closeTab']
|
||||
readonly splitPane: SidebarRightInjected['splitPane']
|
||||
readonly toggleFullscreen: SidebarRightInjected['toggleFullscreen']
|
||||
readonly shortcuts: readonly ShortcutCatalogEntry[]
|
||||
readonly useTabTypes: RightbarSeatProps['useTabTypes']
|
||||
readonly useTabNavigation: RightbarSeatProps['useTabNavigation']
|
||||
readonly useStore: Store['useStore']
|
||||
@@ -150,11 +165,11 @@ function guideIn(layout: LayoutState, paneId: PaneId): TabId | undefined {
|
||||
* @param openTab - the navigation face's `openTab`, which the strip's add control asks for a guide through.
|
||||
* @returns the intents the kit reports gestures to.
|
||||
*/
|
||||
export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab'], closeTab?: PanelProps['closeTab']): DockIntents {
|
||||
export function intentsFor(sessionId: SessionId, actions: Store['actions'], openTab: PanelProps['openTab'], closeTab?: PanelProps['closeTab'], splitPane?: PanelProps['splitPane']): DockIntents {
|
||||
return {
|
||||
focusTab: (tabId) => { actions.focusTab(sessionId, tabId) },
|
||||
focusPane: (paneId) => { actions.focusPane(sessionId, paneId) },
|
||||
splitPane: (paneId) => { actions.splitPane(sessionId, paneId) },
|
||||
splitPane: splitPane ?? ((paneId) => { actions.splitPane(sessionId, paneId) }),
|
||||
// The guide is unique per pane: the control is drawn only while its pane
|
||||
// holds none (`canAddTab` below) and asks for one there without regard to
|
||||
// guides in other panes; the store settles the open on a guide the pane
|
||||
@@ -173,7 +188,7 @@ export function intentsFor(sessionId: SessionId, actions: Store['actions'], open
|
||||
}
|
||||
|
||||
/** One tab's slot dispatch: which seat, and what to render when no type registered. */
|
||||
interface TabSlotProps extends Pick<PanelProps, 'renderSlot' | 'occurrence' | 'useTabTypes' | 'useTabNavigation' | 'useStore' | 'fullscreen' | 'active' | 'retainTab'> {
|
||||
interface TabSlotProps extends Pick<PanelProps, 'renderSlot' | 'occurrence' | 'useTabTypes' | 'useTabNavigation' | 'useStore' | 'fullscreen' | 'active' | 'retainTab' | 'shortcuts'> {
|
||||
readonly tab: TabRecord
|
||||
readonly seat: 'sidebar.right.pane.tab' | 'sidebar.right.pane.tab.title'
|
||||
readonly fallback: ReactNode
|
||||
@@ -183,14 +198,15 @@ interface TabSlotProps extends Pick<PanelProps, 'renderSlot' | 'occurrence' | 'u
|
||||
* Dispatch one tab's body or title with stable framework hooks and record lifetime.
|
||||
*/
|
||||
function TabSlot({
|
||||
renderSlot, occurrence, useTabTypes, useTabNavigation, useStore, fullscreen, active, retainTab, tab, seat, fallback,
|
||||
renderSlot, occurrence, useTabTypes, useTabNavigation, useStore, fullscreen, shortcuts, active, retainTab, tab, seat, fallback,
|
||||
}: TabSlotProps): ReactNode {
|
||||
const { signal, tabActions } = occurrence(tab)
|
||||
const { id, signal, tabActions } = occurrence(tab)
|
||||
const definition = useTabTypes(types => types.find(definition => definition.kind === tab.kind))
|
||||
const retained = seat === 'sidebar.right.pane.tab' && definition?.keepMounted === true
|
||||
useLayoutEffect(() => retained ? retainTab(tab.id, signal) : undefined, [retained, retainTab, tab.id, signal])
|
||||
const hookContext = useMemo((): TabHookContext => ({
|
||||
tabId: tab.id,
|
||||
shortcuts,
|
||||
title: seat === 'sidebar.right.pane.tab.title',
|
||||
fullscreen,
|
||||
active,
|
||||
@@ -198,8 +214,11 @@ function TabSlot({
|
||||
actions: tabActions,
|
||||
useStore,
|
||||
useTabNavigation,
|
||||
}), [tab.id, seat, fullscreen, active, signal, tabActions, useStore, useTabNavigation])
|
||||
return renderSlot(seat, {}, { entryKey: definition?.id ?? tab.kind, fallback, hookContext })
|
||||
}), [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>
|
||||
: <div className={css.tabBody} data-sidebar-right-tab={tab.id} data-sidebar-right-occurrence={id}>{content}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,30 +267,31 @@ function ExitFullscreenGlyph(): ReactNode {
|
||||
}
|
||||
|
||||
/** The panel's two controls, placed by the kit at the top-right pane's strip end. */
|
||||
function PanelChrome({ sessionId, fullscreen, autoFullscreen, actions, t }: Pick<PanelProps, 'sessionId' | 'actions' | 't' | 'fullscreen' | 'autoFullscreen'>): ReactNode {
|
||||
function PanelChrome({ sessionId, fullscreen, actions, t, shortcuts, toggleFullscreen }: Pick<PanelProps, 'sessionId' | 'actions' | 't' | 'fullscreen' | 'shortcuts' | 'toggleFullscreen'>): ReactNode {
|
||||
const next: DockMode = fullscreen ? 'push' : 'fullscreen'
|
||||
const modeLabel = fullscreen ? t('chrome.exitFullscreen') : t('chrome.toFullscreen')
|
||||
const mode = shortcuts.find(entry => entry.id === 'pane.fullscreen.toggle')
|
||||
const toggle = shortcuts.find(entry => entry.id === 'sidebar.right.toggle')
|
||||
return (
|
||||
<>
|
||||
<Tooltip label={modeLabel} side="bottom" delayMs={500}>
|
||||
<Tooltip label={[modeLabel, ...(mode?.keys ?? [])].join(' ')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={modeLabel}
|
||||
aria-keyshortcuts={mode?.aria}
|
||||
data-sidebar-right-mode={next}
|
||||
onClick={() => {
|
||||
if (fullscreen && autoFullscreen) actions.setExpanded(sessionId, false)
|
||||
actions.setMode(sessionId, next)
|
||||
}}
|
||||
onClick={toggleFullscreen}
|
||||
>
|
||||
{fullscreen ? <ExitFullscreenGlyph /> : <FullscreenGlyph />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('chrome.collapse')} side="bottom" delayMs={500}>
|
||||
<Tooltip label={[t('chrome.collapse'), ...(toggle?.keys ?? [])].join(' ')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('chrome.collapseAria')}
|
||||
aria-keyshortcuts={toggle?.aria}
|
||||
data-sidebar-right-toggle
|
||||
onClick={() => { actions.toggleExpanded(sessionId) }}
|
||||
>
|
||||
@@ -287,7 +307,7 @@ function PanelChrome({ sessionId, fullscreen, autoFullscreen, actions, t }: Pick
|
||||
* anchored to the frame's right edge and slid off it while collapsed.
|
||||
*/
|
||||
function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<HTMLDivElement> }): ReactNode {
|
||||
const { sessionId, surface, actions, t, renderSlot, openTab, width, reportRoom, fullscreen, autoFullscreen, panelRef } = panel
|
||||
const { sessionId, surface, actions, t, renderSlot, openTab, width, reportRoom, fullscreen, panelRef } = panel
|
||||
const { expanded } = surface.layout
|
||||
const types = panel.useTabTypes(value => value)
|
||||
return (
|
||||
@@ -296,6 +316,7 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<H
|
||||
className={css.panel}
|
||||
style={{ width: fullscreen ? '100vw' : width,
|
||||
'--dsh-sidebar-width': fullscreen ? '100vw' : `${width}px` } as CSSProperties}
|
||||
data-sidebar-right-session={sessionId}
|
||||
data-sidebar-right-panel={fullscreen ? 'fullscreen' : 'push'}
|
||||
data-sidebar-right-open={expanded || undefined}
|
||||
>
|
||||
@@ -303,20 +324,22 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<H
|
||||
<DockLayout
|
||||
state={surface.layout}
|
||||
canSplit={canSplit(surface.layout) && dockPaneIds(surface.layout).length < 2}
|
||||
hideSplitWhenBlocked
|
||||
dropZones="horizontal"
|
||||
minPaneFraction={0.2}
|
||||
canAddTab={paneId => guideIn(surface.layout, paneId) === undefined}
|
||||
canCloseTab={tabId => canCloseTab(surface, tabId)}
|
||||
intents={intentsFor(sessionId, actions, openTab, panel.closeTab)}
|
||||
labels={dockLabels(t)}
|
||||
intents={intentsFor(sessionId, actions, openTab, panel.closeTab, panel.splitPane)}
|
||||
labels={dockLabels(t, panel.shortcuts.find(entry => entry.id === 'pane.split'), panel.shortcuts.find(entry => entry.id === 'page.close'))}
|
||||
renderTab={bodiesFor(panel)}
|
||||
renderTabTitle={titlesFor(panel)}
|
||||
active={panel.active}
|
||||
keepMounted={tab => types.find(type => type.kind === tab.kind)?.keepMounted === true}
|
||||
renderTabMenuItems={(tab, dismiss) =>
|
||||
renderSlot('sidebar.right.tab.menu.item', { tab, dismiss })}
|
||||
chrome={<PanelChrome sessionId={sessionId} fullscreen={fullscreen} autoFullscreen={autoFullscreen} actions={actions} t={t} />}
|
||||
chrome={<PanelChrome
|
||||
sessionId={sessionId} fullscreen={fullscreen} actions={actions} t={t}
|
||||
shortcuts={panel.shortcuts} toggleFullscreen={panel.toggleFullscreen}
|
||||
/>}
|
||||
onRoom={reportRoom}
|
||||
/>
|
||||
</div>
|
||||
@@ -332,12 +355,13 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<H
|
||||
*/
|
||||
export function RightbarSeat({
|
||||
sessionId, width, viewportWidth, canShow, useStore, actions, t, renderSlot, syncPresentation, bindService, openTab, closeTab,
|
||||
useTabTypes, useTabNavigation, occurrence, retainTab, active,
|
||||
useTabTypes, useTabNavigation, occurrence, retainTab, active, useShortcuts, splitPane, toggleFullscreen, refreshInteraction,
|
||||
}: RightbarSeatProps): ReactNode {
|
||||
// One store instance per session, so this map holds this session's surface.
|
||||
// The binding published below serves the public face's commands on the
|
||||
// mounted session; a tab's own actions route through the controller's
|
||||
// adopted stores instead.
|
||||
const shortcuts = useShortcuts(entries => entries)
|
||||
const surfaces = useStore(state => state.bySession)
|
||||
const surface = surfaces[sessionId]
|
||||
const shown = active && surface !== undefined && surface.layout.expanded
|
||||
@@ -347,7 +371,10 @@ export function RightbarSeat({
|
||||
// The kit's room-rule readings, kept in a ref: the service reads them at
|
||||
// call time through the binding, and a reading never re-renders anything.
|
||||
const room = useRef<ReadonlyMap<PaneId, HalvesFit>>(new Map())
|
||||
const reportRoom = useCallback((fits: ReadonlyMap<PaneId, HalvesFit>): void => { room.current = fits }, [])
|
||||
const reportRoom = useCallback((fits: ReadonlyMap<PaneId, HalvesFit>): void => {
|
||||
room.current = fits
|
||||
refreshInteraction()
|
||||
}, [refreshInteraction])
|
||||
const track = shown && !autoFullscreen
|
||||
|
||||
useEffect(() => {
|
||||
@@ -422,9 +449,11 @@ export function RightbarSeat({
|
||||
// last commit, and its commands act on the session actually on screen.
|
||||
useEffect(
|
||||
() => active
|
||||
? bindService({ sessionId, actions, surfaces, canSplitPane: paneId => room.current.get(paneId)?.row !== false })
|
||||
? bindService({ sessionId, actions, surfaces, autoFullscreen,
|
||||
closeWithFocus: (paneId, close) => { closeWithPaneFocus(document, sessionId, paneId, close) },
|
||||
canSplitPane: paneId => room.current.get(paneId)?.row !== false })
|
||||
: undefined,
|
||||
[bindService, sessionId, actions, surfaces, active],
|
||||
[bindService, sessionId, actions, surfaces, autoFullscreen, active],
|
||||
)
|
||||
// The Tab domain is not synced here: the controller adopted this session's
|
||||
// store as the runtime minted it and reconciles on the store's own commits,
|
||||
@@ -433,7 +462,7 @@ export function RightbarSeat({
|
||||
if (surface === undefined) return null
|
||||
const panel: PanelProps = {
|
||||
sessionId, actions, t, renderSlot, surface, openTab, closeTab, useTabTypes, useTabNavigation, useStore, occurrence,
|
||||
fullscreen, autoFullscreen, reportRoom, active, retainTab,
|
||||
fullscreen, autoFullscreen, reportRoom, active, retainTab, shortcuts, splitPane, toggleFullscreen,
|
||||
}
|
||||
return <SidebarPanel {...panel} width={width} panelRef={panelRef} />
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Focus continuity after a close replaces docked or floating pane elements. */
|
||||
import { flushSync } from 'react-dom'
|
||||
import type { PaneId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/**
|
||||
* Commit a focused page's removal before focusing a surviving visible pane.
|
||||
* @param document - product document owning the input focus.
|
||||
* @param sessionId - Session whose page is closing.
|
||||
* @param paneId - pane whose page is closing; preferred if it survives.
|
||||
* @param close - synchronous cleanup and layout removal; errors preserve focus.
|
||||
*/
|
||||
export function closeWithPaneFocus(document: Document, sessionId: SessionId, paneId: PaneId, close: () => void): void {
|
||||
const before = document.activeElement
|
||||
const owner = before?.closest<HTMLElement>('[data-sidebar-right-session]')
|
||||
const source = before?.closest<HTMLElement>('[data-dockkit-pane], [data-dockkit-float]')
|
||||
const retain = owner?.dataset.sidebarRightSession === sessionId
|
||||
&& (source?.dataset.dockkitPane ?? source?.dataset.dockkitFloat) === paneId
|
||||
flushSync(close)
|
||||
if (!retain || (document.activeElement !== document.body && document.activeElement !== before)) return
|
||||
const panes = [...document.querySelectorAll<HTMLElement>('[data-dockkit-pane], [data-dockkit-float]')]
|
||||
.filter((pane) => {
|
||||
const owner = pane.closest<HTMLElement>('[data-sidebar-right-session]')
|
||||
return owner?.dataset.sidebarRightSession === sessionId
|
||||
&& (pane.hasAttribute('data-dockkit-float') || owner.hasAttribute('data-sidebar-right-open'))
|
||||
})
|
||||
const next = panes.find(pane => (pane.dataset.dockkitPane ?? pane.dataset.dockkitFloat) === paneId)
|
||||
?? panes.find(pane => pane.hasAttribute('data-dockkit-pane-active')) ?? panes[0]
|
||||
next?.focus({ preventScroll: true })
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/** Sidebar-owned commands and live availability from the mounted surface. */
|
||||
import type { Shortcuts, ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { SidebarRightController } from './service.ts'
|
||||
import type { SidebarRightTarget } from './focus.ts'
|
||||
import type {} from './locales.ts'
|
||||
|
||||
/**
|
||||
* Register the sidebar commands over the controller used by its visible controls.
|
||||
* @param shortcuts - effective-binding registry for this window.
|
||||
* @param sidebar - current Session and page owner.
|
||||
* @param t - current localized command and unavailable labels.
|
||||
* @returns release callback for the commands and their availability subscriptions.
|
||||
*/
|
||||
export function registerSidebarShortcuts(shortcuts: Pick<Shortcuts, 'register' | 'runtime' | 'closeWindow'>, sidebar: SidebarRightController, t: TranslateNS<'sidebarRight'>): () => void {
|
||||
const missing = (): string | null => sidebar.commandTarget(null) === undefined ? t('command.noSession') : null
|
||||
const reason = (kind: 'split' | 'fullscreen', target: SidebarRightTarget | undefined): string | null => {
|
||||
if (target === undefined) return t('command.noFocus')
|
||||
if (kind === 'split') {
|
||||
const block = sidebar.splitBlock(target)
|
||||
return block === undefined ? null : t(`command.${block}`)
|
||||
}
|
||||
if (target.host === 'float') return t('command.float')
|
||||
return null
|
||||
}
|
||||
const status = (read: () => string | null) => ({
|
||||
getSnapshot: read, subscribe: (listener: () => void) => sidebar.interaction.subscribe(listener),
|
||||
})
|
||||
const disposers = [shortcuts.register({
|
||||
id: 'sidebar.right.toggle' as ShortcutCommandId, label: () => t('command.toggle'), aliases: ['right sidebar', 'toggle right panel'],
|
||||
defaults: { desktop: { code: 'KeyB', modifiers: ['primary', 'alt'] } },
|
||||
regions: ['page', 'editable', 'terminal'], modals: [], availability: status(missing),
|
||||
resolve: () => {
|
||||
const target = sidebar.commandTarget(null)
|
||||
if (target === undefined) return { status: 'blocked', reason: t('command.noSession') }
|
||||
return { status: 'handled', run: () => { if (sidebar.isTargetCurrent(target)) sidebar.toggleExpanded() } }
|
||||
},
|
||||
})]
|
||||
for (const kind of ['split', 'fullscreen'] as const) {
|
||||
disposers.push(shortcuts.register({
|
||||
id: (kind === 'split' ? 'pane.split' : 'pane.fullscreen.toggle') as ShortcutCommandId,
|
||||
label: () => t(kind === 'split' ? 'dock.splitPane' : 'command.fullscreen'), aliases: [kind, 'panel'],
|
||||
defaults: { desktop: kind === 'split' ? { code: 'Backslash', modifiers: ['primary'] } : { code: 'Enter', modifiers: ['primary', 'alt'] } },
|
||||
regions: ['page', 'editable', 'terminal'], modals: [], availability: status(() => reason(kind, sidebar.focusedTarget())),
|
||||
resolve: ({ target: element }) => {
|
||||
const target = sidebar.focusedTarget(element)
|
||||
if (target === undefined) return { status: 'blocked', reason: t('command.noFocus') }
|
||||
const unavailable = reason(kind, target)
|
||||
if (unavailable !== null) return { status: 'blocked', reason: unavailable }
|
||||
return { status: 'handled', run: () => {
|
||||
if (!sidebar.isTargetCurrent(target)) return
|
||||
if (kind === 'split') sidebar.split(target.paneId)
|
||||
else sidebar.toggleFullscreen(target)
|
||||
} }
|
||||
},
|
||||
}))
|
||||
}
|
||||
for (const kind of ['close', 'refresh'] as const) {
|
||||
disposers.push(shortcuts.register({
|
||||
id: `page.${kind}` as ShortcutCommandId, label: () => t(`command.${kind}`), aliases: [kind, 'page'],
|
||||
defaults: { desktop: { code: kind === 'close' ? 'KeyW' : 'KeyR', modifiers: ['primary'] } },
|
||||
regions: ['page', 'editable', 'terminal'], modals: [],
|
||||
resolve: ({ target: element, source }) => {
|
||||
const target = sidebar.focusedTarget(element)
|
||||
if (target === undefined && (source === 'iframe' || element?.closest('[data-sidebar-right-session]'))) {
|
||||
return { status: 'blocked', reason: t('command.stale') }
|
||||
}
|
||||
if (kind === 'refresh') {
|
||||
const refresh = target?.occurrence?.commands.refresh
|
||||
if (target === undefined || refresh === undefined) return { status: 'blocked', reason: t('command.noRefresh') }
|
||||
return { status: 'handled', run: () => {
|
||||
if (sidebar.isTargetCurrent(target) && target.occurrence?.commands.refresh === refresh) refresh()
|
||||
} }
|
||||
}
|
||||
if (target !== undefined && sidebar.canCloseTarget(target)) {
|
||||
return { status: 'handled', run: () => { sidebar.closeTarget(target) } }
|
||||
}
|
||||
if (shortcuts.runtime !== 'desktop') return { status: 'blocked', reason: t('command.noFocus') }
|
||||
return { status: 'handled', run: () => {
|
||||
if (target === undefined || sidebar.isTargetCurrent(target)) shortcuts.closeWindow()
|
||||
} }
|
||||
},
|
||||
}))
|
||||
}
|
||||
return () => { for (const dispose of disposers.reverse()) dispose() }
|
||||
}
|
||||
@@ -16,12 +16,14 @@
|
||||
* aborted on that commit. The slot framework binds the navigation sources for
|
||||
* each record's `useTabInfo` reader.
|
||||
*/
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import type { LayoutState, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import { findTabPane } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SidebarRightNavigationParams } from './contract/params.ts'
|
||||
import type { SidebarRightTabActions, SidebarRightTabNavigation, SidebarRightTabPlacement } from './contract/slots.ts'
|
||||
import type { SidebarRightTabActions, SidebarRightTabCommands, SidebarRightTabNavigation, SidebarRightTabPlacement } from './contract/slots.ts'
|
||||
import type { SidebarRightOpenResourceOptions, SidebarRightOpenTabOptions, SidebarRightPlacement } from './service.ts'
|
||||
|
||||
/**
|
||||
@@ -40,8 +42,16 @@ export interface SidebarRightNavigator {
|
||||
/** `ctx.resources.pin`: hold an address's content open for as long as `signal` lives. */
|
||||
export type PinResource = (address: string, signal: AbortSignal) => void
|
||||
|
||||
/** Identity of one open lifetime; a restored tab receives a new value. */
|
||||
export type SidebarRightOccurrenceId = Branded<'SidebarRightOccurrenceId'>
|
||||
|
||||
/** What one open tab record holds beyond its layout entry. */
|
||||
export interface TabOccurrence {
|
||||
/** Operations of the currently mounted page body. */
|
||||
readonly commands: SidebarRightTabCommands
|
||||
|
||||
/** Distinguishes a reopened record from its previous lifetime. */
|
||||
readonly id: SidebarRightOccurrenceId
|
||||
readonly sessionId: SessionId
|
||||
readonly tabId: TabId
|
||||
/** Aborted when the record disappears or this package unloads. */
|
||||
@@ -54,6 +64,8 @@ export interface TabOccurrence {
|
||||
|
||||
/** An occurrence plus what only the domain touches. */
|
||||
interface Held extends TabOccurrence {
|
||||
commands: SidebarRightTabCommands
|
||||
|
||||
readonly controller: AbortController
|
||||
/** The docked pane holding the record at the last sync; `undefined` while it floats or before any sync. */
|
||||
paneId: PaneId | undefined
|
||||
@@ -162,14 +174,26 @@ export class TabDomain {
|
||||
...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened },
|
||||
})
|
||||
const held: Held = {
|
||||
id: randomUUID() as SidebarRightOccurrenceId,
|
||||
sessionId,
|
||||
tabId,
|
||||
controller,
|
||||
commands: {},
|
||||
signal: controller.signal,
|
||||
navigation: createSnapshotStore(navigation),
|
||||
paneId: undefined,
|
||||
pinned: false,
|
||||
tabActions: {
|
||||
bindCommands: (commands) => {
|
||||
if (controller.signal.aborted) return () => {}
|
||||
held.commands = commands
|
||||
const release = (): void => {
|
||||
if (held.commands === commands) held.commands = {}
|
||||
controller.signal.removeEventListener('abort', release)
|
||||
}
|
||||
controller.signal.addEventListener('abort', release, { once: true })
|
||||
return release
|
||||
},
|
||||
openResource: (address, options = {}) => {
|
||||
navigator.openResourceIn(sessionId, address, { ...place(options), params: options.params })
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Slot-owned tab information derived from framework-bound store and navigation hooks. */
|
||||
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { useMemo } from 'react'
|
||||
import { findTabPane } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
@@ -8,6 +9,7 @@ import type { createSidebarRightStore } from './stores.ts'
|
||||
|
||||
/** Stable dispatch identity and framework hooks; never passed as tab component props. */
|
||||
export interface TabHookContext {
|
||||
readonly shortcuts: readonly ShortcutCatalogEntry[]
|
||||
readonly tabId: TabId
|
||||
readonly title: boolean
|
||||
readonly fullscreen: boolean
|
||||
@@ -26,7 +28,7 @@ export interface TabHookContext {
|
||||
*/
|
||||
export const tabInfoFactory: SlotHookFactory<'sidebar.right.pane.tab', UseSidebarRightTabInfo> = (standard, context) => {
|
||||
const { sessionId } = standard
|
||||
const { tabId, title, fullscreen, active, signal, actions, useStore, useTabNavigation } = context
|
||||
const { tabId, title, fullscreen, active, signal, actions, useStore, useTabNavigation, shortcuts } = context
|
||||
return function useTabInfo() {
|
||||
const layout = useStore(state => state.bySession[sessionId]?.layout)
|
||||
const navigation = useTabNavigation(tabId)
|
||||
@@ -45,9 +47,10 @@ export const tabInfoFactory: SlotHookFactory<'sidebar.right.pane.tab', UseSideba
|
||||
navigation,
|
||||
signal,
|
||||
actions,
|
||||
refreshShortcut: shortcuts.find(row => row.id === 'page.refresh'),
|
||||
},
|
||||
}
|
||||
}, [layout, navigation, tabId, title, fullscreen, active, signal, actions])
|
||||
}, [layout, navigation, tabId, title, fullscreen, active, signal, actions, shortcuts])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
* Thunked copy (`title`, `guide[].title`, `guide[].description`) is read again
|
||||
* on every use, so a language change needs no re-registration.
|
||||
*/
|
||||
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { ComponentType } from 'react'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -59,6 +60,8 @@ const DEFAULT_BAND: SidebarRightTabPriority = 'extension'
|
||||
|
||||
/** One entry capsule the guide page offers, contributed by the type it opens (picking it opens that type as a page). */
|
||||
export interface SidebarRightGuideEntry {
|
||||
/** Effective shortcut shown on this entry, when the provider registers one. */
|
||||
readonly commandId?: ShortcutCommandId
|
||||
/** Stable entry identity within its provider. */
|
||||
readonly id: string
|
||||
/** Ascending position among every registered type's entries. */
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
width: 380px;
|
||||
max-width: 100%;
|
||||
min-height: 56px;
|
||||
padding: 14px 20px;
|
||||
padding: 14px 64px 14px 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
@@ -88,6 +88,7 @@
|
||||
description while the guide shows one. */
|
||||
.entryText {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* in this tab's place, so the guide is a doorway rather than a page that stays
|
||||
* open.
|
||||
*/
|
||||
import { ShortcutKeys } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ChainRenderOpts, HookContextOf, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -27,7 +29,10 @@ import css from './GuideBody.module.css'
|
||||
/** What the guide body needs from its host beyond the framework shares. */
|
||||
export interface GuideInjected {
|
||||
/** The registry's guide entries in `order`; observable, so a type registering later appears. */
|
||||
readonly hooks: { readonly guideEntries: ObservableSnapshot<readonly SidebarRightGuideBox[]> }
|
||||
readonly hooks: {
|
||||
readonly shortcuts: ObservableSnapshot<readonly ShortcutCatalogEntry[]>
|
||||
readonly guideEntries: ObservableSnapshot<readonly SidebarRightGuideBox[]>
|
||||
}
|
||||
}
|
||||
|
||||
/** The guide body's composed props: the tab it draws, its chain child, and the entries. */
|
||||
@@ -40,7 +45,8 @@ export type GuideBodyProps =
|
||||
const MAX_DESCRIBED_ENTRIES = 4
|
||||
|
||||
/** One entry capsule: the contributing type's glyph and title, and its description while the guide is short. */
|
||||
function EntryBox({ entry, described, onPick }: {
|
||||
function EntryBox({ entry, described, onPick, shortcut }: {
|
||||
shortcut: ShortcutCatalogEntry | undefined
|
||||
entry: SidebarRightGuideBox
|
||||
described: boolean
|
||||
onPick: (entry: SidebarRightGuideBox) => void
|
||||
@@ -52,6 +58,7 @@ function EntryBox({ entry, described, onPick }: {
|
||||
type="button"
|
||||
className={css.entry}
|
||||
data-sidebar-right-guide-entry={entry.kind}
|
||||
aria-keyshortcuts={shortcut?.aria}
|
||||
onClick={() => { onPick(entry) }}
|
||||
>
|
||||
{/* The glyph rides the capsule's height: 22 beside a bare title, 26 beside two lines. */}
|
||||
@@ -62,6 +69,7 @@ function EntryBox({ entry, described, onPick }: {
|
||||
<span className={css.entryTitle}>{entry.title()}</span>
|
||||
{description !== undefined && <span className={css.entryDescription}>{description}</span>}
|
||||
</span>
|
||||
{shortcut !== undefined && shortcut.keys.length > 0 && <ShortcutKeys keys={shortcut.keys} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -77,7 +85,8 @@ function ShippedGuide({ children }: { children: ReactNode }): ReactNode {
|
||||
}
|
||||
|
||||
/** The guide tab's body, replaceable through its chain child. */
|
||||
export function GuideBody({ useTabInfo, useGuideEntries, renderSlot, renderSlotChain }: GuideBodyProps): ReactNode {
|
||||
export function GuideBody({ useTabInfo, useGuideEntries, renderSlot, renderSlotChain, useShortcuts }: GuideBodyProps): ReactNode {
|
||||
const shortcuts = useShortcuts(entries => entries)
|
||||
const { tab } = useTabInfo()
|
||||
const entries = useGuideEntries(entries => entries)
|
||||
const options = {
|
||||
@@ -92,7 +101,7 @@ export function GuideBody({ useTabInfo, useGuideEntries, renderSlot, renderSlotC
|
||||
...description === undefined ? {} : { description },
|
||||
}, {
|
||||
entryKey: entry.providerId, hookContext: useTabInfo,
|
||||
fallback: <EntryBox entry={entry} described={described}
|
||||
fallback: <EntryBox entry={entry} described={described} shortcut={shortcuts.find(shortcut => shortcut.id === entry.commandId)}
|
||||
onPick={(selected) => { tab.actions.openTab(selected.kind, { replaceTab: true }) }} />,
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,8 @@ import { GuideTitle } from '../src/client/tabs/guide/GuideTitle.tsx'
|
||||
import { GUIDE_ID } from '../src/client/tabs/guide/definition.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
const SHORTCUT_CATALOG: readonly never[] = []
|
||||
|
||||
const SESSION = 's-test' as SessionId
|
||||
|
||||
interface Recorded {
|
||||
@@ -64,6 +66,7 @@ async function boot() {
|
||||
const resources = { pin: vi.fn<(address: string, signal: AbortSignal) => void>() }
|
||||
ctx.provide('slots', slots as never)
|
||||
ctx.provide('locale', locale as never)
|
||||
ctx.provide('shortcuts', { register: () => () => {}, catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} } } as never)
|
||||
ctx.provide('layout', layout as never)
|
||||
ctx.provide('resources', resources as never)
|
||||
ctx.provide('sessions', { retain: vi.fn() } as never)
|
||||
@@ -141,7 +144,11 @@ describe('ui-sidebar-right apply', () => {
|
||||
const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
|
||||
const instance = handle.create()
|
||||
instance.clearPersisted()
|
||||
const release = injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true })
|
||||
const release = injected.bindService({
|
||||
sessionId: SESSION, actions: instance.actions, surfaces: {},
|
||||
closeWithFocus: (_paneId, close) => { close() },
|
||||
canSplitPane: () => true,
|
||||
})
|
||||
injected.openTab('guide', { revealIfOpened: false })
|
||||
const surface = instance.getSnapshot().bySession[SESSION]
|
||||
expect(surface?.layout.expanded).toBe(true)
|
||||
@@ -243,7 +250,11 @@ describe('ui-sidebar-right apply', () => {
|
||||
const handle = seat('rightbar.session').store as ReturnType<typeof createSidebarRightStore>
|
||||
// Minted under the session key, so the instance is adopted and the teardown releases it.
|
||||
const instance = handle.create(SESSION)
|
||||
injected.bindService({ sessionId: SESSION, actions: instance.actions, surfaces: {}, canSplitPane: () => true })
|
||||
injected.bindService({
|
||||
sessionId: SESSION, actions: instance.actions, surfaces: {},
|
||||
closeWithFocus: (_paneId, close) => { close() },
|
||||
canSplitPane: () => true,
|
||||
})
|
||||
injected.openTab('guide')
|
||||
const surface = instance.getSnapshot().bySession[SESSION]
|
||||
const guide = Object.values(surface?.layout.tabs ?? {})[0]
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Closing pages retains actual focus through layout replacement without stealing another input owner. */
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import type { PaneId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { closeWithPaneFocus } from '../src/client/shell/close-focus.ts'
|
||||
|
||||
const session = 'closing-session' as SessionId
|
||||
afterEach(() => { document.body.replaceChildren() })
|
||||
|
||||
function pane(id: string, floating = false, ownerSession = session) {
|
||||
const owner = document.createElement('div')
|
||||
owner.dataset.sidebarRightSession = ownerSession
|
||||
owner.dataset.sidebarRightOpen = ''
|
||||
const element = document.createElement('section')
|
||||
element.tabIndex = -1
|
||||
element.dataset[floating ? 'dockkitFloat' : 'dockkitPane'] = id
|
||||
owner.append(element)
|
||||
document.body.append(owner)
|
||||
return element
|
||||
}
|
||||
|
||||
it.each([false, true])('focuses the surviving pane after removing a focused page (floating: %s)', (floating) => {
|
||||
const first = pane('first', floating)
|
||||
first.focus()
|
||||
let survivor: HTMLElement | undefined
|
||||
closeWithPaneFocus(document, session, 'first' as PaneId, () => {
|
||||
first.parentElement!.remove()
|
||||
survivor = pane('second')
|
||||
})
|
||||
expect(document.activeElement).toBe(survivor)
|
||||
closeWithPaneFocus(document, session, 'second' as PaneId, () => { survivor!.parentElement!.remove() })
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
})
|
||||
|
||||
it('prefers another tab in the same pane, then the active visible pane', () => {
|
||||
const first = pane('first')
|
||||
const second = pane('second')
|
||||
second.dataset.dockkitPaneActive = ''
|
||||
const input = document.createElement('input')
|
||||
first.append(input)
|
||||
input.focus()
|
||||
closeWithPaneFocus(document, session, 'first' as PaneId, () => { input.remove() })
|
||||
expect(document.activeElement).toBe(first)
|
||||
closeWithPaneFocus(document, session, 'first' as PaneId, () => { first.parentElement!.remove() })
|
||||
expect(document.activeElement).toBe(second)
|
||||
})
|
||||
|
||||
it('ignores hidden panes and other Sessions and preserves cleanup failure or explicit focus movement', () => {
|
||||
const first = pane('first')
|
||||
const hidden = pane('hidden')
|
||||
delete hidden.parentElement!.dataset.sidebarRightOpen
|
||||
pane('other', false, 'other-session' as SessionId)
|
||||
const composer = document.createElement('textarea')
|
||||
document.body.append(composer)
|
||||
first.focus()
|
||||
expect(() => { closeWithPaneFocus(document, session, 'first' as PaneId, () => { throw new Error('cleanup') }) }).toThrow('cleanup')
|
||||
expect(document.activeElement).toBe(first)
|
||||
closeWithPaneFocus(document, session, 'first' as PaneId, () => { composer.focus() })
|
||||
expect(document.activeElement).toBe(composer)
|
||||
closeWithPaneFocus(document, session, 'first' as PaneId, () => {})
|
||||
expect(document.activeElement).toBe(composer)
|
||||
first.focus()
|
||||
closeWithPaneFocus(document, session, 'first' as PaneId, () => { first.parentElement!.remove() })
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
})
|
||||
@@ -29,6 +29,7 @@ function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapsho
|
||||
function mountButton() {
|
||||
const instance = createSidebarRightStore(() => ({ kind: 'guide', title: 'Start' })).create()
|
||||
const props = {
|
||||
useShortcuts: (selector: (entries: never[]) => unknown) => selector([]),
|
||||
sessionId: SESSION,
|
||||
useStore: hookOf(instance),
|
||||
actions: instance.actions,
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Focus ownership remains separate from layout selection and validates captured lifetimes. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { activeDockPaneId, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { createSidebarRightController } from '../src/client/service.ts'
|
||||
import { createSidebarRightStore } from '../src/client/stores.ts'
|
||||
import { SidebarRightTabRegistry } from '../src/client/tab-registry.ts'
|
||||
import { ShortcutRegistry } from '@deepseek-ai/dsh-client-shortcuts/src/client/registry.ts'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { registerSidebarShortcuts } from '../src/client/shortcuts.ts'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import type { ShortcutCommand, ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { observeSidebarFocus, sidebarTargetFromElement } from '../src/client/focus.ts'
|
||||
|
||||
const SESSION = 'focus-session' as SessionId
|
||||
const releases: Array<() => void> = []
|
||||
afterEach(() => { for (const release of releases.splice(0).reverse()) release(); document.body.replaceChildren() })
|
||||
|
||||
function harness() {
|
||||
const ctx = new Context()
|
||||
const tabs = new SidebarRightTabRegistry(ctx)
|
||||
for (const kind of ['guide', 'files', 'terminal']) tabs.register({ id: kind, kind, title: () => kind, multiple: kind === 'terminal' })
|
||||
const { controller, adopt } = createSidebarRightController(tabs, vi.fn())
|
||||
const store = createSidebarRightStore(() => ({ kind: 'guide', title: 'guide' })).create()
|
||||
releases.push(adopt(SESSION, store), () => { controller.tabDomain.dispose() })
|
||||
store.actions.setExpanded(SESSION, true)
|
||||
const room = { allowed: true, autoFullscreen: false }
|
||||
const bind = (sessionId = SESSION) => controller.bind({ sessionId, actions: store.actions,
|
||||
surfaces: store.getSnapshot().bySession,
|
||||
closeWithFocus: (_paneId, close) => { close() },
|
||||
canSplitPane: () => room.allowed, autoFullscreen: room.autoFullscreen })
|
||||
releases.push(bind())
|
||||
const layout = () => store.getSnapshot().bySession[SESSION]!.layout
|
||||
const owner = document.createElement('section')
|
||||
owner.dataset.sidebarRightSession = SESSION
|
||||
document.body.append(owner)
|
||||
const paneElement = (paneId = activeDockPaneId(layout())) => {
|
||||
const pane = getPane(layout(), paneId)
|
||||
const element = document.createElement('section')
|
||||
element.tabIndex = -1
|
||||
element.dataset[pane.host === 'dock' ? 'dockkitPane' : 'dockkitFloat'] = paneId
|
||||
owner.append(element)
|
||||
return element
|
||||
}
|
||||
const tabElement = (tabId: TabId, parent = paneElement(findTabPane(layout(), tabId).id)) => {
|
||||
const element = document.createElement('div')
|
||||
element.tabIndex = 0
|
||||
element.dataset.dockkitTab = tabId
|
||||
element.dataset.sidebarRightOccurrence = controller.tabDomain.occurrence(SESSION, { id: tabId }).id
|
||||
parent.append(element)
|
||||
return element
|
||||
}
|
||||
return { controller, store, layout, paneElement, tabElement, room, bind }
|
||||
}
|
||||
|
||||
describe('sidebar focus targets', () => {
|
||||
it('uses keyboard-focused inactive tabs and iframe ownership instead of the last selected pane', () => {
|
||||
const h = harness()
|
||||
const left = activeDockPaneId(h.layout())
|
||||
h.controller.openTab('files')
|
||||
const files = h.controller.active()!.id
|
||||
const right = h.controller.split(left)!
|
||||
expect(h.layout().activePaneId).toBe(right)
|
||||
const chip = h.tabElement(files)
|
||||
chip.focus()
|
||||
expect(h.controller.focusedTarget()).toMatchObject({ sessionId: SESSION, paneId: left, tabId: files })
|
||||
const iframe = document.createElement('iframe')
|
||||
chip.append(iframe)
|
||||
iframe.focus()
|
||||
expect(h.controller.focusedTarget()).toMatchObject({ paneId: left, tabId: files })
|
||||
const composer = document.createElement('textarea')
|
||||
document.body.append(composer)
|
||||
composer.focus()
|
||||
expect(h.controller.focusedTarget()).toBeUndefined()
|
||||
expect(h.controller.commandTarget()?.paneId).toBe(right)
|
||||
})
|
||||
|
||||
it('rejects collapsed, disconnected, switched-session, moved and reopened targets', () => {
|
||||
const h = harness()
|
||||
h.controller.openTab('files')
|
||||
const files = h.controller.active()!.id
|
||||
const chip = h.tabElement(files)
|
||||
const target = h.controller.focusedTarget(chip)!
|
||||
expect(h.controller.isTargetCurrent(target)).toBe(true)
|
||||
h.store.actions.setExpanded(SESSION, false)
|
||||
expect(h.controller.splitBlock(target)).toBe('collapsed')
|
||||
expect(h.controller.focusedTarget(chip)).toBeUndefined()
|
||||
expect(h.controller.commandTarget(chip)).toBeUndefined()
|
||||
h.store.actions.setExpanded(SESSION, true)
|
||||
releases.push(h.bind('another-session' as SessionId))
|
||||
expect(h.controller.isTargetCurrent(target)).toBe(false)
|
||||
expect(h.controller.focusedTarget(chip)).toBeUndefined()
|
||||
releases.push(h.bind())
|
||||
h.controller.float(files)
|
||||
expect(h.controller.isTargetCurrent(target)).toBe(false)
|
||||
h.controller.dock(findTabPane(h.layout(), files).id)
|
||||
h.controller.close(files)
|
||||
h.controller._undo()
|
||||
expect(h.controller.isTargetCurrent(target)).toBe(false)
|
||||
expect(h.controller.focusedTarget(chip)).toBeUndefined()
|
||||
chip.remove()
|
||||
expect(h.controller.focusedTarget(chip)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a pane removed by closing its final content page', () => {
|
||||
const h = harness()
|
||||
h.controller.split()
|
||||
h.controller.openTabFromTarget('files', h.controller.commandTarget(null)!)
|
||||
const captured = h.controller.focusedTarget(h.tabElement(h.controller.active()!.id))!
|
||||
h.controller.close(captured.tabId!)
|
||||
expect(h.controller.isTargetCurrent(captured)).toBe(false)
|
||||
h.controller.openTabFromTarget('terminal', captured)
|
||||
expect(Object.values(h.layout().tabs).map(tab => tab.kind)).toEqual(['guide'])
|
||||
})
|
||||
|
||||
it('invalidates navigation captures and keeps opens scoped to the target pane', () => {
|
||||
const h = harness()
|
||||
const first = h.controller.commandTarget(null)!
|
||||
h.controller.openTabFromTarget('files', first)
|
||||
expect(Object.values(h.layout().tabs).map(tab => tab.kind)).toEqual(['files'])
|
||||
const files = h.controller.commandTarget(null)!
|
||||
h.controller.openTabFromTarget('files', files)
|
||||
expect(Object.values(h.layout().tabs)).toHaveLength(1)
|
||||
expect(h.controller.isTargetCurrent(files)).toBe(false)
|
||||
h.controller.openTabFromTarget('terminal', h.controller.commandTarget(null)!)
|
||||
h.controller.openTabFromTarget('terminal', h.controller.commandTarget(null)!)
|
||||
const terminals = Object.values(h.layout().tabs).filter(tab => tab.kind === 'terminal')
|
||||
expect(terminals).toHaveLength(2)
|
||||
expect(terminals[0]!.contentId).not.toBe(terminals[1]!.contentId)
|
||||
h.controller.float(terminals[0]!.id)
|
||||
const floating = h.controller.focusedTarget(h.tabElement(terminals[0]!.id))!
|
||||
h.controller.openTabFromTarget('terminal', floating)
|
||||
expect(Object.values(h.layout().tabs).filter(tab => tab.kind === 'terminal')).toHaveLength(3)
|
||||
expect(h.layout().tabs[terminals[0]!.id]).toBeDefined()
|
||||
expect(getPane(h.layout(), floating.paneId).tabs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shares split limits and fullscreen behavior while preserving floating pages', () => {
|
||||
const h = harness()
|
||||
const target = h.controller.commandTarget(null)!
|
||||
h.room.allowed = false
|
||||
expect(h.controller.splitBlock(target)).toBe('width')
|
||||
h.room.allowed = true
|
||||
expect(h.controller.splitBlock(target)).toBeUndefined()
|
||||
h.controller.toggleFullscreen(target)
|
||||
expect(h.layout().mode).toBe('fullscreen')
|
||||
h.controller.toggleFullscreen(target)
|
||||
expect(h.layout().mode).toBe('push')
|
||||
h.controller.split(target.paneId)
|
||||
expect(h.controller.splitBlock(target)).toBe('budget')
|
||||
h.controller.openTab('files')
|
||||
const files = h.controller.active()!.id
|
||||
h.controller.float(files)
|
||||
const floated = h.controller.focusedTarget(h.tabElement(files))!
|
||||
expect(h.controller.splitBlock(floated)).toBe('float')
|
||||
h.controller.toggleFullscreen(floated)
|
||||
expect(h.layout().mode).toBe('push')
|
||||
h.room.autoFullscreen = true
|
||||
releases.push(h.bind())
|
||||
h.controller.toggleFullscreen(h.controller.commandTarget(null)!)
|
||||
expect(h.layout().expanded).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects orphaned markup and preserves an empty pane identity until content arrives', () => {
|
||||
const h = harness()
|
||||
const pane = h.paneElement()
|
||||
const owner = pane.parentElement!
|
||||
const orphan = document.createElement('button')
|
||||
owner.append(orphan)
|
||||
expect(h.controller.focusedTarget(orphan)).toBeUndefined()
|
||||
const staleTab = document.createElement('div')
|
||||
staleTab.dataset.dockkitTab = 'removed-tab'
|
||||
pane.append(staleTab)
|
||||
expect(h.controller.focusedTarget(staleTab)).toBeUndefined()
|
||||
const stalePane = pane.cloneNode() as HTMLElement
|
||||
stalePane.dataset.dockkitPane = 'removed-pane'
|
||||
owner.append(stalePane)
|
||||
expect(h.controller.focusedTarget(stalePane)).toBeUndefined()
|
||||
h.controller.openTabFromTarget('files', h.controller.commandTarget(null)!)
|
||||
h.controller.close(h.controller.active()!.id)
|
||||
const empty = h.controller.commandTarget(null)!
|
||||
expect(empty.tabId).toBeUndefined()
|
||||
expect(h.controller.isTargetCurrent(empty)).toBe(true)
|
||||
const surface = h.store.getSnapshot().bySession[SESSION]!
|
||||
h.store.store.set({ bySession: { [SESSION]: { ...surface, layout: { ...surface.layout, expanded: true } } } })
|
||||
expect(sidebarTargetFromElement(pane, SESSION, h.layout(), vi.fn())?.tabId).toBeUndefined()
|
||||
expect(h.controller.splitBlock(empty)).toBe('empty')
|
||||
h.controller.openTabFromTarget('files', empty)
|
||||
expect(h.controller.isTargetCurrent(empty)).toBe(false)
|
||||
h.controller.openTabFromTarget('terminal', empty)
|
||||
expect(Object.values(h.layout().tabs).map(tab => tab.kind)).toEqual(['files'])
|
||||
const floatingFile = h.controller.active()!.id
|
||||
h.controller.float(floatingFile)
|
||||
const floating = h.controller.focusedTarget(h.tabElement(floatingFile))!
|
||||
h.store.actions.setExpanded(SESSION, false)
|
||||
h.controller.openTabFromTarget('files', floating)
|
||||
expect(Object.values(h.layout().tabs).filter(tab => tab.kind === 'files')).toHaveLength(1)
|
||||
expect(findTabPane(h.layout(), floatingFile).id).toBe(floating.paneId)
|
||||
expect(h.layout().expanded).toBe(true)
|
||||
expect(h.controller.splitBlock(floating)).toBe('float')
|
||||
releases.push(h.bind('missing-session' as SessionId))
|
||||
expect(h.controller.commandTarget(null)).toBeUndefined()
|
||||
expect(h.controller.splitBlock(floating)).toBe('stale')
|
||||
})
|
||||
|
||||
it('focuses pointer-activated pane contents and releases delayed blur notifications', async () => {
|
||||
const h = harness()
|
||||
const pane = h.paneElement()
|
||||
const body = document.createElement('div')
|
||||
pane.append(body)
|
||||
const changed = vi.fn()
|
||||
const release = observeSidebarFocus(document, changed)
|
||||
body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
expect(document.activeElement).toBe(pane)
|
||||
expect(changed).toHaveBeenCalled()
|
||||
const button = document.createElement('button')
|
||||
pane.append(button)
|
||||
button.focus()
|
||||
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
expect(document.activeElement).toBe(button)
|
||||
document.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
await Promise.resolve()
|
||||
const before = changed.mock.calls.length
|
||||
window.dispatchEvent(new Event('blur'))
|
||||
release()
|
||||
await Promise.resolve()
|
||||
expect(changed).toHaveBeenCalledTimes(before)
|
||||
body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
expect(changed).toHaveBeenCalledTimes(before)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('sidebar keyboard commands', () => {
|
||||
it.each(['macos', 'windows'] as const)('uses the effective %s binding, blocks collapsed splits and consumes repeats', (platform) => {
|
||||
const h = harness()
|
||||
const registry = new ShortcutRegistry('desktop', platform)
|
||||
releases.push(registerSidebarShortcuts({ register: command => registry.register(command),
|
||||
runtime: registry.runtime, closeWindow: vi.fn() }, h.controller, makeTranslate(en)))
|
||||
const consume = vi.fn()
|
||||
const gesture = { code: 'KeyB', meta: platform === 'macos', control: platform === 'windows', alt: true,
|
||||
shift: false, repeat: false, composing: false, defaultPrevented: false }
|
||||
const context = { target: null, region: 'editable' as const, modal: null }
|
||||
expect(registry.dispatch(gesture, context, consume).status).toBe('handled')
|
||||
expect(h.layout().expanded).toBe(false)
|
||||
expect(registry.dispatch({ ...gesture, code: 'Backslash', alt: false }, context, consume).status).toBe('blocked')
|
||||
expect(registry.dispatch({ ...gesture, repeat: true }, context, consume).status).toBe('handled')
|
||||
expect(h.layout().expanded).toBe(false)
|
||||
registry.dispatch(gesture, context, consume)
|
||||
expect(h.layout().expanded).toBe(true)
|
||||
expect(registry.dispatch({ ...gesture, code: 'Enter' }, context, consume).status).toBe('blocked')
|
||||
expect(h.layout().mode).toBe('push')
|
||||
const focused = { ...context, target: h.paneElement() }
|
||||
registry.dispatch({ ...gesture, code: 'Enter' }, focused, consume)
|
||||
expect(h.layout().mode).toBe('fullscreen')
|
||||
registry.dispatch({ ...gesture, code: 'Enter' }, { ...focused, modal: 'settings' }, consume)
|
||||
expect(h.layout().mode).toBe('push')
|
||||
registry.dispatch({ ...gesture, code: 'Enter', composing: true }, focused, consume)
|
||||
expect(h.layout().mode).toBe('push')
|
||||
registry.dispatch({ ...gesture, code: 'Enter' }, focused, consume)
|
||||
expect(h.layout().mode).toBe('fullscreen')
|
||||
})
|
||||
|
||||
it('toggles the current Session while a collapsed pane still owns DOM focus', () => {
|
||||
const h = harness()
|
||||
const registry = new ShortcutRegistry('desktop', 'windows')
|
||||
releases.push(registerSidebarShortcuts({ register: command => registry.register(command),
|
||||
runtime: registry.runtime, closeWindow: vi.fn() }, h.controller, makeTranslate(en)))
|
||||
const pane = h.paneElement()
|
||||
pane.focus()
|
||||
const context = { target: pane, region: 'page' as const, modal: null }
|
||||
const gesture = { code: 'KeyB', control: true, meta: false, alt: true, shift: false,
|
||||
repeat: false, composing: false, defaultPrevented: false }
|
||||
expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('handled')
|
||||
expect(h.layout().expanded).toBe(false)
|
||||
expect(document.activeElement).toBe(pane)
|
||||
expect(registry.dispatch(gesture, context, vi.fn()).status).toBe('handled')
|
||||
expect(h.layout().expanded).toBe(true)
|
||||
})
|
||||
|
||||
it('publishes focus restrictions and refuses captured commands after Session replacement', () => {
|
||||
const h = harness()
|
||||
const commands = new Map<string, ShortcutCommand>()
|
||||
releases.push(registerSidebarShortcuts({ runtime: 'desktop', closeWindow: vi.fn(), register: (command) => {
|
||||
commands.set(command.id, command)
|
||||
return () => { commands.delete(command.id) }
|
||||
} }, h.controller, makeTranslate(en)))
|
||||
const split = commands.get('pane.split')!
|
||||
const fullscreen = commands.get('pane.fullscreen.toggle')!
|
||||
const toggle = commands.get('sidebar.right.toggle')!
|
||||
const pane = h.paneElement()
|
||||
pane.focus()
|
||||
const context = { target: pane, region: 'page' as const, modal: null }
|
||||
expect(split.availability!.getSnapshot()).toBeNull()
|
||||
h.room.allowed = false
|
||||
expect(split.availability!.getSnapshot()).toBe(en['command.width'])
|
||||
expect(split.resolve(context).status).toBe('blocked')
|
||||
h.room.allowed = true
|
||||
const pendingSplit = split.resolve(context)
|
||||
const pendingFullscreen = fullscreen.resolve(context)
|
||||
const pendingToggle = toggle.resolve(context)
|
||||
releases.push(h.bind('missing-session' as SessionId))
|
||||
for (const pending of [pendingSplit, pendingFullscreen, pendingToggle]) {
|
||||
expect(pending.status).toBe('handled')
|
||||
if (pending.status === 'handled') pending.run()
|
||||
}
|
||||
expect(h.layout().mode).toBe('push')
|
||||
expect(h.layout().expanded).toBe(true)
|
||||
expect(toggle.resolve(context).status).toBe('blocked')
|
||||
expect(toggle.availability!.getSnapshot()).toBe(en['command.noSession'])
|
||||
releases.push(h.bind())
|
||||
const splitNow = split.resolve(context)
|
||||
if (splitNow.status === 'handled') splitNow.run()
|
||||
expect(h.controller.splitBlock(h.controller.commandTarget(null)!)).toBe('budget')
|
||||
h.controller.openTab('files')
|
||||
const files = h.controller.active()!.id
|
||||
h.controller.float(files)
|
||||
const floated = h.tabElement(files)
|
||||
floated.focus()
|
||||
expect(fullscreen.availability!.getSnapshot()).toBe(en['command.float'])
|
||||
expect(fullscreen.resolve({ ...context, target: floated }).status).toBe('blocked')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ platform: 'macos', keys: ['Shift+Meta+B', 'Meta+\\', 'Alt+Meta+Enter', 'Alt+Meta+W', undefined] },
|
||||
{ platform: 'windows', keys: ['Control+Shift+B', 'Control+\\', 'Control+Alt+Enter', 'Control+Alt+W', 'Control+Alt+R'] },
|
||||
{ platform: 'linux', keys: [undefined, undefined, undefined, undefined, undefined] },
|
||||
] as const)('publishes the Web defaults and unbound commands on $platform', ({ platform, keys }) => {
|
||||
const h = harness()
|
||||
const registry = new ShortcutRegistry('web', platform)
|
||||
releases.push(registerSidebarShortcuts({ register: command => registry.register(command),
|
||||
runtime: registry.runtime, closeWindow: vi.fn() }, h.controller, makeTranslate(en)))
|
||||
expect(registry.catalog.getSnapshot().map(row => row.id)).toEqual([
|
||||
'sidebar.right.toggle', 'pane.split', 'pane.fullscreen.toggle', 'page.close', 'page.refresh',
|
||||
])
|
||||
expect(registry.catalog.getSnapshot().map(row => row.aria)).toEqual(keys)
|
||||
})
|
||||
})
|
||||
|
||||
describe('page close and refresh', () => {
|
||||
it.each(['macos', 'windows'] as const)('closes two guide panes without closing the window on %s', (platform) => {
|
||||
const h = harness()
|
||||
h.controller.split()
|
||||
const registry = new ShortcutRegistry('desktop', platform)
|
||||
const closeWindow = vi.fn()
|
||||
releases.push(registerSidebarShortcuts({ register: command => registry.register(command), runtime: 'desktop', closeWindow }, h.controller, makeTranslate(en)))
|
||||
const gesture = { code: 'KeyW', meta: platform === 'macos', control: platform === 'windows', alt: false,
|
||||
shift: false, repeat: false, composing: false, defaultPrevented: false }
|
||||
const first = Object.values(h.layout().tabs)[0]!
|
||||
const element = h.tabElement(first.id)
|
||||
registry.dispatch(gesture, { target: element, region: 'page', modal: null }, vi.fn())
|
||||
element.remove()
|
||||
expect(Object.values(h.layout().tabs)).toHaveLength(1)
|
||||
const remaining = Object.values(h.layout().tabs)[0]!
|
||||
expect(remaining.kind).toBe('guide')
|
||||
registry.dispatch(gesture, { target: h.tabElement(remaining.id), region: 'page', modal: null }, vi.fn())
|
||||
expect(closeWindow).not.toHaveBeenCalled()
|
||||
expect(h.layout().expanded).toBe(false)
|
||||
expect(h.layout().tabs[remaining.id]).toBeDefined()
|
||||
})
|
||||
|
||||
it.each(['macos', 'windows'] as const)('refreshes and closes the focused occurrence on %s with priority over modals and terminals', (platform) => {
|
||||
const h = harness()
|
||||
const registry = new ShortcutRegistry('desktop', platform)
|
||||
const closeWindow = vi.fn()
|
||||
releases.push(registerSidebarShortcuts({ register: command => registry.register(command), runtime: 'desktop', closeWindow }, h.controller, makeTranslate(en)))
|
||||
h.controller.openTab('files')
|
||||
const files = h.controller.active()!.id
|
||||
const element = h.tabElement(files)
|
||||
const occurrence = h.controller.tabDomain.occurrence(SESSION, { id: files })
|
||||
const refresh = vi.fn()
|
||||
const release = occurrence.tabActions.bindCommands({ refresh })
|
||||
const gesture = { code: 'KeyR', meta: platform === 'macos', control: platform === 'windows', alt: false,
|
||||
shift: false, repeat: false, composing: false, defaultPrevented: false }
|
||||
const context = { target: element, region: 'page' as const, modal: null }
|
||||
const consume = vi.fn()
|
||||
registry.dispatch(gesture, context, consume)
|
||||
registry.dispatch({ ...gesture, repeat: true }, context, consume)
|
||||
expect(refresh).toHaveBeenCalledTimes(1)
|
||||
expect(consume).toHaveBeenCalledTimes(2)
|
||||
release()
|
||||
expect(registry.dispatch(gesture, context, consume).status).toBe('blocked')
|
||||
expect(registry.dispatch(gesture, { ...context, modal: 'settings' }, consume)).toMatchObject({ status: 'blocked', reason: en['command.noRefresh'] })
|
||||
expect(h.layout().tabs[files]).toBeDefined()
|
||||
expect(registry.dispatch(gesture, { ...context, region: 'terminal' }, consume).status).toBe('blocked')
|
||||
expect(registry.dispatch({ ...gesture, code: 'KeyW' }, { ...context, region: 'terminal', modal: 'settings' }, consume).status).toBe('handled')
|
||||
expect(h.layout().tabs[files]).toBeUndefined()
|
||||
expect(occurrence.signal.aborted).toBe(true)
|
||||
registry.dispatch({ ...gesture, code: 'KeyW' }, context, consume)
|
||||
expect(closeWindow).not.toHaveBeenCalled()
|
||||
registry.dispatch({ ...gesture, code: 'KeyW' }, { ...context, target: null }, consume)
|
||||
expect(closeWindow).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('preserves a page when cleanup fails and never falls back to closing the window', () => {
|
||||
const h = harness()
|
||||
h.controller.openTab('terminal')
|
||||
const tab = h.controller.active()!.id
|
||||
const closeWindow = vi.fn()
|
||||
const registry = new ShortcutRegistry('desktop', 'macos')
|
||||
releases.push(registerSidebarShortcuts({ register: command => registry.register(command), runtime: 'desktop', closeWindow }, h.controller, makeTranslate(en)))
|
||||
const off = h.controller.registerCloseHandler('terminal', () => { throw new Error('cleanup failed') })
|
||||
releases.push(off)
|
||||
const context = { target: h.tabElement(tab), region: 'page' as const, modal: null }
|
||||
expect(() =>{ registry.invoke('page.close' as ShortcutCommandId, context) }).toThrow('cleanup failed')
|
||||
expect(h.layout().tabs[tab]).toBeDefined()
|
||||
expect(closeWindow).not.toHaveBeenCalled()
|
||||
off()
|
||||
registry.invoke('page.close' as ShortcutCommandId, context)
|
||||
expect(h.layout().tabs[tab]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('releases page capabilities on unmount and abort without removing a newer registration', () => {
|
||||
const h = harness()
|
||||
h.controller.openTab('files')
|
||||
const id = h.controller.active()!.id
|
||||
const target = h.controller.focusedTarget(h.tabElement(id))!
|
||||
const occurrence = target.occurrence!
|
||||
const first = occurrence.tabActions.bindCommands({ refresh: vi.fn() })
|
||||
const refresh = vi.fn()
|
||||
const second = occurrence.tabActions.bindCommands({ refresh })
|
||||
first()
|
||||
expect(occurrence.commands.refresh).toBe(refresh)
|
||||
expect(h.controller.closeTarget(target)).toBe('closed')
|
||||
expect(occurrence.commands.refresh).toBeUndefined()
|
||||
second()
|
||||
occurrence.tabActions.bindCommands({ refresh })()
|
||||
expect(occurrence.commands.refresh).toBeUndefined()
|
||||
expect(h.controller.closeTarget(target)).toBe('stale')
|
||||
h.controller._undo()
|
||||
expect(h.controller.closeTarget(target)).toBe('stale')
|
||||
})
|
||||
})
|
||||
@@ -47,7 +47,9 @@ function mountGuide(entries: readonly SidebarRightGuideBox[], custom?: (key: str
|
||||
const openTab = vi.fn()
|
||||
const renderSlot = vi.fn((_seat: string, _owner: unknown, options: { fallback: ReactNode }) => options.fallback)
|
||||
const props = {
|
||||
useTabInfo: () => ({ tab: { ...TAB, actions: { openResource: vi.fn(), openTab, close: vi.fn() } } }),
|
||||
useShortcuts: (selector: (entries: never[]) => unknown) => selector([]),
|
||||
useTabInfo: () => ({ tab: { ...TAB,
|
||||
actions: { bindCommands: vi.fn(() => vi.fn()), openResource: vi.fn(), openTab, close: vi.fn() } } }),
|
||||
useGuideEntries: bindSnapshotSelector(guideEntries),
|
||||
renderSlotChain: renderSlot,
|
||||
renderSlot: vi.fn((_slot: string, _owner: unknown, options: { entryKey: string; fallback: ReactNode }) =>
|
||||
|
||||
@@ -28,6 +28,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
const SHORTCUT_CATALOG: readonly never[] = []
|
||||
|
||||
const SESSION = 's-test' as SessionId
|
||||
const OTHER = 's-other' as SessionId
|
||||
const runtimes: SlotTestRuntime[] = []
|
||||
@@ -73,6 +75,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, o
|
||||
runtime.ctx.provide('resources', { pin } as never)
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.ctx.provide('shortcuts', { register: () => () => {}, catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} } } as never)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.declare({
|
||||
'sidebar-right.test.opener': { kind: 'single', scope: 'session' },
|
||||
@@ -644,7 +647,7 @@ describe('slot-owned useTabInfo', () => {
|
||||
expect(document.querySelector('[data-dockkit-tab-menu]')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides split controls at two panes and adds a guide only to a pane without one', async () => {
|
||||
it('explains the two-pane limit and adds a guide only to a pane without one', async () => {
|
||||
const h = await mountSeat()
|
||||
// Expanding first seeds the left pane's guide; only the right pane will lack one.
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
@@ -656,7 +659,8 @@ describe('slot-owned useTabInfo', () => {
|
||||
const stored = h.instance.getSnapshot()
|
||||
act(() => { expect(h.controller.split()).toBeUndefined() })
|
||||
expect(h.instance.getSnapshot()).toBe(stored)
|
||||
expect(splitButtons()).toHaveLength(0)
|
||||
expect(splitButtons()).toHaveLength(2)
|
||||
expect([...splitButtons()].every(button => button.hasAttribute('disabled'))).toBe(true)
|
||||
const right = dockPaneIds(h.layout())[1]!
|
||||
h.open('right.txt', { paneId: right })
|
||||
const guide = getPane(h.layout(), right).tabs.find(id => h.layout().tabs[id]?.kind === 'guide')!
|
||||
|
||||
@@ -61,7 +61,9 @@ function harness() {
|
||||
const surface = instance.getSnapshot().bySession[SESSION]
|
||||
if (surface !== undefined) controller.tabDomain.sync(SESSION, surface.layout)
|
||||
return controller.bind({
|
||||
sessionId: SESSION, actions: instance.actions, surfaces: instance.getSnapshot().bySession, canSplitPane: () => room.allowed,
|
||||
sessionId: SESSION, actions: instance.actions, surfaces: instance.getSnapshot().bySession,
|
||||
closeWithFocus: (_paneId, close) => { close() },
|
||||
canSplitPane: () => room.allowed,
|
||||
})
|
||||
}
|
||||
const titles = (): string[] => {
|
||||
@@ -523,7 +525,7 @@ describe('SidebarRightController — a tab\'s own actions', () => {
|
||||
sessionId: OTHER,
|
||||
actions: other.actions,
|
||||
surfaces: other.getSnapshot().bySession,
|
||||
canSplitPane: () => true,
|
||||
closeWithFocus: (_paneId, close) => { close() }, canSplitPane: () => true,
|
||||
})
|
||||
controller.openResourceIn(SESSION, A_TXT)
|
||||
expect(titles()).toContain('a.txt')
|
||||
@@ -550,7 +552,11 @@ describe('SidebarRightController — a tab\'s own actions', () => {
|
||||
other.actions.setExpanded(OTHER, true)
|
||||
const otherSurface = other.getSnapshot().bySession[OTHER]
|
||||
if (otherSurface === undefined) throw new Error('expected the other surface')
|
||||
controller.bind({ sessionId: OTHER, actions: other.actions, surfaces: other.getSnapshot().bySession, canSplitPane: () => true })
|
||||
controller.bind({
|
||||
sessionId: OTHER, actions: other.actions, surfaces: other.getSnapshot().bySession,
|
||||
closeWithFocus: (_paneId, close) => { close() },
|
||||
canSplitPane: () => true,
|
||||
})
|
||||
fromOwn.openResource(B_TXT)
|
||||
fromOwn.openTab('guide', { revealIfOpened: false })
|
||||
expect(Object.values(layout().tabs).map(tab => tab.title)).toContain('b.txt')
|
||||
|
||||
@@ -40,7 +40,7 @@ function harness() {
|
||||
const occurrence = domain.occurrence(SESSION, { id: tabId })
|
||||
navigationSources.set(tabId, occurrence.navigation)
|
||||
return tabInfoFactory(standard, {
|
||||
tabId, title: false, fullscreen: false, active: true,
|
||||
tabId, title: false, fullscreen: false, active: true, shortcuts: [],
|
||||
signal: occurrence.signal, actions: occurrence.tabActions, useStore, useTabNavigation,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../shortcuts/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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-terminal/README.md
|
||||
README.md: 02f4c60d20a0cebb594d18085212c8c0bfdc2438
|
||||
README.zh.md: b8ab03ce8a62c2ba27a9258d12c7e5d08a178a72
|
||||
README.md: 78c8b5256e311491289a06ce1ee768260c4edd3c
|
||||
README.zh.md: 0279ac78fed8f23976ec63cdbe6fb2905824b1bd
|
||||
@@ -25,6 +25,8 @@ Choose an installed shell from the right sidebar's Start page to run commands in
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The `terminal.new` command creates a separate terminal in the focused dock pane, replacing a guide and retaining existing content pages. From the conversation or a floating content page, it uses the active dock pane. The guide button displays the current effective shortcut inline without a duplicate tooltip. Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves the command unbound by default.
|
||||
|
||||
Open the right sidebar and click **New terminal** to open the remembered available shell immediately. Its adjacent arrow opens the installed-shell menu; selecting an item remembers it and opens that shell directly. Discovery runs when the menu opens and does not allocate a terminal. A failed lookup offers Retry in the menu. Use **New tab** to return to the guide and open another terminal.
|
||||
|
||||
Double-click the terminal's tab title to rename it. **Take control** makes the current attachment writable when another page owns input. A temporary disconnect preserves the screen and offers **Reconnect**, without exposing transport diagnostics. An exited shell remains visible with its exit code and offers **New terminal**; it never restarts automatically. Exited terminals count toward the Session limit; close unused tabs when the limit is reached.
|
||||
|
||||
@@ -25,6 +25,8 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用此包
|
||||
|
||||
命令 `terminal.new` 在焦点停靠分栏创建独立终端,替换开始页并保留已有内容页。从聊天区或浮动内容页触发时,使用活动停靠分栏。开始页按钮内显示当前有效快捷键,不额外弹出重复提示。Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 默认不绑定此命令。
|
||||
|
||||
展开右侧栏,点击**新建终端**即可直接打开上次选择且仍可用的 shell。旁边的箭头展开已安装 shell 菜单;选择后立即记住该项并打开终端。菜单展开时才查询 shell,不分配终端进程;查询失败可在菜单内重试。通过**新标签页**回到开始页并打开更多终端。
|
||||
|
||||
双击终端标签标题可重命名。其他页面持有输入权时,点击 **接管输入** 使当前连接可写。暂时断线时保留屏幕并提供 **重新连接**,不展示传输内部错误。已退出的 shell 仍显示退出码并提供 **新建终端**,不会自动重启。已退出的终端仍计入 Session 配额;达到上限时请关闭不用的标签。
|
||||
|
||||
@@ -31,7 +31,8 @@
|
||||
"@deepseek-ai/dsh-api-terminal-controller",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-client-ui-theme"
|
||||
"@deepseek-ai/dsh-client-ui-theme",
|
||||
"@deepseek-ai/dsh-client-shortcuts"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -68,7 +69,8 @@
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^"
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
.entry { box-sizing: border-box; display: flex; align-items: stretch; width: 100%; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 24px; background: var(--dsw-alias-bg-layer-1); }
|
||||
.mainAnchor { display: flex; flex: 1; min-width: 0; }
|
||||
.main { flex: 1; justify-content: flex-start; gap: 14px; min-width: 0; height: auto; min-height: 56px; padding: 14px 20px; border-radius: 24px 0 0 24px; text-align: left; }
|
||||
.icon { flex: none; }
|
||||
.text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.text { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.title { overflow: hidden; color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.description { overflow: hidden; color: var(--dsw-alias-label-caption); font-size: 13px; line-height: 1.4; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.trigger { align-self: stretch; flex: none; height: auto; width: 44px; padding: 0; border-radius: 0 24px 24px 0; }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Shell launch menu owned by the terminal provider's guide entry. */
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Button, IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { ShortcutKeys, Button, IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { TerminalLaunchShells } from '@deepseek-ai/dsh-api-terminal-controller/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
@@ -10,6 +12,7 @@ import css from './TerminalGuide.module.css'
|
||||
|
||||
/** Provider-owned discovery and browser preference writes. */
|
||||
export interface TerminalGuideInjected {
|
||||
readonly hooks: { readonly shortcuts: HostObservable<readonly ShortcutCatalogEntry[]> }
|
||||
/** @param signal - open menu lifetime. @returns current Host choices and browser preference. */
|
||||
readonly loadShells: (signal: AbortSignal) => Promise<TerminalLaunchShells>
|
||||
/** @param path - Host-discovered shell selected for the next terminal. */
|
||||
@@ -23,10 +26,14 @@ type MenuState = { phase: 'loading' } | { phase: 'ready'; choices: TerminalLaunc
|
||||
|
||||
/**
|
||||
* Open the remembered shell from the card or choose another shell from its menu.
|
||||
* The launch button displays its current effective shortcut inline.
|
||||
* @param props - guide copy, enclosing tab actions and cancellable discovery.
|
||||
* @returns separate launch and menu buttons within one guide card.
|
||||
*/
|
||||
export function TerminalGuide({ title, description, kind, useTabInfo, loadShells, selectShell, t }: TerminalGuideProps): ReactNode {
|
||||
export function TerminalGuide({
|
||||
title, description, kind, useTabInfo, loadShells, selectShell, t, useShortcuts,
|
||||
}: TerminalGuideProps): ReactNode {
|
||||
const shortcut = useShortcuts(entries => entries.find(entry => entry.id === 'terminal.new'))
|
||||
const { tab } = useTabInfo()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
@@ -47,13 +54,16 @@ export function TerminalGuide({ title, description, kind, useTabInfo, loadShells
|
||||
? [{ id: 'loading', label: t('shellLoading'), disabled: true }]
|
||||
: [{ id: 'error', label: t('failed', { message: state.message }), disabled: true }, { id: 'retry', label: t('retry') }]
|
||||
return <div className={css.entry} data-sidebar-right-guide-entry={kind}>
|
||||
<Button variant="ghost" className={css.main} onClick={() => { tab.actions.openTab('terminal', { replaceTab: true }) }}>
|
||||
<TerminalGuideIcon size={description === undefined ? 22 : 26} className={css.icon} />
|
||||
<span className={css.text}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{description !== undefined && <span className={css.description}>{description}</span>}
|
||||
</span>
|
||||
</Button>
|
||||
<span className={css.mainAnchor}>
|
||||
<Button variant="ghost" className={css.main} aria-keyshortcuts={shortcut?.aria} onClick={() => { tab.actions.openTab('terminal', { replaceTab: true }) }}>
|
||||
<TerminalGuideIcon size={description === undefined ? 22 : 26} className={css.icon} />
|
||||
<span className={css.text}>
|
||||
<span className={css.title}>{title}</span>
|
||||
{description !== undefined && <span className={css.description}>{description}</span>}
|
||||
</span>
|
||||
{shortcut !== undefined && shortcut.keys.length > 0 && <ShortcutKeys keys={shortcut.keys} />}
|
||||
</Button>
|
||||
</span>
|
||||
<Menu
|
||||
open={open} portal autoFocus align="end" className={css.menu}
|
||||
items={items.length === 0 ? [{ id: 'empty', label: t('shellEmpty'), disabled: true }] : items}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Register interactive terminal tabs and explicit process cleanup with the sidebar. */
|
||||
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types'
|
||||
import type { SidebarRightTabParamsMap, TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
@@ -19,7 +20,7 @@ import type { TerminalBodyInjected, TerminalInjected } from './face.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
/** Services needed by the terminal's two sidebar seats. */
|
||||
export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs', 'webTerminals', 'theme']
|
||||
export const inject = ['slots', 'locale', 'sidebarRight', 'sidebarRightTabs', 'webTerminals', 'theme', 'shortcuts']
|
||||
|
||||
/**
|
||||
* Register the terminal type, observable views and background process cleanup.
|
||||
@@ -50,6 +51,22 @@ export function apply(ctx: Context): void {
|
||||
const namespace = 'sidebarTerminal'
|
||||
const id = '@deepseek-ai/dsh-client-ui-sidebar-terminal'
|
||||
const t = ctx.locale.bind(namespace)
|
||||
ctx.inject(['shortcuts'], (ctx) => {
|
||||
ctx.effect(() => ctx.shortcuts.register({
|
||||
id: 'terminal.new' as ShortcutCommandId, label: () => t('new'), aliases: ['new terminal', 'shell'],
|
||||
defaults: { desktop: { code: 'Backquote', modifiers: ['control'] } },
|
||||
regions: ['page', 'editable', 'terminal'], modals: [],
|
||||
availability: {
|
||||
getSnapshot: () => ctx.sidebarRight.commandTarget() === undefined ? t('shortcut.noSession') : null,
|
||||
subscribe: listener => ctx.sidebarRight.interaction.subscribe(listener),
|
||||
},
|
||||
resolve: ({ target: element }) => {
|
||||
const target = ctx.sidebarRight.commandTarget(element)
|
||||
if (target === undefined) return { status: 'blocked', reason: t('shortcut.noSession') }
|
||||
return { status: 'handled', run: () => { ctx.sidebarRight.openTabFromTarget('terminal', target) } }
|
||||
},
|
||||
}), 'ui-sidebar-terminal: shortcut')
|
||||
})
|
||||
ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-terminal.copy')
|
||||
ctx.effect(() => ctx.sidebarRightTabs.register({
|
||||
id, kind: 'terminal', multiple: true, priority: 'builtin', title: () => t('title'),
|
||||
@@ -69,6 +86,7 @@ export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.guide.entry', () => ctx.slots.register({
|
||||
name: 'sidebar.right.tab.guide.entry', key: id, locale: namespace,
|
||||
inject: (sessionId): TerminalGuideInjected => ({
|
||||
hooks: { shortcuts: ctx.shortcuts.catalog },
|
||||
loadShells: signal => ctx.webTerminals.launchShells(sessionId, signal),
|
||||
selectShell: (path) => { ctx.webTerminals.selectShell(path) },
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
|
||||
/** Simplified Chinese terminal copy. */
|
||||
export const zh = {
|
||||
'shortcut.noSession': '请先选择会话',
|
||||
recoveryFailed: '恢复终端失败:{message}', retryRecovery: '重试恢复终端',
|
||||
shell: '选择 Shell', shellLoading: '正在读取 Shell…', shellEmpty: '没有可用的 Shell', description: '在会话工作区运行命令',
|
||||
title: '终端', new: '新建终端', loading: '正在读取终端环境…', creating: '正在启动…',
|
||||
@@ -26,6 +27,7 @@ export const zh = {
|
||||
|
||||
/** English terminal copy. */
|
||||
export const en = {
|
||||
'shortcut.noSession': 'Select a session first',
|
||||
recoveryFailed: 'Terminal recovery failed: {message}', retryRecovery: 'Retry terminal recovery',
|
||||
shell: 'Choose shell', shellLoading: 'Loading shells…', shellEmpty: 'No shells available', description: 'Run commands in the Session workspace',
|
||||
title: 'Terminal', new: 'New terminal', loading: 'Reading terminal environment…', creating: 'Starting…',
|
||||
|
||||
@@ -20,6 +20,8 @@ import type { TerminalBodyInjected } from '../src/client/face.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({ Terminal: vi.fn() }))
|
||||
const SHORTCUT_CATALOG: readonly never[] = []
|
||||
|
||||
const renderedTerminal = vi.hoisted(() => vi.fn(() => null))
|
||||
vi.mock('../src/client/terminal.tsx', () => ({ TerminalBody: renderedTerminal }))
|
||||
|
||||
@@ -52,13 +54,14 @@ async function mountPlugin() {
|
||||
const openTabs = createSnapshotStore<readonly SidebarRightOpenTab[]>([])
|
||||
ctx.provide('webTerminals', terminals as never)
|
||||
ctx.provide('sidebarRight', {
|
||||
tabDomain: { occurrence }, openTabIn, tabsIn, openTabs,
|
||||
tabDomain: { occurrence }, openTabIn, tabsIn, openTabs, interaction: { subscribe: () => () => {} },
|
||||
registerCloseHandler: (kind: string, handler: SidebarRightCloseHandler) => { expect(kind).toBe('terminal'); closeHandler = handler; return () => { closeHandler = undefined } },
|
||||
} as never)
|
||||
ctx.provide('slots', {
|
||||
inject: (_name: string, register: () => () => void) => register(),
|
||||
register: (options: Omit<typeof entries[number], 'component'>, component: unknown) => { const entry = { ...options, component }; entries.push(entry); return () => { entries.splice(entries.indexOf(entry), 1) } },
|
||||
} as never)
|
||||
ctx.provide('shortcuts', { register: () => () => {}, catalog: { getSnapshot: () => SHORTCUT_CATALOG, subscribe: () => () => {} } } as never)
|
||||
ctx.provide('locale', {
|
||||
bind: () => (key: string) => key,
|
||||
register: (name: string, values: unknown) => { dictionaries.set(name, values); return () => { dictionaries.delete(name) } },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { TerminalLaunchShells } from '@deepseek-ai/dsh-api-terminal-controller/client'
|
||||
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { TerminalGuide, type TerminalGuideProps } from '../src/client/TerminalGuide.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
|
||||
@@ -13,18 +14,26 @@ const choices: TerminalLaunchShells = {
|
||||
selectedShell: '/bin/zsh',
|
||||
}
|
||||
|
||||
function mount(description: string | undefined = en.description) {
|
||||
type GuideShortcut = Pick<ShortcutCatalogEntry, 'keys' | 'aria'>
|
||||
|
||||
function mount(description: string | undefined = en.description, shortcut?: GuideShortcut) {
|
||||
const loadShells = vi.fn<TerminalGuideProps['loadShells']>(async () => choices)
|
||||
const selectShell = vi.fn()
|
||||
const openTab = vi.fn()
|
||||
let shortcuts = shortcut === undefined ? [] : [{ id: 'terminal.new', ...shortcut }]
|
||||
// The fixture supplies only the guide's consumed owner and framework shares.
|
||||
const props = {
|
||||
useShortcuts: (selector: (entries: typeof shortcuts) => unknown) => selector(shortcuts),
|
||||
entryId: 'new', kind: 'terminal', title: en.new, description, t: makeTranslate(en),
|
||||
useTabInfo: () => ({ tab: { actions: { openTab } } }), loadShells, selectShell,
|
||||
} as unknown as TerminalGuideProps
|
||||
const view = render(<TerminalGuide {...props} />)
|
||||
const open = () => fireEvent.click(view.getByRole('button', { name: en.shell }))
|
||||
return { view, props, open, loadShells, selectShell, openTab }
|
||||
const setShortcut = (next: GuideShortcut) => {
|
||||
shortcuts = [{ id: 'terminal.new', ...next }]
|
||||
view.rerender(<TerminalGuide {...props} />)
|
||||
}
|
||||
return { view, props, open, loadShells, selectShell, openTab, setShortcut }
|
||||
}
|
||||
|
||||
it('opens the default shell from the main button without querying a menu', () => {
|
||||
@@ -35,6 +44,28 @@ it('opens the default shell from the main button without querying a menu', () =>
|
||||
expect(h.view.container.querySelector('button button')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['hover', 'focus'] as const)('keeps the terminal shortcut inline without a tooltip on %s after rebinding', (trigger) => {
|
||||
const h = mount(en.description, { keys: ['Ctrl', '`'], aria: 'Control+`' })
|
||||
const button = h.view.getByRole('button', { name: /^New terminal/u })
|
||||
if (trigger === 'hover') fireEvent.mouseEnter(button)
|
||||
else fireEvent.focus(button)
|
||||
expect(h.view.queryByRole('tooltip')).toBeNull()
|
||||
expect(button.textContent).toContain('Ctrl`')
|
||||
expect(button.getAttribute('aria-keyshortcuts')).toBe('Control+`')
|
||||
|
||||
h.setShortcut({ keys: ['Alt', 'T'], aria: 'Alt+t' })
|
||||
expect(h.view.queryByRole('tooltip')).toBeNull()
|
||||
expect(button.textContent).toContain('AltT')
|
||||
expect(button.getAttribute('aria-keyshortcuts')).toBe('Alt+t')
|
||||
|
||||
h.setShortcut({ keys: [], aria: undefined })
|
||||
expect(h.view.queryByRole('tooltip')).toBeNull()
|
||||
expect(button.hasAttribute('aria-keyshortcuts')).toBe(false)
|
||||
if (trigger === 'hover') fireEvent.mouseLeave(button)
|
||||
else fireEvent.blur(button)
|
||||
expect(h.view.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
|
||||
it('loads on demand, remembers a selected shell before opening, and cancels on menu dismissal', async () => {
|
||||
const h = mount()
|
||||
h.open()
|
||||
|
||||
@@ -49,6 +49,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../ui-theme/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../shortcuts/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+15
@@ -4905,6 +4905,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-shortcuts':
|
||||
specifier: workspace:^
|
||||
version: link:../shortcuts
|
||||
'@deepseek-ai/dsh-client-store':
|
||||
specifier: workspace:^
|
||||
version: link:../store
|
||||
@@ -5071,6 +5074,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-shortcuts':
|
||||
specifier: workspace:^
|
||||
version: link:../shortcuts
|
||||
'@deepseek-ai/dsh-client-store':
|
||||
specifier: workspace:^
|
||||
version: link:../store
|
||||
@@ -5131,12 +5137,18 @@ importers:
|
||||
'@deepseek-ai/dsh-api-session-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/session-controller
|
||||
'@deepseek-ai/dsh-brand':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/brand
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-resources':
|
||||
specifier: workspace:^
|
||||
version: link:../resources
|
||||
'@deepseek-ai/dsh-client-shortcuts':
|
||||
specifier: workspace:^
|
||||
version: link:../shortcuts
|
||||
'@deepseek-ai/dsh-client-store':
|
||||
specifier: workspace:^
|
||||
version: link:../store
|
||||
@@ -5212,6 +5224,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-shortcuts':
|
||||
specifier: workspace:^
|
||||
version: link:../shortcuts
|
||||
'@deepseek-ai/dsh-client-store':
|
||||
specifier: workspace:^
|
||||
version: link:../store
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"apps/web/tests/shortcuts.e2e.ts",
|
||||
"apps/web/tests/shortcuts-desktop.e2e.ts",
|
||||
"apps/web/tests/shortcuts-workspace.e2e.ts",
|
||||
"apps/web/tests/shortcuts-panels.e2e.ts",
|
||||
"apps/web/tests/default-product-isolation.e2e.ts",
|
||||
"apps/web/tests/diff-context.e2e.ts",
|
||||
"apps/web/tests/scaffold.ts",
|
||||
|
||||
Reference in new issue
Block a user