dsh-plugin-container 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 CHANGED
@@ -1,342 +1,81 @@
1
- # DSH 容器左边栏插件(Docker + Apple container)
1
+ # DSH 容器
2
2
 
3
- 在 DeepSeek Harness 左侧栏加入 **容器** 工作台,面板右上角始终显示 **Docker** / **Apple container** 两个 provider 按钮,点击即可切换:
3
+ DSH 左栏容器面板:管理 macOS 上的 Docker(colima / Docker Desktop / OrbStack / remote context)和 Apple `container`。
4
4
 
5
- - **Docker**:macOS 上的 Docker CLI / context,兼容 Docker Desktop、colima、OrbStack、Rancher Desktop、Podman 等提供 Docker context 或 socket 的运行时。
6
- - **Apple container**:macOS 26 / Apple silicon 原生 `container` CLI(Containerization 框架)。它不是 Docker:没有 Docker daemon/socket/context,每个容器一个轻量 VM,镜像/卷/网络独立,不支持 Compose。
7
-
8
- ![Docker 概览](docs/docker-overview.png)
9
-
10
- ![Provider switch](docs/provider-switch.png)
11
-
12
- ![Apple container 概览](docs/apple-container.png)
13
-
14
- 当前机器验证环境:
15
-
16
- - macOS 26.5.1,arm64
17
- - Docker CLI 28.2.2,colima context 正常
18
- - Apple `container` CLI 1.0.0(`/opt/homebrew/bin/container`)
19
- - Docker / Apple 两个 provider 均已通过 Playwright UI 冒烟
20
- - Apple 系统服务是否运行以面板状态为准;未启动时点击“启动运行时”
5
+ ![概览](docs/docker-overview.png)
21
6
 
22
7
  ## 功能
23
8
 
24
- ### Docker provider
25
-
26
- - 自动识别 colima、Docker Desktop、OrbStack、远程 context。
27
- - 概览、容器(搜索/筛选/启动/停止/重启/日志/Inspect/删除)、镜像(拉取/Inspect/删除/架构)、卷、网络、Compose(只读)。
28
- - **镜像架构**:列表新增“架构”列;Docker 通过 `image inspect` 补齐,Apple 从 OCI variants 读取,显示如 `linux/arm64/v8`。
29
- - **启动新容器**:镜像下拉选择本地镜像/自定义镜像,名称、命令、端口、环境变量,后台运行;启动后立即可 Attach。
30
- - **从镜像启动**:镜像页每行的播放按钮会用该镜像预填并打开启动容器表单。
31
- - **Attach 终端**:基于 DSH PTY 的 `docker attach`,可以输入命令、Ctrl+C、粘贴,关闭对话框结束会话。
32
- - 运行时启停/重启:colima 完整支持;Docker Desktop / OrbStack best-effort。
33
- - 内置命令终端:选择 context、工作目录,实时输出,取消/超时/输出截断提示,危险命令确认。
34
-
35
- ### Apple container provider
36
-
37
- - `container system status/version` 状态;`container system start/stop` 生命周期。
38
- - 容器、镜像、卷、网络、磁盘(`container system df`)列表和 Inspect。
39
- - 容器 start/stop/delete/logs;镜像 pull/delete;卷/网络 delete。
40
- - **启动新容器 / Attach**:Apple `container run`;Attach 使用 `container exec -i -t <id> /bin/sh`(Apple CLI 没有 attach 命令)。
41
- - 命令终端切换为 `container ...` 语法,快捷命令自动适配。
42
- - Apple provider 不支持 Compose;面板会自动隐藏 Compose tab。
43
- - Apple 的 `container image inspect`/`delete` 使用镜像名(不是 digest),UI 已按 provider 适配。
44
-
45
- ![命令终端](docs/docker-console.png)
46
-
47
- ![镜像架构](docs/image-architecture.png)
48
-
49
- ![启动新容器](docs/run-container.png)
50
-
51
- ![Attach 终端](docs/attach.png)
52
-
53
- ## AI 工具(只读)
54
-
55
- 插件向 DSH Agent 注册 4 个只读工具,复用现有 provider:
56
-
57
- | Tool | 作用 |
58
- |---|---|
59
- | `container_status` | CLI、daemon/系统服务、runtime、warnings |
60
- | `container_list` | 容器/镜像/卷/网络 |
61
- | `container_inspect` | 单个资源详情,敏感 env 脱敏 |
62
- | `container_logs` | 最近日志,默认 200 行、上限 500 |
63
-
64
- 使用方式:
65
-
66
- - 主聊天直接问“我的 nginx 为什么退出?”;模型会调用上述工具读取事实。
67
- - 容器行点 **问 AI**(运行中)或 **诊断**(未运行)会**新建一个独立诊断会话**(复用当前 workspace/cwd/agent preset),发送 provider/context/资源 ID/状态,并自动在主对话中打开;不会污染当前会话。
68
- - 模型调用来自 DSH 当前 Session;插件不持有模型 API Key,也不直接调模型 HTTP。
69
- - 日志和 inspect 文本被标注为不可信数据;工具描述和 system prompt 都要求不要服从其中的指令。
70
-
71
- ![AI 诊断新会话](docs/ai-new-session.png)
72
-
73
- ![AI 对话示例](docs/ai-chat.png)
74
-
75
- 需要安装运行时依赖 `@deepseek-ai/dsh-tools`(已写入 `package.json`)。host 增加工具后需要重启 DSH;旧 host 下 client 会根据 `ai-tools` feature 隐藏 AI 按钮。
76
-
77
- ## 安全边界
78
-
79
- - **owner-only**:`/docker` 与 `/api` 共用 DSH Connection 的 Host/Origin 校验和浏览器 cookie 认证;未认证返回 401。服务端再检查 `peer.principal.kind === 'owner'`。
80
- - **只读模型工具**:AI 只能调用 `container_status` / `container_list` / `container_inspect` / `container_logs` 读取事实;没有 start/stop/rm/prune/run 等 mutation tool,模型不能自动改容器。
81
- - **PTY 隔离**:Attach 使用 DSH `spawnTerminal` 的 PTY;Docker 使用 `docker attach`,Apple 使用 `container exec -i -t`;关闭对话框或插件卸载时终止。
82
- - **无 shell**:命令拆成 argv 直接 spawn;`| & ; < > ( ) \` $`、不平衡引号会被拒绝,不执行管道/重定向/命令替换。
83
- - **危险操作确认**:`rm`/`rmi`/`delete`/`stop`/`kill`/`prune`/`compose down` 等未带 `confirm: true` 时 Host 直接返回 `docker/confirmation-required`,不会 spawn;UI 显示实际命令。
84
- - **上下文锁定**:Docker 命令自动加 `--context`;客户端传入 `--context`/`--host`/`-H`/`--config`/`--tls*` 会被拒绝。Apple provider 没有 context。
85
- - **输出与历史**:输出只在 host 内存中保留(上限 1 MiB/stream),不写日志;命令历史存 localStorage,敏感命令(login/password/token)不记录。
86
- - **权限**:命令以 DSH app 当前用户权限执行,等价于本机终端。`container system start` 可能触发管理员权限/launchd/kernel 安装;插件不会静默 sudo。
9
+ - Docker / Apple container 双 provider,运行时状态、上下文切换
10
+ - 容器、镜像、卷、网络、Compose、命令
11
+ - 容器启动/停止/重启/删除,镜像 pull/inspect/delete
12
+ - 查看日志、inspect、实时 follow
13
+ - **Attach**:Docker `docker attach`;Apple `container exec`
14
+ - **Exec**:进入容器内部 shell(`docker exec -it <id> /bin/sh`)
15
+ - 镜像架构列(arm64/amd64)
16
+ - 只读 AI 工具:`container_status`、`container_list`、`container_inspect`、`container_logs`
17
+ - AI 诊断会新建独立会话,不污染当前对话
87
18
 
