feat(open-in-app): 某类型第一次右键时最多等 300 ms 关联应用再弹出菜单

A file type without a recorded association answer now holds the file menu until the background query settles, 300 ms pass, or the menu closes, so the application row usually opens already named instead of changing after the menu appears. Later openings of the type still show the recorded answer at once and refresh in the background. Open menus track the answer they last applied per verification, so answers for other types never re-resolve them. README documents the wait and its first-opening delay.
This commit is contained in:
yudshj committed 2026-11-06 15:30:04 +08:00
1 parent ea7c21c8a4
commit 54387e9278
6 files changed
+116 -32

No files matched your search

@@ -18,11 +18,11 @@
en: b149da9963eb9837
zh: 2426ea37fbd0f8b7
/deepseek-ai-dsh-client-ui-open-in-app/use-this-package/what-to-expect:
en: 53d5d74d5b1d9b66
zh: bdd9806dca51e957
en: c9022effccebd157
zh: 54e76ec13b5c1029
/deepseek-ai-dsh-client-ui-open-in-app/understand-the-implementation:
en: c26d8937f5d38119
zh: d353c2377b9747aa
en: 423b66c7f36f7c40
zh: 9b891fb7c8a8d240
/deepseek-ai-dsh-client-ui-open-in-app/further-exploration:
en: 720527877e452a97
zh: b2241c7a336a3eeb
@@ -33,8 +33,8 @@
en: ca75c51c89c2c9b0
zh: c2241315dc0c89c9
/deepseek-ai-dsh-client-ui-open-in-app/known-limitations-and-deferred-work:
en: f206d2fefa52f81d
zh: 51025a6e19d01295
en: 31af13d29ff76a1c
zh: 6a08bd80d71c6434
/deepseek-ai-dsh-client-ui-open-in-app/known-limitations-and-deferred-work/dev-note:
en: 84f92568fc74adf1
zh: 286b47e6b5b1a556
+3 -2
View File
@@ -43,7 +43,7 @@ Mounted controls with the same association reader and file share one query and i
Workspace context menus reveal the captured folder through the shared directory application catalog. File-tree directories expose native reveal and Copy folder path; they do not offer file preview or save.
Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session submenus resolve the captured Session ID’s directory rather than the main view, and observe application, operation, Session-list, and locale changes. Native operations report through the feature’s global toast after menu dismissal or target changes, without a second registry toast. Native file actions resolve Session paths through authorized metadata, then name the application recorded for the file’s type without waiting for the Host. Until the Host first answers for that type, the row reads “Open in default application” and opens the file through its OS association. Each opening re-queries the Host in the background; a changed answer updates the open menu in place, and a failed query keeps the recorded answer. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast.
Context menus contribute file preview, native application opening, original-file reveal, and URL navigation and copying through [ui-context-menu](../ui-context-menu/README.md). Session submenus resolve the captured Session ID’s directory rather than the main view, and observe application, operation, Session-list, and locale changes. Native operations report through the feature’s global toast after menu dismissal or target changes, without a second registry toast. Native file actions resolve Session paths through authorized metadata, then name the application recorded for the file’s type without waiting for the Host. A type’s first opening on the page waits up to 300 ms for the Host’s answer; if none arrives by then, the row reads “Open in default application” and opens the file through its OS association. Each opening re-queries the Host in the background; a changed answer updates the open menu in place, and a failed query keeps the recorded answer. Missing files disable path actions; image reveal requires an actual Host file and always targets that original. [ui-file-actions](../ui-file-actions/README.md) owns copy, download, save-as, and their completion feedback, including revealing the saved destination from its toast.
File menus copy absolute paths without line numbers; URL copying preserves query and fragment. Image files also expose image copy and original download, alongside the existing file actions. Successful downloads and saves show the shared Toast; picker cancellation is silent.
@@ -59,7 +59,7 @@ A file menu offers “Open in sidebar” and “Open in floating panel”, which
[`session-open-with.ts`](src/client/session-open-with.ts) shares one resolver across the shared context-menu package’s `session` and `sessionHeader` targets; other contributions do not cross between those targets. The file-tree slot uses `OpenInAppAction` with its owner’s `absolutePath`; there is no independent Session-header opener slot. A page-lifetime [`controller`](src/client/controller.ts) owns availability, persisted choice, and launch POST state. Menu observers and injected component hooks read those same sources. Document-relative routes and payload types come from the Host’s browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. Adapters resolve native and transport failures to named outcomes; skipped requests announce nothing.
The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, and sizing. Its injected reporter sends outcomes to one feature-owned `shell.overlay` toast, so switching Sessions or closing the initiating preview cannot discard the result. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). The same controller keeps the page’s answers by association class, runs one background refresh at a time, which the next opening or plugin unload cancels, and publishes a new snapshot only when an answer changes. The file menu observes that snapshot; when its own file type’s answer changes, it re-resolves synchronously from the path its opening verified, so the context-menu registry keeps the other rows while the application row changes. Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation.
The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, and sizing. Its injected reporter sends outcomes to one feature-owned `shell.overlay` toast, so switching Sessions or closing the initiating preview cannot discard the result. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). The same controller keeps the page’s answers by association class, runs one background refresh at a time, which the next opening or plugin unload cancels, and publishes a new snapshot only when an answer changes. The file menu observes that snapshot; when its own file type’s answer changes, it re-resolves synchronously from the path its opening verified, so the context-menu registry keeps the other rows while the application row changes. A type without a recorded answer holds the file contribution until its refresh settles, 300 ms pass, or the menu closes; the registry opens the menu once every contribution is ready, so the whole menu waits. Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation.
</details>
@@ -94,6 +94,7 @@ The Host verifies the path through the composed filesystem before opening or rev
- **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); the desktop answer behind the file controls is read once per page as well.
- **File associations are cached by type for the page.** Files sharing a final name extension share one answer; extensions that differ only in case, such as `.PNG` and `.png`, are separate types. A per-document override, such as a macOS “Open with” choice for one file, can therefore show the type’s previous answer until the background query corrects the open menu. Linux content sniffing and multi-part extensions such as `.tar.gz` have the same effect, and one file’s empty answer shows the row disabled for the type’s other files the same way. Choosing the row before the correction opens the application that answer named. Names without an extension are recorded per path. Records, with one icon per application, grow with the file types seen and end with the page.
- **A type’s first file menu can open late.** The whole menu waits up to 300 ms for that type’s first answer on the page, and a slower answer still changes the row after opening.
- **One background refresh at a time.** Opening another file menu cancels the previous menu’s unanswered query, so that type keeps its earlier answer, or none, until its next opening.
- **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 remains deferred.
+3 -2
View File
@@ -43,7 +43,7 @@ kind: "package-reference"
工作区右键菜单通过共享的目录应用列表定位被点击的文件夹。文件树目录提供原生定位与复制文件夹路径,不提供文件预览或保存。
右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话子菜单解析被捕获会话 ID 的目录,不使用主视图,并观察应用、操作、会话列表及语言变化。原生操作在菜单关闭或目标切换后仍通过本功能的全局 toast 报告,不产生第二个注册表提示。原生文件操作先通过授权元数据解析会话路径,然后直接显示该文件类型已记录的应用,不等待 Host。Host 首次回答该类型之前,这一项显示“使用默认应用打开”,通过系统的文件关联打开文件。每次打开菜单都会在后台重新查询 Host;回答有变化时,已打开的菜单原地更新,查询失败则保留已记录的回答。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及完成提示,包括通过提示定位保存目标。
右键菜单通过 [ui-context-menu](../ui-context-menu/README.zh.md) 贡献文件预览、原生应用打开、原文件定位以及链接导航与复制。会话子菜单解析被捕获会话 ID 的目录,不使用主视图,并观察应用、操作、会话列表及语言变化。原生操作在菜单关闭或目标切换后仍通过本功能的全局 toast 报告,不产生第二个注册表提示。原生文件操作先通过授权元数据解析会话路径,然后直接显示该文件类型已记录的应用,不等待 Host。某个类型在本页面第一次打开菜单时,最多等待 Host 回答 300 毫秒;到时仍未回答,这一项显示“使用默认应用打开”,通过系统的文件关联打开文件。每次打开菜单都会在后台重新查询 Host;回答有变化时,已打开的菜单原地更新,查询失败则保留已记录的回答。文件不存在时禁用路径操作;图片定位要求实际 Host 文件,并始终针对原文件。[ui-file-actions](../ui-file-actions/README.zh.md) 负责复制、下载、另存为及完成提示,包括通过提示定位保存目标。
文件菜单复制不含行号的绝对路径;网址复制保留查询参数与锚点。图片文件在既有文件操作之外,还提供复制图片与下载原图。下载和保存成功后显示共享 Toast,取消选择器时不提示。
@@ -59,7 +59,7 @@ kind: "package-reference"
[`session-open-with.ts`](src/client/session-open-with.ts) 为共享右键菜单包声明的 `session` 和 `sessionHeader` 目标共用一个解析器;其他贡献不会在这两种目标间扩散。文件树插槽通过所属组件的 `absolutePath` 使用 `OpenInAppAction`;会话头部没有独立打开按钮插槽。页面生命周期的 [`controller`](src/client/controller.ts) 持有可用性、持久化偏好与启动 POST 状态。菜单观察器和组件注入 hooks 读取同一组源。文档相对路由与载荷类型来自 Host 的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。适配器将原生操作及传输失败解析为具名结果;跳过的请求不播报。
目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标和尺寸。注入的回调将结果交给本功能在 `shell.overlay` 中持有的唯一 toast,因此切换会话或关闭发起操作的预览不会丢失结果。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。同一控制器按关联类别保存本页面的回答,同一时间只运行一次后台刷新(下一次打开菜单或插件卸载会取消尚未完成的刷新),只在回答变化时发布新快照。文件菜单观察该快照;只有本文件类型的回答变化时,才基于本次打开时验证的路径同步重新解析,因此右键菜单注册表保留其他各项,只有应用这一项原地变化。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。
目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标和尺寸。注入的回调将结果交给本功能在 `shell.overlay` 中持有的唯一 toast,因此切换会话或关闭发起操作的预览不会丢失结果。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。同一控制器按关联类别保存本页面的回答,同一时间只运行一次后台刷新(下一次打开菜单或插件卸载会取消尚未完成的刷新),只在回答变化时发布新快照。文件菜单观察该快照;只有本文件类型的回答变化时,才基于本次打开时验证的路径同步重新解析,因此右键菜单注册表保留其他各项,只有应用这一项原地变化。尚无记录的类型会让文件菜单项等到这次刷新结束、满 300 毫秒或菜单关闭;注册表在所有菜单项都就绪后才打开菜单,所以整个菜单都会等待。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。
</details>
@@ -94,6 +94,7 @@ Host 在打开或定位前通过当前文件系统验证路径。没有对应 Ho
- **词典把守菜单。** 主机目录的新条目若在两份词典中没有对应的 `app.<id>` 条目,将保持不可见而不是显示裸 id;扩展目录意味着同时扩展 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 与本包的 locale。
- **可用性每页只读一次。** 页面打开期间安装的应用要重新加载页面后才出现(主机侧还需主机重启);文件控件背后的桌面回答同样每页只读一次。
- **文件关联按类型在页面内缓存。** 最后一个扩展名相同的文件共用一个回答;只有大小写不同的扩展名(如 `.PNG` 和 `.png`)算作不同类型。因此单个文件的单独设置(例如 macOS 中只针对某个文件的“打开方式”)可能先显示该类型之前的回答,再由后台查询更正已打开的菜单。Linux 的内容嗅探和 `.tar.gz` 这类多段扩展名也有同样的效果;某个文件的空回答也会以同样的方式,让同类型其他文件的这一项先显示为禁用。在更正之前选择这一项,会用该回答中的应用打开。没有扩展名的文件按路径记录。记录以及每个应用的一份图标随出现过的文件类型增长,并在页面关闭时释放。
- **某个类型的第一次文件菜单可能打开得较慢。** 整个菜单最多等待该类型在本页面的第一个回答 300 毫秒;回答更慢时,菜单打开后这一项仍会原地变化。
- **同一时间只有一次后台刷新。** 打开另一个文件的菜单会取消前一个菜单尚未返回的查询,该类型保留之前的回答(或仍没有回答),直到下次打开。
- **所有平台共用一个定位标签。** Session Remote 只报告有没有桌面,不报告它跑的是哪个文件管理器,所以菜单写「显示文件位置」,而不像交付卡片那样点名访达或文件资源管理器。
- **交付卡片保留自己的打开器。** [`ui-deliverables`](../ui-deliverables/README.zh.md) 仍通过自己按 Session 与事件定位的路由打开声明过的文件;把这些卡片并到这里的文件控件上仍属延期工作。
@@ -9,12 +9,38 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type {} from '@deepseek-ai/dsh-api-workspace-files/remote'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-browser/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types'
import type { OpenInAppController } from './controller.ts'
import { fileAddressFor, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
import type { OpenInAppPathController } from './open-path.ts'
import type { OpenInAppKey } from './locales.ts'
import type { OpenInAppToastSource } from './open-toast.tsx'
type Applications = readonly SessionWorkspacePathApplication[]
/** A file menu's verified Host path and the association answer its resolution showed. */
interface Verification { readonly absolutePath: string; readonly shown: Applications | undefined }
/**
* How long a file menu waits for the first association answer of its file type before opening with the
* unnamed default-application row; most first answers arrive sooner, so the row rarely changes after opening.
*/
const FIRST_ANSWER_WAIT_MS = 300
/**
* @param pending - settles without rejecting.
* @param ms - longest wait.
* @param signal - ends the wait early.
* @returns settles when `pending` settles, after `ms`, or on abort, whichever comes first; the timer never outlives it.
*/
function settleWithin(pending: Promise<void>, ms: number, signal: AbortSignal): Promise<void> {
return new Promise((settle) => {
const done = (): void => { clearTimeout(timer); signal.removeEventListener('abort', done); settle() }
const timer = setTimeout(done, ms)
signal.addEventListener('abort', done, { once: true })
void pending.then(done)
})
}
/**
* Register native opening, original-path reveal, and URL actions.
* @param ctx - declared plugin dependencies.
@@ -109,10 +135,10 @@ export function registerContextMenus(
} },
]
}
// The Host path from each file target's latest successful stat, shared by menus open on one target object at once.
// A stat clears it while running; answers arriving meanwhile need no re-resolution, as that stat's resolution reads
// the newest answers.
const verified = new WeakMap<ContextMenuTargetMap['file'], string>()
// The Host path from each file target's latest successful stat and the answer its menu showed, shared by menus open
// on one target object at once. A stat clears it while running; answers arriving meanwhile need no re-resolution, as
// that stat's resolution reads the newest answers.
const verified = new WeakMap<ContextMenuTargetMap['file'], Verification>()
// The target whose changed association answer is being applied; only that re-resolution reuses the verified path,
// so every opening runs its own stat. The in-place update relies on the registry re-resolving synchronously from
// the notification, without which the menu stats again, and on a synchronous resolution replacing rows without
@@ -123,32 +149,39 @@ export function registerContextMenus(
observe: target => [{
getSnapshot: paths.associations.getSnapshot.bind(paths.associations),
subscribe: (listener: () => void) => {
// Answers for other file types leave this menu unchanged.
let shown: ReturnType<typeof paths.cachedApplications>
// The answer this subscription last applied for a verification; answers for other file types leave it unchanged.
let applied: { readonly verification: Verification; readonly applications: Applications | undefined } | undefined
return paths.associations.subscribe(() => {
const absolutePath = verified.get(target)
if (absolutePath === undefined) return
const applications = paths.cachedApplications(absolutePath)
if (applications === shown) return
shown = applications
const verification = verified.get(target)
if (verification === undefined) return
const applications = paths.cachedApplications(verification.absolutePath)
const current = applied?.verification === verification ? applied.applications : verification.shown
if (applications === current) return
applied = { verification, applications }
answering = target
try { listener() } finally { answering = undefined }
})
},
}],
resolve: (target, signal) => {
const verifiedPath = answering === target ? verified.get(target) : undefined
if (verifiedPath !== undefined) return fileMenu(target, verifiedPath)
const reused = answering === target ? verified.get(target) : undefined
if (reused !== undefined) return fileMenu(target, reused.absolutePath)
verified.delete(target)
return (async () => {
await paths.load()
const absolutePath = await resolvePath(target.sessionId, target.path, signal)
signal.throwIfAborted()
if (absolutePath !== undefined) {
verified.set(target, absolutePath)
// A cached answer shows at once; every opening re-queries so a changed association reaches this menu.
if (paths.desktop.getSnapshot() === true) paths.refreshApplications(absolutePath)
if (absolutePath === undefined) return fileMenu(target, undefined)
if (paths.desktop.getSnapshot() === true) {
// Every opening re-queries so a changed association reaches this menu; a type's first opening waits for it briefly.
const unanswered = paths.cachedApplications(absolutePath) === undefined
const refreshed = paths.refreshApplications(absolutePath)
if (unanswered) {
await settleWithin(refreshed, FIRST_ANSWER_WAIT_MS, signal)
signal.throwIfAborted()
}
}
verified.set(target, { absolutePath, shown: paths.cachedApplications(absolutePath) })
return fileMenu(target, absolutePath)
})()
},
@@ -139,13 +139,15 @@ export class OpenInAppPathController {
* Re-query a file's associations in the background. One refresh runs at a time: it outlives the
* requesting menu and ends with its answer, the next refresh, which cancels it, or {@link dispose}.
* @param path - absolute Host path of the file.
* @returns settles without rejecting when this refresh's query settles, after any answer is recorded;
* already settled after {@link dispose}.
*/
refreshApplications(path: string): void {
if (this.lifetime.signal.aborted) return
refreshApplications(path: string): Promise<void> {
if (this.lifetime.signal.aborted) return Promise.resolve()
this.refresh?.abort()
const refresh = new AbortController()
this.refresh = refresh
void this.applications(path, AbortSignal.any([this.lifetime.signal, refresh.signal]))
return this.applications(path, AbortSignal.any([this.lifetime.signal, refresh.signal])).then(() => {})
}
/** Cancel background refreshes and refuse new ones; recorded answers stay readable. */
@@ -122,7 +122,7 @@ it('routes file actions through an explicit host, the line, native association a
const simple = { kind: 'file', value: { sessionId: SessionId('absent'), path: '/file.unknown' } } as const
h.remote.session.workspacePathApplications.mockResolvedValueOnce({ ok: true, value: [] })
const menu = await h.resolve(simple)
// Without an answer the row opens through the association; the Host's empty answer then disables it in place.
// The Host's empty answer arrives within the first-answer wait, so the row opens disabled.
await vi.waitFor(() => { expect(menu.source.getSnapshot().find(item => item.id.endsWith('/open'))?.disabled).toBe(true) })
expect(menu.source.getSnapshot().some(item => item.id.endsWith('/copyImage'))).toBe(false)
expect(menu.source.getSnapshot().filter(item => item.id.startsWith('file-actions.')).map(item => item.id))
@@ -136,7 +136,47 @@ it('routes file actions through an explicit host, the line, native association a
expect(h.sidebar.openResourceIn).toHaveBeenLastCalledWith(SessionId('absent'), expect.any(String), { destination: 'float' })
})
it('opens file menus before the Host answers and changes only the application row in place', async () => {
it('names the application when the first answer of a file type arrives within the wait', async () => {
const h = await boot()
// The answer arrives a macrotask later, after a resolution that did not wait would have published its rows.
h.remote.session.workspacePathApplications.mockImplementationOnce(() => new Promise((answer) => {
setTimeout(() => { answer({ ok: true, value: [{ id: 'preview', name: 'Preview', default: true, icon: null }] }) }, 10)
}))
const menu = h.registry.resolve({ kind: 'file', value: { sessionId, path: 'notes.txt' } })
const labels: (string | undefined)[] = []
const stop = menu.source.subscribe(() => {
labels.push(menu.source.getSnapshot().find(item => item.id === 'open-in-app.file/open')?.label)
})
await menu.ready
stop()
expect(labels.filter(label => label !== undefined)).toEqual(['open.title'])
menu.dispose()
})
it('waits for the first answer of a file type at most 300 ms, and a dismissal ends the wait', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
onTestFinished(() => { vi.useRealTimers() })
const h = await boot()
h.remote.session.workspacePathApplications.mockImplementation(() => new Promise(() => {}))
const menu = h.registry.resolve({ kind: 'file', value: { sessionId, path: 'notes.txt' } })
let ready = false
void menu.ready.then(() => { ready = true })
await vi.advanceTimersByTimeAsync(299)
expect(ready).toBe(false)
await vi.advanceTimersByTimeAsync(1)
expect(ready).toBe(true)
expect(menu.source.getSnapshot().find(item => item.id === 'open-in-app.file/open')).toMatchObject({ label: 'menu.defaultApp', disabled: false })
menu.dispose()
const dismissed = h.registry.resolve({ kind: 'file', value: { sessionId, path: 'todo.md' } })
await vi.advanceTimersByTimeAsync(100)
expect(vi.getTimerCount()).toBe(1)
dismissed.dispose()
await dismissed.ready
expect(vi.getTimerCount()).toBe(0)
expect(h.failure).not.toHaveBeenCalled()
})
it('opens file menus after the first-answer wait and changes only the application row in place', async () => {
const h = await boot()
const answers: ((answer: RemoteResult<Applications>) => void)[] = []
h.remote.session.workspacePathApplications.mockImplementation(() => new Promise((answer) => { answers.push(answer) }))
@@ -228,6 +268,13 @@ it('re-resolves an open file menu only when its own file type answers differentl
await h.paths.applications('/workspace/other.md', new AbortController().signal)
expect(h.paths.cachedApplications('/workspace/other.md')).toHaveLength(1)
expect(menu.source.getSnapshot()).toBe(shown)
h.remote.session.workspacePathApplications.mockResolvedValueOnce({ ok: true, value: [] })
await h.paths.applications('/workspace/todo.txt', new AbortController().signal)
const changed = menu.source.getSnapshot()
expect(changed.find(item => item.id === 'open-in-app.file/open')).toMatchObject({ label: 'menu.defaultApp', disabled: true })
h.remote.session.workspacePathApplications.mockResolvedValueOnce({ ok: true, value: [] })
await h.paths.applications('/workspace/other.md', new AbortController().signal)
expect(menu.source.getSnapshot()).toBe(changed)
menu.dispose()
})