@nimoxie/dsh-ui-tweaks 0.2.0 → 0.2.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/lib/client.js CHANGED
@@ -1,9 +1,12 @@
1
1
  /* dsh-ui-tweaks browser half.
2
2
  *
3
- * 三个独立模块,任一失败都不得拖垮其它(各自 guard):
4
- * 1. tablefix —— 宽表格越界修复(纯 CSS 覆盖,见 README「宽表格修复」)
3
+ * 五个模块各自 guard,任一失败都不得拖垮其它:
4
+ * 1. tablefix —— 宽表格越界修复(纯 CSS 覆盖)
5
5
  * 2. scrollbar —— 对话区左侧自绘滚动条(从 dsh-web-shell 移植,选择器适配 0.2.x)
6
6
  * 3. updates —— 插件更新检查面板(不含 dsh 本体;remote.pluginManager/pluginInventory)
7
+ * 4. anim —— 动画修复(忽略 prefers-reduced-motion,恢复界面动画)
8
+ * 5. changes —— 回合尾改动卡:日志聚合 + 模块 5b 数据适配层(喂数据给官方卡)
9
+ * host 半区(lib/index.js)另有两项:窗口记忆(模块 6)、立即重启路由(模块 7)。
7
10
  *
8
11
  * 装载机制:shell 的 window.__ModuleLoader__.load + factory(require);
9
12
  * apply(ctx) 由客户端 cordis 上下文调用(同 dsh-model-organizer 的结构)。
@@ -14,7 +17,7 @@ window.__ModuleLoader__.load({
14
17
  const module = { exports: {} };
15
18
  const exports = module.exports;
16
19
 
17
- const BUILD = "0.2.0";
20
+ const BUILD = "0.2.2";
18
21
 
19
22
  /* 每个模块的注册各套一层 guard:失败只记 warn(座位/模块失败模式是「静默消失」,
20
23
  必须主动吭声;但不能让一个模块的异常打断同文件里其它模块的 apply)。 */