88
- ## 安装与生效
19
+ ## 要求
89
20
 
90
- 插件以 DSH bundle 包发布,安装时会自动写入 `dependencies` 和 `dsh.profile.bundles`,**不需要手改 profile 的 `cordis.patch.yml`**。
21
+ - macOS
22
+ - DSH 0.2.x
23
+ - Docker CLI;或安装 [Apple container](https://github.com/apple/container)
24
+ - 网络代理按本机环境配置
91
25
 
92
- 本地 tarball:
93
-
94
- ```bash
95
- dsh plugin --profile desktop add /path/to/dsh-plugin-container-0.2.0.tgz
96
- ```
97
-
98
- 发布到 npm 后:
26
+ ## 安装
99
27
 
100
28
  ```bash
101
29
  dsh plugin --profile desktop add dsh-plugin-container
102
30
  ```
103
31
 
104
- 安装后 profile 会变成:
105
-
106
- ```json
107
- "dependencies": {
108
- "dsh-plugin-container": "file:/path/to/dsh-plugin-container-0.2.0.tgz"
109
- },
110
- "dsh": {
111
- "profile": {
112
- "bundles": [
113
- "@deepseek-ai/dsh-base",
114
- "@deepseek-ai/dsh-web-app",
115
- "dsh-plugin-container"
116
- ]
117
- }
118
- }
119
- ```
120
-
121
- 包内的 `cordis.patch.yml` 会被 DSH 自动应用:
122
-
123
- ```yaml
124
- - insert:
125
- - id: docker-sidebar
126
- name: dsh-plugin-container
127
- ```
128
-
129
- 本机安装后的效果:
130
-
131
- ![本地包安装](docs/package-install.png)
32
+ 重启 DSH。安装器会自动写入 `dsh.profile.bundles`,**不需要手改 `cordis.patch.yml`**。
132
33
 
133
- **host 侧代码更新后必须退出并重开 DSH 一次**;client bundle 改动由 `client-hmr` 热替换。bundle 变化需要重启 host。
134
-
135
- 如果右上角只看到 Docker / Apple container 但没有“启动容器”和 Attach 按钮,说明当前 host 还是旧进程:client 会显示“重启 DSH 以启用启动容器 / Attach”,退出并重开 DSH 即可。
136
-
137
- ### 源码开发安装
138
-
139
- 仍可用绝对路径方式开发:
34
+ 本地开发版:
140
35
 
141
36
  ```bash
142
37
  node scripts/install-profile.mjs --home "$HOME/.dsh" --profile desktop
143
38
  ```
144
39
 
145
- 它会写入带标记的 Loader row,指向当前工作区的 `index.js`,适合改 host 代码时热重载。源码模式不要和 bundle 包同时启用,否则会加载两份插件。
40
+ 源码路径模式与 npm 包模式不要同时启用。
146
41
 
147
- ### 手动安装(源码模式)
42
+ ## 使用
148
43
 
149
- 直接编辑 patch 文件:
44
+ 1. 左栏打开 **容器**
45
+ 2. 顶部切换 Docker / Apple container
46
+ 3. 容器行操作:
47
+ - 启动 / 停止 / 重启 / 删除
48
+ - 日志 / Follow / Inspect
49
+ - Attach:接管容器主进程
50
+ - Exec:在容器内开一个新 shell
51
+ - 问 AI / 诊断:新建独立 DSH 会话,调用只读容器工具
52
+ 4. 镜像 tab 可查看架构,或直接用镜像启动容器
150
53
 
151
- ```yaml
152
- # >>> dsh-plugin-container >>>
153
- - insert:
154
- - id: docker-sidebar
155
- name: '/Users/zaynfang/Codes/dsh/docker/index.js'
156
- # <<< dsh-plugin-container <<<
157
- ```
158
-
159
- ### 卸载
54
+ ## 安全
160
55
 
161
- ```bash
162
- dsh plugin --profile desktop remove dsh-plugin-container
163
- ```
164
-
165
- 再从 `dsh.profile.bundles` 中确认已移除;重启 DSH。源码方式则运行:
166
-
167
- ```bash
168
- node scripts/install-profile.mjs --remove --home "$HOME/.dsh" --profile desktop
169
- ```
170
-
171
- ## 配置
172
-
173
- Loader row 可带可选 `config`:
174
-
175
- ```yaml
176
- - insert:
177
- - id: docker-sidebar
178
- name: '/Users/zaynfang/Codes/dsh/docker/index.js'
179
- config:
180
- dockerPath: /opt/homebrew/bin/docker # 显式配置后不回退自动探测
181
- colimaPath: /opt/homebrew/bin/colima
182
- containerPath: /opt/homebrew/bin/container # Apple container CLI
183
- defaultTimeoutMs: 120000
184
- maxOutputBytes: 1048576
185
- maxConcurrentRuns: 4
186
- enableRuntimeControl: true
187
- ```
188
-
189
- 不配置时:
190
-
191
- - `docker` 依次探测 `/opt/homebrew/bin/docker`、`/usr/local/bin/docker`、`~/.docker/bin/docker`、Docker.app 内置路径,再走 PATH。
192
- - `colima` 依次探测 `/opt/homebrew/bin/colima`、`/usr/local/bin/colima`、`~/go/bin/colima`,再走 PATH。
193
- - `container` 依次探测 `/usr/local/bin/container`、`/opt/homebrew/bin/container`、`~/.local/bin/container`,再走 PATH。
194
- - 运行时启停只在 macOS 且 `enableRuntimeControl !== false` 时可用。
195
-
196
- ## macOS 运行时说明
197
-
198
- Docker 兼容运行时包括 Docker Desktop、Colima、OrbStack、Rancher Desktop、Podman machine,以及 Lima/UTM/Parallels/VMware 里自建的 Docker;它们通常通过 Docker CLI/context 工作,因此由 **Docker provider** 统一管理(具体 context 由各运行时提供)。
199
-
200
- Apple `container` 是独立平台:
201
-
202
- - 仅 macOS 26 + Apple silicon。
203
- - 每个容器一个轻量 VM,没有共享 Docker daemon。
204
- - 安装:从 [apple/container releases](https://github.com/apple/container/releases) 安装,或使用 Homebrew;然后 `container system start`。
205
- - `container system status` 在部分安装上会显示 `unregistered`,但 apiserver 实际可达;插件会用 `container ls` 做健康探测兜底。
206
- - 系统服务启动可能需要管理员权限/launchd/kernel;插件使用 `--disable-kernel-install` 避免交互式挂起,失败时提示在终端执行 `container system start`。
207
- - 不支持 Compose;镜像/卷/网络与 Docker 不共享;不支持一键 restart(需先 stop 再 start)。
208
-
209
- ## 工作原理
210
-
211
- ```text
212
- 左栏 sidebar.panellist + main keyed panel "docker"
213
- │
214
- ▼
215
- Browser ctx.connection.rpc.call('/docker', method, { provider, ... })
216
- │
217
- ▼
218
- Host ctx.webServer prefix route /docker
219
- ctx.connection.admit(req)
220
- DockerService (router)
221
- ├─ Docker provider → docker CLI + context + JobManager(idPrefix=docker)
222
- └─ Apple provider → container CLI + JobManager(idPrefix=apple)
223
- │
224
- ▼
225
- ctx.subprocess.spawn(argv) ← 无 shell,provider 决定 CLI 语法
226
- ```
227
-
228
- - `providers` 返回可用 provider 和默认 provider。
229
- - `status/list/inspect/run/action/runtimeAction` 都接受 `provider`;不传默认 Docker。
230
- - `poll/cancel` 按 run id 前缀(`docker_` / `apple_`)路由,切换 provider 时也能取消旧 run。
231
- - 语义化 `action`(container start/stop/delete/logs、image pull/delete、volume/network delete)由 host 映射成对应 CLI,UI 不拼 Docker/Apple 语法。
232
-
233
- ### RPC 方法
234
-
235
- | method | 说明 |
236
- |---|---|
237
- | `providers` | 可用 provider、版本、运行状态 |
238
- | `status` | CLI、daemon/system、context/runtime、资源计数 |
239
- | `list` | containers / images / volumes / networks / compose(Docker) / disk |
240
- | `inspect` | container / image / volume / network |
241
- | `action` | 语义化快捷操作(含启动新容器) |
242
- | `attach` | 启动 Docker attach / Apple exec PTY 会话 |
243
- | `attachPoll` / `attachInput` / `attachResize` / `attachClose` | Attach 输出、输入、尺寸、关闭 |
244
- | `run` | 启动自由命令作业 |
245
- | `poll` / `cancel` | 增量输出 / 取消 |
246
- | `runtimeAction` | Docker 运行时或 Apple system start/stop |
247
-
248
- ## 发布到 npm
249
-
250
- ```bash
251
- npm login
252
- npm publish --access public
253
- ```
254
-
255
- 发布后安装:
256
-
257
- ```bash
258
- dsh plugin --profile desktop add dsh-plugin-container
259
- ```
260
-
261
- 当前包声明了 `dsh.bundle.patch`,安装器会自动写入 `dependencies` 和 `dsh.profile.bundles`,不需要手改 `cordis.patch.yml`。
56
+ - 所有 Docker/Apple 命令以当前用户权限执行
57
+ - 无 shell 拼接;命令拆成 argv 直接 spawn
58
+ - 删除、stop、prune 等危险操作需要确认
59
+ - AI 工具只读,不提供 start/stop/rm/prune tool
60
+ - 日志和 inspect 被标记为不可信数据,不能作为指令执行
262
61
 
263
62
  ## 开发
264
63
 
265
- 构建期依赖只有 esbuild;`lib/client.js` 已提交,运行不需要 `npm install`。
266
-
267
- 本机网络走 SOCKS5 8119(8119 不是 HTTP proxy):
268
-
269
64
  ```bash
270
- env -u http_proxy -u https_proxy -u ALL_PROXY -u all_proxy -u NODE_USE_ENV_PROXY \
271
- ALL_PROXY=socks5h://127.0.0.1:8119 \
272
- npm_config_proxy=socks5h://127.0.0.1:8119 \
273
- npm_config_https_proxy=socks5h://127.0.0.1:8119 \
274
- NO_PROXY=127.0.0.1,localhost,::1 \
275
- npm install --no-audit --no-fund
65
+ npm install
66
+ npm run build # src/client -> lib/client.js
67
+ npm test # 46 tests
68
+ npm pack # 发布前检查
276
69
  ```
277
70
 
278
- ```bash
279
- npm run build # src/client/index.jsx -> lib/client.js
280
- node --check lib/client.js
281
- npm test # 45 个 node:test 单测
282
- ```
283
-
284
- 修改 client 后重新 build;`client-hmr` 会热替换。修改 `index.js` 或 `src/host/*` 后重启 DSH。
285
-
286
- ### UI 冒烟
287
-
288
- ```bash
289
- PLAYWRIGHT_CHROMIUM_EXECUTABLE="/Users/zaynfang/Library/Caches/ms-playwright/chromium-1208/chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing" \
290
- node scripts/ui-smoke.mjs "http://127.0.0.1:<port>/?token=..."
291
- ```
292
-
293
- 冒烟覆盖:
294
-
295
- - Docker provider:状态、容器/镜像/卷/网络/Compose、`docker ps -a`、`docker events` 取消、危险命令确认、Inspect。
296
- - 切换到 Apple provider:provider 选择器、Apple 运行状态、Compose 隐藏、`container ls -a`。
297
- - 不删除资源、不停止运行时;截图写入 `docs/`。
298
-
299
- ## 文件结构
71
+ 主要结构:
300
72
 
301
73
  ```text
302
- index.js Host 入口 / /docker 认证路由
303
- cordis.patch.yml bundle 安装时自动插入的 Loader row
304
- lib/client.js 构建产物
305
- src/host/service.js provider 路由 + Docker provider
306
- src/host/apple.js Apple container provider
307
- src/host/apple-cli.js container CLI 探测/执行
308
- src/host/attach.js PTY attach 会话管理
309
- src/host/tools.js 只读 Agent Tools
310
- src/host/docker-cli.js Docker CLI
311
- src/host/runtime.js colima/Docker Desktop/OrbStack/remote
312
- src/host/jobs.js 命令作业(docker_/apple_ 前缀)
313
- src/host/parsers.js Docker / Apple JSON 归一化
314
- src/client/ provider 切换、页面、tabs、i18n、styles
315
- src/client/attach.jsx Attach 终端模态
316
- src/client/run-container.jsx 启动新容器表单
317
- scripts/build-client.mjs
318
- scripts/install-profile.mjs
319
- scripts/ui-smoke.mjs
320
- test/*.test.mjs
321
- docs/*.png
74
+ index.js Host 入口
75
+ cordis.patch.yml bundle 安装 patch
76
+ src/host/service.js provider 路由 + Docker
77
+ src/host/apple.js Apple container
78
+ src/host/attach.js Attach/Exec PTY
79
+ src/host/tools.js 只读 Agent Tools
80
+ src/client/ 页面、tabs、i18n、styles
322
81
  ```
323
-
324
- ## 限制
325
-
326
- - Docker `attach` 需要容器有 TTY;“启动容器”表单默认使用 `-dit`,已有容器若创建时没有 TTY,`docker attach` 可能无法交互,请改用日志或重新创建容器。
327
- - Apple container 没有 `attach` 命令;面板使用 `container exec -i -t <id> /bin/sh`,镜像内必须存在 `/bin/sh`。
328
- - Attach 依赖 DSH 的 PTY(`spawnTerminal`);若部署环境禁用 PTY,会返回明确的 `attach-unsupported` 错误。
329
- - Attach 是单终端文本视图,不包含完整 xterm 能力(鼠标、复杂 TUI、图片协议可能不完整)。
330
- - 启动新容器固定后台运行(`-d`);交互式前台运行请使用命令终端。
331
- - Apple provider 仍然不支持 Compose。
332
- - host 新增 Attach/启动新容器后需要再重启 DSH 一次;旧 host 下 client 会显示“重启 DSH 以启用启动容器 / Attach”,不会出现无效按钮。
333
-
334
- ## 故障排查
335
-
336
- - **左栏没有“容器”**:确认 patch 块存在;刷新 GUI;host 代码更新后退出并重开 DSH。
337
- - **只有 Docker,没有 Apple container 选项**:确认 `container` CLI 可执行(`container --version`);刷新;host 需要新版本。
338
- - **Apple 服务显示未启动**:点击“启动运行时”,或终端执行 `container system start`。首次可能需要管理员权限/kernel。
339
- - **Apple 启动卡住/失败**:查看命令终端里的 `container system start` 输出;`--disable-kernel-install` 下缺 kernel 会失败,需先在终端启动一次。
340
- - **Apple 镜像 Inspect 失败**:请使用镜像名(UI 已自动这样做),不要用 digest。
341
- - **Docker Desktop 停止失败**:macOS Automation 权限可能拦截 `osascript`;从菜单栏退出或授予权限。
342
- - **patch 损坏**:用 `cordis.patch.yml.docker.bak` 恢复,或 `install-profile.mjs --remove` 后重试。
package/lib/client.js CHANGED
@@ -48,7 +48,7 @@ var DockerApiError = class extends Error {
48
48
  }
49
49
  };
50
50
  function isAbortError(error) {
51
- return error instanceof DOMException && error.name === "AbortError" || error?.name === "AbortError";
51
+ return error instanceof DOMException && error.name === "AbortError" || error?.name === "AbortError" || error?.code === "docker/cancelled";
52
52
  }
53
53
  function isResult(value) {
54
54
  return typeof value === "object" && value !== null && typeof value.ok === "boolean";
@@ -218,6 +218,11 @@ var IconSparkles = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Svg,
218
218
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M18 14l.7 1.8 1.8.7-1.8.7L18 19l-.7-1.8-1.8-.7 1.8-.7L18 14Z" }),
219
219
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M6 15l.6 1.4L8 17l-1.4.6L6 19l-.6-1.4L4 17l1.4-.6L6 15Z" })
220
220
  ] });
