@zhangfengshun/dsh-remote-ssh 2.3.0 → 2.3.2
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/CHANGELOG.md +12 -0
- package/lib/client.js +99 -13
- package/lib/index.js +36 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
|
|
4
4
|
|
|
5
|
+
## [2.3.2] — 远程工作区图标改为单个「地球角标文件夹」
|
|
6
|
+
### 变更
|
|
7
|
+
- **工作区标题去掉 🌐 前缀**:新建远程工作区的原生标题不再带 🌐(此前标题 emoji + 壳层文件夹图标造成「文件夹 + 地球」双重标识);启动自愈同步剥掉既有工作区标题中的旧 🌐 前缀(仅限已知旧形态,不覆盖自定义标题)。
|
|
8
|
+
- **客户端图标替换**:按工作区标题文本定位侧边栏工作区行,隐藏壳层默认纯文件夹 svg,插入内联「地球角标文件夹」SVG(文件夹描边 + 主题色圆形地球角标,跟随明暗主题);复用 2.3.1 的增量观察器(只扫新增子树、30s 刷新标题集、data 标记防重入),稳态成本≈0。
|
|
9
|
+
|
|
10
|
+
## [2.3.1] — 修复 2.3.0 上线后的界面卡顿(GC 压力 + 客户端全页扫描)
|
|
11
|
+
### 修复
|
|
12
|
+
- **读缓存字节预算(review m1)**:单条结果 >1MiB 不再缓存(结果照常返回),缓存总量上限 32MB 超限逐出最旧条目——消除大文件驻留字符串造成的 GC 压力(最坏 ~270MB → ≤32MB)。
|
|
13
|
+
- **设置导航齿轮观察器改为增量扫描**:此前任何 DOM 变动都会触发全文档 `querySelectorAll("button")` 扫描(流式聊天期间持续打断主线程);现改为只扫描新增子树、节流 500ms、后台页签零开销,稳态成本≈0。
|
|
14
|
+
- **exec 失败不再 bump epoch(review m3)**:失败的命令通常无副作用,不再无谓打断整代缓存(部分副作用由 ≤5s TTL 兜底)。
|
|
15
|
+
- **git 只读命令不再整代失效(review m4)**:仅 add/reset/commit/checkout/revert/cherry-pick 变更类子命令 bump,status/diff/log/branch/show 不再打断缓存。
|
|
16
|
+
|
|
5
17
|
## [2.3.0] — 远程文件打开提速:单往返合并读 + 结果缓存(实测 ≈1.3×~5×)
|
|
6
18
|
### 性能
|
|
7
19
|
- **fs.read 单往返合并读**:size/mtime 与文件内容合并为一条池化命令(`__DSH_ST__` 帧,`_` 不在 base64 字母表故标记天然抗噪),去掉每文件一次的 stat 往返。真实 HPC 实测(100KB 文件,各 10 次 p50):raw 直传 396.6→301.8ms、base64 443.8→337.9ms,约 **1.31×**;1MB 大文件同步受益。>4MB 仍 `head -c 4MiB` 截断,二进制探测与解码语义不变。
|
package/lib/client.js
CHANGED
|
@@ -1115,38 +1115,124 @@ window.__ModuleLoader__.load({
|
|
|
1115
1115
|
// 不可靠。这里改为按条目文本(settings.nav)定位设置导航按钮,隐藏其 svg 图标;
|
|
1116
1116
|
// 设置面板按需挂载、语言会切换,用 MutationObserver + 语言订阅自动重跑。
|
|
1117
1117
|
if (slots) {
|
|
1118
|
+
function checkCell(btn) {
|
|
1119
|
+
var label = i18n.t("settings.nav");
|
|
1120
|
+
var text = (btn.textContent || "").trim();
|
|
1121
|
+
if (text !== label.trim() || text === "") return false;
|
|
1122
|
+
var icon = btn.querySelector("svg");
|
|
1123
|
+
if (icon) { icon.style.display = "none"; return true; }
|
|
1124
|
+
return false;
|
|
1125
|
+
}
|
|
1126
|
+
function checkCellIn(root) {
|
|
1127
|
+
try {
|
|
1128
|
+
if (root.tagName && String(root.tagName).toLowerCase() === "button") checkCell(root);
|
|
1129
|
+
var cells = root.querySelectorAll("button");
|
|
1130
|
+
for (var i = 0; i < cells.length; i++) checkCell(cells[i]);
|
|
1131
|
+
} catch (e) {}
|
|
1132
|
+
}
|
|
1118
1133
|
function hideRemoteGearIcon() {
|
|
1119
1134
|
try {
|
|
1120
|
-
var label = i18n.t("settings.nav");
|
|
1121
1135
|
var buttons = document.querySelectorAll("button");
|
|
1122
|
-
for (var i = 0; i < buttons.length; i++)
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1136
|
+
for (var i = 0; i < buttons.length; i++) checkCell(buttons[i]);
|
|
1137
|
+
} catch (e) {}
|
|
1138
|
+
}
|
|
1139
|
+
// ---- 远程工作区图标:单个「地球角标文件夹」SVG 替换壳层默认纯文件夹图标 ----
|
|
1140
|
+
// 壳层工作区行的文件夹图标是固定原语(IconFolderOpen16/Close16),无 per-workspace
|
|
1141
|
+
// 图标 API;旧方案在标题里塞 🌐 emoji 造成「文件夹 + 地球」双重标识。现按标题文本
|
|
1142
|
+
// 定位远程工作区行,隐藏原 svg 并插入内联「地球角标文件夹」svg(data 标记防重入)。
|
|
1143
|
+
var remoteTitles = new Set();
|
|
1144
|
+
function refreshRemoteTitles() {
|
|
1145
|
+
api("listWorkspaces").then(function (r) {
|
|
1146
|
+
if (!(r && r.ok && Array.isArray(r.workspaces))) return;
|
|
1147
|
+
var next = new Set();
|
|
1148
|
+
for (var i = 0; i < r.workspaces.length; i++) {
|
|
1149
|
+
var t = String(r.workspaces[i].title || "").trim();
|
|
1150
|
+
if (t) { next.add(t); next.add("🌐 " + t); } // 兼容自愈前的旧前缀标题
|
|
1151
|
+
}
|
|
1152
|
+
remoteTitles = next;
|
|
1153
|
+
swapWorkspaceIconsIn(document.body); // 标题集就绪后补一次全量替换
|
|
1154
|
+
}).catch(function () {});
|
|
1155
|
+
}
|
|
1156
|
+
function folderGlobeSvg() {
|
|
1157
|
+
var NS = "http://www.w3.org/2000/svg";
|
|
1158
|
+
var svg = document.createElementNS(NS, "svg");
|
|
1159
|
+
svg.setAttribute("viewBox", "0 0 20 16");
|
|
1160
|
+
svg.setAttribute("width", "16");
|
|
1161
|
+
svg.setAttribute("height", "16");
|
|
1162
|
+
svg.setAttribute("aria-hidden", "true");
|
|
1163
|
+
svg.setAttribute("data-rssh-folder-globe", "1");
|
|
1164
|
+
svg.innerHTML =
|
|
1165
|
+
'<path d="M2.4 4.2c0-1.05.85-1.9 1.9-1.9h3.1c.52 0 1.02.21 1.39.58l1.05 1.05c.28.28.66.44 1.06.44h6.3c1.05 0 1.9.85 1.9 1.9v6.53c0 1.05-.85 1.9-1.9 1.9H4.3c-1.05 0-1.9-.85-1.9-1.9V4.2z" fill="none" stroke="currentColor" stroke-width="1.2"/>' +
|
|
1166
|
+
'<circle cx="6.3" cy="12.1" r="3.1" fill="var(--dsw-alias-state-business-primary, #4c6ef5)" stroke="var(--dsw-alias-bg-layer-1, #1b1b1b)" stroke-width="0.8"/>' +
|
|
1167
|
+
'<g fill="none" stroke="#ffffff" stroke-width="0.75"><circle cx="6.3" cy="12.1" r="1.95"/><ellipse cx="6.3" cy="12.1" rx="0.95" ry="1.95"/><path d="M4.35 12.1h3.9"/></g>';
|
|
1168
|
+
return svg;
|
|
1169
|
+
}
|
|
1170
|
+
function swapWorkspaceIconsIn(root) {
|
|
1171
|
+
if (!remoteTitles.size) return;
|
|
1172
|
+
try {
|
|
1173
|
+
var nodes = root.querySelectorAll("span,div,button,a,p");
|
|
1174
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
1175
|
+
var el = nodes[i];
|
|
1176
|
+
if (el.getAttribute && el.getAttribute("data-rssh-title-scan") === "1") continue;
|
|
1177
|
+
var t = (el.textContent || "").trim();
|
|
1178
|
+
if (!t || !remoteTitles.has(t) || el.childElementCount > 2) continue;
|
|
1179
|
+
el.setAttribute("data-rssh-title-scan", "1");
|
|
1180
|
+
// 向上找最近的「直接子级含 svg」的容器(壳层 slot.folder 槽位)
|
|
1181
|
+
var p = el, slot = null, guard = 0;
|
|
1182
|
+
while (p && p.nodeType === 1 && p !== document.body && guard++ < 6) {
|
|
1183
|
+
if (p.querySelector(":scope > svg")) { slot = p; break; }
|
|
1184
|
+
p = p.parentElement;
|
|
1128
1185
|
}
|
|
1186
|
+
if (!slot) continue;
|
|
1187
|
+
var old = slot.querySelector(":scope > svg");
|
|
1188
|
+
if (old && old.getAttribute("data-rssh-folder-globe") === "1") continue;
|
|
1189
|
+
if (old) old.style.display = "none";
|
|
1190
|
+
var mine = folderGlobeSvg();
|
|
1191
|
+
if (old) slot.insertBefore(mine, old); else slot.appendChild(mine);
|
|
1129
1192
|
}
|
|
1130
1193
|
} catch (e) {}
|
|
1131
|
-
return false;
|
|
1132
1194
|
}
|
|
1133
1195
|
ctx.effect(function () {
|
|
1134
1196
|
if (typeof document === "undefined") return;
|
|
1135
1197
|
hideRemoteGearIcon();
|
|
1198
|
+
refreshRemoteTitles();
|
|
1199
|
+
var titleTimer = setInterval(refreshRemoteTitles, 30000);
|
|
1136
1200
|
var timer = null;
|
|
1137
|
-
var
|
|
1201
|
+
var pendingRoots = null;
|
|
1202
|
+
var schedule = function (root) {
|
|
1203
|
+
if (root && root.nodeType === 1) (pendingRoots || (pendingRoots = [])).push(root);
|
|
1138
1204
|
if (timer) return;
|
|
1139
|
-
timer = setTimeout(function () {
|
|
1205
|
+
timer = setTimeout(function () {
|
|
1206
|
+
timer = null;
|
|
1207
|
+
var roots = pendingRoots;
|
|
1208
|
+
pendingRoots = null;
|
|
1209
|
+
if (document.hidden) return; // 后台页签零开销
|
|
1210
|
+
if (roots && roots.length) {
|
|
1211
|
+
// 只扫新增子树:流式聊天期间不做全文档扫描(稳态成本≈0)
|
|
1212
|
+
for (var i = 0; i < roots.length; i++) {
|
|
1213
|
+
if (roots[i].isConnected) { checkCellIn(roots[i]); swapWorkspaceIconsIn(roots[i]); }
|
|
1214
|
+
}
|
|
1215
|
+
} else {
|
|
1216
|
+
hideRemoteGearIcon(); // 语言切换等无根场景全量扫一次
|
|
1217
|
+
swapWorkspaceIconsIn(document.body);
|
|
1218
|
+
}
|
|
1219
|
+
}, 500);
|
|
1140
1220
|
};
|
|
1141
|
-
var mo = new MutationObserver(
|
|
1221
|
+
var mo = new MutationObserver(function (muts) {
|
|
1222
|
+
for (var i = 0; i < muts.length; i++) {
|
|
1223
|
+
var added = muts[i].addedNodes;
|
|
1224
|
+
for (var j = 0; j < added.length; j++) schedule(added[j]);
|
|
1225
|
+
}
|
|
1226
|
+
});
|
|
1142
1227
|
mo.observe(document.body, { childList: true, subtree: true });
|
|
1143
|
-
var off = i18n.subscribe(schedule);
|
|
1228
|
+
var off = i18n.subscribe(function () { schedule(null); });
|
|
1144
1229
|
return function () {
|
|
1145
1230
|
mo.disconnect();
|
|
1146
1231
|
if (off) off();
|
|
1147
1232
|
if (timer) clearTimeout(timer);
|
|
1233
|
+
clearInterval(titleTimer);
|
|
1148
1234
|
};
|
|
1149
|
-
}, "dsh-remote-ssh: hide
|
|
1235
|
+
}, "dsh-remote-ssh: settings gear hide + remote workspace folder-globe icon");
|
|
1150
1236
|
}
|
|
1151
1237
|
|
|
1152
1238
|
// better-sidebar 0.15+ 的文件上传 UI 把原始字节流 POST 到 /sidebar/upload。
|
package/lib/index.js
CHANGED
|
@@ -518,6 +518,23 @@ function lruPut(map, key, val, max) {
|
|
|
518
518
|
while (map.size > max) map.delete(map.keys().next().value);
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
+
/** 读缓存字节预算(review m1):单条 >1MiB 不缓存、总量 ≤32MB,防止大文件驻留
|
|
522
|
+
* 造成 GC 压力拖慢整个宿主进程。超预算时从最旧条目开始逐出。 */
|
|
523
|
+
const READ_CACHE_MAX_ENTRY_BYTES = 1024 * 1024;
|
|
524
|
+
const READ_CACHE_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
525
|
+
function readCachePut(key, entry, max) {
|
|
526
|
+
entry.bytes = Buffer.byteLength(entry.content, "utf8");
|
|
527
|
+
if (entry.bytes > READ_CACHE_MAX_ENTRY_BYTES) return; // 大文件不缓存(结果照常返回)
|
|
528
|
+
lruPut(readCache, key, entry, max);
|
|
529
|
+
let total = 0;
|
|
530
|
+
for (const v of readCache.values()) total += v.bytes || 0;
|
|
531
|
+
while (total > READ_CACHE_TOTAL_BYTES && readCache.size > 1) {
|
|
532
|
+
const oldest = readCache.keys().next().value;
|
|
533
|
+
total -= readCache.get(oldest).bytes || 0;
|
|
534
|
+
readCache.delete(oldest);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
521
538
|
function normalizeCachePath(path) {
|
|
522
539
|
let s = String(path || "");
|
|
523
540
|
while (s.length > 1 && s.charCodeAt(s.length - 1) === 47) s = s.slice(0, -1);
|
|
@@ -689,7 +706,7 @@ async function remoteReadFile(runner, p, path) {
|
|
|
689
706
|
}
|
|
690
707
|
if (rst.framed && rst.size === hit.size && rst.mtime === hit.mtime) {
|
|
691
708
|
hit.at = Date.now();
|
|
692
|
-
|
|
709
|
+
readCachePut(key, hit, READ_CACHE_MAX);
|
|
693
710
|
return readResultOk(path, hit.content, hit.binary, hit.truncated);
|
|
694
711
|
}
|
|
695
712
|
}
|
|
@@ -705,7 +722,7 @@ async function remoteReadFile(runner, p, path) {
|
|
|
705
722
|
if (rawOk) {
|
|
706
723
|
const truncated = st.size !== null && st.size > MAX_BYTES;
|
|
707
724
|
if (!r.truncated) {
|
|
708
|
-
|
|
725
|
+
readCachePut(key, { content: rawOk.text, binary: rawOk.binary, truncated: truncated, size: st.size, mtime: st.mtime, epoch: epoch, at: Date.now() }, READ_CACHE_MAX);
|
|
709
726
|
}
|
|
710
727
|
return readResultOk(path, rawOk.text, rawOk.binary, truncated || !!r.truncated);
|
|
711
728
|
}
|
|
@@ -731,7 +748,7 @@ async function remoteReadFile(runner, p, path) {
|
|
|
731
748
|
}
|
|
732
749
|
const truncated = st.framed && st.size !== null && st.size > MAX_BYTES;
|
|
733
750
|
if (st.framed && !r.truncated) {
|
|
734
|
-
|
|
751
|
+
readCachePut(key, { content: decoded.text, binary: decoded.binary, truncated: truncated, size: st.size, mtime: st.mtime, epoch: epoch, at: Date.now() }, READ_CACHE_MAX);
|
|
735
752
|
}
|
|
736
753
|
return readResultOk(path, decoded.text, decoded.binary, truncated || !!r.truncated);
|
|
737
754
|
}
|
|
@@ -1256,7 +1273,7 @@ function apply(ctx, config) {
|
|
|
1256
1273
|
let workspaceId = null;
|
|
1257
1274
|
if (workspaceRegistry) {
|
|
1258
1275
|
try {
|
|
1259
|
-
const wsTitle =
|
|
1276
|
+
const wsTitle = title; // 图标语义由客户端「地球角标文件夹」图标承担,标题不再带 🌐 前缀
|
|
1260
1277
|
const native = await workspaceRegistry.create(mirrorDir, wsTitle);
|
|
1261
1278
|
workspaceId = native.id;
|
|
1262
1279
|
// workspaceRegistry.create 仅首次创建时应用 title;已存在的旧工作区
|
|
@@ -1306,9 +1323,10 @@ function apply(ctx, config) {
|
|
|
1306
1323
|
if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
|
|
1307
1324
|
if (!args || !args.command) return { ok: false, error: "command 为必填项" };
|
|
1308
1325
|
// 走持久会话池(stderr 分离):首调用建立连接后,后续调用毫秒级返回。
|
|
1309
|
-
// 远程命令可能改任意文件(插件不可见)→
|
|
1326
|
+
// 远程命令可能改任意文件(插件不可见)→ 成功后整代失效读/列举缓存(findings §4.3a);
|
|
1327
|
+
// 失败的命令通常无副作用,不 bump 以免无谓打断缓存(部分副作用由 ≤5s TTL 兜底)。
|
|
1310
1328
|
const r = await runPooled(p, args.command, args.stdin, undefined, true);
|
|
1311
|
-
bumpCacheEpoch(p);
|
|
1329
|
+
if (r.ok) bumpCacheEpoch(p);
|
|
1312
1330
|
return r;
|
|
1313
1331
|
},
|
|
1314
1332
|
listDir: async (args) => {
|
|
@@ -1838,10 +1856,11 @@ function apply(ctx, config) {
|
|
|
1838
1856
|
}
|
|
1839
1857
|
})();
|
|
1840
1858
|
|
|
1841
|
-
// ----
|
|
1859
|
+
// ---- 启动自愈:把既有远程工作区的原生标题统一为「远程路径最后一段」----
|
|
1842
1860
|
// 早期版本 create 工作区时未传 title,DSH 用镜像目录 basename(wsId,如 wmt3tev5cdfge)
|
|
1843
1861
|
// 作标题;workspaceRegistry.create 只对新建记录应用 title,旧记录会被原样返回。
|
|
1844
|
-
//
|
|
1862
|
+
// 2.3.2 起标题不再带 🌐 前缀(图标由客户端地球角标文件夹 SVG 承担),自愈同时剥掉
|
|
1863
|
+
// 旧版遗留的 🌐 前缀。仅在标题等于已知旧形态时修复,不覆盖用户自定义标题。
|
|
1845
1864
|
function healWorkspaceTitles() {
|
|
1846
1865
|
const reg = workspaceRegistry;
|
|
1847
1866
|
if (!reg || typeof reg.resolveByPath !== "function") return;
|
|
@@ -1850,13 +1869,14 @@ function apply(ctx, config) {
|
|
|
1850
1869
|
if (!w || !w.mirrorPath || !w.remotePath) continue;
|
|
1851
1870
|
const lastSeg = String(w.remotePath).replace(/\/+$/, "").split("/").pop();
|
|
1852
1871
|
if (!lastSeg || lastSeg === "~" || lastSeg === ".") continue;
|
|
1853
|
-
const desired =
|
|
1872
|
+
const desired = lastSeg;
|
|
1854
1873
|
const base = String(w.mirrorPath).split(/[\\/]/).filter(Boolean).pop();
|
|
1874
|
+
const legacy = new Set([base, "🌐 " + base, desired, "🌐 " + desired, "🌐 " + w.title]);
|
|
1855
1875
|
(async () => {
|
|
1856
1876
|
try {
|
|
1857
1877
|
const ent = await reg.resolveByPath(w.mirrorPath);
|
|
1858
|
-
//
|
|
1859
|
-
if (ent && typeof ent.setTitle === "function" && (ent.title
|
|
1878
|
+
// 只在标题仍是已知旧形态(镜像目录名旧 bug / 🌐 前缀旧版)时修复,尊重自定义标题。
|
|
1879
|
+
if (ent && typeof ent.setTitle === "function" && legacy.has(ent.title)) {
|
|
1860
1880
|
await ent.setTitle(desired);
|
|
1861
1881
|
}
|
|
1862
1882
|
} catch (e) {}
|
|
@@ -2157,12 +2177,15 @@ function apply(ctx, config) {
|
|
|
2157
2177
|
}
|
|
2158
2178
|
|
|
2159
2179
|
/** 远端 git 执行(远程工作区):在 remoteDir 里跑同一 git 语义。 */
|
|
2180
|
+
const GIT_MUTATING_FIRST_TOKENS = new Set(["add", "reset", "commit", "checkout", "revert", "cherry-pick"]);
|
|
2160
2181
|
async function runGitRemote(profile, remoteDir, args) {
|
|
2161
2182
|
const quoted = args.map((a) => shellQuote(a)).join(" ");
|
|
2162
2183
|
const r = await runPooled(profile, "git -C " + shellQuotePath(remoteDir) + " --no-pager -c color.ui=false " + quoted, undefined, 8 * 1024 * 1024);
|
|
2163
2184
|
if (!r.ok) throw { code: "git-error", message: String(r.stdout || r.error || "").trim() || "remote git exited with " + r.exitCode };
|
|
2164
|
-
// commit/checkout/
|
|
2165
|
-
|
|
2185
|
+
// 仅变更类子命令(stage→add / unstage→reset / commit / checkout / discard→checkout /
|
|
2186
|
+
// revert / cherry-pick)会改工作区文件 → 整代失效读/列举缓存;
|
|
2187
|
+
// status/diff/log/branch/show 等只读命令不再打断缓存(review m4)。
|
|
2188
|
+
if (GIT_MUTATING_FIRST_TOKENS.has(String(args[0] || ""))) bumpCacheEpoch(profile);
|
|
2166
2189
|
return r.stdout;
|
|
2167
2190
|
}
|
|
2168
2191
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhangfengshun/dsh-remote-ssh",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.2",
|
|
4
4
|
"description": "DSH web plugin: VSCode Remote-SSH-like remote development (SSH to supercomputers/servers, remote workspace, file explorer, integrated terminal), integrated with dsh-better-sidebar and DSH settings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|