Chuyển đến nội dung chính

dsh-file-drop

Đã xác minh

@kewab/dsh-file-drop · v1.1.3 · MIT · Giao diện web

DeepSeek Harness plugin: drag a file or folder out of the Workspace Files tree onto the composer to insert the same @ reference the @ menu inserts.

Cài đặt

dsh plugin add @kewab/dsh-file-drop

Xác nhận layer đã áp bằng dsh --profile default --dump-config — xem hướng dẫn cài plugin.

Mã nguồn

Phát hành lên npm mà không có repository công khai. Hãy kiểm tra nội dung package trước khi cài.

Thẻ

Tác giả

Readme

dsh-file-drop — 拖拽工作区文件到消息框以插入 @ 引用

给 DeepSeek Harness 的「工作区文件」面板加上拖拽引用能力:把文件树里的文件或文件夹拖到消息框上,就会在光标处插入一个与 @ 菜单同款的引用 chip(@路径),不必再手打 @ 再翻列表。

icon

行为

  • 拖动来源:右侧栏「工作区文件」树中的任意 file / directory 行(other 类型如 socket、设备文件不可引用,会被忽略)。
  • 拖动区域就是那一行本身(也就是平时 hover 会高亮的圆角条):目录展开后拖的是该目录这一行,不会把展开出来的整棵子树一起拖起来,也不要指望在列表空白处按下就能拖。
  • 鼠标样式沿用宿主:停在文件名上仍然是普通指针(宿主自己的 cursor:pointer),不额外变手型;只有在真的拖起来之后才变成抓取光标。
  • 拖拽图像 = 那一行 hover 时的样子(同样的圆角、同样的 hover 底色),不再是一个直角方块;在行的哪个位置按下,图像就抓在那个位置。
  • 落点:消息框(composer)卡片。悬停时卡片出现虚线高亮表示可以放下。
  • 结果:在光标位置插入引用 chip,后面自动补一个空格(与键盘选择 @ 候选完全一致)。
  • chip 图标沿用文件树:拖入文件时,chip 左侧画的就是工作区文件树里该文件名的同款类型图标(client.js 是那个彩色的 .js 图标,README.md、a.pdf、pic.svg 各是自己的图标);目录沿用宿主自带的文件夹图标,因为这本来就是树里那个文件夹图标。页面型图标(.md、.svg、图片、PDF 之类)画的是「深色页面 + 白色标记」,插件给它上的色就是宿主给这一类文件上的颜色(.md 是 deepseek-500 蓝,.svg 是紫色),而不是 chip 的引用蓝——用引用蓝会把页面和标记糊成一块纯蓝色。
  • 图标的寿命:图标是在插入那一刻贴到 chip 上的,正常打字、改草稿、切会话、滚消息都不会掉。撤销/重做、以及发送失败后宿主回填草稿,都会把这些 chip 重建一遍,新元素上没有插件贴的图标;插件因此跟着消息框自己的投影走,在这类重建之后再补贴一次,判定依据只有「这个文件名是拖进来的」——用户手打的 @ 引用不会被误伤(唯一的重叠:同名文件之前拖过一次时,之后手打的同名引用也会被补上图标;因为图标本来就只由文件名决定,补上的仍然是这个文件在树里的那张图标)。
  • 路径写法遵循宿主 @ 语法:
    • 位于当前会话工作区根目录内 → 相对路径,例如 @src/index.ts;
    • 工作区外 → 绝对路径;
    • 含空格或引号 → 加引号,例如 @"my docs/a b.md";
    • 目录 → 引用文本保留结尾斜杠(宿主用它表示「这是个目录」,例如 @src/components/),但消息框里的 chip 不显示这个斜杠:chip 上只有目录名 components,目录与否由 chip 的文件夹图标表示。
  • 拖拽期间不会触发「附件上传」:本插件在捕获阶段接管事件,附件浮层看不到这次拖拽。
  • 从系统 Finder 拖入文件的行为保持不变,仍然是附件上传。

安装