221
+ var IconExec = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Svg, { ...props, children: [
222
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("rect", { x: "3.5", y: "4.5", width: "17", height: "15", rx: "2" }),
223
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "m7.2 9.2 3 2.8-3 2.8M12.5 15h4" }),
224
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M17 9.2h.01" })
225
+ ] });
221
226
  var IconAttach = (props) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Svg, { ...props, children: [
222
227
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M9.5 14.5 14.5 9.5" }),
223
228
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M11 7.5 12.6 5.9a3.2 3.2 0 0 1 4.5 4.5L15.4 12" }),
@@ -311,6 +316,8 @@ var zh = {
311
316
  restartFeatures: "\u91CD\u542F DSH \u4EE5\u542F\u7528\u542F\u52A8\u5BB9\u5668 / Attach",
312
317
  restartForArchitecture: "\u91CD\u542F DSH \u4EE5\u663E\u793A\u955C\u50CF\u67B6\u6784",
313
318
  attach: "Attach",
319
+ exec: "Exec",
320
+ execTitle: "Exec \u7EC8\u7AEF",
314
321
  askAi: "\u95EE AI",
315
322
  diagnose: "\u8BCA\u65AD",
316
323
  askAiSent: "\u5DF2\u53D1\u9001\u5230 AI \u4F1A\u8BDD",
@@ -325,6 +332,7 @@ var zh = {
325
332
  attachHint: "\u5DF2\u8FDE\u63A5\u3002\u70B9\u51FB\u7EC8\u7AEF\u540E\u8F93\u5165\uFF1BCtrl+C \u4E2D\u65AD\uFF0C\u5173\u95ED\u7A97\u53E3\u7ED3\u675F\u4F1A\u8BDD\u3002",
326
333
  attachInputHint: "\u70B9\u51FB\u7EC8\u7AEF\u8F93\u5165 \xB7 Ctrl+C \u4E2D\u65AD",
327
334
  attachClose: "\u7ED3\u675F Attach",
335
+ execClose: "\u7ED3\u675F Exec",
328
336
  attachNotRunning: "\u5BB9\u5668\u672A\u8FD0\u884C\uFF0C\u65E0\u6CD5 attach",
329
337
  followLogs: "\u6301\u7EED\u65E5\u5FD7",
330
338
  inspect: "\u68C0\u67E5",
@@ -553,6 +561,8 @@ var en = {
553
561
  restartFeatures: "Restart DSH to enable Start container / Attach",
554
562
  restartForArchitecture: "Restart DSH to show image architecture",
555
563
  attach: "Attach",
564
+ exec: "Exec",
565
+ execTitle: "Exec terminal",
556
566
  askAi: "Ask AI",
557
567
  diagnose: "Diagnose",
558
568
  askAiSent: "Sent to the AI conversation",
@@ -567,6 +577,7 @@ var en = {
567
577
  attachHint: "Connected. Click the terminal and type; Ctrl+C interrupts. Closing the dialog ends the session.",
568
578
  attachInputHint: "Click terminal to type \xB7 Ctrl+C interrupts",
569
579
  attachClose: "End attach",
580
+ execClose: "End exec",
570
581
  attachNotRunning: "Container is not running; attach is unavailable",
571
582
  followLogs: "Follow logs",
572
583
  inspect: "Inspect",
@@ -748,17 +759,36 @@ function Button({ variant = "ghost", size = "md", className, type = "button", ch
748
759
  );
749
760
  }
750
761
  function IconButton({ label, size = "md", className, children, ...rest }) {
751
- return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
752
- "button",
753
- {
754
- type: "button",
755
- "aria-label": label,
756
- title: label,
757
- className: cx("dsh-docker-icon-btn", size === "sm" && "dsh-docker-sm", className),
758
- ...rest,
759
- children
760
- }
761
- );
762
+ const [tip, setTip] = (0, import_react2.useState)(null);
763
+ const ref = (0, import_react2.useRef)(null);
764
+ const showTip = () => {
765
+ const rect = ref.current?.getBoundingClientRect();
766
+ if (rect === void 0) return;
767
+ const left = Math.min(Math.max(rect.left + rect.width / 2, 16), window.innerWidth - 16);
768
+ setTip({ left, top: Math.max(rect.top - 7, 8) });
769
+ };
770
+ const hideTip = () => setTip(null);
771
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
772
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
773
+ "button",
774
+ {
775
+ type: "button",
776
+ ref,
777
+ "aria-label": label,
778
+ className: cx("dsh-docker-icon-btn", size === "sm" && "dsh-docker-sm", className),
779
+ onMouseEnter: showTip,
780
+ onMouseLeave: hideTip,
781
+ onFocus: showTip,
782
+ onBlur: hideTip,
783
+ ...rest,
784
+ children
785
+ }
786
+ ),
787
+ tip !== null && (0, import_react_dom.createPortal)(
788
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { className: "dsh-docker-tooltip", style: { left: tip.left, top: tip.top }, role: "tooltip", children: label }),
789
+ document.body
790
+ )
791
+ ] });
762
792
  }
