Skip to content

dsh-file-drop

Verified

@kewab/dsh-file-drop · v1.1.3 · MIT · Web UI

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.

Install

dsh plugin add @kewab/dsh-file-drop

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Published to npm without a public repository. Inspect the package contents before installing.

Tags

Creators

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 不允许覆盖已发布的版本号。