dsh-over-ssh 0.2.3 → 0.2.5
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 +74 -4
- package/agents/agent-manifest.json +7 -7
- package/agents/linux-amd64/dsh-over-ssh-agent +0 -0
- package/agents/linux-arm64/dsh-over-ssh-agent +0 -0
- package/lib/client.js +78 -38
- package/lib/fs.js +2 -1
- package/lib/index.js +1139 -130
- package/lib/types/src/config.d.ts +44 -0
- package/lib/types/src/host/app/backend.d.ts +31 -0
- package/lib/types/src/host/app/routed-fs.d.ts +238 -0
- package/lib/types/src/host/app/transport/transport-service.d.ts +66 -11
- package/lib/types/src/host/domain/capability.d.ts +6 -0
- package/lib/types/src/host/domain/roots/route-table.d.ts +101 -0
- package/lib/types/src/host/infra/backend/poll-watch.d.ts +119 -0
- package/lib/types/src/host/infra/backend/remote-backend.d.ts +297 -0
- package/lib/types/src/host/infra/cache/budget.d.ts +68 -0
- package/lib/types/src/host/infra/cache/versioned.d.ts +98 -0
- package/lib/types/src/host/infra/transport/ssh/askpass.d.ts +132 -0
- package/lib/types/src/host/infra/transport/transport.d.ts +48 -0
- package/lib/types/src/host/integration/agent/askpass-registry.d.ts +65 -0
- package/lib/types/src/host/integration/fs/remote-index.d.ts +183 -0
- package/lib/types/src/host/integration/roots/remote-home.d.ts +68 -0
- package/lib/types/src/host/integration/roots/root-registry.d.ts +148 -0
- package/lib/types/src/index.d.ts +7 -0
- package/lib/types/src/wiring/connections.d.ts +261 -0
- package/lib/types/src/wiring/install-memo.d.ts +31 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,6 +41,76 @@ shell:
|
|
|
41
41
|
|
|
42
42
|
不配也能用,只是 Web 终端永远开在本地(远程文件、远端执行、`@` 补全不受影响)。
|
|
43
43
|
|
|
44
|
+
## 0.2.5 更新
|
|
45
|
+
|
|
46
|
+
> 相对 npm 上的 **0.2.4** 的补丁版本,只有一处修复。
|
|
47
|
+
|
|
48
|
+
- **修复:释放一条连接/删除一个会话,不再取消无关连接正在进行的重连。** transport 的"已释放代际"
|
|
49
|
+
原来是**全局**计数器:`release(连接 A)` 或删除任意会话都会让它自增,于是连接 B 正在 `discard` 旧
|
|
50
|
+
传输、准备重建的 `acquire()` 会拿到一次 `CANCELLED`,调用方无法把它与真实故障区分。现在按连接计数,
|
|
51
|
+
只有真正被释放的那条连接会取消自己的重建。同时补齐"重建窗口内删除本会话仍然取消"的判断:
|
|
52
|
+
该窗口里 slot 与 entry 都已删除、pending 尚未登记,新增 `#rebuilding` 记录在飞的 lane key,
|
|
53
|
+
`releaseSession` 依据 slots/entries/pending/rebuilding 四张表判断归属。
|
|
54
|
+
- **修复:代理侧的编辑/补丁/删除/改名把版本守卫与读-改-写放进同一段临界区。** 之前只有 `fs.write`
|
|
55
|
+
内部的"校验 + rename"持路径条带锁:并发的 `fs.edit`/`fs.patch` 都能读到同一份旧内容,在 mtime 粒度粗的
|
|
56
|
+
文件系统上(`size:mtime` 版本串两次写之间不变)两边都会通过守卫、后者静默覆盖前者;在 ns 粒度上则表现
|
|
57
|
+
为并发编辑偶发 `E_VERSION_CONFLICT`。现在 `Edit`/`Patch` 的整段读-改-写、`Remove` 的校验+删除、
|
|
58
|
+
`Rename` 的源/目标(按字典序取锁,避免 A→B 与 B→A 互等)都在同一把路径锁里。
|
|
59
|
+
- **修复:`fs.copy` / `fs.copyTree` 的目标侧判定与落地现在真的原子。** 旧实现先 `os.Stat(to)` 判断目标是否存在、
|
|
60
|
+
再拷临时文件、再 rename,三段之间没有按目标路径串行化:两个并发 `fs.copy(overwrite=false)` 能同时
|
|
61
|
+
看到"目标不存在",于是两边都报成功、后者覆盖前者(实测 2 并发 = 2 次成功)。现在目标侧的检查与落地
|
|
62
|
+
在同一把路径锁里,实测并发下恰好 1 次成功、另一次 `E_VERSION_CONFLICT`。`fs.copyTree` 同样:
|
|
63
|
+
目标"不存在/为空"的判定与整棵拷贝同持目标锁,避免两棵交错的树同时落盘(旧行为下失败方还会报
|
|
64
|
+
`E_IO: mkdir ... file exists` 而不是契约里的 `E_VERSION_CONFLICT`)。
|
|
65
|
+
|
|
66
|
+
## 0.2.4 更新
|
|
67
|
+
|
|
68
|
+
> 下面这些内容相对 npm 上的 **0.2.3** 一起生效。
|
|
69
|
+
|
|
70
|
+
### 传输与内存
|
|
71
|
+
|
|
72
|
+
- transport lanes(审计 0164):interactive/bulk/compute 分 lane(`limits.transportLanes`,失败回退 interactive);job/LSP 按会话、watch 按会话(agentless 时连接级)使用 stateful lane(`limits.statefulLanes` 默认 8);transfer stat 走 bulk;Go 在飞窗口从 8 提到 16;proc/LSP/index 按会话公平配额(含后来者预留名额)。
|
|
73
|
+
- P6 读/解析分离:读协程只读原始帧,zstd 解压、JSON 解析与 blob 解压移出读协程;原始帧队列有界,慢盘/大 blob 不再阻塞同连接其它会话。
|
|
74
|
+
- 修复 P6 读错误被 `readBlob` 消费后主循环永久阻塞;大帧解析后把 parser 缓冲降回 64 KiB;`#pollListings` 快照表加 64 条上限;并发上传的 stdin drain 监听器合并。
|
|
75
|
+
- 修复重连 discard 期间 `releaseSession` 被忽略导致已删除会话 lane 复活;selector 抛错泄漏 lane 槽;watch 按会话分 lane 后会话删除会回收 `watch:<session>`。
|
|
76
|
+
- 修复浏览后端 dispose 拒绝产生未处理拒绝;`#sendCancel` 写失败遗留 pending 槽。
|
|
77
|
+
|
|
78
|
+
### 会话作用域与「添加目录」
|
|
79
|
+
|
|
80
|
+
- 会话根按 `sessions` + `workspaces` 双重作用域过滤;带 `scopedForSession` 时,客户端仍按当前 workspace 过滤未限定行,dev 热修。
|
|
81
|
+
- 工作区路径比较容忍尾斜杠与 Windows 大小写差异;真正切换会话/工作区时清空旧 roots,并复位卡片的 `open`/`browsing`。
|
|
82
|
+
- 会话作用域修复覆盖:切会话/工作区不再显示上一条会话的目录;同 workspace 的两个会话按 session 隔离;fork 继承仍可见。
|
|
83
|
+
|
|
84
|
+
### Agent / 子进程
|
|
85
|
+
|
|
86
|
+
- 修复取消排队请求不生效:`sessionTable.Cancel` 摘除等待队列与 blob 队列,`dispatchMu` 覆盖出队+launch 与取消。
|
|
87
|
+
- 修复子进程启动失败泄漏 fd:interactive `proc.start` 的 stdin,`rg`/`git status`/`git ls-files` 的 stdout,LSP stdout。
|
|
88
|
+
- 修复 JSON 编码尺寸估算漏算 HTML 转义(`<`/`>`/`&`/U+2028/U+2029),避免含 `<` 的大输出被误判装得下而超帧。
|
|
89
|
+
|
|
90
|
+
### 数据正确性与工具
|
|
91
|
+
|
|
92
|
+
- `remote_read_many`:一次往返读多个文件(1..16,默认每文件 256 KiB),非 UTF-8 给 `binary`/`dataBase64` 与渲染警告;修复批量读总量上限与二进制截断一致。
|
|
93
|
+
- `remote_project_summary`:一次往返给 git 状态、顶层结构、包清单、README 头与 test/build/lint 建议。
|
|
94
|
+
- `remote_apply_patch`:一次往返多文件补丁,先全量校验,默认失败回滚。
|
|
95
|
+
- `remote_exec` / `remote_job`:非 UTF-8 输出附带 base64 精确字节与渲染警告;`remote_job` 不再丢 exactBytes。
|
|
96
|
+
- 协议 v3 zstd 跨帧压缩;`remote_grep` 支持 `offset`/`total`/`nextOffset` 分页;Tier 2 批量读走代理 `fs.readMany`。
|
|
97
|
+
- LSP:`server` 支持带空格路径的引号切分、`lsp.read` 每轮 256 KiB、跨块多字节字符按 `exactBytes` 解析;卡片的初始焦点与 Tab 集合跳过不可见控件。
|
|
98
|
+
- 文件引用补全:单来源失败只降级该来源;Windows 反斜杠路径参与排序;符号链接按目录处理,根内展开、根外只列链接;同根并发走查共享。
|
|
99
|
+
- 端口转发:`net.probe` 远端预检,关闭端口在 start 阶段稳定失败;IPv6 `[::1]` 归一;旧代理没有 `net.probe` 时跳过预检继续。
|
|
100
|
+
- 回收站与锁:父子还原、锁心跳、缺 `entryId` 行可见不可操作、已删连接编辑不再提交。
|
|
101
|
+
- 会话管理页:修正短 id、工作区目录内联、行内回收站按钮、已选计数。
|
|
102
|
+
|
|
103
|
+
### 其它修复
|
|
104
|
+
|
|
105
|
+
- `remote_read_many`、`remote_exec`、`remote_job` 的二进制/截断/渲染一致性问题(审计 0160–0162)。
|
|
106
|
+
- `writeFile` 非 Uint8Array 参数本地立即拒绝;`repo.summary` 只读普通文件;`patchMany` 超大文件先判大小;`fs.readMany` 先校验再起协程。
|
|
107
|
+
- 主菜单/渲染修复:项目概览补顶层/清单/README、分页提示按 Tier 分档、`remote_grep` 真正分页、zstd 小帧只压缩一次。
|
|
108
|
+
|
|
109
|
+
### 其它
|
|
110
|
+
|
|
111
|
+
- 组合层仍为 6 个条目;本地写入围栏由平台沙箱执行。
|
|
112
|
+
- 其余行为不变。
|
|
113
|
+
|
|
44
114
|
## 0.2.3 更新
|
|
45
115
|
|
|
46
116
|
> 下面这些内容相对 npm 上的 **0.2.2** 一起生效。
|
|
@@ -84,11 +154,11 @@ shell:
|
|
|
84
154
|
|
|
85
155
|
| 平台 | 字节 | sha256 |
|
|
86
156
|
|---|---|---|
|
|
87
|
-
| linux-amd64 |
|
|
88
|
-
| linux-arm64 |
|
|
157
|
+
| linux-amd64 | 5,595,296 | `38a32c04c60005cbe3688b05f56b2eed348e2d6de1b4d8dca925f203dd978ac2` |
|
|
158
|
+
| linux-arm64 | 5,243,040 | `eb48c16da284caebd1de3656b3f8be7c7e8741d48bc20e012d07ecd7824d9ba4` |
|
|
89
159
|
|
|
90
|
-
- 代理版本:`0.2.
|
|
91
|
-
- 源码提交:`
|
|
160
|
+
- 代理版本:`0.2.5-59d9305`
|
|
161
|
+
- 源码提交:`59d9305`(Go 仓 `dsh-over-ssh-agent` 的这个提交)
|
|
92
162
|
- 清单原文:包内 `agents/agent-manifest.json`
|
|
93
163
|
- 核对:`sha256sum agents/<平台>/dsh-over-ssh-agent` 与上表比对;按上面的提交检出 Go 仓后
|
|
94
164
|
`scripts/check.sh` 可重建同样的产物。
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
|
-
"generatedAt": "2026-11-
|
|
4
|
-
"agentVersion": "0.2.
|
|
5
|
-
"sourceCommit": "
|
|
3
|
+
"generatedAt": "2026-11-08T23:42:20.933Z",
|
|
4
|
+
"agentVersion": "0.2.5-59d9305",
|
|
5
|
+
"sourceCommit": "59d9305",
|
|
6
6
|
"artifacts": [
|
|
7
7
|
{
|
|
8
8
|
"platform": "linux-amd64",
|
|
9
9
|
"path": "linux-amd64/dsh-over-ssh-agent",
|
|
10
|
-
"sha256": "
|
|
11
|
-
"bytes":
|
|
10
|
+
"sha256": "38a32c04c60005cbe3688b05f56b2eed348e2d6de1b4d8dca925f203dd978ac2",
|
|
11
|
+
"bytes": 5595296
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
"platform": "linux-arm64",
|
|
15
15
|
"path": "linux-arm64/dsh-over-ssh-agent",
|
|
16
|
-
"sha256": "
|
|
17
|
-
"bytes":
|
|
16
|
+
"sha256": "eb48c16da284caebd1de3656b3f8be7c7e8741d48bc20e012d07ecd7824d9ba4",
|
|
17
|
+
"bytes": 5243040
|
|
18
18
|
}
|
|
19
19
|
]
|
|
20
20
|
}
|
|
Binary file
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -90,6 +90,8 @@ var OVER_SSH_DICTS = {
|
|
|
90
90
|
sessionPanelDeletePermanent: "\u6C38\u4E45\u5220\u9664\u2026",
|
|
91
91
|
sessionPanelRestore: "\u8FD8\u539F",
|
|
92
92
|
sessionPanelPurge: "\u6C38\u4E45\u6E05\u9664\u2026",
|
|
93
|
+
sessionPanelRowRecycle: "\u56DE\u6536\u7AD9",
|
|
94
|
+
sessionPanelSelectedCount: "\u5DF2\u9009 {count} \u4E2A",
|
|
93
95
|
sessionPanelSweep: "\u6309\u4FDD\u7559\u7B56\u7565\u6E05\u7406",
|
|
94
96
|
sessionPanelRefresh: "\u5237\u65B0",
|
|
95
97
|
sessionPanelClose: "\u5173\u95ED",
|
|
@@ -102,7 +104,7 @@ var OVER_SSH_DICTS = {
|
|
|
102
104
|
sessionPanelConfirmPurgeBody: "\u5C06\u6C38\u4E45\u6E05\u9664\u56DE\u6536\u7AD9\u6761\u76EE {targets}\uFF0C\u4E0D\u53EF\u6062\u590D\u3002",
|
|
103
105
|
sessionPanelConfirmCancel: "\u53D6\u6D88",
|
|
104
106
|
sessionPanelConfirmAccept: "\u786E\u8BA4\u6C38\u4E45\u5220\u9664",
|
|
105
|
-
sessionPanelDeleteWorkspace: "\u5220\u9664\
|
|
107
|
+
sessionPanelDeleteWorkspace: "\u5220\u9664\u672C\u5DE5\u4F5C\u533A\u2026",
|
|
106
108
|
// 必须是**模板字符串**而不是函数:字典的契约是 Record<OverSshKey, string>,渲染统一走
|
|
107
109
|
// resolveMessage 的 `template.replace(...)`。写成函数会让面板在渲染时抛
|
|
108
110
|
// TypeError: template.replace is not a function,整块白屏。
|
|
@@ -248,8 +250,7 @@ var OVER_SSH_DICTS = {
|
|
|
248
250
|
browseChoose: "\u7528\u8FD9\u4E2A\u76EE\u5F55",
|
|
249
251
|
sessionAddOpen: "\u6DFB\u52A0\u76EE\u5F55",
|
|
250
252
|
sessionAddTitle: "\u6DFB\u52A0\u76EE\u5F55\u5230\u5F53\u524D\u5DE5\u4F5C\u533A",
|
|
251
|
-
sessionAddHint: "\
|
|
252
|
-
sessionAddWorkspace: "\u5F53\u524D\u5DE5\u4F5C\u533A\uFF1A{path}",
|
|
253
|
+
sessionAddHint: "\u52A0\u5165\u672C\u4F1A\u8BDD\uFF1A\u6587\u4EF6\u5DE5\u5177\u4E0E @ \u8865\u5168\u7ACB\u5373\u53EF\u7528\uFF0C\u4E0D\u4F1A\u65B0\u5EFA\u5DE5\u4F5C\u533A\u3002",
|
|
253
254
|
sessionAddWorkspaceUnknown: "\u62FF\u4E0D\u5230\u5F53\u524D\u5DE5\u4F5C\u533A\u8DEF\u5F84\uFF0C\u65E0\u6CD5\u628A\u76EE\u5F55\u52A0\u5165\u672C\u4F1A\u8BDD\uFF08\u672C\u7EC4\u5408\u6CA1\u6709\u63D0\u4F9B\u4F1A\u8BDD\u6216\u5DE5\u4F5C\u533A\u670D\u52A1\u65F6\uFF0C\u8FD9\u4E2A\u5165\u53E3\u4E0D\u53EF\u7528\uFF09\u3002",
|
|
254
255
|
sessionAddPick: "\u52A0\u5165\u8FD9\u4E2A\u76EE\u5F55",
|
|
255
256
|
sessionBrowse: "\u6D4F\u89C8\u2026",
|
|
@@ -257,9 +258,9 @@ var OVER_SSH_DICTS = {
|
|
|
257
258
|
sessionRootsTitle: "\u672C\u4F1A\u8BDD\u53EF\u7528\u7684\u76EE\u5F55",
|
|
258
259
|
sessionRootsEmpty: "\u8FD8\u6CA1\u6709\u6DFB\u52A0\u76EE\u5F55\u3002\u70B9\u300C\u6D4F\u89C8\u2026\u300D\u9009\u4E00\u4E2A\u672C\u5730\u6216\u8FDC\u7AEF\u76EE\u5F55\u3002",
|
|
259
260
|
sessionRootsLoading: "\u6B63\u5728\u8BFB\u53D6\u8FD9\u4E2A\u4F1A\u8BDD\u7684\u76EE\u5F55\u2026",
|
|
260
|
-
sessionRootsHint: "\u672C\u5730\
|
|
261
|
+
sessionRootsHint: "\u672C\u5730\u76F4\u63A5\u8BFB\u5199\uFF0C\u8FDC\u7AEF\u7ECF SSH \u4EE3\u7406\u3002\u53EA\u6709\u4F60\u6DFB\u52A0\u7684\u76EE\u5F55\u53EF\u5220\u9664\uFF1B\u63D2\u4EF6\u8BBE\u7F6E\u91CC\u767B\u8BB0\u7684\u6839\u8981\u5148\u5728\u8FD9\u91CC\u6DFB\u52A0\u3002",
|
|
261
262
|
sessionRootsWorkspaceBadge: "\u5DE5\u4F5C\u533A",
|
|
262
|
-
sessionRootsWorkspaceNote: "\
|
|
263
|
+
sessionRootsWorkspaceNote: "\u9ED8\u8BA4\u8FB9\u754C\uFF0C\u4E0D\u53EF\u5220\u9664\uFF08workspace-write \u4E0B\u540C\u5DF2\u6DFB\u52A0\u76EE\u5F55\u4E00\u6837\u53EF\u5199\uFF09",
|
|
263
264
|
sessionRootsFailed: "\u8BFB\u53D6\u76EE\u5F55\u5217\u8868\u5931\u8D25\uFF1A{reason}",
|
|
264
265
|
sessionAddDone: "\u5DF2\u52A0\u5165\uFF1A{path}\uFF08\u672A\u65B0\u5EFA\u5DE5\u4F5C\u533A\uFF09",
|
|
265
266
|
sessionAddFailed: "\u52A0\u5165\u5931\u8D25\uFF1A{reason}",
|
|
@@ -343,6 +344,8 @@ var OVER_SSH_DICTS = {
|
|
|
343
344
|
sessionPanelDeletePermanent: "Delete permanently\u2026",
|
|
344
345
|
sessionPanelRestore: "Restore",
|
|
345
346
|
sessionPanelPurge: "Purge\u2026",
|
|
347
|
+
sessionPanelRowRecycle: "Bin",
|
|
348
|
+
sessionPanelSelectedCount: "{count} selected",
|
|
346
349
|
sessionPanelSweep: "Apply retention policy",
|
|
347
350
|
sessionPanelRefresh: "Refresh",
|
|
348
351
|
sessionPanelClose: "Close",
|
|
@@ -496,8 +499,7 @@ var OVER_SSH_DICTS = {
|
|
|
496
499
|
browseChoose: "Use this directory",
|
|
497
500
|
sessionAddOpen: "Add a directory",
|
|
498
501
|
sessionAddTitle: "Add a directory to the current workspace",
|
|
499
|
-
sessionAddHint: "
|
|
500
|
-
sessionAddWorkspace: "Current workspace: {path}",
|
|
502
|
+
sessionAddHint: "Added to this session: its file tools and @-completion work immediately; no new workspace is created.",
|
|
501
503
|
sessionAddWorkspaceUnknown: "The current workspace path is unavailable, so the directory cannot be added to this session (when the composition provides neither the session nor the workspace service, this entry cannot work at all).",
|
|
502
504
|
sessionAddPick: "Add this directory",
|
|
503
505
|
sessionBrowse: "Browse\u2026",
|
|
@@ -700,6 +702,8 @@ var CLASS = {
|
|
|
700
702
|
group: "dsh-rw-group",
|
|
701
703
|
/** A workspace section's heading line (label + count). */
|
|
702
704
|
groupHead: "dsh-rw-group-head",
|
|
705
|
+
/** The workspace directory, inline in the group head: one line, ellipsized, full path in `title`. */
|
|
706
|
+
groupPath: "dsh-rw-group-path",
|
|
703
707
|
/**
|
|
704
708
|
* The inventory's action bar (select all / clear / delete). Sticky: the list is long, and a bar that
|
|
705
709
|
* scrolls away forces the user to scroll back up for every action.
|
|
@@ -951,6 +955,7 @@ function controlStylesheet() {
|
|
|
951
955
|
`.${CLASS.group} { display: flex; flex-direction: column; gap: ${SPACE.xs}; box-sizing: border-box; min-width: 0; max-width: 100%; padding: ${SPACE.sm}; background: ${TOKENS.bgLayer2}; border: 1px solid ${TOKENS.border}; border-radius: ${TOKENS.radiusMd}; }`,
|
|
952
956
|
/* The heading is separated from the rows it labels, so a long list has a visible rhythm. */
|
|
953
957
|
`.${CLASS.groupHead} { display: flex; flex-wrap: wrap; align-items: baseline; gap: ${SPACE.xs}; min-width: 0; padding-bottom: ${SPACE.hair}; border-bottom: 1px solid ${TOKENS.border}; }`,
|
|
958
|
+
`.${CLASS.groupPath} { flex: 1 1 12rem; min-width: 0; font-size: 0.75rem; line-height: 1.5; color: ${TOKENS.textTertiary}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }`,
|
|
954
959
|
/**
|
|
955
960
|
* The session inventory's action bar, sticky against **whatever scrolls it**.
|
|
956
961
|
*
|
|
@@ -3982,7 +3987,8 @@ function formatStamp(ms, now = Date.now()) {
|
|
|
3982
3987
|
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
3983
3988
|
}
|
|
3984
3989
|
function shortId(id) {
|
|
3985
|
-
|
|
3990
|
+
const body = id.startsWith("session-") ? id.slice("session-".length) : id;
|
|
3991
|
+
return body.length <= 8 ? body : body.slice(0, 8);
|
|
3986
3992
|
}
|
|
3987
3993
|
function parseSessionSnapshot(value) {
|
|
3988
3994
|
if (typeof value !== "object" || value === null) return emptySnapshot();
|
|
@@ -4163,7 +4169,7 @@ function badgeFor(row, copy) {
|
|
|
4163
4169
|
return void 0;
|
|
4164
4170
|
}
|
|
4165
4171
|
function sessionRowTemplate(props) {
|
|
4166
|
-
const { row, copy, checked, onToggle, pickIdPrefix } = props;
|
|
4172
|
+
const { row, copy, checked, onToggle, pickIdPrefix, onRecycle } = props;
|
|
4167
4173
|
const badge = badgeFor(row, copy);
|
|
4168
4174
|
const stamp = formatStamp(row.updatedAtMs);
|
|
4169
4175
|
return (0, import_react7.createElement)(
|
|
@@ -4205,7 +4211,15 @@ function sessionRowTemplate(props) {
|
|
|
4205
4211
|
(0, import_react7.createElement)("span", void 0, formatBytes(row.bytes))
|
|
4206
4212
|
)
|
|
4207
4213
|
)
|
|
4208
|
-
)
|
|
4214
|
+
),
|
|
4215
|
+
// 便捷手段:勾选 + 顶部批量按钮对"只想删一条"的场景太重。回收站是**可恢复**的,所以这里不弹确认,
|
|
4216
|
+
// 直接移入回收站(永久删除仍然只在批量流程里,且带确认)。
|
|
4217
|
+
row.deletable && onRecycle !== void 0 ? Button({
|
|
4218
|
+
variant: "ghost",
|
|
4219
|
+
size: "compact",
|
|
4220
|
+
onClick: () => onRecycle(row.id),
|
|
4221
|
+
children: copy.rowRecycle
|
|
4222
|
+
}) : null
|
|
4209
4223
|
);
|
|
4210
4224
|
}
|
|
4211
4225
|
var SessionRow = (0, import_react7.memo)(function SessionRow2(props) {
|
|
@@ -4260,6 +4274,8 @@ function renderWorkspaceGroup(props) {
|
|
|
4260
4274
|
{ className: CLASS.groupHead },
|
|
4261
4275
|
(0, import_react7.createElement)("span", { className: CLASS.rowTitle }, group.label === "" ? copy.workspaceUnknown : group.label),
|
|
4262
4276
|
(0, import_react7.createElement)("span", { className: CLASS.badge }, String(group.sessions.length)),
|
|
4277
|
+
// 目录内联在组头里(可省略号截断,完整路径留在 title),不再让组头下面多出整整一行只放路径。
|
|
4278
|
+
group.cwd === void 0 ? null : (0, import_react7.createElement)("span", { className: CLASS.groupPath, title: group.cwd }, group.cwd),
|
|
4263
4279
|
// A workspace with no directory (the platform's no-cwd bucket) has nothing to scope a delete to, so it
|
|
4264
4280
|
// gets no button rather than a button that guesses.
|
|
4265
4281
|
remove === void 0 ? null : Button({
|
|
@@ -4270,7 +4286,6 @@ function renderWorkspaceGroup(props) {
|
|
|
4270
4286
|
children: copy.deleteWorkspace
|
|
4271
4287
|
})
|
|
4272
4288
|
),
|
|
4273
|
-
group.cwd === void 0 ? null : (0, import_react7.createElement)("p", { className: CLASS.hint, title: group.cwd }, group.cwd),
|
|
4274
4289
|
(0, import_react7.createElement)(
|
|
4275
4290
|
"ul",
|
|
4276
4291
|
{ className: CLASS.list },
|
|
@@ -4283,6 +4298,7 @@ function renderWorkspaceGroup(props) {
|
|
|
4283
4298
|
copy,
|
|
4284
4299
|
checked: selectedSet.has(row.id),
|
|
4285
4300
|
onToggle: props.onToggle,
|
|
4301
|
+
...props.onRecycle === void 0 ? {} : { onRecycle: props.onRecycle },
|
|
4286
4302
|
pickIdPrefix: props.pickIdPrefix
|
|
4287
4303
|
})
|
|
4288
4304
|
)
|
|
@@ -4486,6 +4502,7 @@ function SessionPanel({ copy, io, close }) {
|
|
|
4486
4502
|
children: copy.selectAll
|
|
4487
4503
|
}),
|
|
4488
4504
|
Button({ variant: "ghost", size: "compact", onClick: () => setSelected([]), children: copy.clearSelection }),
|
|
4505
|
+
deletable.length === 0 ? null : (0, import_react7.createElement)("span", { className: CLASS.rowMeta }, copy.selectedCount(deletable.length)),
|
|
4489
4506
|
Button({
|
|
4490
4507
|
variant: "primary",
|
|
4491
4508
|
size: "compact",
|
|
@@ -4511,6 +4528,7 @@ function SessionPanel({ copy, io, close }) {
|
|
|
4511
4528
|
selected,
|
|
4512
4529
|
busy,
|
|
4513
4530
|
onToggle: toggle,
|
|
4531
|
+
onRecycle: (id) => void run(() => deleteSessions(io, [id])),
|
|
4514
4532
|
onRemove: setPending,
|
|
4515
4533
|
pickIdPrefix
|
|
4516
4534
|
})
|
|
@@ -5617,7 +5635,15 @@ function createSessionDirectoryIo(options) {
|
|
|
5617
5635
|
}
|
|
5618
5636
|
const all = result.value.roots;
|
|
5619
5637
|
if (sessionId !== void 0 && sessionId !== "" && result.value.scopedForSession === sessionId) {
|
|
5620
|
-
return { ok: true, value: all };
|
|
5638
|
+
if (workspacePath === void 0 || workspacePath === "") return { ok: true, value: all };
|
|
5639
|
+
return {
|
|
5640
|
+
ok: true,
|
|
5641
|
+
value: all.filter((row) => {
|
|
5642
|
+
const sessions = row.sessions ?? [];
|
|
5643
|
+
if (sessions.length > 0) return true;
|
|
5644
|
+
return (row.workspaces ?? []).some((candidate) => sameWorkspaceSpelling(candidate, workspacePath));
|
|
5645
|
+
})
|
|
5646
|
+
};
|
|
5621
5647
|
}
|
|
5622
5648
|
if (workspacePath === void 0 || workspacePath === "") return { ok: true, value: [] };
|
|
5623
5649
|
return {
|
|
@@ -5625,7 +5651,7 @@ function createSessionDirectoryIo(options) {
|
|
|
5625
5651
|
value: all.filter((row) => {
|
|
5626
5652
|
const sessions = row.sessions ?? [];
|
|
5627
5653
|
if (sessions.length > 0 && sessionId !== void 0 && sessionId !== "") return sessions.includes(sessionId);
|
|
5628
|
-
return row.workspaces
|
|
5654
|
+
return (row.workspaces ?? []).some((candidate) => sameWorkspaceSpelling(candidate, workspacePath));
|
|
5629
5655
|
})
|
|
5630
5656
|
};
|
|
5631
5657
|
};
|
|
@@ -5682,16 +5708,26 @@ function sessionDirectoryReducer(state, action) {
|
|
|
5682
5708
|
const next = action.path === void 0 || action.path === "" ? void 0 : action.path;
|
|
5683
5709
|
if (state.workspacePath === next) return state;
|
|
5684
5710
|
const base = withoutOptional2(state);
|
|
5711
|
+
const switched = state.workspacePath !== void 0 && next !== void 0 && state.workspacePath !== next;
|
|
5712
|
+
const cleared = {
|
|
5713
|
+
...base,
|
|
5714
|
+
roots: [],
|
|
5715
|
+
// The card/browser state is also entry-shared. A move between two real workspaces must not carry
|
|
5716
|
+
// the previous card's open/browse state into the new one; a late initial seat (undefined -> value)
|
|
5717
|
+
// must NOT close a card the user already opened.
|
|
5718
|
+
...switched ? { open: false, browsing: false } : {}
|
|
5719
|
+
};
|
|
5685
5720
|
if (next === void 0) {
|
|
5686
|
-
const { workspacePath: _cleared, ...rest } =
|
|
5721
|
+
const { workspacePath: _cleared, ...rest } = cleared;
|
|
5687
5722
|
return rest;
|
|
5688
5723
|
}
|
|
5689
|
-
return { ...
|
|
5724
|
+
return { ...cleared, workspacePath: next };
|
|
5690
5725
|
}
|
|
5691
5726
|
case "session-known": {
|
|
5692
5727
|
const next = action.sessionId === void 0 || action.sessionId === "" ? void 0 : action.sessionId;
|
|
5693
5728
|
if (state.sessionId === next) return state;
|
|
5694
|
-
const
|
|
5729
|
+
const switched = state.sessionId !== void 0 && next !== void 0 && state.sessionId !== next;
|
|
5730
|
+
const base = { ...withoutOptional2(state), roots: [], ...switched ? { open: false, browsing: false } : {} };
|
|
5695
5731
|
if (next === void 0) {
|
|
5696
5732
|
const { sessionId: _cleared, ...rest } = base;
|
|
5697
5733
|
return rest;
|
|
@@ -5736,15 +5772,23 @@ function sessionDirectoryReducer(state, action) {
|
|
|
5736
5772
|
};
|
|
5737
5773
|
}
|
|
5738
5774
|
}
|
|
5775
|
+
function sameWorkspaceSpelling(left, right) {
|
|
5776
|
+
const fold = (value) => value.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, "");
|
|
5777
|
+
const a = fold(left);
|
|
5778
|
+
const b = fold(right);
|
|
5779
|
+
if (a === b) return true;
|
|
5780
|
+
return /^[A-Za-z]:\//.test(a) && /^[A-Za-z]:\//.test(b) && a.toLowerCase() === b.toLowerCase();
|
|
5781
|
+
}
|
|
5739
5782
|
function selectSessionDirectoryView(state, copy) {
|
|
5740
5783
|
const view = {
|
|
5741
5784
|
visible: state.open,
|
|
5742
5785
|
pending: state.pending,
|
|
5743
5786
|
notice: resolveMessage2(copy, state.notice),
|
|
5744
|
-
workspaceLine: state.workspacePath === void 0 ? copy["sessionAddWorkspaceUnknown"] : resolveMessage2(copy, { key: "sessionAddWorkspace", params: { path: state.workspacePath } }),
|
|
5745
5787
|
...state.workspacePath === void 0 ? {} : { workspacePath: state.workspacePath },
|
|
5746
5788
|
...state.added === void 0 ? {} : { added: state.added },
|
|
5747
5789
|
// 工作区永远排在第一行:它是默认边界,`workspace-write` 下与已添加目录一样可写(ADR-0016)。
|
|
5790
|
+
// Filter again at render time: the controller is entry-scoped and shared by every session card, so a
|
|
5791
|
+
// stale `roots` snapshot from the previous session must not paint a row scoped to another workspace.
|
|
5748
5792
|
roots: [
|
|
5749
5793
|
...state.workspacePath === void 0 ? [] : [{
|
|
5750
5794
|
key: `workspace:${state.workspacePath}`,
|
|
@@ -5754,7 +5798,9 @@ function selectSessionDirectoryView(state, copy) {
|
|
|
5754
5798
|
removable: false,
|
|
5755
5799
|
workspace: true
|
|
5756
5800
|
}],
|
|
5757
|
-
...state.roots.
|
|
5801
|
+
...state.roots.filter(
|
|
5802
|
+
(row) => row.workspaces === void 0 || row.workspaces.length === 0 || state.workspacePath === void 0 || state.workspacePath === "" || row.workspaces.some((candidate) => sameWorkspaceSpelling(candidate, state.workspacePath))
|
|
5803
|
+
).map((row) => ({
|
|
5758
5804
|
key: row.key,
|
|
5759
5805
|
// A label is what the user called it; without one the path is the only honest title. A remote path alone
|
|
5760
5806
|
// would hide which host it is on, so the connection name is part of the title there.
|
|
@@ -5916,22 +5962,13 @@ function SessionDirectorySection({
|
|
|
5916
5962
|
},
|
|
5917
5963
|
(0, import_react11.createElement)("h3", { id: titleId, className: CLASS.sectionTitle }, copy.sessionAddTitle),
|
|
5918
5964
|
(0, import_react11.createElement)("p", { className: CLASS.hint }, copy.sessionAddHint),
|
|
5919
|
-
//
|
|
5920
|
-
//
|
|
5921
|
-
(0, import_react11.createElement)("p", { className: CLASS.
|
|
5965
|
+
// 工作区路径只出现一次:列表第一行就是它(带「工作区」徽标)。此前这里还有一行“当前工作区:…”,
|
|
5966
|
+
// 与第一行完全重复;拿不到路径时仍然要明说,否则用户不知道为什么会话加不了目录。
|
|
5967
|
+
view.workspacePath === void 0 ? (0, import_react11.createElement)("p", { className: CLASS.empty }, copy["sessionAddWorkspaceUnknown"]) : null,
|
|
5922
5968
|
// The card no longer *is* a browser: it lists what this session has and offers one way in. The picker is a
|
|
5923
5969
|
// step behind 「浏览…」, which also means the card no longer opens with a full directory listing (that was
|
|
5924
5970
|
// the complaint: the panel showed the current directory's contents before the user asked for anything).
|
|
5925
|
-
view.browsing ? (0, import_react11.createElement)(
|
|
5926
|
-
"div",
|
|
5927
|
-
{ className: CLASS.block },
|
|
5928
|
-
browser,
|
|
5929
|
-
(0, import_react11.createElement)(
|
|
5930
|
-
"div",
|
|
5931
|
-
{ className: CLASS.actions },
|
|
5932
|
-
Button({ variant: "ghost", size: "compact", onClick: actions.onBrowse, children: copy.sessionBrowseBack })
|
|
5933
|
-
)
|
|
5934
|
-
) : (0, import_react11.createElement)(
|
|
5971
|
+
view.browsing ? (0, import_react11.createElement)("div", { className: CLASS.block }, browser) : (0, import_react11.createElement)(
|
|
5935
5972
|
"div",
|
|
5936
5973
|
{ className: CLASS.block },
|
|
5937
5974
|
(0, import_react11.createElement)("h4", { className: CLASS.blockTitle }, copy.sessionRootsTitle),
|
|
@@ -5970,17 +6007,18 @@ function SessionDirectorySection({
|
|
|
5970
6007
|
)
|
|
5971
6008
|
),
|
|
5972
6009
|
// 列表里只剩工作区行时,"还没有添加目录"仍然是有用的下一步提示;但它不再替换列表。
|
|
5973
|
-
view.roots.length > 0 && view.roots.filter((row) => row.workspace !== true).length === 0 && !view.rootsReading && view.notice === "" ? (0, import_react11.createElement)("p", { className: CLASS.empty }, copy.sessionRootsEmpty) : null
|
|
5974
|
-
(0, import_react11.createElement)(
|
|
5975
|
-
"div",
|
|
5976
|
-
{ className: CLASS.actions },
|
|
5977
|
-
Button({ variant: "secondary", size: "compact", onClick: actions.onBrowse, children: copy.sessionBrowse })
|
|
5978
|
-
)
|
|
6010
|
+
view.roots.length > 0 && view.roots.filter((row) => row.workspace !== true).length === 0 && !view.rootsReading && view.notice === "" ? (0, import_react11.createElement)("p", { className: CLASS.empty }, copy.sessionRootsEmpty) : null
|
|
5979
6011
|
),
|
|
5980
6012
|
view.added === void 0 ? null : Notice({ role: "status", tone: "info", children: resolveMessage2(copy, { key: "sessionAddDone", params: { path: view.added } }) }),
|
|
5981
6013
|
view.pending ? Notice({ role: "status", tone: "info", children: copy.flowPending }) : null,
|
|
5982
6014
|
view.notice === "" ? null : Notice({ role: "alert", tone: "error", children: view.notice }),
|
|
5983
|
-
|
|
6015
|
+
// 主操作与关闭收在底部同一行:此前「浏览…」长在列表块里、关闭单独一行,两个按钮上下分离。
|
|
6016
|
+
(0, import_react11.createElement)(
|
|
6017
|
+
"div",
|
|
6018
|
+
{ className: CLASS.actions },
|
|
6019
|
+
view.browsing ? Button({ variant: "secondary", size: "compact", onClick: actions.onBrowse, children: copy.sessionBrowseBack }) : Button({ variant: "secondary", size: "compact", onClick: actions.onBrowse, children: copy.sessionBrowse }),
|
|
6020
|
+
Button({ variant: "secondary", size: "compact", onClick: actions.onClose, children: copy.close })
|
|
6021
|
+
)
|
|
5984
6022
|
);
|
|
5985
6023
|
return (0, import_react11.createElement)(
|
|
5986
6024
|
"div",
|
|
@@ -6701,6 +6739,8 @@ function apply(ctx) {
|
|
|
6701
6739
|
deletePermanent: dict.sessionPanelDeletePermanent,
|
|
6702
6740
|
restore: dict.sessionPanelRestore,
|
|
6703
6741
|
purge: dict.sessionPanelPurge,
|
|
6742
|
+
rowRecycle: dict.sessionPanelRowRecycle,
|
|
6743
|
+
selectedCount: (count) => resolveMessage2(dict, { key: "sessionPanelSelectedCount", params: { count } }),
|
|
6704
6744
|
sweep: dict.sessionPanelSweep,
|
|
6705
6745
|
refresh: dict.sessionPanelRefresh,
|
|
6706
6746
|
close: dict.sessionPanelClose,
|
package/lib/fs.js
CHANGED
|
@@ -787,7 +787,8 @@ function wireGitStatus(ctx) {
|
|
|
787
787
|
if (connections === void 0 || transports === void 0) return void 0;
|
|
788
788
|
const profile = connections.get(connectionId);
|
|
789
789
|
if (profile === void 0) return void 0;
|
|
790
|
-
const
|
|
790
|
+
const lanes = ctx.get("sshDevTransportLanes");
|
|
791
|
+
const acquired = lanes === void 0 ? await transports.acquire(profile) : await lanes.acquire(profile, "compute");
|
|
791
792
|
return acquired.transport;
|
|
792
793
|
},
|
|
793
794
|
resolveRoot: (connectionId, path) => {
|