763
793
  function Spinner({ size = 16, className }) {
764
794
  return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(IconSpinner, { size, className });
@@ -994,7 +1024,7 @@ function keyBytes(event) {
994
1024
  if (event.key.length === 1) return event.key;
995
1025
  return null;
996
1026
  }
997
- function AttachDialog({ open, t, session, output, loading, error, onInput, onClose }) {
1027
+ function AttachDialog({ open, t, mode = "attach", session, output, loading, error, onInput, onClose }) {
998
1028
  const screenRef = (0, import_react3.useRef)(null);
999
1029
  const preRef = (0, import_react3.useRef)(null);
1000
1030
  const [stick, setStick] = (0, import_react3.useState)(true);
@@ -1010,12 +1040,13 @@ function AttachDialog({ open, t, session, output, loading, error, onInput, onClo
1010
1040
  if (element !== null) element.scrollTop = element.scrollHeight;
1011
1041
  }, [output, stick]);
1012
1042
  const running = session?.status === "running";
1043
+ const title = (session?.mode ?? mode) === "exec" ? t("execTitle") : t("attachTitle");
1013
1044
  const shown = cleanTerminal(output);
1014
1045
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(
1015
1046
  Modal,
1016
1047
  {
1017
1048
  open,
1018
- title: `${t("attachTitle")} \xB7 ${session?.containerName ?? ""}`,
1049
+ title: `${title} \xB7 ${session?.containerName ?? ""}`,
1019
1050
  subtitle: session?.command ?? void 0,
1020
1051
  wide: true,
1021
1052
  onClose,
@@ -1029,7 +1060,7 @@ function AttachDialog({ open, t, session, output, loading, error, onInput, onClo
1029
1060
  session.exitCode
1030
1061
  ] }),
1031
1062
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(CopyButton, { text: shown, label: t("copy"), copiedLabel: t("copied") }),
1032
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Button, { size: "sm", onClick: onClose, children: t("attachClose") })
1063
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(Button, { size: "sm", onClick: onClose, children: t((session?.mode ?? mode) === "exec" ? "execClose" : "attachClose") })
1033
1064
  ] }),