包名是 @kewab/dsh-file-drop。同一个字符串同时充当 bundle 名、客户端模块 id 和 Node 解析用的包名,三者必须逐字一致;改名时这四处都要改:package.json 的 name、cordis.patch.yml 的 name、client.js 里 __ModuleLoader__.load({ id }) 和 slots.register({ id })(客户端 id 对不上时,boot 图的「注册后校验」会直接拒绝这个脚本)。

普通 profile:一条命令

dsh plugin --profile <profile> add @kewab/dsh-file-drop

dsh plugin 把包装进 profile 的 node_modules,并把 bundles 条目自动写进 dsh.profile.bundles——凡是依赖清单里声明了 dsh.bundle.patch 的包都会入栈。装完重启 DeepSeek Harness。

desktop profile:手动三步

desktop profile 由 Electron 应用独占管理:dsh plugin --profile desktop … 会被明确拒绝(error: profile "desktop" is managed exclusively by the Electron application)。两条路:用应用内的插件管理器,或者按下面手动安装。

插件源码就在本目录。它通过 profile 的 bundle 列表加载,安装 = 三处改动:

  1. ~/.dsh/profiles/desktop/node_modules/@kewab/dsh-file-drop → 指向本目录的符号链接(让 Host 能解析到这个 bundle);
  2. ~/.dsh/profiles/desktop/package.json 的 dependencies 增加 "@kewab/dsh-file-drop": "file:<本目录绝对路径>"(让插件管理器能列举/卸载它);
  3. 同一个 manifest 的 dsh.profile.bundles 末尾追加 "@kewab/dsh-file-drop"(让它真的启用)。

第 3 步是启用开关:只有第 1、2 步而缺第 3 步时,Host 能解析该包、插件管理器也能看到它,但 bundle 不会加载,功能不生效。

本目录提供了两个脚本做这三件事(不依赖 pnpm,也不需要 pnpm install):

./install.sh     # 写入链接 + 依赖 + bundles 条目
./uninstall.sh   # 撤销上述三者(应用起不来时可在终端直接执行)

从 npm 装到 desktop profile 时,先用任意包管理器把包取进 profile,再执行包内的脚本补最后一步(脚本会认出自己已经躺在 profile 的 node_modules 里,于是只补 bundles 条目,不会把链接指回自己):

cd ~/.dsh/profiles/desktop
pnpm add @kewab/dsh-file-drop          # 或 npm i @kewab/dsh-file-drop
sh node_modules/@kewab/dsh-file-drop/install.sh

卸载同理:sh node_modules/@kewab/dsh-file-drop/uninstall.sh,再 pnpm remove @kewab/dsh-file-drop。

改动生效需要重启 DeepSeek Harness 应用:新的插件行要在启动时重新组合 Host/Client 图,运行中的进程不会重新读取 profile 清单。重启后(必要时刷新页面)即可使用;「设置 → 插件」里也能看到它(插件管理器按 profile 的 dependencies 列举,所以第 2 步的依赖条目是必要的,否则插件能用但不出现在列表里)。

关于安装方式,0.1.7-rc.2 上实测确认的几点:

  • dsh plugin --profile desktop … 会被应用明确拒绝:error: profile "desktop" is managed exclusively by the Electron application。desktop profile 只能手动安装(如上)或通过应用内的插件管理器安装。
  • bundle 解析走 Node 自己的 node_modules 查找:只要 <profile>/node_modules/<包名>/package.json 存在即可,不需要 pnpm lockfile、也不需要任何额外注册文件;符号链接会被正常跟随。
  • 本包没有声明任何 @deepseek-ai/dsh* 的 peerDependencies,因此不会触发 profile 兼容性豁免流程;即使将来解析失败,profile 加载器也只会跳过这个 bundle 并在 stderr 记一行原因,不会导致应用起不来。
  • 应用内「禁用第三方插件并恢复」一类的菜单动作会把 dsh.profile.bundles 重置为默认值(同时备份 cordis.patch.yml),届时第 1、3 步需要重新执行。

