diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml new file mode 100644 index 0000000000..3ebb632746 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md new file mode 100644 index 0000000000..5d99b5815a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md new file mode 100644 index 0000000000..0e361717a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md @@ -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 启动的浏览器场景覆盖源图移除与交付,定向测试覆盖本地资源授权和清理失败。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c349501785..b08c83a529 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 | diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 2d7441061c..f6e0f4c73a 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -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:*" } } diff --git a/apps/desktop-host/src/file-drag.ts b/apps/desktop-host/src/file-drag.ts new file mode 100644 index 0000000000..473ab67e56 --- /dev/null +++ b/apps/desktop-host/src/file-drag.ts @@ -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 { + 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') } +} diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 6ce80e1aa5..3109537b63 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -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 { }), }) let stopping: Promise | undefined + const dragReads = new Map() + const dragTasks = new Set>() const control: { updateTasks?: ReturnType quitInspection?: ReturnType @@ -50,6 +53,8 @@ async function main(): Promise { process.send(message, (error) => { if (error === null) resolve(); else reject(error) }) }) const stop = (): Promise => 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 { 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 diff --git a/apps/desktop-host/tests/file-drag.spec.ts b/apps/desktop-host/tests/file-drag.spec.ts new file mode 100644 index 0000000000..437a062e05 --- /dev/null +++ b/apps/desktop-host/tests/file-drag.spec.ts @@ -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() + 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() +}) diff --git a/apps/desktop-host/tsconfig.json b/apps/desktop-host/tsconfig.json index 31c64f179b..58eaa722db 100644 --- a/apps/desktop-host/tsconfig.json +++ b/apps/desktop-host/tsconfig.json @@ -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" } ] } diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 63bdeb2cbf..f554ad3863 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 883acffea1..931401ec77 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -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 ``. 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. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index cd3c23211f..262eee3ac7 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -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 前尝试清理;清理失败只记录日志,不阻断安装或退出。安装失败后恢复桥接。异步系统文件图标避免主进程解码原图。 + 只有主应用窗口启用 ``。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 校验。 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 64a6bff8fc..23d5e49efe 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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:*" } } diff --git a/apps/desktop/src/file-drag-files.ts b/apps/desktop/src/file-drag-files.ts new file mode 100644 index 0000000000..b659b757c0 --- /dev/null +++ b/apps/desktop/src/file-drag-files.ts @@ -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 +} + +/** Borrowed files are never deleted; temporary exports retain cleanup ownership until removal succeeds. */ +export class DragFiles { + private readonly files = new Map() + private readonly pending = new Set>() + private readonly releasing = new Map>() + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/apps/desktop/src/file-drag.ts b/apps/desktop/src/file-drag.ts new file mode 100644 index 0000000000..33478e6e21 --- /dev/null +++ b/apps/desktop/src/file-drag.ts @@ -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 { + 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 = 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(operation: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted() + const cancelled = Promise.withResolvers() + 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, +): () => Promise { + let disposed = false + const preparations = new Map() + const files = new DragFiles(app.getPath('temp')) + const pending = new Set>() + const icons = new Map() + const borrowed = new Map() + 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 => { + 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() + } +} diff --git a/apps/desktop/src/host-process.ts b/apps/desktop/src/host-process.ts index 948a88e6b0..6dbb908c10 100644 --- a/apps/desktop/src/host-process.ts +++ b/apps/desktop/src/host-process.ts @@ -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 { - 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 { - 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 { 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 | 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((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) } } diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index ffa7884a98..e62a84f0a1 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -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 /** diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 8eee28c58b..fb938dec84 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -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 { let workspaceRecovery: Promise | undefined let mainWindow: BrowserWindow | undefined let welcomeWindow: BrowserWindow | undefined + const installFileDrag = (): (() => Promise) => 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 => { + 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 { 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 } | undefined const navigateMain = (url: string): Promise => { @@ -511,6 +532,7 @@ async function main(): Promise { }, 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 { 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 { 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 { 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) => { diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index f590b8bc8b..c9937a301e 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -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, + 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, + 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, keyboard: { closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise, diff --git a/apps/desktop/tests/file-drag-files.spec.ts b/apps/desktop/tests/file-drag-files.spec.ts new file mode 100644 index 0000000000..996de85d8c --- /dev/null +++ b/apps/desktop/tests/file-drag-files.spec.ts @@ -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)) +}) diff --git a/apps/desktop/tests/file-drag-release.spec.ts b/apps/desktop/tests/file-drag-release.spec.ts new file mode 100644 index 0000000000..bf6bc46cab --- /dev/null +++ b/apps/desktop/tests/file-drag-release.spec.ts @@ -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(), rm: vi.fn(), writeFile: vi.fn(), +})) +const fs = await vi.importActual('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() + 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() + 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() +}) diff --git a/apps/desktop/tests/file-drag.spec.ts b/apps/desktop/tests/file-drag.spec.ts new file mode 100644 index 0000000000..f7cbe89610 --- /dev/null +++ b/apps/desktop/tests/file-drag.spec.ts @@ -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[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(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() + 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() + 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() + 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') +}) diff --git a/apps/desktop/tests/host-process.spec.ts b/apps/desktop/tests/host-process.spec.ts index daeddc74d0..93b5f9bce2 100644 --- a/apps/desktop/tests/host-process.spec.ts +++ b/apps/desktop/tests/host-process.spec.ts @@ -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') diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 91505aaf93..b2d925af6c 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -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>(), 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() + 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) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index e238f583c1..af9a9f226a 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -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 diff --git a/apps/desktop/tsconfig.host.json b/apps/desktop/tsconfig.host.json index 982d7835ee..961dda768a 100644 --- a/apps/desktop/tsconfig.host.json +++ b/apps/desktop/tsconfig.host.json @@ -31,6 +31,9 @@ }, { "path": "../../packages/client/product-analytics/tsconfig.host.json" + }, + { + "path": "../../packages/util/file-drag" } ], "exclude": [ diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index ed5933af9f..e314a707ba 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -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 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index de7d2e1389..969cf3a965 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -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. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index db5869542a..29234f33c6 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -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 关闭操作完成后,再检查进程终止。它还检查第一个窗口的进程、保存的绑定以及重载后的布局。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 9953e112cc..79a5ed4bdb 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -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() diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 32c4c09089..9fc67f9253 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -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() diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index d999685580..afe0f3cb39 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -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 const sessionEvents: SessionEvent[] = [] let answeredSession: SessionId | undefined + let hostClock: ReturnType | 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) diff --git a/apps/web/tests/rich-text-quotes.e2e.ts b/apps/web/tests/rich-text-quotes.e2e.ts index ba7b5e0c7c..91dc43717b 100644 --- a/apps/web/tests/rich-text-quotes.e2e.ts +++ b/apps/web/tests/rich-text-quotes.e2e.ts @@ -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 + let hostClock: ReturnType | 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') }) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 2324f0ae2b..56d7f0e66b 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -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 const apiCalls: string[] = [] + let hostClock: ReturnType | 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') }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 43a16df940..3c5fc354c1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b01ac64d39..9ecd1cedf9 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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) | diff --git a/docs/module-graph.md b/docs/module-graph.md index d05e775f59..6dea2c0abb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -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` | — | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ee3a43565c..57fecbbf33 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -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` | — | diff --git a/docs/upgrade-guide/v0.2.1-rc.2/file-drag-callbacks/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/file-drag-callbacks/guide.i18n.yaml new file mode 100644 index 0000000000..8fd9c8bcf7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/file-drag-callbacks/guide.i18n.yaml @@ -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 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/file-drag-callbacks/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/file-drag-callbacks/guide.md new file mode 100644 index 0000000000..bfe909aeff --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/file-drag-callbacks/guide.md @@ -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. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/file-drag-callbacks/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/file-drag-callbacks/guide.zh.md new file mode 100644 index 0000000000..cff02143af --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/file-drag-callbacks/guide.zh.md @@ -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)。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 5afee068e2..6e842cf61a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -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' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index ed2a93bcd0..8281a74594 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -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:~", diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 9810983691..c80b06b289 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -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 diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index ae497ad6ce..65b551434d 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 0805928455..29e7f3aba0 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -25,6 +25,8 @@ This package renders everything the conversation UI shows about attachments: one ## 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. ----- diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index c17f914b4d..80db6ae928 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -25,6 +25,8 @@ kind: "package-reference" ## 使用本包 +图片拖放回调与输入框的原图读取回调通过 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 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。 ----- diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 0a525f7c71..13b2018090 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -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", diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index a067ffd446..4cce54baa5 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -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 ? : labels.loading} ) - : {label}} + : } {open && src !== null && } @@ -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} /> ))} diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index 8035470f69..2c57ce0e74 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -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) { const [preview, setPreview] = useState(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(() => attachments.map(attachment => ({ id: attachment.id, diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 76a0718c1f..0a95ddc66d 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -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 + compact={compact} thumbnail={thumbnail} labels={resolved} fileDrag={fileDrag} /> } /** diff --git a/packages/client/ui-attachment/src/client/drop-events.ts b/packages/client/ui-attachment/src/client/drop-events.ts index 2f3da69372..9898c714da 100644 --- a/packages/client/ui-attachment/src/client/drop-events.ts +++ b/packages/client/ui-attachment/src/client/drop-events.ts @@ -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) diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 75059f84b3..7ef44e1dd8 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -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({ diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index 9b101ea8ab..281c0a57bb 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -85,6 +85,7 @@ function props(overrides: Partial = {}): 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(
transfer.getData('application/x-dsh-file') === 'token' ? file : undefined} />
) + 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() +}) diff --git a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx index 04f83f3ba8..09f0db62cb 100644 --- a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx +++ b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx @@ -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')) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index d447df5fd9..7d0ee96bea 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -39,7 +39,7 @@ describe('MessageImage', () => { const load = Object.assign(vi.fn(() => new Promise(() => {})), { peek: vi.fn(() => 'blob:seeded'), }) - const view = render() + const view = render() 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() + const view = render() 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(() => {})) - const view = render() + const view = render() 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() + const view = render() 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() + const view = render() 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( - , + , ) 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( - , + , ) 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( - , + , ) 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(() => {})) - const view = render() + const view = render() 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() + const view = render() 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() const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry') - const view = render() + const view = render() 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() + const view = render() 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((r) => { resolve = r })) - const view = render() + const view = render() view.unmount() resolve?.('blob:late') await Promise.resolve() let reject: ((error: Error) => void) | undefined const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) - const second = render() + const second = render() 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( - { it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => { const load = vi.fn() const view = render( - , + , ) 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( - '')} variant="tile" labels={labels} />, + '')} 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() + const empty = render() expect(empty.container.firstChild).toBeNull() const view = render( - { it('renders a lone image large and compact or grouped images as square tiles', () => { const load = vi.fn(() => new Promise(() => {})) - const lone = render() + const lone = render() expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1) lone.unmount() const compact = render( - , + , ) expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1) compact.unmount() const several = render( - , + , ) 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() } diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index 6181d8db35..a14099543e 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -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() } diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 136e1ebb26..1b7d6e0129 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -37,6 +37,12 @@ }, { "path": "../locale/tsconfig.client.json" + }, + { + "path": "../ui-file-drag" + }, + { + "path": "../../util/file-drag" } ] } diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index b73a302dba..d3e5dc6506 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 98b1aa5ec9..b5dc85a0f9 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -33,6 +33,8 @@ File-mention providers receive the viewed Session ID with the closing-turn owner ## 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. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 88fa3d4e5f..06d1d924b2 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" ## 引用预览 +图片拖放回调通过图片入口的注入接口从 [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` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 0f31c600be..398661a4ff 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -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:~" diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index c31a219598..fcbc44d9be 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -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), diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 3fb6ca18c6..f1a0556b9f 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -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) diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index fc4be216cc..948a546689 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -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: { diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index c89f2c88b3..557b9e2a04 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -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() } diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index f5f070f6f4..ab256ad15b 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -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() runtime.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index f1840034d4..cf2516d005 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -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() } diff --git a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx index e6992183a5..c7f67f3505 100644 --- a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx +++ b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx @@ -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() runtime.ctx.provide('configForms', { diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json index 9b241459b1..42e4b6003c 100644 --- a/packages/client/ui-chat/tsconfig.client.json +++ b/packages/client/ui-chat/tsconfig.client.json @@ -117,6 +117,9 @@ { "path": "../product-analytics/tsconfig.client.json" }, + { + "path": "../ui-file-drag" + }, { "path": "../../core/tool-executor-ptc" } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 6f638b2371..f8d86187d3 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -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' } } diff --git a/packages/client/ui-file-drag/README.i18n.yaml b/packages/client/ui-file-drag/README.i18n.yaml new file mode 100644 index 0000000000..af7b1ead0d --- /dev/null +++ b/packages/client/ui-file-drag/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-file-drag/README.md b/packages/client/ui-file-drag/README.md new file mode 100644 index 0000000000..ce8f34cfca --- /dev/null +++ b/packages/client/ui-file-drag/README.md @@ -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) + + +## 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. + + +## Understand the implementation + +
+Implementation internals + +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. + +
+ + +## 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 + + + +- 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. + + +### Dev Note + +
+Working context for maintainers + +None. + +
diff --git a/packages/client/ui-file-drag/README.zh.md b/packages/client/ui-file-drag/README.zh.md new file mode 100644 index 0000000000..4960ef286c --- /dev/null +++ b/packages/client/ui-file-drag/README.zh.md @@ -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) + + +## 使用此包 + +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 读取;取消不会触发回退。 + + +## 理解实现 + +
+实现内部细节 + +Cordis 插件负责文件准备、传输选择、浏览器保留字节与临时反馈。[DraggableImage](../ui-primitives/README.zh.md) 处理图片手势,[Desktop](../../../apps/desktop/README.zh.md) 授权借用资源并持有原生临时导出。图片是首个调用方,同一组回调可接收 PDF 等文件内容,不设图片 MIME 门禁。 + +
+ + +## 模型体验 + +无;该插件只改变浏览器交互,不增加模型可见消息或工具。 + +#### KV Cache effect + +无;该插件既不构建也不发送模型请求。 + +## 已知限制与后续工作 + + + +- 浏览器令牌不能跨应用、窗口或标签页传递。浏览器向外部交付文件依赖 Chromium 的 `DownloadURL` 和接收应用;Desktop 提供原生文件交付。不可读取的跨域图片保留浏览器原生拖动。 +- Blob `DownloadURL` 路径尚无经过验证的外部应用交付结果。传递结束后才读取的接收方可能遇到 URL 已撤销;需要原始文件交付时使用 Desktop 原生拖放。 + + +### 开发备注 + +
+维护者工作上下文 + +无。 + +
diff --git a/packages/client/ui-file-drag/locale/en.json b/packages/client/ui-file-drag/locale/en.json new file mode 100644 index 0000000000..36a9a27247 --- /dev/null +++ b/packages/client/ui-file-drag/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "File dragging", + "description": "Drag original files into the composer or other applications, with localized failure feedback." + } +} diff --git a/packages/client/ui-file-drag/locale/ja.json b/packages/client/ui-file-drag/locale/ja.json new file mode 100644 index 0000000000..3bfb13a66b --- /dev/null +++ b/packages/client/ui-file-drag/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ファイルのドラッグ&ドロップ", + "description": "元のファイルを入力欄や他のアプリにドラッグし、失敗時には選択した言語で通知します。" + } +} diff --git a/packages/client/ui-file-drag/locale/zh.json b/packages/client/ui-file-drag/locale/zh.json new file mode 100644 index 0000000000..0930345d15 --- /dev/null +++ b/packages/client/ui-file-drag/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "文件拖放", + "description": "将原始文件拖入输入框或其他应用,并提供本地化失败提示。" + } +} diff --git a/packages/client/ui-file-drag/package.json b/packages/client/ui-file-drag/package.json new file mode 100644 index 0000000000..16e865db2a --- /dev/null +++ b/packages/client/ui-file-drag/package.json @@ -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" + ] +} diff --git a/packages/client/ui-file-drag/src/client/feedback.tsx b/packages/client/ui-file-drag/src/client/feedback.tsx new file mode 100644 index 0000000000..a90201e195 --- /dev/null +++ b/packages/client/ui-file-drag/src/client/feedback.tsx @@ -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 } + 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) { + const failure = useFailure(value => value) + return failure === null ? null : +} diff --git a/packages/client/ui-file-drag/src/client/index.ts b/packages/client/ui-file-drag/src/client/index.ts new file mode 100644 index 0000000000..efe3fb7eab --- /dev/null +++ b/packages/client/ui-file-drag/src/client/index.ts @@ -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(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)) +} diff --git a/packages/client/ui-file-drag/src/client/locales.ts b/packages/client/ui-file-drag/src/client/locales.ts new file mode 100644 index 0000000000..9497b29dbc --- /dev/null +++ b/packages/client/ui-file-drag/src/client/locales.ts @@ -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 + +/** Japanese file drag failure copy. */ +export const ja = { failed: 'ドラッグ&ドロップに失敗しました。もう一度お試しください。' } satisfies Record + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** File transfer failure feedback. */ + fileDrag: keyof typeof zh + } +} diff --git a/packages/client/ui-file-drag/src/client/transfers.ts b/packages/client/ui-file-drag/src/client/transfers.ts new file mode 100644 index 0000000000..88c1419588 --- /dev/null +++ b/packages/client/ui-file-drag/src/client/transfers.ts @@ -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 { + 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 { + 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 { + 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 | 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() + } +} diff --git a/packages/client/ui-file-drag/src/index.ts b/packages/client/ui-file-drag/src/index.ts new file mode 100644 index 0000000000..01bb3cdbe2 --- /dev/null +++ b/packages/client/ui-file-drag/src/index.ts @@ -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 {} diff --git a/packages/client/ui-file-drag/tests/plugin.client.spec.tsx b/packages/client/ui-file-drag/tests/plugin.client.spec.tsx new file mode 100644 index 0000000000..b0c83a62ec --- /dev/null +++ b/packages/client/ui-file-drag/tests/plugin.client.spec.tsx @@ -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() + 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() + 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() + 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() +}) diff --git a/packages/client/ui-file-drag/tests/transfers.client.spec.ts b/packages/client/ui-file-drag/tests/transfers.client.spec.ts new file mode 100644 index 0000000000..1b0a3d3fe1 --- /dev/null +++ b/packages/client/ui-file-drag/tests/transfers.client.spec.ts @@ -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() + 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() + 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(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(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() + 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() + 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() + vi.stubGlobal('fetch', vi.fn(() => 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 +}) diff --git a/packages/client/ui-file-drag/tsconfig.json b/packages/client/ui-file-drag/tsconfig.json new file mode 100644 index 0000000000..7aec375e70 --- /dev/null +++ b/packages/client/ui-file-drag/tsconfig.json @@ -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" + } + ] +} diff --git a/packages/client/ui-file-drag/tsdown.config.ts b/packages/client/ui-file-drag/tsdown.config.ts new file mode 100644 index 0000000000..7aa03cebc1 --- /dev/null +++ b/packages/client/ui-file-drag/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index ab2f5b4a66..64a363a551 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 202ba0b9e3..acec371082 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -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. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 0241739e2c..8cd1b8cedd 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -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 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。 - **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 09dd9df339..73f1c57c1a 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -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", diff --git a/packages/client/ui-primitives/src/DraggableImage.tsx b/packages/client/ui-primitives/src/DraggableImage.tsx new file mode 100644 index 0000000000..a7ce07efce --- /dev/null +++ b/packages/client/ui-primitives/src/DraggableImage.tsx @@ -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, '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(function DraggableImage( + { fileDrag, src, name, original, resourceAddress, ...props }, ref, +) { + const drag = useImageDrag(fileDrag, src, name, original, resourceAddress) + return +}) diff --git a/packages/client/ui-primitives/src/file-drag-actions.ts b/packages/client/ui-primitives/src/file-drag-actions.ts new file mode 100644 index 0000000000..3dc1dadfc0 --- /dev/null +++ b/packages/client/ui-primitives/src/file-drag-actions.ts @@ -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 + /** Display the plugin's localized retry message, independently of the originating component. */ + reportFailure(): void +} diff --git a/packages/client/ui-primitives/src/image-drag.ts b/packages/client/ui-primitives/src/image-drag.ts new file mode 100644 index 0000000000..dc95954112 --- /dev/null +++ b/packages/client/ui-primitives/src/image-drag.ts @@ -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, + '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(() => 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() + 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): void => { + if (!event.defaultPrevented && event.currentTarget.draggable + && (resourceAddress === undefined || event.type === 'pointerdown')) prepare(event.currentTarget) + }, [prepare, resourceAddress]) + const onDragStart = useCallback((event: DragEvent): 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): void => { + dragged.current = false + if (event.button === 0) intent(event) + }, [intent]) + const onPointerLeave = useCallback((event: PointerEvent): 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 } +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index c81c33b33e..ae3fdebda2 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -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' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx index 681e8f5608..a139c995b6 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.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. */ diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 1d6b28b7bf..e2797e0a3f 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -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 {preview === undefined ? '' : `${preview.labels.failed} · `}{alt || destination} - const img = {alt} { 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 + 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 = return <> ) + 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() + 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() + f.actions.prepare.mockReturnValue(pending.promise) + f.view.rerender() + 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() + 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(
{ event.preventDefault() }}> + +
) + 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)) +}) diff --git a/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx index 61626a483e..c6d36335ba 100644 --- a/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx @@ -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( @@ -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 = `![图片](<${encodeURI(path).replace('#', '%23')}>)\n\n[打开图片](<${encodeURI(path).replace('#', '%23')}>)` const openFile = vi.fn() const content = (streaming: boolean) => @@ -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(`![source](${destination})`) + 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('![report](/work/test dir/report.pdf)'))).not.toContain('"type":"image"') }) + +it('uses the fallback filename for an external image URL without a path', () => { + mount('![Remote](https://example.com/)') + expect(screen.getByRole('img').getAttribute('src')).toBe('https://example.com/') +}) diff --git a/packages/client/ui-primitives/tests/useClipboardCopy.client.spec.tsx b/packages/client/ui-primitives/tests/useClipboardCopy.client.spec.tsx index 470ca1342e..dfce03ed08 100644 --- a/packages/client/ui-primitives/tests/useClipboardCopy.client.spec.tsx +++ b/packages/client/ui-primitives/tests/useClipboardCopy.client.spec.tsx @@ -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 { 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) diff --git a/packages/client/ui-primitives/tsconfig.json b/packages/client/ui-primitives/tsconfig.json index 93a7e9f395..8fe4c103b6 100644 --- a/packages/client/ui-primitives/tsconfig.json +++ b/packages/client/ui-primitives/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../util/workspace-path" + }, + { + "path": "../../util/file-drag" } ] } diff --git a/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx b/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx index 8c6d11c688..eece35507e 100644 --- a/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/quota-notice-hold.client.spec.tsx @@ -80,6 +80,7 @@ function sectionOperations(account: AccountSnapshot, pages: PlatformPages): Acco async function bench() { vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) 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 forms = new Map['scope']>() const formFor = (namespace: string) => { diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 836196338d..7c04fe5269 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -15,14 +15,14 @@ en: 0dd8c3a22e28aacf zh: c27c11bf4b5cceba /deepseek-ai-dsh-client-ui-sidebar-documentpreview/what-it-registers: - en: 54a50f4a3a6e4d45 - zh: af905ed570812432 + en: a02e6fd89e332967 + zh: b5c61cb75458d1a2 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/addresses: en: 57942270e23d3a8e zh: 6617124e652b3f67 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads: - en: b056bbe0f74fe328 - zh: fd9ac88f08825afc + en: e197303166b5a86f + zh: 8d151e49321d67aa /deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview: en: 595499992ceea693 zh: 83c6147c46a80dca diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index a75a39a89a..1e7649dd61 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -28,6 +28,8 @@ Preview files in the right Sidebar and choose among registered renderers. Markdo ## What it registers +Image drag actions arrive through the image entry’s inject face from [ui-file-drag](../ui-file-drag/README.md). + - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, a whole-address file-resource pattern, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. - **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; the shared [`PathLabel`](../ui-primitives/README.md#component-catalog) subdues directories, preserves a clipped path’s trailing characters with a left-edge fade, and exposes the full value on hover. A dropdown appears when multiple supported renderers are available. Plain text is offered only for text-compatible sources; a single renderer shows no viewer control. A known binary container suffix without a registered renderer shows the file-type icon and an unsupported-preview message under the path header, without issuing a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `absolutePath` as the owner prop: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. Automatic refresh defaults to enabled; its separate toggle is hidden, with state, labels, styles, and toggle logic retained. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, pending Resource changes, automatic-refresh state, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. @@ -59,7 +61,7 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. ` When Coding Tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. -PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. The shared zoom controls can produce horizontal and vertical scrolling, but do not provide drag-to-pan. Zoom changes retain the same `` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. +PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. When either axis overflows the viewport, left-drag pans the image with grab/grabbing cursors. A fully contained image instead drags its original file into the composer or an external application; the gesture mode remains fixed until release. Ctrl+wheel and trackpad pinch retain the shared zoom behavior. Zoom changes retain the same `` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a cool light-grey background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. After zoom settles or fit width changes, nearby PDF pages redraw at the resolved scale multiplied by the device pixel ratio. The previous bitmap and selectable text stay visible until the replacement bitmap is ready; offscreen pages refresh when they approach the viewport. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index babe6e7385..022f396ea4 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -28,6 +28,8 @@ kind: "package-reference" ## 注册了什么 +图片拖放回调通过图片入口的注入接口从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。 + - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,匹配完整文件资源地址的 pattern,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 - **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;共享的 [`PathLabel`](../ui-primitives/README.zh.md#component-catalog) 弱化目录颜色,以左侧渐隐保留被裁剪路径的尾部字符,并在悬停时展示完整值。有多个受支持的渲染器时才显示下拉菜单。仅文本兼容的源文件提供纯文本选项;只有一个渲染器时不显示查看器控件。已知的二进制容器后缀没有注册渲染器时,在路径头部下方显示文件类型图标和不支持预览的说明,并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner prop 为 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。自动刷新默认开启;独立开关暂时隐藏,状态、文案、样式与切换逻辑保留。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、待处理 Resource 变化、自动刷新状态、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 @@ -59,7 +61,7 @@ Web 和桌面端均通过代码工作工具选择 HTML 预览策略。渲染器 开启代码工作工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 -PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 +PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。图片在任一方向超出视口时,按住鼠标左键拖动可平移,指针显示为抓手或抓取状态。图片完全位于视口内时,拖动会将原文件交给输入框或外部应用;一次手势的模式保持到松开为止。Ctrl+滚轮和触控板捏合沿用共享缩放行为。缩放不会替换 `` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用冷调浅灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。缩放稳定或适应宽度发生变化后,视口附近的 PDF 页面按最终缩放比例乘以设备像素比重绘。新位图完成前保留原有画面和可选文字层;屏幕外的页面在接近视口时更新。 diff --git a/packages/client/ui-sidebar-documentpreview/package.json b/packages/client/ui-sidebar-documentpreview/package.json index 52e770ada9..b52bfe1c21 100644 --- a/packages/client/ui-sidebar-documentpreview/package.json +++ b/packages/client/ui-sidebar-documentpreview/package.json @@ -36,7 +36,8 @@ "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-settings" + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-file-drag" ], "platform": "web" } @@ -84,7 +85,8 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", - "yaml": "^2.9.0" + "yaml": "^2.9.0", + "@deepseek-ai/dsh-client-ui-file-drag": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css index 414054d2aa..422f480d64 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css @@ -52,3 +52,6 @@ line-height: 1.5; white-space: normal; } + +.image[data-pan="available"] { cursor: grab; } +.image[data-pan="active"] { cursor: grabbing; } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx index 770c806bd5..08c2ec3b0d 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx @@ -1,5 +1,7 @@ /** Complete image bytes rendered in a shared zoom viewport. */ -import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { DraggableImage } from '@deepseek-ai/dsh-client-ui-primitives' +import { bindImagePan } from './pan.ts' import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { DocumentPreviewProps } from '../document/contract.ts' @@ -24,8 +26,13 @@ const IMAGE_MEDIA_TYPES = { type ImageMediaType = typeof IMAGE_MEDIA_TYPES[keyof typeof IMAGE_MEDIA_TYPES] +/** Image-only operations injected alongside the shared zoom lifecycle. */ +export interface ImageBodyInjected extends ZoomInjected { + readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions +} + /** Standard document props plus the image renderer's dictionary. */ -export type ImageBodyProps = DocumentPreviewProps & PropsLocale<'sidebarImage'> & PropsStore & ZoomInjected +export type ImageBodyProps = DocumentPreviewProps & PropsLocale<'sidebarImage'> & PropsStore & ImageBodyInjected type ImageSource = | { @@ -33,6 +40,7 @@ type ImageSource = readonly data: Uint8Array readonly mediaType: ImageMediaType readonly url: string + readonly file: File } | { readonly kind: 'failed'; readonly data: Uint8Array; readonly mediaType: ImageMediaType } @@ -71,15 +79,16 @@ export function ImageBody(props: ImageBodyProps): ReactNode { if (data === undefined || mediaType === undefined) return let url: string | undefined try { - url = URL.createObjectURL(new Blob([data], { type: mediaType })) - setSource({ kind: 'ready', data, mediaType, url }) + const file = new File([data], pathPartsOf(path).name, { type: mediaType }) + url = URL.createObjectURL(file) + setSource({ kind: 'ready', data, mediaType, url, file }) } catch { setSource({ kind: 'failed', data, mediaType }) } return () => { if (url !== undefined) URL.revokeObjectURL(url) } - }, [data, mediaType]) + }, [data, mediaType, path]) if (data === undefined || mediaType === undefined) { return

