Merge pull request #6615 from deepseek-harness/fix/image-drag-plugin-ownership
feat(file-drag): 独立插件交付原始文件并支持图片平移
This commit is contained in:
commit
c7d282bfb0
138 files changed
+3401
-172
No files matched your search
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-19-file-drag-plugin-ownership.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md
|
||||
/agent-note-file-drag-ownership-outside-the-application-layout:
|
||||
en: 951575b08912a949
|
||||
zh: f33e8e6bcb9240bd
|
||||
/agent-note-file-drag-ownership-outside-the-application-layout/problem:
|
||||
en: 557edc1db9bee8d8
|
||||
zh: 20f47c78c8b0078b
|
||||
/agent-note-file-drag-ownership-outside-the-application-layout/decision:
|
||||
en: 1dfea60901d6e6ae
|
||||
zh: e4fc534dbe01b0d5
|
||||
/agent-note-file-drag-ownership-outside-the-application-layout/alternatives-considered:
|
||||
en: 571eabab132ce4c1
|
||||
zh: b5f8207015aeb3be
|
||||
/agent-note-file-drag-ownership-outside-the-application-layout/consequences:
|
||||
en: 56ebb7c25911fd61
|
||||
zh: 88120d2af74785ef
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: File drag ownership outside the application layout
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-19-file-drag-plugin-ownership.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Image dragging spans conversation images, sidebar previews, and composer drop targets. Original files must survive source unmount during a drag, and failures must remain visible after the originating panel closes. An application-root image provider puts feature state in layout and makes independent image entries depend on an undeclared React ancestor.
|
||||
|
||||
## Decision
|
||||
|
||||
The [file-drag plugin](../../../../packages/client/ui-file-drag/README.md) owns generic file preparation, browser transfer tokens, retained original files, desktop bridge selection, and transient failure feedback. Consumers receive preparation and error callbacks through their slot or Factory injection. The plugin registers its feedback through `shell.overlay`; layout declares the generic slot and has no image dependency. The shared image primitive owns gestures and cancellation. Images are the first consumer, while MIME naming and transport accept other files.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Root React provider.** It preserves transfer lifetime but adds image-specific assembly to the layout package and hides dependencies from slot registration. Moving the same wrapper into the renderer would move the feature coupling into another generic owner.
|
||||
|
||||
**State on each image.** It avoids shared assembly but cannot deliver the retained file after the source unmounts or keep a late failure visible after the panel closes.
|
||||
|
||||
**Direct browser File only.** Manual macOS Chrome 154.0.8037.93 drags of PNG and PDF fixtures produced a populated `dataTransfer.files` in trusted `dragstart`, but empty files in trusted same-page and cross-window drops. Finder received filename-only text clippings. These measured failures require the application-local token path; they do not establish behavior in untested browsers or cross-tab delivery. Desktop retains Electron native file dragging.
|
||||
|
||||
## Consequences
|
||||
|
||||
One plugin lifetime governs transfer state, a native source-node end listener, and drop completion. The source listener remains attached after React unmount. Independent render roots can share a transfer without sharing a React ancestor. Custom compositions must load the service and declare the feedback slot; custom image Factory registrations supply the required callbacks. Application-local browser tokens are accepted only over the composer; native Desktop files retain the full-page drop overlay. Plugin disposal removes feedback and clears retained files. Loader-backed browser scenarios cover source removal and delivery, while focused tests cover local resource authorization and cleanup failures.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: 应用布局之外的文件拖放归属
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-19-file-drag-plugin-ownership.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
图片拖放跨越会话图片、侧栏预览和输入框接收区。拖动期间原始文件必须在源组件卸载后继续保留,发起操作的面板关闭后失败提示也必须可见。应用根部的图片 Provider 把功能状态放进布局,并使独立图片入口依赖未声明的 React 祖先组件。
|
||||
|
||||
## Decision
|
||||
|
||||
[文件拖放插件](../../../../packages/client/ui-file-drag/README.zh.md) 持有通用文件准备、浏览器传递令牌、原始文件、桌面桥接选择和临时失败提示。消费方通过 slot 或 Factory 注入接收准备及错误回调。插件通过 `shell.overlay` 注册提示,布局只声明通用插槽,不依赖图片功能。共享图片原语负责手势与取消。图片是首个调用方,MIME 命名与传输也接受其他文件。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**根部 React Provider。** 它能保留传递生命周期,但会把图片专属组合放进布局包,并对 slot 注册隐藏依赖。把同一个包裹组件移到渲染器,只会把功能耦合转移到另一个通用模块。
|
||||
|
||||
**每张图片独立持有状态。** 这能避免共享组合,却不能在源组件卸载后交付保留文件,也不能在面板关闭后继续显示延迟到达的失败提示。
|
||||
|
||||
**仅直接传递浏览器 File。** 在 macOS Chrome 154.0.8037.93 中手动拖放 PNG 与 PDF 时,可信 `dragstart` 中 `dataTransfer.files` 有文件,同页与跨窗口的可信 drop 却收到空文件列表。Finder 收到仅含文件名的文本摘录。这些实测失败要求保留应用内令牌路径,但不能据此判定未测试浏览器或跨标签页交付的行为。Desktop 保留 Electron 原生文件拖放。
|
||||
|
||||
## Consequences
|
||||
|
||||
一个插件生命周期管理传递状态、源节点上的原生结束监听器与投放完成。React 卸载后,监听器仍绑定源节点。独立渲染根可以共享传递,而不共享 React 祖先组件。自定义组合必须加载服务并声明反馈插槽,自定义图片 Factory 注册提供所需回调。应用内浏览器令牌仅在输入框范围接受;Desktop 原生文件保留全页投放遮罩。插件卸载会移除提示并清除保留文件。通过 Loader 启动的浏览器场景覆盖源图移除与交付,定向测试覆盖本地资源授权和清理失败。
|
||||
@@ -114,6 +114,7 @@ External packages installed for runtime use or embedded in distributed Host or b
|
||||
| [`micromark-util-classify-character`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-classify-character) | MIT |
|
||||
| [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT |
|
||||
| [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT |
|
||||
| [`mime`](https://github.com/broofa/mime) | MIT |
|
||||
| [`mime-types`](https://github.com/jshttp/mime-types) | MIT |
|
||||
| [`minisearch`](https://github.com/lucaong/minisearch) | MIT |
|
||||
| [`negotiator`](https://github.com/jshttp/negotiator) | MIT |
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
|
||||
"@deepseek-ai/cordis-plugin-include": "workspace:~",
|
||||
"@deepseek-ai/dsh-skill": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs": "workspace:*",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:*",
|
||||
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
|
||||
"@deepseek-ai/dsh-brand": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-executor-native": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/** Resolve desktop-only file references through the active Host filesystem and Session. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
|
||||
/** Private Host-to-main answer; never exposed to renderer JavaScript. */
|
||||
export interface LocalDragResource {
|
||||
readonly path: string
|
||||
readonly sha256: string
|
||||
readonly size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize an unmodified local file through the composed filesystem's actual read operations.
|
||||
* @param ctx - booted Desktop Host.
|
||||
* @param address - Session-scoped resource reference.
|
||||
* @param cancellation - caller cancellation, combined with the read deadline.
|
||||
* @returns verified Host-local path and content identity, or null for nonlocal resources.
|
||||
*/
|
||||
export async function localDragResource(ctx: Context, address: string, cancellation?: AbortSignal): Promise<LocalDragResource | null> {
|
||||
const resource = parseFileAddress(address)
|
||||
if (resource?.scope !== 'session') throw new Error('File drag requires a Session resource')
|
||||
const sessions = ctx.get('sessions')
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
const fs = ctx.get('fs')
|
||||
const policy = ctx.get('sandboxPolicy')
|
||||
if (fs === undefined || sessions === undefined || policy === undefined) throw new Error('File access unavailable')
|
||||
const sessionId = resource.sessionId as SessionId
|
||||
const header = sessions.get(sessionId)?.header ?? (await persistence?.stat(sessionId))?.header
|
||||
if (header === undefined) throw new Error('File drag Session unavailable')
|
||||
const signal = cancellation === undefined ? AbortSignal.timeout(10_000) : AbortSignal.any([cancellation, AbortSignal.timeout(10_000)])
|
||||
signal.throwIfAborted()
|
||||
const cwd = header.cwd ?? policy.workspaceRoot
|
||||
const entry = await fs.lstat(resource.path, { cwd }, signal)
|
||||
if (entry?.type !== 'file') return null
|
||||
const target = await fs.resolve(resource.path, { cwd, signal })
|
||||
const path = fs.processPath(target)
|
||||
const mapped = fs.processPathFromHostPath(path)
|
||||
if (mapped === undefined) return null
|
||||
const roundTrip = await fs.resolve(mapped, { signal })
|
||||
if (roundTrip.targetKey !== target.targetKey) return null
|
||||
const info = await fs.stat(target, signal)
|
||||
if (info?.type !== 'file' || info.size === undefined) return null
|
||||
const hash = createHash('sha256')
|
||||
let size = 0
|
||||
while (size < info.size) {
|
||||
signal.throwIfAborted()
|
||||
const data = await fs.readByteRange(target, { offset: size, length: Math.min(64 * 1024, info.size - size) }, signal)
|
||||
if (data.length === 0) return null
|
||||
hash.update(data)
|
||||
size += data.length
|
||||
}
|
||||
// Even empty files require a read authorization check.
|
||||
if (size === 0) await fs.readByteRange(target, { offset: 0, length: 1 }, signal)
|
||||
const after = await fs.stat(target, signal)
|
||||
signal.throwIfAborted()
|
||||
if (after?.version !== info.version || after.size !== size) return null
|
||||
return { path, size, sha256: hash.digest('hex') }
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
import type {} from '@deepseek-ai/dsh-deepseek-account'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import * as desktopOffice from './office.ts'
|
||||
import { localDragResource } from './file-drag.ts'
|
||||
|
||||
import { installDesktopUpdateTaskControl } from './update-tasks.ts'
|
||||
import { installDesktopQuitInspection } from './quit-inspection.ts'
|
||||
@@ -41,6 +42,8 @@ async function main(): Promise<void> {
|
||||
}),
|
||||
})
|
||||
let stopping: Promise<void> | undefined
|
||||
const dragReads = new Map<number, AbortController>()
|
||||
const dragTasks = new Set<Promise<void>>()
|
||||
const control: {
|
||||
updateTasks?: ReturnType<typeof installDesktopUpdateTaskControl>
|
||||
quitInspection?: ReturnType<typeof installDesktopQuitInspection>
|
||||
@@ -50,6 +53,8 @@ async function main(): Promise<void> {
|
||||
process.send(message, (error) => { if (error === null) resolve(); else reject(error) })
|
||||
})
|
||||
const stop = (): Promise<void> => stopping ??= (async () => {
|
||||
for (const abort of dragReads.values()) abort.abort()
|
||||
await Promise.allSettled(dragTasks)
|
||||
// Startup failure is reported by main; shutdown only owns a tree that booted.
|
||||
const running = await application.catch(() => undefined)
|
||||
await running?.shutdown.shutdown(0)
|
||||
@@ -62,6 +67,31 @@ async function main(): Promise<void> {
|
||||
process.on('message', (message: unknown) => {
|
||||
if (typeof message !== 'object' || message === null || !('type' in message)) return
|
||||
if (message.type === 'shutdown') { void stop(); return }
|
||||
if (message.type === 'file-drag-cancel') {
|
||||
if ('requestId' in message && typeof message.requestId === 'number') dragReads.get(message.requestId)?.abort()
|
||||
return
|
||||
}
|
||||
if (message.type === 'file-drag-resource') {
|
||||
if (!('requestId' in message) || typeof message.requestId !== 'number' || !Number.isSafeInteger(message.requestId)
|
||||
|| !('address' in message) || typeof message.address !== 'string') return
|
||||
const { requestId, address } = message
|
||||
const abort = new AbortController()
|
||||
dragReads.set(requestId, abort)
|
||||
const task = (async () => {
|
||||
try {
|
||||
if (stopping !== undefined) throw new Error('Host stopping')
|
||||
const { ctx } = await application
|
||||
const resource = await localDragResource(ctx, address, abort.signal)
|
||||
await send({ type: 'file-drag-resource', requestId, resource })
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) console.error(error)
|
||||
await send({ type: 'file-drag-resource', requestId, resource: null, error: 'File drag resource unavailable' })
|
||||
} finally { dragReads.delete(requestId) }
|
||||
})().catch((error: unknown) => { console.error(error) })
|
||||
dragTasks.add(task)
|
||||
void task.finally(() => { dragTasks.delete(task) })
|
||||
return
|
||||
}
|
||||
if (message.type === 'quit-inspection') {
|
||||
if (!('requestId' in message) || !Number.isSafeInteger(message.requestId)) return
|
||||
const requestId = message.requestId
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { localDragResource } from '../src/file-drag.ts'
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-host-drag-'))
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
|
||||
await ctx.plugin(LocalFileSystem, { cwd: root })
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.provide('sandboxPolicy', { workspaceRoot: root } as never)
|
||||
const id = SessionId('file-drag-test')
|
||||
ctx.sessions.create(id, { meta: { cwd: root, origin: 'subagent' } })
|
||||
const path = join(root, '报告.pdf')
|
||||
const bytes = Buffer.from('%PDF-1.4\n原始内容\n%%EOF')
|
||||
await writeFile(path, bytes)
|
||||
return { ctx, root, id, path, bytes, address: sessionFileAddress(id, '报告.pdf') }
|
||||
}
|
||||
|
||||
it('authorizes Session-relative file reads and returns their original digest privately to main', async () => {
|
||||
const f = await fixture()
|
||||
expect(await localDragResource(f.ctx, f.address)).toEqual({
|
||||
path: await realpath(f.path), size: f.bytes.length, sha256: createHash('sha256').update(f.bytes).digest('hex'),
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unscoped references and missing Sessions', async () => {
|
||||
const f = await fixture()
|
||||
await expect(localDragResource(f.ctx, f.path)).rejects.toThrow('Session resource')
|
||||
await expect(localDragResource(f.ctx, sessionFileAddress('missing', f.path))).rejects.toThrow('Session unavailable')
|
||||
await expect(localDragResource(f.ctx, sessionFileAddress(f.id, f.root))).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('does not authorize borrowing when the filesystem has no Host mapping', async () => {
|
||||
const f = await fixture()
|
||||
vi.spyOn(f.ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
|
||||
expect(await localDragResource(f.ctx, f.address)).toBeNull()
|
||||
})
|
||||
|
||||
it('honors filesystem read denial without returning a path', async () => {
|
||||
const f = await fixture()
|
||||
vi.spyOn(f.ctx.fs, 'readByteRange').mockRejectedValue(new Error('read denied'))
|
||||
await expect(localDragResource(f.ctx, f.address)).rejects.toThrow('read denied')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('declines symlink borrowing so the authorized byte reader can copy it', async () => {
|
||||
const f = await fixture()
|
||||
await symlink(f.path, join(f.root, 'linked.pdf'))
|
||||
expect(await localDragResource(f.ctx, sessionFileAddress(f.id, 'linked.pdf'))).toBeNull()
|
||||
})
|
||||
|
||||
it('stops a chunked authorization read when the caller cancels', async () => {
|
||||
const f = await fixture()
|
||||
const pending = Promise.withResolvers<Uint8Array>()
|
||||
const reading = vi.spyOn(f.ctx.fs, 'readByteRange').mockReturnValue(pending.promise)
|
||||
const abort = new AbortController()
|
||||
const preparing = localDragResource(f.ctx, f.address, abort.signal)
|
||||
const rejected = expect(preparing).rejects.toThrow()
|
||||
await vi.waitFor(() => { expect(reading).toHaveBeenCalledOnce() })
|
||||
const signal = reading.mock.calls[0]![2]
|
||||
abort.abort()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
pending.resolve(f.bytes)
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('authorizes empty files and detects content changes while reading', async () => {
|
||||
const f = await fixture()
|
||||
await writeFile(f.path, '')
|
||||
expect(await localDragResource(f.ctx, f.address)).toMatchObject({ size: 0, sha256: createHash('sha256').digest('hex') })
|
||||
await writeFile(f.path, f.bytes)
|
||||
vi.spyOn(f.ctx.fs, 'readByteRange').mockResolvedValueOnce(new Uint8Array())
|
||||
await expect(localDragResource(f.ctx, f.address)).resolves.toBeNull()
|
||||
})
|
||||
@@ -30,6 +30,24 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/credentials/deepseek-account"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/fs/fs"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/workspace-path"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/brand"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 372297005b57a08b
|
||||
zh: 074a635ac17e4362
|
||||
/deepseek-harness-desktop/installation-ownership:
|
||||
en: ae0384d699f55cea
|
||||
zh: ffb879b1d4b08bae
|
||||
en: 7cbb1f9e5006bd34
|
||||
zh: 01e9e15a75a90450
|
||||
/deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation:
|
||||
en: a1a47b09f4a72ba4
|
||||
zh: 7b1cf27f0b72cf31
|
||||
|
||||
@@ -142,6 +142,8 @@ Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages
|
||||
|
||||
The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. The same preload forwards the main-process machine description from `dshDesktop.deviceInfo()` as `name=value` fields separated by `; `: `platform`, `os`, `app_arch` (the architecture the application binary runs as, which differs from the hardware architecture under emulation), `cpu`, and `memory_gib` (total physical memory in GiB with one decimal). A field is omitted when its source is unavailable. The description carries no hostname, user name, or serial number. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments.
|
||||
|
||||
The file-drag bridge accepts arbitrary file bytes or Session-scoped resource references from the owned product main frame. The Host authorizes resource reads through its composed filesystem, confirms a local mapping, and returns a private content digest; main checks content and the shared export filename before borrowing the source. Native startup checks the prepared file’s device, inode, size, modification time, and change time without rereading its bytes. Borrowed files are never deleted. Renamed, transformed, remote, or Blob content uses a private temporary copy. MIME data completes names without restricting file types; compatible suffixes such as `.jpeg` survive. Temporary preparations, partial writes, and retained exports share 64 MiB; unused files release on pointer leave, and OS-delivered copies expire after 60 seconds. Exhaustion reports the generic retry message until capacity is reclaimed. Caller cancellation and shutdown abort Host reads and main-process verification, interrupt icon waits, and release unused files. Shutdown waits for writes/releases and retries failed removals without waiting for the icon service. Update preparation attempts cleanup before stopping the Host; cleanup failures are logged and do not block installation or shutdown. Failed installation restores the bridge. Asynchronous system file icons avoid decoding original images in main.
|
||||
|
||||
Only the main application window enables `<webview>`. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering.
|
||||
|
||||
The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks.
|
||||
|
||||
@@ -142,6 +142,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm
|
||||
|
||||
应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。同一个 preload 通过 `dshDesktop.deviceInfo()` 转发主进程采集的机器描述,按 `name=value` 字段以 `; ` 分隔:`platform`、`os`、`app_arch`(应用二进制实际运行的架构,模拟运行时与硬件架构不同)、`cpu` 和 `memory_gib`(物理内存总量,GiB,保留一位小数)。取值不可用时省略对应字段。该描述不包含主机名、用户名或序列号。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。
|
||||
|
||||
文件拖放桥接接受所属主应用主框架传入的任意文件字节或 Session 资源地址。Host 通过组合的文件系统授权读取、确认本地映射并返回私有内容摘要;主进程检查内容与统一解析的导出名后借用源文件。原生起拖核对准备时的设备、inode、大小、修改时间和变更时间,不再读取整份字节。借用文件不会被删除。改名、转换、远程或 Blob 内容使用私有临时副本。MIME 数据补全名称,不限制文件类型;`.jpeg` 等兼容后缀保持不变。临时准备、部分写入及保留的导出共享 64 MiB;指针移开释放未使用文件,已交给系统的副本在 60 秒后过期。空间耗尽时显示通用重试提示,容量回收后可重试。调用方取消及退出会中止 Host 读取和主进程验证、打断图标等待并释放未使用文件。退出等待写入和释放,重试失败的删除,但不等待图标服务。更新准备在停止 Host 前尝试清理;清理失败只记录日志,不阻断安装或退出。安装失败后恢复桥接。异步系统文件图标避免主进程解码原图。
|
||||
|
||||
只有主应用窗口启用 `<webview>`。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。
|
||||
|
||||
`dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:*",
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-file-drag": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/** Application-owned temporary exports and authorized borrowed files for native dragging. */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { resolveDragFileName, type DragFileName, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
|
||||
|
||||
// Aggregate temporary bytes, including incomplete writes and failed removals.
|
||||
const MAX_TOTAL_DRAG_FILE_BYTES = 64 * 1024 * 1024
|
||||
// Native receivers may open the path after startDrag returns.
|
||||
const DELIVERED_FILE_RETENTION_MS = 60_000
|
||||
|
||||
interface DragFile {
|
||||
readonly directory?: string
|
||||
readonly path: string
|
||||
readonly bytes: number
|
||||
ready: boolean
|
||||
used: boolean
|
||||
timer?: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
/** Borrowed files are never deleted; temporary exports retain cleanup ownership until removal succeeds. */
|
||||
export class DragFiles {
|
||||
private readonly files = new Map<FileDragHandle, DragFile>()
|
||||
private readonly pending = new Set<Promise<FileDragHandle>>()
|
||||
private readonly releasing = new Map<FileDragHandle, Promise<void>>()
|
||||
private stagedBytes = 0
|
||||
private disposed = false
|
||||
|
||||
/** @param temporaryDirectory - OS directory for private temporary exports. */
|
||||
constructor(private readonly temporaryDirectory: string) {}
|
||||
|
||||
/** @param bytes - complete file bytes. @param naming - desired export name. @returns an opaque preparation handle. */
|
||||
prepare(bytes: Uint8Array, naming: DragFileName): Promise<FileDragHandle> {
|
||||
if (this.disposed || bytes.byteLength > MAX_TOTAL_DRAG_FILE_BYTES - this.stagedBytes) {
|
||||
return Promise.reject(new Error('File drag temporary storage unavailable'))
|
||||
}
|
||||
this.stagedBytes += bytes.byteLength
|
||||
const operation = this.write(bytes, resolveDragFileName(naming))
|
||||
this.pending.add(operation)
|
||||
void operation.then(() => { this.pending.delete(operation) }, () => { this.pending.delete(operation) })
|
||||
return operation
|
||||
}
|
||||
|
||||
private async write(bytes: Uint8Array, name: string): Promise<FileDragHandle> {
|
||||
let directory: string
|
||||
try { directory = await mkdtemp(join(this.temporaryDirectory, 'dsh-file-drag-')) }
|
||||
catch (error) { this.stagedBytes -= bytes.byteLength; throw error }
|
||||
const handle = randomUUID() as FileDragHandle
|
||||
const file: DragFile = { directory, path: join(directory, name), bytes: bytes.byteLength, used: false, ready: false }
|
||||
this.files.set(handle, file)
|
||||
try {
|
||||
await writeFile(file.path, bytes, { flag: 'wx', mode: 0o600 })
|
||||
if (this.disposed) throw new Error('File transfer closed')
|
||||
file.ready = true
|
||||
return handle
|
||||
} catch (error) {
|
||||
await this.remove(handle, file)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an authorized source without owning its content.
|
||||
* @param path - verified local source.
|
||||
* @returns borrowed handle.
|
||||
*/
|
||||
borrow(path: string): FileDragHandle {
|
||||
if (this.disposed) throw new Error('File transfer closed')
|
||||
const handle = randomUUID() as FileDragHandle
|
||||
this.files.set(handle, { path, bytes: 0, used: false, ready: true })
|
||||
return handle
|
||||
}
|
||||
|
||||
/** @param handle - preparation identity. @param retain - whether the OS accepted this file. @returns path only while ready and owned. */
|
||||
take(handle: unknown, retain = true): string | undefined {
|
||||
if (typeof handle !== 'string') return undefined
|
||||
const key = handle as FileDragHandle
|
||||
const file = this.files.get(key)
|
||||
if (file === undefined || !file.ready || this.disposed || this.releasing.has(key)) return undefined
|
||||
if (retain && !file.used) {
|
||||
file.used = true
|
||||
file.timer = setTimeout(() => {
|
||||
void this.remove(key, file).catch((error: unknown) => { console.error(error) })
|
||||
}, DELIVERED_FILE_RETENTION_MS)
|
||||
file.timer.unref()
|
||||
}
|
||||
return file.path
|
||||
}
|
||||
|
||||
/** Release unused preparations immediately; used exports have a bounded read grace period. @param handle - preparation identity. */
|
||||
async release(handle: unknown): Promise<void> {
|
||||
if (typeof handle !== 'string') return
|
||||
const key = handle as FileDragHandle
|
||||
const file = this.files.get(key)
|
||||
if (file === undefined || file.used) return
|
||||
await this.remove(key, file)
|
||||
}
|
||||
|
||||
private remove(handle: FileDragHandle, file: DragFile): Promise<void> {
|
||||
const existing = this.releasing.get(handle)
|
||||
if (existing !== undefined) return existing
|
||||
clearTimeout(file.timer)
|
||||
file.ready = false
|
||||
const operation = (file.directory === undefined ? Promise.resolve() : rm(file.directory, { recursive: true, force: true })).then(() => {
|
||||
this.files.delete(handle)
|
||||
this.stagedBytes -= file.bytes
|
||||
}).finally(() => { this.releasing.delete(handle) })
|
||||
this.releasing.set(handle, operation)
|
||||
return operation
|
||||
}
|
||||
|
||||
/** Stop preparation, await every write/release, and retry all tracked removals before reporting failure. */
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
await Promise.allSettled(this.pending)
|
||||
await Promise.allSettled(this.releasing.values())
|
||||
const results = await Promise.allSettled([...this.files].map(([handle, file]) => this.remove(handle, file)))
|
||||
const failure = results.find(result => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') throw failure.reason
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/** Native copy drags authorized only for the product main frame and the active Host. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, statSync, type Stats } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
import { app, ipcMain, type BrowserWindow, type IpcMainInvokeEvent, type NativeImage } from 'electron'
|
||||
import { resolveDragFileName, type DesktopFileSource, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
|
||||
import { DESKTOP_IPC } from './ipc.ts'
|
||||
import { DragFiles } from './file-drag-files.ts'
|
||||
|
||||
interface LocalResource { readonly path: string; readonly size: number; readonly sha256: string }
|
||||
|
||||
function requestOf(value: unknown): DesktopFileSource {
|
||||
if (typeof value !== 'object' || value === null || !('kind' in value)
|
||||
|| !('mediaType' in value) || typeof value.mediaType !== 'string'
|
||||
|| ('name' in value && value.name !== undefined && typeof value.name !== 'string')
|
||||
|| ('outputExtension' in value && value.outputExtension !== undefined && typeof value.outputExtension !== 'string')) throw new Error('Invalid file drag request')
|
||||
const naming = {
|
||||
mediaType: value.mediaType, name: 'name' in value ? value.name as string | undefined : undefined,
|
||||
outputExtension: 'outputExtension' in value ? value.outputExtension as string | undefined : undefined,
|
||||
}
|
||||
if (value.kind === 'bytes' && 'bytes' in value && value.bytes instanceof Uint8Array) return { kind: 'bytes', bytes: value.bytes, ...naming }
|
||||
if (value.kind === 'resource' && 'address' in value && typeof value.address === 'string'
|
||||
&& (!('sha256' in value) || (typeof value.sha256 === 'string' && /^[a-f0-9]{64}$/u.test(value.sha256)))
|
||||
&& (!('size' in value) || (typeof value.size === 'number' && Number.isSafeInteger(value.size) && value.size >= 0))) {
|
||||
return { kind: 'resource', address: value.address, ...naming,
|
||||
...('sha256' in value ? { sha256: value.sha256 as string } : {}),
|
||||
...('size' in value ? { size: value.size as number } : {}) }
|
||||
}
|
||||
throw new Error('Invalid file drag request')
|
||||
}
|
||||
|
||||
function sameVersion(before: Stats, after: Stats): boolean {
|
||||
return after.isFile() && before.dev === after.dev && before.ino === after.ino
|
||||
&& before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs && before.size === after.size
|
||||
}
|
||||
|
||||
async function verifiedVersion(resource: LocalResource, signal: AbortSignal): Promise<Stats | undefined> {
|
||||
signal.throwIfAborted()
|
||||
const info = await stat(resource.path)
|
||||
if (!info.isFile() || info.size !== resource.size) return undefined
|
||||
const hash = createHash('sha256')
|
||||
const stream = createReadStream(resource.path, { signal })
|
||||
const chunks: AsyncIterable<unknown> = stream
|
||||
for await (const chunk of chunks) {
|
||||
if (!(chunk instanceof Uint8Array)) throw new Error('File stream returned non-binary data')
|
||||
hash.update(chunk)
|
||||
}
|
||||
const after = await stat(resource.path)
|
||||
return sameVersion(info, after) && hash.digest('hex') === resource.sha256 ? after : undefined
|
||||
}
|
||||
|
||||
async function cancellable<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
||||
signal.throwIfAborted()
|
||||
const cancelled = Promise.withResolvers<never>()
|
||||
const abort = (): void => { cancelled.reject(signal.reason) }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
try { return await Promise.race([operation, cancelled.promise]) }
|
||||
finally { signal.removeEventListener('abort', abort) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Register file preparation and native dragging.
|
||||
* @param owner - shared product sender authorization, returning its window.
|
||||
* @param resolveResource - cancellable private Host authorization; renderer paths are never used directly.
|
||||
* @returns cleanup aborting resource reads and awaiting tracked writes/removals without waiting for the system icon service.
|
||||
*/
|
||||
export function installDesktopFileDrag(
|
||||
owner: (event: IpcMainInvokeEvent) => BrowserWindow,
|
||||
resolveResource: (address: string, signal: AbortSignal) => Promise<LocalResource | null>,
|
||||
): () => Promise<void> {
|
||||
let disposed = false
|
||||
const preparations = new Map<string, AbortController>()
|
||||
const files = new DragFiles(app.getPath('temp'))
|
||||
const pending = new Set<Promise<FileDragHandle | undefined>>()
|
||||
const icons = new Map<string, NativeImage>()
|
||||
const borrowed = new Map<string, { resource: LocalResource; version: Stats }>()
|
||||
const authorize = (event: IpcMainInvokeEvent): BrowserWindow => {
|
||||
if (disposed) throw new Error('File transfer closed')
|
||||
return owner(event)
|
||||
}
|
||||
const prepare = async (event: IpcMainInvokeEvent, value: unknown, signal: AbortSignal): Promise<FileDragHandle | undefined> => {
|
||||
authorize(event)
|
||||
const source = requestOf(value)
|
||||
let handle: FileDragHandle
|
||||
if (source.kind === 'bytes') handle = await files.prepare(source.bytes, source)
|
||||
else {
|
||||
const resource = await cancellable(resolveResource(source.address, signal), signal)
|
||||
authorize(event)
|
||||
if (resource === null || basename(resource.path) !== resolveDragFileName(source)
|
||||
|| (source.sha256 !== undefined && source.sha256 !== resource.sha256)
|
||||
|| (source.size !== undefined && source.size !== resource.size)) return undefined
|
||||
const version = await verifiedVersion(resource, signal)
|
||||
if (version === undefined) return undefined
|
||||
handle = files.borrow(resource.path)
|
||||
borrowed.set(handle, { resource, version })
|
||||
}
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
const file = files.take(handle, false)
|
||||
if (file === undefined) throw new Error('File transfer closed')
|
||||
const icon = await cancellable(app.getFileIcon(file, { size: 'normal' }), signal)
|
||||
authorize(event)
|
||||
icons.set(handle, icon)
|
||||
return handle
|
||||
} catch (error) {
|
||||
borrowed.delete(handle)
|
||||
await files.release(handle)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
ipcMain.handle(DESKTOP_IPC.fileDragPrepare, (event, source: unknown, requestId: unknown) => {
|
||||
authorize(event)
|
||||
if (typeof requestId !== 'string' || requestId.length === 0 || requestId.length > 128 || preparations.has(requestId)) throw new Error('Invalid file preparation identity')
|
||||
const abort = new AbortController()
|
||||
preparations.set(requestId, abort)
|
||||
const operation = prepare(event, source, abort.signal).finally(() => { preparations.delete(requestId) })
|
||||
pending.add(operation)
|
||||
void operation.then(() => { pending.delete(operation) }, () => { pending.delete(operation) })
|
||||
return operation
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.fileDragCancel, (event, requestId: unknown) => {
|
||||
authorize(event)
|
||||
if (typeof requestId === 'string') preparations.get(requestId)?.abort(new Error('File preparation cancelled'))
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.fileDragStart, (event, handle: unknown) => {
|
||||
const window = authorize(event)
|
||||
const icon = typeof handle === 'string' ? icons.get(handle) : undefined
|
||||
if (icon === undefined || typeof handle !== 'string') return false
|
||||
const file = files.take(handle, false)
|
||||
if (file === undefined) return false
|
||||
try {
|
||||
const original = borrowed.get(handle)
|
||||
if (original !== undefined && !sameVersion(original.version, statSync(original.resource.path))) throw new Error('File changed before dragging')
|
||||
authorize(event)
|
||||
window.webContents.startDrag({ file, icon })
|
||||
files.take(handle)
|
||||
icons.delete(handle)
|
||||
borrowed.delete(handle)
|
||||
return true
|
||||
} catch (_error) {
|
||||
icons.delete(handle)
|
||||
borrowed.delete(handle)
|
||||
void files.release(handle).catch((error: unknown) => { console.error(error) })
|
||||
return false
|
||||
}
|
||||
})
|
||||
ipcMain.handle(DESKTOP_IPC.fileDragRelease, async (event, handle: unknown) => {
|
||||
authorize(event)
|
||||
if (typeof handle === 'string') { icons.delete(handle); borrowed.delete(handle) }
|
||||
await files.release(handle)
|
||||
})
|
||||
return async () => {
|
||||
if (!disposed) {
|
||||
disposed = true
|
||||
for (const abort of preparations.values()) abort.abort(new Error('File transfer closed'))
|
||||
ipcMain.removeHandler(DESKTOP_IPC.fileDragPrepare)
|
||||
ipcMain.removeHandler(DESKTOP_IPC.fileDragCancel)
|
||||
ipcMain.removeHandler(DESKTOP_IPC.fileDragStart)
|
||||
ipcMain.removeHandler(DESKTOP_IPC.fileDragRelease)
|
||||
}
|
||||
await Promise.allSettled(pending)
|
||||
icons.clear()
|
||||
borrowed.clear()
|
||||
await files.dispose()
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,11 @@ interface PlatformSessionEvent {
|
||||
}
|
||||
|
||||
type DesktopHostEvent = ReadyEvent | FatalEvent | PlatformSessionEvent | { readonly type: 'shutdown-complete' } | {
|
||||
readonly type: 'file-drag-resource'
|
||||
readonly requestId: number
|
||||
readonly resource: { readonly path: string; readonly sha256: string; readonly size: number } | null
|
||||
readonly error?: string
|
||||
} | {
|
||||
readonly type: 'update-tasks'
|
||||
readonly requestId: number
|
||||
readonly active: boolean
|
||||
@@ -82,6 +87,13 @@ function isDesktopHostEvent(message: unknown): message is DesktopHostEvent {
|
||||
case 'update-tasks':
|
||||
return Number.isSafeInteger(candidate.requestId) && typeof candidate.active === 'boolean'
|
||||
&& (candidate.error === undefined || typeof candidate.error === 'string')
|
||||
case 'file-drag-resource': {
|
||||
const resource = candidate.resource
|
||||
return Number.isSafeInteger(candidate.requestId) && (candidate.error === undefined || typeof candidate.error === 'string')
|
||||
&& (resource === null || (typeof resource === 'object' && 'path' in resource && typeof resource.path === 'string'
|
||||
&& 'sha256' in resource && typeof resource.sha256 === 'string' && /^[a-f0-9]{64}$/u.test(resource.sha256)
|
||||
&& 'size' in resource && typeof resource.size === 'number' && Number.isSafeInteger(resource.size) && resource.size >= 0))
|
||||
}
|
||||
case 'quit-inspection':
|
||||
return Number.isSafeInteger(candidate.requestId) && typeof candidate.activeTasks === 'boolean'
|
||||
&& typeof candidate.scheduledTasks === 'boolean' && (candidate.error === undefined || typeof candidate.error === 'string')
|
||||
@@ -241,7 +253,7 @@ export class DesktopHostProcess {
|
||||
* an unanswered drain fails at the control-request deadline without authorizing installation.
|
||||
*/
|
||||
async updateTasks(action: 'inspect' | 'lock' | 'unlock'): Promise<boolean> {
|
||||
const response = await this.control({ type: 'update-tasks', action }, 10_000, 'desktop update: task inspection timed out')
|
||||
const response = await this.control({ type: 'update-tasks', action }, 10_000, 'desktop update: task inspection timed out', 'desktop update')
|
||||
if (response.type !== 'update-tasks') throw new Error('desktop update: Host answered with a different control response')
|
||||
return response.active
|
||||
}
|
||||
@@ -252,29 +264,53 @@ export class DesktopHostProcess {
|
||||
* {@link QUIT_INSPECTION_DEADLINE_MS}, and the shell then asks before quitting.
|
||||
*/
|
||||
async inspectQuit(): Promise<DesktopQuitInspection> {
|
||||
const response = await this.control({ type: 'quit-inspection' }, QUIT_INSPECTION_DEADLINE_MS, 'desktop quit: inspection timed out')
|
||||
const response = await this.control({ type: 'quit-inspection' }, QUIT_INSPECTION_DEADLINE_MS, 'desktop quit: inspection timed out', 'desktop quit')
|
||||
if (response.type !== 'quit-inspection') throw new Error('desktop quit: Host answered with a different control response')
|
||||
return { activeTasks: response.activeTasks, scheduledTasks: response.scheduledTasks }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param address - Session-scoped file reference.
|
||||
* @param signal - preparation cancellation.
|
||||
* @returns privately authorized local resource, or null when nonlocal.
|
||||
*/
|
||||
async resolveDragResource(address: string, signal?: AbortSignal): Promise<
|
||||
{ readonly path: string; readonly sha256: string; readonly size: number } | null
|
||||
> {
|
||||
const response = await this.control({ type: 'file-drag-resource', address }, 12_000, 'File drag authorization timed out', 'file drag', signal)
|
||||
if (response.type !== 'file-drag-resource') throw new Error('Host returned a different control response')
|
||||
return response.resource
|
||||
}
|
||||
|
||||
private async control(
|
||||
request: { readonly type: 'update-tasks'; readonly action: 'inspect' | 'lock' | 'unlock' } | { readonly type: 'quit-inspection' },
|
||||
deadlineMs: number, deadlineMessage: string,
|
||||
request: { readonly type: 'update-tasks'; readonly action: 'inspect' | 'lock' | 'unlock' } | { readonly type: 'quit-inspection' }
|
||||
| { readonly type: 'file-drag-resource'; readonly address: string },
|
||||
deadlineMs: number, deadlineMessage: string, operation: string, signal?: AbortSignal,
|
||||
): Promise<DesktopHostControlResponse> {
|
||||
const child = this.child
|
||||
if (child === undefined || !child.connected || this.failureReported || this.stopping) {
|
||||
throw new Error(`${request.type === 'update-tasks' ? 'desktop update' : 'desktop quit'}: Host is unavailable`)
|
||||
throw new Error(`${operation}: Host is unavailable`)
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
const requestId = this.nextControlId++
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const abort = (): void => {
|
||||
if (request.type === 'file-drag-resource' && child.connected) child.send({ type: 'file-drag-cancel', requestId }, (_error) => {
|
||||
// The request still rejects locally if the Host has disconnected.
|
||||
})
|
||||
const reason: unknown = signal?.reason
|
||||
this.controlRequests.get(requestId)?.reject(reason instanceof Error ? reason : new Error(deadlineMessage))
|
||||
}
|
||||
try {
|
||||
return await new Promise<DesktopHostControlResponse>((resolve, reject) => {
|
||||
this.controlRequests.set(requestId, { resolve, reject })
|
||||
timer = setTimeout(() => { reject(new Error(deadlineMessage)) }, deadlineMs)
|
||||
timer = setTimeout(abort, deadlineMs)
|
||||
signal?.addEventListener('abort', abort, { once: true })
|
||||
child.send({ ...request, requestId }, (error) => { if (error !== null) reject(error) })
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener('abort', abort)
|
||||
this.controlRequests.delete(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol'
|
||||
import type { IpcMainInvokeEvent } from 'electron'
|
||||
import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
|
||||
import type { DesktopFileDrag } from '@deepseek-ai/dsh-util-file-drag'
|
||||
|
||||
/** IPC channel names kept private to the desktop application bundle. */
|
||||
export const DESKTOP_IPC = {
|
||||
@@ -21,6 +22,10 @@ export const DESKTOP_IPC = {
|
||||
browserRelease: 'dsh-desktop:browser-release',
|
||||
browserOpenRequested: 'dsh-desktop:browser-open-requested',
|
||||
directoryPick: 'dsh-desktop:directory-pick',
|
||||
fileDragPrepare: 'dsh-desktop:file-drag-prepare',
|
||||
fileDragCancel: 'dsh-desktop:file-drag-cancel',
|
||||
fileDragStart: 'dsh-desktop:file-drag-start',
|
||||
fileDragRelease: 'dsh-desktop:file-drag-release',
|
||||
deviceInfo: 'dsh-desktop:device-info',
|
||||
localeBootstrap: 'dsh-desktop:locale-bootstrap',
|
||||
localeChanged: 'dsh-desktop:locale-changed',
|
||||
@@ -73,6 +78,7 @@ export interface DesktopUpdatePresentation {
|
||||
export interface DshDesktopProductApi {
|
||||
readonly protocolVersion: 1
|
||||
readonly browser: DesktopBrowserBridge
|
||||
readonly fileDrag: DesktopFileDrag
|
||||
readonly keyboard: DesktopKeyboardApi
|
||||
readonly shortcuts: DesktopShortcutsApi
|
||||
/**
|
||||
|
||||
@@ -26,6 +26,7 @@ import { resolveDesktopPaths } from './paths.ts'
|
||||
import { DesktopProjectManager } from './project-manager.ts'
|
||||
import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
|
||||
import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts'
|
||||
import { installDesktopFileDrag } from './file-drag.ts'
|
||||
import { installDesktopDirectoryPicker } from './directory-picker.ts'
|
||||
import { installMicrophonePermissions } from './microphone-permissions.ts'
|
||||
import { DesktopBackendController } from './backend-controller.ts'
|
||||
@@ -345,6 +346,25 @@ async function main(): Promise<void> {
|
||||
let workspaceRecovery: Promise<void> | undefined
|
||||
let mainWindow: BrowserWindow | undefined
|
||||
let welcomeWindow: BrowserWindow | undefined
|
||||
const installFileDrag = (): (() => Promise<void>) => installDesktopFileDrag(
|
||||
event => assertProductSender(event),
|
||||
(address, signal) => {
|
||||
const host = backend.host
|
||||
if (host === undefined) return Promise.reject(new Error('File drag Host unavailable'))
|
||||
return host.resolveDragResource(address, signal)
|
||||
},
|
||||
)
|
||||
const fileDragCleanups = new Set([installFileDrag()])
|
||||
let fileDragStopped = false
|
||||
const disposeFileDrag = async (): Promise<void> => {
|
||||
fileDragStopped = true
|
||||
const results = await Promise.allSettled([...fileDragCleanups].map(async (release) => {
|
||||
await release()
|
||||
fileDragCleanups.delete(release)
|
||||
}))
|
||||
const failure = results.find(result => result.status === 'rejected')
|
||||
if (failure?.status === 'rejected') throw failure.reason
|
||||
}
|
||||
let enteredWorkspace = false
|
||||
// NSIS passes --updated when it launches the application after installation.
|
||||
let raiseAfterUpdate = process.platform === 'win32' && process.argv.includes('--updated')
|
||||
@@ -419,12 +439,13 @@ async function main(): Promise<void> {
|
||||
let returnedAttempt: string | undefined
|
||||
let pendingWelcomeNotice: WelcomeNotice | undefined
|
||||
let previousAccountStatus: string | undefined
|
||||
const assertProductSender = (event: IpcMainInvokeEvent): void => {
|
||||
const assertProductSender = (event: IpcMainInvokeEvent): BrowserWindow => {
|
||||
assertDesktopSender(event, ['app'])
|
||||
if (mainWindow === undefined || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents
|
||||
|| event.senderFrame === null || event.senderFrame !== mainWindow.webContents.mainFrame) {
|
||||
throw new Error('dsh desktop: rejected IPC from an unowned renderer')
|
||||
}
|
||||
return mainWindow
|
||||
}
|
||||
let navigation: { window: BrowserWindow; url: string; promise: Promise<void> } | undefined
|
||||
const navigateMain = (url: string): Promise<void> => {
|
||||
@@ -511,6 +532,7 @@ async function main(): Promise<void> {
|
||||
},
|
||||
updateTasks: (action: 'inspect' | 'lock' | 'unlock') => host.updateTasks(action),
|
||||
inspectQuit: () => host.inspectQuit(),
|
||||
resolveDragResource: (address: string, signal?: AbortSignal) => host.resolveDragResource(address, signal),
|
||||
}
|
||||
}, (state) => {
|
||||
if (state.phase === 'error') reportFatal(state.failure, 'host')
|
||||
@@ -538,6 +560,10 @@ async function main(): Promise<void> {
|
||||
window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state))
|
||||
}
|
||||
if (state.phase === 'error' && state.failedOperation !== 'check') {
|
||||
if (state.failedOperation === 'install' && fileDragStopped && !quitting) {
|
||||
fileDragCleanups.add(installFileDrag())
|
||||
fileDragStopped = false
|
||||
}
|
||||
const restoreHost = state.failedOperation === 'install' && updateStoppedHost && !quitting
|
||||
shellInstallerOwnsQuit = false
|
||||
updateStoppedHost = false
|
||||
@@ -622,6 +648,7 @@ async function main(): Promise<void> {
|
||||
mandatoryUI?.preparingRestart(stillActive)
|
||||
// The embedded Platform document holds credentials issued by the Host that is about to stop.
|
||||
await platformView.closeAndWait()
|
||||
await disposeFileDrag().catch((error: unknown) => { console.error(error) })
|
||||
requireCleanStop = true
|
||||
updateStopFailure = undefined
|
||||
await backend.stop()
|
||||
@@ -1263,7 +1290,8 @@ async function main(): Promise<void> {
|
||||
mandatoryUI?.dispose()
|
||||
void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close(),
|
||||
// A Platform cleanup failure is logged without cutting the remaining Host shutdown short.
|
||||
platformView.dispose().catch((error: unknown) => { console.error(error) })])
|
||||
platformView.dispose().catch((error: unknown) => { console.error(error) }),
|
||||
disposeFileDrag().catch((error: unknown) => { console.error(error) })])
|
||||
.catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() })
|
||||
}
|
||||
app.on('before-quit', (event) => {
|
||||
|
||||
@@ -9,11 +9,23 @@ import { syncNativeTheme } from './preload-theme.ts'
|
||||
import { syncWindowsAppearance } from './preload-windows.ts'
|
||||
import { installMandatoryUpdateOverlay } from './preload-mandatory-overlay.ts'
|
||||
import { createDesktopBrowserBridge } from './preload-browser.ts'
|
||||
import type { FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
|
||||
|
||||
function createProductApi(): DshDesktopProductApi {
|
||||
return {
|
||||
protocolVersion: 1,
|
||||
browser: createDesktopBrowserBridge(),
|
||||
fileDrag: {
|
||||
prepare: (source, requestId) => ipcRenderer.invoke(DESKTOP_IPC.fileDragPrepare, source, requestId) as
|
||||
Promise<FileDragHandle | undefined>,
|
||||
cancel: (requestId) => { void ipcRenderer.invoke(DESKTOP_IPC.fileDragCancel, requestId).catch((_error: unknown) => {
|
||||
// Main-process shutdown cancels any remaining preparations.
|
||||
}) },
|
||||
start: handle => ipcRenderer.invoke(DESKTOP_IPC.fileDragStart, handle) as Promise<boolean>,
|
||||
release: (handle) => { void ipcRenderer.invoke(DESKTOP_IPC.fileDragRelease, handle).catch((_error: unknown) => {
|
||||
// Main-process shutdown also releases every prepared file.
|
||||
}) },
|
||||
},
|
||||
deviceInfo: () => ipcRenderer.invoke(DESKTOP_IPC.deviceInfo) as Promise<string>,
|
||||
keyboard: {
|
||||
closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise<void>,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { DragFiles } from '../src/file-drag-files.ts'
|
||||
|
||||
async function files() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-drag-test-'))
|
||||
const store = new DragFiles(root)
|
||||
onTestFinished(async () => { await store.dispose(); await rm(root, { recursive: true, force: true }) })
|
||||
return { root, store }
|
||||
}
|
||||
|
||||
it('exports original bytes and keeps a used file readable until disposal', async () => {
|
||||
const { root, store } = await files()
|
||||
const bytes = new Uint8Array([137, 80, 78, 71])
|
||||
const handle = await store.prepare(bytes, { name: '../../original.png', mediaType: 'image/png' })
|
||||
const path = store.take(handle)!
|
||||
expect(basename(path)).toBe('_.._original.png')
|
||||
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
|
||||
await store.release(handle)
|
||||
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
|
||||
await store.dispose()
|
||||
expect(await readdir(root)).toEqual([])
|
||||
expect(store.take(handle)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts non-image and unknown types while rejecting writes after disposal', async () => {
|
||||
const { store } = await files()
|
||||
for (const [name, mediaType] of [['报告.pdf', 'application/pdf'], ['custom.asset', 'application/x-unknown']]) {
|
||||
const bytes = new Uint8Array([37, 80, 68, 70])
|
||||
const handle = await store.prepare(bytes, { name, mediaType: mediaType! })
|
||||
const path = store.take(handle, false)!
|
||||
expect(basename(path)).toBe(name)
|
||||
expect(new Uint8Array(await readFile(path))).toEqual(bytes)
|
||||
}
|
||||
expect(store.take('foreign')).toBeUndefined()
|
||||
await store.dispose()
|
||||
await expect(store.prepare(new Uint8Array([1]), { name: 'x.png', mediaType: 'image/png' })).rejects.toThrow('File drag temporary storage unavailable')
|
||||
})
|
||||
|
||||
it('releases unused preparations and waits for a concurrent write before cleanup', async () => {
|
||||
const { root, store } = await files()
|
||||
const handle = await store.prepare(new Uint8Array([1]), { name: 'x.png', mediaType: 'image/png' })
|
||||
await store.release(handle)
|
||||
expect(await readdir(root)).toEqual([])
|
||||
const pending = store.prepare(new Uint8Array([2]), { name: 'y.png', mediaType: 'image/png' })
|
||||
const rejected = expect(pending).rejects.toThrow('File transfer closed')
|
||||
await store.dispose()
|
||||
await rejected
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['CON', 'prn', 'AUX', 'NUL', 'COM1', 'LPT9', 'COM¹', 'CON.extra'])('sanitizes the Windows device filename %s', async (name) => {
|
||||
const { store } = await files()
|
||||
const handle = await store.prepare(new Uint8Array([1]), { name: `${name}.png`, mediaType: 'image/png' })
|
||||
expect(basename(store.take(handle, false)!)).toBe(`_${name}.png`)
|
||||
})
|
||||
|
||||
it('bounds all retained bytes, including pending writes, and frees unused capacity', async () => {
|
||||
const { store } = await files()
|
||||
const pending = store.prepare(new Uint8Array(64 * 1024 * 1024), { name: 'large.png', mediaType: 'image/png' })
|
||||
await expect(store.prepare(new Uint8Array([1]), { name: 'extra.png', mediaType: 'image/png' })).rejects.toThrow('File drag temporary storage unavailable')
|
||||
const handle = await pending
|
||||
await store.release(handle)
|
||||
await expect(store.prepare(new Uint8Array([1]), { name: 'extra.png', mediaType: 'image/png' })).resolves.toEqual(expect.any(String))
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
vi.mock('node:fs/promises', async importOriginal => ({
|
||||
...await importOriginal<typeof import('node:fs/promises')>(), rm: vi.fn(), writeFile: vi.fn(),
|
||||
}))
|
||||
const fs = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises')
|
||||
const { DragFiles } = await import('../src/file-drag-files.ts')
|
||||
afterEach(() => { vi.mocked(rm).mockReset(); vi.mocked(writeFile).mockReset(); vi.useRealTimers() })
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-image-release-'))
|
||||
const store = new DragFiles(root)
|
||||
vi.mocked(rm).mockImplementation(fs.rm)
|
||||
vi.mocked(writeFile).mockImplementation(fs.writeFile)
|
||||
onTestFinished(async () => {
|
||||
vi.mocked(rm).mockImplementation(fs.rm)
|
||||
await store.dispose()
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
})
|
||||
const handle = await store.prepare(new Uint8Array([1]), { name: 'image.png', mediaType: 'image/png' })
|
||||
return { root, store, handle }
|
||||
}
|
||||
|
||||
it('waits for an in-flight release before disposal completes', async () => {
|
||||
const { root, store, handle } = await fixture()
|
||||
const barrier = Promise.withResolvers<undefined>()
|
||||
vi.mocked(rm).mockImplementationOnce(async (path, options) => { await barrier.promise; await fs.rm(path, options) })
|
||||
const releasing = store.release(handle)
|
||||
const secondRelease = store.release(handle)
|
||||
expect(store.take(handle, false)).toBeUndefined()
|
||||
let stopped = false
|
||||
const disposing = store.dispose().then(() => { stopped = true })
|
||||
await Promise.resolve()
|
||||
expect(stopped).toBe(false)
|
||||
expect(await readdir(root)).toHaveLength(1)
|
||||
barrier.resolve(undefined)
|
||||
await Promise.all([releasing, secondRelease, disposing])
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('retains a partial write whose immediate cleanup fails for disposal to retry', async () => {
|
||||
const { root, store } = await fixture()
|
||||
await store.prepare(new Uint8Array(0), { name: 'empty', mediaType: '' })
|
||||
vi.mocked(writeFile).mockImplementationOnce(async (path, data, options) => {
|
||||
await fs.writeFile(path, data, options)
|
||||
throw new Error('disk full after partial write')
|
||||
})
|
||||
vi.mocked(rm).mockRejectedValueOnce(new Error('file busy'))
|
||||
await expect(store.prepare(new Uint8Array(64 * 1024 * 1024 - 1), { name: 'partial.pdf', mediaType: 'application/pdf' })).rejects.toThrow('file busy')
|
||||
await expect(store.prepare(new Uint8Array([2]), { name: 'later', mediaType: '' })).rejects.toThrow('File drag temporary storage unavailable')
|
||||
await store.dispose()
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('reclaims delivered temporary bytes after the receiver grace period', async () => {
|
||||
const { root, store, handle } = await fixture()
|
||||
vi.useFakeTimers()
|
||||
store.take(handle)
|
||||
await store.release(handle)
|
||||
expect(await readdir(root)).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
await store.dispose()
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('retains a failed release for the shutdown cleanup to retry', async () => {
|
||||
const { root, store, handle } = await fixture()
|
||||
const path = store.take(handle, false)!
|
||||
vi.mocked(rm).mockRejectedValueOnce(new Error('file busy'))
|
||||
await expect(store.release(handle)).rejects.toThrow('file busy')
|
||||
expect(new Uint8Array(await readFile(path))).toEqual(new Uint8Array([1]))
|
||||
await store.dispose()
|
||||
expect(await readdir(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('waits for all removals before reporting a shutdown cleanup failure', async () => {
|
||||
const { store } = await fixture()
|
||||
await store.prepare(new Uint8Array([2]), { name: 'second.png', mediaType: 'image/png' })
|
||||
const barrier = Promise.withResolvers<undefined>()
|
||||
vi.mocked(rm).mockRejectedValueOnce(new Error('file busy'))
|
||||
.mockImplementationOnce(async (path, options) => { await barrier.promise; await fs.rm(path, options) })
|
||||
let settled = false
|
||||
const disposing = store.dispose().finally(() => { settled = true })
|
||||
const rejected = expect(disposing).rejects.toThrow('file busy')
|
||||
await vi.waitFor(() => { expect(rm).toHaveBeenCalledTimes(2) })
|
||||
expect(settled).toBe(false)
|
||||
barrier.resolve(undefined)
|
||||
await rejected
|
||||
await store.dispose()
|
||||
})
|
||||
@@ -0,0 +1,165 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import type { BrowserWindow, IpcMainInvokeEvent, NativeImage, WebContents, WebFrameMain } from 'electron'
|
||||
import { DESKTOP_IPC, assertDesktopSender } from '../src/ipc.ts'
|
||||
|
||||
const electron = vi.hoisted(() => ({
|
||||
handle: vi.fn(), removeHandler: vi.fn(), getPath: vi.fn(), getFileIcon: vi.fn(),
|
||||
}))
|
||||
vi.mock('electron', () => ({ app: electron, ipcMain: electron }))
|
||||
const { installDesktopFileDrag } = await import('../src/file-drag.ts')
|
||||
afterEach(() => { vi.resetAllMocks() })
|
||||
|
||||
type Handler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-native-file-drag-'))
|
||||
onTestFinished(async () => { await rm(root, { recursive: true, force: true }) })
|
||||
electron.getPath.mockReturnValue(root)
|
||||
const icon = {} as NativeImage
|
||||
electron.getFileIcon.mockResolvedValue(icon)
|
||||
const frame = Object.assign({} as WebFrameMain, { url: 'dsh-app://app/' })
|
||||
const window = Object.assign({} as BrowserWindow, { isDestroyed: () => false,
|
||||
webContents: Object.assign({} as WebContents, { mainFrame: frame, startDrag: vi.fn() }) })
|
||||
let current: BrowserWindow | undefined = window
|
||||
const resolve = vi.fn<Parameters<typeof installDesktopFileDrag>[1]>(async () => null)
|
||||
const owner = (event: IpcMainInvokeEvent): BrowserWindow => {
|
||||
assertDesktopSender(event, ['app'])
|
||||
if (current === undefined || event.sender !== current.webContents || event.senderFrame !== frame) throw new Error('Unowned file drag request')
|
||||
return current
|
||||
}
|
||||
const dispose = installDesktopFileDrag(owner, resolve)
|
||||
onTestFinished(dispose)
|
||||
const handlers = new Map<string, Handler>(electron.handle.mock.calls.map(([channel, handler]) => [channel, handler]))
|
||||
const event = Object.assign({} as IpcMainInvokeEvent, { sender: window.webContents, senderFrame: frame })
|
||||
const call = (channel: string, ...args: unknown[]) => Promise.resolve().then(() => {
|
||||
const identity = channel === DESKTOP_IPC.fileDragPrepare && args.length === 1 ? [randomUUID()] : []
|
||||
return (handlers.get(channel) as Handler)(event, ...args, ...identity)
|
||||
})
|
||||
const prepare = (bytes: Uint8Array, name = 'original.pdf', mediaType = 'application/pdf') => call(DESKTOP_IPC.fileDragPrepare, { kind: 'bytes', bytes, name, mediaType })
|
||||
return { root, window, frame, event, icon, call, prepare, dispose, resolve, detach: () => { current = undefined } }
|
||||
}
|
||||
|
||||
it('delivers original PDF bytes and filename through native copy drag', async () => {
|
||||
const f = await fixture()
|
||||
const bytes = new TextEncoder().encode('%PDF-1.4\noriginal\n%%EOF')
|
||||
const handle = await f.prepare(bytes, '报告.pdf')
|
||||
expect(f.window.webContents.startDrag).not.toHaveBeenCalled()
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, handle)).toBe(true)
|
||||
const [request] = f.window.webContents.startDrag.mock.calls[0] as [{ file: string; icon: NativeImage }]
|
||||
expect(request.icon).toBe(f.icon)
|
||||
expect(request.file).toContain('报告.pdf')
|
||||
expect(new Uint8Array(await readFile(request.file))).toEqual(bytes)
|
||||
await f.call(DESKTOP_IPC.fileDragRelease, handle)
|
||||
expect(new Uint8Array(await readFile(request.file))).toEqual(bytes)
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, handle)).toBe(false)
|
||||
await f.dispose()
|
||||
expect(await readdir(f.root)).toEqual([])
|
||||
})
|
||||
|
||||
it('uses shared sender authorization and releases failed native starts', async () => {
|
||||
const f = await fixture()
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, 'foreign')).toBe(false)
|
||||
const handle = await f.prepare(new Uint8Array([1]))
|
||||
f.window.webContents.startDrag.mockImplementationOnce(() => { throw new Error('OS rejected drag') })
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, handle)).toBe(false)
|
||||
await vi.waitFor(async () => { expect(await readdir(f.root)).toEqual([]) })
|
||||
f.frame.url = 'https://other.test/'
|
||||
await expect(f.call(DESKTOP_IPC.fileDragStart, handle)).rejects.toThrow('unowned renderer')
|
||||
f.frame.url = 'dsh-app://app/'
|
||||
Object.assign(f.event, { senderFrame: { url: 'dsh-app://app/' } })
|
||||
await expect(f.call(DESKTOP_IPC.fileDragStart, handle)).rejects.toThrow('Unowned file drag request')
|
||||
Object.assign(f.event, { senderFrame: f.frame, sender: {} })
|
||||
await expect(f.call(DESKTOP_IPC.fileDragStart, handle)).rejects.toThrow('Unowned file drag request')
|
||||
f.detach()
|
||||
await expect(f.call(DESKTOP_IPC.fileDragStart, handle)).rejects.toThrow('Unowned file drag request')
|
||||
})
|
||||
|
||||
it('cancels icon preparation on shutdown without waiting for the icon service', async () => {
|
||||
const f = await fixture()
|
||||
const pending = Promise.withResolvers<NativeImage>()
|
||||
electron.getFileIcon.mockReturnValue(pending.promise)
|
||||
const rejected = expect(f.prepare(new Uint8Array([1]))).rejects.toThrow('File transfer closed')
|
||||
await vi.waitFor(() => { expect(electron.getFileIcon).toHaveBeenCalledOnce() })
|
||||
await f.dispose()
|
||||
await rejected
|
||||
pending.resolve(f.icon)
|
||||
await pending.promise
|
||||
expect(await readdir(f.root)).toEqual([])
|
||||
})
|
||||
|
||||
it('cancels stalled icon preparation and returns its storage budget before the icon completes', async () => {
|
||||
const f = await fixture()
|
||||
const icon = Promise.withResolvers<NativeImage>()
|
||||
electron.getFileIcon.mockReturnValueOnce(icon.promise)
|
||||
const preparing = f.call(DESKTOP_IPC.fileDragPrepare,
|
||||
{ kind: 'bytes', bytes: new Uint8Array(64 * 1024 * 1024), name: 'large.pdf', mediaType: 'application/pdf' }, 'cancelled')
|
||||
const rejected = expect(preparing).rejects.toThrow('cancelled')
|
||||
await vi.waitFor(() => { expect(electron.getFileIcon).toHaveBeenCalledOnce() })
|
||||
await f.call(DESKTOP_IPC.fileDragCancel, 'cancelled')
|
||||
await rejected
|
||||
expect(await readdir(f.root)).toEqual([])
|
||||
const next = await f.prepare(new Uint8Array([2]))
|
||||
expect(next).toEqual(expect.any(String))
|
||||
icon.resolve(f.icon)
|
||||
await icon.promise
|
||||
})
|
||||
|
||||
it('aborts pending Host authorization when the bridge closes', async () => {
|
||||
const f = await fixture()
|
||||
const resource = Promise.withResolvers<null>()
|
||||
f.resolve.mockReturnValue(resource.promise)
|
||||
const preparing = f.call(DESKTOP_IPC.fileDragPrepare,
|
||||
{ kind: 'resource', address: 'dsh-resource://file/session/s/file.pdf', name: 'file.pdf', mediaType: 'application/pdf' })
|
||||
const rejected = expect(preparing).rejects.toThrow('File transfer closed')
|
||||
await vi.waitFor(() => { expect(f.resolve).toHaveBeenCalledOnce() })
|
||||
const signal = f.resolve.mock.calls[0]![1]
|
||||
await f.dispose()
|
||||
await rejected
|
||||
expect(signal.aborted).toBe(true)
|
||||
resource.resolve(null)
|
||||
await resource.promise
|
||||
expect(await readdir(f.root)).toEqual([])
|
||||
})
|
||||
|
||||
it('borrows only authorized unchanged files with the requested export name, never deleting the source', async () => {
|
||||
const f = await fixture()
|
||||
const bytes = Buffer.from('original PDF bytes')
|
||||
const path = join(f.root, 'report.pdf')
|
||||
await writeFile(path, bytes)
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
||||
f.resolve.mockResolvedValue({ path, size: bytes.length, sha256 })
|
||||
const request = { kind: 'resource', address: 'dsh-resource://file/session/s/report.pdf', name: 'report.pdf', mediaType: 'application/pdf', sha256, size: bytes.length }
|
||||
const handle = await f.call(DESKTOP_IPC.fileDragPrepare, request)
|
||||
expect(f.resolve).toHaveBeenCalledWith(request.address, expect.any(AbortSignal))
|
||||
expect(await readdir(f.root)).toEqual(['report.pdf'])
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, handle)).toBe(true)
|
||||
expect(f.window.webContents.startDrag).toHaveBeenCalledWith({ file: path, icon: f.icon })
|
||||
await f.dispose()
|
||||
expect(await readFile(path)).toEqual(bytes)
|
||||
})
|
||||
|
||||
it('requests a copy for renamed, changed, and nonlocal resources', async () => {
|
||||
const f = await fixture()
|
||||
const path = join(f.root, 'report.pdf')
|
||||
const bytes = Buffer.from('PDF')
|
||||
await writeFile(path, bytes)
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
||||
f.resolve.mockResolvedValue({ path, size: bytes.length, sha256 })
|
||||
const request = { kind: 'resource', address: 'dsh-resource://file/session/s/report.pdf', name: 'renamed.pdf', mediaType: 'application/pdf' }
|
||||
expect(await f.call(DESKTOP_IPC.fileDragPrepare, request)).toBeUndefined()
|
||||
expect(await f.call(DESKTOP_IPC.fileDragPrepare, { ...request, name: 'report.pdf', sha256: '0'.repeat(64) })).toBeUndefined()
|
||||
expect(await f.call(DESKTOP_IPC.fileDragPrepare, { ...request, name: 'report.pdf', size: 100 })).toBeUndefined()
|
||||
const handle = await f.call(DESKTOP_IPC.fileDragPrepare, { ...request, name: 'report.pdf' })
|
||||
await writeFile(path, 'changed')
|
||||
expect(await f.call(DESKTOP_IPC.fileDragStart, handle)).toBe(false)
|
||||
f.resolve.mockResolvedValue(null)
|
||||
expect(await f.call(DESKTOP_IPC.fileDragPrepare, request)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([null, {}, { kind: 'bytes', bytes: '/private/path', mediaType: 'text/plain' }, { kind: 'resource', address: '/private/path', mediaType: 1 }])('rejects malformed wire requests', async (value) => {
|
||||
const f = await fixture()
|
||||
await expect(f.call(DESKTOP_IPC.fileDragPrepare, value)).rejects.toThrow('Invalid file drag request')
|
||||
})
|
||||
@@ -30,6 +30,21 @@ server.listen(0, '127.0.0.1', () => {
|
||||
process.send({ type: 'ready', url: 'http://127.0.0.1:' + server.address().port + '/?token=fixture' })
|
||||
})
|
||||
process.on('message', message => {
|
||||
if (message.type === 'file-drag-cancel') {
|
||||
process.send({ type: 'file-drag-resource', requestId: message.requestId, resource: null })
|
||||
return
|
||||
}
|
||||
if (message.type === 'file-drag-resource') {
|
||||
if (message.address === 'pending') return
|
||||
if (message.address === 'invalid') {
|
||||
process.send({ type: 'file-drag-resource', requestId: message.requestId, resource: { path: '/invalid', sha256: 'bad', size: -1 } })
|
||||
return
|
||||
}
|
||||
process.send({ type: 'file-drag-resource', requestId: message.requestId,
|
||||
resource: message.address === 'remote' ? null : { path: '/authorized/report.pdf', sha256: 'a'.repeat(64), size: 42 },
|
||||
...(message.address === 'denied' ? { error: 'File read denied' } : {}) })
|
||||
return
|
||||
}
|
||||
if (message.type === 'update-tasks') {
|
||||
process.send({ type: 'update-tasks', requestId: message.requestId, active: message.action === 'lock' })
|
||||
return
|
||||
@@ -73,6 +88,32 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('desktop host process', () => {
|
||||
it('correlates authorized file resources and rejects Host denial over private IPC', async () => {
|
||||
const host = hostProcess(projectWithHost())
|
||||
await host.start()
|
||||
expect(await Promise.all([host.resolveDragResource('local'), host.resolveDragResource('remote')]))
|
||||
.toEqual([{ path: '/authorized/report.pdf', sha256: 'a'.repeat(64), size: 42 }, null])
|
||||
await expect(host.resolveDragResource('denied')).rejects.toThrow('File read denied')
|
||||
await host.stop(true)
|
||||
await expect(host.resolveDragResource('local')).rejects.toThrow('file drag: Host is unavailable')
|
||||
})
|
||||
|
||||
it('cancels a pending resource request without invalidating later Host requests', async () => {
|
||||
const host = hostProcess(projectWithHost())
|
||||
await host.start()
|
||||
const abort = new AbortController()
|
||||
const pending = host.resolveDragResource('pending', abort.signal)
|
||||
const rejected = expect(pending).rejects.toThrow('cancelled')
|
||||
abort.abort(new Error('cancelled'))
|
||||
await rejected
|
||||
await expect(host.resolveDragResource('local')).resolves.toMatchObject({ path: '/authorized/report.pdf' })
|
||||
})
|
||||
|
||||
it('rejects malformed file authorization responses and terminates the Host', async () => {
|
||||
const host = hostProcess(projectWithHost())
|
||||
await host.start()
|
||||
await expect(host.resolveDragResource('invalid')).rejects.toThrow('invalid IPC event')
|
||||
})
|
||||
it('correlates task inspections and admission changes over private IPC', async () => {
|
||||
const host = hostProcess(projectWithHost())
|
||||
await expect(host.updateTasks('inspect')).rejects.toThrow('Host is unavailable')
|
||||
|
||||
@@ -16,6 +16,9 @@ import { writeCrashReport } from '../src/crash-report.ts'
|
||||
type InvokeEvent = { sender?: unknown; senderFrame: { url: string } }
|
||||
type InvokeHandler = (event: InvokeEvent, ...args: unknown[]) => unknown
|
||||
|
||||
const fileDrag = vi.hoisted(() => ({ dispose: vi.fn<() => Promise<void>>(), install: vi.fn() }))
|
||||
vi.mock('../src/file-drag.ts', () => ({ installDesktopFileDrag: () => { fileDrag.install(); return fileDrag.dispose } }))
|
||||
|
||||
vi.mock('../src/web-document.ts', () => ({ authenticateWebHost: async () => 'test-cookie', serveWebDocument: vi.fn(), forwardWebRequest: vi.fn() }))
|
||||
// Report persistence has its own unit tests; here it resolves within microtasks so the fatal
|
||||
// dialog never outlives the test that triggered it.
|
||||
@@ -273,6 +276,8 @@ const harness = await vi.hoisted(async () => {
|
||||
navigated = deferred(); dialogShown = deferred(); quitCompleted = deferred()
|
||||
policyBlocked = deferred()
|
||||
embeddedPolicy = undefined
|
||||
fileDrag.install.mockReset()
|
||||
fileDrag.dispose.mockReset().mockResolvedValue(undefined)
|
||||
platformDisposeDeferred = undefined
|
||||
platformCloseDeferred = undefined
|
||||
},
|
||||
@@ -1518,6 +1523,45 @@ describe('desktop main startup', () => {
|
||||
expect(harness.app.quit).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports file cleanup failure without ending Host shutdown early', async () => {
|
||||
const host = await readyForUpdate()
|
||||
const failure = new Error('image cleanup failed')
|
||||
fileDrag.dispose.mockRejectedValueOnce(failure)
|
||||
harness.app.quit()
|
||||
await host.stopping.promise
|
||||
await vi.waitFor(() => { expect(console.error).toHaveBeenCalledWith(failure) })
|
||||
expect(harness.app.quit).toHaveBeenCalledOnce()
|
||||
host.exited.resolve()
|
||||
await harness.quitCompleted.promise
|
||||
expect(harness.app.quit).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('waits for image cleanup before stopping the Host for installation', async () => {
|
||||
const host = await readyForUpdate()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
fileDrag.dispose.mockReturnValueOnce(cleanup.promise)
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
||||
const preparing = harness.prepareUpdate()
|
||||
await vi.waitFor(() => { expect(fileDrag.dispose).toHaveBeenCalledOnce() })
|
||||
expect(host.stop).not.toHaveBeenCalled()
|
||||
cleanup.resolve(undefined)
|
||||
await host.stopping.promise
|
||||
host.exited.resolve()
|
||||
await expect(preparing).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('continues installation after reporting file cleanup failure', async () => {
|
||||
const host = await readyForUpdate()
|
||||
const failure = new Error('temporary file busy')
|
||||
fileDrag.dispose.mockRejectedValueOnce(failure)
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 })
|
||||
const preparing = harness.prepareUpdate()
|
||||
await host.stopping.promise
|
||||
expect(console.error).toHaveBeenCalledWith(failure)
|
||||
host.exited.resolve()
|
||||
await expect(preparing).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('finishes quitting when the native window is destroyed before its closed listener clears ownership', async () => {
|
||||
const host = await readyForUpdate()
|
||||
const window = harness.windows[0]!
|
||||
@@ -2018,6 +2062,7 @@ describe('desktop main startup', () => {
|
||||
const restarted = harness.nextHostStart()
|
||||
harness.publishUpdate({ phase: 'error', version: '1.0.1-nightly.1', failedOperation: 'install', message: 'Installer failed' })
|
||||
await restarted
|
||||
expect(fileDrag.install).toHaveBeenCalledTimes(2)
|
||||
const replacement = harness.hosts[1]!
|
||||
replacement.url = 'http://127.0.0.1:3099/?token=replacement'
|
||||
if (mandatory) replacement.updateTasks.mockResolvedValue(true)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { DesktopFileDrag, FileDragHandle, FileDragRequestId } from '@deepseek-ai/dsh-util-file-drag'
|
||||
import { JSDOM } from 'jsdom'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { installMandatoryUpdateOverlay } from '../src/preload-mandatory-overlay.ts'
|
||||
@@ -254,3 +255,27 @@ it('forwards browser guest input only for the focused live webview lease', async
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
off()
|
||||
})
|
||||
|
||||
it('prepares original image bytes and starts native drags without renderer filesystem paths', async () => {
|
||||
vi.stubGlobal('location', new URL('dsh-app://app/'))
|
||||
await import('../src/preload-app.ts')
|
||||
const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi
|
||||
const bytes = new Uint8Array([137, 80, 78, 71])
|
||||
electron.ipcRenderer.invoke.mockResolvedValueOnce('image-handle').mockResolvedValueOnce(true).mockResolvedValueOnce(undefined)
|
||||
await expect(api.fileDrag.prepare({ kind: 'bytes', bytes, name: 'original.png', mediaType: 'image/png' }, 'request' as FileDragRequestId)).resolves.toBe('image-handle')
|
||||
await expect(api.fileDrag.start('image-handle' as FileDragHandle)).resolves.toBe(true)
|
||||
api.fileDrag.release('image-handle' as FileDragHandle)
|
||||
api.fileDrag.cancel('request' as FileDragRequestId)
|
||||
expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
|
||||
[DESKTOP_IPC.fileDragPrepare, { kind: 'bytes', bytes, name: 'original.png', mediaType: 'image/png' }, 'request'],
|
||||
[DESKTOP_IPC.fileDragStart, 'image-handle'],
|
||||
[DESKTOP_IPC.fileDragRelease, 'image-handle'],
|
||||
[DESKTOP_IPC.fileDragCancel, 'request'],
|
||||
])
|
||||
})
|
||||
|
||||
// The Host and Client compiler faces share no runtime entry; this assignment checks their image protocol.
|
||||
const imageProtocol = (value: import('../src/ipc.ts').DshDesktopProductApi['fileDrag']): DesktopFileDrag => value
|
||||
const imageProtocolBack = (value: DesktopFileDrag): import('../src/ipc.ts').DshDesktopProductApi['fileDrag'] => value
|
||||
void imageProtocol
|
||||
void imageProtocolBack
|
||||
@@ -31,6 +31,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/product-analytics/tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/file-drag"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/web/tests/README.md
|
||||
/apps-web-browser-e2e:
|
||||
en: 3e8ecd3509fa944f
|
||||
zh: 3fd3ff9fcd1e1e90
|
||||
en: 2deef0df72a6465c
|
||||
zh: b24989ffb2d8586a
|
||||
/apps-web-browser-e2e/completion-observations:
|
||||
en: 1f9c004f3eb465fb
|
||||
zh: 3e45ef8014bad48d
|
||||
en: 8c139966983959cc
|
||||
zh: 54f09f9b7ac84cda
|
||||
/apps-web-browser-e2e/these-are-host-face-tests:
|
||||
en: 101d687b1d98c5b7
|
||||
zh: f8c31115e8a15be7
|
||||
|
||||
@@ -14,6 +14,8 @@ On Linux, `--with-deps` installs dependencies through the system package manager
|
||||
|
||||
Ordinary scenarios begin with no registered Workspace or Session and a durable marker recording a removed default Workspace, so explicit folder-selection scenarios retain control of their cwd. `launchWebScaffold({ firstUse: true })` leaves initialization eligible for startup scenarios.
|
||||
|
||||
Same-day message goldens use the shared `WEB_FIXTURE_TIME`: seeded transcripts pass it as `createdAt` and pin the browser calendar with `pinBrowserClock`; scenarios that submit live messages also use `pinHostClock` and restore it after teardown. These clocks advance with real elapsed time while keeping native timers intact, so midnight does not change a scenario's date labels.
|
||||
|
||||
## Completion observations
|
||||
|
||||
State-sensitive cases use Workspace, admission, attachment, and model-stream barriers to separate visible intermediate states from completed operations. Model-picker persistence assertions wait for the saved default, independently of menu closure. Details close waits for frame transitions; archive verification assigns an explicit title to the seeded Session and follows that identity across reload. The [CI test reliability workflow](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state) owns these synchronization rules.
|
||||
@@ -24,6 +26,8 @@ Plugin-manager and HMR lifecycle switches wait up to `PLUGIN_TOGGLE_SETTLE_MS` (
|
||||
|
||||
Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the locator again when its old element detaches and checks connection in the same browser task as native scrolling. Scenarios retain their visibility and geometry assertions after scrolling.
|
||||
|
||||
Question-composer resize assertions wait for overflow, the clipping mask, and toggle visibility to agree after CSS scroll timelines update.
|
||||
|
||||
[The snapshot after closing a sidebar pane](shortcuts-panels.e2e.ts) moves the pointer outside the controls and waits for hover tooltips to close before capture. Keyboard focus remains on the pane.
|
||||
|
||||
[The same-origin terminal case](sidebar-terminal.e2e.ts) waits for the UI-triggered Host close operation before checking process termination. It also checks the first window's process, saved bindings and layout after reload.
|
||||
|
||||
@@ -14,6 +14,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps
|
||||
|
||||
普通场景以没有已登记 Workspace 或 Session、但持久化标记记录默认 Workspace 已被删除的状态启动,使显式文件夹选择场景自行决定 cwd。`launchWebScaffold({ firstUse: true })` 保留初始化资格,供启动场景使用。
|
||||
|
||||
当天消息的 golden 使用共享的 `WEB_FIXTURE_TIME`:预置会话将它传为 `createdAt`,并通过 `pinBrowserClock` 固定浏览器日期;提交实时消息的场景还使用 `pinHostClock`,在清理后恢复。固定后的时钟随真实耗时前进,保留原生定时器,因此跨午夜不会改变场景的日期标签。
|
||||
|
||||
## 完成状态观察
|
||||
|
||||
依赖状态的用例使用 Workspace、接纳、附件和模型流屏障,区分可见中间状态与已完成操作。模型选择器的持久化断言等待默认设置保存完成,不以菜单关闭作为完成信号。详情关闭等待框架过渡结束;归档验证为 seed Session 设置显式标题,并跨重载跟踪该身份。[CI 测试可靠性工作流](../../../.agents/skills/dsh-ci-test-reliability/SKILL.md#synchronize-on-state)负责这些同步规则。
|
||||
@@ -24,6 +26,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps
|
||||
|
||||
显式滚动使用 `support.ts` 的 `scrollIntoView`:旧元素脱离 DOM 时重新解析 locator,并在同一个浏览器任务中检查连接状态、执行原生滚动。各场景保留滚动后的可见性与几何断言。
|
||||
|
||||
问答输入框的缩放断言等待 CSS 滚动时间线更新,使溢出、裁剪遮罩与展开按钮的可见性一致。
|
||||
|
||||
[侧栏窗格关闭后的快照](shortcuts-panels.e2e.ts)在捕获前将指针移出控件,并等待悬停提示关闭。键盘焦点保持在窗格上。
|
||||
|
||||
[同源窗口终端用例](sidebar-terminal.e2e.ts)等待 UI 触发的 Host 关闭操作完成后,再检查进程终止。它还检查第一个窗口的进程、保存的绑定以及重载后的布局。
|
||||
|
||||
@@ -1082,7 +1082,7 @@ else process.exit(1);
|
||||
width: (node as HTMLImageElement).naturalWidth,
|
||||
height: (node as HTMLImageElement).naturalHeight,
|
||||
draggable: (node as HTMLImageElement).draggable,
|
||||
}))).toEqual({ width: 1, height: 1, draggable: false })
|
||||
}))).toEqual({ width: 1, height: 1, draggable: true })
|
||||
const centering = await tinyImage.evaluate((node) => {
|
||||
const image = node.getBoundingClientRect()
|
||||
const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect()
|
||||
@@ -1105,6 +1105,47 @@ else process.exit(1);
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
await expect.poll(() => imageZoom.innerText()).toBe('100%')
|
||||
|
||||
await tinyImage.dragTo(input)
|
||||
const draggedPreview = page.getByRole('group', { name: 'Pending attachments', exact: true }).getByRole('img')
|
||||
await draggedPreview.waitFor({ state: 'visible', timeout: 5000 })
|
||||
const draggedBytes = await draggedPreview.evaluate(async (node: HTMLImageElement) =>
|
||||
Array.from(new Uint8Array(await (await fetch(node.src)).arrayBuffer())))
|
||||
expect(Buffer.from(draggedBytes)).toEqual(TINY_PNG)
|
||||
expect(await input.innerText()).toBe('')
|
||||
await page.getByRole('button', { name: 'Remove image tiny.png', exact: true }).click()
|
||||
const endedDrag = await tinyImage.evaluateHandle((node) => {
|
||||
const proof = { token: '', started: false, ended: false }
|
||||
window.addEventListener('dragstart', (event) => {
|
||||
proof.token = event.dataTransfer!.getData('application/x-dsh-file')
|
||||
proof.started = event.isTrusted && proof.token !== ''
|
||||
}, { once: true })
|
||||
node.addEventListener('dragend', (event) => { proof.ended = event.isTrusted && !node.isConnected }, { once: true })
|
||||
return proof
|
||||
})
|
||||
const tinyRect = (await tinyImage.boundingBox())!
|
||||
const inputRect = (await input.boundingBox())!
|
||||
await page.mouse.move(tinyRect.x + tinyRect.width / 2, tinyRect.y + tinyRect.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(tinyRect.x - 20, tinyRect.y, { steps: 4 })
|
||||
await expect.poll(() => endedDrag.evaluate(proof => proof.started)).toBe(true)
|
||||
const tinyTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('tiny.png', { exact: true }) })
|
||||
await tinyTab.locator('[data-dockkit-tab-close]').evaluate((button: HTMLElement) => { button.click() })
|
||||
await tinyImage.waitFor({ state: 'detached' })
|
||||
await page.mouse.move(inputRect.x + inputRect.width / 2, inputRect.y + inputRect.height / 2, { steps: 5 })
|
||||
await page.mouse.up()
|
||||
await draggedPreview.waitFor({ state: 'visible', timeout: 5000 })
|
||||
expect(Buffer.from(await draggedPreview.evaluate(async (node: HTMLImageElement) =>
|
||||
Array.from(new Uint8Array(await (await fetch(node.src)).arrayBuffer()))))).toEqual(TINY_PNG)
|
||||
await expect.poll(() => endedDrag.evaluate(proof => proof.ended)).toBe(true)
|
||||
await page.getByRole('button', { name: 'Remove image tiny.png', exact: true }).click()
|
||||
const retiredToken = await endedDrag.evaluate(proof => proof.token)
|
||||
await input.evaluate((node, token) => {
|
||||
const dataTransfer = new DataTransfer()
|
||||
dataTransfer.setData('application/x-dsh-file', token)
|
||||
node.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer }))
|
||||
}, retiredToken)
|
||||
expect(await draggedPreview.count()).toBe(0)
|
||||
await endedDrag.dispose()
|
||||
await openFile('large.svg')
|
||||
await expect.poll(() => viewer.innerText()).toBe('Image')
|
||||
await viewer.click()
|
||||
@@ -1143,6 +1184,17 @@ else process.exit(1);
|
||||
await page.getByRole('menuitem', { name: '100%', exact: true }).click()
|
||||
await expect.poll(async () => (await largeImage.boundingBox())!.width).toBeCloseTo(1200, 0)
|
||||
expect(await imageScrollport.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true)
|
||||
await imageScrollport.evaluate((node) => { node.scrollLeft = 0; node.scrollTop = 0 })
|
||||
const panBox = await imageScrollport.boundingBox()
|
||||
if (panBox === null) throw new Error('image viewport missing')
|
||||
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grab')
|
||||
await page.mouse.move(panBox.x + 180, panBox.y + 180)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(panBox.x + 100, panBox.y + 100, { steps: 8 })
|
||||
await expect.poll(() => imageScrollport.evaluate(node => [node.scrollLeft, node.scrollTop])).toEqual([80, 80])
|
||||
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grabbing')
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grab')
|
||||
svgZoom = await revealDocumentZoom(page, preview)
|
||||
await svgZoom.click()
|
||||
await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click()
|
||||
@@ -1151,9 +1203,11 @@ else process.exit(1);
|
||||
expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull()
|
||||
sections.push([
|
||||
'## Image zoom', '',
|
||||
'- Contained sidebar PNG drags into the composer as an original-byte image attachment',
|
||||
'- Small PNG fit width remains at intrinsic size; 200% doubles it',
|
||||
'- SVG fit width -> 100% -> fit width toggles horizontal overflow: false -> true -> false',
|
||||
'- Image and Blob identities remain stable while zoom changes',
|
||||
'- Overflowing image drag pans both axes by 80px; releasing restores the grab cursor',
|
||||
].join('\n'))
|
||||
|
||||
const releaseRead = Promise.withResolvers<undefined>()
|
||||
|
||||
@@ -320,6 +320,15 @@ describe('web e2e: Markdown image rendering', () => {
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
await workspaceImage.dragTo(input)
|
||||
const dragged = page.getByRole('group', { name: 'Pending attachments', exact: true }).getByRole('img')
|
||||
await dragged.waitFor({ state: 'visible' })
|
||||
expect(Buffer.from(await dragged.evaluate(async (node: HTMLImageElement) =>
|
||||
Array.from(new Uint8Array(await (await fetch(node.src)).arrayBuffer()))))).toEqual(PNG)
|
||||
expect(await input.innerText()).toBe('')
|
||||
expect(await page.getByRole('dialog', { name: 'Image preview', exact: true }).count()).toBe(0)
|
||||
await page.getByRole('button', { name: 'Remove image valid.png', exact: true }).click()
|
||||
await link.evaluate((element) => { element.scrollIntoView({ block: 'end' }) })
|
||||
await page.mouse.move(0, 0)
|
||||
await link.hover()
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import {
|
||||
connectFreshWorkspace, expandTurnProcesses, newEnglishPage, saveFailureShot,
|
||||
connectFreshWorkspace, expandTurnProcesses, newEnglishPage, pinBrowserClock, pinHostClock, saveFailureShot, WEB_FIXTURE_TIME,
|
||||
} from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/question-composer', import.meta.url))
|
||||
@@ -211,9 +211,7 @@ function resettledFixture(fixture: string, outcome: 'cancelled' | 'queued' | 'la
|
||||
const event: unknown = JSON.parse(line)
|
||||
if (!isRecord(event)) throw new Error('question fixture event is invalid')
|
||||
if (event.type === 'session') {
|
||||
// Keep the derived session's relative-time header stable as the source
|
||||
// fixture ages.
|
||||
event.createdAt = Date.now()
|
||||
event.createdAt = WEB_FIXTURE_TIME
|
||||
lines.push(JSON.stringify(event))
|
||||
continue
|
||||
}
|
||||
@@ -299,9 +297,10 @@ async function openSeededTranscript(fixtureText: string, seedId: string): Promis
|
||||
const scaffold = await launchWebScaffold({})
|
||||
let browser: Browser | undefined
|
||||
try {
|
||||
await seedSession(scaffold, fixtureText, seedId)
|
||||
await seedSession(scaffold, fixtureText, seedId, undefined, { createdAt: WEB_FIXTURE_TIME })
|
||||
browser = await chromium.launch()
|
||||
const page = await newEnglishPage(browser)
|
||||
await pinBrowserClock(page)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -326,12 +325,15 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
let answeredSession: SessionId | undefined
|
||||
let hostClock: ReturnType<typeof pinHostClock> | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
if (MODE !== 'record') hostClock = pinHostClock()
|
||||
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15, compareReplaySession: true })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
if (MODE !== 'record') await pinBrowserClock(page)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
@@ -340,8 +342,12 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
try {
|
||||
await browser?.close()
|
||||
} finally {
|
||||
try { await scaffold?.close() }
|
||||
finally { hostClock?.mockRestore() }
|
||||
}
|
||||
})
|
||||
|
||||
it('asks through the composer, answers, and completes with the answer logged', async () => {
|
||||
@@ -412,10 +418,11 @@ describe('web e2e: resident question composer round trip', () => {
|
||||
})
|
||||
expect(squeeze.rows).toBeGreaterThan(0)
|
||||
for (const description of await composer.locator('[data-option-description]').all()) {
|
||||
const copy = await description.evaluate(readCopy)
|
||||
expect(copy.lines).toBeLessThanOrEqual(2)
|
||||
expect(copy.clipped).toBe(copy.overflows)
|
||||
expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined)
|
||||
await expect.poll(async () => {
|
||||
const copy = await description.evaluate(readCopy)
|
||||
return copy.lines <= 2 && copy.clipped === copy.overflows
|
||||
&& copy.toggle === (copy.clipped ? 'expand' : undefined)
|
||||
}).toBe(true)
|
||||
}
|
||||
scrollStates.push(squeeze.scrolls)
|
||||
expect(squeeze.titleInScrollport).toBe(true)
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
parseSeedFixture, renderSeedFixture, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
import { newEnglishPage, pinBrowserClock, pinHostClock, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/rich-text-quotes', import.meta.url))
|
||||
const BORROWED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url))
|
||||
@@ -289,8 +289,10 @@ describe.skipIf(MODE === 'record')('web e2e: rich-text quotes', () => {
|
||||
let page: Page
|
||||
let replayDir: string
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let hostClock: ReturnType<typeof pinHostClock> | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
hostClock = pinHostClock()
|
||||
replayDir = await mkdtemp(join(tmpdir(), 'dsh-rich-text-quotes-replay-'))
|
||||
const replayOverride = join(replayDir, 'replay.override.json')
|
||||
await writeFile(replayOverride, JSON.stringify(REPLAY))
|
||||
@@ -316,6 +318,7 @@ describe.skipIf(MODE === 'record')('web e2e: rich-text quotes', () => {
|
||||
await seedSession(scaffold, otherSessionFixture(borrowed), OTHER_ID, undefined, { createdAt: createdAt + 1_000 })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
await pinBrowserClock(page)
|
||||
await page.addInitScript(({ key, value }) => {
|
||||
if (localStorage.getItem(key) === null) localStorage.setItem(key, value)
|
||||
}, { key: LEGACY_DRAFT_KEY, value: LEGACY_DRAFT })
|
||||
@@ -336,6 +339,7 @@ describe.skipIf(MODE === 'record')('web e2e: rich-text quotes', () => {
|
||||
await rm(replayDir, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
hostClock?.mockRestore()
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'rich-text-quotes e2e cleanup failed')
|
||||
})
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
launchWebScaffold, readPersistedEvents, realizeSeedFixture, seedSession, selectedSessionFixture, watchConsole,
|
||||
webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, pinBrowserClock, pinHostClock, saveFailureShot, writeComposerDraft } from './support.ts'
|
||||
|
||||
const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
|
||||
const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url))
|
||||
@@ -108,9 +108,11 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
let liveReferenceOptions: string[]
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
const apiCalls: string[] = []
|
||||
let hostClock: ReturnType<typeof pinHostClock> | undefined
|
||||
|
||||
beforeAll(async () => {
|
||||
if (MODE === 'record') throw new Error('subagent conversation is a keyless assembled snapshot')
|
||||
hostClock = pinHostClock()
|
||||
const selectedBaseFixture = await selectedSessionFixture(BASE_FIXTURE)
|
||||
const baseFixture = prepareSessionSnapshotFixtureForComparison(
|
||||
await readFile(selectedBaseFixture, 'utf8'),
|
||||
@@ -127,6 +129,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
browser = await chromium.launch()
|
||||
await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/non-migrating-session-reads', import.meta.url)), { recursive: true })
|
||||
page = await newEnglishPage(browser)
|
||||
await pinBrowserClock(page)
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path.startsWith('/api/')) apiCalls.push(path)
|
||||
@@ -357,6 +360,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
await rm(sidecarRoot, { recursive: true, force: true })
|
||||
.catch((error: unknown) => failures.push(error))
|
||||
}
|
||||
hostClock?.mockRestore()
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed')
|
||||
})
|
||||
|
||||
@@ -4702,6 +4702,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-entry-customization` | — | [`packages/client/ui-entry-customization/src/index.ts`](../packages/client/ui-entry-customization/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-file-drag` | — | [`packages/client/ui-file-drag/src/index.ts`](../packages/client/ui-file-drag/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-input-trigger` | — | [`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-jobs` | — | [`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts) |
|
||||
@@ -4890,6 +4891,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
|
||||
| `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-code-language` | — | [`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-crypto` | — | [`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-file-drag` | — | [`packages/util/file-drag/src/index.ts`](../packages/util/file-drag/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-time` | — | [`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-values` | — | [`packages/util/values/src/index.ts`](../packages/util/values/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-workspace-path` | — | [`packages/util/workspace-path/src/index.ts`](../packages/util/workspace-path/src/index.ts) |
|
||||
|
||||
@@ -4704,6 +4704,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-browse` | — | [`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-directory-picker-native` | — | [`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-entry-customization` | — | [`packages/client/ui-entry-customization/src/index.ts`](../packages/client/ui-entry-customization/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-file-drag` | — | [`packages/client/ui-file-drag/src/index.ts`](../packages/client/ui-file-drag/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-goal` | — | [`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-input-trigger` | — | [`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-client-ui-jobs` | — | [`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts) |
|
||||
@@ -4892,6 +4893,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-code-language` | — | [`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-crypto` | — | [`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-file-drag` | — | [`packages/util/file-drag/src/index.ts`](../packages/util/file-drag/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-time` | — | [`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-values` | — | [`packages/util/values/src/index.ts`](../packages/util/values/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-util-workspace-path` | — | [`packages/util/workspace-path/src/index.ts`](../packages/util/workspace-path/src/index.ts) |
|
||||
|
||||
@@ -24,6 +24,7 @@ flowchart TD
|
||||
pkg_timeout["timeout"]
|
||||
pkg_util_code_language["util-code-language"]
|
||||
pkg_util_crypto["util-crypto"]
|
||||
pkg_util_file_drag["util-file-drag"]
|
||||
pkg_util_time["util-time"]
|
||||
pkg_util_values["util-values"]
|
||||
pkg_util_workspace_path["util-workspace-path"]
|
||||
@@ -170,6 +171,7 @@ flowchart TD
|
||||
pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"]
|
||||
pkg_client_ui_dockkit["client-ui-dockkit"]
|
||||
pkg_client_ui_entry_customization["client-ui-entry-customization"]
|
||||
pkg_client_ui_file_drag["client-ui-file-drag"]
|
||||
pkg_client_ui_goal["client-ui-goal"]
|
||||
pkg_client_ui_input_trigger["client-ui-input-trigger"]
|
||||
pkg_client_ui_jobs["client-ui-jobs"]
|
||||
@@ -1471,6 +1473,7 @@ flowchart TD
|
||||
| [`timeout`](../packages/util/timeout) | `util` | — |
|
||||
| [`util-code-language`](../packages/util/code-language) | `util` | — |
|
||||
| [`util-crypto`](../packages/util/crypto) | `util` | — |
|
||||
| [`util-file-drag`](../packages/util/file-drag) | `util` | — |
|
||||
| [`util-time`](../packages/util/time) | `util` | — |
|
||||
| [`util-values`](../packages/util/values) | `util` | — |
|
||||
| [`util-workspace-path`](../packages/util/workspace-path) | `util` | — |
|
||||
@@ -1507,6 +1510,7 @@ flowchart TD
|
||||
| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — |
|
||||
| [`client-ui-dockkit`](../packages/client/ui-dockkit) | `client` | — |
|
||||
| [`client-ui-entry-customization`](../packages/client/ui-entry-customization) | `client` | — |
|
||||
| [`client-ui-file-drag`](../packages/client/ui-file-drag) | `client` | — |
|
||||
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | — |
|
||||
| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — |
|
||||
| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — |
|
||||
|
||||
@@ -26,6 +26,7 @@ flowchart TD
|
||||
pkg_timeout["timeout"]
|
||||
pkg_util_code_language["util-code-language"]
|
||||
pkg_util_crypto["util-crypto"]
|
||||
pkg_util_file_drag["util-file-drag"]
|
||||
pkg_util_time["util-time"]
|
||||
pkg_util_values["util-values"]
|
||||
pkg_util_workspace_path["util-workspace-path"]
|
||||
@@ -172,6 +173,7 @@ flowchart TD
|
||||
pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"]
|
||||
pkg_client_ui_dockkit["client-ui-dockkit"]
|
||||
pkg_client_ui_entry_customization["client-ui-entry-customization"]
|
||||
pkg_client_ui_file_drag["client-ui-file-drag"]
|
||||
pkg_client_ui_goal["client-ui-goal"]
|
||||
pkg_client_ui_input_trigger["client-ui-input-trigger"]
|
||||
pkg_client_ui_jobs["client-ui-jobs"]
|
||||
@@ -1473,6 +1475,7 @@ flowchart TD
|
||||
| [`timeout`](../packages/util/timeout) | `util` | — |
|
||||
| [`util-code-language`](../packages/util/code-language) | `util` | — |
|
||||
| [`util-crypto`](../packages/util/crypto) | `util` | — |
|
||||
| [`util-file-drag`](../packages/util/file-drag) | `util` | — |
|
||||
| [`util-time`](../packages/util/time) | `util` | — |
|
||||
| [`util-values`](../packages/util/values) | `util` | — |
|
||||
| [`util-workspace-path`](../packages/util/workspace-path) | `util` | — |
|
||||
@@ -1509,6 +1512,7 @@ flowchart TD
|
||||
| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — |
|
||||
| [`client-ui-dockkit`](../packages/client/ui-dockkit) | `client` | — |
|
||||
| [`client-ui-entry-customization`](../packages/client/ui-entry-customization) | `client` | — |
|
||||
| [`client-ui-file-drag`](../packages/client/ui-file-drag) | `client` | — |
|
||||
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | — |
|
||||
| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — |
|
||||
| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — |
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/file-drag-callbacks/guide.md
|
||||
/:
|
||||
en: 9af755cc45c2291b
|
||||
zh: 3c4053dd22a8263c
|
||||
/supply-file-preparation-callbacks-to-image-views:
|
||||
en: 52816e83f1c07873
|
||||
zh: fd96927a64d80a3c
|
||||
/supply-file-preparation-callbacks-to-image-views/change:
|
||||
en: f790da179b0f64b4
|
||||
zh: 7640190a12fd6796
|
||||
/supply-file-preparation-callbacks-to-image-views/migration:
|
||||
en: 02f27032b22dae7c
|
||||
zh: 242a3b6779ad4c4b
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Custom image factories and Markdown image delegates must supply file preparation callbacks."
|
||||
---
|
||||
|
||||
# Supply file preparation callbacks to image views
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
The published `conversation.images.content` Factory now requires a `fileDrag` inject member, and `MarkdownDelegate.fileImages` requires the same callbacks. `ChatViewInjected` also gains `fileDrag`. Custom registrations that construct these values must update before compilation succeeds. The shipped Web and Desktop compositions provide `@deepseek-ai/dsh-client-ui-file-drag`; custom compositions must provide the service and its feedback slot. Existing image label fields are unchanged.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Include `@deepseek-ai/dsh-client-ui-file-drag` in the custom client composition, together with locale, the slot renderer, and a shell declaring `shell.overlay`. Declare `fileDrag` in each consumer plugin’s `inject` list.
|
||||
2. Pass `ctx.fileDrag.actions` as `fileDrag` when registering `conversation.images.content`, assembling `ChatViewInjected`, or constructing `MarkdownDelegate.fileImages`. The callbacks prepare Blob or URL content and report localized failures; components receive them explicitly. Optional `resourceAddress(path)` supplies a Session resource reference for authorized Desktop source reuse.
|
||||
3. Custom composer implementations can receive `ctx.fileDrag.read` through their own inject interface to accept application-local tokens. The `conversation.input.attachments` slot’s public props remain unchanged; only the shipped attachment implementation requires this callback. Accept internal drops in the composer region and retain ordinary `dataTransfer.files` handling for external files.
|
||||
4. Rebuild custom packages and verify an image drag into the composer. Confirm the original filename and bytes and that dropping back on the source does not add an attachment. See the [file-drag plugin](../../../../packages/client/ui-file-drag/README.md) for ownership and browser limitations.
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "自定义图片 Factory 和 Markdown 图片委托必须提供文件准备回调。"
|
||||
---
|
||||
|
||||
# 为图片视图提供文件准备回调
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
已发布的 `conversation.images.content` Factory 现在要求 `fileDrag` 注入成员,`MarkdownDelegate.fileImages` 也要求相同回调。`ChatViewInjected` 同样新增 `fileDrag`。构造这些值的自定义注册必须更新才能通过编译。内置 Web 和 Desktop 组合提供 `@deepseek-ai/dsh-client-ui-file-drag`;自定义组合必须提供该服务及其反馈插槽。已有图片文案字段不变。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 在自定义客户端组合中加载 `@deepseek-ai/dsh-client-ui-file-drag`,同时提供 locale、插槽渲染器和声明 `shell.overlay` 的外壳。在每个消费插件的 `inject` 列表中声明 `fileDrag`。
|
||||
2. 注册 `conversation.images.content`、组装 `ChatViewInjected` 或构造 `MarkdownDelegate.fileImages` 时,将 `ctx.fileDrag.actions` 作为 `fileDrag` 传入。回调负责准备 Blob 或 URL 内容及报告本地化失败;组件显式接收它们。可选的 `resourceAddress(path)` 提供 Session 资源引用,以便 Desktop 经授权后复用源文件。
|
||||
3. 自定义输入框实现可通过自己的注入接口接收 `ctx.fileDrag.read`,以接受应用内令牌。`conversation.input.attachments` 插槽的公开 props 不变;只有内置附件实现要求该回调。内部投放仅在输入框范围接受,外部文件保留普通 `dataTransfer.files` 处理。
|
||||
4. 重新构建自定义包,验证图片拖入输入框后的原始文件名和字节,并确认拖回源图不会增加附件。所有权和浏览器限制见[文件拖放插件](../../../../packages/client/ui-file-drag/README.zh.md)。
|
||||
@@ -334,6 +334,9 @@
|
||||
- id: ui-attachment
|
||||
name: '@deepseek-ai/dsh-client-ui-attachment'
|
||||
|
||||
- id: ui-file-drag
|
||||
name: '@deepseek-ai/dsh-client-ui-file-drag'
|
||||
|
||||
# Tool call tree, generic fallback, and keyed business Tool views.
|
||||
- id: ui-tool
|
||||
name: '@deepseek-ai/dsh-client-ui-tool'
|
||||
|
||||
@@ -191,7 +191,8 @@
|
||||
"@deepseek-ai/dsh-host-product-telemetry-otel": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-settings-session-log": "workspace:*",
|
||||
"@deepseek-ai/dsh-harness-dev-context": "workspace:*",
|
||||
"@deepseek-ai/dsh-deliverable-file-references": "workspace:*"
|
||||
"@deepseek-ai/dsh-deliverable-file-references": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-file-drag": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
|
||||
@@ -82,7 +82,7 @@ function styleInjectionModule(
|
||||
* Everything else under @deepseek-ai/* is either a module-table entry
|
||||
* (external) or a leak the purity gate rejects.
|
||||
*/
|
||||
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
|
||||
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
|
||||
|
||||
/**
|
||||
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
|
||||
|
||||
@@ -15,17 +15,17 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-attachment/use-this-package:
|
||||
en: 15cdd9d52dcc71b8
|
||||
zh: 53933fbbf5d39100
|
||||
en: df47b6abd789dcb5
|
||||
zh: 7e17c7f18631a572
|
||||
/deepseek-ai-dsh-client-ui-attachment/use-this-package/draft-attachments:
|
||||
en: 8246cb3c470e3fc5
|
||||
zh: 7028cf466a832b99
|
||||
/deepseek-ai-dsh-client-ui-attachment/use-this-package/message-images-and-the-lightbox:
|
||||
en: 8b3d7729152d8060
|
||||
zh: 815f932ccc9653f5
|
||||
en: 38717dbf90a2520d
|
||||
zh: b81915d2b06e18f8
|
||||
/deepseek-ai-dsh-client-ui-attachment/use-this-package/drop-overlay:
|
||||
en: 836a62c8f73366ac
|
||||
zh: 35da7836eaf68a67
|
||||
en: bf1a8ebd1a550396
|
||||
zh: a4d736010ab6602e
|
||||
/deepseek-ai-dsh-client-ui-attachment/understand-the-implementation:
|
||||
en: 07204b7d538b7434
|
||||
zh: 705cc6b0c9387cce
|
||||
|
||||
@@ -25,6 +25,8 @@ This package renders everything the conversation UI shows about attachments: one
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Image drag actions and the composer’s original-file reader arrive through slot injection from [ui-file-drag](../ui-file-drag/README.md). The image gallery Factory also receives these actions through its declared inject face; custom Factory replacements must supply that face.
|
||||
|
||||
Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) and [`ui-tool`](../ui-tool/README.md) when tool results need an image gallery. It waits for their slot declarations and registers its components into them. Users then see the mixed draft-attachment rail, DeepSeek Web file cards with upload controls, the drop overlay with its limits line, message images sized by count, the tool card's gallery, and the Escape/mask/close lightbox.
|
||||
|
||||
### Draft attachments
|
||||
@@ -37,9 +39,13 @@ In Chat, one user message presents files and images in a right-aligned wrapping
|
||||
|
||||
Trajectory attachment rows request 48px square thumbnails that contain the complete image without cropping. Loading and retry icons keep the same box, with localized tooltips and accessible names; the image opens the same lightbox. A slot owner may supply a presentation-only image label for the thumbnail and lightbox without changing the durable reference or the cache lookup.
|
||||
|
||||
Loaded conversation images can be dragged into the composer as image attachments or to another application as original files. Dragging does not open the lightbox. The shared image-transfer owner retains Web drag bytes until the gesture ends; Desktop uses the system file drag. A failed preparation or drag keeps the image visible and reports a localized retry message.
|
||||
|
||||
Internal Web image drags do not show the full-page overlay. A foreign or expired image token without actual files is ignored. Browser transfer limitations are documented by [ui-primitives](../ui-primitives/README.md#known-limitations-and-deferred-work).
|
||||
|
||||
### Drop overlay
|
||||
|
||||
While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them.
|
||||
While an external file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
图片拖放回调与输入框的原图读取回调通过 slot 注入从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。图片图库 Factory 也通过声明的注入接口接收回调;自定义 Factory 替换实现必须提供该接口。
|
||||
|
||||
与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件,工具结果需要图片图库时也要挂载 [`ui-tool`](../ui-tool/README.zh.md)。插件等待这些 slot 的声明,并把组件注册进去。用户会看到混合草稿附件栏、带上传控件的 DeepSeek Web 文件卡、带限制说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片图库,以及支持 Escape、遮罩和关闭按钮的灯箱。
|
||||
|
||||
### 草稿附件
|
||||
@@ -37,9 +39,13 @@ Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行
|
||||
|
||||
Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试图标保持相同尺寸,提供本地化的工具提示和可访问名称;图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。
|
||||
|
||||
已加载的会话图片可拖入输入框作为图片附件,也可将原文件拖到其他应用。拖动不会打开灯箱。共享图片传递组件将 Web 拖动的原图字节保留到手势结束;Desktop 使用系统文件拖放。准备或拖放失败时保留图片,并显示本地化的重试提示。
|
||||
|
||||
应用内 Web 图片拖放不显示全屏遮罩。没有实际文件的外来或过期图片令牌会被忽略。浏览器传递限制见 [ui-primitives](../ui-primitives/README.zh.md#known-limitations-and-deferred-work)。
|
||||
|
||||
### 拖放遮罩
|
||||
|
||||
文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。
|
||||
外部文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-renderer",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory",
|
||||
"@deepseek-ai/dsh-client-ui-tool"
|
||||
"@deepseek-ai/dsh-client-ui-tool",
|
||||
"@deepseek-ai/dsh-client-ui-file-drag"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -57,7 +58,9 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-tool": "workspace:*",
|
||||
"clsx": "^2.0.0"
|
||||
"clsx": "^2.0.0",
|
||||
"@deepseek-ai/dsh-client-ui-file-drag": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-file-drag": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconLoadingOutlineRegular, IconRefreshOutlineRegular, DraggableImage, ImageLightbox, type FileDragActions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import css from './MessageImage.module.css'
|
||||
|
||||
@@ -68,10 +67,11 @@ function dimensionsOf(image: MessageImageSpec): { readonly width: number; readon
|
||||
* @param props.labels - resolved strings (tooltip, loading, retry, lightbox).
|
||||
* @returns the bounded thumbnail button, or the retry control on failure.
|
||||
*/
|
||||
export function MessageImage({ image, load, variant, labels }: {
|
||||
export function MessageImage({ image, load, variant, labels, fileDrag }: {
|
||||
image: MessageImageSpec
|
||||
load: ImageLoader
|
||||
variant: 'single' | 'tile' | 'thumbnail'
|
||||
fileDrag: FileDragActions
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
const preview = 'preview' in image ? image.preview : undefined
|
||||
@@ -143,7 +143,8 @@ export function MessageImage({ image, load, variant, labels }: {
|
||||
{loadingThumbnail ? <IconLoadingOutlineRegular className={css.spinner} /> : labels.loading}
|
||||
</span>
|
||||
)
|
||||
: <img src={src} alt={label} style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
|
||||
: <DraggableImage fileDrag={fileDrag} src={src} alt={label} name={preview?.name ?? attachment?.name ?? 'image'}
|
||||
style={fit === undefined ? undefined : { objectPosition: fit.objectPosition }} />}
|
||||
</button>
|
||||
{open && src !== null && <ImageLightbox src={src} alt={label} labels={labels.lightbox} onClose={close} />}
|
||||
</>
|
||||
@@ -152,12 +153,13 @@ export function MessageImage({ image, load, variant, labels }: {
|
||||
|
||||
/** Wrapping image group shared by user and assistant history: a lone image
|
||||
* renders large unless its owner requests compact tiles or contained list thumbnails. */
|
||||
export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels }: {
|
||||
export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels, fileDrag }: {
|
||||
images: readonly MessageImageSpec[]
|
||||
load: ImageLoader
|
||||
align: 'start' | 'end'
|
||||
compact?: boolean
|
||||
thumbnail?: boolean
|
||||
fileDrag: FileDragActions
|
||||
labels: MessageImageLabels
|
||||
}) {
|
||||
if (images.length === 0) return null
|
||||
@@ -170,6 +172,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail =
|
||||
image={image}
|
||||
load={load}
|
||||
variant={variant}
|
||||
fileDrag={fileDrag}
|
||||
labels={labels}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { FileCard } from '../FileCard.tsx'
|
||||
import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts'
|
||||
import { installDocumentDropEvents } from './drop-events.ts'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import css from './ComposerAttachments.module.css'
|
||||
|
||||
/** Rail item retaining its browser-owned attachment for callbacks. */
|
||||
@@ -19,6 +20,11 @@ interface ComposerRailItem extends AttachmentRailItem {
|
||||
attachment: ComposerAttachment
|
||||
}
|
||||
|
||||
/** File transfer lookup injected by this attachment implementation. */
|
||||
export interface ComposerAttachmentsInjected {
|
||||
readonly readFileDrag: (transfer: DataTransfer) => File | undefined
|
||||
}
|
||||
|
||||
/** The owner share and locale seat the attachment rail reads from its slot props. */
|
||||
type ComposerAttachmentsInput = Pick<ComposerAttachmentsProps,
|
||||
| 'attachments' | 'canAcceptDrop' | 'onAddFiles' | 'onRemoveAttachment' | 'uploads' | 'onRetryFile'
|
||||
@@ -26,8 +32,8 @@ type ComposerAttachmentsInput = Pick<ComposerAttachmentsProps,
|
||||
|
||||
/** Draft image previews, pending-file cards, drop target, and original-image preview. */
|
||||
export function ComposerAttachments({
|
||||
attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, uploadFailureText, dropLimits, t,
|
||||
}: ComposerAttachmentsInput) {
|
||||
attachments, canAcceptDrop, onAddFiles, onRemoveAttachment, uploads, onRetryFile, uploadFailureText, dropLimits, t, readFileDrag,
|
||||
}: ComposerAttachmentsInput & InjectFace<ComposerAttachmentsInjected>) {
|
||||
const [preview, setPreview] = useState<ComposerImageAttachment | null>(null)
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const dragDepth = useRef(0)
|
||||
@@ -37,8 +43,8 @@ export function ComposerAttachments({
|
||||
}, [attachments, preview])
|
||||
|
||||
useEffect(() => {
|
||||
return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive)
|
||||
}, [canAcceptDrop, onAddFiles])
|
||||
return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive, readFileDrag)
|
||||
}, [canAcceptDrop, onAddFiles, readFileDrag])
|
||||
|
||||
const railItems = useMemo<ComposerRailItem[]>(() => attachments.map(attachment => ({
|
||||
id: attachment.id,
|
||||
|
||||
@@ -12,11 +12,11 @@ type MessageImagesProps = MessageImagesOwnerProps & PropsRenderFactories
|
||||
* @param props - image sources, authorized loader, layout hints, optional labels, and Factory locale seat.
|
||||
* @returns the official gallery; no Session binding is needed by this component.
|
||||
*/
|
||||
export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t }:
|
||||
export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, fileDrag }:
|
||||
FactoryComponentPropsOf<'conversation.images.content'>) {
|
||||
const resolved = useMemo(() => labels ?? messageImageLabels(t), [labels, t])
|
||||
return <ImageGallery images={images} load={loadImage} align={align}
|
||||
compact={compact} thumbnail={thumbnail} labels={resolved} />
|
||||
compact={compact} thumbnail={thumbnail} labels={resolved} fileDrag={fileDrag} />
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Document drag-and-drop listeners owned by one mounted attachment view. */
|
||||
import { FILE_DRAG_TOKEN_TYPE } from '@deepseek-ai/dsh-util-file-drag'
|
||||
import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,7 @@ function droppedDirectories(dataTransfer: DataTransfer, files: readonly File[]):
|
||||
* @param onAddFiles - attachment intake callback.
|
||||
* @param dragDepth - the view's retained nested-drag counter.
|
||||
* @param setDragActive - publish whether a file drag is active.
|
||||
* @param readFileDrag - resolve an original image retained by this application during a browser drag.
|
||||
* @returns cleanup for exactly these listeners.
|
||||
*/
|
||||
export function installDocumentDropEvents(
|
||||
@@ -32,10 +34,13 @@ export function installDocumentDropEvents(
|
||||
onAddFiles: ComposerAttachmentsProps['onAddFiles'],
|
||||
dragDepth: { current: number },
|
||||
setDragActive: (active: boolean) => void,
|
||||
readFileDrag: (transfer: DataTransfer) => File | undefined,
|
||||
): () => void {
|
||||
const fileTransfer = (event: globalThis.DragEvent): DataTransfer | null => {
|
||||
const dataTransfer = event.dataTransfer
|
||||
if (dataTransfer === null || !dataTransfer.types.includes('Files')) return null
|
||||
if (dataTransfer === null || (!dataTransfer.types.includes('Files') && !dataTransfer.types.includes(FILE_DRAG_TOKEN_TYPE))) return null
|
||||
if (dataTransfer.types.includes(FILE_DRAG_TOKEN_TYPE)
|
||||
&& (!(event.target instanceof Element) || event.target.closest('[data-composer-card]') === null)) return null
|
||||
return dataTransfer
|
||||
}
|
||||
const reset = (): void => {
|
||||
@@ -43,10 +48,11 @@ export function installDocumentDropEvents(
|
||||
setDragActive(false)
|
||||
}
|
||||
const onDragEnter = (event: globalThis.DragEvent): void => {
|
||||
if (fileTransfer(event) === null) return
|
||||
const dataTransfer = fileTransfer(event)
|
||||
if (dataTransfer === null) return
|
||||
event.preventDefault()
|
||||
dragDepth.current += 1
|
||||
setDragActive(true)
|
||||
if (!dataTransfer.types.includes(FILE_DRAG_TOKEN_TYPE)) setDragActive(true)
|
||||
}
|
||||
const onDragOver = (event: globalThis.DragEvent): void => {
|
||||
const dataTransfer = fileTransfer(event)
|
||||
@@ -68,8 +74,9 @@ export function installDocumentDropEvents(
|
||||
event.preventDefault()
|
||||
reset()
|
||||
if (canAcceptDrop) {
|
||||
const files = [...dataTransfer.files]
|
||||
onAddFiles(files, droppedDirectories(dataTransfer, files))
|
||||
const image = dataTransfer.types.includes(FILE_DRAG_TOKEN_TYPE) ? readFileDrag(dataTransfer) : undefined
|
||||
const files = image === undefined ? [...dataTransfer.files] : [image]
|
||||
if (files.length > 0) onAddFiles(files, droppedDirectories(dataTransfer, files))
|
||||
}
|
||||
}
|
||||
document.addEventListener('dragenter', onDragEnter)
|
||||
|
||||
@@ -5,11 +5,12 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-tool/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client'
|
||||
import { ComposerAttachments } from './ComposerAttachments.tsx'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-file-drag/client'
|
||||
import { ComposerAttachments, type ComposerAttachmentsInjected } from './ComposerAttachments.tsx'
|
||||
import { MessageImages, MessageImagesContent } from './MessageImages.tsx'
|
||||
|
||||
/** Slot registry required by the attachment presentation plugin. */
|
||||
export const inject = ['slots']
|
||||
export const inject = ['slots', 'fileDrag']
|
||||
|
||||
/**
|
||||
* Register composer attachments and the shared gallery with its domain adapters.
|
||||
@@ -18,9 +19,11 @@ export const inject = ['slots']
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.slots.registerFactory({
|
||||
name: 'conversation.images.content', scope: 'root', locale: 'conversation',
|
||||
inject: () => ({ fileDrag: ctx.fileDrag.actions }),
|
||||
}, MessageImagesContent)
|
||||
ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({
|
||||
name: 'conversation.input.attachments',
|
||||
inject: (): ComposerAttachmentsInjected => ({ readFileDrag: ctx.fileDrag.read }),
|
||||
locale: 'conversation',
|
||||
}, ComposerAttachments))
|
||||
ctx.slots.inject('conversation.chat.message.images', () => ctx.slots.register({
|
||||
|
||||
@@ -85,6 +85,7 @@ function props(overrides: Partial<ComposerAttachmentsOwnerProps> = {}): Paramete
|
||||
uploadFailureText: (reason: string) => `refused:${reason}`,
|
||||
t,
|
||||
...overrides,
|
||||
readFileDrag: () => undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,3 +379,28 @@ describe('ComposerAttachments file drafts', () => {
|
||||
expect(view.getByTitle('.env').textContent).toContain('ENV 3B')
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts internal file tokens only inside the composer without the full-page overlay', () => {
|
||||
const file = new File([Uint8Array.of(1, 2)], 'original.pdf', { type: 'application/pdf' })
|
||||
const onAddFiles = vi.fn()
|
||||
const view = render(<div data-composer-card data-testid="composer"><ComposerAttachments {...props({ onAddFiles })}
|
||||
readFileDrag={transfer => transfer.getData('application/x-dsh-file') === 'token' ? file : undefined} /></div>)
|
||||
const data = new Map([['application/x-dsh-file', 'token']])
|
||||
const transfer = {
|
||||
types: ['application/x-dsh-file'], items: [], files: [],
|
||||
getData: (key: string) => data.get(key) ?? '',
|
||||
}
|
||||
fireEvent.dragEnter(document.body, { dataTransfer: transfer })
|
||||
fireEvent.dragOver(document.body, { dataTransfer: transfer })
|
||||
fireEvent.drop(document.body, { dataTransfer: transfer })
|
||||
expect(onAddFiles).not.toHaveBeenCalled()
|
||||
const target = view.getByTestId('composer')
|
||||
fireEvent.dragEnter(target, { dataTransfer: transfer })
|
||||
fireEvent.dragOver(target, { dataTransfer: transfer })
|
||||
expect(view.queryByText('文件或图片拖动到此处即可添加')).toBeNull()
|
||||
fireEvent.drop(target, { dataTransfer: transfer })
|
||||
expect(onAddFiles).toHaveBeenCalledExactlyOnceWith([file], new Set())
|
||||
data.set('application/x-dsh-file', 'foreign')
|
||||
fireEvent.drop(target, { dataTransfer: transfer })
|
||||
expect(onAddFiles).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Independent domain image positions with one shared official gallery Factory. */
|
||||
import * as fileDragPlugin from '@deepseek-ai/dsh-client-ui-file-drag/client'
|
||||
import { act, cleanup, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { AttachmentId } from '@deepseek-ai/dsh-attachment'
|
||||
@@ -27,6 +28,7 @@ async function bench() {
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
new UiConversation(runtime.ctx, runtime.sessions)
|
||||
await runtime.mount(fileDragPlugin)
|
||||
await runtime.mount(attachmentPlugin)
|
||||
await runtime.sessions.add({ id: 'image-domains' })
|
||||
const session = runtime.sessions.retainFor(runtime.ctx, SessionId('image-domains'))
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('MessageImage', () => {
|
||||
const load = Object.assign(vi.fn(() => new Promise<string>(() => {})), {
|
||||
peek: vi.fn(() => 'blob:seeded'),
|
||||
})
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
expect(view.queryByText('图片加载中…')).toBeNull()
|
||||
expect((view.getByAltText('history.png') as HTMLImageElement).src).toContain('blob:seeded')
|
||||
expect(load).toHaveBeenCalledWith(attachment)
|
||||
@@ -47,7 +47,7 @@ describe('MessageImage', () => {
|
||||
|
||||
it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:history')
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(frame.getAttribute('style')).toContain('width: 240px')
|
||||
expect(frame.getAttribute('style')).toContain('height: 120px')
|
||||
@@ -62,7 +62,7 @@ describe('MessageImage', () => {
|
||||
|
||||
it('ignores a click while the thumbnail is still loading', () => {
|
||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(view.getByText('图片加载中…')).toBeTruthy()
|
||||
fireEvent.click(frame)
|
||||
@@ -72,7 +72,7 @@ describe('MessageImage', () => {
|
||||
it('falls back to the image label for an unnamed attachment', async () => {
|
||||
const { name: _named, ...unnamed } = attachment
|
||||
const load = vi.fn().mockResolvedValue('blob:unnamed')
|
||||
const view = render(<MessageImage image={{ attachment: unnamed }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment: unnamed }} load={load} variant="single" labels={labels} />)
|
||||
await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() })
|
||||
expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy()
|
||||
})
|
||||
@@ -82,7 +82,7 @@ describe('MessageImage', () => {
|
||||
.mockRejectedValueOnce(new Error('offline'))
|
||||
.mockRejectedValueOnce(new Error('still offline'))
|
||||
.mockResolvedValueOnce('blob:retry')
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
fireEvent.click(retry)
|
||||
const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
@@ -94,7 +94,7 @@ describe('MessageImage', () => {
|
||||
it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:ratio')
|
||||
const tall = render(
|
||||
<MessageImage image={{ attachment: { ...attachment, width: 100, height: 2000 } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage fileDrag={fileDrag} image={{ attachment: { ...attachment, width: 100, height: 2000 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(tallFrame.getAttribute('style')).toContain('width: 60px')
|
||||
@@ -103,7 +103,7 @@ describe('MessageImage', () => {
|
||||
expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top')
|
||||
tall.unmount()
|
||||
const wide = render(
|
||||
<MessageImage image={{ attachment: { ...attachment, width: 4000, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage fileDrag={fileDrag} image={{ attachment: { ...attachment, width: 4000, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(wideFrame.getAttribute('style')).toContain('width: 240px')
|
||||
@@ -112,7 +112,7 @@ describe('MessageImage', () => {
|
||||
expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center')
|
||||
wide.unmount()
|
||||
const small = render(
|
||||
<MessageImage image={{ attachment: { ...attachment, width: 100, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage fileDrag={fileDrag} image={{ attachment: { ...attachment, width: 100, height: 100 } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(smallFrame.getAttribute('style')).toContain('width: 100px')
|
||||
@@ -121,7 +121,7 @@ describe('MessageImage', () => {
|
||||
|
||||
it('renders a tile at the fixed square without inline sizing', () => {
|
||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="tile" labels={labels} />)
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="tile" labels={labels} />)
|
||||
const frame = view.getByRole('button', { name: 'history.png,点击查看原图' })
|
||||
expect(frame.getAttribute('data-variant')).toBe('tile')
|
||||
expect(frame.getAttribute('style')).toBeNull()
|
||||
@@ -129,7 +129,7 @@ describe('MessageImage', () => {
|
||||
|
||||
it.each(['tile', 'thumbnail'] as const)('keeps the %s variant on the failed-load retry control', async (variant) => {
|
||||
const load = vi.fn().mockRejectedValue(new Error('offline'))
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant={variant} labels={labels} />)
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant={variant} labels={labels} />)
|
||||
const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' })
|
||||
expect(retry.getAttribute('data-variant')).toBe(variant)
|
||||
})
|
||||
@@ -140,7 +140,7 @@ describe('MessageImage', () => {
|
||||
] as const)('labels thumbnail status icons and retries a failed read in %s', async (_locale, statusLabels) => {
|
||||
const pending = Promise.withResolvers<string>()
|
||||
const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry')
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="thumbnail" labels={statusLabels} />)
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="thumbnail" labels={statusLabels} />)
|
||||
const loading = view.getByRole('button', { name: statusLabels.loading })
|
||||
expect(loading.getAttribute('title')).toBe(statusLabels.loading)
|
||||
expect(loading.getAttribute('aria-busy')).toBe('true')
|
||||
@@ -164,7 +164,7 @@ describe('MessageImage', () => {
|
||||
it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => {
|
||||
const { name: _name, ...unnamed } = attachment
|
||||
const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail')
|
||||
const view = render(<ImageGallery images={[{ attachment: unnamed, label: 'Image 2' }]} load={load} align="start" thumbnail labels={labels} />)
|
||||
const view = render(<ImageGallery fileDrag={fileDrag} images={[{ attachment: unnamed, label: 'Image 2' }]} load={load} align="start" thumbnail labels={labels} />)
|
||||
fireEvent.click(await view.findByRole('button', { name: labels.loadFailed }))
|
||||
const image = await view.findByAltText('Image 2')
|
||||
expect(image.closest('button')?.getAttribute('data-variant')).toBe('thumbnail')
|
||||
@@ -182,13 +182,13 @@ describe('MessageImage', () => {
|
||||
it('ignores a load settling after unmount', async () => {
|
||||
let resolve: ((url: string) => void) | undefined
|
||||
const load = vi.fn(() => new Promise<string>((r) => { resolve = r }))
|
||||
const view = render(<MessageImage image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
const view = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={load} variant="single" labels={labels} />)
|
||||
view.unmount()
|
||||
resolve?.('blob:late')
|
||||
await Promise.resolve()
|
||||
let reject: ((error: Error) => void) | undefined
|
||||
const failing = vi.fn(() => new Promise<string>((_r, rej) => { reject = rej }))
|
||||
const second = render(<MessageImage image={{ attachment }} load={failing} variant="single" labels={labels} />)
|
||||
const second = render(<MessageImage fileDrag={fileDrag} image={{ attachment }} load={failing} variant="single" labels={labels} />)
|
||||
second.unmount()
|
||||
reject?.(new Error('late failure'))
|
||||
await Promise.resolve()
|
||||
@@ -199,7 +199,7 @@ describe('MessageImage preview arm', () => {
|
||||
it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => {
|
||||
const load = vi.fn()
|
||||
const view = render(
|
||||
<MessageImage
|
||||
<MessageImage fileDrag={fileDrag}
|
||||
image={{ preview: { url: 'blob:echo', name: 'echo.png', width: 640, height: 320 } }}
|
||||
load={load}
|
||||
variant="single"
|
||||
@@ -217,7 +217,7 @@ describe('MessageImage preview arm', () => {
|
||||
it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => {
|
||||
const load = vi.fn()
|
||||
const view = render(
|
||||
<MessageImage image={{ preview: { url: 'blob:unprobed' } }} load={load} variant="single" labels={labels} />,
|
||||
<MessageImage fileDrag={fileDrag} image={{ preview: { url: 'blob:unprobed' } }} load={load} variant="single" labels={labels} />,
|
||||
)
|
||||
const img = view.getByAltText('图片') as HTMLImageElement
|
||||
const frame = img.closest('button') as HTMLButtonElement
|
||||
@@ -227,7 +227,7 @@ describe('MessageImage preview arm', () => {
|
||||
|
||||
it('opens the lightbox from a preview thumbnail', () => {
|
||||
const view = render(
|
||||
<MessageImage image={{ preview: { url: 'blob:box' } }} load={vi.fn(async () => '')} variant="tile" labels={labels} />,
|
||||
<MessageImage fileDrag={fileDrag} image={{ preview: { url: 'blob:box' } }} load={vi.fn(async () => '')} variant="tile" labels={labels} />,
|
||||
)
|
||||
fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' }))
|
||||
expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy()
|
||||
@@ -237,10 +237,10 @@ describe('MessageImage preview arm', () => {
|
||||
describe('ImageGallery', () => {
|
||||
it('renders nothing without images and an aligned wrapping group with them', async () => {
|
||||
const load = vi.fn().mockResolvedValue('blob:gallery')
|
||||
const empty = render(<ImageGallery images={[]} load={load} align="start" labels={labels} />)
|
||||
const empty = render(<ImageGallery fileDrag={fileDrag} images={[]} load={load} align="start" labels={labels} />)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const view = render(
|
||||
<ImageGallery
|
||||
<ImageGallery fileDrag={fileDrag}
|
||||
images={[{ attachment }, { preview: { url: 'blob:echo', name: 'echo.png' } }, { attachment }]}
|
||||
load={load}
|
||||
align="end"
|
||||
@@ -254,16 +254,16 @@ describe('ImageGallery', () => {
|
||||
|
||||
it('renders a lone image large and compact or grouped images as square tiles', () => {
|
||||
const load = vi.fn(() => new Promise<string>(() => {}))
|
||||
const lone = render(<ImageGallery images={[{ attachment }]} load={load} align="start" labels={labels} />)
|
||||
const lone = render(<ImageGallery fileDrag={fileDrag} images={[{ attachment }]} load={load} align="start" labels={labels} />)
|
||||
expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1)
|
||||
lone.unmount()
|
||||
const compact = render(
|
||||
<ImageGallery images={[{ attachment }]} load={load} align="end" compact labels={labels} />,
|
||||
<ImageGallery fileDrag={fileDrag} images={[{ attachment }]} load={load} align="end" compact labels={labels} />,
|
||||
)
|
||||
expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1)
|
||||
compact.unmount()
|
||||
const several = render(
|
||||
<ImageGallery images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
|
||||
<ImageGallery fileDrag={fileDrag} images={[{ attachment }, { attachment }, { attachment }]} load={load} align="end" labels={labels} />,
|
||||
)
|
||||
expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3)
|
||||
})
|
||||
@@ -276,6 +276,7 @@ describe('ImageGallery', () => {
|
||||
locale.setLocale('en')
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined })
|
||||
await runtime.mount(attachmentPlugin)
|
||||
const images = [{ preview: { url: 'blob:labels', name: 'preview.png' } }]
|
||||
const loadImage = vi.fn(async () => 'blob:unused')
|
||||
@@ -316,6 +317,7 @@ describe('ImageGallery', () => {
|
||||
new UiConversation(runtime.ctx, runtime.sessions)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.declare({ 'conversation.chat.message.images': { kind: 'single', scope: 'session' } })
|
||||
runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined })
|
||||
await runtime.mount(attachmentPlugin)
|
||||
await runtime.sessions.add({ id: 'message-images-test' })
|
||||
const session = runtime.sessions.retainFor(runtime.ctx, SessionId('message-images-test'))
|
||||
@@ -342,3 +344,5 @@ describe('ImageGallery', () => {
|
||||
expect(loadImage).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() }
|
||||
@@ -33,6 +33,7 @@ async function bench() {
|
||||
'tool.call.images': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
} as never, () => null)
|
||||
ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined })
|
||||
const registerFactory = vi.spyOn(ctx.slots, 'registerFactory')
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
@@ -46,10 +47,12 @@ describe('attachment plugin', () => {
|
||||
|
||||
it('registers all entries and removes them with the plugin fiber', async () => {
|
||||
const { ctx, fiber, registerFactory } = await bench()
|
||||
expect(inject).toEqual(['slots'])
|
||||
expect(registerFactory).toHaveBeenCalledWith({
|
||||
expect(inject).toEqual(['slots', 'fileDrag'])
|
||||
expect(registerFactory.mock.calls[0]?.[0]).toMatchObject({
|
||||
name: 'conversation.images.content', scope: 'root', locale: 'conversation',
|
||||
}, MessageImagesContent)
|
||||
})
|
||||
expect(typeof registerFactory.mock.calls[0]?.[0].inject).toBe('function')
|
||||
expect(registerFactory.mock.calls[0]?.[1]).toBe(MessageImagesContent)
|
||||
expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(1)
|
||||
expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{
|
||||
locale: 'conversation', component: ComposerAttachments,
|
||||
@@ -63,3 +66,5 @@ describe('attachment plugin', () => {
|
||||
expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() }
|
||||
@@ -37,6 +37,12 @@
|
||||
},
|
||||
{
|
||||
"path": "../locale/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-file-drag"
|
||||
},
|
||||
{
|
||||
"path": "../../util/file-drag"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 41cd21266011d571
|
||||
zh: 3172d1a2acbb27b7
|
||||
/deepseek-ai-dsh-client-ui-chat/reference-previews:
|
||||
en: aa6b1abec265bf1b
|
||||
zh: 7260ce78afba2584
|
||||
en: a05d85ff4949b526
|
||||
zh: 7261ffc98665e9e7
|
||||
/deepseek-ai-dsh-client-ui-chat/hidden-chat-rows:
|
||||
en: cbf1f0a2205046a5
|
||||
zh: 2e59d0815351349f
|
||||
|
||||
@@ -33,6 +33,8 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
|
||||
<a id="reference-previews"></a>
|
||||
## Reference previews
|
||||
|
||||
Image drag actions arrive through the image entry’s inject face from [ui-file-drag](../ui-file-drag/README.md).
|
||||
|
||||
Sent generic-file attachments retain the draft's [file-card radius](../../../docs/ui-radius.md#radius-scale).
|
||||
|
||||
`ChatView` supplies file and HTTP(S) navigation through the existing `MarkdownDelegateProvider` around its node list, passing `requestOpenFile`, `openExternalLink`, and `fileImages` directly. Its file opener retains Chat's failure feedback and retry handling. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state.
|
||||
|
||||
@@ -33,6 +33,8 @@ kind: "package-reference"
|
||||
<a id="reference-previews"></a>
|
||||
## 引用预览
|
||||
|
||||
图片拖放回调通过图片入口的注入接口从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。
|
||||
|
||||
已发送的普通文件附件保留草稿中的[文件卡片圆角](../../../docs/ui-radius.zh.md#radius-scale)。
|
||||
|
||||
`ChatView` 通过节点列表外围既有的 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航,直接传入 `requestOpenFile`、`openExternalLink` 和 `fileImages`。文件打开动作保留 Chat 的失败反馈与重试处理。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
"@deepseek-ai/dsh-client-ui-session",
|
||||
"@deepseek-ai/dsh-client-ui-settings",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar-right",
|
||||
"@deepseek-ai/dsh-client-ui-workspace"
|
||||
"@deepseek-ai/dsh-client-ui-workspace",
|
||||
"@deepseek-ai/dsh-client-ui-file-drag"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
@@ -95,7 +96,8 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@deepseek-ai/dsh-settings": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-file-drag": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** Register the Chat Conversation target, renderers, stats, and details surface. */
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-file-drag/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
@@ -75,7 +76,7 @@ export const CHAT_NODE_INJECT: ChatNodeInjected = {
|
||||
/** Services required by the Chat target and its presentation registrations. */
|
||||
export const inject = [
|
||||
'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale',
|
||||
'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight',
|
||||
'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight', 'fileDrag',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -249,6 +250,7 @@ export function apply(ctx: Context): void {
|
||||
const chat = chatSource(binding)
|
||||
return {
|
||||
...nodeSources(binding),
|
||||
fileDrag: ctx.fileDrag.actions,
|
||||
hooks: { presentation, interactionIds: ctx.uiConversation.interactions },
|
||||
dispatchInteraction: gesture => ctx.uiConversation.interactions.dispatch(sessionId, gesture),
|
||||
fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
|
||||
|
||||
@@ -17,7 +17,7 @@ import { TurnNavigator } from './TurnNavigator.tsx'
|
||||
import { mergeTurnRailItems } from './turn-rail-items.ts'
|
||||
import { useTextInteraction } from './text-interaction.ts'
|
||||
import { useChatScroll } from './use-chat-scroll.ts'
|
||||
import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import { fileMediaUrl, resolveWorkspacePath, sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
/** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */
|
||||
@@ -61,7 +61,7 @@ const MOTION_TAIL_MS = 800
|
||||
export function ChatView({
|
||||
useSession, useChat, useConversation, useSessions, renderSlot,
|
||||
sessionId, openFile, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions,
|
||||
usePresentation, useProjection, useInteractionIds, dispatchInteraction, t,
|
||||
usePresentation, useProjection, useInteractionIds, dispatchInteraction, t, fileDrag,
|
||||
}: ChatViewSlotProps) {
|
||||
const order = useChat(s => s.order)
|
||||
const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries)
|
||||
@@ -85,12 +85,14 @@ export function ChatView({
|
||||
// Workspace root off the session list row: path summaries display relative to it.
|
||||
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
|
||||
const fileImages = useMemo(() => ({
|
||||
fileDrag,
|
||||
resolve: (path: string) => fileMediaUrl(document.baseURI, resolveWorkspacePath(cwd, path)),
|
||||
resourceAddress: (path: string) => sessionFileAddress(sessionId, resolveWorkspacePath(cwd, path)),
|
||||
labels: {
|
||||
open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'),
|
||||
dialog: t('image.dialog'), close: t('image.close'),
|
||||
},
|
||||
}), [cwd, t])
|
||||
}), [cwd, t, fileDrag, sessionId])
|
||||
const running = useSession(s => s.running)
|
||||
// Fold transitions run while a Turn is live, while an input is pending, and briefly after settling.
|
||||
const [motionTail, setMotionTail] = useState(false)
|
||||
|
||||
@@ -298,6 +298,8 @@ export interface PerformanceUsageInjected {
|
||||
|
||||
/** Business callbacks injected into the Chat view. */
|
||||
export interface ChatViewInjected {
|
||||
/** File preparation and failure callbacks owned by the file-drag plugin. */
|
||||
readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions
|
||||
/** Dispatch one gesture to the Conversation plugin registry. */
|
||||
dispatchInteraction: (gesture: TextInteractionGesture) => string | null
|
||||
hooks: {
|
||||
|
||||
@@ -62,6 +62,7 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true,
|
||||
} as never)
|
||||
const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() }
|
||||
runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never)
|
||||
runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined })
|
||||
runtime.ctx.provide('layout', layout as never)
|
||||
const sidebarRight = {
|
||||
openResource: vi.fn<(address: string) => void>(),
|
||||
@@ -535,3 +536,5 @@ describe('Chat inject API', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() }
|
||||
@@ -44,6 +44,7 @@ const SID = 'session-1' as SessionId
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('fileDrag', { actions: { prepare: async () => ({ start: () => true, release: () => {} }), reportFailure: () => {} }, read: () => undefined } satisfies import('@deepseek-ai/cordis').Context['fileDrag'])
|
||||
const chatSettings = stubConfigForm<ChatSettings>()
|
||||
runtime.ctx.provide('configForms', {
|
||||
developerTools: { enabled: createSnapshotStore(true) },
|
||||
|
||||
@@ -468,6 +468,7 @@ function makeHarness(
|
||||
const useConnectionState = bindSnapshotSelector(connection)
|
||||
const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children}</>
|
||||
const props: HarnessStandardProps = {
|
||||
fileDrag,
|
||||
renderFactorySlot,
|
||||
usePanelInfo: selector => selector({ activePanelId: null }),
|
||||
sessionId: SID,
|
||||
@@ -4609,3 +4610,5 @@ describe('ChatView', () => {
|
||||
expect(rows).toEqual(['fixture:steering:3'])
|
||||
})
|
||||
})
|
||||
|
||||
const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() }
|
||||
@@ -46,6 +46,7 @@ const quotaEntry = (seq: number, code: string): SessionLiveEventEntry => ({
|
||||
*/
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtime.ctx.provide('fileDrag', { actions: { prepare: async () => ({ start: () => true, release: () => {} }), reportFailure: () => {} }, read: () => undefined } satisfies import('@deepseek-ai/cordis').Context['fileDrag'])
|
||||
disposers.push(() => runtime.dispose())
|
||||
const chatSettings = stubConfigForm<ChatSettings>()
|
||||
runtime.ctx.provide('configForms', {
|
||||
|
||||
@@ -117,6 +117,9 @@
|
||||
{
|
||||
"path": "../product-analytics/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../ui-file-drag"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tool-executor-ptc"
|
||||
}
|
||||
|
||||
@@ -204,6 +204,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
'conversation.images.content': {
|
||||
scope: 'root'
|
||||
props: MessageImagesContentInput
|
||||
inject: { readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions }
|
||||
locale: 'conversation'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-file-drag/README.md
|
||||
/:
|
||||
en: 354a238a0316cc97
|
||||
zh: 8b23d2fb131be7c7
|
||||
/deepseek-ai-dsh-client-ui-file-drag:
|
||||
en: 1cda49c784cdc224
|
||||
zh: 12633697bfec7214
|
||||
/deepseek-ai-dsh-client-ui-file-drag/summary:
|
||||
en: b9fd57af1a4d9ed5
|
||||
zh: 97e2bfc158465d12
|
||||
/deepseek-ai-dsh-client-ui-file-drag/table-of-contents:
|
||||
en: 122f0d41696980b1
|
||||
zh: 5423073f383f055d
|
||||
/deepseek-ai-dsh-client-ui-file-drag/use-this-package:
|
||||
en: 86b2a44b7966615b
|
||||
zh: 5998751060e63a0f
|
||||
/deepseek-ai-dsh-client-ui-file-drag/understand-the-implementation:
|
||||
en: d52facbaeb4b2d5e
|
||||
zh: 674ab93d84588733
|
||||
/deepseek-ai-dsh-client-ui-file-drag/model-experience:
|
||||
en: bcbde555cf1ac05e
|
||||
zh: c555dba949ddd0e9
|
||||
/deepseek-ai-dsh-client-ui-file-drag/model-experience/kv-cache-effect:
|
||||
en: 410edb0ad2da08cf
|
||||
zh: 581cbff891b5f34d
|
||||
/deepseek-ai-dsh-client-ui-file-drag/known-limitations-and-deferred-work:
|
||||
en: ff752628c43903ac
|
||||
zh: 1eba3fbd50d35263
|
||||
/deepseek-ai-dsh-client-ui-file-drag/known-limitations-and-deferred-work/dev-note:
|
||||
en: b836f01bd0bceb4a
|
||||
zh: 25e979141fbe5501
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
description: "Application-scoped file transfers and localized drag failure feedback."
|
||||
kind: package-reference
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-client-ui-file-drag
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Users can drag original images from conversations or the sidebar into the composer, or into other applications through Desktop. Drag failures remain visible after the originating image or panel closes. Transfers between browser windows remain subject to browser capabilities.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Use this package](#use-this-package)
|
||||
- [Understand the implementation](#understand-the-implementation)
|
||||
- [Model Experience](#model-experience)
|
||||
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
|
||||
- [Dev Note](#dev-note)
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
The Web profile includes this plugin alongside locale, the slot renderer, and a shell declaring `shell.overlay`. Consumers inject `fileDrag` and pass `ctx.fileDrag.actions` through their entry’s inject face. `prepare(source, signal)` accepts Blob or URL content with an export name and optional Session resource address, returning synchronous start and release operations. `reportFailure()` uses the plugin dictionary; failure feedback and plugin metadata are available in Chinese, English, and Japanese. Composer drop targets receive `ctx.fileDrag.read`; components do not read a service or React context.
|
||||
|
||||
The service retains one original `File` for a browser drag. The random `application/x-dsh-file` token resolves only in its application instance. A native listener stays on the source node after React unmount, and drag end, drop completion, or plugin disposal clears the transfer. Internal tokens are accepted only over the composer. External files keep the existing full-page drop overlay. Desktop native drags return as ordinary files and use that overlay, including within the source window.
|
||||
|
||||
The desktop bridge is resolved once at activation for protocol version 1. File naming is shared with Desktop through [dsh-util-file-drag](../../util/file-drag/README.md): explicit output suffix wins, compatible suffixes survive, known conflicts use the declared MIME suffix, and unknown types retain the caller name. The plugin owns failure copy and its overlay, which survives source-panel closure.
|
||||
|
||||
Managed browser transfers carry an application-local token and `DownloadURL` pointing to a plugin-owned Blob URL containing the prepared bytes. They do not expose the source workspace URL, filename, or alt text through `text/plain`. A drag started before preparation completes is cancelled quietly; a settled failure reports the localized retry message on the next attempt. Resource-backed images prepare on primary pointer down; other sources may prepare on hover. Releasing inside the source preserves preparation for a subsequent drag. Pointer leave without a pressed drag, release outside the source, blur, or unmount retires unused preparation. Cancellation reaches pending native requests, including Host reads and icon waits. Optional resource borrowing failures fall back to the supplied Blob or the authorized URL read; cancellation never starts that fallback.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals</summary>
|
||||
|
||||
The Cordis plugin owns file preparation, transport choice, retained browser bytes, and transient feedback. [DraggableImage](../ui-primitives/README.md) handles image gestures; [Desktop](../../../apps/desktop/README.md) authorizes borrowed resources and owns temporary native exports. Images are the first consumer; the same callbacks accept PDF and other file content without an image MIME gate.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
None, as this plugin changes browser interaction without adding model-visible messages or tools.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this plugin neither constructs nor sends model requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Browser tokens do not transfer between applications, windows, or tabs. External browser file delivery depends on Chromium `DownloadURL` and the receiving application; Desktop supplies native file delivery. Unreadable cross-origin images retain browser-native dragging.
|
||||
- The Blob `DownloadURL` path has no verified external-application delivery result. Receivers that read after the transfer ends may find its URL revoked; use Desktop native dragging when original-file delivery is required.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
description: "应用作用域内的文件传递与本地化拖放失败反馈。"
|
||||
kind: package-reference
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-client-ui-file-drag
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
用户可以把会话或侧栏中的原图拖回输入框,也可以通过桌面端拖到其他应用。图片或面板关闭后,拖放失败提示仍然可见。浏览器跨窗口传递受浏览器能力限制。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用此包](#use-this-package)
|
||||
- [理解实现](#understand-the-implementation)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与后续工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用此包
|
||||
|
||||
Web profile 将该插件与 locale、插槽渲染器及声明 `shell.overlay` 的外壳一同加载。消费方注入 `fileDrag`,通过入口的注入接口传递 `ctx.fileDrag.actions`。`prepare(source, signal)` 接受 Blob 或 URL 内容、导出名及可选 Session 资源地址,返回同步启动和释放操作。`reportFailure()` 使用插件词典;失败提示和插件元数据提供中文、英文与日语。输入框接收方获取 `ctx.fileDrag.read`;组件不读取服务或 React context。
|
||||
|
||||
服务为一次浏览器拖放保留一个原始 `File`。随机的 `application/x-dsh-file` 令牌只能在所属应用实例解析。原生监听器在 React 卸载后仍绑定源节点,拖放结束、投放完成或插件卸载会清除传递。内部令牌仅在输入框范围接受;外部文件保留已有的全页投放遮罩。Desktop 原生拖放返回时属于普通文件,包括拖回源窗口,也使用该遮罩。
|
||||
|
||||
桌面桥接在插件激活时解析一次,支持协议版本 1。文件命名通过 [dsh-util-file-drag](../../util/file-drag/README.zh.md) 与桌面端共享:显式后缀优先,兼容后缀保留,已知冲突使用声明 MIME 的后缀,未知类型保留调用方名称。失败文案与覆盖层由插件持有,在源面板关闭后仍然可见。
|
||||
|
||||
托管浏览器传输携带应用内令牌,以及指向插件持有、包含准备字节的 Blob URL 的 `DownloadURL`,不会通过 `text/plain` 暴露源工作区 URL、文件名或替代文本。准备完成前起拖会静默取消;已确定的失败在下一次尝试时显示本地化重试提示。带资源引用的图片仅在主指针按下时准备,其他来源可在悬停时准备。在源图片内松开会保留准备,供后续起拖使用。未按住拖动时移出图片、在图片外松开、窗口失焦或卸载会释放未使用的准备。取消会传递到原生请求,包括 Host 读取和图标等待。可选资源借用失败后回退到传入的 Blob 或经过授权的 URL 读取;取消不会触发回退。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
<details>
|
||||
<summary>实现内部细节</summary>
|
||||
|
||||
Cordis 插件负责文件准备、传输选择、浏览器保留字节与临时反馈。[DraggableImage](../ui-primitives/README.zh.md) 处理图片手势,[Desktop](../../../apps/desktop/README.zh.md) 授权借用资源并持有原生临时导出。图片是首个调用方,同一组回调可接收 PDF 等文件内容,不设图片 MIME 门禁。
|
||||
|
||||
</details>
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
无;该插件只改变浏览器交互,不增加模型可见消息或工具。
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
无;该插件既不构建也不发送模型请求。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 浏览器令牌不能跨应用、窗口或标签页传递。浏览器向外部交付文件依赖 Chromium 的 `DownloadURL` 和接收应用;Desktop 提供原生文件交付。不可读取的跨域图片保留浏览器原生拖动。
|
||||
- Blob `DownloadURL` 路径尚无经过验证的外部应用交付结果。传递结束后才读取的接收方可能遇到 URL 已撤销;需要原始文件交付时使用 Desktop 原生拖放。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者工作上下文</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "File dragging",
|
||||
"description": "Drag original files into the composer or other applications, with localized failure feedback."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "ファイルのドラッグ&ドロップ",
|
||||
"description": "元のファイルを入力欄や他のアプリにドラッグし、失敗時には選択した言語で通知します。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "文件拖放",
|
||||
"description": "将原始文件拖入输入框或其他应用,并提供本地化失败提示。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-file-drag",
|
||||
"description": "Application-owned file drag transfers and localized failure feedback",
|
||||
"version": "0.2.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-file-drag"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:*",
|
||||
"react": "^18.2.0",
|
||||
"@deepseek-ai/dsh-client-test-runtime": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-file-drag": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/** File-drag failure presentation in the existing application overlay. */
|
||||
import { Toast } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
/** Localized transient failure, with identity for restarting its presentation lifetime. */
|
||||
export interface FileDragFailure {
|
||||
readonly text: string
|
||||
readonly sequence: number
|
||||
}
|
||||
|
||||
/** Registration-owned feedback source and dismissal callback. */
|
||||
export interface FileDragFeedbackInjected {
|
||||
readonly hooks: { readonly failure: ObservableSnapshot<FileDragFailure | null> }
|
||||
readonly dismiss: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep transient failures visible after their originating source unmounts.
|
||||
* @param props - framework-bound failure source and dismissal callback.
|
||||
* @returns the current failure toast, or nothing.
|
||||
*/
|
||||
export function FileDragFeedback({ useFailure, dismiss }: InjectFace<FileDragFeedbackInjected>) {
|
||||
const failure = useFailure(value => value)
|
||||
return failure === null ? null : <Toast key={failure.sequence} text={failure.text} onDone={dismiss} />
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Cordis-owned file transfers and localized feedback shared across independent entries. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { DesktopFileDrag } from '@deepseek-ai/dsh-util-file-drag'
|
||||
import type { FileDragActions } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { FileDragFeedback, type FileDragFailure, type FileDragFeedbackInjected } from './feedback.tsx'
|
||||
import { FileTransfers } from './transfers.ts'
|
||||
import { en, ja, zh } from './locales.ts'
|
||||
|
||||
/** Application-local file preparation and drop resolution. */
|
||||
export interface FileDragService {
|
||||
readonly actions: FileDragActions
|
||||
/** @param transfer - browser drop payload. @returns original file for a matching application-local token. */
|
||||
readonly read: (transfer: DataTransfer) => File | undefined
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** File transfer ownership scoped to this plugin's lifetime. */
|
||||
fileDrag: FileDragService
|
||||
}
|
||||
}
|
||||
|
||||
/** Registry and locale used by the independent feedback overlay. */
|
||||
export const inject = ['slots', 'locale']
|
||||
|
||||
/** @param ctx - client plugin context carrying slots and locale. */
|
||||
export function apply(ctx: Context): void {
|
||||
const bridge = (globalThis as typeof globalThis & { dshDesktop?: { protocolVersion: number; fileDrag?: DesktopFileDrag } }).dshDesktop
|
||||
const desktop = bridge?.protocolVersion === 1 ? bridge.fileDrag : undefined
|
||||
const failure = createSnapshotStore<FileDragFailure | null>(null)
|
||||
const t = ctx.locale.bind('fileDrag')
|
||||
ctx.effect(() => ctx.locale.register('fileDrag', { zh, en, ja }))
|
||||
let sequence = 0
|
||||
const reportFailure = (): void => { failure.set({ text: t('failed'), sequence: ++sequence }) }
|
||||
const transfers = new FileTransfers(desktop, reportFailure)
|
||||
const dismiss = (): void => { failure.set(null) }
|
||||
ctx.provide('fileDrag', {
|
||||
actions: { prepare: (source, signal) => transfers.prepare(source, signal), reportFailure },
|
||||
read: transfer => transfers.read(transfer),
|
||||
})
|
||||
ctx.effect(() => () => { transfers.dispose(); dismiss() })
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'file-drag.feedback',
|
||||
inject: (): FileDragFeedbackInjected => ({ hooks: { failure }, dismiss }),
|
||||
}, FileDragFeedback))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** File drag failure copy owned by the transfer plugin. */
|
||||
export const zh = { failed: '拖放失败,请重试' }
|
||||
|
||||
/** English file drag failure copy. */
|
||||
export const en = { failed: 'Drag failed. Try again.' } satisfies Record<keyof typeof zh, string>
|
||||
|
||||
/** Japanese file drag failure copy. */
|
||||
export const ja = { failed: 'ドラッグ&ドロップに失敗しました。もう一度お試しください。' } satisfies Record<keyof typeof zh, string>
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** File transfer failure feedback. */
|
||||
fileDrag: keyof typeof zh
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
/** File transport selected once per plugin activation; sources only receive prepared operations. */
|
||||
import {
|
||||
FILE_DRAG_TOKEN_TYPE, resolveDragFileName,
|
||||
type DesktopFileDrag, type DesktopFileSource, type FileDragHandle, type FileDragRequestId,
|
||||
} from '@deepseek-ai/dsh-util-file-drag'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import type { FileDragSource, PreparedFileDrag } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/** Application-local browser transfer owner, including detached source-node drag end. */
|
||||
export class FileTransfers {
|
||||
private active: { token: string; file: File; end: () => void } | undefined
|
||||
private readonly prepared = new Set<() => void>()
|
||||
private disposed = false
|
||||
private readonly lifetime = new AbortController()
|
||||
|
||||
/** @param desktop - verified native bridge, absent in Web. @param reportFailure - localized plugin feedback. */
|
||||
constructor(private readonly desktop: DesktopFileDrag | undefined, private readonly reportFailure: () => void) {}
|
||||
|
||||
/**
|
||||
* Resolve this instance's active transfer.
|
||||
* @param transfer - browser drop data.
|
||||
* @returns retained bytes only for this instance's current token.
|
||||
*/
|
||||
read(transfer: DataTransfer): File | undefined {
|
||||
return this.active?.token === transfer.getData(FILE_DRAG_TOKEN_TYPE) ? this.active.file : undefined
|
||||
}
|
||||
|
||||
private assertOpen(signal: AbortSignal): void {
|
||||
signal.throwIfAborted()
|
||||
}
|
||||
|
||||
private async prepareNative(
|
||||
desktop: DesktopFileDrag, source: DesktopFileSource, signal: AbortSignal,
|
||||
): Promise<FileDragHandle | undefined> {
|
||||
this.assertOpen(signal)
|
||||
const requestId = randomUUID() as FileDragRequestId
|
||||
const cancel = (): void => { desktop.cancel(requestId) }
|
||||
signal.addEventListener('abort', cancel, { once: true })
|
||||
try { return await desktop.prepare(source, requestId) }
|
||||
finally { signal.removeEventListener('abort', cancel) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare bytes or borrow an authorized native file.
|
||||
* @param source - content plus export naming.
|
||||
* @param signal - caller cancellation.
|
||||
* @returns synchronous drag startup and preparation release.
|
||||
*/
|
||||
async prepare(source: FileDragSource, signal: AbortSignal): Promise<PreparedFileDrag> {
|
||||
signal = AbortSignal.any([signal, this.lifetime.signal])
|
||||
this.assertOpen(signal)
|
||||
const desktop = this.desktop
|
||||
if (desktop === undefined) {
|
||||
const file = await this.readSource(source, signal)
|
||||
this.assertOpen(signal)
|
||||
return this.browser(file)
|
||||
}
|
||||
let handle: FileDragHandle | undefined
|
||||
try {
|
||||
if (source.resourceAddress !== undefined) {
|
||||
const bytes = source.kind === 'blob' ? await source.blob.arrayBuffer() : undefined
|
||||
const hash = bytes === undefined ? undefined
|
||||
: Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
this.assertOpen(signal)
|
||||
try { handle = await this.prepareNative(desktop, {
|
||||
kind: 'resource', address: source.resourceAddress, name: source.name,
|
||||
mediaType: source.mediaType, outputExtension: source.outputExtension,
|
||||
...(hash === undefined ? {} : { sha256: hash }),
|
||||
...(source.kind === 'blob' ? { size: source.blob.size } : {}),
|
||||
}, signal) } catch (_error) {
|
||||
// Borrowing is optional; the Blob or authorized URL remains the source for a copy.
|
||||
this.assertOpen(signal)
|
||||
}
|
||||
}
|
||||
if (handle === undefined) {
|
||||
const file = await this.readSource(source, signal)
|
||||
handle = await this.prepareNative(desktop, { kind: 'bytes', bytes: new Uint8Array(await file.arrayBuffer()),
|
||||
name: file.name, mediaType: file.type, outputExtension: source.outputExtension }, signal)
|
||||
if (handle === undefined) throw new Error('File preparation unavailable')
|
||||
}
|
||||
this.assertOpen(signal)
|
||||
return this.native(desktop, handle)
|
||||
} catch (error) {
|
||||
if (handle !== undefined) desktop.release(handle)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async readSource(source: FileDragSource, signal: AbortSignal): Promise<File> {
|
||||
this.assertOpen(signal)
|
||||
let content: Blob
|
||||
if (source.kind === 'url') {
|
||||
const response = await fetch(source.url, { signal, referrerPolicy: 'no-referrer' })
|
||||
if (!response.ok) throw new Error('File read failed')
|
||||
content = await response.blob()
|
||||
} else content = source.blob
|
||||
this.assertOpen(signal)
|
||||
const mediaType = source.mediaType || content.type
|
||||
return new File([content], resolveDragFileName({ ...source, mediaType }), { type: mediaType })
|
||||
}
|
||||
|
||||
private native(desktop: DesktopFileDrag, nativeHandle: FileDragHandle): PreparedFileDrag {
|
||||
let starting: Promise<void> | undefined
|
||||
const release = (): void => {
|
||||
if (!this.prepared.delete(release)) return
|
||||
if (starting === undefined) desktop.release(nativeHandle)
|
||||
else void starting.then(() => { desktop.release(nativeHandle) })
|
||||
}
|
||||
this.prepared.add(release)
|
||||
return {
|
||||
start: () => {
|
||||
if (!this.prepared.has(release)) return false
|
||||
starting = desktop.start(nativeHandle).then((started) => { if (!started) this.reportFailure() }, this.reportFailure)
|
||||
return false
|
||||
},
|
||||
release,
|
||||
}
|
||||
}
|
||||
|
||||
private browser(file: File): PreparedFileDrag {
|
||||
const url = URL.createObjectURL(file)
|
||||
let released = false
|
||||
let started = false
|
||||
const releaseUrl = (): void => { this.prepared.delete(release); URL.revokeObjectURL(url) }
|
||||
const release = (): void => { released = true; if (!started) releaseUrl() }
|
||||
this.prepared.add(release)
|
||||
return {
|
||||
start: (transfer, source) => {
|
||||
if (released || this.disposed) return false
|
||||
this.active?.end()
|
||||
started = true
|
||||
const token = randomUUID()
|
||||
let ended = false
|
||||
const end = (): void => {
|
||||
if (ended) return
|
||||
ended = true
|
||||
source.removeEventListener('dragend', end)
|
||||
window.removeEventListener('drop', dropped)
|
||||
this.active = undefined
|
||||
started = false
|
||||
if (released) releaseUrl()
|
||||
}
|
||||
const dropped = (): void => { queueMicrotask(end) }
|
||||
this.active = { token, file, end }
|
||||
source.addEventListener('dragend', end, { once: true })
|
||||
window.addEventListener('drop', dropped, { once: true })
|
||||
transfer.items.clear()
|
||||
transfer.effectAllowed = 'copy'
|
||||
transfer.setData(FILE_DRAG_TOKEN_TYPE, token)
|
||||
transfer.setData('DownloadURL', `${file.type || 'application/octet-stream'}:${file.name}:${url}`)
|
||||
return true
|
||||
},
|
||||
release,
|
||||
}
|
||||
}
|
||||
|
||||
/** End active transfers and retire every preparation, including sources detached during dragging. */
|
||||
dispose(): void {
|
||||
this.disposed = true
|
||||
this.lifetime.abort(new Error('File transfers disposed'))
|
||||
this.active?.end()
|
||||
for (const release of this.prepared) release()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Host Loader entry; file dragging executes in the browser and desktop shell. */
|
||||
|
||||
/** Register the browser package without adding Host behavior. */
|
||||
export function apply(): void {}
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Plugin lifecycle and feedback outlive the source component. */
|
||||
import { File as NodeFile } from 'node:buffer'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { DraggableImage } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { FILE_DRAG_TOKEN_TYPE, type DesktopFileDrag, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
|
||||
import * as plugin from '../src/client/index.ts'
|
||||
import { apply as applyHost } from '../src/index.ts'
|
||||
|
||||
afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.useRealTimers() })
|
||||
const revokeUrl = vi.fn<(url: string) => void>()
|
||||
|
||||
function transfer(token = ''): DataTransfer {
|
||||
const data = new Map([[FILE_DRAG_TOKEN_TYPE, token]])
|
||||
return Object.assign({} as DataTransfer, {
|
||||
items: { clear: vi.fn() }, effectAllowed: 'none',
|
||||
getData: (type: string) => data.get(type) ?? '',
|
||||
setData: (type: string, value: string) => { data.set(type, value) },
|
||||
})
|
||||
}
|
||||
|
||||
async function bench(language: 'zh' | 'en' | 'ja' = 'zh') {
|
||||
revokeUrl.mockReset()
|
||||
vi.stubGlobal('File', NodeFile)
|
||||
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: vi.fn(() => 'blob:transfer'), revokeObjectURL: revokeUrl }))
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
onTestFinished(() => runtime.dispose())
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
onTestFinished(locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' }))
|
||||
locale.setLocale(language)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
const owner = await runtime.mount(plugin)
|
||||
await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } },
|
||||
({ renderSlot }) => renderSlot('shell.overlay', {}))
|
||||
const view = runtime.renderRoot()
|
||||
return { runtime, owner, view, service: runtime.ctx.fileDrag }
|
||||
}
|
||||
|
||||
it('keeps the Host entry empty', () => { applyHost() })
|
||||
|
||||
it('retains bytes after source unmount and releases on the detached node dragend', async () => {
|
||||
const { service, owner, runtime } = await bench()
|
||||
const file = new File(['pdf-original'], '报告.pdf', { type: 'application/pdf' })
|
||||
const source = render(<DraggableImage fileDrag={service.actions} src="blob:original" name={file.name} original={file} alt="original" />)
|
||||
const node = source.getByRole('img')
|
||||
const data = transfer()
|
||||
expect(service.read(data)).toBeUndefined()
|
||||
await act(async () => { fireEvent.pointerEnter(node) })
|
||||
fireEvent.dragStart(node, { dataTransfer: data })
|
||||
source.unmount()
|
||||
const received = service.read(data)!
|
||||
expect(received.name).toBe(file.name)
|
||||
expect(await received.text()).toBe('pdf-original')
|
||||
expect(data.getData('DownloadURL')).toBe('application/pdf:报告.pdf:blob:transfer')
|
||||
expect(service.read(transfer('foreign'))).toBeUndefined()
|
||||
node.dispatchEvent(new Event('dragend'))
|
||||
expect(service.read(data)).toBeUndefined()
|
||||
expect(revokeUrl).toHaveBeenCalledWith('blob:transfer')
|
||||
await owner.dispose()
|
||||
expect(runtime.ctx.get('fileDrag')).toBeUndefined()
|
||||
expect(runtime.slots.entries('shell.overlay')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh', '拖放失败,请重试'],
|
||||
['en', 'Drag failed. Try again.'],
|
||||
['ja', 'ドラッグ&ドロップに失敗しました。もう一度お試しください。'],
|
||||
] as const)('reports a late native failure after source unmount in %s', async (language, message) => {
|
||||
const pending = Promise.withResolvers<boolean>()
|
||||
const handle = 'handle' as FileDragHandle
|
||||
const desktop: DesktopFileDrag = {
|
||||
prepare: vi.fn(async () => handle), cancel: vi.fn(), start: vi.fn(() => pending.promise), release: vi.fn(),
|
||||
}
|
||||
vi.stubGlobal('dshDesktop', { protocolVersion: 1, fileDrag: desktop })
|
||||
const { service } = await bench(language)
|
||||
vi.stubGlobal('dshDesktop', { protocolVersion: 2 })
|
||||
const file = new File(['image'], 'image.png', { type: 'image/png' })
|
||||
const source = render(<DraggableImage fileDrag={service.actions} src="blob:original" name={file.name} original={file} alt="original" />)
|
||||
await act(async () => { fireEvent.pointerEnter(source.getByRole('img')) })
|
||||
fireEvent.dragStart(source.getByRole('img'), { dataTransfer: transfer() })
|
||||
source.unmount()
|
||||
expect(desktop.release).not.toHaveBeenCalled()
|
||||
vi.useFakeTimers()
|
||||
await act(async () => { pending.resolve(false); await pending.promise })
|
||||
expect(desktop.release).toHaveBeenCalledWith(handle)
|
||||
expect(screen.getByRole('alert').textContent).toBe(message)
|
||||
await act(async () => { await vi.runAllTimersAsync() })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores an unsupported desktop protocol and removes feedback with the plugin', async () => {
|
||||
vi.stubGlobal('dshDesktop', { protocolVersion: 2, fileDrag: {} })
|
||||
const { service, owner } = await bench()
|
||||
act(() => { service.actions.reportFailure() })
|
||||
expect(screen.getByRole('alert')).toBeTruthy()
|
||||
await owner.dispose()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
import { File as NodeFile, Blob as NodeBlob } from 'node:buffer'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { FILE_DRAG_TOKEN_TYPE, type DesktopFileDrag, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag'
|
||||
import { FileTransfers } from '../src/client/transfers.ts'
|
||||
|
||||
const revokeUrl = vi.fn<(url: string) => void>()
|
||||
beforeEach(() => {
|
||||
revokeUrl.mockReset()
|
||||
vi.stubGlobal('File', NodeFile)
|
||||
vi.stubGlobal('Blob', NodeBlob)
|
||||
vi.stubGlobal('crypto', webcrypto)
|
||||
vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: vi.fn(() => 'blob:copy'), revokeObjectURL: revokeUrl }))
|
||||
})
|
||||
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() })
|
||||
const blobSource = () => ({ kind: 'blob' as const, blob: new Blob(['%PDF-original'], { type: 'application/pdf' }), name: 'report', mediaType: '' })
|
||||
const signal = () => new AbortController().signal
|
||||
const native = (): DesktopFileDrag => ({
|
||||
prepare: vi.fn(async () => 'handle' as FileDragHandle), cancel: vi.fn(), start: vi.fn(async () => true), release: vi.fn(),
|
||||
})
|
||||
function transfer(): DataTransfer {
|
||||
const data = new Map<string, string>()
|
||||
return Object.assign({} as DataTransfer, { items: { clear: vi.fn() }, effectAllowed: 'none',
|
||||
setData: (key: string, value: string) => { data.set(key, value) }, getData: (key: string) => data.get(key) ?? '' })
|
||||
}
|
||||
|
||||
it('borrows an authorized resource before fetching URL bytes', async () => {
|
||||
const desktop = native()
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const files = new FileTransfers(desktop, vi.fn())
|
||||
const prepared = await files.prepare({ kind: 'url', url: '/api/file', name: 'report.pdf', mediaType: 'application/pdf', resourceAddress: 'dsh-resource://file/session/s/report.pdf' }, signal())
|
||||
expect(desktop.prepare).toHaveBeenCalledWith(expect.objectContaining({ kind: 'resource', address: 'dsh-resource://file/session/s/report.pdf' }), expect.any(String))
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
expect(prepared.start(transfer(), document.createElement('div'))).toBe(false)
|
||||
await Promise.resolve()
|
||||
prepared.release()
|
||||
await Promise.resolve()
|
||||
expect(desktop.release).toHaveBeenCalledOnce()
|
||||
prepared.release()
|
||||
expect(prepared.start(transfer(), document.createElement('div'))).toBe(false)
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it('compares original Blob bytes by digest and copies a mismatching resource', async () => {
|
||||
const desktop = native()
|
||||
vi.mocked(desktop.prepare).mockResolvedValueOnce(undefined)
|
||||
const files = new FileTransfers(desktop, vi.fn())
|
||||
const prepared = await files.prepare({ ...blobSource(), resourceAddress: 'dsh-resource://file/session/s/report.pdf', outputExtension: '.bin' }, signal())
|
||||
expect(desktop.prepare).toHaveBeenNthCalledWith(1, expect.objectContaining({ kind: 'resource', size: 13 }), expect.any(String))
|
||||
const request = vi.mocked(desktop.prepare).mock.calls[0]![0]
|
||||
expect(request.kind === 'resource' ? request.sha256 : undefined).toMatch(/^[a-f0-9]{64}$/u)
|
||||
expect(desktop.prepare).toHaveBeenNthCalledWith(2, expect.objectContaining({ kind: 'bytes', name: 'report.bin', outputExtension: '.bin' }), expect.any(String))
|
||||
prepared.release()
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it('fetches generic remote bytes and preserves explicit unknown types and extensions', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>(async () => new Response('sheet-bytes', { headers: { 'Content-Type': 'application/x-custom' } }))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const files = new FileTransfers(undefined, vi.fn())
|
||||
const prepared = await files.prepare({ kind: 'url', url: '/sheet', name: 'sheet.custom', mediaType: '' }, signal())
|
||||
const data = transfer()
|
||||
const node = document.createElement('div')
|
||||
expect(prepared.start(data, node)).toBe(true)
|
||||
const file = files.read(data)!
|
||||
expect(file.name).toBe('sheet.custom')
|
||||
expect(await file.text()).toBe('sheet-bytes')
|
||||
expect(data.getData(FILE_DRAG_TOKEN_TYPE)).not.toBe('')
|
||||
node.dispatchEvent(new Event('dragend'))
|
||||
expect(files.read(data)).toBeUndefined()
|
||||
prepared.release()
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it('keeps retained files through the drop task and retires them afterward', async () => {
|
||||
const files = new FileTransfers(undefined, vi.fn())
|
||||
const prepared = await files.prepare(blobSource(), signal())
|
||||
const data = transfer()
|
||||
prepared.start(data, document.createElement('div'))
|
||||
prepared.release()
|
||||
window.dispatchEvent(new Event('drop'))
|
||||
expect(files.read(data)).toBeDefined()
|
||||
await Promise.resolve()
|
||||
expect(files.read(data)).toBeUndefined()
|
||||
expect(revokeUrl).toHaveBeenCalledOnce()
|
||||
expect(prepared.start(data, document.createElement('div'))).toBe(false)
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it('replaces active transfers and disposes mounted preparations', async () => {
|
||||
const files = new FileTransfers(undefined, vi.fn())
|
||||
const first = await files.prepare(blobSource(), signal())
|
||||
const second = await files.prepare(blobSource(), signal())
|
||||
const a = transfer(), b = transfer()
|
||||
first.start(a, document.createElement('div'))
|
||||
second.start(b, document.createElement('div'))
|
||||
expect(files.read(a)).toBeUndefined()
|
||||
expect(files.read(b)).toBeDefined()
|
||||
files.dispose()
|
||||
expect(files.read(b)).toBeUndefined()
|
||||
expect(revokeUrl).toHaveBeenCalledTimes(2)
|
||||
await expect(files.prepare(blobSource(), signal())).rejects.toThrow('File transfers disposed')
|
||||
})
|
||||
|
||||
it('reports native start rejection and releases failed preparation', async () => {
|
||||
const desktop = native()
|
||||
const failed = vi.fn()
|
||||
const files = new FileTransfers(desktop, failed)
|
||||
const prepared = await files.prepare(blobSource(), signal())
|
||||
vi.mocked(desktop.start).mockRejectedValueOnce(new Error('OS failed'))
|
||||
prepared.start(transfer(), document.createElement('div'))
|
||||
await Promise.resolve()
|
||||
expect(failed).toHaveBeenCalledOnce()
|
||||
files.dispose()
|
||||
vi.mocked(desktop.prepare).mockResolvedValue(undefined)
|
||||
const retry = new FileTransfers(desktop, failed)
|
||||
await expect(retry.prepare(blobSource(), signal())).rejects.toThrow('File preparation unavailable')
|
||||
retry.dispose()
|
||||
})
|
||||
|
||||
it('rejects unreadable remote files without retaining a preparation', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>(async () => new Response('', { status: 404 })))
|
||||
const files = new FileTransfers(undefined, vi.fn())
|
||||
await expect(files.prepare({ kind: 'url', url: '/missing', mediaType: '' }, signal())).rejects.toThrow('File read failed')
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it('copies already loaded PDF bytes when optional resource authorization rejects', async () => {
|
||||
const desktop = native()
|
||||
vi.mocked(desktop.prepare).mockRejectedValueOnce(new Error('Host resource unavailable'))
|
||||
const files = new FileTransfers(desktop, vi.fn())
|
||||
const prepared = await files.prepare({ ...blobSource(), resourceAddress: 'dsh-resource://file/session/s/report.pdf' }, signal())
|
||||
const request = vi.mocked(desktop.prepare).mock.calls[1]![0]
|
||||
expect(request.kind).toBe('bytes')
|
||||
expect(request.name).toBe('report.pdf')
|
||||
if (request.kind === 'bytes') expect(new TextDecoder().decode(request.bytes)).toBe('%PDF-original')
|
||||
prepared.release()
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it('sends cancellation before native preparation has returned its handle', async () => {
|
||||
const desktop = native()
|
||||
const pending = Promise.withResolvers<FileDragHandle | undefined>()
|
||||
vi.mocked(desktop.prepare).mockReturnValue(pending.promise)
|
||||
const files = new FileTransfers(desktop, vi.fn())
|
||||
const controller = new AbortController()
|
||||
const preparing = files.prepare(blobSource(), controller.signal)
|
||||
const rejected = expect(preparing).rejects.toThrow()
|
||||
await vi.waitFor(() => { expect(desktop.prepare).toHaveBeenCalledOnce() })
|
||||
const requestId = vi.mocked(desktop.prepare).mock.calls[0]![1]
|
||||
controller.abort()
|
||||
expect(desktop.cancel).toHaveBeenCalledWith(requestId)
|
||||
pending.resolve('late' as FileDragHandle)
|
||||
await rejected
|
||||
expect(desktop.release).toHaveBeenCalledWith('late')
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it('does not let an older drop microtask clear a newer transfer and supplies a MIME for untyped files', async () => {
|
||||
const files = new FileTransfers(undefined, vi.fn())
|
||||
const first = await files.prepare(blobSource(), signal())
|
||||
const second = await files.prepare({ kind: 'blob', blob: new Blob(['raw']), name: 'raw.custom', mediaType: '' }, signal())
|
||||
const a = transfer(), b = transfer()
|
||||
first.start(a, document.createElement('div'))
|
||||
first.release()
|
||||
window.dispatchEvent(new Event('drop'))
|
||||
second.start(b, document.createElement('div'))
|
||||
await Promise.resolve()
|
||||
expect(files.read(b)?.name).toBe('raw.custom')
|
||||
expect(b.getData('DownloadURL')).toBe('application/octet-stream:raw.custom:blob:copy')
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it.each(['abort', 'dispose'] as const)('releases a native preparation completed after %s', async (action) => {
|
||||
const desktop = native()
|
||||
const pending = Promise.withResolvers<FileDragHandle | undefined>()
|
||||
vi.mocked(desktop.prepare).mockReturnValue(pending.promise)
|
||||
const files = new FileTransfers(desktop, vi.fn())
|
||||
const abort = new AbortController()
|
||||
const preparing = files.prepare(blobSource(), abort.signal)
|
||||
const rejected = expect(preparing).rejects.toThrow()
|
||||
await vi.waitFor(() => { expect(desktop.prepare).toHaveBeenCalledOnce() })
|
||||
if (action === 'abort') abort.abort()
|
||||
else files.dispose()
|
||||
pending.resolve('late' as FileDragHandle)
|
||||
await rejected
|
||||
expect(desktop.release).toHaveBeenCalledWith('late')
|
||||
files.dispose()
|
||||
})
|
||||
|
||||
it('rejects a Web preparation when the plugin is disposed while fetching', async () => {
|
||||
const pending = Promise.withResolvers<Response>()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>(() => pending.promise))
|
||||
const files = new FileTransfers(undefined, vi.fn())
|
||||
const preparing = files.prepare({ kind: 'url', url: '/pending', mediaType: 'application/pdf' }, signal())
|
||||
const rejected = expect(preparing).rejects.toThrow()
|
||||
files.dispose()
|
||||
pending.resolve(new Response('pdf'))
|
||||
await rejected
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../store"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-renderer"
|
||||
},
|
||||
{
|
||||
"path": "../ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../../util/file-drag"
|
||||
},
|
||||
{
|
||||
"path": "../../util/crypto"
|
||||
},
|
||||
{
|
||||
"path": "../locale/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle(import.meta.dirname, ['lib/types/index.js'])
|
||||
@@ -18,14 +18,14 @@
|
||||
en: d2f5701274be5110
|
||||
zh: d60e2617136a6f26
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: 4091bc18d6eabedd
|
||||
zh: c59f84589a5f692a
|
||||
en: 9a278223fc7a2910
|
||||
zh: f086530927d74dee
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: fff68b60f4abdc7e
|
||||
zh: 5cf5952ec78f7f91
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
|
||||
en: 629f57d60fe5cce1
|
||||
zh: 5263447a14c21d61
|
||||
en: 02f310acbae29955
|
||||
zh: 1e41393d5d714b2d
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy:
|
||||
en: 4aef65dea11de083
|
||||
zh: abc22cd7c65cdf38
|
||||
@@ -54,8 +54,8 @@
|
||||
en: ca75c51c89c2c9b0
|
||||
zh: de54f3467ef3b9be
|
||||
/deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work:
|
||||
en: 039cf54697c97608
|
||||
zh: ac95443caa5be0ed
|
||||
en: 31117d1844eb3c19
|
||||
zh: 741eea7d78e00d3f
|
||||
/deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -62,6 +62,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
|
||||
| `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. |
|
||||
| `HoverCard`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. |
|
||||
| `DraggableImage` | Original-image gestures with explicit transfer and feedback callbacks. |
|
||||
| `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. |
|
||||
| `Toast` | Transient top-center banner held for the owner's `holdMs`. |
|
||||
| `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. |
|
||||
@@ -118,6 +119,8 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and
|
||||
|
||||
`ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. Like `Modal`, its mask leaves the Windows caption unpainted, and its close control sits 20px below the caption. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Its transparent hover region connects the anchor to the panel across the visual gap, so crossing the gap does not activate content beneath it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal.
|
||||
|
||||
`DraggableImage` receives explicit `fileDrag: FileDragActions`, also supplied through `MarkdownDelegateProvider.fileImages.fileDrag`. The [file-drag plugin](../ui-file-drag/README.md) owns preparation, transfer, and localized failure feedback; the image primitive manages gestures and cancellation. Resource-backed images prepare on primary pointer down; other sources also prepare on hover. Releasing inside the image preserves preparation for the next gesture; pointer leave releases unused preparation. Markdown export names use the decoded path basename. Panning images skip preparation. Starting before preparation finishes cancels that gesture quietly; an actual preparation failure reports a retry message. Managed Web transfers carry an application-local token and a `DownloadURL` pointing to a plugin-owned Blob URL, with no `text/plain` payload.
|
||||
|
||||
### Localizing copy
|
||||
|
||||
The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface.
|
||||
@@ -208,6 +211,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
|
||||
|
||||
- **Browser image transfers** — external targets depend on Chromium `DownloadURL` and the receiving application; native file delivery is provided by Desktop. Web tokens do not transfer between windows or tabs. Unreadable cross-origin images retain browser-native dragging, as do linked Markdown images and Markdown without `fileImages`; those native paths may include URLs.
|
||||
- **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations.
|
||||
- **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network.
|
||||
- **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it.
|
||||
|
||||
@@ -62,6 +62,7 @@ kind: "package-library"
|
||||
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
|
||||
| `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
|
||||
| `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 |
|
||||
| `DraggableImage` | 通过显式传递与反馈回调提供原图拖放。 |
|
||||
| `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 |
|
||||
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
|
||||
| `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 |
|
||||
@@ -118,6 +119,8 @@ kind: "package-library"
|
||||
|
||||
`ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 透明悬停区域连接锚点与弹层之间的视觉空隙,鼠标经过时不会激活下方内容。
|
||||
|
||||
`DraggableImage` 接收显式的 `fileDrag: FileDragActions`,受管理的 Markdown 图片通过 `MarkdownDelegateProvider.fileImages.fileDrag` 接收同一组回调。[文件拖放插件](../ui-file-drag/README.zh.md) 负责准备、传递与本地化失败提示;图片原语负责手势和取消。带资源引用的图片仅在主指针按下时准备,其他来源也可在悬停时准备。在图片内松开保留准备供下一次手势使用;指针移开释放未使用的准备,平移模式跳过准备。Markdown 导出名取已解码路径的文件名。在准备完成前起拖会静默取消本次手势;实际准备失败才显示重试提示。受管理的 Web 拖放携带应用内令牌和指向插件所持 Blob URL 的 `DownloadURL`,不设置 `text/plain`。
|
||||
|
||||
### 本地化文案
|
||||
|
||||
这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。
|
||||
@@ -208,6 +211,7 @@ kind: "package-library"
|
||||
|
||||
这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
|
||||
|
||||
- **浏览器图片传递**:外部接收依赖 Chromium 的 `DownloadURL` 与接收应用;原生文件交付由 Desktop 提供。Web 令牌不跨窗口或标签页传递。不可读取的跨源图片保留浏览器原生拖动,链接内的 Markdown 图片及未配置 `fileImages` 的 Markdown 也保留原生行为;这些原生路径可能携带 URL。
|
||||
- **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。
|
||||
- **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。
|
||||
- **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
"mdast-util-math": "^3.0.0",
|
||||
"micromark-extension-gfm": "^3.0.0",
|
||||
"micromark-util-sanitize-uri": "^2.0.1",
|
||||
"simple-icons": "16.31.0"
|
||||
"simple-icons": "16.31.0",
|
||||
"@deepseek-ai/dsh-util-file-drag": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Original-image drag gestures owned by a presentation primitive. */
|
||||
import { forwardRef, type ImgHTMLAttributes } from 'react'
|
||||
import { useImageDrag } from './image-drag.ts'
|
||||
import type { FileDragActions } from './file-drag-actions.ts'
|
||||
|
||||
/** Image presentation plus original content and explicit file preparation callbacks. */
|
||||
export interface DraggableImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'draggable' | 'onDragStart' | 'onDragEnd' | 'onPointerEnter' | 'onPointerLeave' | 'onPointerDown' | 'onPointerUp' | 'onClickCapture'> {
|
||||
readonly fileDrag: FileDragActions
|
||||
readonly src: string
|
||||
readonly name: string
|
||||
readonly resourceAddress?: string | undefined
|
||||
readonly original?: File | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an image with original-file dragging; using explicit application-owned callbacks.
|
||||
* @param props - image attributes, original filename and file preparation callbacks.
|
||||
* @param ref - image element for measurement and pointer-mode bindings.
|
||||
* @returns a draggable image that suppresses only the mouse click following its drag.
|
||||
*/
|
||||
export const DraggableImage = forwardRef<HTMLImageElement, DraggableImageProps>(function DraggableImage(
|
||||
{ fileDrag, src, name, original, resourceAddress, ...props }, ref,
|
||||
) {
|
||||
const drag = useImageDrag(fileDrag, src, name, original, resourceAddress)
|
||||
return <img {...props} {...drag} src={src} ref={ref} draggable />
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Stable file preparation callbacks supplied by a transfer-owning plugin. */
|
||||
import type { DragFileName } from '@deepseek-ai/dsh-util-file-drag'
|
||||
|
||||
/** Content source and optional Host-verifiable identity of its unmodified local original. */
|
||||
export type FileDragSource = DragFileName & { readonly resourceAddress?: string | undefined } & (
|
||||
| { readonly kind: 'blob'; readonly blob: Blob }
|
||||
| { readonly kind: 'url'; readonly url: string }
|
||||
)
|
||||
|
||||
/** Prepared operation; release cancels unused resources without ending an active browser transfer. */
|
||||
export interface PreparedFileDrag {
|
||||
/**
|
||||
* Start the prepared transfer while the browser payload is writable.
|
||||
* @param transfer - writable dragstart payload.
|
||||
* @param source - native source node, retained through drag end.
|
||||
* @returns true for browser dragging; false when native dragging takes over.
|
||||
*/
|
||||
start(transfer: DataTransfer, source: HTMLElement): boolean
|
||||
/** Release this preparation once the pointer leaves or the source unmounts. */
|
||||
release(): void
|
||||
}
|
||||
|
||||
/** Presentation sees preparation and feedback; transport selection belongs to the plugin. */
|
||||
export interface FileDragActions {
|
||||
/** @param source - file content and export name. @param signal - preparation cancellation. @returns the ready start/release operation. */
|
||||
prepare(source: FileDragSource, signal: AbortSignal): Promise<PreparedFileDrag>
|
||||
/** Display the plugin's localized retry message, independently of the originating component. */
|
||||
reportFailure(): void
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/** Image gestures delegate file preparation and transport to the owning feature. */
|
||||
import { useCallback, useEffect, useMemo, useRef, type DragEvent, type MouseEvent, type PointerEvent, type ImgHTMLAttributes } from 'react'
|
||||
import type { FileDragActions, FileDragSource, PreparedFileDrag } from './file-drag-actions.ts'
|
||||
|
||||
interface Preparation { abort: AbortController; prepared?: PreparedFileDrag; failed: boolean }
|
||||
type ImageGestures = Required<Pick<ImgHTMLAttributes<HTMLImageElement>,
|
||||
'onPointerEnter' | 'onPointerDown' | 'onPointerLeave' | 'onDragStart' | 'onDragEnd' | 'onClickCapture'>>
|
||||
|
||||
/**
|
||||
* Prepare resource-backed images on primary press, other images also on hover; release on leave.
|
||||
* Releasing inside the image retains preparation for the next gesture; cancelled drags never start late.
|
||||
* @param actions - stable file preparation and error callbacks.
|
||||
* @param src - displayed image URL.
|
||||
* @param name - proposed export name.
|
||||
* @param original - already loaded original bytes.
|
||||
* @param resourceAddress - verifiable reference to an unmodified local original.
|
||||
* @returns image gesture handlers; overflowing images remain owned by their pan handler.
|
||||
*/
|
||||
export function useImageDrag(
|
||||
actions: FileDragActions, src: string, name: string, original?: File, resourceAddress?: string,
|
||||
): ImageGestures {
|
||||
const source = useMemo<FileDragSource>(() => original === undefined
|
||||
? { kind: 'url', url: src, name, mediaType: '', resourceAddress }
|
||||
: { kind: 'blob', blob: original, name, mediaType: original.type, resourceAddress }, [src, name, original, resourceAddress])
|
||||
const state = useRef<Preparation>()
|
||||
const dragged = useRef(false)
|
||||
const release = useCallback((): void => {
|
||||
const previous = state.current
|
||||
state.current = undefined
|
||||
previous?.abort.abort()
|
||||
previous?.prepared?.release()
|
||||
}, [])
|
||||
useEffect(() => release, [release, source, actions])
|
||||
const prepare = useCallback((image: HTMLImageElement): void => {
|
||||
if (src === '' || state.current !== undefined) return
|
||||
const current: Preparation = { abort: new AbortController(), failed: false }
|
||||
state.current = current
|
||||
window.addEventListener('pointerup', (event) => {
|
||||
if (event.target !== image) release()
|
||||
}, { signal: current.abort.signal })
|
||||
window.addEventListener('blur', release, { once: true, signal: current.abort.signal })
|
||||
void actions.prepare(source, current.abort.signal).then((prepared) => {
|
||||
if (current.abort.signal.aborted) prepared.release()
|
||||
else current.prepared = prepared
|
||||
}, (_error: unknown) => {
|
||||
// Speculation stays quiet; only a later failed gesture presents retry feedback.
|
||||
current.failed = true
|
||||
})
|
||||
}, [src, actions, source, release])
|
||||
const intent = useCallback((event: PointerEvent<HTMLImageElement>): void => {
|
||||
if (!event.defaultPrevented && event.currentTarget.draggable
|
||||
&& (resourceAddress === undefined || event.type === 'pointerdown')) prepare(event.currentTarget)
|
||||
}, [prepare, resourceAddress])
|
||||
const onDragStart = useCallback((event: DragEvent<HTMLImageElement>): void => {
|
||||
if (event.defaultPrevented || src === '') return
|
||||
dragged.current = true
|
||||
const current = state.current
|
||||
if (current?.prepared !== undefined) {
|
||||
if (!current.prepared.start(event.dataTransfer, event.currentTarget)) { event.preventDefault(); release() }
|
||||
return
|
||||
}
|
||||
const url = new URL(src, location.href)
|
||||
if (original === undefined && (url.protocol === 'https:' || url.protocol === 'http:') && url.origin !== location.origin) return
|
||||
event.preventDefault()
|
||||
if (current?.failed === true) { actions.reportFailure(); release() }
|
||||
prepare(event.currentTarget)
|
||||
}, [src, original, actions, prepare, release])
|
||||
const onPointerDown = useCallback((event: PointerEvent<HTMLImageElement>): void => {
|
||||
dragged.current = false
|
||||
if (event.button === 0) intent(event)
|
||||
}, [intent])
|
||||
const onPointerLeave = useCallback((event: PointerEvent<HTMLImageElement>): void => {
|
||||
// Browsers may leave a small image before dispatching its dragstart.
|
||||
if (event.buttons !== 1) release()
|
||||
}, [release])
|
||||
const onClickCapture = useCallback((event: MouseEvent): void => {
|
||||
if (event.detail === 0) { dragged.current = false; return }
|
||||
if (!dragged.current) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
dragged.current = false
|
||||
}, [])
|
||||
return { onPointerEnter: intent, onPointerDown, onPointerLeave, onDragStart, onDragEnd: release, onClickCapture }
|
||||
}
|
||||
@@ -72,6 +72,9 @@ export { pointerModality } from './input-modality.ts'
|
||||
export { Toast } from './Toast.tsx'
|
||||
export { fileSizeText } from './file-size.ts'
|
||||
export { writeClipboard } from './clipboard.ts'
|
||||
export type { FileDragActions, FileDragSource, PreparedFileDrag } from './file-drag-actions.ts'
|
||||
export { DraggableImage } from './DraggableImage.tsx'
|
||||
export type { DraggableImageProps } from './DraggableImage.tsx'
|
||||
export { useClipboardCopy } from './use-clipboard-copy.ts'
|
||||
export type { ClipboardCopyState } from './use-clipboard-copy.ts'
|
||||
export { SettingsForm } from './settings-form/SettingsForm.tsx'
|
||||
|
||||
@@ -13,7 +13,9 @@ export type MarkdownExternalLinkHandler = (href: string) => void
|
||||
export interface MarkdownDelegate {
|
||||
/** Image previews for decoded local paths in this owner's workspace. */
|
||||
readonly fileImages?: {
|
||||
fileDrag: import('../file-drag-actions.ts').FileDragActions
|
||||
resolve: (path: string) => string | undefined
|
||||
resourceAddress?: ((path: string) => string) | undefined
|
||||
labels: ImageLightboxLabels & { open: string; loading: string; failed: string }
|
||||
} | undefined
|
||||
/** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */
|
||||
|
||||
@@ -29,6 +29,7 @@ import { renderTexToReact } from './katex.tsx'
|
||||
import { LinkIconMedium, classifyLinkPath } from '../LinkIcon.tsx'
|
||||
import { useMarkdownDelegate } from './MarkdownDelegate.tsx'
|
||||
import { HoverCard } from '../HoverCard.tsx'
|
||||
import { DraggableImage } from '../DraggableImage.tsx'
|
||||
import { ImageLightbox } from '../ImageLightbox.tsx'
|
||||
import { ImagePreview } from '../ImagePreview.tsx'
|
||||
import type { PositionedBlock } from './incremental.ts'
|
||||
@@ -658,9 +659,19 @@ function LoadedMarkdownImage({ src, alt, destination, preview }: {
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = useCallback(() => { setOpen(false) }, [])
|
||||
if (failed) return <span className={css.imageAlt}>{preview === undefined ? '' : `${preview.labels.failed} · `}{alt || destination}</span>
|
||||
const img = <img className={css.image} src={src} alt={alt}
|
||||
onError={() => { setFailed(true) }} loading="lazy" decoding="async" referrerPolicy="no-referrer" />
|
||||
if (preview === undefined) return img
|
||||
const imageProps = { className: css.image, src, alt, onError: () => { setFailed(true) },
|
||||
loading: 'lazy' as const, decoding: 'async' as const, referrerPolicy: 'no-referrer' as const }
|
||||
if (preview === undefined) return <img {...imageProps} />
|
||||
const file = parseFileLink(destination)
|
||||
const localFileName = file?.path.split(/[\\/]/u).at(-1)
|
||||
const urlFileName = destination.split('/').at(-1)?.split(/[?#]/u)[0]
|
||||
const exportName = localFileName || urlFileName || 'image'
|
||||
const img = <DraggableImage
|
||||
fileDrag={preview.fileDrag}
|
||||
{...imageProps}
|
||||
resourceAddress={file === undefined ? undefined : preview.resourceAddress?.(file.path)}
|
||||
name={exportName}
|
||||
/>
|
||||
return <>
|
||||
<button type="button" className={css.imageButton} title={preview.labels.open}
|
||||
aria-label={alt ? `${preview.labels.open}: ${alt}` : preview.labels.open}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { DraggableImage } from '../src/DraggableImage.tsx'
|
||||
import type { PreparedFileDrag } from '../src/file-drag-actions.ts'
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
function fixture(src = '/image.png') {
|
||||
const prepared = { start: vi.fn(() => true), release: vi.fn() }
|
||||
const actions = { prepare: vi.fn<() => Promise<PreparedFileDrag>>(async () => prepared), reportFailure: vi.fn() }
|
||||
const open = vi.fn()
|
||||
const view = render(<button onClick={open}><DraggableImage fileDrag={actions} src={src} name="original.png" alt="source" /></button>)
|
||||
const image = view.getByRole('img')
|
||||
return { prepared, actions, open, view, image }
|
||||
}
|
||||
|
||||
it('prepares only on intent, releases on leave and reprepares for another drag', async () => {
|
||||
const f = fixture()
|
||||
expect(f.actions.prepare).not.toHaveBeenCalled()
|
||||
await act(async () => { fireEvent.pointerEnter(f.image) })
|
||||
expect(f.actions.prepare).toHaveBeenCalledOnce()
|
||||
await act(async () => { fireEvent.pointerEnter(f.image) })
|
||||
expect(f.actions.prepare).toHaveBeenCalledOnce()
|
||||
const data = {}
|
||||
fireEvent.dragStart(f.image, { dataTransfer: data })
|
||||
expect(f.prepared.start).toHaveBeenCalledExactlyOnceWith(data, f.image)
|
||||
fireEvent.pointerLeave(f.image)
|
||||
expect(f.prepared.release).toHaveBeenCalledOnce()
|
||||
await act(async () => { fireEvent.pointerEnter(f.image) })
|
||||
expect(f.actions.prepare).toHaveBeenCalledTimes(2)
|
||||
fireEvent.dragEnd(f.image)
|
||||
expect(f.prepared.release).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('silently cancels pending preparation and releases a late result after unmount', async () => {
|
||||
const f = fixture()
|
||||
const pending = Promise.withResolvers<PreparedFileDrag>()
|
||||
f.actions.prepare.mockReturnValue(pending.promise)
|
||||
fireEvent.dragStart(f.image, { dataTransfer: {} })
|
||||
expect(f.actions.reportFailure).not.toHaveBeenCalled()
|
||||
f.view.unmount()
|
||||
await act(async () => { pending.resolve(f.prepared); await pending.promise })
|
||||
expect(f.prepared.release).toHaveBeenCalledOnce()
|
||||
expect(f.prepared.start).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains preparation when the pressed pointer leaves a small source before dragstart', async () => {
|
||||
vi.stubGlobal('PointerEvent', MouseEvent)
|
||||
const f = fixture()
|
||||
await act(async () => { fireEvent.pointerDown(f.image, { button: 0, buttons: 1 }) })
|
||||
fireEvent.pointerLeave(f.image, { buttons: 1 })
|
||||
expect(f.prepared.release).not.toHaveBeenCalled()
|
||||
fireEvent.dragStart(f.image, { dataTransfer: {} })
|
||||
expect(f.prepared.start).toHaveBeenCalledOnce()
|
||||
fireEvent.pointerUp(window)
|
||||
expect(f.prepared.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('releases an abandoned preparation when the window loses focus', async () => {
|
||||
const f = fixture()
|
||||
await act(async () => { fireEvent.pointerEnter(f.image) })
|
||||
fireEvent.blur(window)
|
||||
expect(f.prepared.release).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps preparation after clicking without leaving so the next drag starts immediately', async () => {
|
||||
vi.stubGlobal('PointerEvent', MouseEvent)
|
||||
const f = fixture()
|
||||
await act(async () => { fireEvent.pointerEnter(f.image) })
|
||||
fireEvent.pointerDown(f.image, { button: 0, buttons: 1 })
|
||||
fireEvent.pointerUp(f.image)
|
||||
expect(f.prepared.release).not.toHaveBeenCalled()
|
||||
fireEvent.pointerDown(f.image, { button: 0, buttons: 1 })
|
||||
fireEvent.dragStart(f.image, { dataTransfer: {} })
|
||||
expect(f.actions.prepare).toHaveBeenCalledOnce()
|
||||
expect(f.prepared.start).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retains a pending local preparation after release inside the image for a later drag', async () => {
|
||||
vi.stubGlobal('PointerEvent', MouseEvent)
|
||||
const f = fixture()
|
||||
const pending = Promise.withResolvers<PreparedFileDrag>()
|
||||
f.actions.prepare.mockReturnValue(pending.promise)
|
||||
f.view.rerender(<DraggableImage fileDrag={f.actions} src="/image.png" name="image.png" resourceAddress="dsh-resource://file/session/s/image.png" alt="source" />)
|
||||
const image = f.view.getByRole('img')
|
||||
fireEvent.pointerEnter(image)
|
||||
expect(f.actions.prepare).not.toHaveBeenCalled()
|
||||
fireEvent.pointerDown(image, { button: 0, buttons: 1 })
|
||||
expect(fireEvent.dragStart(image, { dataTransfer: {} })).toBe(false)
|
||||
fireEvent.pointerUp(image)
|
||||
await act(async () => { pending.resolve(f.prepared); await pending.promise })
|
||||
expect(f.prepared.release).not.toHaveBeenCalled()
|
||||
fireEvent.pointerDown(image, { button: 0, buttons: 1 })
|
||||
fireEvent.dragStart(image, { dataTransfer: {} })
|
||||
expect(f.prepared.start).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reports a settled preparation failure only on the next drag and retries', async () => {
|
||||
const f = fixture()
|
||||
f.actions.prepare.mockRejectedValueOnce(new Error('unreadable'))
|
||||
await act(async () => { fireEvent.pointerEnter(f.image) })
|
||||
expect(f.actions.reportFailure).not.toHaveBeenCalled()
|
||||
await act(async () => { expect(fireEvent.dragStart(f.image, { dataTransfer: {} })).toBe(false) })
|
||||
expect(f.actions.reportFailure).toHaveBeenCalledOnce()
|
||||
expect(f.actions.prepare).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps unreadable cross-origin image dragging native', async () => {
|
||||
const f = fixture('https://other.test/image.png')
|
||||
f.actions.prepare.mockRejectedValueOnce(new Error('CORS'))
|
||||
await act(async () => { fireEvent.pointerEnter(f.image) })
|
||||
expect(fireEvent.dragStart(f.image, { dataTransfer: {} })).toBe(true)
|
||||
expect(f.actions.reportFailure).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels HTML dragging when the prepared native operation takes over', async () => {
|
||||
const f = fixture()
|
||||
f.prepared.start.mockReturnValue(false)
|
||||
await act(async () => { fireEvent.pointerEnter(f.image) })
|
||||
expect(fireEvent.dragStart(f.image, { dataTransfer: {} })).toBe(false)
|
||||
fireEvent.click(f.image, { detail: 1 })
|
||||
expect(f.open).not.toHaveBeenCalled()
|
||||
fireEvent.click(f.image, { detail: 0 })
|
||||
expect(f.open).toHaveBeenCalledOnce()
|
||||
fireEvent.click(f.image, { detail: 1 })
|
||||
expect(f.open).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('preserves keyboard activation after drag end', async () => {
|
||||
const f = fixture()
|
||||
await act(async () => { fireEvent.pointerEnter(f.image) })
|
||||
fireEvent.dragStart(f.image, { dataTransfer: {} })
|
||||
fireEvent.dragEnd(f.image)
|
||||
fireEvent.click(f.image, { detail: 0 })
|
||||
expect(f.open).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not prepare panned, right-button, or empty images', async () => {
|
||||
vi.stubGlobal('PointerEvent', MouseEvent)
|
||||
const f = fixture('')
|
||||
await act(async () => { fireEvent.pointerEnter(f.image) })
|
||||
expect(fireEvent.dragStart(f.image, { dataTransfer: {} })).toBe(true)
|
||||
f.view.rerender(<DraggableImage fileDrag={f.actions} src="/image.png" name="image.png" alt="source" />)
|
||||
const image = f.view.getByRole('img')
|
||||
fireEvent.pointerDown(image, { button: 2 })
|
||||
expect(f.actions.prepare).not.toHaveBeenCalled()
|
||||
Object.defineProperty(image, 'draggable', { value: false, configurable: true })
|
||||
fireEvent.pointerEnter(image)
|
||||
expect(f.actions.prepare).not.toHaveBeenCalled()
|
||||
Object.defineProperty(image, 'draggable', { value: true, configurable: true })
|
||||
await act(async () => { fireEvent.pointerDown(image, { button: 0 }) })
|
||||
expect(f.actions.prepare).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps already-cancelled gestures inert and forwards loaded original bytes', async () => {
|
||||
vi.stubGlobal('PointerEvent', MouseEvent)
|
||||
const f = fixture()
|
||||
const file = new File(['data'], 'loaded.png', { type: 'image/png' })
|
||||
f.view.rerender(<div onDragStartCapture={(event) => { event.preventDefault() }}>
|
||||
<DraggableImage fileDrag={f.actions} src="blob:original" name={file.name} original={file} resourceAddress="dsh-resource://file/session/s/a.png" />
|
||||
</div>)
|
||||
const image = f.view.getByRole('img')
|
||||
expect(fireEvent.dragStart(image, { dataTransfer: {} })).toBe(false)
|
||||
expect(f.prepared.start).not.toHaveBeenCalled()
|
||||
await act(async () => { fireEvent.pointerEnter(image) })
|
||||
expect(f.actions.prepare).not.toHaveBeenCalled()
|
||||
await act(async () => { fireEvent.pointerDown(image, { button: 0 }) })
|
||||
expect(f.actions.prepare).toHaveBeenCalledWith(expect.objectContaining({ kind: 'blob', blob: file, resourceAddress: 'dsh-resource://file/session/s/a.png' }), expect.any(AbortSignal))
|
||||
})
|
||||
@@ -7,8 +7,9 @@ import { MarkdownText } from './markdown-test-components.tsx'
|
||||
import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts'
|
||||
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() })
|
||||
const labels = { open: 'View full image', dialog: 'Image preview', close: 'Close preview', loading: 'Loading image', failed: 'Image unavailable' }
|
||||
const fileImages = { resolve: (path: string) => `https://example.com/api/file?path=${encodeURIComponent('/workspace/' + path)}`, labels }
|
||||
const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() }
|
||||
const labels = { dragFailed: 'Image drag failed', open: 'View full image', dialog: 'Image preview', close: 'Close preview', loading: 'Loading image', failed: 'Image unavailable' }
|
||||
const fileImages = { fileDrag, resolve: (path: string) => `https://example.com/api/file?path=${encodeURIComponent('/workspace/' + path)}`, labels }
|
||||
function mount(text: string, streaming = false) {
|
||||
const openFile = vi.fn()
|
||||
const view = render(<MarkdownDelegateProvider openFile={openFile} fileImages={fileImages}>
|
||||
@@ -21,7 +22,7 @@ it('loads Desktop inline and hover previews through the file route after streami
|
||||
vi.useFakeTimers()
|
||||
const path = '/work/测试 图片#100%.png'
|
||||
const src = fileMediaUrl('dsh-app://app/', path)!
|
||||
const images = { resolve: (value: string) => fileMediaUrl('dsh-app://app/', value), labels }
|
||||
const images = { fileDrag, resolve: (value: string) => fileMediaUrl('dsh-app://app/', value), labels }
|
||||
const text = `.replace('#', '%23')}>)\n\n[打开图片](<${encodeURI(path).replace('#', '%23')}>)`
|
||||
const openFile = vi.fn()
|
||||
const content = (streaming: boolean) => <MarkdownDelegateProvider fileImages={images} openFile={openFile}>
|
||||
@@ -63,6 +64,17 @@ it('opens inline images in the shared lightbox, restores focus, and never opens
|
||||
expect(openFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['assets/my%20photo.png', 'my photo.png'],
|
||||
['assets/%E5%9B%BE%E7%89%87.png', '图片.png'],
|
||||
['C:%5Cassets%5Cmy%20photo.png', 'my photo.png'],
|
||||
])('prepares the decoded basename for %s', async (destination, name) => {
|
||||
fileDrag.prepare.mockClear()
|
||||
mount(``)
|
||||
await act(async () => { fireEvent.pointerEnter(screen.getByRole('img')) })
|
||||
expect(fileDrag.prepare).toHaveBeenCalledWith(expect.objectContaining({ name }), expect.any(AbortSignal))
|
||||
})
|
||||
|
||||
it('loads a link preview only after hover dwell and keeps its existing sidebar activation', () => {
|
||||
vi.useFakeTimers()
|
||||
const { openFile } = mount('[View comparison](.artifacts/对比%20image.png)')
|
||||
@@ -147,3 +159,8 @@ it.each([parseGfm, parseGfmWithMath])('uses the shared image categories for bare
|
||||
}
|
||||
expect(JSON.stringify(parse(''))).not.toContain('"type":"image"')
|
||||
})
|
||||
|
||||
it('uses the fallback filename for an external image URL without a path', () => {
|
||||
mount('')
|
||||
expect(screen.getByRole('img').getAttribute('src')).toBe('https://example.com/')
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { StrictMode } from 'react'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useClipboardCopy } from '../src/use-clipboard-copy.ts'
|
||||
@@ -24,7 +25,9 @@ async function settleCopy(): Promise<void> {
|
||||
|
||||
describe('useClipboardCopy', () => {
|
||||
it('copies current text, blocks success-window repeats, and resets after one second', async () => {
|
||||
const { result, rerender } = renderHook(({ text }) => useClipboardCopy(text), { initialProps: { text: 'first' } })
|
||||
const { result, rerender } = renderHook(({ text }) => useClipboardCopy(text), {
|
||||
initialProps: { text: 'first' }, wrapper: StrictMode,
|
||||
})
|
||||
const callback = result.current.onCopy
|
||||
rerender({ text: 'first' })
|
||||
expect(result.current.onCopy).toBe(callback)
|
||||
|
||||
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user