dsh-remote 0.8.5 → 0.8.7
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 +231 -220
- package/README.zh.md +178 -178
- package/cordis.patch.yml +53 -24
- package/lib/client.js +112 -7
- package/lib/credential.js +107 -107
- package/lib/errors.js +78 -78
- package/lib/forwards.js +219 -219
- package/lib/hostkey.js +48 -48
- package/lib/ignore.js +123 -123
- package/lib/index.js +92 -9
- package/lib/paths.js +169 -169
- package/lib/search.js +127 -127
- package/lib/sshconfig.js +57 -57
- package/lib/sync.js +270 -270
- package/lib/tasks.js +95 -95
- package/lib/update.js +196 -196
- package/package.json +66 -66
package/README.zh.md
CHANGED
|
@@ -1,179 +1,179 @@
|
|
|
1
|
-
[English](./README.md) · **中文**
|
|
2
|
-
|
|
3
|
-
---
|
|
4
|
-
|
|
5
|
-
# dsh-remote
|
|
6
|
-
|
|
7
|
-
[](https://www.npmjs.com/package/dsh-remote)
|
|
8
|
-
[](LICENSE)
|
|
9
|
-
[](https://github.com/topics/dsh-plugin)
|
|
10
|
-
|
|
11
|
-
**为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)打造的远程工作助手。**
|
|
12
|
-
|
|
13
|
-
维护多台 SSH 机器,然后在「选择工作区」时选一个**远程工作区**(或**本地工作区**),Agent 就能在不离开 harness 的情况下直接操作——列文件、读代码、在远程主机上跑构建/命令,并把远程目录镜像成一个真实的本地工作区对象。
|
|
14
|
-
|
|
15
|
-
DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.0.0`)。本插件反过来:**由你主动连出**到你维护的机器,选一个工作区,然后通过 DSH 原生的工作区 + 文件流来工作——**不改动 `dsh-workspace` 核心**。
|
|
16
|
-
|
|
17
|
-
## 界面预览
|
|
18
|
-
|
|
19
|
-
设置 → **远程工作区** —— 多机 SSH 列表(增/删/改/设为当前,密码本地保存、不回显):
|
|
20
|
-
|
|
21
|
-
<img src="https://cdn.jsdelivr.net/gh/flymysql/dsh-remote@main/docs/ui-settings-panel.png" alt="dsh-remote 设置页 — 多机列表(浅色主题,主机已打码)" width="720"/>
|
|
22
|
-
|
|
23
|
-
原生 **「Add workspace / 选择工作区」** 流程 —— **居中弹窗**、两个 tab,**默认落在「本机」**;切到 **「远程」**:
|
|
24
|
-
|
|
25
|
-
- **远程** —— 一个**机器下拉**;路径输入框**自动预填 `/` 并实时补全目录**(点选一个目录后**立即列出它的下一级**,像系统/VSCode 逐级选目录);另外有**「浏览…」浮窗**,选中仅回填到输入框(不直接提交),你复核 / 修改后点「设为远程工作区」。
|
|
26
|
-
|
|
27
|
-
真实截图(机器已打码为占位):
|
|
28
|
-
|
|
29
|
-
<img src="https://cdn.jsdelivr.net/gh/flymysql/dsh-remote@main/docs/ui-picker-panel.png" alt="dsh-remote 工作区选择 — 真实弹窗;默认本机 tab;远程:机器下拉 + 预填根路径 + 自动补全" width="720"/>
|
|
30
|
-
|
|
31
|
-
---
|
|
32
|
-
|
|
33
|
-
## 功能
|
|
34
|
-
|
|
35
|
-
- **多机 SSH** —— 可存任意多台主机(host/port/user + **私钥**或**密码**)。密码只存在本地,界面不回显;在设置里一键切当前机。
|
|
36
|
-
- **双 tab 工作区选择器**(填充原生「Add workspace」流程):
|
|
37
|
-
- **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。优先用 DSH 的 `directoryPicker` 服务,服务缺失时**回退到插件自持的原生选择器**(macOS `osascript` / Linux `zenity`→`kdialog`)——桌面启动路径上框架服务不注册也能用。
|
|
38
|
-
- **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → 路径框**自动预填 `/`** 并**实时补全**目录;**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」浮层**(不透明、定高、内部滚动、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定。确定会创建**真实本地镜像**(`$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<basename>`;仅当同主机上**别的远端路径**已占用同名 basename 时才追加短路径 hash)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。所选工作区会**持久化到该机器**,重启不丢。
|
|
39
|
-
- **双向 SFTP 同步(增量)** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程),本地镜像改动可回传机器。两者都会**跳过 size+mtime 未变化的文件**,并有**单文件大小上限**防误拉大二进制;目录遍历带**有界并发**。
|
|
40
|
-
- **模型工具** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_write_file`、`rw_exec`(默认在工作区目录执行,可传 `cwd=`)、`rw_search`(可移植递归 grep)、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_disconnect`。
|
|
41
|
-
- **直接写远程文件** —— `rw_write_file` 直接创建/覆盖远程文件(自动建父目录),单个文件改动不必绕本地镜像来回同步;`rw_download` / `rw_upload` 可单文件双向取真字节。
|
|
42
|
-
- **连接体检** —— 设置页提供「测试连接」按钮,在保存机器之前先验证 host/user/密码/私钥是否可用。
|
|
43
|
-
- 当前 `user@host:/path` 会注入每次系统提示,让 Agent 明确自己的工作根。
|
|
44
|
-
- **远端跨平台** —— 命令全部用可移植 POSIX 写法(`ls -la` / `sed -n` / `find … -exec grep`),macOS/BSD 与 GNU/Linux 远端都能用。
|
|
45
|
-
- **主机指纹校验(TOFU)** —— 每次 SSH 连接都校验主机密钥(`hostKeyMode: accept-new`):首次连接记录,之后**密钥一旦变化立即拒绝**(防中间人)。`verify` 模式还会拒绝从未见过的机器;`off` 关闭校验。指纹存于 `$DSH_HOME/remote-workspaces/known_hosts.json`;误判可用 `/remote forget-key` 重置。
|
|
46
|
-
- **数据跟随 Harness 根目录** —— 机器清单与镜像放在 `$DSH_HOME/remote-workspaces`(桌面版即 `userData/harness` 下);0.6 之前落在 `~/.dsh/remote-workspaces` 的数据**首次启动自动迁移**,不丢失。
|
|
47
|
-
- **不改任何 `dsh-workspace` 官方代码** —— 全部作为普通插件实现(client 半以 `priority -100` 填充 directory-flow holes)。
|
|
48
|
-
|
|
49
|
-
## 安装
|
|
50
|
-
|
|
51
|
-
```bash
|
|
52
|
-
dsh plugin add dsh-remote # 添加 bundle
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
一条命令装齐:从 **v0.7.2** 起,侧边栏
|
|
56
|
-
([dsh-better-sidebar](https://www.npmjs.com/package/dsh-better-sidebar))是
|
|
57
|
-
**硬依赖并自动挂载** —— 装完 dsh-remote 后,侧边栏里的「🌐 远程文件」目录树和
|
|
58
|
-
远程文件查看器即可直接用,无需额外步骤。如果你已单独安装过该侧边栏,内嵌副本
|
|
59
|
-
会自动退避(不会重复挂载)。
|
|
60
|
-
|
|
61
|
-
> **要求 profile 的 pnpm linker 为 `hoisted`**(DSH profile 默认,
|
|
62
|
-
> `pnpm-workspace.yaml` 里 `nodeLinker: hoisted`)。loader 从 profile 根解析
|
|
63
|
-
> 插件包,侧边栏必须能在顶层 `node_modules` 被解析到。如果你的
|
|
64
|
-
> `pnpm-workspace.yaml` 被重写丢掉了 `nodeLinker: hoisted`,请补回并执行一次
|
|
65
|
-
> `pnpm install` —— 否则内嵌侧边栏行会报
|
|
66
|
-
> `Cannot find package 'dsh-better-sidebar'`。
|
|
67
|
-
|
|
68
|
-
(或 `npm install dsh-remote`,再在 `cordis.patch.yml` 加 `- id: dsh-remote / name: dsh-remote`。)
|
|
69
|
-
|
|
70
|
-
## 快速上手
|
|
71
|
-
|
|
72
|
-
1. **加一台机器** —— 设置 → 远程工作区 → 填 host/port/user + 密码或 key →(可选)设为当前。
|
|
73
|
-
2. **选工作区** —— 点侧边栏/会话的 **Add workspace**:
|
|
74
|
-
- **本机** → 系统文件夹选择(或输入本地路径)→ 本地工作区。
|
|
75
|
-
- **远程** → 选机器 → 浏览到远程目录(或输入 `/path`)→ 「设为远程工作区」⇒ 创建并收养一个本地镜像工作区。
|
|
76
|
-
3. **让 Agent 工作** —— 把它当普通工作区用:
|
|
77
|
-
- `rw_list_dir(path?)` / `rw_read_file` —— 查看远程文件
|
|
78
|
-
- `rw_write_file(path, content)` —— 直接创建或覆盖远程文件
|
|
79
|
-
- `rw_search(pattern, path?)` —— 远程 grep
|
|
80
|
-
- `rw_exec(command, cwd?)` —— 在远程执行命令(默认在工作区目录)
|
|
81
|
-
- `rw_sync` / `rw_push` —— 拉取/推送本地镜像 <-> 远程
|
|
82
|
-
|
|
83
|
-
## 可选:CLI 默认机
|
|
84
|
-
|
|
85
|
-
可在 `cordis.patch.yml` 提供默认机:
|
|
86
|
-
|
|
87
|
-
```yaml
|
|
88
|
-
# 示例:请换成你自己的机器
|
|
89
|
-
- id: dsh-remote
|
|
90
|
-
name: dsh-remote
|
|
91
|
-
config:
|
|
92
|
-
host: 203.0.113.10 # 或你的真实主机 / hostname
|
|
93
|
-
port: 22
|
|
94
|
-
username: dev
|
|
95
|
-
privateKeyPath: ~/.ssh/id_rsa
|
|
96
|
-
# 或用密码登录:
|
|
97
|
-
# password: '…'
|
|
98
|
-
workspace: ~/project
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
若 `host` 为空,插件启动时处于断开状态,在 UI 里配置机器即可。
|
|
102
|
-
|
|
103
|
-
## 常用命令(安装 / 查看 / 启动)
|
|
104
|
-
|
|
105
|
-
DSH 的 `dsh` 可能不在某些 shell 的 PATH(比如 Windows PowerShell 里在某个仓库目录下),所以同时列出 `dsh` 与 `npx` 两种写法。操作都要用 `--profile <name>` 指定 profile(一般 `web`):
|
|
106
|
-
|
|
107
|
-
```bash
|
|
108
|
-
# 安装(从 npm 拉到 profile)
|
|
109
|
-
dsh plugin --profile web add dsh-remote
|
|
110
|
-
# 同一效果:当 `dsh` 不在 PATH 时用 npx
|
|
111
|
-
npx --yes @deepseek-ai/dsh plugin --profile web add dsh-remote
|
|
112
|
-
|
|
113
|
-
# 确认已装
|
|
114
|
-
dsh plugin --profile web list
|
|
115
|
-
npx --yes @deepseek-ai/dsh plugin --profile web list
|
|
116
|
-
|
|
117
|
-
# 启动 web 界面(重载 profile,新插件在启动时生效)
|
|
118
|
-
dsh --profile web
|
|
119
|
-
npx --yes @deepseek-ai/dsh --profile web # 访问 http://127.0.0.1:3080
|
|
120
|
-
|
|
121
|
-
# 迭代用本地源码替换 npm 版(便于改 dsh 插件代码后即测)
|
|
122
|
-
npx --yes @deepseek-ai/dsh plugin --profile web add D:/path/to/dsh-remote
|
|
123
|
-
npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # 恢复用发行版
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
启动成功后,设置 →「远程工作区」会出现;「Add workspace」流程会带「本机 / 远程」两个 tab(见上方效果图)。
|
|
127
|
-
|
|
128
|
-
## 开发(沙箱优先,勿改产品)
|
|
129
|
-
|
|
130
|
-
迭代**一律在沙箱**里做,绝不手工改产品 profile——产品 profile 由插件管理器重管,
|
|
131
|
-
重装会把手工部署的文件还原掉。用仓库内的辅助脚本:
|
|
132
|
-
|
|
133
|
-
```bash
|
|
134
|
-
scripts/dev-run.sh --restart # 启动 / 重启隔离沙箱
|
|
135
|
-
scripts/dev-run.sh --stop # 停止
|
|
136
|
-
scripts/dev-run.sh --status # 是否在运行
|
|
137
|
-
```
|
|
138
|
-
|
|
139
|
-
- 自带一套独立 DSH 实例(仓库内 `dev-harness/harness`),把 `lib/` 复制进沙箱
|
|
140
|
-
profile——与桌面 App 走同一条 `bin.js web --patch` 启动路径,沙箱即产品启动行为。
|
|
141
|
-
- 沙箱 web UI 在 `http://127.0.0.1:50599`,插件路由立即可见(如
|
|
142
|
-
`GET /dsh-remote/machines`)。
|
|
143
|
-
- **宿主半改动**(`lib/index.js`)需重启沙箱(`--restart`);**客户端半改动**
|
|
144
|
-
(`lib/client.js`)只需刷新页面。
|
|
145
|
-
- Node ESM 按导入文件的真实路径解析依赖,脚本用**硬链接拷贝**(`cp -al`)把
|
|
146
|
-
`lib/` 复制进沙箱 profile,而不是软链——软链会破坏 `@deepseek-ai/*` 的解析。
|
|
147
|
-
- 每次提交前跑 `scripts/check.mjs`(静态框架约束闸门:命令名正则等);
|
|
148
|
-
`scripts/boot-smoke.sh` 用隔离实例证明插件仍能启动。
|
|
149
|
-
- 完整规则见 `scripts/dev-standards.md`(命令名、cordis 服务只许 `ctx.get()`、
|
|
150
|
-
可选框架服务可能压根不注册、三方库回调契约以真实运行为准等)。
|
|
151
|
-
|
|
152
|
-
部署到产品 profile 是单独的受控动作(`./sync.sh`),只在确定要发布时做。
|
|
153
|
-
|
|
154
|
-
## 配置
|
|
155
|
-
|
|
156
|
-
| 键 | 类型 | 默认 | 说明 |
|
|
157
|
-
| --- | --- | --- | --- |
|
|
158
|
-
| `host` | string | `''` | 默认 SSH 主机(空=断开) |
|
|
159
|
-
| `port` | int | `22` | 默认 SSH 端口 |
|
|
160
|
-
| `username` | string | `''` | 默认 SSH 用户 |
|
|
161
|
-
| `password` | string | `''` | 默认 SSH 密码(非空覆盖 key) |
|
|
162
|
-
| `privateKeyPath` | string | `''` | 私钥路径(仅在显式提供时使用) |
|
|
163
|
-
| `workspace` | string | `''` | 默认远程工作区路径 |
|
|
164
|
-
| `commandTimeoutMs` | int | 20000 | 单条远程命令超时 |
|
|
165
|
-
| `connectTimeoutMs` | int | 15000 | SSH 连接超时 |
|
|
166
|
-
| `maxFileBytes` | int | 52428800 | 镜像同步时跳过超过该大小的文件(0=不设上限) |
|
|
167
|
-
| `hostKeyMode` | string | `accept-new` | 主机指纹策略:`accept-new`(首次信任)、`verify`(拒绝未知主机)、`off`(跳过校验) |
|
|
168
|
-
|
|
169
|
-
## 安全提醒
|
|
170
|
-
|
|
171
|
-
把机器凭据交给插件,等于允许 Agent 以你的用户身份在主机上执行 **shell 命令**。只添加你可信的机器。密码保存在本机文件里,请当作敏感数据处理(可收紧文件 ACL)。
|
|
172
|
-
|
|
173
|
-
## License
|
|
174
|
-
|
|
175
|
-
MIT
|
|
176
|
-
|
|
177
|
-
## 变更记录
|
|
178
|
-
|
|
1
|
+
[English](./README.md) · **中文**
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# dsh-remote
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/dsh-remote)
|
|
8
|
+
[](LICENSE)
|
|
9
|
+
[](https://github.com/topics/dsh-plugin)
|
|
10
|
+
|
|
11
|
+
**为 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)打造的远程工作助手。**
|
|
12
|
+
|
|
13
|
+
维护多台 SSH 机器,然后在「选择工作区」时选一个**远程工作区**(或**本地工作区**),Agent 就能在不离开 harness 的情况下直接操作——列文件、读代码、在远程主机上跑构建/命令,并把远程目录镜像成一个真实的本地工作区对象。
|
|
14
|
+
|
|
15
|
+
DSH 的 Web 界面刻意只监听 `127.0.0.1`(CLI 为安全拒绝 `--host 0.0.0.0`)。本插件反过来:**由你主动连出**到你维护的机器,选一个工作区,然后通过 DSH 原生的工作区 + 文件流来工作——**不改动 `dsh-workspace` 核心**。
|
|
16
|
+
|
|
17
|
+
## 界面预览
|
|
18
|
+
|
|
19
|
+
设置 → **远程工作区** —— 多机 SSH 列表(增/删/改/设为当前,密码本地保存、不回显):
|
|
20
|
+
|
|
21
|
+
<img src="https://cdn.jsdelivr.net/gh/flymysql/dsh-remote@main/docs/ui-settings-panel.png" alt="dsh-remote 设置页 — 多机列表(浅色主题,主机已打码)" width="720"/>
|
|
22
|
+
|
|
23
|
+
原生 **「Add workspace / 选择工作区」** 流程 —— **居中弹窗**、两个 tab,**默认落在「本机」**;切到 **「远程」**:
|
|
24
|
+
|
|
25
|
+
- **远程** —— 一个**机器下拉**;路径输入框**自动预填 `/` 并实时补全目录**(点选一个目录后**立即列出它的下一级**,像系统/VSCode 逐级选目录);另外有**「浏览…」浮窗**,选中仅回填到输入框(不直接提交),你复核 / 修改后点「设为远程工作区」。
|
|
26
|
+
|
|
27
|
+
真实截图(机器已打码为占位):
|
|
28
|
+
|
|
29
|
+
<img src="https://cdn.jsdelivr.net/gh/flymysql/dsh-remote@main/docs/ui-picker-panel.png" alt="dsh-remote 工作区选择 — 真实弹窗;默认本机 tab;远程:机器下拉 + 预填根路径 + 自动补全" width="720"/>
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 功能
|
|
34
|
+
|
|
35
|
+
- **多机 SSH** —— 可存任意多台主机(host/port/user + **私钥**或**密码**)。密码只存在本地,界面不回显;在设置里一键切当前机。
|
|
36
|
+
- **双 tab 工作区选择器**(填充原生「Add workspace」流程):
|
|
37
|
+
- **本机** —— 走 **host 端原生系统文件夹对话框**选本地目录(或直接输入本地路径)→ 直接成为普通 DSH 本地工作区(与本地工作区共存)。优先用 DSH 的 `directoryPicker` 服务,服务缺失时**回退到插件自持的原生选择器**(macOS `osascript` / Linux `zenity`→`kdialog`)——桌面启动路径上框架服务不注册也能用。
|
|
38
|
+
- **远程** —— 选择器是**居中弹窗**(窄侧边栏也不会被挤压)。先**选机器** → 路径框**自动预填 `/`** 并**实时补全**目录;**选中一个目录立即列出它下一级**(OS/VSCode 式级联)。另有 **「浏览…」浮层**(不透明、定高、内部滚动、跟随软链),选中**回填输入框不提交**,你复核/修改后再确定。确定会创建**真实本地镜像**(`$DSH_HOME/remote-workspaces/<host>-<user>-<port>/<basename>`;仅当同主机上**别的远端路径**已占用同名 basename 时才追加短路径 hash)→ harness 把它当真实工作区收养,同时 dsh-remote 通过 SFTP 保持同步。所选工作区会**持久化到该机器**,重启不丢。
|
|
39
|
+
- **双向 SFTP 同步(增量)** —— `rw_sync`(远程→镜像)、`rw_push`(镜像→远程),本地镜像改动可回传机器。两者都会**跳过 size+mtime 未变化的文件**,并有**单文件大小上限**防误拉大二进制;目录遍历带**有界并发**。
|
|
40
|
+
- **模型工具** —— `rw_info`、`rw_connect`、`rw_pick_workspace`、`rw_list_dir`、`rw_read_file`、`rw_write_file`、`rw_exec`(默认在工作区目录执行,可传 `cwd=`)、`rw_search`(可移植递归 grep)、`rw_download`、`rw_upload`、`rw_sync`、`rw_push`、`rw_disconnect`。
|
|
41
|
+
- **直接写远程文件** —— `rw_write_file` 直接创建/覆盖远程文件(自动建父目录),单个文件改动不必绕本地镜像来回同步;`rw_download` / `rw_upload` 可单文件双向取真字节。
|
|
42
|
+
- **连接体检** —— 设置页提供「测试连接」按钮,在保存机器之前先验证 host/user/密码/私钥是否可用。
|
|
43
|
+
- 当前 `user@host:/path` 会注入每次系统提示,让 Agent 明确自己的工作根。
|
|
44
|
+
- **远端跨平台** —— 命令全部用可移植 POSIX 写法(`ls -la` / `sed -n` / `find … -exec grep`),macOS/BSD 与 GNU/Linux 远端都能用。
|
|
45
|
+
- **主机指纹校验(TOFU)** —— 每次 SSH 连接都校验主机密钥(`hostKeyMode: accept-new`):首次连接记录,之后**密钥一旦变化立即拒绝**(防中间人)。`verify` 模式还会拒绝从未见过的机器;`off` 关闭校验。指纹存于 `$DSH_HOME/remote-workspaces/known_hosts.json`;误判可用 `/remote forget-key` 重置。
|
|
46
|
+
- **数据跟随 Harness 根目录** —— 机器清单与镜像放在 `$DSH_HOME/remote-workspaces`(桌面版即 `userData/harness` 下);0.6 之前落在 `~/.dsh/remote-workspaces` 的数据**首次启动自动迁移**,不丢失。
|
|
47
|
+
- **不改任何 `dsh-workspace` 官方代码** —— 全部作为普通插件实现(client 半以 `priority -100` 填充 directory-flow holes)。
|
|
48
|
+
|
|
49
|
+
## 安装
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
dsh plugin add dsh-remote # 添加 bundle
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
一条命令装齐:从 **v0.7.2** 起,侧边栏
|
|
56
|
+
([dsh-better-sidebar](https://www.npmjs.com/package/dsh-better-sidebar))是
|
|
57
|
+
**硬依赖并自动挂载** —— 装完 dsh-remote 后,侧边栏里的「🌐 远程文件」目录树和
|
|
58
|
+
远程文件查看器即可直接用,无需额外步骤。如果你已单独安装过该侧边栏,内嵌副本
|
|
59
|
+
会自动退避(不会重复挂载)。
|
|
60
|
+
|
|
61
|
+
> **要求 profile 的 pnpm linker 为 `hoisted`**(DSH profile 默认,
|
|
62
|
+
> `pnpm-workspace.yaml` 里 `nodeLinker: hoisted`)。loader 从 profile 根解析
|
|
63
|
+
> 插件包,侧边栏必须能在顶层 `node_modules` 被解析到。如果你的
|
|
64
|
+
> `pnpm-workspace.yaml` 被重写丢掉了 `nodeLinker: hoisted`,请补回并执行一次
|
|
65
|
+
> `pnpm install` —— 否则内嵌侧边栏行会报
|
|
66
|
+
> `Cannot find package 'dsh-better-sidebar'`。
|
|
67
|
+
|
|
68
|
+
(或 `npm install dsh-remote`,再在 `cordis.patch.yml` 加 `- id: dsh-remote / name: dsh-remote`。)
|
|
69
|
+
|
|
70
|
+
## 快速上手
|
|
71
|
+
|
|
72
|
+
1. **加一台机器** —— 设置 → 远程工作区 → 填 host/port/user + 密码或 key →(可选)设为当前。
|
|
73
|
+
2. **选工作区** —— 点侧边栏/会话的 **Add workspace**:
|
|
74
|
+
- **本机** → 系统文件夹选择(或输入本地路径)→ 本地工作区。
|
|
75
|
+
- **远程** → 选机器 → 浏览到远程目录(或输入 `/path`)→ 「设为远程工作区」⇒ 创建并收养一个本地镜像工作区。
|
|
76
|
+
3. **让 Agent 工作** —— 把它当普通工作区用:
|
|
77
|
+
- `rw_list_dir(path?)` / `rw_read_file` —— 查看远程文件
|
|
78
|
+
- `rw_write_file(path, content)` —— 直接创建或覆盖远程文件
|
|
79
|
+
- `rw_search(pattern, path?)` —— 远程 grep
|
|
80
|
+
- `rw_exec(command, cwd?)` —— 在远程执行命令(默认在工作区目录)
|
|
81
|
+
- `rw_sync` / `rw_push` —— 拉取/推送本地镜像 <-> 远程
|
|
82
|
+
|
|
83
|
+
## 可选:CLI 默认机
|
|
84
|
+
|
|
85
|
+
可在 `cordis.patch.yml` 提供默认机:
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
# 示例:请换成你自己的机器
|
|
89
|
+
- id: dsh-remote
|
|
90
|
+
name: dsh-remote
|
|
91
|
+
config:
|
|
92
|
+
host: 203.0.113.10 # 或你的真实主机 / hostname
|
|
93
|
+
port: 22
|
|
94
|
+
username: dev
|
|
95
|
+
privateKeyPath: ~/.ssh/id_rsa
|
|
96
|
+
# 或用密码登录:
|
|
97
|
+
# password: '…'
|
|
98
|
+
workspace: ~/project
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
若 `host` 为空,插件启动时处于断开状态,在 UI 里配置机器即可。
|
|
102
|
+
|
|
103
|
+
## 常用命令(安装 / 查看 / 启动)
|
|
104
|
+
|
|
105
|
+
DSH 的 `dsh` 可能不在某些 shell 的 PATH(比如 Windows PowerShell 里在某个仓库目录下),所以同时列出 `dsh` 与 `npx` 两种写法。操作都要用 `--profile <name>` 指定 profile(一般 `web`):
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
# 安装(从 npm 拉到 profile)
|
|
109
|
+
dsh plugin --profile web add dsh-remote
|
|
110
|
+
# 同一效果:当 `dsh` 不在 PATH 时用 npx
|
|
111
|
+
npx --yes @deepseek-ai/dsh plugin --profile web add dsh-remote
|
|
112
|
+
|
|
113
|
+
# 确认已装
|
|
114
|
+
dsh plugin --profile web list
|
|
115
|
+
npx --yes @deepseek-ai/dsh plugin --profile web list
|
|
116
|
+
|
|
117
|
+
# 启动 web 界面(重载 profile,新插件在启动时生效)
|
|
118
|
+
dsh --profile web
|
|
119
|
+
npx --yes @deepseek-ai/dsh --profile web # 访问 http://127.0.0.1:3080
|
|
120
|
+
|
|
121
|
+
# 迭代用本地源码替换 npm 版(便于改 dsh 插件代码后即测)
|
|
122
|
+
npx --yes @deepseek-ai/dsh plugin --profile web add D:/path/to/dsh-remote
|
|
123
|
+
npx --yes @deepseek-ai/dsh plugin --profile web remove dsh-remote # 恢复用发行版
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
启动成功后,设置 →「远程工作区」会出现;「Add workspace」流程会带「本机 / 远程」两个 tab(见上方效果图)。
|
|
127
|
+
|
|
128
|
+
## 开发(沙箱优先,勿改产品)
|
|
129
|
+
|
|
130
|
+
迭代**一律在沙箱**里做,绝不手工改产品 profile——产品 profile 由插件管理器重管,
|
|
131
|
+
重装会把手工部署的文件还原掉。用仓库内的辅助脚本:
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
scripts/dev-run.sh --restart # 启动 / 重启隔离沙箱
|
|
135
|
+
scripts/dev-run.sh --stop # 停止
|
|
136
|
+
scripts/dev-run.sh --status # 是否在运行
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- 自带一套独立 DSH 实例(仓库内 `dev-harness/harness`),把 `lib/` 复制进沙箱
|
|
140
|
+
profile——与桌面 App 走同一条 `bin.js web --patch` 启动路径,沙箱即产品启动行为。
|
|
141
|
+
- 沙箱 web UI 在 `http://127.0.0.1:50599`,插件路由立即可见(如
|
|
142
|
+
`GET /dsh-remote/machines`)。
|
|
143
|
+
- **宿主半改动**(`lib/index.js`)需重启沙箱(`--restart`);**客户端半改动**
|
|
144
|
+
(`lib/client.js`)只需刷新页面。
|
|
145
|
+
- Node ESM 按导入文件的真实路径解析依赖,脚本用**硬链接拷贝**(`cp -al`)把
|
|
146
|
+
`lib/` 复制进沙箱 profile,而不是软链——软链会破坏 `@deepseek-ai/*` 的解析。
|
|
147
|
+
- 每次提交前跑 `scripts/check.mjs`(静态框架约束闸门:命令名正则等);
|
|
148
|
+
`scripts/boot-smoke.sh` 用隔离实例证明插件仍能启动。
|
|
149
|
+
- 完整规则见 `scripts/dev-standards.md`(命令名、cordis 服务只许 `ctx.get()`、
|
|
150
|
+
可选框架服务可能压根不注册、三方库回调契约以真实运行为准等)。
|
|
151
|
+
|
|
152
|
+
部署到产品 profile 是单独的受控动作(`./sync.sh`),只在确定要发布时做。
|
|
153
|
+
|
|
154
|
+
## 配置
|
|
155
|
+
|
|
156
|
+
| 键 | 类型 | 默认 | 说明 |
|
|
157
|
+
| --- | --- | --- | --- |
|
|
158
|
+
| `host` | string | `''` | 默认 SSH 主机(空=断开) |
|
|
159
|
+
| `port` | int | `22` | 默认 SSH 端口 |
|
|
160
|
+
| `username` | string | `''` | 默认 SSH 用户 |
|
|
161
|
+
| `password` | string | `''` | 默认 SSH 密码(非空覆盖 key) |
|
|
162
|
+
| `privateKeyPath` | string | `''` | 私钥路径(仅在显式提供时使用) |
|
|
163
|
+
| `workspace` | string | `''` | 默认远程工作区路径 |
|
|
164
|
+
| `commandTimeoutMs` | int | 20000 | 单条远程命令超时 |
|
|
165
|
+
| `connectTimeoutMs` | int | 15000 | SSH 连接超时 |
|
|
166
|
+
| `maxFileBytes` | int | 52428800 | 镜像同步时跳过超过该大小的文件(0=不设上限) |
|
|
167
|
+
| `hostKeyMode` | string | `accept-new` | 主机指纹策略:`accept-new`(首次信任)、`verify`(拒绝未知主机)、`off`(跳过校验) |
|
|
168
|
+
|
|
169
|
+
## 安全提醒
|
|
170
|
+
|
|
171
|
+
把机器凭据交给插件,等于允许 Agent 以你的用户身份在主机上执行 **shell 命令**。只添加你可信的机器。密码保存在本机文件里,请当作敏感数据处理(可收紧文件 ACL)。
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT
|
|
176
|
+
|
|
177
|
+
## 变更记录
|
|
178
|
+
|
|
179
179
|
见 [CHANGELOG.md](./CHANGELOG.md)。
|
package/cordis.patch.yml
CHANGED
|
@@ -1,24 +1,53 @@
|
|
|
1
|
-
# dsh-remote bundle patch: inserts this plugin into a profile's layer stack.
|
|
2
|
-
# Applied when a profile lists this bundle; rows reference the package by name so
|
|
3
|
-
# Node resolution finds the installed code.
|
|
4
|
-
#
|
|
5
|
-
# Since v0.7.2 dsh-remote ships dsh-better-sidebar as a hard dependency and
|
|
6
|
-
# mounts it here, so `dsh plugin add dsh-remote` brings up the sidebar with
|
|
7
|
-
# zero extra steps. The sidebar row uses a DEDICATED id (`dsh-remote-sidebar`)
|
|
8
|
-
# and a guard so it backs off when another
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
1
|
+
# dsh-remote bundle patch: inserts this plugin into a profile's layer stack.
|
|
2
|
+
# Applied when a profile lists this bundle; rows reference the package by name so
|
|
3
|
+
# Node resolution finds the installed code.
|
|
4
|
+
#
|
|
5
|
+
# Since v0.7.2 dsh-remote ships dsh-better-sidebar as a hard dependency and
|
|
6
|
+
# mounts it here, so `dsh plugin add dsh-remote` brings up the sidebar with
|
|
7
|
+
# zero extra steps. The sidebar row uses a DEDICATED id (`dsh-remote-sidebar`)
|
|
8
|
+
# and a guard so it backs off when another row mounts `dsh-better-sidebar`
|
|
9
|
+
# (e.g. the user installed the sidebar bundle on its own, or an aggregate
|
|
10
|
+
# bundle owns it) — two enabled mounts would both register /sidebar/api and
|
|
11
|
+
# fail the whole plugin tree at boot.
|
|
12
|
+
#
|
|
13
|
+
# Order-independence (issue #12): the guard must NOT depend on
|
|
14
|
+
# `ctx.loader.entries()` creation order. The loader creates entries in bundle
|
|
15
|
+
# order and each row's disabled expression is evaluated at creation time, when
|
|
16
|
+
# only rows created BEFORE it are visible — so with
|
|
17
|
+
# `bundles: [..., "dsh-remote", "dsh-better-sidebar", ...]` (standalone sidebar
|
|
18
|
+
# installed after dsh-remote, the normal `dsh plugin add` append order) both
|
|
19
|
+
# rows saw no other sidebar mount and BOTH started, crashing boot with
|
|
20
|
+
# `duplicate prefix route "/sidebar/api"`.
|
|
21
|
+
#
|
|
22
|
+
# Instead we scan the COMPOSED PATCH STACK — the include entry's
|
|
23
|
+
# `config.patches` (every bundle layer in `dsh.profile.bundles` order, then the
|
|
24
|
+
# profile/home/overlay layers) — for any OTHER row that mounts
|
|
25
|
+
# `dsh-better-sidebar`. That data is fully materialized before any row guard
|
|
26
|
+
# evaluates, so the outcome is identical no matter where the standalone bundle
|
|
27
|
+
# sits in the bundle list. It is also pure data: it never reads another row's
|
|
28
|
+
# `disabled` (reading another row's disabled expression would recursively
|
|
29
|
+
# re-enter the loader — dsh-better-sidebar's own row carries one too, so the
|
|
30
|
+
# two would ping-pong), so no recursion is possible. The old entries()-based
|
|
31
|
+
# check is kept as a belt-and-suspenders fallback for rows not visible in the
|
|
32
|
+
# patch stack (e.g. an entry injected at runtime by HMR or another plugin).
|
|
33
|
+
- insert:
|
|
34
|
+
- id: dsh-remote
|
|
35
|
+
name: 'dsh-remote'
|
|
36
|
+
- id: dsh-remote-sidebar
|
|
37
|
+
name: 'dsh-better-sidebar'
|
|
38
|
+
disabled: !!js >-
|
|
39
|
+
(function () {
|
|
40
|
+
try {
|
|
41
|
+
const inc = ctx.loader && ctx.loader.resolve && ctx.loader.resolve('include')
|
|
42
|
+
const patches = inc && inc.subtree && inc.subtree.config && inc.subtree.config.patches
|
|
43
|
+
if (Array.isArray(patches)) {
|
|
44
|
+
for (const p of patches) {
|
|
45
|
+
if (!p || !Array.isArray(p.insert)) continue
|
|
46
|
+
for (const row of p.insert) {
|
|
47
|
+
if (row && row.name === 'dsh-better-sidebar' && row.id !== 'dsh-remote-sidebar') return true
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {}
|
|
52
|
+
return [...ctx.loader.entries()].some((e) => e.options.name === 'dsh-better-sidebar' && e.options.id !== 'dsh-remote-sidebar')
|
|
53
|
+
})()
|
package/lib/client.js
CHANGED
|
@@ -379,7 +379,7 @@ window.__ModuleLoader__.load({
|
|
|
379
379
|
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '⭐ 去 GitHub 点个 Star'),
|
|
380
380
|
React.createElement('span', {}, '·'),
|
|
381
381
|
React.createElement('a', { href: 'https://github.com/flymysql/dsh-remote/issues/new', target: '_blank', rel: 'noopener noreferrer', style: { color: 'inherit' } }, '💬 反馈建议 / 提 issue'),
|
|
382
|
-
React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote
|
|
382
|
+
React.createElement('span', { style: { marginLeft: 'auto', opacity: 0.5 } }, 'dsh-remote v' + (upd && upd.current ? upd.current : '?.?.?')),
|
|
383
383
|
),
|
|
384
384
|
)
|
|
385
385
|
}
|
|
@@ -436,6 +436,9 @@ window.__ModuleLoader__.load({
|
|
|
436
436
|
const [suggest, setSuggest] = React.useState([])
|
|
437
437
|
const [suggestOpen, setSuggestOpen] = React.useState(false)
|
|
438
438
|
const [recent, setRecent] = React.useState([])
|
|
439
|
+
// 本机浏览浮层(browse 后端):{ path, home, crumbs, entries, truncated }。
|
|
440
|
+
const [localPop, setLocalPop] = React.useState(null)
|
|
441
|
+
const [newDirName, setNewDirName] = React.useState('')
|
|
439
442
|
const suggestTimer = React.useRef(null)
|
|
440
443
|
const suggestRef = React.useRef(null)
|
|
441
444
|
React.useEffect(() => {
|
|
@@ -485,6 +488,7 @@ window.__ModuleLoader__.load({
|
|
|
485
488
|
}
|
|
486
489
|
|
|
487
490
|
React.useEffect(() => { if (open) {
|
|
491
|
+
setLocalPop(null); setNewDirName('')
|
|
488
492
|
api('GET', '/dsh-remote/machines').then((r) => {
|
|
489
493
|
setMachines(r.machines || [])
|
|
490
494
|
const cur = (r.machines || []).find((m) => m.id === r.currentId)
|
|
@@ -493,16 +497,45 @@ window.__ModuleLoader__.load({
|
|
|
493
497
|
})
|
|
494
498
|
} }, [open])
|
|
495
499
|
|
|
500
|
+
// 本机目录浏览(browse 后端兜底):列出 path 的子目录;path 为空 → 宿主家目录。
|
|
501
|
+
const loadLocal = (p) => {
|
|
502
|
+
setLoading(true); setErr('')
|
|
503
|
+
api('GET', '/dsh-remote/local-list' + (p ? '?path=' + encodeURIComponent(p) : ''))
|
|
504
|
+
.then((r) => setLocalPop({ path: (r && r.path) || p || '', home: (r && r.home) || '', crumbs: (r && r.crumbs) || [], entries: (r && r.entries) || [], truncated: !!(r && r.truncated), drives: (r && r.drives) || [] }))
|
|
505
|
+
.catch((e) => setErr(String((e && e.message) || e)))
|
|
506
|
+
.finally(() => setLoading(false))
|
|
507
|
+
}
|
|
508
|
+
|
|
496
509
|
const chooseLocal = () => {
|
|
497
510
|
setLoading(true); setErr('')
|
|
498
511
|
api('POST', '/dsh-remote/local-pick')
|
|
499
512
|
.then((r) => {
|
|
500
|
-
if (r && r.path) onPicked(String(r.path))
|
|
501
|
-
|
|
502
|
-
|
|
513
|
+
if (r && r.path) { setLoading(false); onPicked(String(r.path)) }
|
|
514
|
+
// browse 后端(DSH Desktop / 无显示的远程宿主):没有 OS 对话框可开
|
|
515
|
+
// → 打开内置本机目录浏览器。
|
|
516
|
+
else if (r && r.kind === 'browse') loadLocal('')
|
|
517
|
+
else if (r && r.cancelled) { setLoading(false); setErr('已取消选择') }
|
|
518
|
+
else { setLoading(false); setErr((r && r.error) || '无法打开系统文件夹选择器,可直接在输入框填本地路径') }
|
|
503
519
|
})
|
|
504
|
-
.catch((e) => setErr(String((e && e.message) || e) + ' — 可直接在输入框填本地路径'))
|
|
505
|
-
|
|
520
|
+
.catch((e) => { setLoading(false); setErr(String((e && e.message) || e) + ' — 可直接在输入框填本地路径') })
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 在浮层当前目录下新建文件夹(browse 后端 createDirectory),成功后刷新列表。
|
|
524
|
+
const mkdirLocal = () => {
|
|
525
|
+
const cur = localPop
|
|
526
|
+
const name = newDirName.trim()
|
|
527
|
+
if (!cur || !cur.path || !name || loading) return
|
|
528
|
+
setLoading(true); setErr('')
|
|
529
|
+
api('POST', '/dsh-remote/local-mkdir', { path: cur.path, name })
|
|
530
|
+
.then(() => { setNewDirName(''); loadLocal(cur.path) })
|
|
531
|
+
.catch((e) => { setErr(String((e && e.message) || e)); setLoading(false) })
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// 选用浮层中的本机目录:回填输入框(不直接提交),复核后再点「选用此本地路径」。
|
|
535
|
+
const acceptLocalPick = (p) => {
|
|
536
|
+
setPath(String(p || ''))
|
|
537
|
+
setErr('')
|
|
538
|
+
setLocalPop(null)
|
|
506
539
|
}
|
|
507
540
|
|
|
508
541
|
const switchTab = (t) => {
|
|
@@ -612,6 +645,77 @@ window.__ModuleLoader__.load({
|
|
|
612
645
|
.catch((e) => setErr(String((e && e.message) || e)))
|
|
613
646
|
}
|
|
614
647
|
|
|
648
|
+
// 本机浏览浮层的面包屑:browse 后端给出完整祖先链(含盘符根),可点击跳转。
|
|
649
|
+
const localCrumbs = (cur) => {
|
|
650
|
+
const crumbs = (cur && cur.crumbs) || []
|
|
651
|
+
if (!crumbs.length) return React.createElement('span', null, '/')
|
|
652
|
+
const out = []
|
|
653
|
+
crumbs.forEach((c, i) => {
|
|
654
|
+
if (i > 0) out.push(React.createElement('span', { key: 'sep' + i, style: { color: T.muted } }, ' › '))
|
|
655
|
+
out.push(React.createElement('span', {
|
|
656
|
+
key: c.path || i,
|
|
657
|
+
title: c.path,
|
|
658
|
+
onClick: () => loadLocal(c.path),
|
|
659
|
+
style: { cursor: 'pointer', color: i === crumbs.length - 1 ? T.label : T.ok, fontWeight: i === crumbs.length - 1 ? 700 : 400 },
|
|
660
|
+
}, c.name))
|
|
661
|
+
})
|
|
662
|
+
return React.createElement('span', null, out)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// 本机目录浏览浮层(browse 后端):进入子目录 / 面包屑跳转 / 回家目录 /
|
|
666
|
+
// 新建文件夹 / 选用回填。与远程浏览弹层同一套交互。
|
|
667
|
+
function renderLocalPopup() {
|
|
668
|
+
const cur = localPop
|
|
669
|
+
if (!cur) return null
|
|
670
|
+
const crumbs = cur.crumbs || []
|
|
671
|
+
const parent = crumbs.length >= 2 ? crumbs[crumbs.length - 2].path : null
|
|
672
|
+
return React.createElement('div', {
|
|
673
|
+
style: { position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 12 },
|
|
674
|
+
onClick: () => setLocalPop(null),
|
|
675
|
+
},
|
|
676
|
+
React.createElement('div', { style: { background: v('--dsw-alias-bg-overlay', '#1e1e1e'), border: '1px solid ' + T.borderStrong, borderRadius: 10, boxShadow: '0 10px 40px rgba(0,0,0,0.5)', width: 'min(560px, 94vw)', minWidth: 320, display: 'flex', flexDirection: 'column', maxHeight: 'min(460px, 84vh)', overflow: 'hidden' }, onClick: (e) => e.stopPropagation() },
|
|
677
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid ' + T.border } },
|
|
678
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => parent && loadLocal(parent), disabled: !parent || loading, title: parent || '' }, '回上一级 ▴'),
|
|
679
|
+
cur.home ? React.createElement('button', { style: { ...buttonS, padding: '3px 8px' }, onClick: () => loadLocal(cur.home), disabled: loading, title: cur.home }, '🏠') : null,
|
|
680
|
+
React.createElement('div', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, localCrumbs(cur)),
|
|
681
|
+
React.createElement('button', { style: { ...buttonS, padding: '3px 10px' }, onClick: () => setLocalPop(null) }, '关闭 ✕'),
|
|
682
|
+
),
|
|
683
|
+
// 盘符切换行(Windows 无统一根目录,browse 面包屑到不了其他盘)。
|
|
684
|
+
(cur.drives && cur.drives.length > 1) ? React.createElement('div', { style: { display: 'flex', gap: 4, flexWrap: 'wrap', padding: '6px 12px', borderBottom: '1px solid ' + T.border } },
|
|
685
|
+
cur.drives.map((d) => {
|
|
686
|
+
const active = String(cur.path).toUpperCase().startsWith(String(d.name).toUpperCase())
|
|
687
|
+
return React.createElement('button', {
|
|
688
|
+
key: d.path,
|
|
689
|
+
onClick: () => loadLocal(d.path),
|
|
690
|
+
disabled: loading,
|
|
691
|
+
title: d.path,
|
|
692
|
+
style: { ...buttonS, padding: '2px 8px', fontSize: 11, fontFamily: 'monospace', fontWeight: active ? 700 : 400, color: active ? T.ok : T.label },
|
|
693
|
+
}, d.name)
|
|
694
|
+
}),
|
|
695
|
+
) : null,
|
|
696
|
+
React.createElement('div', { style: { overflowY: 'auto', overflowX: 'hidden' } },
|
|
697
|
+
loading ? React.createElement('div', { style: { opacity: 0.7, padding: 12 } }, '加载中…')
|
|
698
|
+
: (cur.entries.length ? cur.entries.slice(0, 400).map((it, i) => React.createElement('div', {
|
|
699
|
+
key: it.path || i, title: '进入 ' + it.name,
|
|
700
|
+
onClick: () => loadLocal(it.path),
|
|
701
|
+
style: { padding: '7px 12px', cursor: 'pointer', color: T.label, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', display: 'flex', gap: 8, alignItems: 'center', fontSize: 13, borderBottom: '1px solid ' + T.border },
|
|
702
|
+
},
|
|
703
|
+
React.createElement('span', null, '📁'),
|
|
704
|
+
React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, it.name),
|
|
705
|
+
)) : React.createElement('div', { style: { opacity: 0.6, padding: 12 } }, '(没有子目录 — 可直接选用当前目录)')),
|
|
706
|
+
cur.truncated ? React.createElement('div', { style: { opacity: 0.6, padding: '6px 12px', fontSize: 11 } }, '(条目过多已截断)') : null,
|
|
707
|
+
),
|
|
708
|
+
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12, padding: '4px 12px' } }, err) : null,
|
|
709
|
+
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center', padding: '8px 12px', borderTop: '1px solid ' + T.border } },
|
|
710
|
+
React.createElement('input', { value: newDirName, onChange: (e) => setNewDirName(e.target.value), placeholder: '新建文件夹', style: { ...inputS, width: 110, flex: '0 0 auto', padding: '4px 8px', fontSize: 12 } }),
|
|
711
|
+
React.createElement('button', { style: { ...buttonS, padding: '4px 10px', whiteSpace: 'nowrap' }, onClick: mkdirLocal, disabled: loading || !newDirName.trim() || !cur.path }, '新建'),
|
|
712
|
+
React.createElement('span', { style: { fontSize: 11, opacity: 0.75, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, '所选: ' + cur.path),
|
|
713
|
+
React.createElement('button', { style: { ...buttonS, fontWeight: 600, whiteSpace: 'nowrap' }, onClick: () => acceptLocalPick(cur.path) }, '选用此路径'),
|
|
714
|
+
),
|
|
715
|
+
),
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
|
|
615
719
|
function renderDirPopup() {
|
|
616
720
|
if (!levels || !levels.length) {
|
|
617
721
|
return React.createElement('div', { style: { opacity: 0.6, fontSize: 12 } }, (loading ? '加载中…' : '正在读取根目录…'))
|
|
@@ -660,13 +764,14 @@ window.__ModuleLoader__.load({
|
|
|
660
764
|
),
|
|
661
765
|
tab === 'local'
|
|
662
766
|
? React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8 } },
|
|
663
|
-
React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '
|
|
767
|
+
React.createElement('div', { style: { fontSize: 12, opacity: 0.8 } }, '弹出系统文件夹对话框选择;不可用时用内置目录浏览,也可直接输入本机目录。'),
|
|
664
768
|
React.createElement('div', { style: { display: 'flex', gap: 6 } },
|
|
665
769
|
React.createElement('input', { value: path, onChange: (e) => setPath(e.target.value), placeholder: '本机目录,如 C:\\Users\\you\\project', style: inputS }),
|
|
666
770
|
React.createElement('button', { style: buttonS, onClick: () => (path.trim() ? onPicked(path) : undefined), disabled: !path.trim() }, '选用此本地路径'),
|
|
667
771
|
),
|
|
668
772
|
React.createElement('button', { style: { ...buttonS, alignSelf: 'flex-start' }, onClick: chooseLocal, disabled: loading }, loading ? '打开中…' : '打开系统文件夹选择器'),
|
|
669
773
|
err ? React.createElement('div', { style: { color: T.danger, fontSize: 12 } }, err) : null,
|
|
774
|
+
localPop ? renderLocalPopup() : null,
|
|
670
775
|
)
|
|
671
776
|
: React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, minHeight: 0, overflowY: 'auto' } },
|
|
672
777
|
React.createElement('div', { style: { display: 'flex', gap: 6, alignItems: 'center' } },
|