卸载:直接运行 ./uninstall.sh,或手动删掉上面的符号链接、dependencies 条目和 bundles 条目;安装前的 profile 清单备份在 <工作区>/.dsh-file-drop-install/desktop-package.json.orig。

启动期安全性(为什么它不会再让应用崩溃)

客户端插件的激活审计很严格:只要有一行插件没有激活,整个 web boot 就失败,表现为启动时弹窗 / ~/Library/Logs/DeepSeek Harness/crash-*-web-boot.log 里写着 web boot: 1 entry did not activate + @kewab/dsh-file-drop: pending (waiting for service: …)。

第一版安装正是踩了这一点:它把 @deepseek-ai/dsh-client-ui-conversation 错当成「服务名」放进了插件的 inject,而客户端 Cordis 里并不存在这个服务,于是这一行永远 pending,整个界面起不来。现已从三个层面消除这类风险:

  1. 插件的 inject 是空数组,不等待任何服务;slots / locale / sessions 改由 ctx.inject([...], cb) 在服务就绪后再取(宿主自己的插件如 dsh-client-ui-sidebar-files 也用这个写法)。服务缺失时本插件只是不工作,不会 pending。
  2. dsh.client.inject(清单里的字段,仅用于客户端模块到达顺序)也已去掉:本插件的模块只 require('react')(基础 seed),槽位声明本身已经处理了先后顺序,所以不需要额外边。
  3. apply() 整体包在 try/catch 里,并把时间花在注册监听器上,任何意外都只留下一条 console.error,绝不向外抛。
  4. chip 图标另需三个基础模块(@deepseek-ai/dsh-client-ui-primitives、react-dom、react-dom/client,都是宿主 web boot 的 seed 模块表里的键)。它们在第一次拖入文件时才 require,并整段包在 try/catch 里:拿不到就只是不画文件类型图标(chip 保持宿主的通用引用图标),既不会 pending 也不会抛。

结论:这一行现在只会「激活但什么都不做」,不会再影响启动。

文件