@@ -354,6 +357,9 @@ window.__ModuleLoader__.load({
354
357
  * 全部走官方 remote API,不自建 host 路由:
355
358
  * remote.pluginManager.listBundles() → 已装插件包(name/version/installed)
356
359
  * remote.pluginManager.installBundle(spec, {enabled, requestId}) → 更新
360
+ * (开着 HMR 的运行时里,安装**已装过**的包会被拒 already-installed —— 与版本无关;
361
+ * 此时退回"先 removeBundle 再 installBundle",见 installBundleEx)
362
+ * remote.pluginManager.removeBundle(包名) → 卸载(两步更新时用)
357
363
  * remote.pluginManager.waitForInstall(requestId) → 安装进度轮询
358
364
  * remote.pluginManager.cancelInstall(requestId) → 取消
359
365
  * registry 比对从浏览器直连(registry.npmjs.org 带 CORS *)。
@@ -719,27 +725,62 @@ window.__ModuleLoader__.load({
719
725
  (预检 + 二次确认 + host 已写生效豁免)才会带上它。用完即删、终态也清。 */
720
726
  var forcedNames = {}; // name → true
721
727
 
722
- function hostPost(route, body) {
728
+ /* 调 host 半区的本机回环路由。**必须带超时**:回包丢了不能让面板永远停在
729
+ 「正在写入豁免表…」—— 真机遇到过响应没回来、阶段无限停留(写盘其实已成功)。
730
+ AbortController 到点 abort,结果统一收敛成 {ok:false, code:"timeout"}。 */
731
+ function hostPost(route, body, timeoutMs) {
732
+ var ms = Math.max(1000, Number(timeoutMs) || 15000);
733
+ var ctl = null, timer = null;
734
+ try { if (typeof AbortController === "function") { ctl = new AbortController(); } } catch (e0) {}
735
+ function stop() { if (timer) { clearTimeout(timer); timer = null; } }
736
+ var opts = {
737
+ method: "POST", cache: "no-store",
738
+ headers: { "Content-Type": "application/json" },
739
+ body: JSON.stringify(body || {}),
740
+ };
741
+ if (ctl) {
742
+ opts.signal = ctl.signal;
743
+ timer = setTimeout(function () { try { ctl.abort(); } catch (e2) {} }, ms);
744
+ }
723
745
  try {
724
- return fetch("/api/ui-tweaks/" + route, {
725
- method: "POST", cache: "no-store",
726
- headers: { "Content-Type": "application/json" },
727
- body: JSON.stringify(body || {}),
728
- }).then(function (r) {
746
+ return fetch("/api/ui-tweaks/" + route, opts).then(function (r) {
747
+ stop();
729
748
  return r.json().then(function (j) { return j; }, function () { return { ok: false, code: "HTTP " + r.status }; });
730
- }).catch(function (e) { return { ok: false, code: String((e && e.message) || e) }; });
749
+ }).catch(function (e) {
750
+ stop();
751
+ var aborted = false;
752
+ try { aborted = !!(ctl && ctl.signal && ctl.signal.aborted); } catch (e3) {}
753
+ return { ok: false, code: aborted ? "timeout" : String((e && e.message) || e) };
754
+ });
731
755
  } catch (e) {
756
+ stop();
732
757
  return Promise.resolve({ ok: false, code: String((e && e.message) || e) });
733
758
  }
734
759
  }
735
- function hostGet(route) {
760
+ function hostGet(route, timeoutMs) {
761
+ var ms = Math.max(1000, Number(timeoutMs) || 10000);
762
+ var ctl = null, timer = null;
763
+ try { if (typeof AbortController === "function") { ctl = new AbortController(); } } catch (e0) {}
764
+ function stop() { if (timer) { clearTimeout(timer); timer = null; } }
765
+ var opts = { cache: "no-store" };
766
+ if (ctl) {
767
+ opts.signal = ctl.signal;
768
+ timer = setTimeout(function () { try { ctl.abort(); } catch (e2) {} }, ms);
769
+ }
736
770
  try {
737
- return fetch("/api/ui-tweaks/" + route, { cache: "no-store" })
771
+ return fetch("/api/ui-tweaks/" + route, opts)
738
772
  .then(function (r) {
773
+ stop();
739
774
  return r.json().then(function (j) { return j; }, function () { return { ok: false, code: "HTTP " + r.status }; });
740
775
  })
741
- .catch(function (e) { return { ok: false, code: String((e && e.message) || e) }; });
776
+ .catch(function (e) {
777
+ stop();
778
+ var aborted = false;
779
+ try { aborted = !!(ctl && ctl.signal && ctl.signal.aborted); } catch (e3) {}
780
+ return { ok: false, code: aborted ? "timeout" : String((e && e.message) || e) };
781
+ });
742
782
  } catch (e) {
783
+ stop();
743
784
  return Promise.resolve({ ok: false, code: String((e && e.message) || e) });
744
785
  }
745
786
  }
@@ -1024,7 +1065,7 @@ window.__ModuleLoader__.load({
1024
1065
  rstStage = 'confirm';
1025
1066
  rstRender();
1026
1067
  }
1027
- hostGet('restart').then(function (j) { plan = j || { ok: false, code: 'empty' }; decide(); });
1068
+ hostGet('restart', 10000).then(function (j) { plan = j || { ok: false, code: 'empty' }; decide(); });
1028
1069
  runningSessions(function (n, names) { running = n; rstNames = names || []; decide(); });
1029
1070
  }
1030
1071
 
@@ -1034,7 +1075,7 @@ window.__ModuleLoader__.load({
1034
1075
  showScrim(true);
1035
1076
  armKillTimer();
1036
1077
  rstRender();
1037
- hostPost('restart', {}).then(function (j) {
1078
+ hostPost('restart', {}, 16000).then(function (j) {
1038
1079
  if (!j || !j.ok) {
1039
1080
  /* 回包中断多半就是宿主已经退出(连接先断是正常的),但"多半"不等于一定:
1040
1081
  如实报成失败并给可点的出口(重试 / 刷新页面看看),别留一个死状态。 */
@@ -1073,10 +1114,16 @@ window.__ModuleLoader__.load({
1073
1114
  var row = findRow(name);
1074
1115
  if (!row || row.state !== "cooling" || !row.latest) { return; }
1075
1116
  forceSet(name, "checking");
1076
- hostPost("precheck-exempt", { name: name, version: row.latest, windowMs: policyWindowMs }).then(function (r) {
1117
+ /* 预检要逐包查 registry 发布时长,可能十几秒;给足 30 秒但仍要有上限 */
1118
+ hostPost("precheck-exempt", { name: name, version: row.latest, windowMs: policyWindowMs }, 30000).then(function (r) {
1077
1119
  var cur = findRow(name);
1078
1120
  if (!cur || cur.state !== "cooling") { forceReset(name); return; }
1079
- if (!r || !r.ok) { forceSet(name, "error", "预检没能完成:" + String((r && r.code) || "未知原因")); return; }
1121
+ if (!r || !r.ok) {
1122
+ forceSet(name, "error", (r && r.code) === "timeout"
1123
+ ? "预检超时(registry 响应慢),稍后重试。"
1124
+ : "预检没能完成:" + String((r && r.code) || "未知原因"));
1125
+ return;
1126
+ }
1080
1127
  var b = (r.blocked || []).filter(function (x) { return x && x.name && x.name !== name; });
1081
1128
  if (b.length) { forceBlocked[name] = b; forceSet(name, "blocked"); return; }
1082
1129
  forceSet(name, "confirm");
@@ -1088,10 +1135,33 @@ window.__ModuleLoader__.load({
1088
1135
  if (!row || !row.latest) { return; }
1089
1136
  var ver = row.latest;
1090
1137
  forceSet(name, "writing");
1091
- hostPost("force-exempt", { name: name, version: ver }).then(function (r) {
1092
- if (!r || !r.ok) { forceSet(name, "error", "写入豁免表失败:" + String((r && r.code) || "未知原因")); return; }
1138
+ /* 写豁免表是幂等的(同值重复写返回 changed:false),所以超时可以安全重试一次:
1139
+ 回包丢失时上一次很可能已经写成功,重试只会立刻返回 changed:false。 */
1140
+ function attempt(left) {
1141
+ return hostPost("force-exempt", { name: name, version: ver }, 15000).then(function (r) {
1142
+ if (r && r.code === "timeout" && left > 0) { return attempt(left - 1); }
1143
+ return r;
1144
+ });
1145
+ }
1146
+ attempt(1).then(function (r) {
1147
+ if (!r || !r.ok) {
1148
+ forceSet(name, "error", (r && r.code) === "timeout"
1149
+ ? "写入豁免表没有等到回应(超时)。这一步是幂等的:很可能已经写进去了 —— 点「重试」或先在官方插件页看一下豁免表都安全。"
1150
+ : "写入豁免表失败:" + String((r && r.code) || "未知原因"));
1151
+ return;
1152
+ }
1093
1153
  forceReset(name);
1094
1154
  forcedNames[name] = true; // 硬闸放行凭据(一次性,终态清理)
1155
+ /* 豁免不能「装完立刻删」:pnpm 每次安装都拿整个 lockfile 校验供应链策略,
1156
+ 被强制装上的版本还没满 24h 时删掉豁免,接下来几小时里所有 pnpm 操作都会被
1157
+ 整体拒绝。登记成熟时刻(+60s 缓冲),由 host 到点自动删掉这一条。 */
1158
+ try {
1159
+ var mature = isFinite(row.matureAt) ? Number(row.matureAt) : NaN;
1160
+ if (!isFinite(mature) && isFinite(row.publishedAt)) { mature = Number(row.publishedAt) + policyWindowMs; }
1161
+ if (isFinite(mature) && mature > 0) {
1162
+ hostPost("schedule-exempt-cleanup", { name: name, version: ver, dropAt: mature + 60000 }, 8000);
1163
+ }
1164
+ } catch (e1) { /* 登记失败只是这条豁免会多留一阵,不影响安装 */ }
1095
1165
  enqueue(name, { force: true });
1096
1166
  });
1097
1167
  }
@@ -1167,9 +1237,40 @@ window.__ModuleLoader__.load({
1167
1237
  return null;
1168
1238
  }
1169
1239
 
1240
+ /* 安装进度心跳:pnpm 一次重链几十个包时可能好几分钟零输出,看门狗不能只按总时长
1241
+ 判死。官方 client 用 remote.$on 订阅安装事件(plugin-manager/install-log 等),
1242
+ 我们拿同一组事件当「还在动」的心跳;拿不到就退回按总时长兜底。 */
1243
+ var pmEventsBound = false;
1244
+ var lastPmProgressAt = 0;
1245
+ function bindPmEvents() {
1246
+ if (pmEventsBound) { return; }
1247
+ var root = null;
1248
+ try { root = (ctx && ctx.remote) || null; } catch (e0) { root = null; }
1249
+ if (!root) { try { root = pickService(null, "remote"); } catch (e1) { root = null; } }
1250
+ if (!root || typeof root.$on !== "function") { return; }
1251
+ pmEventsBound = true;
1252
+ function note() { lastPmProgressAt = Date.now(); }
1253
+ try {
1254
+ root.$on("plugin-manager/install-log", note);
1255
+ root.$on("plugin-manager/install-state", note);
1256
+ root.$on("plugin-manager/changed", note);
1257
+ } catch (e2) {}
1258
+ }
1259
+
1260
+ /* /latest 必须带超时:比对是**串行**的,一个请求挂住会让后面所有包永远停在
1261
+ 「比对中 …」(fetch 默认没有超时,网络半死/代理吞包时最容易撞上)。 */
1262
+ var REGISTRY_TIMEOUT_MS = 12000;
1170
1263
  function fetchLatest(name) {
1171
1264
  var path = String(name).split("/").map(encodeURIComponent).join("/");
1172
- return fetch("https://registry.npmjs.org/" + path + "/latest", { cache: "no-store" })
1265
+ var ctl = null, timer = null;
1266
+ try { if (typeof AbortController === "function") { ctl = new AbortController(); } } catch (e0) {}
1267
+ function stop() { if (timer) { clearTimeout(timer); timer = null; } }
1268
+ var opts = { cache: "no-store" };
1269
+ if (ctl) {
1270
+ opts.signal = ctl.signal;
1271
+ timer = setTimeout(function () { try { ctl.abort(); } catch (e2) {} }, REGISTRY_TIMEOUT_MS);
1272
+ }
1273
+ return fetch("https://registry.npmjs.org/" + path + "/latest", opts)
1173
1274
  .then(function (r) {
1174
1275
  if (r.status === 404) {
1175
1276
  /* npm 上没有这个包:本地直装(link:/file:)或尚未发布 —— 不是「检查失败」。
@@ -1183,6 +1284,13 @@ window.__ModuleLoader__.load({
1183
1284
  })
1184
1285
  .then(function (d) {
1185
1286
  return d && d.version ? { version: String(d.version), requiresDsh: dshRequirementOf(d) } : null;
1287
+ })
1288
+ .then(function (v) { stop(); return v; }, function (e) {
1289
+ stop();
1290
+ if (e && (e.name === "AbortError" || /abort/i.test(String(e.message || "")))) {
1291
+ throw new Error("registry 请求超时(" + Math.round(REGISTRY_TIMEOUT_MS / 1000) + " 秒)");
1292
+ }
1293
+ throw e;
1186
1294
  });
1187
1295
  }
1188
1296
 
@@ -1220,17 +1328,29 @@ window.__ModuleLoader__.load({
1220
1328
  * 来源判定主路径是 host 半区的 /api/ui-tweaks/local-plugins(读 profile
1221
1329
  * 依赖声明):本地路径(link:/file:)与仓库/URL 直装(git/github/http)分列,
1222
1330
  * host 未重启/路由不可用时退回 registry 404 签名。 */
1331
+ /* host 半区的来源名单:它**卡住整条比对链**(回调不回来,所有行都停在「比对中 …」),
1332
+ 所以同样要超时;拿不到就当没有,退回 404 签名判定。 */
1333
+ var LOCAL_NAMES_TIMEOUT_MS = 8000;
1223
1334
  function fetchLocalNames(cb) {
1224
- fetch("/api/ui-tweaks/local-plugins", { cache: "no-store" })
1335
+ var ctl = null, timer = null;
1336
+ try { if (typeof AbortController === "function") { ctl = new AbortController(); } } catch (e0) {}
1337
+ function stop() { if (timer) { clearTimeout(timer); timer = null; } }
1338
+ var opts = { cache: "no-store" };
1339
+ if (ctl) {
1340
+ opts.signal = ctl.signal;
1341
+ timer = setTimeout(function () { try { ctl.abort(); } catch (e2) {} }, LOCAL_NAMES_TIMEOUT_MS);
1342
+ }
1343
+ fetch("/api/ui-tweaks/local-plugins", opts)
1225
1344
  .then(function (r) { if (!r.ok) { throw new Error("HTTP " + r.status); } return r.json(); })
1226
1345
  .then(function (d) {
1346
+ stop();
1227
1347
  cb({
1228
1348
  local: d && d.ok && Array.isArray(d.local) ? d.local : [],
1229
1349
  remote: d && d.ok && Array.isArray(d.remote) ? d.remote : [],
1230
1350
  dsh: d && d.ok && typeof d.dsh === "string" ? d.dsh : ""
1231
1351
  });
1232
1352
  })
1233
- .catch(function () { cb({ local: [], remote: [] }); });
1353
+ .catch(function () { stop(); cb({ local: [], remote: [] }); });
1234
1354
  }
1235
1355
  function isLocalSrc(src) { return /^(link:|file:)/.test(String(src || "")); }
1236
1356
  function isRemoteSrc(src) { return /^(git[+:@]|github:|git:|http)/.test(String(src || "")); }
@@ -1251,8 +1371,25 @@ window.__ModuleLoader__.load({
1251
1371
  var busyBefore = ""; // 本次安装前该行的版本(回读比对基准,不受中途重查影响)
1252
1372
  var busyLatest = ""; // 本次安装解析出的目标版本(取消后退回「有新版本」要用)
1253
1373
  var cancelling = false;
1374
+ /* 队列放行闸:底层请求真的收尾(busyChainDone)+ 终态已判定(busyFinished)才释放
1375
+ busy 并 pump 下一项。真机上「看门狗到点先放行、旧请求还在跑」会让两条 pnpm
1376
+ 叠在一起抢 profile 锁,表现就是全都卡住。 */
1377
+ var busyChainDone = true;
1378
+ var busyFinished = true;
1379
+ var busyReleased = true;
1380
+ var busyWatchdog = 0;
1381
+ var busyStartedAt = 0;
1254
1382
  var checking = false;
1255
1383
  var updatedCount = 0; // 本次页面会话里成功更新的插件数(>0 时头部挂「重启生效」提示条)
1384
+ /* 本次会话里成功更新、但装完仍是「关闭」的插件:重启提示栏里给一枚「启用」按钮。
1385
+ 官方安装默认不启用(installBundle 的 enabled 默认 false),我们更新时又刻意保留
1386
+ 行内原状态,所以「原本关着的插件」更新完仍然关着。按钮逐个调
1387
+ pluginManager.setBundleEnabled(name, true)(官方插件页那个总开关用的同一 API),
1388
+ 只动**这次更新过**的包,不碰用户没更新的插件。 */
1389
+ var updatedNames = []; // 更新成功的插件名(按顺序,去重)
1390
+ var updatedOff = {}; // name → true:更新后回读清单发现仍是关闭
1391
+ var enableStage = ""; // "" | "running" | "error"
1392
+ var enableNote = ""; // 启用失败的说明
1256
1393
  var isDesktop = /Electron/i.test(String(navigator.userAgent || ""));
1257
1394
  var listeners = []; // 面板重绘订阅(面板未开时也维护状态)
1258
1395
 
@@ -1303,14 +1440,29 @@ window.__ModuleLoader__.load({
1303
1440
  var name = b.name || b.id || "";
1304
1441
  var ver = b.version != null ? String(b.version) : (b.installedVersion != null ? String(b.installedVersion) : "");
1305
1442
  var src = typeof b.installed === "string" ? b.installed : (typeof b.source === "string" ? b.source : "");
1443
+ var errCode = b.error && b.error.code ? String(b.error.code) : "";
1444
+ var errDiag = b.error && b.error.diagnostic ? String(b.error.diagnostic) : "";
1445
+ /* listBundles 对「装了但读不到」的包仍会返回一行(带 error、没有 version)。
1446
+ 旧实现只显示「无法比对」,看不出是文件缺失还是清单坏了;把原因带到
1447
+ 标签悬停里(见 tagHtml 的 unknown 分支)。 */
1448
+ var why = "";
1449
+ if (!ver) {
1450
+ if (errCode) {
1451
+ why = "插件清单报错 " + errCode + (errDiag ? "(" + errDiag.slice(0, 160) + ")" : "") + ":读不到已装版本";
1452
+ } else if (b.availability === "missing") {
1453
+ why = "安装文件缺失(package.json 读不到)";
1454
+ }
1455
+ }
1306
1456
  return {
1307
1457
  name: name, version: ver, latest: "",
1308
1458
  state: isLocalSrc(src) ? "local" : (isRemoteSrc(src) ? "remote" : (ver ? "checking" : "unknown")),
1309
- src: src, enabled: b.enabled !== false, why: ""
1459
+ src: src, enabled: b.enabled !== false, why: why
1310
1460
  };
1311
1461
  }).filter(function (x) { return x.name; })
1312
1462
  /* 官方 @deepseek-ai 插件由 dsh 本体更新流程负责,直接从检查列表里剔除 */
1313
1463
  .filter(function (x) { return !/^@deepseek-ai\//.test(x.name); });
1464
+ /* 重查顺便校正「更新过但仍关闭」的标记(比如刚在官方插件页手动打开了) */
1465
+ rows.forEach(function (x) { if (x.enabled !== false) { delete updatedOff[x.name]; } });
1314
1466
  /* 与在飞的安装对齐(见 reconcileInstallStates):重查不得抹掉「更新中/等待更新」 */
1315
1467
  reconcileInstallStates();
1316
1468
  checking = false;
@@ -1403,7 +1555,8 @@ window.__ModuleLoader__.load({
1403
1555
  "bundle-in-use": "插件正在使用中,无法替换",
1404
1556
  "stale-approval": "构建脚本授权已失效,需重新授权",
1405
1557
  "incompatible-version": "版本与当前 dsh 不兼容",
1406
- "operation-error": "安装命令执行失败(pnpm 非零退出)"
1558
+ "operation-error": "安装命令执行失败(pnpm 非零退出)",
1559
+ "already-installed": "该插件已安装,DSH 不允许原地覆盖版本(已自动尝试「先卸载再安装」,仍未成功)"
1407
1560
  };
1408
1561
  /* failureText 与 coolingFromResult 都在文件上方的「纯函数区」里(错误码表作为
1409
1562
  参数传入),这里只保留这张码表;旧的长文案实现已删除,避免两份逻辑漂移。 */
@@ -1440,29 +1593,244 @@ window.__ModuleLoader__.load({
1440
1593
  queue.splice(i, 1);
1441
1594
  setRow(name, { state: "update", why: "" });
1442
1595
  }
1596
+ /* 放行队列(幂等):busy 状态、看门狗、强制凭据一起收掉,再 pump 下一项。
1597
+ 只有「底层请求已收尾 + 终态已判定」才该调用它(见 startOne / finishOne 的 done)。
1598
+ expectedName:旧一项的迟到回调(先前超时被放行的链)不得放行**当前**这一项。 */
1599
+ function releaseBusy(expectedName) {
1600
+ if (busyReleased) { return; }
1601
+ if (expectedName && busyName && expectedName !== busyName) { return; }
1602
+ busyReleased = true;
1603
+ if (busyWatchdog) { clearInterval(busyWatchdog); busyWatchdog = 0; }
1604
+ var nm = busyName;
1605
+ busyName = ""; busyReq = ""; busyBefore = ""; busyLatest = "";
1606
+ cancelling = false;
1607
+ if (nm) { delete forcedNames[nm]; } // 放行凭据只对一次安装生效,终态一律清掉
1608
+ emit();
1609
+ pump();
1610
+ }
1611
+
1612
+ /* ---- 更新 = 「先卸载、再安装」(DSH 安装接口的硬约束)--------------------
1613
+ 官方 installBundle 在**开着 HMR(热重载)的运行时**里会直接拒绝:
1614
+ `already-installed` —— 判据是"这个包名已经在 profile 依赖里",与版本无关
1615
+ (@deepseek-ai/dsh-plugin-manager/lib/index.js 的 installBundle:
1616
+ `if (ownerContext.get("hmr") !== void 0 && hasOwn(before, name)) throw`)。
1617
+ 官方工具说明给出的正路就是"先 removeBundle 再 installBundle"。因此:
1618
+ ① 先按老办法直接装 —— 没有 HMR 的运行时这一步就成功,且不用碰卸载;
1619
+ ② 只有被 already-installed 拒绝时,才退回"卸载 → 安装"两步。
1620
+ 返回最终那次 installBundle 的信封;onRemoved 在"确实卸载过"时回调(供失败回滚)。 */
1621
+ function isAlreadyInstalledResult(res) {
1622
+ if (!res) { return false; }
1623
+ /* 形状①:远程 API 的拒绝信封(web 端 / 未来版本) */
1624
+ if (res.ok === false) { return String((res.error && res.error.code) || "") === "already-installed"; }
1625
+ /* 形状②:桌面端进程内管理器把它报成「应用层失败」—— true 信封里 value.application === "failed"
1626
+ 且 value.error.code 就是那个码(failureText 正是这么取码的,实测就是这条路径) */
1627
+ var st = res.value;
1628
+ return !!(st && st.application === "failed" && String((st.error && st.error.code) || "") === "already-installed");
1629
+ }
1630
+ /* 结果信封到底是成功还是失败?**不能只看 ok**:桌面端进程内管理器把失败包成
1631
+ `{ok:true, value:{application:"failed", error:{code}}}`(0.2.1 第一版就栽在这,
1632
+ 把"卸载被拒"当成卸载成功,于是"已回滚"是假话、"仍需卸载"这条真正的原因被吞掉)。
1633
+ 返回空串=不是失败;否则返回失败码。 */
1634
+ function resultFailure(res) {
1635
+ if (!res) { return "empty-result"; }
1636
+ if (res.ok === false) { return String((res.error && res.error.code) || "unknown-error"); }
1637
+ var st = res.value;
1638
+ if (st && st.application === "failed") { return String((st.error && st.error.code) || "unknown-error"); }
1639
+ return "";
1640
+ }
1641
+ /* 卸载会触发一次 profile 重载。真机踩过的坑:卸载刚返回就立刻重装,新请求可能仍在
1642
+ 上一代管理器里执行、读到的还是卸载前的清单 → 又被 already-installed 拒。
1643
+ 所以两步之间要**等清单里真的没有它**再装,装的时候再按退避重试几次。 */
1644
+ var REMOVE_SETTLE_MS = 10000; // 等清单消失的上限
1645
+ var REMOVE_POLL_MS = 400; // 轮询间隔
1646
+ var INSTALL_RETRY_TIMES = 3; // 被 already-installed 拒后的重试次数
1647
+ var INSTALL_RETRY_DELAY_MS = 1500; // 每次重试前等多久
1648
+ /* 看门狗(真机教训:一次重链 44 个包的卸载跑了 5 分 08 秒,旧的固定 240 秒把它
1649
+ 判成超时,队列提前放行,旧请求还在跑 → 两条 pnpm 抢锁,全都卡住): */
1650
+ var WATCHDOG_TICK_MS = 5000; // 巡检间隔
1651
+ var SLOW_NOTE_MS = 90000; // 无进度 90s:只在行内提示「还在跑」
1652
+ var HARD_CAP_MS = 15 * 60000; // 单项总时长上限:到点才判超时
1653
+ var HARD_CAP_GRACE_MS = 5 * 60000; // 判超时后再等底层收尾 5 分钟才放行队列
1654
+ function bundleStillListed(pm, nm) {
1655
+ return Promise.resolve(pm.listBundles()).then(function (r) {
1656
+ var list = r && r.ok === false ? null : (r && r.value) || [];
1657
+ if (!list || !list.length) { return false; } // 拿不到清单:当作"没有",别把更新卡死
1658
+ for (var i = 0; i < list.length; i++) {
1659
+ if (list[i] && list[i].name === nm) { return true; }
1660
+ }
1661
+ return false;
1662
+ }, function () { return false; });
1663
+ }
1664
+ function waitBundlesGone(pm, nm, timeoutMs) {
1665
+ if (typeof pm.listBundles !== "function") { return Promise.resolve(false); }
1666
+ var deadline = Date.now() + (timeoutMs || REMOVE_SETTLE_MS);
1667
+ function poll() {
1668
+ return bundleStillListed(pm, nm).then(function (still) {
1669
+ if (!still) { return true; }
1670
+ if (Date.now() >= deadline) { return false; }
1671
+ return new Promise(function (r) { setTimeout(r, REMOVE_POLL_MS); }).then(poll);
1672
+ });
1673
+ }
1674
+ return poll();
1675
+ }
1676
+ function installRetrying(pm, spec, opts, left) {
1677
+ var n = left === undefined ? INSTALL_RETRY_TIMES : left;
1678
+ return Promise.resolve(pm.installBundle(spec, opts)).then(function (res) {
1679
+ if (!isAlreadyInstalledResult(res) || n <= 1) { return res; }
1680
+ return new Promise(function (r) { setTimeout(r, INSTALL_RETRY_DELAY_MS); })
1681
+ .then(function () { return installRetrying(pm, spec, opts, n - 1); });
1682
+ });
1683
+ }
1684
+ function installBundleEx(pm, spec, nm, opts, onRemoved) {
1685
+ return Promise.resolve(pm.installBundle(spec, opts)).then(function (res) {
1686
+ if (!isAlreadyInstalledResult(res) || typeof pm.removeBundle !== "function") {
1687
+ return { result: res, removed: false, removeFailure: "" };
1688
+ }
1689
+ return Promise.resolve(pm.removeBundle(nm)).then(function (rm) {
1690
+ var rmFail = resultFailure(rm);
1691
+ /* 卸载被拒:把原始拒绝交回去,同时把"卸载失败"这个真实原因一起带出,别再吞掉。
1692
+ 光一个 operation-error 没法排查 —— 把卸载那次的 diagnostic / pnpm 输出首行
1693
+ 一并带上(例如 pnpm 读不了 pnpm-workspace.yaml 时的 YAML 报错)。 */
1694
+ if (rmFail) {
1695
+ var diag = "";
1696
+ try {
1697
+ var st = rm && rm.value;
1698
+ diag = String((st && st.error && st.error.diagnostic) || (st && st.packageResult && st.packageResult.output) || "");
1699
+ } catch (e0) { diag = ""; }
1700
+ diag = stripAnsi(diag).split("\n").filter(function (ln) { return String(ln).trim(); })[0] || "";
1701
+ if (diag.length > 160) { diag = diag.slice(0, 160) + "…"; }
1702
+ try {
1703
+ var pr = rm && rm.value && rm.value.packageResult;
1704
+ if (pr && pr.logPath) { diag += (diag ? ";" : "") + "日志 " + pr.logPath; }
1705
+ } catch (e1) { /* 字段缺失就算了 */ }
1706
+ return { result: res, removed: false, removeFailure: rmFail + (diag ? ":" + diag : "") };
1707
+ }
1708
+ if (onRemoved) { onRemoved(); }
1709
+ return waitBundlesGone(pm, nm)
1710
+ .then(function () { return installRetrying(pm, spec, opts); })
1711
+ .then(function (res2) { return { result: res2, removed: true, removeFailure: "" }; });
1712
+ });
1713
+ });
1714
+ }
1715
+ /* 安装器返回的错误码翻成人话(这些码是稳定契约,优先用它,而不是甩一串英文) */
1716
+ function installCodeText(code) {
1717
+ var map = {
1718
+ "already-installed": "该插件已安装,DSH 不允许原地覆盖版本(已自动尝试「先卸载再安装」,仍未成功)",
1719
+ "incompatible-version": "这个版本与本机 dsh 不兼容,已被安装器拒绝",
1720
+ "invalid-spec": "安装参数不合法",
1721
+ "not-removable": "这个条目不允许卸载,因此没法用「卸载 + 安装」的方式更新",
1722
+ "bundle-in-use": "该插件正在使用中,安装器拒绝了卸载",
1723
+ "unknown-plugin": "安装器不认识这个插件,请点「重新检查」确认清单",
1724
+ "operation-error": "安装器执行失败",
1725
+ "stale-approval": "上一次安装的批准已过期,请重试",
1726
+ "ambiguous-install": "这次依赖变更没指明唯一插件,请点「重新检查」确认"
1727
+ };
1728
+ var t = map[String(code || "")];
1729
+ return t ? t + "(" + code + ")" : "";
1730
+ }
1443
1731
 
1444
1732
  function startOne(name) {
1445
1733
  var row = findRow(name);
1734
+ var pm = pickService(null, "remote.pluginManager");
1446
1735
  busyName = name;
1447
1736
  busyBefore = row ? row.version : "";
1448
1737
  busyLatest = (row && row.latest) || "";
1449
1738
  busyReq = uuid();
1739
+ busyChainDone = false;
1740
+ busyFinished = false;
1741
+ busyReleased = false;
1742
+ busyStartedAt = Date.now();
1450
1743
  setRow(name, { state: "busy", why: "" });
1451
1744
  emit();
1452
- var pm = pickService(null, "remote.pluginManager");
1453
- if (!pm) { finishOne(name, "failed", "remote.pluginManager 不可用"); return; }
1745
+ if (!pm) { busyChainDone = true; finishOne(name, "failed", "remote.pluginManager 不可用"); return; }
1454
1746
  var settled = false;
1455
- /* 看门狗只作兜底:正常路径一次调用就定案,不等它。 */
1456
- var watchdog = setTimeout(function () {
1457
- finish("unknown", "更新超时(240 秒未结束);结果未知,请点「重新检查」确认");
1458
- try { var pm2 = pickService(null, "remote.pluginManager"); if (pm2 && pm2.cancelInstall && busyReq) { pm2.cancelInstall(busyReq); } } catch (e) {}
1459
- }, 240000);
1747
+ var busyRemoved = false; // 本次更新走没走「先卸载再安装」:卸载过 → 失败必须回滚
1748
+ var lastProgress = busyStartedAt;
1749
+ var slowNote = "";
1750
+ bindPmEvents();
1751
+ /* 看门狗改成「有进度就不算卡」:①无输出 90s 只在行内提示仍在跑;②单项 15 分钟
1752
+ 才判超时;③判超时后仍等底层请求收尾(最多再 5 分钟)才放行队列 —— 否则旧请求
1753
+ 没结束、下一项又开始,两条 pnpm 会在 profile 锁上互相等,表现就是全都卡住。 */
1754
+ busyWatchdog = setInterval(function () {
1755
+ if (settled) { return; }
1756
+ var now = Date.now();
1757
+ if (lastPmProgressAt > lastProgress) { lastProgress = lastPmProgressAt; }
1758
+ var total = now - busyStartedAt;
1759
+ var idle = now - lastProgress;
1760
+ if (total >= HARD_CAP_MS) {
1761
+ clearInterval(busyWatchdog); busyWatchdog = 0;
1762
+ finish("unknown", "更新超时(" + Math.round(total / 60000) + " 分钟未结束);结果未知,请点「重新检查」确认");
1763
+ try { var pm2 = pickService(null, "remote.pluginManager"); if (pm2 && pm2.cancelInstall && busyReq) { pm2.cancelInstall(busyReq); } } catch (e) {}
1764
+ /* 判超时后底层请求可能还在跑:再给 5 分钟收尾,才放行队列 */
1765
+ setTimeout(function () { if (!busyReleased && busyName === name) { releaseBusy(name); } }, HARD_CAP_GRACE_MS);
1766
+ return;
1767
+ }
1768
+ if (idle >= SLOW_NOTE_MS && !settled) {
1769
+ var note = "pnpm 仍在运行(已 " + Math.max(1, Math.round(total / 60000)) +
1770
+ " 分钟,最近一次输出在 " + Math.round(idle / 1000) + " 秒前);要重链几十个包时可能超过 5 分钟,请再等等";
1771
+ if (note !== slowNote) { slowNote = note; setRow(name, { state: "busy", why: note }); emit(); }
1772
+ }
1773
+ }, WATCHDOG_TICK_MS);
1460
1774
  function finish(kind, why, meta) {
1461
1775
  if (settled) { return; }
1462
1776
  settled = true;
1463
- clearTimeout(watchdog);
1777
+ busyFinished = true;
1778
+ if (busyWatchdog) { clearInterval(busyWatchdog); busyWatchdog = 0; }
1779
+ /* 卸载成功但重装没成(失败/被取消)→ 把旧版本装回去,绝不把插件留在"没了"的状态。
1780
+ 只有 failed / cancelled 自动回滚:unknown 时安装可能仍在飞,硬回滚会打架,
1781
+ 改为交给 recoverAfterUnknown 复查(见下)。 */
1782
+ if (busyRemoved && busyBefore && (kind === "failed" || kind === "cancelled")) {
1783
+ var pmR = pickService(null, "remote.pluginManager");
1784
+ if (pmR && typeof pmR.installBundle === "function") {
1785
+ setRow(name, { state: "busy", why: "正在回滚到 " + busyBefore + "…" });
1786
+ emit();
1787
+ Promise.resolve(pmR.installBundle(name + "@" + busyBefore, { enabled: enabled })).then(function (r) {
1788
+ var rf = resultFailure(r);
1789
+ finishOne(name, kind, (why || "") +
1790
+ (rf ? ";回滚也失败了(" + rf + "),请手动重装 " + name + "@" + busyBefore : ";该插件已回滚到 " + busyBefore), meta);
1791
+ }, function () {
1792
+ finishOne(name, kind, (why || "") + ";回滚失败,请手动重装 " + name + "@" + busyBefore, meta);
1793
+ });
1794
+ return;
1795
+ }
1796
+ finishOne(name, kind, (why || "") + ";未能回滚,请手动重装 " + name + "@" + busyBefore, meta);
1797
+ return;
1798
+ }
1799
+ if (busyRemoved && kind === "unknown") {
1800
+ recoverAfterUnknown(name, busyBefore, why || "", meta);
1801
+ return;
1802
+ }
1464
1803
  finishOne(name, kind, why || "", meta);
1465
1804
  }
1805
+ /* 超时(unknown)且本次中途卸载过:不能只写一句「请确认」就放走 —— 真机上
1806
+ @linxin666/dsh-session-archive 就是这样被卸掉后没装回来(后续「装回」步骤的
1807
+ 结果还会因为 settled 被丢弃)。这里主动复查清单:不在就自动装回旧版本。 */
1808
+ function recoverAfterUnknown(nm, ver, why, meta) {
1809
+ var pmR = pickService(null, "remote.pluginManager");
1810
+ if (!ver || !pmR || typeof pmR.listBundles !== "function") {
1811
+ finishOne(nm, "unknown", why + (ver ? ";本次中途卸载过该插件,请手动重装 " + nm + "@" + ver : ""), meta);
1812
+ return;
1813
+ }
1814
+ setRow(nm, { state: "busy", why: "更新超时,正在确认 " + nm + " 还在不在并自动回滚 …" });
1815
+ emit();
1816
+ Promise.resolve(pmR.listBundles()).then(function (r) {
1817
+ var list = r && r.ok !== false ? (r.value || []) : [];
1818
+ var present = false;
1819
+ for (var i = 0; i < list.length; i++) { if (list[i] && list[i].name === nm) { present = true; break; } }
1820
+ if (present) {
1821
+ finishOne(nm, "unknown", why + ";该插件仍在清单中,版本请点「重新检查」确认", meta);
1822
+ return;
1823
+ }
1824
+ return Promise.resolve(pmR.installBundle(nm + "@" + ver, { enabled: true })).then(function (rr) {
1825
+ var rf = resultFailure(rr);
1826
+ finishOne(nm, "unknown", why + (rf
1827
+ ? ";自动回滚失败(" + rf + "),请手动重装 " + nm + "@" + ver
1828
+ : ";已自动装回 " + nm + "@" + ver), meta);
1829
+ });
1830
+ }).catch(function () {
1831
+ finishOne(nm, "unknown", why + ";复查/回滚失败,请手动确认并重装 " + nm + "@" + ver, meta);
1832
+ });
1833
+ }
1466
1834
  function settle(st) {
1467
1835
  if (!st || typeof st !== "object") { finish("unknown", "安装未返回可判定的结果"); return; }
1468
1836
  var app = st.application;
@@ -1504,17 +1872,17 @@ window.__ModuleLoader__.load({
1504
1872
  function abortForCooling(version, publishedAt, matureAt) {
1505
1873
  if (settled) { return; }
1506
1874
  settled = true;
1507
- clearTimeout(watchdog);
1875
+ busyFinished = true;
1876
+ if (busyWatchdog) { clearInterval(busyWatchdog); busyWatchdog = 0; }
1508
1877
  setRow(name, {
1509
1878
  state: "cooling", latest: version, why: "", publishedAt: publishedAt, matureAt: matureAt,
1510
1879
  detail: "安装前校验:registry 记录 " + version + " 发布于 " + new Date(publishedAt).toISOString() +
1511
1880
  ",仍在 minimumReleaseAge 窗口内(" + fmtWindow(policyWindowMs) + "),本次安装已拦下(未安装)。"
1512
1881
  });
1513
1882
  scheduleCoolingRefresh(name, matureAt);
1514
- busyName = ""; busyReq = ""; busyBefore = ""; busyLatest = "";
1515
- cancelling = false;
1516
- emit();
1517
- pump();
1883
+ /* 还没开始装就被拦下:底层链不会再跑,直接放行队列 */
1884
+ busyChainDone = true;
1885
+ releaseBusy(name);
1518
1886
  }
1519
1887
  function guardMaturity(nm, version, go) {
1520
1888
  if (!version) { go(); return; } // 版本未知(@latest 兜底路径)不拦
@@ -1544,11 +1912,42 @@ window.__ModuleLoader__.load({
1544
1912
  guardMaturity(name, targetVersion, function () { resolve(spec); });
1545
1913
  });
1546
1914
  }).then(function (spec) {
1547
- return pm.installBundle(spec, { enabled: enabled, requestId: busyReq });
1548
- }).then(function (res) {
1549
- if (!res || res.ok === false) { lostReply(res); return; }
1915
+ return installBundleEx(pm, spec, name, { enabled: enabled, requestId: busyReq }, function () {
1916
+ busyRemoved = true;
1917
+ /* 让用户看清这两步:HMR 运行时不允许原地覆盖已装的包,只能先卸再装 */
1918
+ setRow(name, { state: "busy", why: "该插件已装过:DSH 不允许原地覆盖版本,正在先卸载、再用 " + targetVersion + " 装回…" });
1919
+ emit();
1920
+ });
1921
+ }).then(function (out) {
1922
+ var res = out.result;
1923
+ if (!res || res.ok === false) {
1924
+ /* 明确的拒绝(带错误码)直接翻成人话给用户看;没有码才按"回包丢了"去追结果 */
1925
+ var code = res && res.error ? res.error.code : "";
1926
+ var human = installCodeText(code);
1927
+ if (human) { finish("failed", human); return; }
1928
+ lostReply(res); return;
1929
+ }
1930
+ var fail = resultFailure(res);
1931
+ if (fail) {
1932
+ var text = installCodeText(fail);
1933
+ if (!text) { lostReply(res); return; }
1934
+ if (out.removeFailure) {
1935
+ text += ";这一步必须「先卸载再安装」,但卸载被拒(" + out.removeFailure + "),所以没法用两步法更新它";
1936
+ } else if (!out.removed) {
1937
+ text += "(DSH 不允许原地覆盖已装的包,需要「先卸载再安装」)";
1938
+ }
1939
+ finish("failed", text); return;
1940
+ }
1550
1941
  settle(res.value);
1551
- }).catch(function (e) { finish("failed", "安装请求失败:" + msgOf(e)); });
1942
+ }).catch(function (e) { finish("failed", "安装请求失败:" + msgOf(e)); })
1943
+ .then(chainSettled, chainSettled);
1944
+ /* 底层链真正收尾:只有「终态已判定 + 链已收尾」才放行队列 —— 旧实现看门狗到点就
1945
+ done()→pump(),旧请求还在跑,下一项又发 pnpm,两条操作叠在 profile 锁上。 */
1946
+ function chainSettled() {
1947
+ if (busyName !== name) { return; } // 这一项早已收尾/被放行:别动当前项
1948
+ busyChainDone = true;
1949
+ if (busyFinished) { releaseBusy(name); }
1950
+ }
1552
1951
  }
1553
1952
 
1554
1953
  /* 终态落定:回读清单核对真实版本(registry 解析与 pnpm 实际安装可能不一致,
@@ -1560,16 +1959,27 @@ window.__ModuleLoader__.load({
1560
1959
  var before = busyBefore;
1561
1960
  var latest = busyLatest;
1562
1961
  var pm = pickService(null, "remote.pluginManager");
1962
+ var doneOnce = false;
1963
+ var doneGuard = 0;
1563
1964
  function done() {
1564
- busyName = ""; busyReq = ""; busyBefore = ""; busyLatest = "";
1565
- cancelling = false;
1566
- delete forcedNames[name]; // 放行凭据只对一次安装生效,终态一律清掉
1567
- emit();
1568
- pump();
1965
+ if (doneOnce) { return; }
1966
+ doneOnce = true;
1967
+ if (doneGuard) { clearTimeout(doneGuard); doneGuard = 0; }
1968
+ if (busyName !== name) { return; } // 迟到回调(旧一项已被放行):不改当前项
1969
+ busyFinished = true;
1970
+ /* 链已收尾就直接放行;否则等 chainSettled(例如 lostReply 还在等 waitForInstall) */
1971
+ if (busyChainDone) { releaseBusy(name); }
1569
1972
  }
1570
- function judge(ver) {
1973
+ /* 回读清单本身也可能挂住(RPC 无超时):60 秒还没结论就先收尾,别让队列陪着它卡死 */
1974
+ doneGuard = setTimeout(done, 60000);
1975
+ function judge(ver, info) {
1571
1976
  if (ver && ver !== before) {
1572
1977
  updatedCount++; // 版本真的变了:装到盘上的已是新代码,运行中的进程还是旧的
1978
+ if (updatedNames.indexOf(name) < 0) { updatedNames.push(name); }
1979
+ /* 装完回读的 enabled 才是真的:官方安装默认不启用,我们传的是行内原状态,
1980
+ 所以「原本关着的插件」更新完仍然关着 —— 记下来给重启提示栏的启用按钮用。 */
1981
+ var onNow = info ? info.enabled !== false : ((findRow(name) || {}).enabled !== false);
1982
+ if (onNow) { delete updatedOff[name]; } else { updatedOff[name] = true; }
1573
1983
  setRow(name, { version: ver, state: "current", latest: ver, why: "", publishedAt: NaN, matureAt: NaN, detail: "" });
1574
1984
  /* 安装真的落地了:顺手让 host 半边归并豁免表,免得陈旧条遮蔽下一次安装 */
1575
1985
  normalizeExcludes();
@@ -1596,23 +2006,85 @@ window.__ModuleLoader__.load({
1596
2006
  done();
1597
2007
  }
1598
2008
  function attempt(n) {
1599
- if (!pm || !pm.listBundles) { judge(""); return; }
2009
+ if (!pm || !pm.listBundles) { judge("", null); return; }
1600
2010
  Promise.resolve(pm.listBundles()).then(function (r) {
1601
2011
  var bundles = (r && r.ok && r.value) || [];
1602
2012
  if (!bundles.length && n < 3) { setTimeout(function () { attempt(n + 1); }, 1500); return; }
1603
- var ver = "";
2013
+ var found = null;
1604
2014
  for (var i = 0; i < bundles.length; i++) {
1605
- if (bundles[i].name === name) { ver = bundles[i].version != null ? String(bundles[i].version) : ""; break; }
2015
+ if (bundles[i].name === name) { found = bundles[i]; break; }
1606
2016
  }
1607
- judge(ver);
2017
+ judge(found && found.version != null ? String(found.version) : "", found || null);
1608
2018
  }).catch(function () {
1609
2019
  if (n < 3) { setTimeout(function () { attempt(n + 1); }, 1500); return; }
1610
- judge("");
2020
+ judge("", null);
1611
2021
  });
1612
2022
  }
1613
2023
  attempt(0);
1614
2024
  }
1615
2025
 
2026
+ /* ---- 重启提示栏里的「启用已更新的插件」 ---------------------------------
2027
+ * 官方安装/更新的默认结果是**不启用**(installBundle 的 enabled 默认 false),
2028
+ * 面板更新又刻意保留行内原状态,于是「原本关着的插件」更新完仍然关着。
2029
+ * 这里给一个显式按钮:逐个调 setBundleEnabled(name, true)(官方插件页总开关同款),
2030
+ * 串行执行(host 每次启用都会写 manifest + reload,别并发),只动本次更新过的包。
2031
+ * 成功就从标记里删掉;失败留在按钮里可重试,原因写在提示条下方。 */
2032
+ function pendingEnableNames() {
2033
+ var out = [];
2034
+ for (var i = 0; i < updatedNames.length; i++) {
2035
+ if (updatedOff[updatedNames[i]]) { out.push(updatedNames[i]); }
2036
+ }
2037
+ return out;
2038
+ }
2039
+ function enableUpdated() {
2040
+ if (enableStage === "running") { return; }
2041
+ var todo = pendingEnableNames();
2042
+ if (!todo.length) { return; }
2043
+ var pm = pickService(null, "remote.pluginManager");
2044
+ if (!pm || typeof pm.setBundleEnabled !== "function") {
2045
+ enableStage = "error";
2046
+ enableNote = "这个 dsh 版本的插件管理器没有 setBundleEnabled,请到官方插件页手动打开这几个插件。";
2047
+ emit();
2048
+ return;
2049
+ }
2050
+ enableStage = "running";
2051
+ enableNote = "";
2052
+ emit();
2053
+ var failed = [];
2054
+ function step(i) {
2055
+ if (i >= todo.length) {
2056
+ enableStage = failed.length ? "error" : "";
2057
+ enableNote = failed.length ? "有 " + failed.length + " 个没启用成功:" + failed.join("、") + ";可以再点一次。" : "";
2058
+ emit();
2059
+ return;
2060
+ }
2061
+ var nm = todo[i];
2062
+ var p;
2063
+ try { p = Promise.resolve(pm.setBundleEnabled(nm, true)); }
2064
+ catch (e0) { failed.push(nm + "(" + msgOf(e0) + ")"); step(i + 1); return; }
2065
+ p.then(function (res) {
2066
+ var code = "";
2067
+ if (!res || res.ok === false) {
2068
+ code = String((res && res.error && res.error.code) || "unknown-error");
2069
+ } else {
2070
+ var st = res.value;
2071
+ if (st && st.application === "failed") { code = String((st.error && st.error.code) || "unknown-error"); }
2072
+ }
2073
+ if (code) {
2074
+ failed.push(nm + "(" + code + ")");
2075
+ } else {
2076
+ delete updatedOff[nm];
2077
+ setRow(nm, { enabled: true });
2078
+ }
2079
+ step(i + 1);
2080
+ }, function (e) {
2081
+ failed.push(nm + "(" + msgOf(e) + ")");
2082
+ step(i + 1);
2083
+ });
2084
+ }
2085
+ step(0);
2086
+ }
2087
+
1616
2088
  /* 取消:排队项只摘自己(cancelQueued);在装项请求取消**本次**,队列保留,
1617
2089
  等它落定后 done() → pump() 自然接着装下一项。
1618
2090
  旧实现把整个 queue 清空,于是「等待更新」的项全部退回「有新版本」——
@@ -1725,7 +2197,7 @@ window.__ModuleLoader__.load({
1725
2197
  var rw = liveWrap(RST_PANEL_ID, '.rwrap');
1726
2198
  if (rw) { placeNear(rstBtn, rw, RST_WIDTH, 180); }
1727
2199
  var pw = liveWrap(PANEL_ID, '.wrap');
1728
- if (pw) { placeNear(document.getElementById(BTN_ID), pw, PANEL_WIDTH, 220); }
2200
+ if (pw) { placeNear(document.getElementById(BTN_ID), pw, PANEL_WIDTH, 220); syncSb(pw); }
1729
2201
  };
1730
2202
  try { if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(run); return; } } catch (e) {}
1731
2203
  setTimeout(run, 0);
@@ -1769,7 +2241,20 @@ window.__ModuleLoader__.load({
1769
2241
  ".hd b{flex:1;font-size:13px;font-weight:600}" +
1770
2242
  ".x{cursor:pointer;opacity:.55;font-size:17px;line-height:1;padding:0 3px}" +
1771
2243
  ".x:hover{opacity:1}" +
1772
- ".rows{flex:1 1 auto;min-height:0;overflow:auto}" +
2244
+ ".rowswrap{flex:1 1 auto;min-height:0;position:relative;display:flex;flex-direction:column}" +
2245
+ /* 列表容器:隐藏原生滚动条(Windows 上会吃掉 17px 行内宽),改用浮层拇指 */
2246
+ ".rows{flex:1 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;scrollbar-width:none;-ms-overflow-style:none}" +
2247
+ ".rows::-webkit-scrollbar{width:0;height:0}" +
2248
+ /* 浮层滚动条:与对话区那条自绘滚动条同一套观感(currentColor、7px→9px、可拖动) */
2249
+ ".sbbar{position:absolute;top:0;right:0;width:12px;height:100%;z-index:2;" +
2250
+ "user-select:none;-webkit-user-select:none;touch-action:none}" +
2251
+ ".sbbar[hidden]{display:none}" +
2252
+ ".sbthumb{position:absolute;right:0;top:0;width:100%;background:transparent}" +
2253
+ ".sbthumb::after{content:\"\";position:absolute;top:0;bottom:0;right:2px;width:7px;border-radius:4px;" +
2254
+ "background:rgba(128,128,128,.45);background:color-mix(in srgb,currentColor 36%,transparent);" +
2255
+ "transition:width .12s,right .12s,background .12s}" +
2256
+ ".sbbar:hover .sbthumb::after,.sbthumb.drag::after{right:1px;width:9px;" +
2257
+ "background:rgba(128,128,128,.68);background:color-mix(in srgb,currentColor 60%,transparent)}" +
1773
2258
  ".rowwrap{border-bottom:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.12))}" +
1774
2259
  ".rowwrap:last-child{border-bottom:0}" +
1775
2260
  ".row{display:flex;align-items:center;gap:8px;padding:7px 12px}" +
@@ -1818,6 +2303,9 @@ window.__ModuleLoader__.load({
1818
2303
  ".restart{flex:0 0 auto;padding:6px 12px;font-size:12px;line-height:1.5;" +
1819
2304
  "background:rgba(62,207,142,.12);color:#4fbf8b;" +
1820
2305
  "border-bottom:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.18))}" +
2306
+ /* 提示栏里的「启用已更新的插件」:与冷静期详情里的强制按钮同一套 .fbtn.fgo 样式 */
2307
+ ".restart .fbtn{margin-left:8px;vertical-align:1px}" +
2308
+ ".rnote{margin-top:4px;font-size:11px;line-height:1.45;color:#e07070;opacity:.9}" +
1821
2309
  ".foot{padding:8px 12px;font-size:11px;line-height:1.5;opacity:.6;" +
1822
2310
  "border-top:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.12))}" +
1823
2311
  "</style><div class=\"wrap\" role=\"dialog\" aria-label=\"插件更新\" tabindex=\"-1\" hidden></div>";
@@ -1855,6 +2343,8 @@ window.__ModuleLoader__.load({
1855
2343
  rows.forEach(function (r) { if (r.state === "update" && queue.indexOf(r.name) < 0 && r.name !== busyName) { enqueue(r.name); } });
1856
2344
  return;
1857
2345
  }
2346
+ /* 重启提示栏里的「启用已更新的插件」(橙色按钮,样式同强制更新) */
2347
+ if (t.classList.contains("renable")) { enableUpdated(); return; }
1858
2348
  if (t.classList.contains("cooling")) {
1859
2349
  /* 冷静期标签:只展开 / 收起详情,绝不触发安装 */
1860
2350
  var nm0 = t.getAttribute("data-n");
@@ -1984,7 +2474,109 @@ window.__ModuleLoader__.load({
1984
2474
  }
1985
2475
  function accessModeBar() {
1986
2476
  if (!hasAutoReviewSession()) { return ""; }
1987
- return "<div class=\"warn\">⚠ 有会话正在用「自动审查」(Auto review)访问模式:这个模式下装/更新插件有时会失败,助手这边也会跟着一连串报错,要重启客户端才能恢复;该模式下每次工具调用还会先过一遍审查,审查服务不可用或限流时,这次调用会被直接拒绝、不执行。建议先把该会话切换成「工作区内修改」,装完再切回「自动审查」。</div>";
2477
+ return "<div class=\"warn\">⚠ 有会话在用「自动审查」:装/更新插件容易失败,工具调用也可能被审查拦掉。建议先切「工作区内修改」,装完再切回。</div>";
2478
+ }
2479
+
2480
+ /* ---- 浮层滚动条(更新面板)-----------------------------------------------
2481
+ * 原生滚动条会在 Windows 上占掉列表 17px 的宽,把每一行往里挤;这里隐藏原生条,
2482
+ * 只在内容右缘画一条拇指(绝对定位、不参与布局)。
2483
+ * **抄对话区那条自绘滚动条的观感与手感**(模块 2,A/B 实测过的实现):
2484
+ * · 视觉:默认 `color-mix(currentColor 32%)`、悬停/拖动时加宽到 9px、55%;
2485
+ * 不自动隐藏(常驻半透明,一眼看得出还能滚)。
2486
+ * · 拖动:PointerCapture + 按下点偏移(不是「拇指中心跳过来」),
2487
+ * 点轨道时先把拇指中心移到点击处再顺势进入拖动;pointermove 同步写
2488
+ * scrollTop,scroll 事件回纠几何;轨道上滚轮转发给真容器。
2489
+ * 面板是 Shadow DOM 浮层:事件只挂在面板内部节点,不监听 document。 */
2490
+ var SB_THUMB_MIN = 28;
2491
+ function sbParts(wrap) {
2492
+ var sc = wrap && wrap.querySelector(".rows");
2493
+ var bar = wrap && wrap.querySelector(".sbbar");
2494
+ var th = bar && bar.querySelector(".sbthumb");
2495
+ return sc && bar && th ? { sc: sc, bar: bar, th: th } : null;
2496
+ }
2497
+ /* 几何:高 = 轨道 × 可见比,位置 = scrollTop / 可滚动范围。
2498
+ 注意别用 bar.clientHeight 量轨道高:`hidden` 时它是 0,会把自己永久锁在隐藏。
2499
+ 轨道与滚动容器同高(都填满 .rowswrap),用视口高即可。 */
2500
+ function syncSb(wrap) {
2501
+ var p = sbParts(wrap);
2502
+ if (!p) { return; }
2503
+ var viewH = p.sc.clientHeight;
2504
+ var range = p.sc.scrollHeight - viewH;
2505
+ if (range <= 0 || viewH <= 0) { p.bar.hidden = true; return; }
2506
+ p.bar.hidden = false;
2507
+ var barH = viewH;
2508
+ var h = Math.round(barH * viewH / p.sc.scrollHeight);
2509
+ if (h < SB_THUMB_MIN) { h = SB_THUMB_MIN; }
2510
+ if (h > barH) { h = barH; }
2511
+ var maxTop = barH - h;
2512
+ var top = maxTop > 0 ? Math.round(maxTop * p.sc.scrollTop / range) : 0;
2513
+ if (top < 0) { top = 0; }
2514
+ if (top > maxTop) { top = maxTop; }
2515
+ if (p.th.style.height !== h + "px") { p.th.style.height = h + "px"; }
2516
+ if (p.th.style.top !== top + "px") { p.th.style.top = top + "px"; }
2517
+ }
2518
+ function bindSb(wrap) {
2519
+ var p = sbParts(wrap);
2520
+ if (!p || p.bar.__sbBound) { return; }
2521
+ p.bar.__sbBound = true;
2522
+ var sc = p.sc, bar = p.bar, th = p.th;
2523
+ var drag = null;
2524
+ sc.addEventListener("scroll", function () { syncSb(wrap); }, { passive: true });
2525
+ bar.addEventListener("pointerdown", function (e) {
2526
+ if (e.button !== undefined && e.button !== 0) { return; }
2527
+ var range = sc.scrollHeight - sc.clientHeight;
2528
+ var barH = sc.clientHeight;
2529
+ if (range <= 0 || barH <= 0) { return; }
2530
+ syncSb(wrap);
2531
+ var barRect = bar.getBoundingClientRect();
2532
+ var thRect = th.getBoundingClientRect();
2533
+ if (e.clientY >= thRect.top && e.clientY <= thRect.bottom) {
2534
+ /* 按在拇指上:保持按下点与拇指顶部的偏移 */
2535
+ drag = { offsetY: e.clientY - thRect.top, barRect: barRect };
2536
+ } else {
2537
+ /* 按在轨道上:先把拇指中心挪到点击处,再顺势进入拖动 */
2538
+ var h = th.offsetHeight || SB_THUMB_MIN;
2539
+ var maxTop = Math.max(0, barH - h);
2540
+ var top0 = Math.round(e.clientY - barRect.top - h / 2);
2541
+ if (top0 < 0) { top0 = 0; }
2542
+ if (top0 > maxTop) { top0 = maxTop; }
2543
+ sc.scrollTop = maxTop > 0 ? top0 / maxTop * range : 0;
2544
+ syncSb(wrap);
2545
+ thRect = th.getBoundingClientRect();
2546
+ drag = { offsetY: e.clientY - thRect.top, barRect: barRect };
2547
+ }
2548
+ th.classList.add("drag");
2549
+ try { bar.setPointerCapture(e.pointerId); } catch (e0) {}
2550
+ e.preventDefault();
2551
+ });
2552
+ bar.addEventListener("pointermove", function (e) {
2553
+ if (!drag) { return; }
2554
+ var range = sc.scrollHeight - sc.clientHeight;
2555
+ var barH = sc.clientHeight;
2556
+ var h = th.offsetHeight || SB_THUMB_MIN;
2557
+ var maxTop = Math.max(0, barH - h);
2558
+ var top = e.clientY - drag.barRect.top - drag.offsetY;
2559
+ if (top < 0) { top = 0; }
2560
+ if (top > maxTop) { top = maxTop; }
2561
+ sc.scrollTop = maxTop > 0 ? top / maxTop * range : 0;
2562
+ syncSb(wrap);
2563
+ e.preventDefault();
2564
+ });
2565
+ function release(e) {
2566
+ if (!drag) { return; }
2567
+ drag = null;
2568
+ th.classList.remove("drag");
2569
+ try { bar.releasePointerCapture(e.pointerId); } catch (e0) {}
2570
+ }
2571
+ bar.addEventListener("pointerup", release);
2572
+ bar.addEventListener("pointercancel", release);
2573
+ /* 指针压在条上时,滚轮转发给真容器(条是覆盖层,不是滚动容器本身) */
2574
+ bar.addEventListener("wheel", function (e) {
2575
+ e.preventDefault();
2576
+ sc.scrollTop += e.deltaY;
2577
+ syncSb(wrap);
2578
+ }, { passive: false });
2579
+ syncSb(wrap);
1988
2580
  }
1989
2581
 
1990
2582
  function paint() {
@@ -1993,9 +2585,19 @@ window.__ModuleLoader__.load({
1993
2585
  /* 有成功更新就常驻提示:装到盘上的是新代码,运行中的还是旧的,
1994
2586
  不重启(桌面端)/不重启 dsh 并强刷(web 端)就不会生效。 */
1995
2587
  function restartBar() {
1996
- if (!updatedCount) { return ""; }
1997
- return "<div class=\"restart\">已更新 " + updatedCount + " 个插件," +
1998
- (isDesktop ? "重启客户端后生效" : "重启 dsh 服务并刷新页面后生效") + "</div>";
2588
+ var pending = pendingEnableNames();
2589
+ if (!updatedCount && !pending.length) { return ""; }
2590
+ var head = updatedCount
2591
+ ? "已更新 " + updatedCount + " 个插件" +
2592
+ (pending.length ? ",其中 " + pending.length + " 个仍是关闭状态" : "") +
2593
+ "," + (isDesktop ? "重启客户端后生效" : "重启 dsh 服务并刷新页面后生效")
2594
+ : "";
2595
+ var btn = pending.length
2596
+ ? "<span class=\"fbtn fgo renable\"" + KBD + " title=\"调用插件管理器的 setBundleEnabled,逐个打开这次更新过、但仍是关闭的插件\">" +
2597
+ (enableStage === "running" ? "正在启用 …" : "启用已更新的 " + pending.length + " 个插件") + "</span>"
2598
+ : "";
2599
+ var note = enableNote ? "<div class=\"rnote\">" + esc(enableNote) + "</div>" : "";
2600
+ return "<div class=\"restart\">" + head + btn + note + "</div>";
1999
2601
  }
2000
2602
  rows.forEach(function (r) { if (r.state === "update") { upd++; } });
2001
2603
  var allBtn = upd ? "<span class=\"allbtn\"" + KBD + ">全部更新(" + upd + ")</span>" : "<span class=\"allbtn\" hidden></span>";
@@ -2023,7 +2625,9 @@ window.__ModuleLoader__.load({
2023
2625
  }
2024
2626
  var html = "<div class=\"hd\"><b>插件更新 · " + (busyName ? "正在更新 " + esc(busyName) + " …" : (checking ? "检查中…" : (rows.length ? upd + " 个插件有新版本" : "插件更新检查"))) + "</b>" +
2025
2627
  "<span class=\"recheck\"" + KBD + " title=\"重新读取插件清单并比对 registry\">重新检查</span>" + allBtn +
2026
- "<span class=\"x\"" + KBD + " title=\"关闭\" aria-label=\"关闭\">×</span></div>" + restartBar() + accessModeBar() + "<div class=\"rows\">" + body + "</div>" +
2628
+ "<span class=\"x\"" + KBD + " title=\"关闭\" aria-label=\"关闭\">×</span></div>" + restartBar() + accessModeBar() +
2629
+ "<div class=\"rowswrap\"><div class=\"rows\">" + body + "</div>" +
2630
+ "<div class=\"sbbar\" aria-hidden=\"true\"><div class=\"sbthumb\"></div></div></div>" +
2027
2631
  /* 面板脚注:把「只查第三方」这条口径写在明处(plugin-manager 同款一句) */
2028
2632
  "<div class=\"foot\">只检查第三方插件;官方 @deepseek-ai/ 包随 DSH 本体升级。</div>";
2029
2633
  if (wrap.innerHTML !== html) {
@@ -2038,6 +2642,9 @@ window.__ModuleLoader__.load({
2038
2642
  wrap.hidden = false;
2039
2643
  /* 位置只有一个来源:那枚「检查更新」按钮(不可拖动) */
2040
2644
  placeNear(document.getElementById(BTN_ID), wrap, PANEL_WIDTH, 220);
2645
+ /* 浮层滚动条:先绑事件(innerHTML 换过就重绑),再按当前高度画拇指 */
2646
+ bindSb(wrap);
2647
+ syncSb(wrap);
2041
2648
  }
2042
2649
  }
2043
2650
 
@@ -2203,34 +2810,69 @@ window.__ModuleLoader__.load({
2203
2810
  }
2204
2811
 
2205
2812
  /* ======================================================================
2206
- * 模块 5:回合尾「本回合改动」补位卡(0.2.0 起)
2813
+ * 模块 5:回合尾「本回合改动」——数据聚合 + 两种兜底情形下的自画卡
2207
2814
  *
2208
- * 缺陷(0.2.1-rc.2 实测):官方回合尾卡读的是 workspaceChanges 服务,摘要与
2209
- * 对比只在内存 + 系统临时目录里活着,Session 一释放就 404,卡片变成一句
2210
- * 「这轮改动的内容已不可用」。但每次文件工具调用都把前后全文写在工具结果事件的
2211
- * meta.diffs: [{path, oldText, newText}] 里,**随会话日志永久保存** —— 客户端画
2212
- * 工具卡 diff 用的就是它(dsh-client-ui-tool 的 appliedDiffs)。所以本模块
2815
+ * 缺陷(0.2.1-rc.2 实测):官方回合尾卡读的是 workspaceChanges 服务,摘要与对比
2816
+ * 只活在 Host 内存 + 系统临时目录里,Session 一释放就 404,卡片直接不画。但每次
2817
+ * 文件工具调用都把前后全文写在工具结果事件的 meta.diffs:[{path,oldText,newText}]
2818
+ * 里,**随会话日志永久保存** —— 客户端画工具卡 diff 用的就是它。所以本模块
2213
2819
  * **不新增任何持久化**:用 uiConversation 的 Turn Definition 把一回合内这些
2214
- * meta.diffs 聚合起来,注册到 conversation.chat.turnTail(官方声明的 list 槽位,
2215
- * 已有 deliverables / plan / schedule 三家),并且**只在官方卡已经失效时才补位**。
2820
+ * meta.diffs 按文件聚合起来,注册到 conversation.chat.turnTail(官方声明的 list
2821
+ * 槽位),供两处使用:
2822
+ * 1. 模块 5b:喂给官方卡的数据(主路径,官方组件自己渲染);
2823
+ * 2. 本模块的自画卡:只在官方一定不会画的两种情形下出现(见下)。
2824
+ *
2825
+ * 0.2.1 修的两处(0.2.0 实测「卡片一次都没画出来」):
2826
+ * - **必须实现 buildLocationData**:会话装配器只为实现了它的 Definition 发布
2827
+ * Turn 作用域数据,而 changesCard 读的 `owner.turn.data.get(CHANGES_KIND)`
2828
+ * 正是这里发布的 value。0.2.0 缺了它 → state 恒为 null → 卡片永不画。
2829
+ * - **补位条件按「官方那侧会不会画」判断,不是只按「探测到 404」**:官方卡还受
2830
+ * 设置项「显示编码工具视图」约束(showCodeDiff = configForms.developerTools
2831
+ * .enabled);开关关着时路由照样 200,只按 404 判断会两边都不画。同理,该回合
2832
+ * 没有 workspace/changes 公告(seq 为 null)时官方没有可请求的摘要。
2216
2833
  *
2217
- * 边界(README 同步):
2834
+ * 自画卡的边界(README 同步):
2218
2835
  * - 只覆盖**文件工具**的改动;pwsh / npm 等造成的改动进不来(只有官方卡含)。
2219
2836
  * - 嵌套调用(run_code 里的 edit)走 tool/ptc-dispatch 事件,该事件没有 turn 字段,
2220
2837
  * 用 rootCallId → turn 的映射(来自 tool/call)挂回回合。
2221
2838
  * - 探测结果只缓存在本次连接内(connection/reset 清空)。官方自己永久缓存 missing,
2222
2839
  * 那会在宿主恢复后留下双份卡。
2840
+ * - 外观照官方骨架(60px 卡头 / 图标块 / 千分位统计 / 4 行折叠 / hover 迷你 diff),
2841
+ * 点击一律打开官方右侧 Review;没有任何内联展开的 diff。
2223
2842
  * ====================================================================== */
2224
2843
  var REACT = null;
2225
2844
  try { REACT = require("react"); } catch (e) { REACT = null; }
2226
2845
 
2227
2846
  var CHANGES_KIND = "ui-tweaks-changes";
2228
2847
  var SUMMARY_ROUTE = "api/changes.summary"; /* 文档相对,勿加前导斜杠(同官方 CHANGED_FILES_ROUTE) */
2229
- var DIFF_ROW_LIMIT = 400; /* 单个文件最多画多少行 diff */
2230
2848
  var LCS_LINE_LIMIT = 1200; /* 超过就退化为「全删 + 全增」,避免 DP 吃内存 */
2231
2849
  var probeCache = {}; /* sessionId:seq -> "ok" | "missing" */
2232
2850
  var rootTurn = {}; /* rootCallId -> turn(ptc 事件没有 turn) */
2233
2851
 
2852
+ /* 官方卡还受设置项「显示编码工具视图」约束(官方 deliverables 的 showCodeDiff =
2853
+ configForms.developerTools.enabled)。关着时官方卡不画,但 /api/changes.summary
2854
+ 照样 200 —— 只按探测结果判断会两边都不画。拿不到这个 store(注入未就绪)时按
2855
+ 「可能开着」处理:仍然走探测,结果与 0.2.0 一致,不会多出一张卡。 */
2856
+ var devToolsStore = null;
2857
+ function useDevToolsEnabled() {
2858
+ var s = REACT.useState(function () {
2859
+ return devToolsStore && typeof devToolsStore.getSnapshot === "function"
2860
+ ? devToolsStore.getSnapshot() === true
2861
+ : true;
2862
+ });
2863
+ var enabled = s[0], setEnabled = s[1];
2864
+ REACT.useEffect(function () {
2865
+ if (!devToolsStore || typeof devToolsStore.subscribe !== "function") { return undefined; }
2866
+ var sync = function () {
2867
+ try { setEnabled(devToolsStore.getSnapshot() === true); } catch (e) {}
2868
+ };
2869
+ sync();
2870
+ var off = devToolsStore.subscribe(sync);
2871
+ return function () { if (typeof off === "function") { off(); } };
2872
+ }, []);
2873
+ return enabled;
2874
+ }
2875
+
2234
2876
  function splitLines(text) {
2235
2877
  var s = text == null ? "" : String(text);
2236
2878
  if (s === "") { return []; }
@@ -2274,26 +2916,6 @@ window.__ModuleLoader__.load({
2274
2916
  }
2275
2917
  return { added: added, deleted: deleted };
2276
2918
  }
2277
- /* 同一文件在回合内被改多次时按 old→new 链折成一份净变化;链断返回 null(分段显示)。 */
2278
- function foldParts(parts) {
2279
- if (!parts || parts.length === 0) { return null; }
2280
- var before = parts[0][0] == null ? "" : String(parts[0][0]);
2281
- var after = parts[0][1] == null ? "" : String(parts[0][1]);
2282
- for (var i = 1; i < parts.length; i++) {
2283
- var o = parts[i][0];
2284
- if (o != null && String(o) !== after) { return null; }
2285
- if (parts[i][1] != null) { after = String(parts[i][1]); }
2286
- }
2287
- return { before: before, after: after };
2288
- }
2289
- function countParts(parts) {
2290
- var added = 0, deleted = 0;
2291
- for (var i = 0; i < parts.length; i++) {
2292
- var c = countRows(diffRows(parts[i][0], parts[i][1]));
2293
- added += c.added; deleted += c.deleted;
2294
- }
2295
- return { added: added, deleted: deleted };
2296
- }
2297
2919
  function metaDiffs(data) {
2298
2920
  if (!data || typeof data !== "object") { return null; }
2299
2921
  var meta = data.meta;
@@ -2344,25 +2966,109 @@ window.__ModuleLoader__.load({
2344
2966
  }
2345
2967
  return null;
2346
2968
  },
2347
- start: function () { return { seq: null, files: [] }; },
2969
+ start: function (context, match) {
2970
+ var turn = match && match.event && match.event.data ? match.event.data.turn : undefined;
2971
+ return { turn: turn, seq: null, files: [] };
2972
+ },
2348
2973
  update: function (context, match) {
2349
- var prev = (context && context.state) || context || { seq: null, files: [] };
2350
- var next = { seq: prev.seq == null ? null : prev.seq, files: prev.files || [] };
2974
+ var prev = (context && context.state) || context || { turn: undefined, seq: null, files: [] };
2975
+ var next = { turn: prev.turn, seq: prev.seq == null ? null : prev.seq, files: prev.files || [] };
2351
2976
  var event = match && match.event;
2352
2977
  if (!event) { return next; }
2353
- if (event.type === "workspace/changes") { return { seq: event.seq, files: next.files }; }
2978
+ if (event.type === "workspace/changes") { return { turn: next.turn, seq: event.seq, files: next.files }; }
2354
2979
  var list = metaDiffs(event.data || {});
2355
2980
  if (!list) { return next; }
2356
2981
  var files = next.files;
2357
2982
  for (var i = 0; i < list.length; i++) { files = mergeDiff(files, list[i]); }
2358
- return { seq: next.seq, files: files };
2983
+ return { turn: next.turn, seq: next.seq, files: files };
2984
+ },
2985
+ /* 卡片的取数通路(0.2.0 漏了它 → 卡片一次都没画):装配器只为实现了本方法的
2986
+ Definition 发布 Turn 作用域数据,changesCard 的 owner.turn.data.get(CHANGES_KIND)
2987
+ 读到的就是这里的 value。约定:data.kind === scope、"key === 本 Definition 的 kind"、
2988
+ data.turn 是安全整数,任一条不满足装配器会直接抛错 —— 所以这里只做保守返回。 */
2989
+ buildLocationData: function (context, scope, previous) {
2990
+ if (scope !== "turn") { return null; }
2991
+ var state = context && context.state;
2992
+ var turn = state ? state.turn : undefined;
2993
+ if (typeof turn !== "number" || !Number.isSafeInteger(turn) || turn < 0) { return null; }
2994
+ if (previous && previous.kind === "turn" && previous.turn === turn && previous.key === CHANGES_KIND &&
2995
+ previous.value && previous.value.seq === state.seq && previous.value.files === state.files) {
2996
+ return previous; /* 引用不变 → 装配器判定未变化,卡片不跟着重绘 */
2997
+ }
2998
+ return { kind: "turn", turn: turn, key: CHANGES_KIND, value: { seq: state.seq, files: state.files } };
2359
2999
  }
2360
3000
  };
2361
3001
  }
2362
- function shortPath(p) {
3002
+ /* ---------- 自画卡对齐官方所需的取数/渲染辅助(只服务两种兜底情形) ----------
3003
+
3004
+ 官方那张卡由 deliverables 自己渲染,我们抢不过来也不该抢;但有两种情形官方一定
3005
+ 不画:①「显示编码工具视图」关着(源码里 announced = showCodeDiff ? … : null);
3006
+ ② 该回合根本没有 workspace/changes 公告。这两处才由本卡补位,外观尽量贴官方:
3007
+ 类名不硬编码(hash 每版会变),运行时从官方注入的 CSS module <style> 里读出前缀。 */
3008
+ var officialClassCache = null;
3009
+ function officialChangesClasses() {
3010
+ if (officialClassCache !== null) { return officialClassCache; }
3011
+ officialClassCache = false;
3012
+ try {
3013
+ var tag = document.querySelector("style[data-plugin-css$=\"ChangedFiles.module.css\"]");
3014
+ var text = tag && tag.textContent ? String(tag.textContent) : "";
3015
+ var hit = /\.([A-Za-z0-9_-]{4,12})_card\b/.exec(text);
3016
+ if (!hit) { return officialClassCache; }
3017
+ var k = hit[1];
3018
+ officialClassCache = {
3019
+ card: k + "_card", header: k + "_header", tile: k + "_tile",
3020
+ titles: k + "_titles", title: k + "_title", stat: k + "_stat",
3021
+ statCounts: k + "_statCounts", previewHint: k + "_previewHint",
3022
+ list: k + "_list", row: k + "_row", path: k + "_path",
3023
+ counts: k + "_counts", toggle: k + "_toggle",
3024
+ preview: k + "_preview", previewPath: k + "_previewPath"
3025
+ };
3026
+ } catch (e) { officialClassCache = false; }
3027
+ return officialClassCache;
3028
+ }
3029
+ /* 读不到官方样式表时的近似样式(只影响观感,不影响结构/交互)。 */
3030
+ var CHANGES_FALLBACK_STYLES = {
3031
+ card: { marginTop: "4px", border: "0.5px solid var(--dsw-alias-border-l2, rgba(128,128,128,.3))", borderRadius: "var(--dsw-radius-lg, 8px)", background: "var(--dsw-alias-bg-layer-1, transparent)", display: "flex", flexDirection: "column", overflow: "hidden" },
3032
+ header: { display: "flex", alignItems: "center", gap: "10px", width: "100%", boxSizing: "border-box", height: "60px", padding: "8px 10px", background: "var(--dsw-static-neutral-50, rgba(128,128,128,.08))", color: "inherit", textAlign: "left", border: "0", cursor: "pointer", font: "inherit" },
3033
+ tile: { display: "grid", placeItems: "center", width: "40px", height: "40px", flex: "none", borderRadius: "10px", border: "0.5px solid var(--dsw-alias-border-l1, rgba(128,128,128,.25))" },
3034
+ titles: { display: "flex", flexDirection: "column", flex: "1", minWidth: "0" },
3035
+ title: { fontSize: "13px", fontWeight: 500, lineHeight: "20px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
3036
+ stat: { display: "inline-flex", gap: "6px", fontSize: "10px", lineHeight: "16px", color: "var(--dsw-alias-label-tertiary, #888)" },
3037
+ list: { margin: "0", padding: "0", listStyle: "none", borderTop: "0.5px solid var(--dsw-alias-border-l2, rgba(128,128,128,.3))" },
3038
+ row: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: "10px", width: "100%", boxSizing: "border-box", padding: "7px 18px 7px 14px", minHeight: "24px", background: "none", border: "0", cursor: "pointer", textAlign: "left", color: "var(--dsw-alias-label-tertiary, #888)", fontFamily: "var(--ds-font-family-code, monospace)", fontSize: "11px", lineHeight: "18px" },
3039
+ path: { minWidth: "0", fontFamily: "var(--dsw-font-family, inherit)", fontSize: "12px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" },
3040
+ counts: { display: "inline-flex", gap: "6px", flex: "none" },
3041
+ toggle: { display: "inline-flex", alignItems: "center", gap: "4px", width: "100%", padding: "10px 18px 10px 14px", background: "none", border: "0", cursor: "pointer", color: "var(--dsw-alias-label-tertiary, #888)", fontSize: "12px", lineHeight: "18px", textAlign: "left" }
3042
+ };
3043
+ var CHANGES_ADDED_STYLE = { color: "var(--dsw-alias-state-success-primary, #3fb950)" };
3044
+ var CHANGES_DELETED_STYLE = { color: "var(--dsw-alias-state-error-primary, #f85149)" };
3045
+ var changesNumberFormat = null;
3046
+ var changesHoverTimer = null;
3047
+ var sidebarRightForChanges = null;
3048
+
3049
+ function changesNumber(value) {
3050
+ try {
3051
+ changesNumberFormat = changesNumberFormat || new Intl.NumberFormat("en-US");
3052
+ return changesNumberFormat.format(value);
3053
+ } catch (e) { return String(value); }
3054
+ }
3055
+ function changesBaseName(p) {
2363
3056
  var s = String(p);
2364
- return s.length > 64 ? "…" + s.slice(s.length - 63) : s;
3057
+ var at = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
3058
+ return at < 0 ? s : s.slice(at + 1);
3059
+ }
3060
+ /* 点卡头/行 → 官方右侧 Review(地址与 ReviewTab 注册的 tab 类型对得上)。 */
3061
+ function openOfficialChangesReview(sessionId, seq, turn, index) {
3062
+ try {
3063
+ var svc = sidebarRightForChanges || pickService(null, "sidebarRight");
3064
+ if (!svc || typeof svc.openResource !== "function" || turn == null) { return false; }
3065
+ var address = "dsh-resource://changes-review/session/" + encodeURIComponent(String(sessionId)) +
3066
+ "/" + String(seq) + "/" + String(turn);
3067
+ svc.openResource(address, { params: { index: index } });
3068
+ return true;
3069
+ } catch (e) { return false; }
2365
3070
  }
3071
+
2366
3072
  function changesCard(props) {
2367
3073
  var owner = props || {};
2368
3074
  var state = null;
@@ -2373,14 +3079,27 @@ window.__ModuleLoader__.load({
2373
3079
  } catch (e) { state = null; }
2374
3080
  var sessionId = owner.sessionId;
2375
3081
  var seq = state && state.seq != null ? state.seq : null;
3082
+ /* 模块 5b:把这条坐标(sessionId + seq → 文件/统计/hunk)登记进适配层索引。
3083
+ 必须在这里(渲染期)而不是 useEffect:React 先跑完所有 slot 的 render 才跑
3084
+ effect,官方那个组件正是在它的 useEffect 里发请求,所以登记必然早于请求。 */
3085
+ try {
3086
+ registerChangesCoordinates(sessionId, seq, state,
3087
+ (owner.turn && typeof owner.turn.turn === "number") ? owner.turn.turn : undefined);
3088
+ } catch (e) {}
3089
+ var devEnabled = useDevToolsEnabled();
3090
+ /* 官方卡成立的两个前提:①「显示编码工具视图」开着;②该回合有 workspace/changes
3091
+ 公告(没公告就没有可请求的 seq,官方那侧一定不画)。缺任一个都不必探测。 */
3092
+ var officialMayRender = devEnabled === true && seq != null;
2376
3093
  var key = (sessionId && seq != null) ? String(sessionId) + ":" + String(seq) : null;
2377
3094
  var cached = key && probeCache[key] ? probeCache[key] : null;
2378
3095
  var probeState = REACT.useState(cached || "pending");
2379
3096
  var probe = probeState[0], setProbe = probeState[1];
2380
3097
  var expState = REACT.useState(false);
2381
3098
  var expanded = expState[0], setExpanded = expState[1];
3099
+ var hoverState = REACT.useState(null); /* {index, top, left} | null:hover 迷你 diff 的锚点 */
3100
+ var hover = hoverState[0], setHover = hoverState[1];
2382
3101
  REACT.useEffect(function () {
2383
- if (!key || cached) { return undefined; }
3102
+ if (!key || !officialMayRender || cached) { return undefined; }
2384
3103
  var cancelled = false;
2385
3104
  try {
2386
3105
  fetch(SUMMARY_ROUTE + "?" + new URLSearchParams({ sessionId: String(sessionId), seq: String(seq) }),
@@ -2392,85 +3111,509 @@ window.__ModuleLoader__.load({
2392
3111
  }).catch(function () {});
2393
3112
  } catch (e) {}
2394
3113
  return function () { cancelled = true; };
2395
- }, [key, cached]);
2396
- /* 只在「官方已失效」时才补位:ok / pending / error 一律不画,避免出现双份卡。 */
2397
- if (!state || !state.files || state.files.length === 0 || seq == null || probe !== "missing") { return null; }
2398
-
2399
- var files = state.files, totals = { added: 0, deleted: 0 }, prepared = [], i, j;
2400
- for (i = 0; i < files.length; i++) {
2401
- var net = foldParts(files[i].parts);
2402
- var rows = (expanded && net !== null) ? diffRows(net.before, net.after) : null;
2403
- var counted = net !== null ? countRows(diffRows(net.before, net.after)) : countParts(files[i].parts);
2404
- totals.added += counted.added; totals.deleted += counted.deleted;
2405
- prepared.push({ path: files[i].path, parts: files[i].parts, added: counted.added, deleted: counted.deleted, rows: rows });
2406
- }
2407
-
2408
- var mono = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
2409
- var dim = { opacity: 0.6, fontSize: 11 };
2410
- var add = { color: "#3fb950" };
2411
- var del = { color: "#f85149" };
2412
- var children = [];
2413
- children.push(REACT.createElement("div", {
2414
- key: "head",
2415
- style: { display: "flex", alignItems: "center", gap: 8, cursor: "pointer", userSelect: "none" },
2416
- onClick: function () { setExpanded(!expanded); }
3114
+ }, [key, cached, officialMayRender]);
3115
+ if (!state || !state.files || state.files.length === 0) { return null; }
3116
+ /* 只在「官方那侧不会画」时补位:官方可能画且探测不是 missing(ok / pending / error)
3117
+ 一律不画,避免双份卡。 */
3118
+ if (officialMayRender && probe !== "missing") { return null; }
3119
+
3120
+ /* 数据直接复用模块 5b 的入口:保证卡片上的数字/hunk 与喂给官方卡的完全是同一份
3121
+ (官方口径 = 回合首末净变化),不另算一套。无公告的回合用合成坐标 0 供 Review 取数。 */
3122
+ var turnNumber = (owner.turn && typeof owner.turn.turn === "number") ? owner.turn.turn : null;
3123
+ var seqForReview = seq != null ? seq : 0;
3124
+ try { registerChangesCoordinates(sessionId, seqForReview, state, turnNumber); } catch (e) {}
3125
+ var entry = changesEntryFor(sessionId, seqForReview);
3126
+ if (!entry || entry.files.length === 0) { return null; }
3127
+ var list = entry.files;
3128
+ var total = list.length;
3129
+ var single = total === 1 ? list[0] : null;
3130
+ var foldable = total > 4;
3131
+ var visible = foldable && !expanded ? list.slice(0, 4) : list;
3132
+ var totals = { added: 0, deleted: 0 }, i;
3133
+ for (i = 0; i < list.length; i++) { totals.added += list[i].added; totals.deleted += list[i].deleted; }
3134
+
3135
+ var classes = officialChangesClasses();
3136
+ var classOf = function (name) { return classes ? classes[name] : undefined; };
3137
+ var styleOf = function (name) {
3138
+ if (classes) { return undefined; }
3139
+ return CHANGES_FALLBACK_STYLES[name];
3140
+ };
3141
+ var hiddenNote = seq == null
3142
+ ? "由 dsh-ui-tweaks 补位 · 该回合没有官方的改动记录"
3143
+ : (devEnabled === true ? null : "由 dsh-ui-tweaks 补位 · 显示编码工具视图已关闭");
3144
+ function beginHover(index, event) {
3145
+ try {
3146
+ var rect = event.currentTarget.getBoundingClientRect();
3147
+ if (changesHoverTimer) { clearTimeout(changesHoverTimer); }
3148
+ changesHoverTimer = setTimeout(function () {
3149
+ setHover({ index: index, top: rect.bottom + 6, left: Math.max(8, rect.left) });
3150
+ }, 500);
3151
+ } catch (e) {}
3152
+ }
3153
+ function endHover() {
3154
+ if (changesHoverTimer) { clearTimeout(changesHoverTimer); changesHoverTimer = null; }
3155
+ if (hover) { setHover(null); }
3156
+ }
3157
+ function countsOf(added, deleted) {
3158
+ return [
3159
+ REACT.createElement("span", { key: "a", style: CHANGES_ADDED_STYLE }, "+" + changesNumber(added)),
3160
+ REACT.createElement("span", { key: "d", style: CHANGES_DELETED_STYLE }, "−" + changesNumber(deleted))
3161
+ ];
3162
+ }
3163
+
3164
+ var header = REACT.createElement("button", {
3165
+ key: "header",
3166
+ type: "button",
3167
+ className: classOf("header"),
3168
+ style: styleOf("header"),
3169
+ title: hiddenNote || undefined,
3170
+ "aria-label": single === null ? "打开本回合改动审查" : ("查看 " + single.display + " 的逐行对比"),
3171
+ onClick: function () { openOfficialChangesReview(sessionId, seqForReview, turnNumber, 0); },
3172
+ onMouseEnter: single === null ? undefined : function (event) { beginHover(0, event); },
3173
+ onMouseLeave: single === null ? undefined : endHover
2417
3174
  },
2418
- REACT.createElement("span", { style: { fontWeight: 600 } }, "本回合改动"),
2419
- REACT.createElement("span", { style: dim }, files.length + " 个文件"),
2420
- REACT.createElement("span", { style: add }, "+" + totals.added),
2421
- REACT.createElement("span", { style: del }, "−" + totals.deleted),
2422
- REACT.createElement("span", { style: { marginLeft: "auto", opacity: 0.6, fontSize: 11 } }, expanded ? "收起" : "展开")
2423
- ));
2424
- children.push(REACT.createElement("div", { key: "note", style: { opacity: 0.5, fontSize: 11, marginTop: 2 } },
2425
- "(据会话日志重建 · 仅文件工具的改动)"));
2426
- if (expanded) {
2427
- for (i = 0; i < prepared.length; i++) {
2428
- var f = prepared[i];
2429
- var block = [];
2430
- block.push(REACT.createElement("div", {
2431
- key: "p", style: { display: "flex", alignItems: "center", gap: 6, marginTop: 6 }
3175
+ REACT.createElement("span", { key: "tile", className: classOf("tile"), style: styleOf("tile") },
3176
+ REACT.createElement("svg", {
3177
+ width: 20, height: 20, viewBox: "0 0 20 20", fill: "none",
3178
+ stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round",
3179
+ style: { color: "var(--dsw-alias-link, #6cb6ff)" }
2432
3180
  },
2433
- REACT.createElement("span", { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", fontFamily: mono }, title: f.path }, shortPath(f.path)),
2434
- REACT.createElement("span", { style: add }, "+" + f.added),
2435
- REACT.createElement("span", { style: del }, "−" + f.deleted)
2436
- ));
2437
- if (f.rows) {
2438
- var shown = f.rows.length > DIFF_ROW_LIMIT ? f.rows.slice(0, DIFF_ROW_LIMIT) : f.rows;
2439
- for (j = 0; j < shown.length; j++) {
2440
- block.push(REACT.createElement("div", {
2441
- key: "r" + j,
2442
- style: { fontFamily: mono, fontSize: 11, whiteSpace: "pre-wrap", wordBreak: "break-all",
2443
- color: shown[j].k === "+" ? "#3fb950" : (shown[j].k === "-" ? "#f85149" : "inherit"),
2444
- opacity: shown[j].k === " " ? 0.7 : 1 }
2445
- }, (shown[j].k === " " ? " " : shown[j].k + " ") + shown[j].t));
2446
- }
2447
- if (f.rows.length > DIFF_ROW_LIMIT) {
2448
- block.push(REACT.createElement("div", { key: "more", style: dim }, "…还有 " + (f.rows.length - DIFF_ROW_LIMIT) + " 行"));
2449
- }
2450
- } else {
2451
- for (j = 0; j < f.parts.length; j++) {
2452
- block.push(REACT.createElement("div", { key: "seg" + j, style: { opacity: 0.6, fontSize: 11, marginTop: 4 } },
2453
- "第 " + (j + 1) + " 段改动(与前一段不衔接,分段显示)"));
2454
- var prows = diffRows(f.parts[j][0], f.parts[j][1]);
2455
- var pshown = prows.length > DIFF_ROW_LIMIT ? prows.slice(0, DIFF_ROW_LIMIT) : prows;
2456
- for (var r2 = 0; r2 < pshown.length; r2++) {
2457
- block.push(REACT.createElement("div", {
2458
- key: "pr" + j + "_" + r2,
2459
- style: { fontFamily: mono, fontSize: 11, whiteSpace: "pre-wrap", wordBreak: "break-all",
2460
- color: pshown[r2].k === "+" ? "#3fb950" : (pshown[r2].k === "-" ? "#f85149" : "inherit"),
2461
- opacity: pshown[r2].k === " " ? 0.7 : 1 }
2462
- }, (pshown[r2].k === " " ? " " : pshown[r2].k + " ") + pshown[r2].t));
3181
+ REACT.createElement("path", { d: "M7.5 6 4 10l3.5 4" }),
3182
+ REACT.createElement("path", { d: "M12.5 6 16 10l-3.5 4" }))),
3183
+ REACT.createElement("span", { key: "titles", className: classOf("titles"), style: styleOf("titles") },
3184
+ REACT.createElement("span", { key: "title", className: classOf("title"), style: styleOf("title") },
3185
+ single === null ? ("已编辑 " + changesNumber(total) + " 个文件") : changesBaseName(single.path)),
3186
+ REACT.createElement("span", { key: "stat", className: classOf("stat"), style: styleOf("stat") },
3187
+ countsOf(totals.added, totals.deleted))));
3188
+
3189
+ var rows = [];
3190
+ for (i = 0; i < visible.length; i++) {
3191
+ rows.push((function (file, index) {
3192
+ return REACT.createElement("li", { key: "row:" + file.display, style: { display: "block", margin: 0, padding: 0 } },
3193
+ REACT.createElement("button", {
3194
+ type: "button",
3195
+ className: classOf("row"),
3196
+ style: styleOf("row"),
3197
+ title: file.path,
3198
+ "aria-label": "查看 " + file.display + " 的逐行对比",
3199
+ onClick: function () { openOfficialChangesReview(sessionId, seqForReview, turnNumber, index); },
3200
+ onMouseEnter: function (event) { beginHover(index, event); },
3201
+ onMouseLeave: endHover
3202
+ },
3203
+ REACT.createElement("span", { className: classOf("path"), style: styleOf("path") }, file.display),
3204
+ REACT.createElement("span", { className: classOf("counts"), style: styleOf("counts") },
3205
+ countsOf(file.added, file.deleted))));
3206
+ })(visible[i], i));
3207
+ }
3208
+
3209
+ var preview = null;
3210
+ if (hover && list[hover.index]) {
3211
+ var hovered = list[hover.index];
3212
+ var previewLines = [];
3213
+ for (var h = 0; h < hovered.hunks.length; h++) {
3214
+ var hunk = hovered.hunks[h];
3215
+ if (h > 0) { previewLines.push(REACT.createElement("div", { key: "gap" + h, style: { height: "6px" } })); }
3216
+ var oldNo = hunk.oldStart, newNo = hunk.newStart;
3217
+ for (var n = 0; n < hunk.lines.length; n++) {
3218
+ var text = hunk.lines[n];
3219
+ var mark = text.charAt(0);
3220
+ /* 行号照官方:删除行给旧行号,新增行给新行号,上下文行给新行号。 */
3221
+ var shownNo = mark === "-" ? oldNo : newNo;
3222
+ if (mark === "-") { oldNo++; }
3223
+ else if (mark === "+") { newNo++; }
3224
+ else { oldNo++; newNo++; }
3225
+ previewLines.push(REACT.createElement("div", {
3226
+ key: "l" + h + "_" + n,
3227
+ style: {
3228
+ display: "flex", gap: "8px", padding: "0 8px 0 4px",
3229
+ fontFamily: "var(--ds-font-family-code, monospace)", fontSize: "11px", lineHeight: "16px",
3230
+ whiteSpace: "pre",
3231
+ background: mark === "+" ? "rgba(63,185,80,.16)" : (mark === "-" ? "rgba(248,81,73,.16)" : "transparent"),
3232
+ borderLeft: "2px solid " + (mark === "+"
3233
+ ? "var(--dsw-alias-state-success-primary, #3fb950)"
3234
+ : (mark === "-" ? "var(--dsw-alias-state-error-primary, #f85149)" : "transparent")),
3235
+ color: mark === "+"
3236
+ ? "var(--dsw-alias-state-success-primary, #3fb950)"
3237
+ : (mark === "-" ? "var(--dsw-alias-state-error-primary, #f85149)" : "inherit"),
3238
+ opacity: mark === " " ? 0.72 : 1
2463
3239
  }
3240
+ },
3241
+ REACT.createElement("span", { key: "no", style: { flex: "none", minWidth: "30px", textAlign: "right", opacity: 0.55 } }, String(shownNo)),
3242
+ REACT.createElement("span", { key: "mark", style: { flex: "none", width: "9px", opacity: 0.8 } }, mark === " " ? "" : mark),
3243
+ REACT.createElement("span", { key: "text", style: { flex: "1" } }, text.slice(1))));
3244
+ }
3245
+ }
3246
+ preview = REACT.createElement("div", {
3247
+ key: "preview",
3248
+ style: {
3249
+ position: "fixed", top: hover.top + "px", left: hover.left + "px", zIndex: 9999,
3250
+ maxWidth: "720px", maxHeight: "320px", overflow: "auto", pointerEvents: "none",
3251
+ background: "var(--dsw-alias-bg-layer-1, #1e1e1e)",
3252
+ border: "0.5px solid var(--dsw-alias-border-l2, rgba(128,128,128,.35))",
3253
+ borderRadius: "var(--dsw-radius-lg, 8px)", padding: "0 0 6px",
3254
+ boxShadow: "0 8px 24px rgba(0,0,0,.35)"
3255
+ },
3256
+ children: [
3257
+ REACT.createElement("div", {
3258
+ key: "path",
3259
+ style: { fontSize: "12px", lineHeight: "20px", color: "var(--dsw-alias-label-tertiary, #888)",
3260
+ fontFamily: "var(--ds-font-family-code, monospace)", padding: "6px 10px 4px", whiteSpace: "nowrap" }
3261
+ }, hovered.display)
3262
+ ].concat(previewLines)
3263
+ });
3264
+ }
3265
+
3266
+ var children = [header];
3267
+ if (rows.length > 0) {
3268
+ children.push(REACT.createElement("ul", { key: "list", className: classOf("list"), style: styleOf("list") }, rows));
3269
+ }
3270
+ if (foldable) {
3271
+ children.push(REACT.createElement("button", {
3272
+ key: "toggle",
3273
+ type: "button",
3274
+ className: classOf("toggle"),
3275
+ style: styleOf("toggle"),
3276
+ "aria-expanded": expanded,
3277
+ onClick: function () { setExpanded(!expanded); }
3278
+ }, expanded ? "收起" : ("全部 " + changesNumber(total) + " 个")));
3279
+ }
3280
+ /* 只有「无公告」这种官方压根没有过记录的回合,才在卡内留一行可见小字;编码视图关
3281
+ 的情形走 title(鼠标悬停可见),保持与官方外观一致。 */
3282
+ if (seq == null) {
3283
+ children.push(REACT.createElement("div", {
3284
+ key: "note",
3285
+ style: { padding: "0 14px 8px", fontSize: "11px", lineHeight: "16px", color: "var(--dsw-alias-label-tertiary, #888)" }
3286
+ }, hiddenNote));
3287
+ }
3288
+ if (preview) { children.push(preview); }
3289
+
3290
+ var cardProps = {
3291
+ className: classOf("card"),
3292
+ style: styleOf("card"),
3293
+ "data-changed-files": true,
3294
+ title: hiddenNote || undefined
3295
+ };
3296
+ if (single !== null) { cardProps["data-single"] = true; }
3297
+ return REACT.createElement("div", cardProps, children);
3298
+ }
3299
+ /* ======================================================================
3300
+ * 模块 5b:官方改动卡数据适配层(A 线)
3301
+ *
3302
+ * 官方那张卡不自己算数据,它向两条同源路由要东西:
3303
+ * GET api/changes.summary?sessionId=…&seq=…
3304
+ * GET api/changes.diff?sessionId=…&seq=…&index=…
3305
+ * 而这两条路由的数据只活在该 Session 的 Host 内存里,会话一释放就 404。本模块把
3306
+ * 会话日志里已聚合好的改动(meta.diffs)在**客户端**按官方形状重算一遍:官方 404 时
3307
+ * 由包装过的 fetch 直接顶上 200,于是**官方组件原生渲染** —— 卡头、hover 预览、
3308
+ * 右侧 Review 面板全是官方的,我们一个像素都不用画。
3309
+ *
3310
+ * 三条硬约束:
3311
+ * 1. 只拦这两个路径、且只对索引里存在的坐标生效;其余请求原样透传给原生 fetch。
3312
+ * 2. 坐标必须在官方组件发请求**之前**进索引 → 卡片在渲染期登记(见 changesCard)。
3313
+ * 3. 形状必须过官方 validator(deliverables 的 isChangesSummary / isChangesDiff):
3314
+ * summary = { turn, files:[{path, display, added, deleted}], total, added, deleted }
3315
+ * diff = { kind:"text", path, display, before, after, coarse,
3316
+ * hunks:[{ oldStart, oldLines, newStart, newLines, lines:["+…"] }] }
3317
+ * 口径差异(有意为之):官方是「回合开始快照 → 回合结束」的净变化,所以同文件多段
3318
+ * 改动链不上时,我们给首段 oldText → 末段 newText 的净变化,数字因此与旧卡的
3319
+ * 分段累加不同(例如 +16 −13 vs +2 −12)。
3320
+ * ====================================================================== */
3321
+ var ADAPTER_SUMMARY_SUFFIX = "/api/changes.summary";
3322
+ var ADAPTER_DIFF_SUFFIX = "/api/changes.diff";
3323
+ var ADAPTER_OPEN_SUFFIX = "/api/changes.open";
3324
+ var ADAPTER_CONTEXT_LINES = 3;
3325
+ var ADAPTER_MAX_HUNK_LINES = 2000;
3326
+
3327
+ var changesCoordinates = {}; /* "sessionId:seq" -> entry */
3328
+ var adapterInstalled = false;
3329
+ var adapterOriginalFetch = null;
3330
+ var adapterPatchedFetch = null;
3331
+ var adapterCtx = null;
3332
+
3333
+ /* 显示口径照官方:工作目录内用相对路径,否则给绝对路径(家目录 ~ 归一化暂缺)。 */
3334
+ function changesDisplayOf(cwd, p) {
3335
+ var abs = String(p);
3336
+ if (cwd) {
3337
+ var base = String(cwd).replace(/[\\/]+$/, "");
3338
+ var low = abs.toLowerCase(), lowBase = base.toLowerCase();
3339
+ if (low === lowBase) { return abs; }
3340
+ if (low.indexOf(lowBase + "\\") === 0 || low.indexOf(lowBase + "/") === 0) {
3341
+ return abs.slice(base.length + 1).replace(/\//g, "\\");
3342
+ }
3343
+ }
3344
+ return abs.replace(/\//g, "\\");
3345
+ }
3346
+
3347
+ /* 行 → 官方 hunk(3 行上下文、1 起始行号、每行带 +/-/空格 前缀)。 */
3348
+ function changesRowsToHunks(rows) {
3349
+ var i, row, idx = [];
3350
+ for (i = 0; i < rows.length; i++) { if (rows[i].k !== " ") { idx[i] = 1; } }
3351
+ var changed = [];
3352
+ for (i = 0; i < rows.length; i++) { if (idx[i] === 1) { changed.push(i); } }
3353
+ if (changed.length === 0) { return []; }
3354
+ var ranges = [];
3355
+ var start = Math.max(0, changed[0] - ADAPTER_CONTEXT_LINES);
3356
+ var end = Math.min(rows.length - 1, changed[0] + ADAPTER_CONTEXT_LINES);
3357
+ for (i = 1; i < changed.length; i++) {
3358
+ if (changed[i] - ADAPTER_CONTEXT_LINES <= end + 1) {
3359
+ end = Math.min(rows.length - 1, changed[i] + ADAPTER_CONTEXT_LINES);
3360
+ } else {
3361
+ ranges.push([start, end]);
3362
+ start = Math.max(0, changed[i] - ADAPTER_CONTEXT_LINES);
3363
+ end = Math.min(rows.length - 1, changed[i] + ADAPTER_CONTEXT_LINES);
3364
+ }
3365
+ }
3366
+ ranges.push([start, end]);
3367
+ var hunks = [], oldNo = 1, newNo = 1, cursor = 0, budget = ADAPTER_MAX_HUNK_LINES, at;
3368
+ for (var r = 0; r < ranges.length; r++) {
3369
+ var from = ranges[r][0], to = ranges[r][1];
3370
+ while (cursor < from) {
3371
+ row = rows[cursor];
3372
+ if (row.k !== "+") { oldNo++; }
3373
+ if (row.k !== "-") { newNo++; }
3374
+ cursor++;
3375
+ }
3376
+ var oldStart = oldNo, newStart = newNo, lines = [], oldLines = 0, newLines = 0;
3377
+ for (at = from; at <= to && budget > 0; at++, budget--) {
3378
+ row = rows[at];
3379
+ lines.push((row.k === " " ? " " : row.k) + row.t);
3380
+ if (row.k !== "+") { oldLines++; oldNo++; }
3381
+ if (row.k !== "-") { newLines++; newNo++; }
3382
+ }
3383
+ cursor = to + 1;
3384
+ hunks.push({ oldStart: oldStart, oldLines: oldLines, newStart: newStart, newLines: newLines, lines: lines });
3385
+ if (budget <= 0) { break; }
3386
+ }
3387
+ return hunks;
3388
+ }
3389
+
3390
+ function buildChangesEntry(sessionId, seq, turn, files, cwd) {
3391
+ var out = [], i, file, parts, before, after, rows, counts;
3392
+ for (i = 0; i < files.length; i++) {
3393
+ file = files[i] || {};
3394
+ parts = Array.isArray(file.parts) ? file.parts : [];
3395
+ before = parts.length > 0 ? parts[0][0] : null;
3396
+ after = parts.length > 0 ? parts[parts.length - 1][1] : null;
3397
+ rows = diffRows(before, after);
3398
+ counts = countRows(rows);
3399
+ out.push({
3400
+ path: String(file.path),
3401
+ display: changesDisplayOf(cwd, file.path),
3402
+ added: counts.added,
3403
+ deleted: counts.deleted,
3404
+ before: before != null,
3405
+ after: after != null,
3406
+ hunks: changesRowsToHunks(rows)
3407
+ });
3408
+ }
3409
+ out.sort(function (left, right) { return left.display < right.display ? -1 : (left.display > right.display ? 1 : 0); });
3410
+ return { sessionId: String(sessionId), seq: String(seq), turn: turn, files: out, stateFiles: files, cwd: cwd || null };
3411
+ }
3412
+
3413
+ /* 会话 cwd:官方 display 的基准。取不到就退化为绝对路径(不影响校验,只是显示不同)。 */
3414
+ function changesCwdOf(sessionId) {
3415
+ try {
3416
+ var svc = (adapterCtx && typeof adapterCtx.get === "function") ? adapterCtx.get("sessions") : null;
3417
+ if (!svc) { svc = pickService(null, "sessions"); }
3418
+ var store = svc && svc.list;
3419
+ var snap = store && typeof store.getSnapshot === "function" ? store.getSnapshot() : null;
3420
+ var meta = snap && snap.byId ? snap.byId[sessionId] : null;
3421
+ return meta && typeof meta.cwd === "string" ? meta.cwd : null;
3422
+ } catch (e) { return null; }
3423
+ }
3424
+
3425
+ /* 渲染期登记:必须早于官方组件的 useEffect(React 先跑完所有 slot 的 render)。
3426
+ turn 来自 slot props 里的 TurnLocation(owner.turn.turn),Definition 发布的状态里
3427
+ 只有 {seq, files} —— 所以这里显式收 turn,缺了就退回 state.turn。 */
3428
+ function registerChangesCoordinates(sessionId, seq, state, turn) {
3429
+ if (!sessionId || seq == null || !state || !Array.isArray(state.files) || state.files.length === 0) { return; }
3430
+ var turnNumber = (typeof turn === "number" && Number.isSafeInteger(turn) && turn >= 1) ? turn
3431
+ : (typeof state.turn === "number" && Number.isSafeInteger(state.turn) && state.turn >= 1 ? state.turn : null);
3432
+ if (turnNumber === null) { return; } /* 官方 validator 要求 turn 是 ≥1 的整数,缺了宁可不拦 */
3433
+ var key = String(sessionId) + ":" + String(seq);
3434
+ var current = changesCoordinates[key];
3435
+ if (current && current.stateFiles === state.files && current.turn === turnNumber) { return; }
3436
+ changesCoordinates[key] = buildChangesEntry(sessionId, seq, turnNumber, state.files, changesCwdOf(sessionId));
3437
+ }
3438
+
3439
+ function changesEntryFor(sessionId, seq) {
3440
+ if (!sessionId || seq == null) { return null; }
3441
+ return changesCoordinates[String(sessionId) + ":" + String(seq)] || null;
3442
+ }
3443
+
3444
+ function changesSummaryPayload(entry) {
3445
+ var files = [], added = 0, deleted = 0, i, f;
3446
+ for (i = 0; i < entry.files.length; i++) {
3447
+ f = entry.files[i];
3448
+ files.push({ path: f.path, display: f.display, added: f.added, deleted: f.deleted });
3449
+ added += f.added;
3450
+ deleted += f.deleted;
3451
+ }
3452
+ return { turn: entry.turn, files: files, total: files.length, added: added, deleted: deleted };
3453
+ }
3454
+
3455
+ function changesDiffPayload(entry, index) {
3456
+ var f = entry.files[index];
3457
+ if (!f) { return null; }
3458
+ return { kind: "text", path: f.path, display: f.display, before: f.before, after: f.after, coarse: false, hunks: f.hunks };
3459
+ }
3460
+
3461
+ function changesJsonResponse(payload) {
3462
+ try {
3463
+ return new Response(JSON.stringify(payload), {
3464
+ status: 200,
3465
+ headers: { "content-type": "application/json", "cache-control": "no-store" }
3466
+ });
3467
+ } catch (e) { return null; }
3468
+ }
3469
+
3470
+ /* ---------- 动作路由补位(会话释放后官方 /api/changes.open 只回 404) ----------
3471
+ 官方那条路由要先查 Host 内存里的改动摘要才能把 file.path 解析成绝对路径,
3472
+ 会话一释放就 404 —— 文件却还在盘上。这里补两件事:
3473
+ GET → 由 host 枚举系统关联,返回真实应用列表(官方控件的主按钮=第一个应用,
3474
+ 菜单列全部;枚举不出来就给空数组,控件退化成只有「显示文件位置」);
3475
+ POST → 用自己 host 半区的 /api/ui-tweaks/open-path 打开 / 定位 / 用指定应用打开。
3476
+ 只对索引里已有的坐标生效,活会话照旧走官方原路(先试官方,404 才补位)。 */
3477
+ function changesAbsPath(entry, p) {
3478
+ var s = String(p == null ? "" : p);
3479
+ if (!s) { return null; }
3480
+ var isWin = /^[A-Za-z]:[\\/]/.test(s) || s.slice(0, 2) === "\\\\";
3481
+ if (isWin || s.charAt(0) === "/") { return s; }
3482
+ var cwd = entry && entry.cwd ? String(entry.cwd) : "";
3483
+ if (!cwd) { return null; }
3484
+ var sep = (/^[A-Za-z]:[\\/]/.test(cwd) || cwd.slice(0, 2) === "\\\\") ? "\\" : "/";
3485
+ return cwd.replace(/[\\/]+$/, "") + sep + s.replace(/^[\\/]+/, "").split("/").join(sep);
3486
+ }
3487
+ /* 调 host 半区回环路由(模块 5b 自身作用域;带超时,回包丢失不会吊死动作) */
3488
+ function adapterHostPost(route, body, timeoutMs) {
3489
+ var ms = Math.max(1000, Number(timeoutMs) || 8000);
3490
+ var ctl = null, timer = null;
3491
+ try { if (typeof AbortController === "function") { ctl = new AbortController(); } } catch (e0) {}
3492
+ function stop() { if (timer) { clearTimeout(timer); timer = null; } }
3493
+ var opts = {
3494
+ method: "POST", cache: "no-store",
3495
+ headers: { "Content-Type": "application/json" },
3496
+ body: JSON.stringify(body || {}),
3497
+ };
3498
+ if (ctl) {
3499
+ opts.signal = ctl.signal;
3500
+ timer = setTimeout(function () { try { ctl.abort(); } catch (e2) {} }, ms);
3501
+ }
3502
+ try {
3503
+ return fetch("/api/ui-tweaks/" + route, opts).then(function (r) {
3504
+ stop();
3505
+ return r.json().then(function (j) { return j; }, function () { return { ok: false, code: "HTTP " + r.status }; });
3506
+ }).catch(function (e) {
3507
+ stop();
3508
+ var aborted = false;
3509
+ try { aborted = !!(ctl && ctl.signal && ctl.signal.aborted); } catch (e3) {}
3510
+ return { ok: false, code: aborted ? "timeout" : String((e && e.message) || e) };
3511
+ });
3512
+ } catch (e) {
3513
+ stop();
3514
+ return Promise.resolve({ ok: false, code: String((e && e.message) || e) });
3515
+ }
3516
+ }
3517
+
3518
+ function installChangesFetchAdapter() {
3519
+ if (adapterInstalled) { return; }
3520
+ if (typeof window === "undefined" || typeof window.fetch !== "function") { return; }
3521
+ var original = window.fetch;
3522
+ var patched = function (input, init) {
3523
+ var raw = "";
3524
+ try { raw = typeof input === "string" ? input : ((input && input.url) || ""); } catch (e0) { raw = ""; }
3525
+ var url = null;
3526
+ try { url = new URL(raw, (typeof location !== "undefined" && location.href) ? location.href : "http://localhost/"); } catch (e1) { url = null; }
3527
+ if (!url) { return original.apply(this, arguments); }
3528
+ var path = url.pathname || "";
3529
+ var isSummary = path.length >= ADAPTER_SUMMARY_SUFFIX.length && path.slice(-ADAPTER_SUMMARY_SUFFIX.length) === ADAPTER_SUMMARY_SUFFIX;
3530
+ var isDiff = path.length >= ADAPTER_DIFF_SUFFIX.length && path.slice(-ADAPTER_DIFF_SUFFIX.length) === ADAPTER_DIFF_SUFFIX;
3531
+ var isOpen = path.length >= ADAPTER_OPEN_SUFFIX.length && path.slice(-ADAPTER_OPEN_SUFFIX.length) === ADAPTER_OPEN_SUFFIX;
3532
+ if (!isSummary && !isDiff && !isOpen) { return original.apply(this, arguments); }
3533
+ var sessionId = url.searchParams.get("sessionId");
3534
+ var seq = url.searchParams.get("seq");
3535
+ var entry = changesEntryFor(sessionId, seq);
3536
+ if (!entry) { return original.apply(this, arguments); }
3537
+ if (isOpen) {
3538
+ /* 先走官方原路:活会话它本来就能干活(有真应用列表)。只有它答 404
3539
+ (会话释放:Host 内存里没有改动摘要了)才由我们补位。 */
3540
+ var self = this, args = arguments;
3541
+ var fallbackOpen = function () {
3542
+ var method = "GET";
3543
+ try { method = String((init && init.method) || "GET").toUpperCase(); } catch (eM) { method = "GET"; }
3544
+ var openIndex = Number(url.searchParams.get("index"));
3545
+ var openFile = entry.files[openIndex] || null;
3546
+ var openTarget = changesAbsPath(entry, openFile ? openFile.path : "");
3547
+ if (method === "GET") {
3548
+ /* 应用列表交给 host(复用官方 native-command 枚举系统关联,只回真实应用);
3549
+ host 不可用或枚举失败就给空数组 —— 官方控件的退化形态就是「显示文件位置」。 */
3550
+ return adapterHostPost("open-apps", { path: openTarget || "" }, 20000).then(function (r) {
3551
+ var apps = (r && r.ok && Array.isArray(r.apps)) ? r.apps : [];
3552
+ var res = changesJsonResponse(apps);
3553
+ if (!res) { return new Response("unavailable", { status: 500 }); }
3554
+ try {
3555
+ console.info("[dsh-ui-tweaks:" + BUILD + "] 动作补位 · 应用列表 " + apps.length + " 项(" +
3556
+ ((r && r.source) || "fallback") + (r && r.code ? " · " + r.code : "") + ")");
3557
+ } catch (e2) {}
3558
+ return res;
3559
+ });
3560
+ }
3561
+ if (!openTarget) {
3562
+ try { console.warn("[dsh-ui-tweaks:" + BUILD + "] 动作补位失败:拿不到绝对路径", openFile); } catch (e3) {}
3563
+ return Promise.resolve(new Response("no-path", { status: 404 }));
2464
3564
  }
3565
+ var openAction = url.searchParams.get("action") === "reveal" ? "reveal" : "open";
3566
+ /* 菜单里选中的应用 id 原样交给 host(它按官方 openNativeFileApplication 启动);
3567
+ host 不认的 id 由它按默认关联打开,不会把点击变成失败。 */
3568
+ var openApp = url.searchParams.get("application") || "";
3569
+ return adapterHostPost("open-path", { path: openTarget, action: openAction, application: openApp }, 20000).then(function (r) {
3570
+ if (r && r.ok) { return new Response(null, { status: 204 }); }
3571
+ var code = r && r.code ? String(r.code) : "failed";
3572
+ var status = code === "path-not-found" ? 404 : (code === "timeout" ? 504 : 500);
3573
+ try {
3574
+ console.warn("[dsh-ui-tweaks:" + BUILD + "] 动作补位失败:" + code +
3575
+ (r && r.source ? "(" + r.source + ")" : "") + " · " + openTarget);
3576
+ } catch (e4) {}
3577
+ return new Response(code, { status: status });
3578
+ });
3579
+ };
3580
+ var attempt;
3581
+ try { attempt = original.apply(self, args); }
3582
+ catch (eA) { return fallbackOpen(); }
3583
+ if (!attempt || typeof attempt.then !== "function") {
3584
+ /* 不是 Promise(被别的包装改了签名):原样交回 */
3585
+ return attempt;
2465
3586
  }
2466
- children.push(REACT.createElement("div", { key: "f" + i }, block));
3587
+ return attempt.then(function (res) {
3588
+ if (res && res.status === 404) { return fallbackOpen(); }
3589
+ return res;
3590
+ }, function () { return fallbackOpen(); });
2467
3591
  }
3592
+ var payload = isSummary ? changesSummaryPayload(entry) : changesDiffPayload(entry, Number(url.searchParams.get("index")));
3593
+ if (payload === null) { return original.apply(this, arguments); }
3594
+ var response = changesJsonResponse(payload);
3595
+ if (!response) { return original.apply(this, arguments); }
3596
+ try {
3597
+ console.info("[dsh-ui-tweaks:" + BUILD + "] 改动卡数据由会话日志重建 · " + (isSummary ? "summary" : "diff #" + url.searchParams.get("index")) + " · turn " + entry.turn);
3598
+ } catch (e2) {}
3599
+ return Promise.resolve(response);
3600
+ };
3601
+ adapterOriginalFetch = original;
3602
+ adapterPatchedFetch = patched;
3603
+ window.fetch = patched;
3604
+ adapterInstalled = true;
3605
+ }
3606
+
3607
+ function uninstallChangesFetchAdapter() {
3608
+ if (!adapterInstalled) { return; }
3609
+ if (typeof window !== "undefined" && window.fetch === adapterPatchedFetch && adapterOriginalFetch) {
3610
+ window.fetch = adapterOriginalFetch;
2468
3611
  }
2469
- return REACT.createElement("div", {
2470
- style: { marginTop: 8, border: "1px solid var(--dsw-alias-border-neutral-secondary, rgba(128,128,128,0.25))",
2471
- borderRadius: 8, padding: "8px 10px", fontSize: 12 }
2472
- }, children);
3612
+ adapterInstalled = false;
3613
+ adapterPatchedFetch = null;
3614
+ adapterOriginalFetch = null;
2473
3615
  }
3616
+
2474
3617
  function installChangesCard(scope, ctx) {
2475
3618
  if (!REACT) { throw new Error("ui-tweaks: require(\"react\") 不可用,跳过改动补位卡"); }
2476
3619
  var s = scope || ctx || {};
@@ -2482,7 +3625,41 @@ window.__ModuleLoader__.load({
2482
3625
  if (!slots || typeof slots.register !== "function" || typeof slots.inject !== "function") {
2483
3626
  throw new Error("ui-tweaks: slots 服务不可用");
2484
3627
  }
2485
- conv.events.register(changesDefinition());
3628
+ var definition = changesDefinition();
3629
+ /* 模块 5b:先装数据适配层。装了之后,已释放会话里官方卡会自己长出来(我们这张卡
3630
+ 因为探测同样被答成 200 而自动让位);活会话不受影响(原生 200 优先,我们不拦)。 */
3631
+ adapterCtx = ctx || null;
3632
+ guard("changes-adapter", installChangesFetchAdapter);
3633
+ try {
3634
+ if (ctx && typeof ctx.effect === "function") {
3635
+ ctx.effect(function () { return uninstallChangesFetchAdapter; });
3636
+ }
3637
+ } catch (e) {}
3638
+ /* 回归闸:buildLocationData 是卡片唯一的取数通路,缺了它 state 恒 null、卡片静默
3639
+ 不画(0.2.0 的真实缺陷)。这里宁可显式报错,也不要再静默消失一次。 */
3640
+ if (typeof definition.buildLocationData !== "function") {
3641
+ throw new Error("ui-tweaks: changesDefinition 缺 buildLocationData,补位卡不会渲染");
3642
+ }
3643
+ conv.events.register(definition);
3644
+ /* 官方卡的开关(「显示编码工具视图」)。拿不到就保持 null → useDevToolsEnabled 按
3645
+ 「可能开着」走探测,行为与 0.2.0 一致。configForms 缺失时 ctx.inject 的回调不会
3646
+ 执行(Cordis 语义),所以这里不当作错误。 */
3647
+ try {
3648
+ ctx.inject(["configForms"], function (cfg) {
3649
+ try {
3650
+ var cf = cfg && cfg.configForms ? cfg.configForms : cfg;
3651
+ if (cf && cf.developerTools && cf.developerTools.enabled) { devToolsStore = cf.developerTools.enabled; }
3652
+ } catch (e) {}
3653
+ });
3654
+ } catch (e) {}
3655
+ try {
3656
+ ctx.inject(["sidebarRight"], function (right) {
3657
+ try {
3658
+ var svc = right && right.sidebarRight ? right.sidebarRight : right;
3659
+ if (svc && typeof svc.openResource === "function") { sidebarRightForChanges = svc; }
3660
+ } catch (e) {}
3661
+ });
3662
+ } catch (e) {}
2486
3663
  slots.inject("conversation.chat.turnTail", function () {
2487
3664
  return slots.register({
2488
3665
  name: "conversation.chat.turnTail",