feat(web): open Sidebar-previewed files in the default application
The document preview declares two child slots handing the previewed file and its Host path to other plugins: the header actions after its own controls, and the empty state of a file it cannot render, where Retry stood. Empty-state read failures now name their recourse: not-text and too-large offer the handoff, not-found and non-regular paths offer nothing, and the rest keep Retry. The unsupported-suffix empty state offers the same seat. ui-open-in-app fills both seats with an "Open" split button (default application, or the file manager reveal from its menu) and an empty-state "Open in default app" button. Both call the Session Remote the Host already publishes: canOpenWorkspacePath once per page decides whether they render, openWorkspacePath runs each gesture. Only the pressed control disables while its gesture settles; a failure announces once through a transient toast. The delivery cards keep their own opener this iteration. The design, slot pair, failure classification, and toast rule come from Yifffan's PR #4120.
This commit is contained in:
1 parent
8225e18d70
commit
dcf2b67896
42 files changed
+1194
-87
No files matched your search
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md
|
||||
2026-09-16-open-in-default-app-for-sidebar-files.md: c66d79618bea5c8bc190f50141169aea3090d833
|
||||
2026-09-16-open-in-default-app-for-sidebar-files.zh.md: 3a22f9abcc5f0c19af59efdf0be48b50facb2e40
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Open Sidebar-previewed files in the default application
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-open-in-default-app-for-sidebar-files.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Opening a file in its Host default application or showing it in the file manager existed only on the delivery cards, through routes and a fetch controller private to `ui-deliverables` and addressed by Session, declaration-event sequence, and file index. The right Sidebar's document preview offered no native handoff: a video, an archive, an office document, a file the reader rejected as non-text, or an oversized file dead-ended on an explanation, with Retry as the only control even where a second read could not help (issue #3932). Those files have no delivery event to address, so the existing plumbing could not serve a second surface. A first implementation (PR #4120, by Yifffan) added a new `ui-open-locally` package with its own Host routes and moved the delivery cards onto it; product review asked instead that file opening live in the existing open-in-app feature and accepted an intermediate state for this iteration.
|
||||
|
||||
## Decision
|
||||
|
||||
[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.md) owns the browser controls. The document preview of [ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.md) declares two Session-scoped list child slots whose owner props carry the previewed file and its absolute Host path, rendered only once the file's metadata reports that path: `sidebar.right.tab.document.actions` after the header's own controls, in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` where Retry would stand. The preview classifies an empty-state read failure: `not-text` and `too-large` are readable files the preview cannot render and offer the unpreviewable slot; `not-found` and `not-regular-file` offer only their explanation; every other failure keeps Retry because a second read may resolve it. The unsupported-suffix empty state, which never reads, offers the same slot.
|
||||
|
||||
ui-open-in-app fills both slots: an "Open ▾" split button whose main button opens the file in the default application and whose menu adds the file-manager reveal, and an "Open in default app" empty-state button. Neither control renders until the Host answered that a desktop exists. The controls call the Session Remote the Host already publishes, `session.canOpenWorkspacePath` once per page and `session.openWorkspacePath` per gesture with the absolute path and, for reveal, `action: 'reveal'`; no new Host route exists. Both controls share one gesture hook: only the control the user pressed disables while its call settles, a failed call resolves as a failure kind, and that control announces it once through a transient toast, so no failure state persists on a control.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A new `ui-open-locally` package with its own Host routes** (PR #4120) is where the slot pair, the failure classification, the split-button design, and the toast rule come from. It was set aside because the repository already has an open-in-app feature and a second package for opening things locally duplicates it; the routes it added re-verified through the Session filesystem what `openWorkspacePath` already accepts from any authenticated client.
|
||||
|
||||
**Host routes in `dsh-host-open-in-app`** serving desktop facts and a path open would let the Host name the file manager and distinguish a missing file from a failed launch. They would make the Host half depend on `sessionController`, `workspaceFiles`, and `fs` for two calls the Session Remote already publishes, so the Remote is used directly and the reveal label stays generic.
|
||||
|
||||
**Moving the delivery cards onto these controls now** would repeat the largest part of PR #4120, including the cross-plugin runtime import its review rejected and the recorded delivery scenarios it had to migrate. The cards keep their own routes this iteration.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every previewed file with a Host path can be opened locally from the Sidebar, including files that were never delivered. The delivery cards and the preview run two openers over one Host capability; unifying them, and deciding per surface between the full application list and the default application only, is the next step the product direction names. The reveal menu item says "Show file location" on every platform because the Session Remote reports no file manager. Default-application open carries the Host's file-association execution semantics for what it opens; the guard is the explicit user gesture on a file the Session can already read, and no new Host authority was granted.
|
||||
|
||||
Unit tests cover the failure classification, the preview's rendering of each recourse and its withholding of the seats before a Host path is known, the path controller's desktop read and gesture outcomes, both controls' visibility, busy isolation, and toast, and the plugin's registration and fiber-disposal removal. The keyless Web document-preview scenario drives the unsupported-suffix controls against a stubbed opener on POSIX hosts, and the seeded-history file-preview golden pins the header control.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
# Agent Note:用默认应用打开 Sidebar 预览的文件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-16-open-in-default-app-for-sidebar-files.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
用 Host 默认应用打开文件、或在文件管理器中显示文件,此前只存在于交付卡片上,走的是 `ui-deliverables` 私有的路由与 fetch 控制器,按 Session、声明事件序号和文件下标定位。右侧 Sidebar 的文档预览没有任何本地交接:视频、压缩包、office 文档、被读取器判为非文本的文件、过大的文件,都停在一行说明上,唯一的控件是重试,哪怕再读一次也无济于事(issue #3932)。这些文件没有交付事件可供定位,所以现有管线无法服务第二个界面。第一版实现(PR #4120,作者 Yifffan)新增了 `ui-open-locally` 包,自带 Host 路由,并把交付卡片也迁了过去;产品评审要求改为把文件打开放进现有的 open-in-app 功能,并接受本期先落一个中间态。
|
||||
|
||||
## 决定
|
||||
|
||||
[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.zh.md) 拥有浏览器侧的控件。[ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 的文档预览声明两个 Session 作用域的 list 子 slot,owner props 携带当前文件及其 Host 绝对路径,只在文件元数据报出该路径后渲染:`sidebar.right.tab.document.actions` 在每个显示头部的状态里跟在头部自有控件之后,`sidebar.right.tab.document.unpreviewable` 占据原本放重试的位置。预览对空态的读取失败分类:`not-text` 与 `too-large` 是可读但预览无法渲染的文件,提供 unpreviewable slot;`not-found` 与 `not-regular-file` 只给说明;其余失败保留重试,因为再读一次可能解决。不支持后缀的空态不发起读取,提供同一个 slot。
|
||||
|
||||
ui-open-in-app 填满这两个 slot:一个「打开 ▾」分体按钮,主按钮用默认应用打开文件,菜单再加文件管理器定位;一个空态的「使用默认应用打开」按钮。Host 回答有桌面之前,两个控件都不渲染。控件调用 Host 早已发布的 Session Remote:每页调用一次 `session.canOpenWorkspacePath`,每个手势调用一次 `session.openWorkspacePath`,带上绝对路径,定位时再带 `action: 'reveal'`;没有新增任何 Host 路由。两个控件共用一个手势 hook:只有用户按下的那个控件在调用结算期间禁用,失败的调用解析为一个失败种类,由该控件通过短暂 toast 提示一次,控件上不残留失败状态。
|
||||
|
||||
## 考虑过的备选
|
||||
|
||||
**新建自带 Host 路由的 `ui-open-locally` 包**(PR #4120)是这对 slot、失败分类、分体按钮设计和 toast 规则的来源。放弃它是因为仓库已经有 open-in-app 功能,再来一个负责本地打开的包属于重复;它新增的路由经由 Session 文件系统重新校验的,正是 `openWorkspacePath` 已经接受任意已认证客户端传入的东西。
|
||||
|
||||
**在 `dsh-host-open-in-app` 里加 Host 路由**提供桌面信息和按路径打开,能让 Host 报出文件管理器的名字,并区分文件不存在和启动失败。但这会让 Host 半边为了 Session Remote 已经发布的两个调用而依赖 `sessionController`、`workspaceFiles` 与 `fs`,所以直接使用 Remote,定位标签保持通用说法。
|
||||
|
||||
**现在就把交付卡片迁到这些控件上**会重做 PR #4120 里最大的那部分,包括评审否决的跨插件运行时导入,以及它不得不迁移的交付录制场景。本期卡片保留自己的路由。
|
||||
|
||||
## 后果
|
||||
|
||||
每个有 Host 路径的预览文件都能从 Sidebar 本地打开,包括从未交付过的文件。交付卡片和预览在同一项 Host 能力上跑着两套打开器;把它们统一起来,并按界面决定展示完整应用列表还是只展示默认应用,是产品方向点名的下一步。定位菜单项在所有平台都写「显示文件位置」,因为 Session Remote 不报告文件管理器。用默认应用打开带有 Host 文件关联的执行语义;护栏是用户对 Session 已可读文件的明确手势,没有授予任何新的 Host 权限。
|
||||
|
||||
单元测试覆盖失败分类、预览对每种出路的渲染以及在 Host 路径未知时对座位的保留、路径控制器的桌面读取与手势结果、两个控件的可见性、忙碌隔离与 toast,以及插件的注册与 fiber 释放时的移除。无密钥的 Web 文档预览场景在 POSIX 主机上用桩打开器驱动不支持后缀的控件,seeded-history 的文件预览 golden 固定住头部控件。
|
||||
@@ -1,11 +1,12 @@
|
||||
/** Keyless document-preview smoke through a real Session, Files tab, and shipped renderers. */
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
/** Keyless document-preview smoke through a real Session, Files tab, shipped renderers, and the default-application controls. */
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative } from 'node:path'
|
||||
import { delimiter, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { pdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
|
||||
import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
@@ -18,6 +19,8 @@ const PAGE_LINES = 64
|
||||
const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url))
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
const MODE = webSnapshotMode()
|
||||
/** The stubbed opener runs as a POSIX script; Windows keeps its real file associations out of the lane. */
|
||||
const STUB_OPENER = process.platform !== 'win32'
|
||||
const TINY_PNG = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
@@ -57,10 +60,34 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let outsideRoot: string | undefined
|
||||
let nativeRoot: string | undefined
|
||||
let openLog = ''
|
||||
const opened = async (): Promise<Array<{ path: string; action: 'open' | 'reveal' }>> =>
|
||||
(await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' })
|
||||
|
||||
beforeAll(async () => {
|
||||
outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-'))
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH })
|
||||
if (STUB_OPENER) {
|
||||
// Exercise the built Host through its actual OS command, replacing only the desktop application.
|
||||
nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-native-'))
|
||||
openLog = join(nativeRoot, 'opened.jsonl')
|
||||
await writeFile(openLog, '')
|
||||
const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
|
||||
await writeFile(join(nativeRoot, command), `#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2];
|
||||
const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open';
|
||||
fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n');
|
||||
`, { mode: 0o700 })
|
||||
vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`)
|
||||
}
|
||||
// The Open In rows carry the default-application controls; the SSH marker
|
||||
// keeps the host's application catalog empty, so the Session-header split
|
||||
// button stays off every platform while the pinned desktop serves the file controls.
|
||||
scaffold = await launchWebScaffold({
|
||||
replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH,
|
||||
openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]),
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
@@ -75,7 +102,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
try {
|
||||
await scaffold?.close()
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
if (outsideRoot !== undefined) await rm(outsideRoot, { recursive: true, force: true })
|
||||
if (nativeRoot !== undefined) await rm(nativeRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -474,13 +503,39 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
|
||||
await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'clip.mp4', exact: true }).click()
|
||||
const unsupported = column.locator('[data-textpreview-state="unsupported"]')
|
||||
await unsupported.waitFor({ timeout: 15_000 })
|
||||
const unsupportedLine = await unsupported.locator('[data-textpreview-unsupported]').innerText()
|
||||
const unsupportedLine = await unsupported.locator('[data-textpreview-unsupported] p').innerText()
|
||||
expect(unsupportedLine).toContain('Preview is not available for this file type yet.')
|
||||
expect(await unsupported.locator('[data-textpreview-path]').innerText()).toContain('clip.mp4')
|
||||
expect(await unsupported.locator('[data-document-viewer-menu]').count()).toBe(0)
|
||||
expect(await unsupported.locator('[data-textpreview-tool="reload"]').count()).toBe(0)
|
||||
// The default-application controls land once the Host answered the pinned desktop read.
|
||||
const headerOpen = unsupported.locator('[data-open-path-open]')
|
||||
await headerOpen.waitFor({ timeout: 15_000 })
|
||||
const emptyOpen = unsupported.locator('[data-textpreview-unsupported] [data-open-path-unpreviewable]')
|
||||
await emptyOpen.waitFor({ timeout: 15_000 })
|
||||
await successShot(page, 'unsupported')
|
||||
sections.push(['## Unviewable binary', '', '- State: unsupported', `- Line: ${unsupportedLine.trim()}`].join('\n'))
|
||||
sections.push([
|
||||
'## Unviewable binary', '',
|
||||
'- State: unsupported',
|
||||
`- Line: ${unsupportedLine.trim()}`,
|
||||
`- Header control: ${await headerOpen.innerText()}`,
|
||||
`- Empty-state control: ${await emptyOpen.innerText()}`,
|
||||
].join('\n'))
|
||||
if (STUB_OPENER) {
|
||||
// Real Host gestures against the stubbed opener: default application from the empty state, reveal from the header menu.
|
||||
const clip = join(cwd, 'clip.mp4')
|
||||
await emptyOpen.click()
|
||||
await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(1)
|
||||
await unsupported.locator('[data-open-path-more]').click()
|
||||
await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click()
|
||||
await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(2)
|
||||
const gestures = await opened()
|
||||
expect(gestures[0]).toEqual({ path: clip, action: 'open' })
|
||||
expect(gestures[1]?.action).toBe('reveal')
|
||||
expect([clip, cwd]).toContain(gestures[1]?.path)
|
||||
// Gesture facts stay out of the golden: the stub does not run on Windows.
|
||||
expect(await page.getByRole('alert').count()).toBe(0)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Pin desktop availability so native-open controls render identically in headless CI.
|
||||
- id: session-controller
|
||||
config:
|
||||
nativeOpen: true
|
||||
@@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -206,8 +207,15 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
let seededThroughSeq = -1
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold(process.platform === 'win32' ? {} : {
|
||||
extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)),
|
||||
// The POSIX terminal fixture stays off Windows; the pinned desktop applies
|
||||
// everywhere. The Open In rows carry the document header's file controls,
|
||||
// and the SSH marker keeps the application catalog empty so the
|
||||
// Session-header split button stays out of every golden.
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: fileURLToPath(new URL(process.platform === 'win32'
|
||||
? './fixtures/native-open-on.patch.yml'
|
||||
: './seeded-history.overlay.yml', import.meta.url)),
|
||||
openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]),
|
||||
})
|
||||
// Composer recording uses a child workspace; seedSession owns the scaffold root.
|
||||
const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd
|
||||
@@ -447,6 +455,8 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
await expect.poll(() => path.textContent()).toBe(absolutePath)
|
||||
expect(await path.getAttribute('title')).toBe(absolutePath)
|
||||
await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
|
||||
// The header's "Open" control lands once the Host answered the desktop read.
|
||||
await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 })
|
||||
const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE)
|
||||
// Put the column back so the later goldens see the default frame.
|
||||
@@ -737,12 +747,14 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
const column = page.locator('[data-rightbar-col]')
|
||||
await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
|
||||
const tabId = await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab')
|
||||
await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 })
|
||||
const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
|
||||
expect(await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab')).toBe(tabId)
|
||||
await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 })
|
||||
const restoredPreview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
|
||||
expect(restoredPreview).toBe(preview)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# The cold-resume lane's own overlay: the bounded terminal fixture the terminal
|
||||
# replacement case drives, plus a pinned desktop so the document header's
|
||||
# "Open" control lands in the file-preview golden on every platform.
|
||||
- id: terminal-controller
|
||||
config:
|
||||
maxTerminals: 2
|
||||
unattendedTimeoutMs: 2000
|
||||
activityPollIntervalMs: 100
|
||||
cleanupRetryMs: 100
|
||||
shellCandidates: [/bin/bash, /bin/sh]
|
||||
shell:
|
||||
path: /bin/bash
|
||||
name: bash
|
||||
args: [--noprofile, --norc, -i]
|
||||
- id: session-controller
|
||||
config:
|
||||
nativeOpen: true
|
||||
@@ -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/README.md
|
||||
README.md: d3aee40a79e5d57029d8af42c333abcd5a9112a9
|
||||
README.zh.md: a3776a2c1034cb747fb191c206b52d570ec47aaf
|
||||
README.md: ff0acc7da1be69e0fb76ed3ce72d31d490dcd32c
|
||||
README.zh.md: 22ab17872ae3a0f62de05bc7ace41fd92332b355
|
||||
@@ -78,7 +78,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
|
||||
| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — |
|
||||
| [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — |
|
||||
| [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the local Desktop or Host OS chooser | — |
|
||||
| [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — |
|
||||
| [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file | — |
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ kind: "package-group"
|
||||
| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — |
|
||||
| [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — |
|
||||
| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动本地 Desktop 或 Host OS 选择器的原生目录选择界面 | — |
|
||||
| [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮 | — |
|
||||
| [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮,以及文档预览里用默认应用打开单个文件的控件 | — |
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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-open-in-app/README.md
|
||||
README.md: d242d5a84dffbf0895485f7c79eafd5e332fe446
|
||||
README.zh.md: e954b7c71e8c4dbd1b19a041aafb7fe154631fbb
|
||||
README.md: 91d259c73ad73460bca151d9e4cacfb6338c62ab
|
||||
README.zh.md: 30d28f0681c6abfd7b3ea3ab3dc341df7fb3bdaf
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Web Session-header \"Open In...\" split button: launches the remembered application on the session workspace directory and lists every application the host probed as installed."
|
||||
description: "Web \"Open In...\" controls: the Session-header split button launching the remembered application on the workspace directory, and the document preview's default-application open and reveal controls for one file."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
This package provides the browser surface of the open-in-app feature: a Session-header split button whose main button opens the current session's workspace directory (the summary's `cwd`) in the remembered application, and whose chevron lists every catalog application the host probed as installed. Availability, icons, and launches come from the host routes of [`dsh-host-open-in-app`](../../host/open-in-app/README.md); mount the two packages together. A session without a workspace directory, or a host where nothing nameable is installed, renders no button at all.
|
||||
This package provides the browser surface of the open-in-app feature. A Session-header split button opens the current session's workspace directory (the summary's `cwd`) in the remembered application, and its chevron lists every catalog application the host probed as installed; availability, icons, and launches come from the host routes of [`dsh-host-open-in-app`](../../host/open-in-app/README.md), so mount the two packages together. In the right Sidebar's document preview, an "Open" split button and an empty-state button open the previewed file in its default application or show its location, through the Session Remote. A host without the capability renders none of these controls.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,12 +25,14 @@ This package provides the browser surface of the open-in-app feature: a Session-
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); the pair composes the whole feature in two cordis.yml rows and this row takes no config. The Session header grows an "Open In..." split button whenever the host probed at least one installed catalog application and the session has a known workspace directory.
|
||||
Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); the pair composes the whole feature in two cordis.yml rows and this row takes no config. The Session header grows an "Open In..." split button whenever the host probed at least one installed catalog application and the session has a known workspace directory. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) grows its file controls whenever the Host reports a desktop through the Session Remote's `session.canOpenWorkspacePath`; removing this row removes every control at once.
|
||||
|
||||
### What to expect
|
||||
|
||||
The main button shows the remembered application's icon — the real application icon wherever the host extracts one (macOS bundle icons, Windows executable icons, Linux theme icons), a generic glyph where it serves none — and a design-system tooltip ("Open locally"); clicking launches immediately. The chevron opens a dense menu of the installed applications with the remembered one marked by a filled row. Availability is read once per page from the host; the last chosen application persists in the browser (`dsh.open-in-app.choice`), and a choice that is no longer installed falls back to the first available entry. A launch that finishes quickly leaves the button untouched — the dimmed busy treatment appears only after 250 ms in flight — and a failed launch shows the error tooltip and a red outline for two seconds. All copy lives in the bilingual `open-in-app` locale namespace; an application id the dictionaries cannot name is not offered.
|
||||
|
||||
In the document preview, the header's "Open ▾" split button opens the previewed file in the Host's default application from its main button, and its menu offers that action and "Show file location" (the file manager's reveal). A file the preview cannot render, whether an unsupported suffix such as video or an archive or a file the reader rejected as non-text or oversized, shows an "Open in default app" button in its empty state where Retry would otherwise stand. Both controls hand the Host the absolute path the file's metadata reports, appear only after the Host answered that a desktop is available, disable only while their own gesture settles, and announce a failed gesture once through a transient toast ("Could not open. Try again." or "Could not show the file location. Try again."); nothing stays on the control afterwards.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
@@ -39,7 +41,9 @@ The main button shows the remembered application's icon — the real application
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Route paths and wire payload types are inlined from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. The node half is an empty `apply` that keeps the plugin on the host roster.
|
||||
The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Route paths and wire payload types are inlined from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise.
|
||||
|
||||
The file controls register on the document preview's `sidebar.right.tab.document.actions` and `sidebar.right.tab.document.unpreviewable` child slots, whose owner props carry the previewed file and its absolute Host path. A second page-lifetime controller ([`src/client/open-path.ts`](src/client/open-path.ts)) reads the Session Remote's `session.canOpenWorkspacePath` once per page into a desktop snapshot store both controls share, and runs each gesture through `session.openWorkspacePath` with the path and, for reveal, `action: 'reveal'`; a refused or rejected call resolves as the failure kind the control announces. The controls share one gesture hook that owns the pending flag and the toast, so a gesture from one control never restyles the other. The node half is an empty `apply` that keeps the plugin on the host roster.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -50,6 +54,8 @@ The plugin registers the split button on `conversation.session.header.utilities`
|
||||
|
||||
- [dsh-host-open-in-app](../../host/open-in-app/README.md) — the host routes serving availability, icons, and launches, and the catalog behind them.
|
||||
- [dsh-session-log-export](../../session-query/session-log-export/README.md) — the sibling Session-header action.
|
||||
- [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.md) — the document preview declaring the header and empty-state child slots the file controls occupy.
|
||||
- [ui-deliverables](../ui-deliverables/README.md) — the delivery cards, which still open declared files through their own routes.
|
||||
- [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots.
|
||||
|
||||
-----
|
||||
@@ -68,7 +74,9 @@ None; this package neither assembles nor sends a provider request.
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **The dictionaries gate the menu.** A host catalog extension without a matching `app.<id>` entry in both dictionaries stays invisible instead of showing a raw id; extending the catalog means extending [`dsh-host-open-in-app`](../../host/open-in-app/README.md) and this package's locales together.
|
||||
- **Availability is read once per page.** An application installed while the page is open appears after a reload (and, host-side, after a host restart).
|
||||
- **Availability is read once per page.** An application installed while the page is open appears after a reload (and, host-side, after a host restart); the desktop answer behind the file controls is read once per page as well.
|
||||
- **One reveal label for every platform.** The Session Remote reports whether a desktop exists, not which file manager it runs, so the menu says "Show file location" rather than naming Finder or File Explorer as the delivery cards do.
|
||||
- **The delivery cards keep their own opener.** [`ui-deliverables`](../ui-deliverables/README.md) still opens declared files through its own Session-and-event routes; folding those cards onto the file controls here is deferred to the [Agent Note](../../../.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md).
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
@@ -76,8 +84,8 @@ None; this package neither assembles nor sends a provider request.
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
The feature-level decisions, including the split into the host package and this surface, are recorded in the [promotion Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md).
|
||||
The feature-level decisions, including the split into the host package and this surface, are recorded in the [promotion Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md); the document preview's file controls are recorded in the [default-application Agent Note](../../../.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md).
|
||||
|
||||
</details>
|
||||
|
||||
**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and one header-slot entry whose disposal the HMR-safety spec proves; availability and choice live in the controller's snapshot stores with no second copy to diverge.
|
||||
**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and three slot entries whose disposal the HMR-safety spec proves; application availability, the choice, and the desktop answer live in the controllers' snapshot stores with no second copy to diverge.
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Web 会话头部 \"Open In...\" 分体按钮:在记住的应用中打开会话 workspace 目录,并列出主机探测到已安装的全部应用。"
|
||||
description: "Web \"Open In...\" 控件:会话头部在记住的应用中打开 workspace 目录的分体按钮,以及文档预览里用默认应用打开、显示单个文件位置的控件。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
本包提供 open-in-app 功能的浏览器表面:会话头部的一个分体按钮,主按钮在记住的应用中打开当前会话的 workspace 目录(会话摘要的 `cwd`),下拉箭头列出主机探测到已安装的全部 catalog 应用。可用性、图标与启动均来自 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 的主机路由;两个包应一起挂载。没有 workspace 目录的会话、或没装任何可命名应用的主机,完全不渲染按钮。
|
||||
本包提供 open-in-app 功能的浏览器表面。会话头部的分体按钮在记住的应用中打开当前会话的 workspace 目录(会话摘要的 `cwd`),下拉箭头列出主机探测到已安装的全部 catalog 应用;可用性、图标与启动均来自 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 的主机路由,所以两个包要一起挂载。在右侧 Sidebar 的文档预览里,一个「打开」分体按钮和一个空态按钮经由 Session Remote 用默认应用打开当前文件或显示其位置。主机没有这项能力时,这些控件一个都不渲染。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,12 +25,14 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂进 Web 组合;这对包用两行 cordis.yml 组成完整功能,本行不接受任何配置。只要主机探测到至少一个已安装的 catalog 应用且会话有已知的 workspace 目录,会话头部就会出现 "Open In..." 分体按钮。
|
||||
把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂进 Web 组合;这对包用两行 cordis.yml 组成完整功能,本行不接受任何配置。只要主机探测到至少一个已安装的 catalog 应用且会话有已知的 workspace 目录,会话头部就会出现 "Open In..." 分体按钮。只要 Host 通过 Session Remote 的 `session.canOpenWorkspacePath` 报告有桌面,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览就会长出文件控件;删掉这一行会一次去掉所有控件。
|
||||
|
||||
### 预期行为
|
||||
|
||||
主按钮显示记住的应用图标——凡主机能提取的都是应用真实图标(macOS bundle 图标、Windows 可执行文件图标、Linux 主题图标),提取不到时是通用占位图形——并带设计系统 tooltip(「在本地打开」);点击立即启动。下拉箭头打开已安装应用的紧凑菜单,记住的条目以整行填充标记。可用性每页读取一次;上次选择的应用持久化在浏览器中(`dsh.open-in-app.choice`),不再安装的选择回退到第一个可用条目。快速完成的启动不改变按钮外观——变暗的等待态只在飞行超过 250 毫秒后出现——失败的启动显示错误 tooltip 与红色描边两秒。所有文案在双语 `open-in-app` locale 命名空间中;词典无法命名的应用 id 不会被提供。
|
||||
|
||||
在文档预览里,头部的「打开 ▾」分体按钮的主按钮用 Host 默认应用打开当前文件,菜单提供同一动作和「显示文件位置」(文件管理器的定位)。预览无法渲染的文件,无论是视频、压缩包这类不支持的后缀,还是被读取器判为非文本或过大的文件,都会在空态里原本放重试的位置显示「使用默认应用打开」按钮。两个控件都把文件元数据报出的绝对路径交给 Host,只在 Host 回答有桌面之后出现,只在自己的手势结算期间禁用,失败时通过短暂 toast 提示一次(「打开失败,请重试」或「无法显示文件位置,请重试」),之后控件上不残留任何状态。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
@@ -39,7 +41,9 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现内幕——点击展开</summary>
|
||||
|
||||
插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。路由路径与 wire 载荷类型从主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared` 内联。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。
|
||||
插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。路由路径与 wire 载荷类型从主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared` 内联。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。
|
||||
|
||||
文件控件注册在文档预览的 `sidebar.right.tab.document.actions` 与 `sidebar.right.tab.document.unpreviewable` 子 slot 上,owner props 携带当前文件及其 Host 绝对路径。第二个页面生命周期的 controller([`src/client/open-path.ts`](src/client/open-path.ts))每页读取一次 Session Remote 的 `session.canOpenWorkspacePath`,写进两个控件共用的桌面 snapshot store,并把每个手势交给 `session.openWorkspacePath`,带上路径,定位时再带 `action: 'reveal'`;被拒绝或抛错的调用解析为控件要提示的失败种类。两个控件共用一个手势 hook,由它持有 pending 标志和 toast,所以一个控件的手势不会改变另一个控件的样子。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -50,6 +54,8 @@ kind: "package-reference"
|
||||
|
||||
- [dsh-host-open-in-app](../../host/open-in-app/README.zh.md)——提供可用性、图标与启动的主机路由,及其背后的目录。
|
||||
- [dsh-session-log-export](../../session-query/session-log-export/README.zh.md)——会话头部的姊妹动作。
|
||||
- [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.zh.md)——声明文件控件所占头部与空态子 slot 的文档预览。
|
||||
- [ui-deliverables](../ui-deliverables/README.zh.md)——交付卡片,仍通过自己的路由打开声明过的文件。
|
||||
- [Web client 架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册 slot。
|
||||
|
||||
-----
|
||||
@@ -68,7 +74,9 @@ kind: "package-reference"
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **词典把守菜单。** 主机目录的新条目若在两份词典中没有对应的 `app.<id>` 条目,将保持不可见而不是显示裸 id;扩展目录意味着同时扩展 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 与本包的 locale。
|
||||
- **可用性每页只读一次。** 页面打开期间安装的应用要重新加载页面后才出现(主机侧还需主机重启)。
|
||||
- **可用性每页只读一次。** 页面打开期间安装的应用要重新加载页面后才出现(主机侧还需主机重启);文件控件背后的桌面回答同样每页只读一次。
|
||||
- **所有平台共用一个定位标签。** Session Remote 只报告有没有桌面,不报告它跑的是哪个文件管理器,所以菜单写「显示文件位置」,而不像交付卡片那样点名访达或文件资源管理器。
|
||||
- **交付卡片保留自己的打开器。** [`ui-deliverables`](../ui-deliverables/README.zh.md) 仍通过自己按 Session 与事件定位的路由打开声明过的文件;把这些卡片并到这里的文件控件上,延后到 [Agent Note](../../../.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md) 记录的后续工作。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
@@ -76,8 +84,8 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>维护者工作语境——点击展开</summary>
|
||||
|
||||
功能层面的各项决定,包括拆分为主机包与本表面包,记录在[转正 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md)。
|
||||
功能层面的各项决定,包括拆分为主机包与本表面包,记录在[转正 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md);文档预览的文件控件记录在[默认应用 Agent Note](../../../.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md)。
|
||||
|
||||
</details>
|
||||
|
||||
**运行时不变式:** 不发布伴生入口。插件注册一个词典 effect 和一个 header slot 条目,HMR 安全性 spec 证明二者都会在资源释放时撤销;可用性与选择存储在控制器的快照存储中,不存在可能与之分歧的第二份副本。
|
||||
**运行时不变式:** 不发布伴生入口。插件注册一个词典 effect 和三个 slot 条目,HMR 安全性 spec 证明它们都会在资源释放时撤销;应用可用性、选择与桌面回答存储在控制器的快照存储中,不存在可能与之分歧的第二份副本。
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-open-in-app",
|
||||
"description": "Web Session-header \"Open In...\" split button opening the session workspace directory in a locally installed application",
|
||||
"description": "Web \"Open In...\" controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file",
|
||||
"version": "0.1.6-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -38,10 +38,14 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-api-gateway",
|
||||
"@deepseek-ai/dsh-api-remotes",
|
||||
"@deepseek-ai/dsh-api-session-controller",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-renderer",
|
||||
"@deepseek-ai/dsh-client-ui-session"
|
||||
"@deepseek-ai/dsh-client-ui-session",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-documentpreview"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -51,6 +55,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
@@ -59,6 +65,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-open-in-app": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/* Sized to sit in the document header's 28px control row beside its round
|
||||
icon buttons: a hairline pill with a 12px label and a narrow chevron half. */
|
||||
|
||||
.menuAnchor {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.split {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
box-sizing: border-box;
|
||||
height: 28px;
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--dsw-alias-border-l3);
|
||||
border-radius: 14px;
|
||||
font-family: var(--dsw-font-family);
|
||||
}
|
||||
|
||||
.main,
|
||||
.chevron {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 5px 8px 5px 10px;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
padding: 5px 6px 5px 4px;
|
||||
border-left: 0.5px solid var(--dsw-alias-border-l3);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.main:hover:not(:disabled),
|
||||
.main:focus-visible,
|
||||
.chevron:hover:not(:disabled),
|
||||
.chevron:focus-visible {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Pending disables clicks without a restyle; only hover feedback pauses. */
|
||||
.main:disabled,
|
||||
.chevron:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Document-header split button: the main button opens the previewed file in
|
||||
* its default application, the chevron's menu adds the file-manager reveal.
|
||||
* Both path controls share the gesture hook declared here.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
IconChevronDownOutline14, IconFolderOpenOutline16, IconRightUpOutline16, Menu, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client'
|
||||
import type { OpenInAppPathAction, OpenInAppPathFailure } from './open-path.ts'
|
||||
import { useOpenFailureToast } from './open-failure-toast.tsx'
|
||||
import type { NS } from './locales.ts'
|
||||
import css from './OpenPathAction.module.css'
|
||||
|
||||
/** Desktop availability and the gesture carrier injected into both path controls. */
|
||||
export interface OpenPathInjected {
|
||||
hooks: {
|
||||
openInAppDesktop: ObservableSnapshot<boolean | null>
|
||||
}
|
||||
loadDesktop: () => Promise<void>
|
||||
openPath: (path: string, action: OpenInAppPathAction) => Promise<OpenInAppPathFailure | null>
|
||||
}
|
||||
|
||||
/** Full props of the document-header contribution. */
|
||||
export type OpenPathActionProps =
|
||||
PropsRuntime<'sidebar.right.tab.document.actions'>
|
||||
& PropsLocale<typeof NS>
|
||||
& InjectFace<OpenPathInjected>
|
||||
|
||||
/** What one path control needs from its props: the file, the injected face, and copy. */
|
||||
type PathGestureProps = Pick<OpenPathActionProps, 'absolutePath' | 'useOpenInAppDesktop' | 'loadDesktop' | 'openPath'> & {
|
||||
t: TranslateNS<typeof NS>
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop availability, this control's own pending state, and a gesture
|
||||
* runner announcing failures through this control's toast. Only the control
|
||||
* that ran the gesture disables while it settles; failure never persists on
|
||||
* the control.
|
||||
* @param props - the file's Host path, the injected face, and copy.
|
||||
* @returns availability, pending, the toast to render, and the runner.
|
||||
*/
|
||||
export function usePathGesture({ absolutePath, useOpenInAppDesktop, loadDesktop, openPath, t }: PathGestureProps): {
|
||||
available: boolean
|
||||
pending: boolean
|
||||
toast: ReactNode
|
||||
act: (action: OpenInAppPathAction) => void
|
||||
} {
|
||||
const desktop = useOpenInAppDesktop(value => value)
|
||||
const [pending, setPending] = useState(false)
|
||||
const { toast, show } = useOpenFailureToast()
|
||||
useEffect(() => {
|
||||
if (desktop === null) void loadDesktop()
|
||||
}, [desktop, loadDesktop])
|
||||
return {
|
||||
available: desktop === true,
|
||||
pending,
|
||||
toast,
|
||||
act: (action) => {
|
||||
setPending(true)
|
||||
void openPath(absolutePath, action).then((failure) => {
|
||||
if (failure !== null) show(t(`path.${failure}`))
|
||||
}).finally(() => { setPending(false) })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the split button, or nothing until the Host reports a desktop.
|
||||
* @param props - the previewed file, the injected face, and copy.
|
||||
* @returns the split button with its menu and toast, or null.
|
||||
*/
|
||||
export function OpenPathAction(props: OpenPathActionProps): ReactNode {
|
||||
const { t } = props
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const { available, pending, toast, act } = usePathGesture(props)
|
||||
if (!available) return null
|
||||
const run = (action: OpenInAppPathAction): void => {
|
||||
setMenuOpen(false)
|
||||
act(action)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Menu
|
||||
className={css.menuAnchor}
|
||||
open={menuOpen && !pending}
|
||||
autoFocus
|
||||
portal
|
||||
dense
|
||||
align="end"
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={[
|
||||
{ id: 'open', icon: <IconRightUpOutline16 size={16} className={css.menuIcon} />, label: t('path.defaultApp') },
|
||||
{ id: 'reveal', icon: <IconFolderOpenOutline16 />, label: t('path.reveal') },
|
||||
]}
|
||||
onSelect={(id) => { run(id === 'reveal' ? 'reveal' : 'open') }}
|
||||
anchor={(
|
||||
<div className={css.split} data-open-path data-state={pending ? 'busy' : 'idle'}>
|
||||
<Tooltip label={t('path.open.tooltip')} side="bottom" delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.main}
|
||||
disabled={pending}
|
||||
data-open-path-open
|
||||
onClick={() => { run('open') }}
|
||||
>
|
||||
{t('path.open')}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className={css.chevron}
|
||||
disabled={pending}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen && !pending}
|
||||
aria-label={t('path.more')}
|
||||
data-open-path-more
|
||||
onClick={() => { setMenuOpen(value => !value) }}
|
||||
>
|
||||
<IconChevronDownOutline14 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{toast}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* The empty state's capsule, sized like the preview's own Retry
|
||||
(ui-sidebar-documentpreview TextPreview `.retry`) so the two recourses read
|
||||
as one family. */
|
||||
.open {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 12px 0 14px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: var(--dsh-content-font-size-secondary, 13px);
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
border: 0.5px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.open:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Pending disables clicks without a restyle; only hover feedback pauses. */
|
||||
.open:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Empty-state contribution: open a file the document preview cannot render in its default application. */
|
||||
import type { ReactNode } from 'react'
|
||||
import { IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client'
|
||||
import type { NS } from './locales.ts'
|
||||
import { usePathGesture, type OpenPathInjected } from './OpenPathAction.tsx'
|
||||
import css from './OpenPathEmptyAction.module.css'
|
||||
|
||||
/** Full props of the unpreviewable empty-state contribution. */
|
||||
export type OpenPathEmptyActionProps =
|
||||
PropsRuntime<'sidebar.right.tab.document.unpreviewable'>
|
||||
& PropsLocale<typeof NS>
|
||||
& InjectFace<OpenPathInjected>
|
||||
|
||||
/**
|
||||
* Render the default-application open button, or nothing until the Host reports a desktop.
|
||||
* @param props - the unpreviewable file, the injected face, and copy.
|
||||
* @returns the open button and its toast, or null.
|
||||
*/
|
||||
export function OpenPathEmptyAction(props: OpenPathEmptyActionProps): ReactNode {
|
||||
const { t } = props
|
||||
const { available, pending, toast, act } = usePathGesture(props)
|
||||
if (!available) return null
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={css.open}
|
||||
disabled={pending}
|
||||
data-open-path-unpreviewable
|
||||
onClick={() => { act('open') }}
|
||||
>
|
||||
{t('path.unpreviewable')}
|
||||
<IconRightUpOutline16 size={14} />
|
||||
</button>
|
||||
{toast}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,28 @@
|
||||
/**
|
||||
* Browser half of open-in-app: one Session-header split button opening the
|
||||
* session's workspace directory (the summary's `cwd`) in the remembered
|
||||
* installed application. Availability arrives once per page from the host
|
||||
* apps route; the last choice persists in the browser through the controller's
|
||||
* persisted snapshot store.
|
||||
* installed application, and the document preview's default-application
|
||||
* controls opening one previewed file. Application availability arrives once
|
||||
* per page from the host apps route and the last choice persists in the
|
||||
* browser through the controller's persisted snapshot store; desktop
|
||||
* availability for file paths arrives once per page from the Session Remote,
|
||||
* which also runs the open and reveal gestures.
|
||||
*/
|
||||
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-api-session-controller/remote'
|
||||
import { OPEN_IN_APP_ICON_PREFIX } from '@deepseek-ai/dsh-host-open-in-app/shared'
|
||||
import { OpenInAppController } from './controller.ts'
|
||||
import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx'
|
||||
import { OpenInAppPathController } from './open-path.ts'
|
||||
import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx'
|
||||
import { OpenPathEmptyAction } from './OpenPathEmptyAction.tsx'
|
||||
import { en, NS, zh, type OpenInAppKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
@@ -23,17 +33,22 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
|
||||
export type { OpenInAppActionInjected, OpenInAppActionProps } from './OpenInAppAction.tsx'
|
||||
export type { OpenInAppPathAction, OpenInAppPathFailure, OpenInAppPathRemote } from './open-path.ts'
|
||||
export type { OpenPathActionProps, OpenPathInjected } from './OpenPathAction.tsx'
|
||||
export type { OpenPathEmptyActionProps } from './OpenPathEmptyAction.tsx'
|
||||
|
||||
/** Required services for locale registration and the header-slot contribution. */
|
||||
export const inject = ['sessions', 'slots', 'locale']
|
||||
/** Required services: sessions, the slot registry, copy, and the Remote carrier with its `session` namespace. */
|
||||
export const inject = ['sessions', 'slots', 'locale', 'remote', 'remote.session']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the dictionaries and the header split button.
|
||||
* Client plugin body: register the dictionaries, the header split button, and
|
||||
* the document preview's path controls.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const controller = new OpenInAppController()
|
||||
void controller.load()
|
||||
const paths = new OpenInAppPathController(ctx.remote)
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'open-in-app: dictionaries')
|
||||
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
||||
name: 'conversation.session.header.utilities',
|
||||
@@ -50,4 +65,21 @@ export function apply(ctx: ClientContext): void {
|
||||
iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX}/${appId}`,
|
||||
}),
|
||||
}, OpenInAppAction))
|
||||
const pathInjected = (): OpenPathInjected => ({
|
||||
hooks: { openInAppDesktop: paths.desktop },
|
||||
loadDesktop: () => paths.load(),
|
||||
openPath: (path, action) => paths.openPath(path, action),
|
||||
})
|
||||
ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({
|
||||
name: 'sidebar.right.tab.document.actions',
|
||||
id: 'open-in-app',
|
||||
locale: NS,
|
||||
inject: pathInjected,
|
||||
}, OpenPathAction))
|
||||
ctx.slots.inject('sidebar.right.tab.document.unpreviewable', () => ctx.slots.register({
|
||||
name: 'sidebar.right.tab.document.unpreviewable',
|
||||
id: 'open-in-app',
|
||||
locale: NS,
|
||||
inject: pathInjected,
|
||||
}, OpenPathEmptyAction))
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/** `open-in-app` namespace dictionaries. */
|
||||
/** `open-in-app` namespace dictionaries: the workspace split button and the document-preview path controls. */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'open-in-app'
|
||||
@@ -44,6 +44,14 @@ export const zh = {
|
||||
'open.error': '打开失败',
|
||||
'menu.toggle': '选择打开方式',
|
||||
'menu.aria': '打开方式',
|
||||
'path.open': '打开',
|
||||
'path.open.tooltip': '用默认应用打开',
|
||||
'path.more': '更多打开方式',
|
||||
'path.defaultApp': '用默认应用打开',
|
||||
'path.reveal': '显示文件位置',
|
||||
'path.unpreviewable': '使用默认应用打开',
|
||||
'path.openError': '打开失败,请重试',
|
||||
'path.revealError': '无法显示文件位置,请重试',
|
||||
...PRODUCT_NAMES,
|
||||
'app.finder': '访达',
|
||||
'app.explorer': '文件资源管理器',
|
||||
@@ -58,6 +66,14 @@ export const en: Record<OpenInAppKey, string> = {
|
||||
'open.error': 'Failed to open',
|
||||
'menu.toggle': 'Choose an app to open in',
|
||||
'menu.aria': 'Open in',
|
||||
'path.open': 'Open',
|
||||
'path.open.tooltip': 'Open in the default app',
|
||||
'path.more': 'More ways to open',
|
||||
'path.defaultApp': 'Open in default app',
|
||||
'path.reveal': 'Show file location',
|
||||
'path.unpreviewable': 'Open in default app',
|
||||
'path.openError': 'Could not open. Try again.',
|
||||
'path.revealError': 'Could not show the file location. Try again.',
|
||||
...PRODUCT_NAMES,
|
||||
'app.finder': 'Finder',
|
||||
'app.explorer': 'File Explorer',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Per-control failure banner for path gestures. */
|
||||
import { useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { IconWarningOutline16, Toast } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/**
|
||||
* Transient failure banner owned by the control that initiated the gesture,
|
||||
* so one failed request announces once, from the control the user pressed.
|
||||
* @returns `toast` to render inside the control and `show` to announce one
|
||||
* failure with resolved copy; announcing again replays the banner.
|
||||
*/
|
||||
export function useOpenFailureToast(): { toast: ReactNode; show: (text: string) => void } {
|
||||
// The seq keys the banner so the same failure text replays instead of staying silently in place.
|
||||
const seq = useRef(0)
|
||||
const [banner, setBanner] = useState<{ seq: number; text: string } | null>(null)
|
||||
return {
|
||||
toast: banner === null
|
||||
? null
|
||||
: <Toast key={banner.seq} text={banner.text} icon={<IconWarningOutline16 />} onDone={() => { setBanner(null) }} />,
|
||||
show: (text) => {
|
||||
seq.current += 1
|
||||
setBanner({ seq: seq.current, text })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Default-application actions on one Host file path, over the Session Remote:
|
||||
* whether this Host can hand a path to a desktop, and the open or reveal call.
|
||||
* The desktop answer is read once per page; a failed read renders no control.
|
||||
*/
|
||||
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { SessionOpenWorkspacePathRequest } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
|
||||
/** What a path gesture asks of the Host desktop: the default application, or the file manager showing the file. */
|
||||
export type OpenInAppPathAction = 'open' | 'reveal'
|
||||
|
||||
/** Failure kind of one settled path gesture, for the initiating control to announce. */
|
||||
export type OpenInAppPathFailure = 'openError' | 'revealError'
|
||||
|
||||
/** The slice of the Client Remote the path actions call. */
|
||||
export interface OpenInAppPathRemote {
|
||||
readonly session: {
|
||||
/** Whether the Host can hand a workspace path to a native desktop. */
|
||||
canOpenWorkspacePath(): Promise<RemoteResult<boolean>>
|
||||
/** Open one Host path in its default application, or reveal it in the file manager. */
|
||||
openWorkspacePath(request: SessionOpenWorkspacePathRequest, signal?: AbortSignal): Promise<RemoteResult<unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
/** Page-lifetime desktop availability and the open/reveal carrier shared by every path control. */
|
||||
export class OpenInAppPathController {
|
||||
/** Whether the Host desktop can open paths; null until the Host answered, false also after a failed read. */
|
||||
readonly desktop: SnapshotStore<boolean | null> = createSnapshotStore<boolean | null>(null)
|
||||
private loading: Promise<void> | undefined
|
||||
|
||||
/**
|
||||
* @param remote - the Session Remote namespace answering availability and running gestures.
|
||||
*/
|
||||
constructor(private readonly remote: OpenInAppPathRemote) {}
|
||||
|
||||
/**
|
||||
* Read desktop availability once per controller life; concurrent calls share the read.
|
||||
* @returns after availability is published.
|
||||
*/
|
||||
load(): Promise<void> {
|
||||
this.loading ??= this.run()
|
||||
return this.loading
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one Host path in its default application, or reveal it in the file manager.
|
||||
* @param path - absolute path on the Host, as the file's metadata reports it.
|
||||
* @param action - default application open, or file-manager reveal.
|
||||
* @returns the failure kind to announce, or `null` once the Host acknowledged.
|
||||
*/
|
||||
async openPath(path: string, action: OpenInAppPathAction): Promise<OpenInAppPathFailure | null> {
|
||||
const request: SessionOpenWorkspacePathRequest = action === 'reveal' ? { path, action } : { path }
|
||||
let ok = false
|
||||
try {
|
||||
ok = (await this.remote.session.openWorkspacePath(request)).ok
|
||||
} catch {
|
||||
// Swallows carrier rejections: a Host that cannot be reached failed the
|
||||
// gesture like a Host that refused it, and the control announces that.
|
||||
}
|
||||
return ok ? null : action === 'open' ? 'openError' : 'revealError'
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
let available = false
|
||||
try {
|
||||
const result = await this.remote.session.canOpenWorkspacePath()
|
||||
available = result.ok && result.value
|
||||
} catch {
|
||||
// Swallows carrier rejections: an unreachable Host reads as no desktop,
|
||||
// so the preview shows no path control rather than a broken one.
|
||||
}
|
||||
this.desktop.set(available)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Browser-half lifecycle over the real SlotRegistry: the dictionary and
|
||||
* header-slot registrations with fiber teardown proving removal (HMR safety)
|
||||
* and the injected controller face.
|
||||
* Browser-half lifecycle over the real SlotRegistry: the dictionary,
|
||||
* header-slot, and document-preview path registrations with fiber teardown
|
||||
* proving removal (HMR safety) and the injected controller faces.
|
||||
*/
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
@@ -9,16 +9,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { apply, inject, type OpenInAppActionInjected } from '../src/client/index.ts'
|
||||
import { apply, inject, type OpenInAppActionInjected, type OpenPathInjected } from '../src/client/index.ts'
|
||||
import { apply as nodeApply } from '../src/index.ts'
|
||||
import { OpenInAppAction } from '../src/client/OpenInAppAction.tsx'
|
||||
import { OpenPathAction } from '../src/client/OpenPathAction.tsx'
|
||||
import { OpenPathEmptyAction } from '../src/client/OpenPathEmptyAction.tsx'
|
||||
import { en, NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Boot the browser half over a real slot tree that declares the header list. */
|
||||
/** The Session Remote slice the path controls call; answers a desktop and acknowledges every gesture. */
|
||||
const remote = {
|
||||
session: {
|
||||
canOpenWorkspacePath: vi.fn(async () => ({ ok: true as const, value: true })),
|
||||
openWorkspacePath: vi.fn(async () => ({ ok: true as const, value: { opened: true as const } })),
|
||||
},
|
||||
}
|
||||
|
||||
/** Boot the browser half over a real slot tree that declares the header list and the document-preview seats. */
|
||||
async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']> }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
@@ -26,10 +36,14 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugi
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
|
||||
'sidebar.right.tab.document.actions': { kind: 'list', scope: 'session' },
|
||||
'sidebar.right.tab.document.unpreviewable': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
} as never, () => null)
|
||||
ctx.provide('sessions', {})
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
ctx.provide('remote', remote as never)
|
||||
ctx.provide('remote.session', remote.session as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
return { ctx, fiber }
|
||||
@@ -41,7 +55,29 @@ function headerEntryIds(ctx: Context): (string | undefined)[] {
|
||||
|
||||
describe('open-in-app browser half', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['sessions', 'slots', 'locale'])
|
||||
expect(inject).toEqual(['sessions', 'slots', 'locale', 'remote', 'remote.session'])
|
||||
})
|
||||
|
||||
it('registers both document-preview path controls behind one desktop answer, and fiber teardown removes them', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 })))
|
||||
const { ctx, fiber } = await bench()
|
||||
const header = ctx.slots.entries('sidebar.right.tab.document.actions')[0]
|
||||
const empty = ctx.slots.entries('sidebar.right.tab.document.unpreviewable')[0]
|
||||
expect(header?.component).toBe(OpenPathAction)
|
||||
expect(empty?.component).toBe(OpenPathEmptyAction)
|
||||
expect(header?.options).toMatchObject({ id: 'open-in-app' })
|
||||
const face = (header?.inject as unknown as () => OpenPathInjected)()
|
||||
const emptyFace = (empty?.inject as unknown as () => OpenPathInjected)()
|
||||
expect(emptyFace.hooks.openInAppDesktop).toBe(face.hooks.openInAppDesktop)
|
||||
expect(face.hooks.openInAppDesktop.getSnapshot()).toBeNull()
|
||||
await Promise.all([face.loadDesktop(), emptyFace.loadDesktop()])
|
||||
expect(remote.session.canOpenWorkspacePath).toHaveBeenCalledOnce()
|
||||
expect(face.hooks.openInAppDesktop.getSnapshot()).toBe(true)
|
||||
expect(await face.openPath('/w/clip.mp4', 'reveal')).toBeNull()
|
||||
expect(remote.session.openWorkspacePath).toHaveBeenLastCalledWith({ path: '/w/clip.mp4', action: 'reveal' })
|
||||
await fiber.dispose()
|
||||
expect(ctx.slots.entries('sidebar.right.tab.document.actions').map(entry => entry.options.id)).not.toContain('open-in-app')
|
||||
expect(ctx.slots.entries('sidebar.right.tab.document.unpreviewable').map(entry => entry.options.id)).not.toContain('open-in-app')
|
||||
})
|
||||
|
||||
it('registers the header split button, and fiber teardown removes it (HMR safety)', async () => {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The two document-preview path controls: hidden until the Host reports a
|
||||
* desktop, busy only for their own gesture, and announcing a failure once
|
||||
* through their own toast.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { OpenPathAction, type OpenPathActionProps } from '../src/client/OpenPathAction.tsx'
|
||||
import { OpenPathEmptyAction, type OpenPathEmptyActionProps } from '../src/client/OpenPathEmptyAction.tsx'
|
||||
import type { OpenInAppPathAction, OpenInAppPathFailure } from '../src/client/open-path.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const t = makeTranslate(zh)
|
||||
const FILE = { sessionId: 's-1' as SessionId, path: 'work/clip.mp4' }
|
||||
const ABSOLUTE_PATH = '/host/project/work/clip.mp4'
|
||||
|
||||
interface Bench {
|
||||
props: OpenPathActionProps & OpenPathEmptyActionProps
|
||||
desktop: ReturnType<typeof createSnapshotStore<boolean | null>>
|
||||
loadDesktop: ReturnType<typeof vi.fn>
|
||||
openPath: ReturnType<typeof vi.fn<(path: string, action: OpenInAppPathAction) => Promise<OpenInAppPathFailure | null>>>
|
||||
}
|
||||
|
||||
function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } = {}): Bench {
|
||||
const desktop = createSnapshotStore<boolean | null>(over.desktop === undefined ? true : over.desktop)
|
||||
const loadDesktop = vi.fn(async () => {})
|
||||
const openPath = over.openPath ?? vi.fn(async () => null)
|
||||
function useSelector<T, R>(source: { getSnapshot(): T }): (select: (value: T) => R) => R {
|
||||
return select => select(source.getSnapshot())
|
||||
}
|
||||
const props = {
|
||||
file: FILE,
|
||||
absolutePath: ABSOLUTE_PATH,
|
||||
useOpenInAppDesktop: useSelector(desktop),
|
||||
loadDesktop,
|
||||
openPath,
|
||||
t,
|
||||
} as unknown as Bench['props']
|
||||
return { props, desktop, loadDesktop, openPath }
|
||||
}
|
||||
|
||||
describe('OpenPathAction visibility', () => {
|
||||
it('renders nothing and asks for the desktop answer while it is unknown, and nothing without a desktop', () => {
|
||||
const unknown = bench({ desktop: null })
|
||||
const { container } = render(<OpenPathAction {...unknown.props} />)
|
||||
expect(container.innerHTML).toBe('')
|
||||
expect(unknown.loadDesktop).toHaveBeenCalledOnce()
|
||||
cleanup()
|
||||
const absent = bench({ desktop: false })
|
||||
expect(render(<OpenPathAction {...absent.props} />).container.innerHTML).toBe('')
|
||||
expect(absent.loadDesktop).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenPathAction gestures', () => {
|
||||
it('opens the file in its default application from the main button and reveals it from the menu', async () => {
|
||||
const b = bench()
|
||||
render(<OpenPathAction {...b.props} />)
|
||||
const main = screen.getByRole('button', { name: zh['path.open'] })
|
||||
await act(async () => { fireEvent.click(main) })
|
||||
expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open')
|
||||
fireEvent.click(screen.getByRole('button', { name: zh['path.more'] }))
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.reveal'] })) })
|
||||
expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal')
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('offers the default application from the menu too, and Escape closes the menu without a gesture', async () => {
|
||||
const b = bench()
|
||||
render(<OpenPathAction {...b.props} />)
|
||||
const more = screen.getByRole('button', { name: zh['path.more'] })
|
||||
fireEvent.click(more)
|
||||
expect(more.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(b.openPath).not.toHaveBeenCalled()
|
||||
fireEvent.click(more)
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) })
|
||||
expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open')
|
||||
})
|
||||
|
||||
it('disables both halves while its own gesture settles and leaves no failure on the control', async () => {
|
||||
const settled = Promise.withResolvers<OpenInAppPathFailure | null>()
|
||||
const b = bench({ openPath: vi.fn(() => settled.promise) })
|
||||
const { container } = render(<OpenPathAction {...b.props} />)
|
||||
const main = screen.getByRole('button', { name: zh['path.open'] })
|
||||
fireEvent.click(main)
|
||||
expect(main).toHaveProperty('disabled', true)
|
||||
expect(screen.getByRole('button', { name: zh['path.more'] })).toHaveProperty('disabled', true)
|
||||
expect(container.querySelector('[data-open-path]')?.getAttribute('data-state')).toBe('busy')
|
||||
fireEvent.click(main)
|
||||
expect(b.openPath).toHaveBeenCalledOnce()
|
||||
await act(async () => { settled.resolve('openError'); await settled.promise })
|
||||
expect(main).toHaveProperty('disabled', false)
|
||||
expect(container.querySelector('[data-open-path]')?.getAttribute('data-state')).toBe('idle')
|
||||
expect(screen.getByRole('alert').textContent).toContain(zh['path.openError'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenPathEmptyAction', () => {
|
||||
it('renders nothing without a desktop and opens the file in its default application with one', async () => {
|
||||
const absent = bench({ desktop: false })
|
||||
expect(render(<OpenPathEmptyAction {...absent.props} />).container.innerHTML).toBe('')
|
||||
cleanup()
|
||||
const b = bench()
|
||||
render(<OpenPathEmptyAction {...b.props} />)
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) })
|
||||
expect(b.openPath).toHaveBeenCalledWith(ABSOLUTE_PATH, 'open')
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('announces a failed open once through its own toast, which leaves after its hold and fade', async () => {
|
||||
vi.useFakeTimers()
|
||||
const b = bench({ openPath: vi.fn(async () => 'openError' as const) })
|
||||
render(<OpenPathEmptyAction {...b.props} />)
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) })
|
||||
expect(screen.getAllByRole('alert')).toHaveLength(1)
|
||||
expect(screen.getByRole('alert').textContent).toContain(zh['path.openError'])
|
||||
act(() => { vi.advanceTimersByTime(4_000) })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
/** Path controller behavior: the once-per-page desktop read and the open/reveal outcomes over the Session Remote. */
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionOpenWorkspacePathRequest } from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
import { OpenInAppPathController, type OpenInAppPathRemote } from '../src/client/open-path.ts'
|
||||
|
||||
type SessionRemote = OpenInAppPathRemote['session']
|
||||
|
||||
function remoteOf(over: Partial<SessionRemote> = {}): OpenInAppPathRemote {
|
||||
return {
|
||||
session: {
|
||||
canOpenWorkspacePath: async () => ({ ok: true, value: true }),
|
||||
openWorkspacePath: async () => ({ ok: true, value: { opened: true } }),
|
||||
...over,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('OpenInAppPathController desktop availability', () => {
|
||||
it('starts unanswered and shares one read across concurrent loads', async () => {
|
||||
const canOpenWorkspacePath = vi.fn(async () => ({ ok: true as const, value: true }))
|
||||
const controller = new OpenInAppPathController(remoteOf({ canOpenWorkspacePath }))
|
||||
expect(controller.desktop.getSnapshot()).toBeNull()
|
||||
await Promise.all([controller.load(), controller.load()])
|
||||
await controller.load()
|
||||
expect(canOpenWorkspacePath).toHaveBeenCalledOnce()
|
||||
expect(controller.desktop.getSnapshot()).toBe(true)
|
||||
})
|
||||
|
||||
it('publishes no desktop for a refusing Host, a failed answer, and a rejecting carrier', async () => {
|
||||
const refusing = new OpenInAppPathController(remoteOf({ canOpenWorkspacePath: async () => ({ ok: true, value: false }) }))
|
||||
await refusing.load()
|
||||
expect(refusing.desktop.getSnapshot()).toBe(false)
|
||||
|
||||
const failed = new OpenInAppPathController(remoteOf({
|
||||
canOpenWorkspacePath: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'boom', {}) }),
|
||||
}))
|
||||
await failed.load()
|
||||
expect(failed.desktop.getSnapshot()).toBe(false)
|
||||
|
||||
const rejecting = new OpenInAppPathController(remoteOf({ canOpenWorkspacePath: () => Promise.reject(new Error('down')) }))
|
||||
await rejecting.load()
|
||||
expect(rejecting.desktop.getSnapshot()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OpenInAppPathController gestures', () => {
|
||||
it('sends the default-application open without an action and the reveal with one', async () => {
|
||||
const openWorkspacePath = vi.fn(async (_request: SessionOpenWorkspacePathRequest, _signal?: AbortSignal) =>
|
||||
({ ok: true as const, value: { opened: true as const } }))
|
||||
const controller = new OpenInAppPathController(remoteOf({ openWorkspacePath }))
|
||||
expect(await controller.openPath('/w/clip.mp4', 'open')).toBeNull()
|
||||
expect(await controller.openPath('/w/clip.mp4', 'reveal')).toBeNull()
|
||||
expect(openWorkspacePath.mock.calls.map(call => call[0])).toEqual([
|
||||
{ path: '/w/clip.mp4' },
|
||||
{ path: '/w/clip.mp4', action: 'reveal' },
|
||||
])
|
||||
})
|
||||
|
||||
it('names the failed gesture for a refused answer and for a rejecting carrier', async () => {
|
||||
const refused = new OpenInAppPathController(remoteOf({
|
||||
openWorkspacePath: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'boom', {}) }),
|
||||
}))
|
||||
expect(await refused.openPath('/w/clip.mp4', 'open')).toBe('openError')
|
||||
expect(await refused.openPath('/w/clip.mp4', 'reveal')).toBe('revealError')
|
||||
|
||||
const rejecting = new OpenInAppPathController(remoteOf({ openWorkspacePath: () => Promise.reject(new Error('down')) }))
|
||||
expect(await rejecting.openPath('/w/clip.mp4', 'open')).toBe('openError')
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,12 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../api/gateway/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/session-controller/tsconfig.client.json"
|
||||
},
|
||||
@@ -35,6 +41,9 @@
|
||||
{
|
||||
"path": "../ui-session"
|
||||
},
|
||||
{
|
||||
"path": "../ui-sidebar-documentpreview"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
}
|
||||
|
||||
@@ -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: d4e114aa6e12171d61f8a5284fdbe923916fe0d9
|
||||
README.zh.md: 32cb704d29fd0009fdf7a51235617c25029295f0
|
||||
README.md: c0cb4796036d3ca20e7f3bbd239c9816a177feeb
|
||||
README.zh.md: 7b47c00b85b6c128946e3070d805b52b2a0781ef
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, image, PDF and HTML renderers, and plain-text fallback."
|
||||
description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, image, PDF and HTML renderers, plain-text fallback, and child slots handing a file to the Host desktop."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF, HTML, and common images receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`.
|
||||
Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF, HTML, and common images receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. Two further child slots let another plugin hand the previewed file to the Host desktop from the header and from the empty state of a file the preview cannot render. The Sidebar tab kind is `text`.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -27,7 +27,7 @@ Preview readable files in the right Sidebar and choose among registered renderer
|
||||
## What it registers
|
||||
|
||||
- **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text; a suffix declared binary (bitmap images, PDF) drops the plain-text fallback, and a single remaining candidate renders no viewer control at all. A known binary container suffix with no registered renderer (audio, video, archives, office documents) shows an unsupported empty state under the path header — the file-type icon and one explanatory line — and never issues a read. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text; a suffix declared binary (bitmap images, PDF) drops the plain-text fallback, and a single remaining candidate renders no viewer control at all. A known binary container suffix with no registered renderer (audio, video, archives, office documents) shows an unsupported empty state under the path header — the file-type icon and one explanatory line — and never issues a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `file` and `absolutePath` as owner props: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge.
|
||||
- **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state.
|
||||
|
||||
Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` names the suffixes among `extensions` whose bytes are not readable text; a file matching one loses the plain-text fallback among its viewer choices. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations.
|
||||
@@ -43,7 +43,7 @@ A tab uses the Session address built by `fileAddressFor`, carrying a relative or
|
||||
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; neither automatically replaces loaded content.
|
||||
- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, explanation, and retry; a later failure retains loaded content and adds the retry below it.
|
||||
- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, the explanation, and the recourse the failure names (Retry, the unpreviewable child slot, or nothing); a later failure retains loaded content and adds the retry below it.
|
||||
- **Complete bytes** — PDF, HTML, and common images use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array<ArrayBuffer>` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results.
|
||||
- **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、图片、PDF 和 HTML 渲染器,并以纯文本兜底。"
|
||||
description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、图片、PDF 和 HTML 渲染器,以纯文本兜底,并提供把文件交给 Host 桌面的子 slot。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF、HTML 和常见图片接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。
|
||||
在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF、HTML 和常见图片接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。另有两个子 slot 让别的插件从头部和从无法渲染文件的空态把当前文件交给 Host 桌面。Sidebar tab 的 kind 为 `text`。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-reference"
|
||||
## 注册了什么
|
||||
|
||||
- **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。
|
||||
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换;声明为二进制的后缀(位图图片、PDF)不提供纯文本兜底,只剩一个候选时完全不渲染查看器控件。已知的二进制容器后缀(音频、视频、压缩包、office 文档)没有注册渲染器时,在路径头部下方显示不支持预览的空态——文件类型图标加一行说明——并且不会发起读取。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
|
||||
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换;声明为二进制的后缀(位图图片、PDF)不提供纯文本兜底,只剩一个候选时完全不渲染查看器控件。已知的二进制容器后缀(音频、视频、压缩包、office 文档)没有注册渲染器时,在路径头部下方显示不支持预览的空态——文件类型图标加一行说明——并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner props 都是 `file` 与 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。
|
||||
- **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。
|
||||
|
||||
文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中字节不可按文本阅读的后缀;匹配这类后缀的文件在其查看器选项中不再提供纯文本兜底。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。
|
||||
@@ -43,7 +43,7 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路
|
||||
正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取:
|
||||
|
||||
- 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;两者都不会自动替换已加载内容。
|
||||
- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明与重试按钮填满正文;较晚的失败保留已有内容并在其下提供重试。
|
||||
- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明和该失败对应的出路(重试、unpreviewable 子 slot,或什么都不给)填满正文;较晚的失败保留已有内容并在其下提供重试。
|
||||
- **完整字节** —— PDF、HTML 和常见图片通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array<ArrayBuffer>`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。
|
||||
- **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStor
|
||||
import { FileTypeIcon, IconRefreshOutline16, Menu, Tooltip, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { TextInjected } from './face.ts'
|
||||
import { failureLine } from './failure-line.ts'
|
||||
import { emptyFailureRecourse, failureLine } from './failure-line.ts'
|
||||
import { IconNowrapFill16, IconWrapFill16 } from './icons.tsx'
|
||||
import { LoadingIndicator } from './LoadingIndicator.tsx'
|
||||
import { hostFileOf } from './rpc.ts'
|
||||
@@ -82,7 +82,7 @@ export interface TextPreviewInjected extends TextInjected {
|
||||
/** The body's composed props: the tab, its navigation, the shared store and face, and copy. */
|
||||
export type TextPreviewProps =
|
||||
& PropsRuntime<'sidebar.right.pane.tab'>
|
||||
& PropsRenderSlots<'sidebar.right.tab.document'>
|
||||
& PropsRenderSlots<'sidebar.right.tab.document' | 'sidebar.right.tab.document.actions' | 'sidebar.right.tab.document.unpreviewable'>
|
||||
& PropsStore<TextStore>
|
||||
& InjectFace<TextPreviewInjected>
|
||||
& PropsLocale<'sidebarDocumentPreview'>
|
||||
@@ -120,7 +120,10 @@ export function TextPreview({
|
||||
const pathRef = useRef<HTMLDivElement | null>(null)
|
||||
const pathTextRef = useRef<HTMLSpanElement | null>(null)
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const displayPath = meta.value?.absolutePath ?? current?.complete?.absolutePath ?? file.path
|
||||
const absolutePath = meta.value?.absolutePath ?? current?.complete?.absolutePath
|
||||
const displayPath = absolutePath ?? file.path
|
||||
// Contributions that hand the file to the Host wait for its Host path.
|
||||
const fileOwner = absolutePath === undefined ? undefined : { file, absolutePath }
|
||||
usePathClipped(pathRef, pathTextRef, displayPath, state !== undefined)
|
||||
// Every tab of this type is a `file` resource address, so its params are the
|
||||
// `file` type's; the union is narrowed on the one field read, not validated.
|
||||
@@ -195,18 +198,21 @@ export function TextPreview({
|
||||
}, [mode, loaded, current?.complete, current?.eof])
|
||||
|
||||
// A known binary suffix with no matching renderer never reads: no plain-text
|
||||
// fallback, no viewer control, only the path and the unsupported line.
|
||||
// fallback, no viewer control, only the path, the unsupported line, and the
|
||||
// contributions that hand the file to the Host.
|
||||
if (selected === undefined && unviewable) {
|
||||
const { name: unsupportedName } = pathPartsOf(displayPath)
|
||||
return (
|
||||
<div className={css.preview} data-textpreview-state="unsupported" data-textpreview-url={tab.contentId}>
|
||||
<div className={css.header}>
|
||||
<HeaderPath pathRef={pathRef} pathTextRef={pathTextRef} path={displayPath} />
|
||||
{fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)}
|
||||
</div>
|
||||
<div className={css.body} data-textpreview-body>
|
||||
<div className={css.empty} data-textpreview-unsupported>
|
||||
<FileTypeIcon kind={classifyFileType(unsupportedName)} size={36} className={css.emptyIcon} />
|
||||
<p className={css.emptyLine}>{t('unsupportedFile')}</p>
|
||||
{fileOwner !== undefined && renderSlot('sidebar.right.tab.document.unpreviewable', fileOwner)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,6 +321,7 @@ export function TextPreview({
|
||||
<IconRefreshOutline16 />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)}
|
||||
</div>
|
||||
<div
|
||||
ref={bindBody}
|
||||
@@ -355,20 +362,27 @@ export function TextPreview({
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
// With no content, retry the selected renderer's read; metadata
|
||||
// observation remains owned by the resource provider.
|
||||
// With no content, the failure names its recourse: retry the
|
||||
// selected renderer's read when a second read may resolve it, hand
|
||||
// a file this preview cannot render to the unpreviewable
|
||||
// contributions, and offer nothing for a path with nothing to show.
|
||||
// Metadata observation remains owned by the resource provider.
|
||||
<div className={css.empty} data-textpreview-failed={current.failure.code}>
|
||||
<FileTypeIcon kind={classifyFileType(name)} size={36} className={css.emptyIcon} />
|
||||
<p className={css.emptyLine}>{failureLine(t, current.failure)}</p>
|
||||
<button
|
||||
type="button"
|
||||
className={css.retry}
|
||||
data-textpreview-retry
|
||||
onClick={reload}
|
||||
>
|
||||
<IconRefreshOutline16 size={14} />
|
||||
{t('retry')}
|
||||
</button>
|
||||
{emptyFailureRecourse(current.failure) === 'open' && fileOwner !== undefined
|
||||
&& renderSlot('sidebar.right.tab.document.unpreviewable', fileOwner)}
|
||||
{emptyFailureRecourse(current.failure) === 'retry' && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.retry}
|
||||
data-textpreview-retry
|
||||
onClick={reload}
|
||||
>
|
||||
<IconRefreshOutline16 size={14} />
|
||||
{t('retry')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{mode === 'text-pages' && current !== undefined && loaded.length > 0 && !current.eof && current.failure === undefined && (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { PropsRuntime, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import type { RefCallback } from 'react'
|
||||
import type { SessionFile } from '../rpc.ts'
|
||||
|
||||
/** One loaded text window, retaining source line positions. */
|
||||
export interface DocumentTextPage {
|
||||
@@ -41,6 +42,34 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Header toolbar contributions acting on the previewed file, rendered
|
||||
* after the preview's own controls once the file's Host path is known.
|
||||
*/
|
||||
'sidebar.right.tab.document.actions': {
|
||||
kind: 'list'
|
||||
scope: 'session'
|
||||
owner: {
|
||||
/** The previewed file: the Session the read runs under and the path handed to the Host. */
|
||||
readonly file: SessionFile
|
||||
/** The file's absolute path on the Host, from its metadata. */
|
||||
readonly absolutePath: string
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Empty-state contributions for a file this preview cannot render,
|
||||
* offered where Retry would stand once the file's Host path is known.
|
||||
*/
|
||||
'sidebar.right.tab.document.unpreviewable': {
|
||||
kind: 'list'
|
||||
scope: 'session'
|
||||
owner: {
|
||||
/** The previewed file: the Session the read runs under and the path handed to the Host. */
|
||||
readonly file: SessionFile
|
||||
/** The file's absolute path on the Host, from its metadata. */
|
||||
readonly absolutePath: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,3 +33,25 @@ export function failureLine(t: TranslateNS<'sidebarDocumentPreview'>, failure: R
|
||||
default: return t('error.unavailable', { message: failure.message })
|
||||
}
|
||||
}
|
||||
|
||||
/** The action an empty preview offers beside its failure line. */
|
||||
export type EmptyFailureRecourse = 'open' | 'retry' | 'none'
|
||||
|
||||
/**
|
||||
* Choose the empty state's action for one settled read failure.
|
||||
* @param failure - the settled Remote failure.
|
||||
* @returns `open` for a readable file this preview cannot render, `none` for a
|
||||
* path with nothing to show or open, `retry` for the rest: carrier and
|
||||
* unclassified failures a second read may resolve.
|
||||
*/
|
||||
export function emptyFailureRecourse(failure: RemoteFailure): EmptyFailureRecourse {
|
||||
switch (failure.code) {
|
||||
case 'workspace-file/not-text':
|
||||
case 'workspace-file/too-large':
|
||||
return 'open'
|
||||
case 'workspace-file/not-found':
|
||||
case 'workspace-file/not-regular-file':
|
||||
return 'none'
|
||||
default: return 'retry'
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,8 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'sidebar.right.pane.tab', key: TEXTPREVIEW_ID, locale: NS, store,
|
||||
children: {
|
||||
'sidebar.right.tab.document': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } } },
|
||||
'sidebar.right.tab.document.actions': { kind: 'list', scope: 'session' },
|
||||
'sidebar.right.tab.document.unpreviewable': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
inject: (sessionId, actions): TextPreviewInjected => ({ ...face(sessionId, actions), hooks: { documentPreviews: source } }),
|
||||
},
|
||||
|
||||
@@ -25,7 +25,9 @@ function codeProps(h: ReturnType<typeof harness>): TextPreviewProps {
|
||||
...props,
|
||||
useDocumentPreviews: selector => selector([definition]),
|
||||
// This adapter only receives the concrete document slot, not an arbitrary generic key.
|
||||
renderSlot: (_key, owner) => <CodeBody {...props} {...owner as unknown as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />,
|
||||
renderSlot: (key: string, owner: unknown) => key === 'sidebar.right.tab.document'
|
||||
? <CodeBody {...props} {...owner as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,18 +106,19 @@ describe('document toolbar', () => {
|
||||
await pending.promise
|
||||
})
|
||||
h.bytes.mockReturnValueOnce(pending.promise)
|
||||
const renderSlot = vi.fn(() => null)
|
||||
const renderSlot = vi.fn((_key: string) => null)
|
||||
const documentRenders = (): number => renderSlot.mock.calls.filter(call => call[0] === 'sidebar.right.tab.document').length
|
||||
const props: TextPreviewProps = { ...h.props(), useDocumentPreviews: selector => selector([binary]), renderSlot }
|
||||
const view = render(<TextPreview {...props} />)
|
||||
expect(view.getByRole('status').hasAttribute('data-document-loading')).toBe(true)
|
||||
expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading')
|
||||
expect(view.container.querySelector('[data-textpreview-body]')?.firstElementChild).toBe(view.getByRole('status'))
|
||||
expect(renderSlot).not.toHaveBeenCalled()
|
||||
expect(documentRenders()).toBe(0)
|
||||
await act(async () => {
|
||||
pending.resolve(result)
|
||||
await pending.promise
|
||||
})
|
||||
expect(renderSlot).toHaveBeenCalled()
|
||||
expect(documentRenders()).toBeGreaterThan(0)
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -123,7 +126,7 @@ describe('document toolbar', () => {
|
||||
const h = harness()
|
||||
const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true } }
|
||||
const read = h.bytes.mockResolvedValueOnce({
|
||||
ok: false, error: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }),
|
||||
ok: false, error: new RemoteError('gateway/internal', 'socket closed', {}),
|
||||
}).mockResolvedValue(result)
|
||||
h.useResource.mockReturnValue({ status: 'loading', value: undefined, failure: undefined })
|
||||
const props: TextPreviewProps = {
|
||||
@@ -245,6 +248,11 @@ describe('document toolbar', () => {
|
||||
expect(view.container.querySelector('[data-textpreview-path]')).not.toBeNull()
|
||||
expect(view.container.querySelector('[data-document-viewer-menu]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-textpreview-tool="reload"]')).toBeNull()
|
||||
// Both handoff seats receive the file once its Host path is known.
|
||||
const header = view.container.querySelector('[data-textpreview-path]')?.parentElement
|
||||
expect(header?.querySelector('[data-slot="sidebar.right.tab.document.actions"]')?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH)
|
||||
const empty = view.container.querySelector('[data-textpreview-unsupported]')
|
||||
expect(empty?.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')?.getAttribute('data-slot-file')).toBe('work/clip.mp4')
|
||||
expect(h.read).not.toHaveBeenCalled()
|
||||
expect(h.bytes).not.toHaveBeenCalled()
|
||||
h.controller.abort()
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
// The namespace declaration `TranslateNS<'sidebarDocumentPreview'>` resolves against.
|
||||
import type {} from '../src/client/index.ts'
|
||||
import { failureLine } from '../src/client/failure-line.ts'
|
||||
import { emptyFailureRecourse, failureLine } from '../src/client/failure-line.ts'
|
||||
|
||||
/** Key-echoing translate that also shows its parameters, so a formatted value is visible. */
|
||||
const t: TranslateNS<'sidebarDocumentPreview'> = (key, params) =>
|
||||
@@ -33,3 +33,20 @@ describe('failureLine', () => {
|
||||
expect(failureLine(t, failure('gateway/internal', {}, 'socket closed'))).toBe('error.unavailable(message=socket closed)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('emptyFailureRecourse', () => {
|
||||
it('hands a readable file the preview cannot render to the Host', () => {
|
||||
expect(emptyFailureRecourse(failure('workspace-file/not-text'))).toBe('open')
|
||||
expect(emptyFailureRecourse(failure('workspace-file/too-large', { limit: 512 }))).toBe('open')
|
||||
})
|
||||
|
||||
it('offers nothing for a path with nothing to show or open', () => {
|
||||
expect(emptyFailureRecourse(failure('workspace-file/not-found'))).toBe('none')
|
||||
expect(emptyFailureRecourse(failure('workspace-file/not-regular-file'))).toBe('none')
|
||||
})
|
||||
|
||||
it('retries carrier and unclassified failures', () => {
|
||||
expect(emptyFailureRecourse(failure('gateway/internal'))).toBe('retry')
|
||||
expect(emptyFailureRecourse(failure('someday/a-new-code'))).toBe('retry')
|
||||
})
|
||||
})
|
||||
@@ -122,9 +122,18 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
|
||||
onTestFinished(() => { controller.abort() })
|
||||
const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() }
|
||||
const definitions = [textBodyDefinition(() => t('viewer.text'))]
|
||||
const renderSlot: TextPreviewProps['renderSlot'] = (_key, owner, opts) => createElement(TextBody, {
|
||||
...owner, useTabInfo: opts.hookContext, sessionId: SESSION, useResource,
|
||||
} as unknown as DocumentPreviewProps)
|
||||
// The document seat renders the plain body; the file-handoff seats render a
|
||||
// marker carrying what the owner handed them.
|
||||
const renderSlot: TextPreviewProps['renderSlot'] = (key: string, owner: unknown, opts?: { hookContext?: unknown }) =>
|
||||
key === 'sidebar.right.tab.document'
|
||||
? createElement(TextBody, {
|
||||
...owner as object, useTabInfo: opts?.hookContext, sessionId: SESSION, useResource,
|
||||
} as unknown as DocumentPreviewProps)
|
||||
: createElement('div', {
|
||||
'data-slot': key,
|
||||
'data-slot-file': (owner as { file: SessionFile }).file.path,
|
||||
'data-slot-path': (owner as { absolutePath: string }).absolutePath,
|
||||
})
|
||||
const props = (navigation: { params?: unknown; revision: number } = { revision: 1 }) => ({
|
||||
useTabInfo: () => ({
|
||||
sidebar: { expanded: true, fullscreen: false },
|
||||
|
||||
@@ -88,7 +88,9 @@ function codeProps(h: ReturnType<typeof harness>, navigation: { params?: unknown
|
||||
return {
|
||||
...props,
|
||||
useDocumentPreviews: selector => selector([definition]),
|
||||
renderSlot: (_key, owner) => <CodeBody {...props} {...owner as unknown as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />,
|
||||
renderSlot: (key: string, owner: unknown) => key === 'sidebar.right.tab.document'
|
||||
? <CodeBody {...props} {...owner as OwnerOf<'sidebar.right.tab.document'>} t={key => key} />
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,15 +255,16 @@ describe('TextPreview — pages', () => {
|
||||
})
|
||||
|
||||
it('says why a page failed and retries the same page', async () => {
|
||||
const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) })
|
||||
const h = harness({ 1: failure('gateway/internal', { path: PATH }) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
const failed = view.container.querySelector('[data-textpreview-failed]')
|
||||
expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text')
|
||||
expect(view.container.textContent).toContain('error.notText')
|
||||
expect(failed?.getAttribute('data-textpreview-failed')).toBe('gateway/internal')
|
||||
expect(view.container.textContent).toContain('error.unavailable(message=boom)')
|
||||
// Nothing read yet: the failure stands as the body, under the file's type sheet.
|
||||
expect(failed?.querySelector('svg')).not.toBeNull()
|
||||
expect(view.container.querySelector('[data-textpreview-more]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')).toBeNull()
|
||||
h.script(1, page(1, ['one'], true))
|
||||
click(view.container, '[data-textpreview-retry]')
|
||||
await settle()
|
||||
@@ -270,6 +273,40 @@ describe('TextPreview — pages', () => {
|
||||
expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
|
||||
})
|
||||
|
||||
it('hands a readable file it cannot render to the unpreviewable seat instead of Retry', async () => {
|
||||
const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
const failed = view.container.querySelector('[data-textpreview-failed]')
|
||||
expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text')
|
||||
expect(view.container.textContent).toContain('error.notText')
|
||||
expect(view.container.querySelector('[data-textpreview-retry]')).toBeNull()
|
||||
const seat = failed?.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')
|
||||
expect(seat?.getAttribute('data-slot-file')).toBe(PATH)
|
||||
expect(seat?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH)
|
||||
// The header's own handoff seat receives the same file.
|
||||
const actions = view.container.querySelector('[data-slot="sidebar.right.tab.document.actions"]')
|
||||
expect(actions?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH)
|
||||
})
|
||||
|
||||
it('offers neither Retry nor a handoff for a path with nothing to show', async () => {
|
||||
const h = harness({ 1: failure('workspace-file/not-found', { path: PATH }) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-found')
|
||||
expect(view.container.querySelector('[data-textpreview-retry]')).toBeNull()
|
||||
expect(view.container.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('withholds the handoff seats until the file reports a Host path', async () => {
|
||||
const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) })
|
||||
h.setVersion(undefined)
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
await settle()
|
||||
expect(view.container.querySelector('[data-textpreview-failed]')).not.toBeNull()
|
||||
expect(view.container.querySelector('[data-slot]')).toBeNull()
|
||||
})
|
||||
|
||||
it('says why a later page failed on a line under the pages already read', async () => {
|
||||
const h = harness({ 1: page(1, ['a'], false), 2: failure('workspace-file/too-large', { path: PATH, limit: 1024 }) })
|
||||
const view = render(<TextPreview {...h.props()} />)
|
||||
@@ -429,7 +466,7 @@ describe('TextPreview — navigation and view', () => {
|
||||
it('rebinds scrolling when the selected Slot body is replaced without changing the renderer id', async () => {
|
||||
const h = harness({ 1: page(1, ['a', 'b', 'c'], true) })
|
||||
const code = codeProps(h, { revision: 1 })
|
||||
const fallback: TextPreviewProps = { ...code, renderSlot: () => <div data-late-renderer /> }
|
||||
const fallback: TextPreviewProps = { ...code, renderSlot: (key: string) => key === 'sidebar.right.tab.document' ? <div data-late-renderer /> : null }
|
||||
const view = render(<TextPreview {...fallback} />)
|
||||
await settle()
|
||||
const outer = body(view.container)
|
||||
@@ -465,10 +502,11 @@ describe('TextPreview — navigation and view', () => {
|
||||
const props: TextPreviewProps = {
|
||||
...base,
|
||||
useDocumentPreviews: selector => selector(definitions),
|
||||
renderSlot: (_key, owner, opts) => {
|
||||
const documentOwner = owner as unknown as OwnerOf<'sidebar.right.tab.document'>
|
||||
if (opts.entryKey === 'code') return <CodeBody {...base} {...documentOwner} t={key => key} />
|
||||
if (opts.entryKey === PLAIN_BODY_ID) return <TextBody {...base} {...documentOwner} />
|
||||
renderSlot: (key: string, owner: unknown, opts?: { entryKey?: string }) => {
|
||||
if (key !== 'sidebar.right.tab.document') return null
|
||||
const documentOwner = owner as OwnerOf<'sidebar.right.tab.document'>
|
||||
if (opts?.entryKey === 'code') return <CodeBody {...base} {...documentOwner} t={key => key} />
|
||||
if (opts?.entryKey === PLAIN_BODY_ID) return <TextBody {...base} {...documentOwner} />
|
||||
return <div data-test-no-lines />
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2449,7 +2449,125 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:24',
|
||||
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:25',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.right.tab.document.actions',
|
||||
kind: 'list',
|
||||
scope: 'session',
|
||||
summary: 'Header toolbar contributions acting on the previewed file, rendered after the preview\'s own controls once the file\'s Host path is known.',
|
||||
doc: 'Header toolbar contributions acting on the previewed file, rendered after the preview\'s own controls once the file\'s Host path is known.',
|
||||
registerOptions: [
|
||||
{
|
||||
name: 'id',
|
||||
requirement: 'required',
|
||||
type: 'string',
|
||||
doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
requirement: 'optional',
|
||||
type: 'number',
|
||||
doc: 'Position among the entries, ascending (default 0).',
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
requirement: 'optional',
|
||||
type: 'string | (() => string)',
|
||||
doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
|
||||
},
|
||||
],
|
||||
ownerProps: [
|
||||
'/** The file one tab reads: the session the read runs under and the path handed to the Host. */\nexport interface SessionFile {\n /** The Session whose workspace resolves relative paths. */\n readonly sessionId: SessionId\n /** The path the Host receives, absolute or relative to the addressed Session\'s workspace. */\n readonly path: string\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'SessionId',
|
||||
],
|
||||
standardProps: [
|
||||
'useResource: UseResource',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
'usePanelInfo: UsePanelInfo',
|
||||
'useSessions: UseSessions',
|
||||
'useSessionPendingInteraction: UseSessionPendingInteraction',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
'useChat: UseChat',
|
||||
'useConversation: UseConversation',
|
||||
'useInput: SnapshotSelectorHook<InputState>',
|
||||
'inputActions: InputActions',
|
||||
'useSession: SessionSnapshotSelector',
|
||||
'sessionId: SessionId',
|
||||
'useProjection: UseProjection',
|
||||
'useTrajectory: UseTrajectory',
|
||||
],
|
||||
keyDomain: '',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted',
|
||||
occupants: [
|
||||
'client-ui-open-in-app OpenPathAction id \'open-in-app\'',
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.actions\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:46',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.right.tab.document.unpreviewable',
|
||||
kind: 'list',
|
||||
scope: 'session',
|
||||
summary: 'Empty-state contributions for a file this preview cannot render, offered where Retry would stand once the file\'s Host path is known.',
|
||||
doc: 'Empty-state contributions for a file this preview cannot render, offered where Retry would stand once the file\'s Host path is known.',
|
||||
registerOptions: [
|
||||
{
|
||||
name: 'id',
|
||||
requirement: 'required',
|
||||
type: 'string',
|
||||
doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
|
||||
},
|
||||
{
|
||||
name: 'order',
|
||||
requirement: 'optional',
|
||||
type: 'number',
|
||||
doc: 'Position among the entries, ascending (default 0).',
|
||||
},
|
||||
{
|
||||
name: 'label',
|
||||
requirement: 'optional',
|
||||
type: 'string | (() => string)',
|
||||
doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
|
||||
},
|
||||
],
|
||||
ownerProps: [
|
||||
'/** The file one tab reads: the session the read runs under and the path handed to the Host. */\nexport interface SessionFile {\n /** The Session whose workspace resolves relative paths. */\n readonly sessionId: SessionId\n /** The path the Host receives, absolute or relative to the addressed Session\'s workspace. */\n readonly path: string\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'SessionId',
|
||||
],
|
||||
standardProps: [
|
||||
'useResource: UseResource',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
'usePanelInfo: UsePanelInfo',
|
||||
'useSessions: UseSessions',
|
||||
'useSessionPendingInteraction: UseSessionPendingInteraction',
|
||||
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
|
||||
'useChat: UseChat',
|
||||
'useConversation: UseConversation',
|
||||
'useInput: SnapshotSelectorHook<InputState>',
|
||||
'inputActions: InputActions',
|
||||
'useSession: SessionSnapshotSelector',
|
||||
'sessionId: SessionId',
|
||||
'useProjection: UseProjection',
|
||||
'useTrajectory: UseTrajectory',
|
||||
],
|
||||
keyDomain: '',
|
||||
hookContext: '',
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted',
|
||||
occupants: [
|
||||
'client-ui-open-in-app OpenPathEmptyAction id \'open-in-app\'',
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.unpreviewable\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.unpreviewable\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:57',
|
||||
},
|
||||
{
|
||||
key: 'sidebar.right.tab.guide',
|
||||
|
||||
Generated
+9
@@ -3488,6 +3488,12 @@ importers:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-api-gateway':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/gateway
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/remotes
|
||||
'@deepseek-ai/dsh-api-session-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/session-controller
|
||||
@@ -3512,6 +3518,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-ui-session':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-session
|
||||
'@deepseek-ai/dsh-client-ui-sidebar-documentpreview':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-sidebar-documentpreview
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
|
||||
@@ -51,3 +51,5 @@
|
||||
|
||||
- State: unsupported
|
||||
- Line: Preview is not available for this file type yet.
|
||||
- Header control: Open
|
||||
- Empty-state control: Open in default app
|
||||
@@ -3,3 +3,7 @@
|
||||
name: '@deepseek-ai/dsh-api-workspace-files'
|
||||
config:
|
||||
maxLines: 64
|
||||
# Pin desktop availability so the default-application controls render identically in headless CI.
|
||||
- id: session-controller
|
||||
config:
|
||||
nativeOpen: true
|
||||
@@ -3,4 +3,7 @@
|
||||
- img
|
||||
- button "Read the file again":
|
||||
- img
|
||||
- button "Open"
|
||||
- button "More ways to open":
|
||||
- img
|
||||
- text: alpha
|
||||
Reference in new issue
Block a user