@lyhue1991/dsh-soup 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -104
- package/lib/client.js +219 -9
- package/lib/index.js +78 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,140 +1,131 @@
|
|
|
1
|
-
# dsh-soup
|
|
2
|
-
|
|
3
|
-
一个可安装的 DSH(DeepSeek Harness)插件:在会话右侧以**并列网格列**的形式提供项目文件树,与左侧工作区同款配色、同宽(默认 280px,可拖拽 264–420px)。文件「预览」在会话区新增**「预览」标签页**(原生 `conversation.view`,与 对话/轨迹 同级),采用**只读预览**:Markdown / HTML / JSON / CSV·TSV 专属渲染、图片预览、其余纯文本(不提供编辑保存)。并附带**模型吞吐徽标**(输入框正上方实时显示「正在等待模型...」与彩色 `t/s` 速率)与**GoalBar 多行展示**(把原生单行截断 GoalBar 放开为多行完整显示)。**三平台通用**(macOS / Linux / Windows)。
|
|
4
|
-
|
|
5
|
-
## 功能
|
|
6
|
-
|
|
7
|
-
### 文件标签页
|
|
8
|
-
- **入口**:资源管理器中右键「👁 预览」或双击文件,会话头部自动出现「预览」tab(原生 `conversation.view` 槽位,`ui-trajectory` 同款注册方式,不改 DSH 源码)。
|
|
9
|
-
- **多文件**:单个「文件」tab 内部子 tab 条管理多个已开文件;切走再切回内容不丢;子 tab 可单独关闭。
|
|
10
|
-
- **只读预览**:不提供编辑与保存(宿主也无 `write` 动作),所见即磁盘内容,⟳ 重载刷新。
|
|
11
|
-
- **Markdown 预览**:复用 DSH 客户端自带的不可信渲染器 `MarkdownText`(micromark 管线,禁不安全协议/相对链接;旧版宿主缺该模块时自动回退纯文本)。
|
|
12
|
-
- **HTML 预览**:可交互沙箱 iframe(`allow-scripts/popups/forms/modals`,**不给 `allow-same-origin`**——页面脚本跑在 opaque origin,触不到 GUI 的 DOM/存储/Cookie)+ `srcdoc` 注入 + `no-referrer`。
|
|
13
|
-
- **JSON 预览**:解析后 pretty-print + 轻量语法着色(键/字符串/数字/布尔/null);解析失败回退纯文本并提示。
|
|
14
|
-
- **CSV / TSV 预览**:RFC 4180 引号感知解析(内嵌逗号/换行/`""` 转义)、首行作表头(sticky)、最多预览 500 行 × 64 列并提示截断。
|
|
15
|
-
- **代码语法高亮**:常见语言(js/ts/jsx/tsx、python、go、rust、java、c/cpp、c#、ruby、php、kotlin、swift、shell、yaml/toml/ini、css/scss/less、sql、xml、lua 等)复用 DSH 的 `CodeBlock`(Shiki JS 引擎,主题跟随 `--shiki-*` token),扩展名映射对齐 DSH 读卡片;语法包懒加载——首帧可能纯文本,加载完成后自动补上高亮;超 40 万字符的大文件与未知扩展名回退纯文本。
|
|
16
|
-
- **PDF 预览**:整文件 base64(≤20MB)交给浏览器原生 PDF 查看器渲染——JupyterLab pdf-extension 同思路(base64 → Blob → object 嵌入,含缩放/搜索工具栏);超限提示用系统打开。
|
|
17
|
-
- **Notebook 预览(.ipynb)**:GitHub 风格静态渲染——markdown 单元走 `MarkdownText`、代码单元走 `CodeBlock` 高亮、gutter 显示执行序号;输出按 MIME 择优:image/png·jpeg·gif·svg 内联、text/html 沙箱 iframe(禁脚本)、stream/error 分别以输出块与红块 traceback 展示(剥离 ANSI);兼容 nbformat 4 与 v3(worksheets)。读取上限放宽至 20MB(含图输出的 base64 膨胀)。
|
|
18
|
-
- **会话隔离**:预览列表跟随当前会话工作目录;切换到其他项目的会话时,范围外的预览 tab 自动关闭。
|
|
19
|
-
- **FIFO 上限**:同时最多 5 个预览 tab,打开第 6 个时自动关闭最早的一个(先进先出)。
|
|
20
|
-
- **图片预览**:png/jpg/gif/webp/svg 等按 base64 内联展示(上限 8 MB)。
|
|
21
|
-
- **二进制**:NUL 嗅探判定后只读提示;文本超过 2 MB 截断展示并提示。
|
|
22
|
-
|
|
23
|
-
### 资源管理器
|
|
24
|
-
- **右侧详情列(details)文件树**:与会话区真正并列(三列网格 `侧栏 | 会话 | 资源管理器`),不悬浮、不遮挡。
|
|
25
|
-
- **宽度与左侧工作区完全一致**:默认 280px,拖左边缘在 264–420px 间调整(shell 自带 details 列被硬限制在 300–520px,本插件直接改写网格轨实现同宽)。
|
|
26
|
-
- **切换按钮**:有 Session log 时保持在「Session log」胶囊右侧;空白新会话则在输入框上方右端显示 📁,与 workspace / preset 控件同一水平线。
|
|
27
|
-
- **上传与分块**:拖入/选择多文件上传;超过 1MB 自动走分块通道(JupyterLab 同款语义:首块覆盖、后续追加),面板路径栏下方显示实时进度行(队列 n/m + 百分比),完成后短暂提示 ✓。
|
|
28
|
-
- **文件类型图标**:采用 JupyterLab `ui-components` 的 filetype SVG 集(BSD-3-Clause)——notebook 橙、markdown 紫、json 黄、pdf 红、表格绿等固定品牌色,16px 渲染;未命中扩展名按「已知代码语言 → 文本编辑器图标,其余 → 空白文件」兜底。
|
|
29
|
-
- **跟随会话**:默认展示当前会话工作目录(`session.header.cwd`),切换会话/工作区自动跟随;路径栏只读展示,不可跳转。
|
|
30
|
-
- **文件操作**(右键菜单):预览、用系统打开、重命名、移到废纸篓、复制完整路径、⬇ 下载到本机(菜单末项)——POST 换取一次性 2 分钟时效票据 URL;≤100MB 页面内 fetch 流式进度 + Blob 另存(进度行同上传),>100MB 浏览器原生下载(零内存,提示浏览器接管);空白处右键可刷新。
|
|
31
|
-
- **多选**:⌘/Ctrl 加选、Shift 连选;多选时批量移到废纸篓、批量拖拽移动。
|
|
32
|
-
- **拖拽**:文件/文件夹拖到目录移动;系统文件拖入上传。
|
|
33
|
-
- **交互**:单击选中、双击文件夹展开/收起、双击文件系统打开、⌘ 打开文件、F2/回车/Delete/Escape 等键位习惯(行内重命名选中扩展名前半段)。
|
|
34
|
-
|
|
35
|
-
### 模型吞吐徽标
|
|
36
|
-
- 输入框**正上方居中**显示,运行中(`llm/stream` 流期间)出现、结束后自动消失。
|
|
37
|
-
- **等待阶段**:`正在等待模型...` 灰色文字,省略号 `.`→`..`→`...` 原地循环(文字不滚动)。
|
|
38
|
-
- **流式阶段**:token 计数(向下箭头)+ 彩色 `xx.x t/s` 徽标(≥50 青 / ≥30 绿 / ≥15 黄 / 其余红)。
|
|
39
|
-
- **数据源**:Host 包 `llm/stream` waterfall 实时统计真实 chunk/token(优先 usage 的 `outputTokens`,退化 CJK 字符估算),比浏览器端估算更准。
|
|
40
|
-
|
|
41
|
-
### GoalBar 多行展示
|
|
42
|
-
- 在 DSH 原生 GoalBar 基础上**仅调整展示**:把单行截断(`text-overflow: ellipsis`)放开为**多行完整显示**(`white-space: pre-wrap`),编辑控件从 `input` 改为 `textarea`。不改 DSH 源码,用 `conversation.input.dock` 槽位同 id + 更低 priority 遮蔽默认实现。
|
|
43
|
-
- **数据与工具**:完全复用原生 goal projection 与原生 `create_goal` / `get_goal` / `update_goal` 工具,不替换、不遮蔽。
|
|
44
|
-
- **操作按钮**:暂停 / 恢复 / 编辑 / 清除(与原生 GoalBar 一致)。
|
|
45
|
-
- **轮次**:显示原生 goal projection 的 `roundsStarted/maxGoalRounds`。
|
|
46
|
-
- **编辑**:点击 ✏️ 进入 textarea 编辑,`Ctrl/⌘ + Enter` 保存、`Esc` 取消。
|
|
1
|
+
# dsh-soup
|
|
47
2
|
|
|
48
|
-
|
|
3
|
+
> DeepSeek Harness 本身是一大锅汤——营养丰富,但尝起来略显寡淡,不太好喝。dsh-soup 给它加上点 UI 调料,让它变得美味起来。
|
|
49
4
|
|
|
50
|
-
|
|
5
|
+
## 为什么叫 soup
|
|
51
6
|
|
|
52
|
-
|
|
53
|
-
# 安装最新版(npm 包名 @lyhue1991/dsh-soup)
|
|
54
|
-
dsh plugin add @lyhue1991/dsh-soup
|
|
55
|
-
```
|
|
7
|
+
DSH(DeepSeek Harness)功能很强:会话、工具、轨迹、Goal 一应俱全,营养都在汤里。但 UI 上略显寡淡——文件散在终端里、预览要跳出去、模型在跑你却看不到速度、长一点的目标被截成一行省略号。
|
|
56
8
|
|
|
57
|
-
|
|
9
|
+
**dsh-soup** 不重写 DSH,而是像下厨一样给这锅汤加几样调料:
|
|
58
10
|
|
|
59
|
-
|
|
11
|
+
- 🧅 **葱** —— 右侧文件树,提鲜点缀,让项目结构在会话旁长出来;
|
|
12
|
+
- 🫚 **姜** —— 会话内「预览」tab,去腥增香,Markdown / 代码 / 图片 / PDF / Notebook 就地看;
|
|
13
|
+
- 🧄 **蒜** —— 多行 GoalBar,味重提味,把那条总被截断的目标条熬成完整一行行;
|
|
14
|
+
- 🧂 **盐** —— 输入框上方的实时 `t/s` 速率徽标,百味之首,虽小却提味,实时知道模型跑得多快。
|
|
60
15
|
|
|
61
|
-
|
|
16
|
+
四样调料,一个插件,三平台通用(macOS / Linux / Windows)。装上之后——DSH 还是那锅汤,只是变得美味了。
|
|
62
17
|
|
|
63
|
-
|
|
18
|
+

|
|
64
19
|
|
|
65
|
-
|
|
66
|
-
- 宿主半区新增 `read`(文本截断读取 / 图片 base64 / NUL 二进制嗅探)动作,走 `node:fs/promises`。
|
|
67
|
-
- 单 tab 内子 tab 条管理多文件;只读预览(md/html/json/csv 渲染 + 纯文本兜底)、⟳ 重载;图片内联预览。
|
|
68
|
-
- 右键菜单第一项为「👁 预览」(标签页展示),原「🖥 用系统打开」保留为第二项。
|
|
20
|
+
## 速览
|
|
69
21
|
|
|
70
|
-
|
|
22
|
+
| 调料 | 是什么 | 一句话 |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| 🧅 右侧文件树 | 和左侧工作区并列的右列文件浏览器 | 提鲜点缀,结构在旁长出来 |
|
|
25
|
+
| 🫚 会话内预览 | 会话区顶部新增「预览」标签页 | 去腥增香,内容就地入味 |
|
|
26
|
+
| 🧄 多行 GoalBar | 不再被截断的多行目标条 | 味重提味,目标完整入味 |
|
|
27
|
+
| 🧂 速率徽标 | 输入框上方的彩色 `t/s` 指示 | 百味之首,小而提味 |
|
|
71
28
|
|
|
72
|
-
|
|
73
|
-
- 流式输出开始后立即显示 token 计数,不再因速率暖机窗口(0.5s)而停留在「正在等待模型...」(此前短输出全程显示等待)。
|
|
74
|
-
- 速率改为**滑动窗口瞬时值**(近 2s 采样),不再是从首 chunk 起的累计平均——中途停顿不会把 t/s 慢慢摊到个位数。
|
|
75
|
-
- 新增**停顿检测**:流中超过 2s 无新 chunk 时回到「正在等待模型...」。
|
|
29
|
+
---
|
|
76
30
|
|
|
77
|
-
|
|
31
|
+
## 它能给你什么
|
|
78
32
|
|
|
79
|
-
|
|
33
|
+
### 🧅 右侧文件树(主打 · 葱 · 提鲜点缀)
|
|
80
34
|
|
|
81
|
-
|
|
35
|
+
一个真正和左侧工作区并列的文件浏览器——不悬浮、不遮挡,配色与宽度都与左侧一致(默认 280px,拖左缘在 264–420px 间调整)。
|
|
82
36
|
|
|
83
|
-
-
|
|
84
|
-
-
|
|
37
|
+
- **跟随会话**:自动展示当前会话的工作目录,切会话/工作区时跟着走。
|
|
38
|
+
- **右键菜单**:预览、用系统打开、重命名、移到废纸篓、复制完整路径、下载到本机。
|
|
39
|
+
- **多选与拖拽**:⌘/Ctrl 加选、Shift 连选;文件拖进目录即移动;系统文件拖入即上传(>1MB 自动分块,带实时进度)。
|
|
40
|
+
- **类型图标**:按品牌色区分文件类型(notebook 橙、markdown 紫、json 黄、pdf 红…)。
|
|
41
|
+
- **沙箱围栏**:所有文件操作只能落在你的工作区与会话目录内,越界自动拒绝。
|
|
42
|
+
- **自动刷新**:每 3 秒轻量探测一次目录签名(mtime),有变化才重拉——agent 或终端里的文件增删改无需手动刷新即可出现;面板关闭且无预览时零开销;宿主不可达(如 DSH 重启中)自动指数退避(封顶 60s)。
|
|
43
|
+
- **一键刷新**:顶部 ⟳ 重拉整个文件树,已展开的子目录保持展开不收起。
|
|
85
44
|
|
|
86
|
-
###
|
|
45
|
+
### 🫚 会话内文件预览(主打 · 姜 · 去腥增香)
|
|
87
46
|
|
|
88
|
-
|
|
47
|
+
双击文件、或右键「👁 预览」,会话区顶部就会新增一个**「预览」标签页**,和「对话 / 轨迹」同级——看文件不用跳出当前会话。
|
|
89
48
|
|
|
90
|
-
-
|
|
49
|
+
- **多文件子标签**:单个「预览」tab 内用子标签条管理多个文件,切走再切回内容不丢;最多同时开 5 个,超出按先进先出挤掉最早的。
|
|
50
|
+
- **只读预览**(⟳ 可重载,不提供编辑/保存),按类型自动选渲染方式:
|
|
51
|
+
- **Markdown**:渲染为富文本(复用 DSH 聊天同款渲染器)。
|
|
52
|
+
- **HTML**:可交互沙箱(脚本被隔离,碰不到 GUI 的 DOM/存储)。
|
|
53
|
+
- **JSON**:格式化 + 语法着色。
|
|
54
|
+
- **CSV / TSV**:表格视图(引号感知、首行作表头、最多 500 行 × 64 列)。
|
|
55
|
+
- **代码**:常见语言语法高亮(Shiki,主题跟随 DSH 明暗)。
|
|
56
|
+
- **PDF**:浏览器原生查看器(≤20MB,含缩放/搜索)。
|
|
57
|
+
- **Notebook(.ipynb)**:GitHub 风格静态渲染(代码高亮 + 图片/输出内联)。
|
|
58
|
+
- **图片**:png/jpg/gif/webp/svg 等内联展示(≤8MB)。
|
|
59
|
+
- **其余**:二进制给出提示,超大文本截断为纯文本。
|
|
60
|
+
- **跟随会话**:切到别的项目时,范围外的预览自动关闭。
|
|
61
|
+
- **自动重读**:正在看的文件被外部(agent / 编辑器)修改时,预览内容几秒内自动更新,无需手动点「⟳ 重载」。
|
|
91
62
|
|
|
92
|
-
###
|
|
63
|
+
### 🧄 多行 GoalBar(附赠 · 蒜 · 味重提味)
|
|
93
64
|
|
|
94
|
-
|
|
95
|
-
- **模型吞吐徽标**:输入框正上方显示「正在等待模型...」(省略号 1→2→3 循环)与彩色 `t/s` 徽标,数据来自 `llm/stream` 真实流。
|
|
65
|
+
把 DSH 原生那条总是被截断成一行、末尾 `…` 的 GoalBar,放开成**多行完整显示**,编辑控件也从单行 `input` 换成 `textarea`。
|
|
96
66
|
|
|
97
|
-
|
|
67
|
+
- **数据与工具完全复用原生**:goal 的增删改查仍走 DSH 原生 `create_goal` / `get_goal` / `update_goal`,不替换、不遮蔽数据。
|
|
68
|
+
- **操作一致**:暂停 / 恢复 / 编辑 / 清除按钮与原生 GoalBar 相同;编辑时 `Ctrl/⌘ + Enter` 保存、`Esc` 取消。
|
|
69
|
+
- **不改 DSH 源码**:以更低优先级遮蔽默认实现,主题跟随桌面明暗。
|
|
98
70
|
|
|
99
|
-
|
|
100
|
-
- 修复浏览器半区的布局实例引用,供文件树面板的布局操作正常使用。
|
|
71
|
+
### 🧂 模型实时速率徽标(附赠 · 盐 · 百味之首)
|
|
101
72
|
|
|
102
|
-
|
|
73
|
+
输入框正上方的一枚小徽标,模型生成时出现、结束自动消失——让你对「现在到底在等还是在跑、跑得多快」有直观感受。
|
|
74
|
+
|
|
75
|
+
- **等待阶段**:灰色「正在等待模型...」(省略号 1→2→3 原地循环)。
|
|
76
|
+
- **流式阶段**:token 计数 + 彩色 `t/s` 速率(≥50 青 / ≥30 绿 / ≥15 黄 / 其余红)。
|
|
77
|
+
- **数据可信**:来自模型真实流(不是浏览器端估算);用近 2 秒滑动窗口算瞬时速率,中途停顿会回到等待态,不会被摊成个位数。
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## 安装
|
|
82
|
+
|
|
83
|
+
> 需要 DSH Desktop(或任何支持 `dsh plugin add` 的 DSH 安装)。
|
|
103
84
|
|
|
104
85
|
```sh
|
|
105
|
-
|
|
106
|
-
cd dsh-soup
|
|
107
|
-
# 纯 JS 产物,无需构建:
|
|
108
|
-
# lib/index.js —— 宿主半区(webServer 路由 /api/dsh-soup,含 llm/stream 统计)
|
|
109
|
-
# lib/client.js —— 浏览器半区(__ModuleLoader__ 工厂,内联 CSS)
|
|
86
|
+
dsh plugin add @lyhue1991/dsh-soup
|
|
110
87
|
```
|
|
111
88
|
|
|
112
|
-
|
|
89
|
+
或在 DSH Desktop「设置 → 插件 → 插件市场」搜索 **dsh-soup** 安装。安装后**重启 DSH** 即可生效。
|
|
113
90
|
|
|
114
|
-
##
|
|
91
|
+
## 上手
|
|
115
92
|
|
|
116
|
-
-
|
|
117
|
-
-
|
|
118
|
-
-
|
|
119
|
-
-
|
|
120
|
-
|
|
121
|
-
|
|
93
|
+
- **打开文件树**:点击 Session log 右侧的 📁;空白新会话则在输入框上方右端找 📁。
|
|
94
|
+
- **预览文件**:在文件树里双击文件,或右键选「👁 预览」。
|
|
95
|
+
- **看速率**:向任意会话发一条消息,输入框上方即出现徽标。
|
|
96
|
+
- **多行 GoalBar**:创建 goal 后自动以多行展示。
|
|
97
|
+
|
|
98
|
+
## 更新日志
|
|
122
99
|
|
|
123
|
-
|
|
100
|
+
- **0.3.0(当前)** —
|
|
101
|
+
- **Markdown 预览渲染相对路径图片**:`` 经同源 `/api/dsh-soup/img` 端点内联加载(confine 围栏 + 仅图片类型 + 拒跨站)。
|
|
102
|
+
- **自动刷新**:资源管理器与预览页每 3 秒轻量探测目录/文件签名(mtime),变了才重拉;面板关闭且无预览时零开销;宿主不可达时指数退避(封顶 60s)。
|
|
103
|
+
- **顶部 ⟳ 刷新按钮**:重拉整棵树并保持已展开子目录不收起;空白右键「刷新」项同步升级为同一枚图标。
|
|
104
|
+
- **工具栏按钮升级**:头部 ⟳/⬆/✕ 与预览「重载」对齐 JupyterLab 设计(26px 触达区 + SVG 图标,BSD-3 引用);修复关闭按钮渲染异常。
|
|
105
|
+
- **0.2.0** — 改名 `dsh-tree → dsh-soup`(npm 包 `@lyhue1991/dsh-soup`)。
|
|
106
|
+
- **0.1.18** — 下载进度行 + 完成通知自动消失。
|
|
107
|
+
- **0.1.17** — 大文件流式下载(一次性票据 URL + 流式发送),替代 base64-over-JSON,任意大小零内存放大。
|
|
108
|
+
- **0.1.16** — 下载菜单项 + 复制完整路径修复 + 目录展开态图标。
|
|
109
|
+
- **0.1.15** — 只读文件预览系统:会话区新增「预览」tab(md/html/json/csv·tsv 渲染 + 图片/PDF/Notebook/代码高亮 + 纯文本兜底);宿主新增 `read` 动作;路由安全加固。
|
|
110
|
+
- **0.1.12** — 吞吐徽标状态修复:流式即显 token 计数、滑动窗口瞬时 `t/s`、停顿检测回等待态。
|
|
111
|
+
- **0.1.11** — 空白新会话入口(输入框上方右端 📁)。
|
|
112
|
+
- **0.1.10** — GoalBar 改用原生 goal 工具 + 多行展示(textarea)。
|
|
113
|
+
- **0.1.7** — 速度徽标稳定化(贴附 Deep diving 状态行)。
|
|
114
|
+
- **0.1.5** — 跨平台:文件核心走 `node:fs`;新增模型吞吐徽标。
|
|
115
|
+
- **0.1.3** — 修复 details 插槽优先级与布局实例引用。
|
|
124
116
|
|
|
125
|
-
|
|
126
|
-
- **路径围栏**:除 `list` 走沙箱 `fs.resolve` 外,所有具名文件操作(`read`/`move`/`trash`/`open`/`create`/`upload`)先经 `confine()`:realpath 解析后必须落在「workspace 根 ∪ 所有已知会话 cwd」子树内(请求可携带 sessionId,供新会话注册滞后时直查其 cwd 兜底;list/read/download 遇竞态 403 自动重试),越界返回 403。realpath 解析可防符号链接逃逸,且容忍目标末级尚不存在(新建/上传/移动目的地)。已知残留:confine 与实际操作之间存在理论上的 TOCTOU 窗口(符号链接竞态),Node 可移植 API 无法根除。
|
|
127
|
-
- **文件名校验**:`create` 与 `upload` 的 `name` 一律拒绝 `.`、`..`、路径分隔符与 NUL,杜绝 `../` 穿越。
|
|
117
|
+
## 开发
|
|
128
118
|
|
|
129
|
-
|
|
119
|
+
纯 JS 产物,无需构建:
|
|
130
120
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
| Linux | `xdg-open` | `gio trash`(回退 `trash-cli`) | `node:fs` |
|
|
135
|
-
| Windows | `cmd /c start` | PowerShell `Shell.Application`(回收站) | `node:fs` |
|
|
121
|
+
- `lib/index.js` — 宿主半区(注册 `/api/dsh-soup` 文件操作路由 + `llm/stream` 速率统计)。
|
|
122
|
+
- `lib/client.js` — 浏览器半区(右侧文件树面板 + 预览 tab + 速率徽标 + 多行 GoalBar,内联 CSS)。
|
|
123
|
+
- `cordis.patch.yml` — 在组合中插入 `ui-dsh-soup`。
|
|
136
124
|
|
|
137
|
-
|
|
125
|
+
```sh
|
|
126
|
+
git clone https://github.com/lyhue1991/dsh-soup.git
|
|
127
|
+
cd dsh-soup
|
|
128
|
+
```
|
|
138
129
|
|
|
139
130
|
## 许可证
|
|
140
131
|
|
package/lib/client.js
CHANGED
|
@@ -53,8 +53,9 @@ window.__ModuleLoader__.load({
|
|
|
53
53
|
'.expl-head{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 14px;border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;}' +
|
|
54
54
|
'.expl-title{font-weight:600;color:var(--dsw-alias-label-primary);white-space:nowrap;font-size:14px;}' +
|
|
55
55
|
'.expl-head-btns{display:flex;gap:2px;flex-wrap:nowrap;}' +
|
|
56
|
-
'.expl-btn{background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-size:15px;cursor:pointer;padding:
|
|
56
|
+
'.expl-btn{width:26px;height:26px;display:flex;align-items:center;justify-content:center;background:transparent;border:none;color:var(--dsw-alias-label-secondary);font-size:15px;cursor:pointer;padding:0;border-radius:8px;flex:none;}' +
|
|
57
57
|
'.expl-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);}' +
|
|
58
|
+
'.expl-btn svg{width:16px;height:16px;display:block;}' +
|
|
58
59
|
'.expl-path{font-size:11px;color:var(--dsw-alias-label-secondary);padding:7px 14px;border-bottom:1px solid var(--dsw-alias-border-l1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;direction:rtl;text-align:left;flex:none;cursor:default;}' +
|
|
59
60
|
'.expl-error{color:var(--dsw-alias-state-error-primary);padding:6px 14px;font-size:12px;border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;}' +
|
|
60
61
|
'.expl-notice{color:var(--dsw-alias-state-success-primary);padding:6px 14px;font-size:12px;border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;}' +
|
|
@@ -78,7 +79,8 @@ window.__ModuleLoader__.load({
|
|
|
78
79
|
'.expl-muted{color:var(--dsw-alias-label-tertiary);padding:4px 14px;font-size:12px;}' +
|
|
79
80
|
'.expl-menu-mask{position:fixed;inset:0;z-index:1980;}' +
|
|
80
81
|
'.expl-menu{position:fixed;z-index:1990;min-width:172px;background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-inverted);border-radius:12px;padding:4px;box-shadow:var(--dsw-shadow-lv3);pointer-events:auto;}' +
|
|
81
|
-
'.expl-menu-item{display:
|
|
82
|
+
'.expl-menu-item{display:flex;align-items:center;gap:7px;width:100%;text-align:left;background:transparent;border:none;color:var(--dsw-alias-label-primary);font-size:13px;padding:6px 10px;border-radius:8px;cursor:pointer;line-height:1.4;}' +
|
|
83
|
+
'.expl-menu-ico{flex:none;display:inline-flex;align-items:center;}.expl-menu-ico svg{width:14px;height:14px;display:block;}' +
|
|
82
84
|
'.expl-menu-item:hover{background:var(--dsw-alias-interactive-bg-hover);}' +
|
|
83
85
|
'.expl-danger{color:var(--dsw-alias-state-error-primary);} .expl-menu-item.expl-danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger);}' +
|
|
84
86
|
'.expl-menu-sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l1);}' +
|
|
@@ -97,6 +99,14 @@ window.__ModuleLoader__.load({
|
|
|
97
99
|
document.head.appendChild(tag)
|
|
98
100
|
}
|
|
99
101
|
|
|
102
|
+
// ------------------------------------------------------------------
|
|
103
|
+
// 工具栏图标:取自 JupyterLab ui-components(BSD-3-Clause,与文件类型
|
|
104
|
+
// 图标同源的引用方式),fill 改为 currentColor 以跟随 DSH 主题 token。
|
|
105
|
+
// 注意:只引入刷新图标——ICON_CLOSE / ICON_UPLOAD 与 GoalBar 段的
|
|
106
|
+
// 同名 React 元素常量冲突(var 提升覆盖),字符样式反而更稳。
|
|
107
|
+
// ------------------------------------------------------------------
|
|
108
|
+
var ICON_REFRESH = '<svg xmlns="http://www.w3.org/2000/svg" width="16" viewBox="0 0 18 18"><path fill="currentColor" d="M9 13.5c-2.49 0-4.5-2.01-4.5-4.5S6.51 4.5 9 4.5c1.24 0 2.36.52 3.17 1.33L10 8h5V3l-1.76 1.76A6 6 0 0 0 9 3C5.69 3 3.01 5.69 3.01 9S5.69 15 9 15a5.98 5.98 0 0 0 5.9-5h-1.52c-.46 2-2.24 3.5-4.38 3.5"/></svg>'
|
|
109
|
+
|
|
100
110
|
// GoalBar 多行版:沿用 DSH 原生 GoalBar 的结构和主题,仅放开单行截断。
|
|
101
111
|
// 布局分两行:head 行(图标 + 相位标签 + 操作按钮),body 行(objective 多行)。
|
|
102
112
|
var GOAL_CSS =
|
|
@@ -137,8 +147,9 @@ window.__ModuleLoader__.load({
|
|
|
137
147
|
'.dfv-body{flex:1;min-height:0;display:flex;flex-direction:column;background:var(--dsw-alias-bg-layer-1);}' +
|
|
138
148
|
'.dfv-toolbar{display:flex;align-items:center;gap:8px;flex:none;padding:6px 12px;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:11px;color:var(--dsw-alias-label-secondary);}' +
|
|
139
149
|
'.dfv-toolbar-path{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left;font-family:var(--ds-font-family-code);}' +
|
|
140
|
-
'.dfv-btn{flex:none;background:transparent;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);font-size:
|
|
150
|
+
'.dfv-btn{flex:none;display:inline-flex;align-items:center;gap:5px;height:26px;box-sizing:border-box;background:transparent;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-primary);font-size:12px;line-height:1;padding:0 10px;border-radius:7px;cursor:pointer;}' +
|
|
141
151
|
'.dfv-btn:hover{background:var(--dsw-alias-interactive-bg-hover);}' +
|
|
152
|
+
'.dfv-btn svg{width:14px;height:14px;display:block;flex:none;}' +
|
|
142
153
|
'.dfv-error{padding:8px 14px;font-size:12px;color:var(--dsw-alias-state-error-primary);flex:none;}' +
|
|
143
154
|
'.dfv-muted{padding:24px 14px;font-size:13px;color:var(--dsw-alias-label-tertiary);text-align:center;}' +
|
|
144
155
|
'.dfv-cap{padding:5px 14px;font-size:11px;color:var(--dsw-alias-label-caption);border-bottom:1px solid var(--dsw-alias-border-l1);flex:none;}' +
|
|
@@ -281,6 +292,7 @@ window.__ModuleLoader__.load({
|
|
|
281
292
|
var cwd = await defaultCwd(sessionId, cwdHint)
|
|
282
293
|
// 会话切换:预览列表按新 cwd 裁剪(范围外自动关闭),并清掉上一路径的旧错误横幅
|
|
283
294
|
var files = pruneFilesToScope(state.files, cwd)
|
|
295
|
+
lastMtimes = {} // 换了目录,旧 mtime 基线全部作废(首轮 tick 重建基线)
|
|
284
296
|
setState({ cwd: cwd, selected: new Set(), renaming: null, newItem: null, menu: null, error: '', notice: '', files: files })
|
|
285
297
|
var items = await loadDir(cwd)
|
|
286
298
|
if (items) setState({ error: '', tree: items })
|
|
@@ -292,6 +304,150 @@ window.__ModuleLoader__.load({
|
|
|
292
304
|
if (items) setState({ error: '', tree: items })
|
|
293
305
|
}
|
|
294
306
|
|
|
307
|
+
// ------------------------------------------------------------------
|
|
308
|
+
// 自动刷新:低频轮询宿主 mtime 探测(3s 一次),签名变了才真正重拉。
|
|
309
|
+
// watch 范围 = 当前 cwd + 已展开目录 + 打开的预览文件;面板关闭且无
|
|
310
|
+
// 预览时整个 tick 直接返回(零请求)。不做文件 watcher——fs.watch
|
|
311
|
+
// 递归语义跨平台不一致且大工作区开销高,stat 几个路径便宜得多。
|
|
312
|
+
// ------------------------------------------------------------------
|
|
313
|
+
var AUTO_REFRESH_MS = 3000
|
|
314
|
+
var AUTO_WATCH_MAX = 64
|
|
315
|
+
var lastMtimes = {} // path -> {m,s,d}|null;undefined = 尚无基线(首轮只建基线不触发)
|
|
316
|
+
var autoTicking = false
|
|
317
|
+
|
|
318
|
+
function collectWatchPaths() {
|
|
319
|
+
var paths = []
|
|
320
|
+
if (state.cwd) paths.push(state.cwd)
|
|
321
|
+
var walk = function (nodes) {
|
|
322
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
323
|
+
var n = nodes[i]
|
|
324
|
+
if (n.type === 'directory' && n.children != null) {
|
|
325
|
+
paths.push(n.path)
|
|
326
|
+
if (n.open && n.children.length) walk(n.children)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
walk(state.tree)
|
|
331
|
+
for (var j = 0; j < state.files.list.length; j++) {
|
|
332
|
+
var e = state.files.list[j]
|
|
333
|
+
if (e.path && paths.indexOf(e.path) < 0) paths.push(e.path)
|
|
334
|
+
}
|
|
335
|
+
return paths.slice(0, AUTO_WATCH_MAX)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 预览文件在磁盘上变了:静默重读该 entry(不动激活状态、不闪 tab)。 */
|
|
339
|
+
async function silentReloadEntry(entry) {
|
|
340
|
+
var res = await rpc('read', { path: entry.path, sessionId: activeSessionId })
|
|
341
|
+
if (!res || !res.ok) return
|
|
342
|
+
entry.kind = res.kind
|
|
343
|
+
entry.size = res.size || 0
|
|
344
|
+
if (res.kind === 'text') { entry.content = res.content; entry.truncated = !!res.truncated }
|
|
345
|
+
else if (res.kind === 'image') { entry.dataUrl = 'data:' + res.mime + ';base64,' + res.data }
|
|
346
|
+
else if (res.kind === 'pdf') { entry.data = res.data }
|
|
347
|
+
setFiles({ list: state.files.list.slice() })
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** 已展开目录的子项变了:重拉该目录(保持展开态)。 */
|
|
351
|
+
async function reloadDirNode(node) {
|
|
352
|
+
var items = await loadDir(node.path)
|
|
353
|
+
if (items) items.forEach(function (c) { c.parent = node })
|
|
354
|
+
node.children = items || []
|
|
355
|
+
node.open = true
|
|
356
|
+
setState({ tree: state.tree })
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 探一轮。返回 'idle'(本轮没发请求)/ true(探测成功)/ false(请求失败)。
|
|
361
|
+
* 只有真正发出去且失败的请求才累积退避;空闲不累积——面板重开时本就有
|
|
362
|
+
* 一次全量 loadDir,无需为"关着的时候宿主是否恢复"操心。
|
|
363
|
+
*/
|
|
364
|
+
async function autoRefreshTick() {
|
|
365
|
+
if (autoTicking) return 'idle'
|
|
366
|
+
if (!state.open && state.files.list.length === 0) return 'idle'
|
|
367
|
+
// 用户正在操作(重命名/新建/右键菜单)时跳过本轮,避免打断输入
|
|
368
|
+
if (state.renaming || state.newItem || state.menu) return 'idle'
|
|
369
|
+
var paths = collectWatchPaths()
|
|
370
|
+
if (!paths.length) return 'idle'
|
|
371
|
+
autoTicking = true
|
|
372
|
+
try {
|
|
373
|
+
var res = await rpc('mtime', { paths: paths })
|
|
374
|
+
if (!res || !res.ok) return false
|
|
375
|
+
var mtimes = res.mtimes || {}
|
|
376
|
+
var dirtyDirs = []
|
|
377
|
+
var dirtyFiles = []
|
|
378
|
+
for (var i = 0; i < paths.length; i++) {
|
|
379
|
+
var p = paths[i]
|
|
380
|
+
var now = Object.prototype.hasOwnProperty.call(mtimes, p) ? mtimes[p] : null
|
|
381
|
+
var prev = Object.prototype.hasOwnProperty.call(lastMtimes, p) ? lastMtimes[p] : undefined
|
|
382
|
+
lastMtimes[p] = now
|
|
383
|
+
if (prev === undefined || prev === null || now === null) continue
|
|
384
|
+
if (now.m === prev.m && now.s === prev.s) continue
|
|
385
|
+
if (now.d) dirtyDirs.push(p)
|
|
386
|
+
else dirtyFiles.push(p)
|
|
387
|
+
}
|
|
388
|
+
for (var d = 0; d < dirtyDirs.length; d++) {
|
|
389
|
+
var dp = dirtyDirs[d]
|
|
390
|
+
if (dp === state.cwd) { await refresh(); continue }
|
|
391
|
+
var node = findNode(state.tree, dp)
|
|
392
|
+
if (node && node.children != null) await reloadDirNode(node)
|
|
393
|
+
}
|
|
394
|
+
for (var f = 0; f < dirtyFiles.length; f++) {
|
|
395
|
+
var entry = findFileEntry(dirtyFiles[f])
|
|
396
|
+
if (entry && entry.loaded) await silentReloadEntry(entry)
|
|
397
|
+
}
|
|
398
|
+
return true
|
|
399
|
+
} finally {
|
|
400
|
+
autoTicking = false
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// 失败退避:宿主不可达(DSH 重启中 / 插件重载 / 断连)时别每 3s 硬打——
|
|
405
|
+
// 指数退避 3s→6s→12s→24s→48s,封顶 60s;一旦成功回到 3s。
|
|
406
|
+
// (JupyterLab FileBrowserModel 的 Poll 同思路:backoff + max 封顶。)
|
|
407
|
+
// 实现:1s 固定心跳 + 「到期才探测」,而不是变间隔定时器——宿主提供的
|
|
408
|
+
// timer 服务可 dispose,测试环境(interval 为 no-op mock)也不会挂住进程。
|
|
409
|
+
var AUTO_HEARTBEAT_MS = 1000
|
|
410
|
+
var AUTO_BACKOFF_MAX_MS = 60000
|
|
411
|
+
var autoFailCount = 0
|
|
412
|
+
var autoLastProbeAt = 0
|
|
413
|
+
|
|
414
|
+
function autoDelayMs() {
|
|
415
|
+
if (autoFailCount <= 0) return AUTO_REFRESH_MS
|
|
416
|
+
return Math.min(AUTO_REFRESH_MS * Math.pow(2, autoFailCount), AUTO_BACKOFF_MAX_MS)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function autoHeartbeat() {
|
|
420
|
+
var now = Date.now()
|
|
421
|
+
if (now - autoLastProbeAt < autoDelayMs()) return
|
|
422
|
+
autoLastProbeAt = now
|
|
423
|
+
autoRefreshTick().then(function (outcome) {
|
|
424
|
+
if (outcome === false) autoFailCount++
|
|
425
|
+
else autoFailCount = 0
|
|
426
|
+
})
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** 手动刷新:重拉根目录,已展开的子目录逐个重拉、保持展开不收起。 */
|
|
430
|
+
async function refreshAll() {
|
|
431
|
+
if (!state.cwd) return
|
|
432
|
+
var expanded = []
|
|
433
|
+
var walk = function (nodes) {
|
|
434
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
435
|
+
var n = nodes[i]
|
|
436
|
+
if (n.type === 'directory' && n.children != null) {
|
|
437
|
+
if (n.path !== state.cwd) expanded.push({ path: n.path, open: !!n.open })
|
|
438
|
+
if (n.open && n.children.length) walk(n.children)
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
walk(state.tree)
|
|
443
|
+
await refresh()
|
|
444
|
+
for (var j = 0; j < expanded.length; j++) {
|
|
445
|
+
if (!expanded[j].open) continue
|
|
446
|
+
var node = findNode(state.tree, expanded[j].path)
|
|
447
|
+
if (node) await reloadDirNode(node)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
295
451
|
async function toggleNode(node) {
|
|
296
452
|
if (node.type !== 'directory') return
|
|
297
453
|
if (node.children == null) {
|
|
@@ -951,7 +1107,7 @@ window.__ModuleLoader__.load({
|
|
|
951
1107
|
} else {
|
|
952
1108
|
items.push({ key: 'newfile', label: '📄 新建文件', onClick: function () { startNew(state.cwd, false) } })
|
|
953
1109
|
items.push({ key: 'newfolder', label: '📁 新建文件夹', onClick: function () { startNew(state.cwd, true) } })
|
|
954
|
-
items.push({ key: 'refresh', label: '
|
|
1110
|
+
items.push({ key: 'refresh', label: [create('span', { className: 'expl-menu-ico', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }), '刷新'], onClick: refresh, separatorAfter: true })
|
|
955
1111
|
if (state.selected.size > 0) items.push({ key: 'none', label: '取消选择', onClick: function () { setState({ selected: new Set(), lastIndex: null }) } })
|
|
956
1112
|
}
|
|
957
1113
|
return items
|
|
@@ -1100,13 +1256,46 @@ window.__ModuleLoader__.load({
|
|
|
1100
1256
|
var CSV_MAX_ROWS = 500
|
|
1101
1257
|
var CSV_MAX_COLS = 64
|
|
1102
1258
|
|
|
1259
|
+
// ------------------------------------------------------------------
|
|
1260
|
+
// Markdown 相对图片 → dsh-soup 同源 img 端点绝对 URL。
|
|
1261
|
+
// 预览 markdown 时,DSH 的 MarkdownText 渲染器「禁相对链接/相对资源」,
|
|
1262
|
+
// `` 这类相对路径图片会被直接丢弃。因此把 src 改写成
|
|
1263
|
+
// 本插件同名端点(GET /api/dsh-soup/img)的同源绝对地址,交给 <img>
|
|
1264
|
+
// 从磁盘内联加载。绝对(http/https/data 等)与根相对路径放行不处理。
|
|
1265
|
+
// ------------------------------------------------------------------
|
|
1266
|
+
var MD_IMG_RE = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+["'][^"']*["'])?\)/g
|
|
1267
|
+
function posixResolve(dir, rel) {
|
|
1268
|
+
var combined = (String(dir || '/').replace(/\/+$/, '') || '/') + '/' + String(rel)
|
|
1269
|
+
var out = []
|
|
1270
|
+
combined.split('/').forEach(function (seg) {
|
|
1271
|
+
if (seg === '' || seg === '.') return
|
|
1272
|
+
if (seg === '..') { if (out.length) out.pop(); return }
|
|
1273
|
+
out.push(seg)
|
|
1274
|
+
})
|
|
1275
|
+
return '/' + out.join('/')
|
|
1276
|
+
}
|
|
1277
|
+
function imgUrl(absPath) {
|
|
1278
|
+
return hostBase() + '/api/dsh-soup/img?p=' + encodeURIComponent(absPath)
|
|
1279
|
+
}
|
|
1280
|
+
function absolutizeMarkdownImages(text, baseDir) {
|
|
1281
|
+
if (!text) return text
|
|
1282
|
+
var dir = baseDir || '/'
|
|
1283
|
+
return text.replace(MD_IMG_RE, function (m0, alt, src) {
|
|
1284
|
+
var s = String(src || '').trim()
|
|
1285
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|#|\/)/i.test(s)) return m0
|
|
1286
|
+
return ') + ')'
|
|
1287
|
+
})
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1103
1290
|
function MarkdownPreview(props) {
|
|
1291
|
+
var text = props.text
|
|
1104
1292
|
if (MarkdownText) {
|
|
1293
|
+
var md = props.baseDir ? absolutizeMarkdownImages(text, props.baseDir) : text
|
|
1105
1294
|
return create('div', { className: 'dfv-md-wrap' },
|
|
1106
|
-
create('div', { className: 'dfv-md' }, create(MarkdownText, { text:
|
|
1295
|
+
create('div', { className: 'dfv-md' }, create(MarkdownText, { text: md })))
|
|
1107
1296
|
}
|
|
1108
1297
|
// 回退:宿主缺 ui-primitives 时按纯文本展示
|
|
1109
|
-
return create('pre', { className: 'dfv-code' },
|
|
1298
|
+
return create('pre', { className: 'dfv-code' }, text)
|
|
1110
1299
|
}
|
|
1111
1300
|
|
|
1112
1301
|
function HtmlPreview(props) {
|
|
@@ -1358,7 +1547,7 @@ window.__ModuleLoader__.load({
|
|
|
1358
1547
|
// 文本类:按扩展名选预览渲染器(纯只读,无编辑)
|
|
1359
1548
|
var pk = previewKind(entry.path)
|
|
1360
1549
|
var view = null
|
|
1361
|
-
if (pk === 'markdown') view = create(MarkdownPreview, { text: entry.content })
|
|
1550
|
+
if (pk === 'markdown') view = create(MarkdownPreview, { text: entry.content, baseDir: parentOf(entry.path) })
|
|
1362
1551
|
else if (pk === 'html') view = create(HtmlPreview, { text: entry.content, title: entry.name })
|
|
1363
1552
|
else if (pk === 'json') view = create(JsonPreview, { text: entry.content })
|
|
1364
1553
|
else if (pk === 'notebook') view = create(NotebookPreview, { text: entry.content })
|
|
@@ -1420,7 +1609,10 @@ window.__ModuleLoader__.load({
|
|
|
1420
1609
|
className: 'dfv-btn',
|
|
1421
1610
|
title: '从磁盘重新读取(刷新预览)',
|
|
1422
1611
|
onClick: function () { reloadFile(active.path) },
|
|
1423
|
-
},
|
|
1612
|
+
},
|
|
1613
|
+
create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: ICON_REFRESH } }),
|
|
1614
|
+
'重载',
|
|
1615
|
+
) : null,
|
|
1424
1616
|
),
|
|
1425
1617
|
create(FileContent, { key: active.path, entry: active }),
|
|
1426
1618
|
)
|
|
@@ -1585,9 +1777,21 @@ window.__ModuleLoader__.load({
|
|
|
1585
1777
|
create('div', { className: 'expl-head' },
|
|
1586
1778
|
create('span', { className: 'expl-title' }, '📁 资源管理器'),
|
|
1587
1779
|
create('div', { className: 'expl-head-btns' },
|
|
1588
|
-
create('button', { className: 'expl-btn', onClick: function () { if (uploadInputEl) uploadInputEl.click() }, title: '上传文件' }, '⬆'),
|
|
1589
1780
|
create('button', {
|
|
1590
1781
|
className: 'expl-btn',
|
|
1782
|
+
onClick: function () { refreshAll() },
|
|
1783
|
+
title: '刷新(保持已展开目录)',
|
|
1784
|
+
'aria-label': '刷新',
|
|
1785
|
+
}, create('span', { className: 'expl-icon', dangerouslySetInnerHTML: { __html: ICON_REFRESH } })),
|
|
1786
|
+
create('button', {
|
|
1787
|
+
className: 'expl-btn',
|
|
1788
|
+
onClick: function () { if (uploadInputEl) uploadInputEl.click() },
|
|
1789
|
+
title: '上传文件',
|
|
1790
|
+
'aria-label': '上传文件',
|
|
1791
|
+
}, '⬆'),
|
|
1792
|
+
create('button', {
|
|
1793
|
+
className: 'expl-btn',
|
|
1794
|
+
style: { fontSize: '18px' },
|
|
1591
1795
|
onClick: function (event) {
|
|
1592
1796
|
try { layout.closeDetails() } catch (e) {}
|
|
1593
1797
|
setHeroDetailsWidth(event.currentTarget, 0)
|
|
@@ -2026,6 +2230,12 @@ window.__ModuleLoader__.load({
|
|
|
2026
2230
|
var sessions = ctx.sessions
|
|
2027
2231
|
var remoteGoals = ctx.remote.goals
|
|
2028
2232
|
var disposers = []
|
|
2233
|
+
// 自动刷新心跳:1s 固定心跳 + 到期才探测(基础 3s;宿主不可达时指数退避,封顶 60s)。
|
|
2234
|
+
// 面板关且无预览时 tick 内直接返回(零请求);首个 tick 只建基线。
|
|
2235
|
+
if (timerRef && typeof timerRef.interval === 'function') {
|
|
2236
|
+
var stopAuto = timerRef.interval(function () { autoHeartbeat() }, AUTO_HEARTBEAT_MS)
|
|
2237
|
+
disposers.push(function () { try { stopAuto() } catch (e) {} })
|
|
2238
|
+
}
|
|
2029
2239
|
slots.inject('conversation.session.header.utilities', function () {
|
|
2030
2240
|
disposers.push(slots.register(
|
|
2031
2241
|
{ name: 'conversation.session.header.utilities', id: 'dsh-soup-toggle', order: 10, label: '资源管理器' },
|
package/lib/index.js
CHANGED
|
@@ -533,6 +533,65 @@ export function apply(ctx) {
|
|
|
533
533
|
'dsh-soup: streaming download route',
|
|
534
534
|
)
|
|
535
535
|
|
|
536
|
+
/**
|
|
537
|
+
* 图片内联端点:GET /api/dsh-soup/img?p=<路径>。
|
|
538
|
+
* 浏览器端 Markdown 预览会把「相对路径图片」改写为这里的绝对 URL,
|
|
539
|
+
* 由 <img> 内联加载真实图片字节(JupyterLab md 图片同思路)。只读、无
|
|
540
|
+
* Content-Disposition(内联显示而非下载)。
|
|
541
|
+
* 安全模型:confine 围栏(只读工作区/会话目录)+ 仅图片类型 + 大小上限 +
|
|
542
|
+
* 不返回任何 CORS 头 + 拒绝跨站(Sec-Fetch-Site: cross-site 与不同源 Origin)。
|
|
543
|
+
* 注意 <img> 只能确认「是否 200/404」,无法读取像素,跨站利用面很小。
|
|
544
|
+
*/
|
|
545
|
+
async function imageHandler(req, res) {
|
|
546
|
+
try {
|
|
547
|
+
const site = String(req.headers['sec-fetch-site'] || '')
|
|
548
|
+
if (site === 'cross-site') {
|
|
549
|
+
writeJson(res, 403, { ok: false, error: '跨站图片请求已拒绝' })
|
|
550
|
+
return
|
|
551
|
+
}
|
|
552
|
+
const origin = req.headers.origin
|
|
553
|
+
if (typeof origin === 'string' && origin !== '') {
|
|
554
|
+
let originHost = ''
|
|
555
|
+
try { originHost = new URL(origin).host } catch { originHost = '' }
|
|
556
|
+
const host = typeof req.headers.host === 'string' ? req.headers.host : ''
|
|
557
|
+
if (!originHost || originHost !== host) {
|
|
558
|
+
writeJson(res, 403, { ok: false, error: '跨源图片请求已拒绝' })
|
|
559
|
+
return
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
let p = ''
|
|
563
|
+
try { p = new URL(req.url || '/img?', 'http://local').searchParams.get('p') || '' } catch { p = '' }
|
|
564
|
+
if (!p) { writeJson(res, 400, { ok: false, error: '缺少图片路径' }); return }
|
|
565
|
+
const rp = await confine(p, '图片路径')
|
|
566
|
+
const mime = IMAGE_MIME[extOf(rp)]
|
|
567
|
+
if (!mime) { writeJson(res, 415, { ok: false, error: '不是可预览的图片类型' }); return }
|
|
568
|
+
let st
|
|
569
|
+
try { st = await stat(rp) } catch { writeJson(res, 404, { ok: false, error: '图片不可读' }); return }
|
|
570
|
+
if (st.isDirectory() || st.size > IMAGE_MAX_BYTES) {
|
|
571
|
+
writeJson(res, 413, { ok: false, error: '图片过大或目标为目录' }); return
|
|
572
|
+
}
|
|
573
|
+
const fh = await openFile(rp, 'r')
|
|
574
|
+
let buf
|
|
575
|
+
try {
|
|
576
|
+
buf = Buffer.alloc(st.size)
|
|
577
|
+
await fh.read(buf, 0, st.size, 0)
|
|
578
|
+
} finally { await fh.close() }
|
|
579
|
+
res.writeHead(200, {
|
|
580
|
+
'content-type': mime,
|
|
581
|
+
'content-length': String(st.size),
|
|
582
|
+
'cache-control': 'no-store',
|
|
583
|
+
})
|
|
584
|
+
res.end(buf)
|
|
585
|
+
} catch (err) {
|
|
586
|
+
if (!res.headersSent) writeJson(res, (err && err.statusCode) || 500, { ok: false, error: String((err && err.message) || err) })
|
|
587
|
+
else res.end()
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
ctx.effect(
|
|
591
|
+
() => ctx.webServer.register({ kind: 'exact', path: '/api/dsh-soup/img', handler: imageHandler }),
|
|
592
|
+
'dsh-soup: inline image route',
|
|
593
|
+
)
|
|
594
|
+
|
|
536
595
|
/** 统一路由 dispatch:每个 action 返回可 JSON 序列化的结果。 */
|
|
537
596
|
async function handleAction(body) {
|
|
538
597
|
const action = body && body.action
|
|
@@ -657,6 +716,25 @@ export function apply(ctx) {
|
|
|
657
716
|
downloadTickets.set(token, { path: rp, name: path.basename(rp), exp: Date.now() + 120000 })
|
|
658
717
|
return { ok: true, name: path.basename(rp), size: dst.size, url: `/api/dsh-soup/dl?t=${token}` }
|
|
659
718
|
}
|
|
719
|
+
case 'mtime': {
|
|
720
|
+
// 轻量变更探测:对一批路径返回 mtime(+size/目录标志),供浏览器端
|
|
721
|
+
// 低频轮询比较、变了才真正重拉——避免引入跨平台文件 watcher。
|
|
722
|
+
// 上限 64 个路径;越界/不可达的路径返回 null(客户端视为消失)。
|
|
723
|
+
const paths = Array.isArray(args && args.paths) ? args.paths.slice(0, 64) : []
|
|
724
|
+
const mtimes = {}
|
|
725
|
+
for (const raw of paths) {
|
|
726
|
+
const p = String(raw || '')
|
|
727
|
+
if (!p) continue
|
|
728
|
+
try {
|
|
729
|
+
const rp = await confine(p, '探测路径', sessionIdArg)
|
|
730
|
+
const st = await stat(rp)
|
|
731
|
+
mtimes[p] = st.isDirectory() ? { m: st.mtimeMs, d: 1 } : { m: st.mtimeMs, s: st.size }
|
|
732
|
+
} catch {
|
|
733
|
+
mtimes[p] = null
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
return { ok: true, mtimes }
|
|
737
|
+
}
|
|
660
738
|
case 'speed-status': {
|
|
661
739
|
const sid = args && args.sessionId ? String(args.sessionId) : ''
|
|
662
740
|
return { ok: true, ...speedStatus(sid) }
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lyhue1991/dsh-soup",
|
|
3
3
|
"description": "DSH 项目资源管理器:右侧 details 列文件树(并列网格、宽度与左侧工作区一致 264-420px),有 Session log 时在其右侧、空白新会话在输入框上方右端提供 📁 切换按钮;支持多选、双击展开/打开、右键菜单(打开/系统打开/重命名/废纸篓/新建/复制路径)、拖拽移动、上传。文件「打开」在会话区新增「文件」标签页(原生 conversation.view,与对话/轨迹同级):文本可编辑 ⌘S 保存、图片预览、二进制提示。附带模型吞吐徽标:输入框正上方显示「正在等待模型...」与彩色 t/s 徽标(数据来自 llm/stream 真实流)。附带 GoalBar 多行展示:把原生单行截断 GoalBar 放开为多行完整显示,编辑用 textarea,工具与数据仍走原生 goal。",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|