{t('unsupported')}

@@ -93,15 +102,29 @@ export function ImageBody(props: ImageBodyProps): ReactNode { controls: t('zoomControls'), menu: t('zoomMenu'), out: t('zoomOut'), into: t('zoomIn'), fitWidth: t('zoomFitWidth'), value: percent => t('zoomValue', { percent }), } - return + return } /** SVG stays in the browser's static image mode because its bytes only reach an img Blob URL. */ -function LoadedImage({ url, name, preference, onPreference, labels, signal, scrollportRef, t }: { +function LoadedImage({ url, name, file, resourceAddress, preference, onPreference, labels, signal, scrollportRef, t, fileDrag }: { + readonly fileDrag: ImageBodyInjected['fileDrag'] readonly url: string readonly name: string + readonly file: File + readonly resourceAddress: string readonly preference: ZoomPreference readonly onPreference: (preference: ZoomPreference) => void readonly labels: ZoomLabels @@ -109,21 +132,37 @@ function LoadedImage({ url, name, preference, onPreference, labels, signal, scro readonly scrollportRef: ImageBodyProps['scrollportRef'] readonly t: ImageBodyProps['t'] }): ReactNode { + const releasePan = useRef<() => void>() + const attachImage = useCallback((image: HTMLImageElement | null): void => { + releasePan.current?.() + releasePan.current = image === null ? undefined : bindImagePan(image) + }, []) const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading') const [width, setWidth] = useState() - return + return
{state === 'loading' && } {state === 'failed' &&

{t('failed')}

}