Verify local file opening and address sidebar review findings

This commit is contained in:
creatixchu committed 2026-09-18 15:31:50 +08:00
1 parent 12c0cbb56d
commit 04d0235673
42 files changed
+185 -122

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .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
2026-09-16-open-in-default-app-for-sidebar-files.md: fa7506be77e8a62e5dfd97b59de6ebc4be510ae0
2026-09-16-open-in-default-app-for-sidebar-files.zh.md: af124182dc1b38658ff55736277e499e963a6281
@@ -10,13 +10,13 @@ Opening a file in its Host default application or showing it in the file manager
## 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](../../../../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 file’s absolute execution-environment 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.
**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 shared Session Remote owns verification of the Host path through the composed filesystem.
**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.
@@ -24,6 +24,6 @@ ui-open-in-app fills both slots: an "Open ▾" split button whose main button op
## 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.
Every previewed file with a verified Host mapping can be opened locally from the Sidebar, including files that were never delivered. Paths without that mapping are refused before native opening. 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.
@@ -10,13 +10,13 @@ Status: implemented
## 决定
[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](../../../../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 携带文件在执行环境中的绝对路径,只在文件元数据报出该路径后渲染:`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 提示一次,控件上不残留失败状态。
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` 已经接受任意已认证客户端传入的东西。
**新建自带 Host 路由的 `ui-open-locally` 包**(PR #4120)是这对 slot、失败分类、分体按钮设计和 toast 规则的来源。放弃它是因为仓库已经有 open-in-app 功能,再来一个负责本地打开的包属于重复;共享的 Session Remote 负责通过当前文件系统验证 Host 路径。
**在 `dsh-host-open-in-app` 里加 Host 路由**提供桌面信息和按路径打开,能让 Host 报出文件管理器的名字,并区分文件不存在和启动失败。但这会让 Host 半边为了 Session Remote 已经发布的两个调用而依赖 `sessionController`、`workspaceFiles` 与 `fs`,所以直接使用 Remote,定位标签保持通用说法。
@@ -24,6 +24,6 @@ ui-open-in-app 填满这两个 slot:一个「打开 ▾」分体按钮,主
## 后果
每个有 Host 路径的预览文件都能从 Sidebar 本地打开,包括从未交付过的文件。交付卡片和预览在同一项 Host 能力上跑着两套打开器;把它们统一起来,并按界面决定展示完整应用列表还是只展示默认应用,是产品方向点名的下一步。定位菜单项在所有平台都写「显示文件位置」,因为 Session Remote 不报告文件管理器。用默认应用打开带有 Host 文件关联的执行语义;护栏是用户对 Session 已可读文件的明确手势,没有授予任何新的 Host 权限。
每个有经过验证的 Host 映射的预览文件都能从 Sidebar 本地打开,包括从未交付过的文件。没有这种映射的路径会在原生打开前被拒绝。交付卡片和预览在同一项 Host 能力上跑着两套打开器;把它们统一起来,并按界面决定展示完整应用列表还是只展示默认应用,是产品方向点名的下一步。定位菜单项在所有平台都写「显示文件位置」,因为 Session Remote 不报告文件管理器。用默认应用打开带有 Host 文件关联的执行语义;护栏是用户对 Session 已可读文件的明确手势,没有授予任何新的 Host 权限。
单元测试覆盖失败分类、预览对每种出路的渲染以及在 Host 路径未知时对座位的保留、路径控制器的桌面读取与手势结果、两个控件的可见性、忙碌隔离与 toast,以及插件的注册与 fiber 释放时的移除。无密钥的 Web 文档预览场景在 POSIX 主机上用桩打开器驱动不支持后缀的控件,seeded-history 的文件预览 golden 固定住头部控件。
+5 -4
View File
@@ -1,6 +1,7 @@
/** 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 { nativeFileManager } from '@deepseek-ai/dsh-native-command'
import { delimiter, join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { Browser, Locator, Page } from 'playwright'
@@ -20,8 +21,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'
/** The stubbed opener runs as a POSIX script; Windows and WSL keep their real file associations out of the lane. */
const STUB_OPENER = nativeFileManager() !== 'explorer'
const TINY_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
'base64',
@@ -99,7 +100,7 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
openLog = join(nativeRoot, 'opened.jsonl')
await writeFile(openLog, '')
const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
await writeFile(join(nativeRoot, command), `#!${process.execPath}
await writeFile(join(nativeRoot, command), `#!/usr/bin/env node
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';
@@ -111,7 +112,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
// 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({
developerTools: false, replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH,
developerTools: false, replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: [PAGING_PATCH, fileURLToPath(new URL('./fixtures/native-open-on.patch.yml', import.meta.url))],
openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]),
})
browser = await chromium.launch()
+3 -2
View File
@@ -314,7 +314,7 @@ export interface LaunchOptions {
* the scaffold's hermetic test patches, matching the launcher's `--patch`
* ordering.
*/
extraOverlayPath?: string
extraOverlayPath?: string | readonly string[]
/**
* Additional package manifests whose dependency closures supply experimental
* profile layers named by {@link extraOverlayPath}.
@@ -542,7 +542,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
const surfacePatches = loadOverlayPatches('web e2e scaffold', WEB_PATCH_PATH)
const extraOverlayPatches = options.extraOverlayPath === undefined
? []
: loadOverlayPatches('web e2e scaffold', options.extraOverlayPath)
: (typeof options.extraOverlayPath === 'string' ? [options.extraOverlayPath] : options.extraOverlayPath)
.flatMap(path => loadOverlayPatches('web e2e scaffold', path))
const composedRows = composeEntries([basePatches, surfacePatches, extraOverlayPatches])
const webRuntimeConfig = composedRows.find(row => row.id === 'web-runtime')?.config as {
surfaceContext?: boolean
+4 -3
View File
@@ -213,9 +213,10 @@ describe('web e2e: seeded history renders through cold resume', () => {
// 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)),
extraOverlayPath: [
...process.platform === 'win32' ? [] : [fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url))],
fileURLToPath(new URL('./fixtures/native-open-on.patch.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.
-17
View File
@@ -1,17 +0,0 @@
# 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 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session.md
session.md: 7c7e244bfca17c8e22cabe45fc69126113b685d6
session.zh.md: fecae92809bdba93588e163447d372c7a42d0260
session.md: 69f0ee565e47191e2468a857d12eb7d255c9ecdb
session.zh.md: 56a0502fcbccf89625833d116cb2eb2a1fcb49f7
+2 -2
View File
@@ -818,11 +818,11 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | 'explorer' | 'directory' | null }
/**
* Open one path prepared by a Session-aware caller on the Host desktop.
* Verify one path through the composed filesystem and open it on the Host desktop.
* @param request - path after best-effort Session workspace resolution.
* @param signal - caller lifetime; abort terminates the native command.
* @returns confirmation after the native opener accepts the path.
* @throws RemoteError when the request is invalid, cancelled, or the opener fails.
* @throws RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.
*/
@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>
+2 -2
View File
@@ -822,11 +822,11 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspectio
workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | 'explorer' | 'directory' | null }
/**
* Open one path prepared by a Session-aware caller on the Host desktop.
* Verify one path through the composed filesystem and open it on the Host desktop.
* @param request - path after best-effort Session workspace resolution.
* @param signal - caller lifetime; abort terminates the native command.
* @returns confirmation after the native opener accepts the path.
* @throws RemoteError when the request is invalid, cancelled, or the opener fails.
* @throws RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.
*/
@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>
@@ -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/api/session-controller/README.md
README.md: 0ab2a452587da8e818e2744e59232fe67f15f79f
README.zh.md: 6819f07ae0a495896937a7097c5be2dcb0fbefc9
README.md: 498f3028ff55184a1a43681e78a48c565f69d22c
README.zh.md: d5e6c5d4c624955b59c59f644fa17a921e92c7cc
+1 -1
View File
@@ -29,7 +29,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se
The Client journal validates exact V3 event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application.
Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs.
Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank.
+1 -1
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
Client journal 在发布 follow 快照、live entry 或历史页之前验证精确的 V3 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时打开默认应用。
每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。
Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。
+14 -4
View File
@@ -1,11 +1,13 @@
/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */
import { hostname } from 'node:os'
import { resolve } from 'node:path'
import type {} from '@deepseek-ai/dsh-fs'
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-client-file-upload'
import { canOpenNativePath, nativeFileManager, openNativePath, revealNativePath } from '@deepseek-ai/dsh-native-command'
import { canOpenNativePath, nativeFileManager, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
@@ -90,6 +92,7 @@ export class SessionController extends TypertRemoteService {
'agents',
'attachments',
'fileUploads',
'fs',
'llm',
'sessions',
'sessionProjections',
@@ -135,7 +138,7 @@ export class SessionController extends TypertRemoteService {
}, 'session-controller.promotions')
this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
this.listState = new ApiSessionList(ctx)
this.openPath = internals.openPath ?? openNativePath
this.openPath = internals.openPath ?? openNativeAssociatedPath
this.revealPath = internals.revealPath ?? revealNativePath
this.canOpenPath = internals.canOpenPath
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
@@ -284,11 +287,11 @@ export class SessionController extends TypertRemoteService {
}
/**
* Open one path prepared by a Session-aware caller on the Host desktop.
* Verify one path through the composed filesystem and open it on the Host desktop.
* @param request - path after best-effort Session workspace resolution.
* @param signal - caller lifetime; abort terminates the native command.
* @returns confirmation after the native opener accepts the path.
* @throws RemoteError when the request is invalid, cancelled, or the opener fails.
* @throws RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.
*/
@Remote('openWorkspacePath')
async openWorkspacePath(
@@ -304,6 +307,13 @@ export class SessionController extends TypertRemoteService {
}
signal.throwIfAborted()
try {
const hostPath = resolve(request.path)
const { fs } = this.ctx
const mapped = fs.processPathFromHostPath(hostPath)
if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) {
throw new Error('Path has no verified Host path')
}
signal.throwIfAborted()
if (request.action === 'reveal') await this.revealPath(request.path, signal)
else await this.openPath(request.path, signal)
return { opened: true }
@@ -2,6 +2,8 @@ import * as nativeCommand from '@deepseek-ai/dsh-native-command'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import FsLocal from '@deepseek-ai/dsh-fs-local'
import { onTestFinished } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import {
createSessionTestController,
@@ -12,6 +14,8 @@ async function context(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(FsLocal)
onTestFinished(() => ctx.fiber.dispose())
return ctx
}
@@ -74,9 +78,9 @@ describe('session/openWorkspacePath', () => {
openPath,
})
await remote.openWorkspacePath({ path: '/tmp/result.html' })
await remote.openWorkspacePath({ path: '/workspace/result.html' })
await remote.openWorkspacePath({ path: 'result.html' })
expect(openPath.mock.calls.map(call => call[0])).toEqual(['/tmp/result.html', 'result.html'])
expect(openPath.mock.calls.map(call => call[0])).toEqual(['/workspace/result.html', 'result.html'])
})
it('rejects empty paths before opening anything', async () => {
@@ -170,3 +174,44 @@ it('uses the native reveal adapter without a test override and respects unsuppor
expect(reveal).toHaveBeenCalledOnce()
} finally { manager.mockRestore(); reveal.mockRestore(); await ctx.fiber.dispose() }
})
it.each(['open', 'reveal'] as const)('rejects an unmapped remote path before native %s', async (action) => {
const ctx = await context()
const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
onTestFinished(() => mapping.mockRestore())
const openPath = vi.fn(async () => {})
const revealPath = vi.fn(async () => {})
const controller = createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath,
})
await expect(controller.openWorkspacePath({ path: '/remote/report.html', ...(action === 'reveal' ? { action } : {}) }, new AbortController().signal))
.rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' })
expect(openPath).not.toHaveBeenCalled()
expect(revealPath).not.toHaveBeenCalled()
})
it('rejects a filesystem mapping that resolves to another process path', async () => {
const ctx = await context()
const mapping = vi.spyOn(ctx.fs, 'processPath').mockReturnValue('/different/report.html')
onTestFinished(() => mapping.mockRestore())
const openPath = vi.fn(async () => {})
const controller = createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath,
})
await expect(controller.openWorkspacePath({ path: '/report.html' }, new AbortController().signal))
.rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' })
expect(openPath).not.toHaveBeenCalled()
})
it('dispatches default-app opening to the association adapter', async () => {
const ctx = await context()
const open = vi.spyOn(nativeCommand, 'openNativeAssociatedPath').mockResolvedValue(undefined)
onTestFinished(() => open.mockRestore())
const controller = createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true,
})
const signal = new AbortController().signal
await controller.openWorkspacePath({ path: '/report.html' }, signal)
expect(open).toHaveBeenCalledWith('/report.html', signal)
})
@@ -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: 91d259c73ad73460bca151d9e4cacfb6338c62ab
README.zh.md: 30d28f0681c6abfd7b3ea3ab3dc341df7fb3bdaf
README.md: 4014b8c0e62376e7c26265a7a7d08926ee669484
README.zh.md: 0244533c52585cec23a5e1c3cadbf47f4f4d0a49
+3 -1
View File
@@ -43,7 +43,7 @@ In the document preview, the header's "Open ▾" split button opens the previewe
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.
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 file’s absolute execution-environment 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>
@@ -71,6 +71,8 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
The Host verifies the path through the composed filesystem before opening or revealing it. Paths without a matching Host mapping fail without launching a native application. Default opening follows the file-type association, including HTML and SVG.
<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.
+4 -2
View File
@@ -31,7 +31,7 @@ kind: "package-reference"
主按钮显示记住的应用图标——凡主机能提取的都是应用真实图标(macOS bundle 图标、Windows 可执行文件图标、Linux 主题图标),提取不到时是通用占位图形——并带设计系统 tooltip(「在本地打开」);点击立即启动。下拉箭头打开已安装应用的紧凑菜单,记住的条目以整行填充标记。可用性每页读取一次;上次选择的应用持久化在浏览器中(`dsh.open-in-app.choice`),不再安装的选择回退到第一个可用条目。快速完成的启动不改变按钮外观——变暗的等待态只在飞行超过 250 毫秒后出现——失败的启动显示错误 tooltip 与红色描边两秒。所有文案在双语 `open-in-app` locale 命名空间中;词典无法命名的应用 id 不会被提供。
在文档预览里,头部的「打开 ▾」分体按钮的主按钮用 Host 默认应用打开当前文件,菜单提供同一动作和「显示文件位置」(文件管理器的定位)。预览无法渲染的文件,无论是视频、压缩包这类不支持的后缀,还是被读取器判为非文本或过大的文件,都会在空态里原本放重试的位置显示「使用默认应用打开」按钮。两个控件都把文件元数据报出的绝对路径交给 Host,只在 Host 回答有桌面之后出现,只在自己的手势结算期间禁用,失败时通过短暂 toast 提示一次(「打开失败,请重试」或「无法显示文件位置,请重试」),之后控件上不残留任何状态。
在文档预览里,头部的「打开 ▾」分体按钮的主按钮用 Host 默认应用打开当前文件,菜单提供同一动作和「显示文件位置」(文件管理器的定位)。预览无法渲染的文件,无论是视频、压缩包这类不支持的后缀,还是被读取器判为非文本或过大的文件,都会在空态里原本放重试的位置显示「用默认应用打开」按钮。两个控件都把文件元数据报出的绝对路径交给 Host,只在 Host 回答有桌面之后出现,只在自己的手势结算期间禁用,失败时通过短暂 toast 提示一次(「打开失败,请重试」或「无法显示文件位置,请重试」),之后控件上不残留任何状态。
-----
@@ -43,7 +43,7 @@ kind: "package-reference"
插件通过标准 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`,让插件出现在主机侧的插件名册上。
文件控件注册在文档预览的 `sidebar.right.tab.document.actions` 与 `sidebar.right.tab.document.unpreviewable` 子 slot 上,owner props 携带文件在执行环境中的绝对路径。第二个页面生命周期的 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>
@@ -71,6 +71,8 @@ kind: "package-reference"
## 已知限制与延后工作
Host 在打开或定位前通过当前文件系统验证路径。没有对应 Host 映射的路径会失败,不会启动原生应用。默认打开遵循文件类型关联,包括 HTML 和 SVG。
<a id="known-limitations-and-deferred-work"></a>
- **词典把守菜单。** 主机目录的新条目若在两份词典中没有对应的 `app.<id>` 条目,将保持不可见而不是显示裸 id;扩展目录意味着同时扩展 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 与本包的 locale。
@@ -54,9 +54,3 @@
.chevron:disabled {
cursor: default;
}
.menuIcon {
display: block;
width: 16px;
height: 16px;
}
@@ -94,7 +94,7 @@ export function OpenPathAction(props: OpenPathActionProps): ReactNode {
align="end"
onClose={() => { setMenuOpen(false) }}
items={[
{ id: 'open', icon: <IconRightUpOutlineRegular size={16} className={css.menuIcon} />, label: t('path.defaultApp') },
{ id: 'open', icon: <IconRightUpOutlineRegular />, label: t('path.defaultApp') },
{ id: 'reveal', icon: <IconFolderOpenOutlineRegular />, label: t('path.reveal') },
]}
onSelect={(id) => { run(id === 'reveal' ? 'reveal' : 'open') }}
@@ -21,7 +21,6 @@
background: var(--dsw-alias-interactive-bg-hover);
}
/* Pending disables clicks without a restyle; only hover feedback pauses. */
.open:disabled {
cursor: default;
}
@@ -49,7 +49,7 @@ export const zh = {
'path.more': '更多打开方式',
'path.defaultApp': '用默认应用打开',
'path.reveal': '显示文件位置',
'path.unpreviewable': '使用默认应用打开',
'path.unpreviewable': '用默认应用打开',
'path.openError': '打开失败,请重试',
'path.revealError': '无法显示文件位置,请重试',
...PRODUCT_NAMES,
@@ -67,7 +67,7 @@ export const en: Record<OpenInAppKey, string> = {
'menu.toggle': 'Choose an app to open in',
'menu.aria': 'Open in',
'path.open': 'Open',
'path.open.tooltip': 'Open in the default app',
'path.open.tooltip': 'Open in default app',
'path.more': 'More ways to open',
'path.defaultApp': 'Open in default app',
'path.reveal': 'Show file location',
@@ -1,22 +1,23 @@
/** Per-control failure banner for path gestures. */
import { useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { IconWarningOutlineRegular, 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
* @returns `toast` owned by 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)
const dismiss = useCallback(() => { setBanner(null) }, [])
return {
toast: banner === null
? null
: <Toast key={banner.seq} text={banner.text} icon={<IconWarningOutlineRegular />} onDone={() => { setBanner(null) }} />,
: <Toast key={banner.seq} text={banner.text} icon={<IconWarningOutlineRegular />} onDone={dismiss} />,
show: (text) => {
seq.current += 1
setBanner({ seq: seq.current, text })
@@ -18,6 +18,7 @@ import { en, NS, zh } from '../src/client/locales.ts'
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
/** The Session Remote slice the path controls call; answers a desktop and acknowledges every gesture. */
@@ -8,7 +8,6 @@ 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'
@@ -21,7 +20,6 @@ afterEach(() => {
})
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 {
@@ -39,7 +37,6 @@ function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] }
return select => select(source.getSnapshot())
}
const props = {
file: FILE,
absolutePath: ABSOLUTE_PATH,
useOpenInAppDesktop: useSelector(desktop),
loadDesktop,
@@ -130,3 +127,16 @@ describe('OpenPathEmptyAction', () => {
expect(screen.queryByRole('alert')).toBeNull()
})
})
it('dismisses a failure on schedule even when its owner rerenders', async () => {
vi.useFakeTimers()
const b = bench({ openPath: vi.fn(async () => 'openError' as const) })
const view = render(<OpenPathEmptyAction {...b.props} />)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) })
act(() => { vi.advanceTimersByTime(2_000) })
view.rerender(<OpenPathEmptyAction {...b.props} />)
act(() => { vi.advanceTimersByTime(1_999) })
expect(screen.getByRole('alert')).toBeTruthy()
act(() => { vi.advanceTimersByTime(1) })
expect(screen.queryByRole('alert')).toBeNull()
})
@@ -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: 8564a6f271f469039aa4a08b2651de8ba6ac8309
README.zh.md: 82ec9ac6b2d4cbc099189cef8e12d3ca7efaa3a1
README.md: d635e241ae15ccbdd4a8a9946360f080ec3263a1
README.zh.md: e0ec8d6815b467bb8898d17a9ae0a69e15310ebd
@@ -9,8 +9,8 @@ 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. Office documents convert locally to PDF. 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`.
Preview files in the right Sidebar and choose among registered renderers. Markdown and code support paged text; PDF, HTML, and common images receive complete bytes; unknown extensions use plain text. Office documents convert locally to PDF. The tab provides file status, renderer selection, wrap, and reload. Plugins can add local opening controls to the header and unsupported-preview empty state.
## Table of Contents
- [What it registers](#what-it-registers)
@@ -28,7 +28,7 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo
## 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 appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing 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.
- **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 appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `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` lists suffixes in `extensions` that cannot be read as text and omit the plain-text option. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks. An inner scrolling element attaches `scrollportRef`; unmounting restores the shared body as scroll owner. 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. HTML, SVG, and unmatched extensions retain the plain-text fallback independently of loading mode.
@@ -9,8 +9,8 @@ kind: "package-reference"
## 概述
在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF、HTML 和常见图片接收完整字节;未知文件扩展名使用纯文本。Office 文档在本地转换为 PDF。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。另有两个子 slot 让别的插件从头部和从无法渲染文件的空态把当前文件交给 Host 桌面。Sidebar tab 的 kind 为 `text`。
在右侧 Sidebar 预览文件,并切换已注册的渲染器。Markdown 和代码支持分页文本;PDF、HTML 和常见图片接收完整字节;未知扩展名使用纯文本。Office 文档在本地转换为 PDF。tab 提供文件状态、渲染器选择、换行和重新载入。插件可在头部和不支持预览的空态中添加本地打开控件。
## 目录
- [注册了什么](#what-it-registers)
@@ -28,7 +28,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 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 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 与代码滚动区终止于格的边缘。
- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `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`;卸载时恢复共享正文的滚动职责。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变。HTML、SVG 和未匹配的扩展名保留纯文本回退,与加载方式无关。
@@ -124,7 +124,7 @@ export function TextPreview({
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 }
const fileOwner = absolutePath === undefined ? undefined : { 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.
@@ -2,7 +2,6 @@
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 {
@@ -62,9 +61,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
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. */
/** Absolute path in the file's execution environment; native actions must verify a Host mapping. */
readonly absolutePath: string
}
}
@@ -76,9 +73,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
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. */
/** Absolute path in the file's execution environment; native actions must verify a Host mapping. */
readonly absolutePath: string
}
}
@@ -19,7 +19,7 @@ import type { WorkspaceFileStat, WorkspaceFileText } from '@deepseek-ai/dsh-api-
import type { TextPreviewProps } from '../src/client/TextPreview.tsx'
import { textFace } from '../src/client/face.ts'
import type { TextInjected } from '../src/client/face.ts'
import type { ReadDocumentBytes, ReadWorkspaceFilePage, SessionFile } from '../src/client/rpc.ts'
import type { ReadDocumentBytes, ReadWorkspaceFilePage } from '../src/client/rpc.ts'
import { createTextStore } from '../src/client/store.ts'
import type { TextStore } from '../src/client/store.ts'
import type { DocumentPreviewProps } from '../src/client/document/contract.ts'
@@ -31,7 +31,7 @@ type BodySlot = PropsRenderSlots<'sidebar.right.tab.document'>['renderSlot']
/** Preserve the body-slot callback used by component fixtures. */
export function documentSlots(body: BodySlot): TextPreviewProps['renderSlot'] {
return (name, owner, options) => name === 'sidebar.right.tab.document'
return (name: string, owner: unknown, options?: unknown) => name === 'sidebar.right.tab.document'
? body(name, owner as unknown as Parameters<BodySlot>[1], options as Parameters<BodySlot>[2])
: null
}
@@ -141,7 +141,6 @@ export function harness(script: Record<number, RemoteResult<WorkspaceFileText>>
} as unknown as DocumentPreviewProps)
: key === 'sidebar.right.tab.document.action' ? null : 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 }) => ({
@@ -46,7 +46,7 @@ it('lets a non-Office renderer load content, report its version, and reload thro
useEffect(() => { if (displayed !== undefined) request?.loaded(displayed.version) }, [displayed, request?.loaded])
return <p>{displayed?.text ?? 'Loading custom content'}</p>
}
const renderSlot: TextPreviewProps['renderSlot'] = (name, input) => {
const renderSlot: TextPreviewProps['renderSlot'] = (name: string, input: unknown) => {
if (name !== 'sidebar.right.tab.document') return null
const owner = input as unknown as OwnerOf<'sidebar.right.tab.document'>
return <CustomBody content={owner.content} />
@@ -101,9 +101,9 @@ function setup() {
}
const describeFailure: OfficeBodyProps['describeFailure'] = error => error.message
let request: Extract<DocumentContent, { kind: 'renderer' }> | undefined
const slots: TextPreviewProps['renderSlot'] = (key, input, options) => {
const slots: TextPreviewProps['renderSlot'] = (key: string, input: unknown, options?: { hookContext?: unknown }) => {
if (key === 'sidebar.right.tab.document.action') {
return <OfficeFontAction {...{ ...input, useTabInfo: options.hookContext, useStore: useOffice,
return <OfficeFontAction {...{ ...input, useTabInfo: options?.hookContext, useStore: useOffice,
actions: office.actions, t: makeTranslate(en) } as unknown as OfficeFontActionProps} />
}
if (key !== 'sidebar.right.tab.document') return null
@@ -111,7 +111,7 @@ function setup() {
if (owner.content.kind !== 'renderer') return <p>Raw bytes</p>
request = owner.content
// The component fixture supplies the standard seats used by Office; the real slot binding is exercised by the browser scenario.
const props = { ...h.props(), ...owner, useTabInfo: options.hookContext, useStore: useOffice,
const props = { ...h.props(), ...owner, useTabInfo: options?.hookContext, useStore: useOffice,
actions: office.actions, read, retainTab, describeFailure,
t: makeTranslate(en), renderSlot: (_name: string, child: { content: DocumentContent }) => (
<p data-test-pdf>{child.content.kind === 'bytes' ? new TextDecoder().decode(child.content.data) : ''}</p>
@@ -280,7 +280,6 @@ describe('TextPreview — pages', () => {
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"]')
@@ -303,6 +302,11 @@ describe('TextPreview — pages', () => {
await settle()
expect(view.container.querySelector('[data-textpreview-failed]')).not.toBeNull()
expect(view.container.querySelector('[data-slot]')).toBeNull()
h.script(1, page(1, ['reloaded'], true))
click(view.container, '[data-textpreview-tool="reload"]')
await settle()
expect(h.read).toHaveBeenCalledTimes(2)
expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull()
})
it('says why a later page failed on a line under the pages already read', async () => {
@@ -2735,7 +2735,7 @@ 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:46',
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:45',
},
{
key: 'sidebar.right.tab.document.action',
@@ -2783,7 +2783,7 @@ 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.action\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.action\', 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:86',
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:81',
},
{
key: 'sidebar.right.tab.document.actions',
@@ -2811,12 +2811,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
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',
],
ownerProps: [],
ownerPropsReferences: [],
standardProps: [
'useResource: UseResource',
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
@@ -2843,7 +2839,7 @@ 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.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:61',
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:60',
},
{
key: 'sidebar.right.tab.document.office.pdf',
@@ -2919,12 +2915,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
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',
],
ownerProps: [],
ownerPropsReferences: [],
standardProps: [
'useResource: UseResource',
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
@@ -2951,7 +2943,7 @@ 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.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:75',
source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:72',
},
{
key: 'sidebar.right.tab.guide',
@@ -1720,10 +1720,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>',
description: 'Open one path prepared by a Session-aware caller on the Host desktop.',
description: 'Verify one path through the composed filesystem and open it on the Host desktop.',
parameters: [{ name: 'request', description: 'path after best-effort Session workspace resolution.' }, { name: 'signal', description: 'caller lifetime; abort terminates the native command.' }],
returns: 'confirmation after the native opener accepts the path.',
throws: ['RemoteError when the request is invalid, cancelled, or the opener fails.'],
throws: ['RemoteError when the request is invalid, has no verified Host mapping, is cancelled, or the opener fails.'],
},
{
signature: '@Remote(\'rename\') rename(request: SessionRenameRequest): Promise<SessionRenameValue>',
@@ -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/util/native-command/README.md
README.md: 10b60266403f6a479a286cccd369ee2e52dd3aa6
README.zh.md: e8f2adda57ee257983a06bb2798c852cab20bf6a
README.md: 9abf4031a915121b6fd2a218473b27c864f8ced8
README.zh.md: 083318cf4bb37e1c18f016512d58df8bb8f3aec5
+1 -1
View File
@@ -45,7 +45,7 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ
### Opening a Host path
`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target.
`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeAssociatedPath(path, signal)` always uses the file-type association, without a browser override. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target.
`revealNativePath(path, signal)` selects the file in Finder or Explorer, including WSL path translation, and opens its parent directory through `xdg-open` on desktop Linux. `nativeFileManager()` identifies that action for Host-derived UI labels; desktop availability remains a separate `canOpenNativePath()` check. Callers must authorize the absolute file path before invoking either operation. Platform dispatch is covered by injected-runner tests; native desktop verification belongs to the corresponding platform. Explorer receives an encoded file URI as a separate argument. Its exit code 1 is accepted as a delegated handoff; cancellation, missing executables, and other exit codes still reject. This acknowledgement does not prove that a desktop window selected the file.
+1 -1
View File
@@ -45,7 +45,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s
### 打开 Host 路径
`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。
`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeAssociatedPath(path, signal)` 始终使用文件类型关联,不覆盖为浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。
`revealNativePath(path, signal)` 在 Finder 或文件资源管理器中选中文件,包含 WSL 路径转换;在桌面 Linux 上通过 `xdg-open` 打开上层目录。`nativeFileManager()` 标识该操作,供 UI 根据 Host 选择文案;桌面是否可用仍由独立的 `canOpenNativePath()` 检查决定。调用方必须先授权绝对文件路径,再执行操作。平台分派由注入运行器的测试覆盖;原生桌面验证由对应平台负责。 Explorer 接收独立参数中的编码文件 URI。退出码 1 按已转交请求处理;取消、找不到可执行文件和其他退出码仍然报错。该确认不能证明桌面窗口已选中文件。
@@ -10,6 +10,7 @@ export {
nativeFileManager,
revealNativePath,
openNativePath,
openNativeAssociatedPath,
openNativeTextFile,
} from './path-opener.ts'
export type {
@@ -80,7 +80,7 @@ async function openInBrowser(
}
/** Native path-open intent; macOS distinguishes text editing from file association. */
type PathOpenIntent = 'default' | 'text-editor'
type PathOpenIntent = 'default' | 'association' | 'text-editor'
/** PowerShell single-quoted literal (doubles embedded quotes). */
function powershellLiteral(path: string): string {
@@ -188,6 +188,19 @@ export function openNativePath(
return openNativePathWithIntent(path, signal, 'default', internals)
}
/**
* Open a filesystem path through its file-type association, including HTML and SVG.
* @param path - absolute or host-resolvable path; the caller verifies local access.
* @param signal - caller lifetime; abort terminates the native command.
* @param internals - platform, environment, and runner facts for adapter tests.
* @returns after the associated application accepts the path.
*/
export function openNativeAssociatedPath(
path: string, signal: AbortSignal, internals: PathOpenerInternals = {},
): Promise<void> {
return openNativePathWithIntent(path, signal, 'association', internals)
}
/**
* Open a text document for editing; macOS bypasses the file-type association
* so a YAML association with a browser cannot consume the gesture.
@@ -17,7 +17,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { release as osRelease } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import { canOpenNativePath, nativeFileManager, revealNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/index.ts'
import { canOpenNativePath, nativeFileManager, revealNativePath, openNativeAssociatedPath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/index.ts'
const signal = () => new AbortController().signal
@@ -413,3 +413,16 @@ it.each([
await revealNativePath(path, signal(), { platform: 'win32', run })
expect(run).toHaveBeenCalledWith('explorer.exe', ['/select,', target], expect.any(AbortSignal))
})
it.each(['.html', '.svg'])('uses the file association for %s despite a configured browser', async (extension) => {
const path = `/work/report${extension}`
for (const platform of ['darwin', 'linux'] as const) {
const run = vi.fn<PathOpenerRunner>(async () => ({ stdout: 'browser bundle', stderr: '' }))
const lifetime = signal()
await openNativeAssociatedPath(path, lifetime, {
platform, osRelease: '6.8.0-generic', env: { BROWSER: 'custom-browser' }, run,
})
expect(run.mock.calls).toEqual([[platform === 'darwin' ? 'open' : 'xdg-open', [path], lifetime]])
}
})
@@ -3,9 +3,5 @@
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
- id: office-to-pdf
disabled: true