fix: type shared opening fixtures and document default actions

This commit is contained in:
creatixchu committed 2026-09-20 11:48:58 +08:00
1 parent e969aaff58
commit 5c409c39bc
6 files changed
+15 -17

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 packages/client/ui-open-in-app/README.md
README.md: a21a0b3bf3cc5dabe79dc2aa907640d66c8b25c5
README.zh.md: cbbefaa2d619f591167f7b7775b2eb4c2c945ff1
README.md: e38909417c715e6b953dfe54c24b85dda351045c
README.zh.md: 301e9cf9bba736c9056a5df3cb59b320f998f500
+4 -4
View File
@@ -29,11 +29,11 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h
### 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.
The Session header and document header use the same 24px-high, icon-only split button with 9px corners. Both show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application.
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.
File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” always stays last. When no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file’s “Open” button uses the same default action.
The document header shows the default application’s icon when available. Its menu lists the file’s OS-registered applications and marks the default. Choosing an application opens this file once; the main button continues to use the system default. Associations refresh when the file or menu visibility changes; cancelled queries cannot replace another file’s results. A failed query displays a menu message while default opening and reveal remain available. Platform coverage follows [native-command](../../util/native-command/README.md).
File associations refresh when the file changes or its menu opens; cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control.
-----
@@ -45,7 +45,7 @@ The document header shows the default application’s icon when available. Its m
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. Document-relative route forms and wire payload types come 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 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.
The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while their native operations pass through [`open-path.ts`](src/client/open-path.ts) and the Session Remote. The directory adapter keeps the existing catalog routes. A failed or unavailable file query therefore needs no platform-specific UI implementation.
</details>
+4 -4
View File
@@ -29,11 +29,11 @@ kind: "package-reference"
### 预期行为
主按钮显示记住的应用图标——凡主机能提取的都是应用真实图标(macOS bundle 图标、Windows 可执行文件图标、Linux 主题图标),提取不到时是通用占位图形——并带设计系统 tooltip(「在本地打开」);点击立即启动。下拉箭头打开已安装应用的紧凑菜单,记住的条目以整行填充标记。可用性每页读取一次;上次选择的应用持久化在浏览器中(`dsh.open-in-app.choice`),不再安装的选择回退到第一个可用条目。快速完成的启动不改变按钮外观——变暗的等待态只在飞行超过 250 毫秒后出现——失败的启动显示错误 tooltip 与红色描边两秒。所有文案在双语 `open-in-app` locale 命名空间中;词典无法命名的应用 id 不会被提供。
会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的纯图标分体按钮。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。
在文档预览里,头部的「打开 ▾」分体按钮的主按钮用 Host 默认应用打开当前文件,菜单提供同一动作和「显示文件位置」(文件管理器的定位)。预览无法渲染的文件,无论是视频、压缩包这类不支持的后缀,还是被读取器判为非文本或过大的文件,都会在空态里原本放重试的位置显示「用默认应用打开」按钮。两个控件都把文件元数据报出的绝对路径交给 Host,只在 Host 回答有桌面之后出现,只在自己的手势结算期间禁用,失败时通过短暂 toast 提示一次(「打开失败,请重试」或「无法显示文件位置,请重试」),之后控件上不残留任何状态。
文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”始终放在最后。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态中的“打开”按钮也使用相同的默认动作。
文档标题栏在可用时显示默认应用图标。菜单列出系统为该文件注册的应用,并标记默认项。选择应用只用于本次打开,主按钮始终使用系统默认应用。文件或菜单显示状态变化时会重新查询关联应用;已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,默认打开和定位仍可使用。平台支持范围见 [native-command](../../util/native-command/README.zh.md)。
文件变化或打开菜单时会重新查询文件关联;已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。
-----
@@ -45,7 +45,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 携带文件在执行环境中的绝对路径。第二个页面生命周期的 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`,让插件出现在主机侧的插件名册上。
目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,原生操作通过 [`open-path.ts`](src/client/open-path.ts) 与 Session Remote 执行。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。
</details>
@@ -58,7 +58,7 @@ export function FileOpenTarget(props: FileOpenTargetProps): ReactNode {
return (
<OpenTargetButton
key={props.absolutePath} kind="file" applications={apps ?? []} defaultId={apps?.find(app => app.default)?.id}
failed={association?.path === props.absolutePath && apps === null} empty={props.empty} t={props.t}
failed={association?.path === props.absolutePath && apps === null} empty={props.empty === true} t={props.t}
refresh={() => { setRevision(value => value + 1) }}
execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open',
operation.kind === 'application' ? operation.id : undefined)}
@@ -29,15 +29,13 @@ function bench(over: {
cwd?: string
launch?: (appId: string, path: string) => Promise<void>
} = {}): Bench {
const state = {
const state: SessionListState = {
ids: [SESSION],
byId: over.cwd === undefined ? {} : { [SESSION]: { cwd: over.cwd } },
current: SESSION,
byId: over.cwd === undefined ? {} : { [SESSION]: { id: SESSION, displayTitle: 'Workspace', cwd: over.cwd, running: false, retainedBy: {}, blank: false, updatedAt: 0 } },
phase: 'ready',
projectionsBySession: {},
jobsBySession: {},
currentAddress: undefined,
} as SessionListState
}
const apps = createSnapshotStore<readonly string[] | null>(over.apps ?? null)
const choice = createSnapshotStore<string>(over.choice ?? '')
const launch = vi.fn(over.launch ?? (async () => {}))
@@ -195,7 +195,7 @@ it('keeps reveal last and makes it the default when the file query fails', async
expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal', undefined)
})
it.each([[], [{ id: '/Player.app', name: 'Player', default: false, icon: null }]])('uses reveal as the default after the available applications when none is default', async (apps) => {
it.each([{ apps: [] }, { apps: [{ id: '/Player.app', name: 'Player', default: false, icon: null }] }])('uses reveal as the default after the available applications when none is default', async ({ apps }) => {
const b = bench()
render(<OpenPathAction {...b.props} applications={async () => apps} />)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) })