1034
1065
  children: [
1035
1066
  /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("div", { className: "dsh-docker-attach-status", children: [
@@ -1050,7 +1081,7 @@ function AttachDialog({ open, t, session, output, loading, error, onInput, onClo
1050
1081
  className: "dsh-docker-attach-screen",
1051
1082
  tabIndex: 0,
1052
1083
  role: "textbox",
1053
- "aria-label": t("attachTitle"),
1084
+ "aria-label": title,
1054
1085
  onKeyDown: (event) => {
1055
1086
  if (!running || loading) return;
1056
1087
  const data = keyBytes(event);
@@ -1817,6 +1848,7 @@ function ContainersTab({ t, api, provider, context, status, refreshToken, featur
1817
1848
  children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(IconSparkles, { size: 14 })
1818
1849
  }
1819
1850
  ),
1851
+ features.includes("exec") && item.state === "running" && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(IconButton, { size: "sm", label: t("exec"), onClick: () => onAttach(item, "exec"), children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(IconExec, { size: 14 }) }),
1820
1852
  features.includes("attach") && item.state === "running" && /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(IconButton, { size: "sm", label: t("attach"), onClick: () => onAttach(item), children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(IconAttach, { size: 14 }) }),
1821
1853
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(IconButton, { size: "sm", label: t("logs"), onClick: () => act("logs", item, true), children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(IconTerminal, { size: 14 }) }),
1822
1854
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(IconButton, { size: "sm", label: t("followLogs"), onClick: () => act("logs", item, true, true), children: /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(IconTerminal, { size: 14, style: { transform: "scaleX(-1)" } }) }),
@@ -2304,7 +2336,7 @@ function DockerPage(props) {
2304
2336
  const [runtimeBusy, setRuntimeBusy] = (0, import_react13.useState)(null);
2305
2337
  const [inspect, setInspect] = (0, import_react13.useState)({ open: false, loading: false, kind: null, id: null, value: null, raw: "", error: null });
2306
2338
  const [confirm, setConfirm] = (0, import_react13.useState)(null);
2307
- const [attach, setAttach] = (0, import_react13.useState)({ open: false, loading: false, session: null, output: "", error: null });
2339
+ const [attach, setAttach] = (0, import_react13.useState)({ open: false, loading: false, mode: "attach", session: null, output: "", error: null });
2308
2340
  const [runDialog, setRunDialog] = (0, import_react13.useState)({ open: false, image: "" });
2309
2341
  const attachRef = (0, import_react13.useRef)(null);
2310
2342
  const attachRequest = (0, import_react13.useRef)(0);
@@ -2383,6 +2415,19 @@ function DockerPage(props) {
2383
2415
  setRefreshToken((value) => value + 1);
2384
2416
  statusState.refresh();
2385
2417
  }, [statusState.refresh]);
2418
+ const lastRun = (0, import_react13.useRef)(null);
2419
+ (0, import_react13.useEffect)(() => {
2420
+ const run = runState.run;
2421
+ if (run === null || run === void 0) {
2422
+ lastRun.current = null;
2423
+ return;
2424
+ }
2425
+ if (run.status === "running" || run.status === "cancelling") return;
2426
+ const signature = `${run.id ?? ""}:${run.status}`;
2427
+ if (lastRun.current === signature) return;
2428
+ lastRun.current = signature;
2429
+ refreshAll();
2430
+ }, [refreshAll, runState.run?.id, runState.run?.status]);
2386
2431
  const confirmThen = (0, import_react13.useCallback)((config, execute) => {
2387
2432
  setConfirm({
2388
2433
  ...config,
@@ -2481,11 +2526,11 @@ function DockerPage(props) {
2481
2526
  attachRequest.current += 1;
2482
2527
  const session = attachRef.current;
2483
2528
  attachRef.current = null;
2484
- setAttach({ open: false, loading: false, session: null, output: "", error: null });
2529
+ setAttach({ open: false, loading: false, mode: "attach", session: null, output: "", error: null });
2485
2530
  if (session !== null && session?.id !== void 0) api.call("attachClose", { attachId: session.id }).catch(() => {
2486
2531
  });
2487
2532
  }, [api]);
2488
- const openAttach = (0, import_react13.useCallback)(async (container) => {
2533
+ const openAttach = (0, import_react13.useCallback)(async (container, mode = "attach") => {
2489
2534
  if (container?.state !== "running") {
2490
2535
  notify("info", t("attachNotRunning"));
2491
2536
  return;
@@ -2497,13 +2542,14 @@ function DockerPage(props) {
2497
2542
  api.call("attachClose", { attachId: previous.id }).catch(() => {
2498
2543
  });
2499
2544
  }
2500
- setAttach({ open: true, loading: true, session: null, output: "", error: null });
2545
+ setAttach({ open: true, loading: true, mode, session: null, output: "", error: null });
2501
2546
  try {
2502
2547
  const value = await api.call("attach", {
2503
2548
  provider,
2504
2549
  context: selectedContext ?? void 0,
2505
2550
  id: container.id,
2506
2551
  name: container.name || container.id,
2552
+ mode,
2507
2553
  cols: 120,
2508
2554
  rows: 30
2509
2555
  });
@@ -2513,10 +2559,10 @@ function DockerPage(props) {
2513
2559
  return;
2514
2560
  }
2515
2561
  attachRef.current = value.session;
2516
- setAttach({ open: true, loading: false, session: value.session, output: "", error: null });
2562
+ setAttach({ open: true, loading: false, mode, session: value.session, output: "", error: null });
2517
2563
  } catch (error) {
2518
2564
  if (request !== attachRequest.current) return;
2519
- setAttach({ open: true, loading: false, session: null, output: "", error });
2565
+ setAttach({ open: true, loading: false, mode, session: null, output: "", error });
2520
2566
  }
2521
2567
  }, [api, notify, provider, selectedContext, t]);
2522
2568
  const attachInput = (0, import_react13.useCallback)(async (data) => {
@@ -2594,6 +2640,10 @@ function DockerPage(props) {
2594
2640
  }, [canAskAI, notify, provider, selectedContext, startDiagnosis, t]);
2595
2641
  const openRun = (0, import_react13.useCallback)((image = "") => setRunDialog({ open: true, image }), []);
2596
2642
  const closeRun = (0, import_react13.useCallback)(() => setRunDialog({ open: false, image: "" }), []);
2643
+ const scheduleListRefresh = (0, import_react13.useCallback)(() => {
2644
+ window.setTimeout(() => refreshAll(), 900);
2645
+ window.setTimeout(() => refreshAll(), 2600);
2646
+ }, [refreshAll]);
2597
2647
  const runAction = (0, import_react13.useCallback)(async (input, { focusConsole = false } = {}) => {
2598
2648
  if (runs.active) {
2599
2649
  notify("info", t("commandRunning"));
@@ -2604,6 +2654,7 @@ function DockerPage(props) {
2604
2654
  try {
2605
2655
  const run = await runs.startAction(payload);
2606
2656
  if (run !== null && input.kind === "container" && input.action === "run") notify("success", t("runStarted"));
2657
+ if (run !== null) scheduleListRefresh();
2607
2658
  return run;
2608
2659
  } catch (error) {
2609
2660
  if (error instanceof DockerApiError && error.code === "docker/confirmation-required") {
@@ -2617,6 +2668,7 @@ function DockerPage(props) {
2617
2668
  },
2618
2669
  async () => {
2619
2670
  await runs.startAction({ ...payload, confirm: true });
2671
+ scheduleListRefresh();
2620
2672
  }
2621
2673
  );
2622
2674
  return null;
@@ -2624,7 +2676,7 @@ function DockerPage(props) {
2624
2676
  notify("error", errorMessage(error));
2625
2677
  return null;
2626
2678
  }
2627
- }, [actions, confirmThen, notify, provider, runs, t]);
2679
+ }, [actions, confirmThen, notify, provider, runs, scheduleListRefresh, t]);
2628
2680
  const openTab = (0, import_react13.useCallback)((next) => actions.setTab(next), [actions]);
2629
2681
  const tabProps = {
2630
2682
  t,
@@ -2798,6 +2850,7 @@ ${t("statusErrorHint")}`,
2798
2850
  AttachDialog,
2799
2851
  {
2800
2852
  open: attach.open,
2853
+ mode: attach.mode,
2801
2854
  t,
2802
2855
  session: attach.session,
2803
2856
  output: attach.output,
@@ -3160,6 +3213,7 @@ var CSS = `
3160
3213
  .dsh-docker-btn.dsh-docker-sm { height: 26px; padding: 0 9px; font-size: 12px; border-radius: 6px; }
3161
3214
  .dsh-docker-btn.dsh-docker-lg { height: 34px; padding: 0 14px; font-size: 13px; }
3162
3215
  .dsh-docker-icon-btn {
3216
+ position: relative;
3163
3217
  appearance: none; border: .5px solid transparent; background: transparent; cursor: pointer;
3164
3218
  width: 30px; height: 30px; border-radius: 7px; padding: 0;
3165
3219
  display: inline-grid; place-items: center;
@@ -3170,6 +3224,13 @@ var CSS = `
3170
3224
  .dsh-docker-icon-btn[disabled] { opacity: .4; cursor: not-allowed; }
3171
3225
  .dsh-docker-icon-btn.dsh-docker-sm { width: 26px; height: 26px; }
3172
3226
 
3227
+ .dsh-docker-tooltip {
3228
+ position: fixed; z-index: 10000; transform: translate(-50%, -100%);
3229
+ padding: 5px 8px; border-radius: 6px; pointer-events: none; white-space: nowrap;
3230
+ color: var(--dsw-alias-label-primary, #fff); background: var(--dsw-alias-bg-inverse, #23252b);
3231
+ font-size: 11px; line-height: 1.2; box-shadow: 0 4px 14px rgba(0,0,0,.18);
3232
+ }
3233
+
3173
3234
  .dsh-docker-select, .dsh-docker-input {
3174
3235
  appearance: none; -webkit-appearance: none;
3175
3236
  height: 30px; min-width: 0; padding: 0 9px; border-radius: 7px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-container",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "DSH sidebar container panel for macOS Docker runtimes and Apple container",
6
6
  "type": "module",
package/src/client/api.js CHANGED
@@ -20,7 +20,11 @@ export class DockerApiError extends Error {
20
20
 
21
21
  /** @param {unknown} error */
22
22
  export function isAbortError(error) {
23
- return error instanceof DOMException && error.name === 'AbortError' || error?.name === 'AbortError'
23
+ return (
24
+ (error instanceof DOMException && error.name === 'AbortError') ||
25
+ error?.name === 'AbortError' ||
26
+ error?.code === 'docker/cancelled'
27
+ )
24
28
  }
25
29
 
26
30
  /** @param {unknown} value */
@@ -45,7 +45,7 @@ function keyBytes(event) {
45
45
  return null
46
46
  }
47
47
 
48
- export default function AttachDialog({ open, t, session, output, loading, error, onInput, onClose }) {
48
+ export default function AttachDialog({ open, t, mode = 'attach', session, output, loading, error, onInput, onClose }) {
49
49
  const screenRef = useRef(null)
50
50
  const preRef = useRef(null)
51
51
  const [stick, setStick] = useState(true)
@@ -64,11 +64,12 @@ export default function AttachDialog({ open, t, session, output, loading, error,
64
64
  }, [output, stick])
65
65
 
66
66
  const running = session?.status === 'running'
67
+ const title = (session?.mode ?? mode) === 'exec' ? t('execTitle') : t('attachTitle')
67
68
  const shown = cleanTerminal(output)
68
69
  return (
69
70
  <Modal
70
71
  open={open}
71
- title={`${t('attachTitle')} · ${session?.containerName ?? ''}`}
72
+ title={`${title} · ${session?.containerName ?? ''}`}
72
73
  subtitle={session?.command ?? undefined}
73
74
  wide
74
75
  onClose={onClose}
@@ -79,7 +80,7 @@ export default function AttachDialog({ open, t, session, output, loading, error,
79
80
  <span style={{ flex: 1 }} />
80
81
  {session?.exitCode !== null && session?.exitCode !== undefined && <span className="dsh-docker-muted">{t('exitCode')}: {session.exitCode}</span>}
81
82
  <CopyButton text={shown} label={t('copy')} copiedLabel={t('copied')} />
82
- <Button size="sm" onClick={onClose}>{t('attachClose')}</Button>
83
+ <Button size="sm" onClick={onClose}>{t((session?.mode ?? mode) === 'exec' ? 'execClose' : 'attachClose')}</Button>
83
84
  </>
84
85
  }
85
86
  >
@@ -95,7 +96,7 @@ export default function AttachDialog({ open, t, session, output, loading, error,
95
96
  className="dsh-docker-attach-screen"
96
97
  tabIndex={0}
97
98
  role="textbox"
98
- aria-label={t('attachTitle')}
99
+ aria-label={title}
99
100
  onKeyDown={(event) => {
100
101
  if (!running || loading) return
101
102
  const data = keyBytes(event)
@@ -1,5 +1,5 @@
1
1
  /** Small presentational building blocks shared by the Docker tabs. */
2
- import { useEffect, useState } from 'react'
2
+ import { useEffect, useRef, useState } from 'react'
3
3
  import { createPortal } from 'react-dom'
4
4
 
5
5
  import { IconAlert, IconCheck, IconCopy, IconSearch, IconSpinner, IconX } from './icons.jsx'
@@ -30,16 +30,35 @@ export function Button({ variant = 'ghost', size = 'md', className, type = 'butt
30
30
  }
31
31
 
32
32
  export function IconButton({ label, size = 'md', className, children, ...rest }) {
33
+ const [tip, setTip] = useState(null)
34
+ const ref = useRef(null)
35
+ const showTip = () => {
36
+ const rect = ref.current?.getBoundingClientRect()
37
+ if (rect === undefined) return
38
+ const left = Math.min(Math.max(rect.left + rect.width / 2, 16), window.innerWidth - 16)
39
+ setTip({ left, top: Math.max(rect.top - 7, 8) })
40
+ }
41
+ const hideTip = () => setTip(null)
33
42
  return (
34
- <button
35
- type="button"
36
- aria-label={label}
37
- title={label}
38
- className={cx('dsh-docker-icon-btn', size === 'sm' && 'dsh-docker-sm', className)}
39
- {...rest}
40
- >
41
- {children}
42
- </button>
43
+ <>
44
+ <button
45
+ type="button"
46
+ ref={ref}
47
+ aria-label={label}
48
+ className={cx('dsh-docker-icon-btn', size === 'sm' && 'dsh-docker-sm', className)}
49
+ onMouseEnter={showTip}
50
+ onMouseLeave={hideTip}
51
+ onFocus={showTip}
52
+ onBlur={hideTip}
53
+ {...rest}
54
+ >
55
+ {children}
56
+ </button>
57
+ {tip !== null && createPortal(
58
+ <span className="dsh-docker-tooltip" style={{ left: tip.left, top: tip.top }} role="tooltip">{label}</span>,
59
+ document.body,
60
+ )}
61
+ </>
43
62
  )
44
63
  }
45
64
 
@@ -91,6 +91,8 @@ export const zh = {
91
91
  restartFeatures: '重启 DSH 以启用启动容器 / Attach',
92
92
  restartForArchitecture: '重启 DSH 以显示镜像架构',
93
93
  attach: 'Attach',
94
+ exec: 'Exec',
95
+ execTitle: 'Exec 终端',
94
96
  askAi: '问 AI',
95
97
  diagnose: '诊断',
96
98
  askAiSent: '已发送到 AI 会话',
@@ -105,6 +107,7 @@ export const zh = {
105
107
  attachHint: '已连接。点击终端后输入;Ctrl+C 中断,关闭窗口结束会话。',
106
108
  attachInputHint: '点击终端输入 · Ctrl+C 中断',
107
109
  attachClose: '结束 Attach',
110
+ execClose: '结束 Exec',
108
111
  attachNotRunning: '容器未运行,无法 attach',
109
112
  followLogs: '持续日志',
110
113
  inspect: '检查',
@@ -334,6 +337,8 @@ export const en = {
334
337
  restartFeatures: 'Restart DSH to enable Start container / Attach',
335
338
  restartForArchitecture: 'Restart DSH to show image architecture',
336
339
  attach: 'Attach',
340
+ exec: 'Exec',
341
+ execTitle: 'Exec terminal',
337
342
  askAi: 'Ask AI',
338
343
  diagnose: 'Diagnose',
339
344
  askAiSent: 'Sent to the AI conversation',
@@ -348,6 +353,7 @@ export const en = {
348
353
  attachHint: 'Connected. Click the terminal and type; Ctrl+C interrupts. Closing the dialog ends the session.',
349
354
  attachInputHint: 'Click terminal to type · Ctrl+C interrupts',
350
355
  attachClose: 'End attach',
356
+ execClose: 'End exec',
351
357
  attachNotRunning: 'Container is not running; attach is unavailable',
352
358
  followLogs: 'Follow logs',
353
359
  inspect: 'Inspect',
@@ -206,6 +206,14 @@ export const IconSparkles = (props) => (
206
206
  </Svg>
207
207
  )
208
208
 
209
+ export const IconExec = (props) => (
210
+ <Svg {...props}>
211
+ <rect x="3.5" y="4.5" width="17" height="15" rx="2" />
212
+ <path d="m7.2 9.2 3 2.8-3 2.8M12.5 15h4" />
213
+ <path d="M17 9.2h.01" />
214
+ </Svg>
215
+ )
216
+
209
217
  export const IconAttach = (props) => (
210
218
  <Svg {...props}>
211
219
  <path d="M9.5 14.5 14.5 9.5" />
@@ -97,7 +97,7 @@ export default function DockerPage(props) {
97
97
  const [runtimeBusy, setRuntimeBusy] = useState(null)
98
98
  const [inspect, setInspect] = useState({ open: false, loading: false, kind: null, id: null, value: null, raw: '', error: null })
99
99
  const [confirm, setConfirm] = useState(null)
100
- const [attach, setAttach] = useState({ open: false, loading: false, session: null, output: '', error: null })
100
+ const [attach, setAttach] = useState({ open: false, loading: false, mode: 'attach', session: null, output: '', error: null })
101
101
  const [runDialog, setRunDialog] = useState({ open: false, image: '' })
102
102
  const attachRef = useRef(null)
103
103
  const attachRequest = useRef(0)
@@ -186,6 +186,22 @@ export default function DockerPage(props) {
186
186
  statusState.refresh()
187
187
  }, [statusState.refresh])
188
188
 
189
+ // Some very short actions finish before the controller's callback is observed;
190
+ // refresh on every terminal run transition so the list/count cannot stay stale.
191
+ const lastRun = useRef(null)
192
+ useEffect(() => {
193
+ const run = runState.run
194
+ if (run === null || run === undefined) {
195
+ lastRun.current = null
196
+ return
197
+ }
198
+ if (run.status === 'running' || run.status === 'cancelling') return
199
+ const signature = `${run.id ?? ''}:${run.status}`
200
+ if (lastRun.current === signature) return
201
+ lastRun.current = signature
202
+ refreshAll()
203
+ }, [refreshAll, runState.run?.id, runState.run?.status])
204
+
189
205
  const confirmThen = useCallback((config, execute) => {
190
206
  setConfirm({
191
207
  ...config,
@@ -288,11 +304,11 @@ export default function DockerPage(props) {
288
304
  attachRequest.current += 1
289
305
  const session = attachRef.current
290
306
  attachRef.current = null
291
- setAttach({ open: false, loading: false, session: null, output: '', error: null })
307
+ setAttach({ open: false, loading: false, mode: 'attach', session: null, output: '', error: null })
292
308
  if (session !== null && session?.id !== undefined) api.call('attachClose', { attachId: session.id }).catch(() => {})
293
309
  }, [api])
294
310
 
295
- const openAttach = useCallback(async (container) => {
311
+ const openAttach = useCallback(async (container, mode = 'attach') => {
296
312
  if (container?.state !== 'running') {
297
313
  notify('info', t('attachNotRunning'))
298
314
  return
@@ -303,13 +319,14 @@ export default function DockerPage(props) {
303
319
  attachRef.current = null
304
320
  api.call('attachClose', { attachId: previous.id }).catch(() => {})
305
321
  }
306
- setAttach({ open: true, loading: true, session: null, output: '', error: null })
322
+ setAttach({ open: true, loading: true, mode, session: null, output: '', error: null })
307
323
  try {
308
324
  const value = await api.call('attach', {
309
325
  provider,
310
326
  context: selectedContext ?? undefined,
311
327
  id: container.id,
312
328
  name: container.name || container.id,
329
+ mode,
313
330
  cols: 120,
314
331
  rows: 30,
315
332
  })
@@ -318,10 +335,10 @@ export default function DockerPage(props) {
318
335
  return
319
336
  }
320
337
  attachRef.current = value.session
321
- setAttach({ open: true, loading: false, session: value.session, output: '', error: null })
338
+ setAttach({ open: true, loading: false, mode, session: value.session, output: '', error: null })
322
339
  } catch (error) {
323
340
  if (request !== attachRequest.current) return
324
- setAttach({ open: true, loading: false, session: null, output: '', error })
341
+ setAttach({ open: true, loading: false, mode, session: null, output: '', error })
325
342
  }
326
343
  }, [api, notify, provider, selectedContext, t])
327
344
 
@@ -406,6 +423,13 @@ export default function DockerPage(props) {
406
423
  const openRun = useCallback((image = '') => setRunDialog({ open: true, image }), [])
407
424
  const closeRun = useCallback(() => setRunDialog({ open: false, image: '' }), [])
408
425
 
426
+ const scheduleListRefresh = useCallback(() => {
427
+ // The job may finish before the run poll is observed by this page; a short
428
+ // second timer makes stop/delete reliably drop out of the list.
429
+ window.setTimeout(() => refreshAll(), 900)
430
+ window.setTimeout(() => refreshAll(), 2600)
431
+ }, [refreshAll])
432
+
409
433
  const runAction = useCallback(async (input, { focusConsole = false } = {}) => {
410
434
  if (runs.active) {
411
435
  notify('info', t('commandRunning'))
@@ -416,6 +440,7 @@ export default function DockerPage(props) {
416
440
  try {
417
441
  const run = await runs.startAction(payload)
418
442
  if (run !== null && input.kind === 'container' && input.action === 'run') notify('success', t('runStarted'))
443
+ if (run !== null) scheduleListRefresh()
419
444
  return run
420
445
  } catch (error) {
421
446
  if (error instanceof DockerApiError && error.code === 'docker/confirmation-required') {
@@ -429,6 +454,7 @@ export default function DockerPage(props) {
429
454
  },
430
455
  async () => {
431
456
  await runs.startAction({ ...payload, confirm: true })
457
+ scheduleListRefresh()
432
458
  },
433
459
  )
434
460
  return null
@@ -436,7 +462,7 @@ export default function DockerPage(props) {
436
462
  notify('error', errorMessage(error))
437
463
  return null
438
464
  }
439
- }, [actions, confirmThen, notify, provider, runs, t])
465
+ }, [actions, confirmThen, notify, provider, runs, scheduleListRefresh, t])
440
466
 
441
467
  const openTab = useCallback((next) => actions.setTab(next), [actions])
442
468
 
@@ -631,6 +657,7 @@ export default function DockerPage(props) {
631
657
 
632
658
  <AttachDialog
633
659
  open={attach.open}
660
+ mode={attach.mode}
634
661
  t={t}
635
662
  session={attach.session}
636
663
  output={attach.output}
@@ -89,6 +89,7 @@ const CSS = `
89
89
  .dsh-docker-btn.dsh-docker-sm { height: 26px; padding: 0 9px; font-size: 12px; border-radius: 6px; }
90
90
  .dsh-docker-btn.dsh-docker-lg { height: 34px; padding: 0 14px; font-size: 13px; }
91
91
  .dsh-docker-icon-btn {
92
+ position: relative;
92
93
  appearance: none; border: .5px solid transparent; background: transparent; cursor: pointer;
93
94
  width: 30px; height: 30px; border-radius: 7px; padding: 0;
94
95
  display: inline-grid; place-items: center;
@@ -99,6 +100,13 @@ const CSS = `
99
100
  .dsh-docker-icon-btn[disabled] { opacity: .4; cursor: not-allowed; }
100
101
  .dsh-docker-icon-btn.dsh-docker-sm { width: 26px; height: 26px; }
101
102
 
103
+ .dsh-docker-tooltip {
104
+ position: fixed; z-index: 10000; transform: translate(-50%, -100%);
105
+ padding: 5px 8px; border-radius: 6px; pointer-events: none; white-space: nowrap;
106
+ color: var(--dsw-alias-label-primary, #fff); background: var(--dsw-alias-bg-inverse, #23252b);
107
+ font-size: 11px; line-height: 1.2; box-shadow: 0 4px 14px rgba(0,0,0,.18);
108
+ }
109
+
102
110
  .dsh-docker-select, .dsh-docker-input {
103
111
  appearance: none; -webkit-appearance: none;
104
112
  height: 30px; min-width: 0; padding: 0 9px; border-radius: 7px;
@@ -12,7 +12,7 @@ import {
12
12
  StateLabel,
13
13
  } from '../components.jsx'
14
14
  import { useDockerList } from '../hooks.js'
15
- import { IconAlert, IconAttach, IconBox, IconInfo, IconPlay, IconRestart, IconSparkles, IconStop, IconTerminal, IconTrash } from '../icons.jsx'
15
+ import { IconAlert, IconAttach, IconBox, IconExec, IconInfo, IconPlay, IconRestart, IconSparkles, IconStop, IconTerminal, IconTrash } from '../icons.jsx'
16
16
 
17
17
  export default function ContainersTab({ t, api, provider, context, status, refreshToken, features, onAction, onAttach, onRunContainer, onAskAI, canAskAI, onInspect }) {
18
18
  const [all, setAll] = useState(true)
@@ -112,6 +112,11 @@ export default function ContainersTab({ t, api, provider, context, status, refre
112
112
  <IconSparkles size={14} />
113
113
  </IconButton>
114
114
  )}
115
+ {features.includes('exec') && item.state === 'running' && (
116
+ <IconButton size="sm" label={t('exec')} onClick={() => onAttach(item, 'exec')}>
117
+ <IconExec size={14} />
118
+ </IconButton>
119
+ )}
115
120
  {features.includes('attach') && item.state === 'running' && (
116
121
  <IconButton size="sm" label={t('attach')} onClick={() => onAttach(item)}>
117
122
  <IconAttach size={14} />
@@ -53,6 +53,7 @@ export class AttachManager {
53
53
  * containerId: string,
54
54
  * containerName?: string,
55
55
  * command: string,
56
+ * mode?: 'attach' | 'exec',
56
57
  * cols?: number,
57
58
  * rows?: number,
58
59
  * signal?: AbortSignal,
@@ -95,6 +96,7 @@ export class AttachManager {
95
96
  containerId: input.containerId,
96
97
  containerName: input.containerName ?? input.containerId,
97
98
  command: input.command,
99
+ mode: input.mode === 'exec' ? 'exec' : 'attach',
98
100
  cols,
99
101
  rows,
100
102
  status: 'running',
@@ -146,6 +148,7 @@ export class AttachManager {
146
148
  containerId: session.containerId,
147
149
  containerName: session.containerName,
148
150
  command: session.command,
151
+ mode: session.mode,
149
152
  cols: session.cols,
150
153
  rows: session.rows,
151
154
  status: session.status,
@@ -256,7 +256,7 @@ export class DockerService {
256
256
  }
257
257
  providers.push(await this.apple.info(signal))
258
258
  const defaultProvider = providers.find((provider) => provider.id === 'docker' && provider.available)?.id ?? providers.find((provider) => provider.available)?.id ?? 'docker'
259
- const features = ['attach', 'run', 'image-architecture']
259
+ const features = ['attach', 'exec', 'run', 'image-architecture']
260
260
  if (this.toolsEnabled) features.push('ai-tools')
261
261
  return { providers, defaultProvider, features }
262
262
  }
@@ -645,6 +645,7 @@ export class DockerService {
645
645
  async attachStart(provider, payload, signal) {
646
646
  const id = safeIdentifier(payload.id)
647
647
  const name = optionalString(payload.name, 'name') ?? id
648
+ const mode = payload.mode === 'exec' ? 'exec' : 'attach'
648
649
  const cols = optionalInteger(payload.cols, 'cols', { min: 20, max: 300 }) ?? 120
649
650
  const rows = optionalInteger(payload.rows, 'rows', { min: 5, max: 120 }) ?? 30
650
651
  if (provider === 'apple-container') {
@@ -657,7 +658,7 @@ export class DockerService {
657
658
  } catch (error) {
658
659
  throw new CommandError(`无法读取 Apple container 状态: ${messageOf(error)}`, 'container/exec-failed')
659
660
  }
660
- if (!running) throw new CommandError('容器未运行,无法 attach/exec', 'container/not-running')
661
+ if (!running) throw new CommandError(`容器未运行,无法 ${mode}`, 'container/not-running')
661
662
  const argv = [path, 'exec', '-i', '-t', id, '/bin/sh']
662
663
  return {
663
664
  session: await this.attach.start({
@@ -668,6 +669,7 @@ export class DockerService {
668
669
  containerId: id,
669
670
  containerName: name,
670
671
  command: `container exec -i -t ${id} /bin/sh`,
672
+ mode,
671
673
  cols,
672
674
  rows,
673
675
  signal,
@@ -682,16 +684,18 @@ export class DockerService {
682
684
  signal,
683
685
  timeoutMs: 15_000,
684
686
  })
685
- if (running.stdout.trim() !== 'true') throw new CommandError('容器未运行,无法 attach', 'docker/not-running')
687
+ if (running.stdout.trim() !== 'true') throw new CommandError(`容器未运行,无法 ${mode}`, 'docker/not-running')
688
+ const subargv = mode === 'exec' ? ['exec', '-i', '-t', id, '/bin/sh'] : ['attach', id]
686
689
  return {
687
690
  session: await this.attach.start({
688
- argv: this.cli.argvFor(['attach', id], { context }),
691
+ argv: this.cli.argvFor(subargv, { context }),
689
692
  cwd: this.home,
690
693
  env: this.cli.childEnv(),
691
694
  provider: 'docker',
692
695
  containerId: id,
693
696
  containerName: name,
694
- command: describeCommand(['attach', id], context),
697
+ command: describeCommand(subargv, context),
698
+ mode,
695
699
  cols,
696
700
  rows,
697
701
  signal,