文件 作用
package.json bundle 清单:dsh.bundle.patch + dsh.client(platform: web、immediately;不注入任何服务,也不声明模块顺序边)
cordis.patch.yml 插入一行插件 @kewab/dsh-file-drop
index.js Host 半边:无操作,仅让该行可加载
client.js 浏览器半边:拖拽监听、落点高亮、引用插入、chip 的文件类型图标
install.sh / uninstall.sh 手动安装/卸载 desktop profile(链接 + 依赖 + bundles 三处改动;从 npm 装好后只需跑脚本补 bundles 条目)
icon.svg / locale/*.json 插件管理界面里的图标与文案
LICENSE MIT

实现要点

  • dragstart(捕获、委托):文件树的行本身没有 draggable,插件在鼠标碰到该行自身时补上 draggable="true",随后在 dragstart 中读取该条目已经公开的 data-files-path(绝对路径)、data-files-entry(file / directory)和行内名称,并写入私有拖拽类型 application/x-dsh-file-drop。
  • 拖的是「行」而不是「条目」:目录展开后,<li data-files-entry="directory"> 里除了行按钮还包着整棵子树,浏览器会拿整个 <li> 当拖拽图像——于是拖文件夹变成拖一大片直角方块。插件现在只给条目自己的行元素(:scope > button,也就是视觉上那个带 border-radius: var(--dsw-radius-md) 的圆角条)加 draggable, 并且只在指针落在行内时才武装它(悬停在展开出来的子层或列表空白处不算);名称与路径仍从条目的 data-files-* 属性读取,行为不变。
  • 拖拽图像 = hover 态的行:dragstart 里先给该行加上 data-file-drop-dragging(CSS 用与宿主 :hover 相同的 --dsw-alias-interactive-bg-hover 底色 + cursor:grabbing 画出 hover 的样子),再调用 dataTransfer.setDragImage(row, clientX - rect.left, clientY - rect.top) 把这一行本身交给浏览器; 浏览器是在 dragstart 返回之后才生成拖拽图像的,所以此时行已经是 hover 态,图像就是那个圆角条,而且抓取点就在指针按下的位置。dragend / 窗口失焦 / 插件卸载时移除该属性。注意这里没有给 [draggable="true"] 写任何 cursor 规则,因此悬停时不会出现多余的手型。
  • 四角为什么会变直角(以及修法):Blink 生成元素拖拽图像的 DraggedNodeImageBuilder(third_party/blink/renderer/core/clipboard/data_transfer.cc)是**「从最近的 stacking context 开始绘制,再裁剪到该元素本身」,源码注释写明这会把元素背后、同一 stacking context 中先绘制的内容一起画进来**。行的 border-radius 只是让圆角之外变成透明,而那块透明紧接着被身后的面板底色填满,于是图像变成一个直角方块——行自己的圆角背景其实是画对了的。 修法:拖拽期间给该行加 isolation:isolate + transform:translateZ(0),让这一行自己成为 stacking context,Blink 的绘制就从该行开始,圆角外保持透明,图像与 hover 态完全一致(同样的圆角、同样的底色)。
  • dragover / drop(捕获):只有携带该私有类型的拖拽才会被接管;用会话作用域的探针组件(注册在 conversation.input.overlay)找到指针下的消息框所属会话。
  • 插入:在该会话自己的 Cordis 作用域上派发 slash/input-insert-reference,payload 是 { source: 'reference', ref, label, appearance, clipboardText }, 其中 span 由消息框公开的 inputActions.captureInsertion() 取到(detect 坐标 + 实时 draftRev),因此在有 chip 的草稿里位置也正确。 ref / clipboardText 用宿主 @ 语法(目录带结尾斜杠),label 单独给:目录只用条目名,不带斜杠——chip 显示的是 label, 文件夹图标已经说明它是目录,斜杠在消息框里只会显得像路径尾巴。chip 的 label 是插入时缓存的,草稿恢复(restoreDraft)与投影($projectComposer)都从 chip 节点读回,所以这个显示不会被重新推导成带斜杠的文本。
  • chip 的文件类型图标:chip 是 conversation 包里的 React 节点(ReferenceChipNode.createDOM 建的宿主 <span data-composer-chip="reference">,加上 ReferenceChip 用 portal 渲染进去的子树)。往里面插/换 DOM 节点会被 React 的删除记录打中(removeChild 抛 NotFoundError),所以插件对 chip 只做两件 React 不管理的事:在宿主元素上标一个 data-file-drop-chip,并写一个自定义属性 --dsh-file-drop-icon。 样式表负责把宿主自带的通用引用 glyph([data-file-drop-chip] svg)藏起来,再用 ::before 以背景图把文件类型图标画在同一个位置——chip 内层本来就是 inline-flex,::before 顶替原来那个 svg 的 flex 位置,align-self:center 与 14px 尺寸都和原 glyph 一致,所以行高与文字位置不变。 图标本体来自宿主的 FileTypeIcon + classifyFileType(与文件树同一套判定,所以 client.js 就是树里那个彩色 .js 图标),渲染到一棵脱离文档的 React root 后取 svg.outerHTML;data URI 是独立文档,currentColor 与主题变量都不会继承,因此先把主题变量烘成字面量(遇到 var(...) 别名就顺着链往下走),再 encodeURIComponent 成 url("data:image/svg+xml,…")。主题变量必须从 chip 所在的子树读(getComputedStyle(chip 的父节点),退回 document.body):这套调色板是主题样式表声明在 body 上的(body{--dsw-static-…} 与 body[data-ds-dark-theme]),从 document.documentElement 上读什么都读不到,页面标记与折角会静默用上兜底的灰字面量(#a2a4a6),表现出来就是「页面颜色对了、图标里的元素发灰」。 注意每个图标都按它自己 28px 的设计尺寸渲染:chip 只有 14px,而 FileTypeIcon 的尺寸参数同时决定 width/height 与 viewBox,按 14px 渲染等于把画布缩小到 14 个单位,图形会被裁掉一大半(表现就是只剩左上角一团色块)。 currentColor 不改写、而是给文档根加一个 color:通用页面图标用 currentColor 画页面本体,又用 <g color="var(--dsw-static-neutral-00)"> 把白色留给页面上的标记(MD 字样、图片的几块形状都是这么来的)。只有让「离得最近的 color 生效」、并把那个白也烘成字面量,页面本体与白色标记才能同时正确;给根加的那个颜色就是宿主给这一类文件上的颜色——宿主那句 _icon_…{color:var(--dsh-file-type-icon-color,var(--dsh-file-type-default-color))} 加上每个类型一条 --dsh-file-type-default-color(.md/.code/.html 取 deepseek-500,.svg/视频取那个紫色字面量 rgb(139,118,246),PDF 红、Word 蓝、Excel 绿、PPT 橙、文件夹琥珀、其他灰)。这个颜色不抄进插件当唯一来源:先测量活页面——把渲染图标的那棵离屏容器临时挂到一枚已标记 chip 旁边(只有进了消息框,宿主那句 glyph 规则才生效),让宿主自己渲染一遍、读 getComputedStyle(svg).color,随即摘掉;只有量不到时才退回插件里那张同样内容的常量表,因此自定义主题或 --dsh-file-type-icon-color 覆盖也能跟上。语言类彩色图标(CodeFileIcon 那批)全是字面色,既没有 currentColor 也没有主题变量,根本不需要这个颜色,只走一遍变量烘焙即可。同一类型只渲染一次并缓存。 chip 何时进 DOM 由 React 决定(可能是异步提交),因此用宿主投影 state.getSnapshot().occurrences 在派发前后做差分,定位到这一枚 chip——同一条引用已经在草稿里时也一样,差分给出的是新插入的那一枚,而不是更早的那一枚;只有连派发前的投影都读不到时才退回「最后一枚同引用 chip」,而且位置对了还要用 DOM 上的 textContent 复核 label。最多 30 次 × 16ms 重试,仍对不上就安静放弃(chip 保持宿主默认样子),绝不去改别的 chip。
  • 重建后的补贴:撤销/重做与发送失败的回填都会把 chip 换成新元素(标记长在元素上,于是随之消失)。插件在第一次成功拖入时订阅消息框自己的 shell.state(失效的 chip 用 isConnected 从账本里摘掉,避免泄漏),投影一变就防抖 120ms 扫一遍卡片上的 chip:只看 label,只要这个文件名是拖进来的就补上图标。不按「投影里的引用」来配对是有原因的:回填草稿走的是纯文本,重建出来的 chip 只有 label、没有引用,而 label 本来就是选图标要用的东西;反过来,手打的 @ 引用从没进过这张名单,所以永远不会被补上图标。
  • 所有监听器、样式与登记都通过 ctx.effect / React.useEffect 注册并清理,插件热重载不会留下残留。
  • 激活契约:apply() 不做任何可能抛出的服务访问,exports.inject 为空数组,所需服务全部走 ctx.inject(['slots','locale','sessions'], …);slots / locale 在服务到位前分别退化为「不注册」和「空字符串」(translate / sessions 两个闭包变量),因此该行在任何环境里都是「激活」而不是「pending」。

发布到 npm(维护者)

npm login --registry=https://registry.npmjs.org/   # 这台机器上 registry 默认指向镜像,发布必须显式指定官方源
npm pack --dry-run                                  # 先看 tarball 里到底有哪些文件
npm publish                                         # 走 package.json 里的 publishConfig(官方源 + public)

要点:

  • private 必须为 false/缺省,否则 npm publish 直接拒绝。本包已移除该字段。
  • scope 包默认按私有发布,publishConfig.access: "public" 已写死为公开,免去 --access public。
  • files 白名单决定 tarball 内容;README.md / LICENSE / package.json 无论如何都会包含。
  • 发布后校验:npm view @kewab/dsh-file-drop version、curl -sI https://unpkg.com/@kewab/dsh-file-drop/client.js。
  • 升版本:npm version patch|minor|major,再 npm publish。npm 不允许覆盖已发布的版本号。