dsh-workspace-mover 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -28,7 +28,6 @@ window.__ModuleLoader__.load({
28
28
  rolledBack: "已自动回滚:{msg}",
29
29
  failed: "移动失败:{msg}",
30
30
  noTarget: "无法识别目标工作区(侧边栏结构变化?)",
31
- ungroupedUnsupported: "暂不支持移动到「未分组」",
32
31
  staleList: "工作区列表与侧边栏不一致,请重试",
33
32
  restartHint: "缓存失效失败:归属可能需要重启 Harness 后才刷新",
34
33
  rescueSection: "会话修复",
@@ -58,6 +57,10 @@ window.__ModuleLoader__.load({
58
57
  helpPath: "会话原来的文件夹被移动、改名或删除,所以侧边栏看不到它。选择一个分组,历史会原样迁移过去,会话 ID 保持不变。",
59
58
  helpUnreg: "会话文件完好,只是还没有归属到任何工作区分组。点击按钮即可归入匹配的分组,文件不会移动。",
60
59
  pickTarget: "请先在下拉框选择要移入的分组",
60
+ idUnresolved: "无法识别要移动的会话(侧边栏结构或标题有变化),已取消本次移动。请刷新页面后重试。",
61
+ idUnresolvedPartial: "有 {n} 行无法识别,将被跳过——本次只移动能确认身份的会话。",
62
+ batchTooMany: "单次最多移动 {max} 个会话(本次 {n} 个)",
63
+ dragPayloadBad: "无法读取拖拽内容(宿主版本变化?),已取消本次移动",
61
64
  relinkBtn: "移入该分组",
62
65
  attachBtn: "归入分组",
63
66
  relinked: "✓ 已迁入「{title}」,历史原样保留",
@@ -91,13 +94,13 @@ window.__ModuleLoader__.load({
91
94
  batchUndoPartial: ";{n} 个未能撤回(可重试)",
92
95
  pickHint: "已选 {n} 个会话 · Ctrl+点击行可多选 · 拖到目标工作区标题行批量移动 · Esc 清空",
93
96
  pickCleared: "已清空多选",
94
- pickEscHint: "按 Esc 清空多选(输入框聚焦时无效)",
95
97
  groupMoveTitle: "把「{title}」的会话移到…",
96
98
  groupMoveHint: "整组会话将真迁移到目标分组;运行中的会话会被跳过并在结果中说明。",
97
99
  groupMoveEmpty: "这个分组下没有可移动的会话。",
98
100
  openFolderMenu: "打开文件夹",
99
101
  archivedCaption: "已归档的会话",
100
102
  archivedHelp: "这些会话被官方「归档」后从侧边栏消失,但文件和分组归属都还在。点「恢复」回到原来的分组,或选「恢复到…」换一个分组;恢复不会动文件。",
103
+ archiveTruncated: "注意:扫描只解析了最新 {parsed} / {total} 个会话档案,较旧的归档会话可能不在此列表中。",
101
104
  restoreBtn: "恢复",
102
105
  restoreToBtn: "恢复到…",
103
106
  restorePickTitle: "把「{title}」恢复到…",
@@ -121,12 +124,20 @@ window.__ModuleLoader__.load({
121
124
  purgeAllBtn: "清空回收站",
122
125
  purgeAllConfirm: "彻底删除回收站里的全部 {n} 个会话?此操作不可撤销。",
123
126
  purgedMsg: "✓ 已彻底删除 {n} 项",
127
+ purgeBackupsToo: "这个会话还有 {n} 份迁移备份。一并删除吗?\n\n(会话已不存在,这些备份不会再被用到;保留则可在备份列表里单独恢复或清理。)",
128
+ purgeBackupsDone: "(含 {n} 份备份)",
124
129
  backupsCaption: "备份",
125
130
  backupsHelp: "每次迁移前自动生成的字节级备份都在这里。可以把会话恢复回备份时的原位置(若那里没有同 id 会话),或恢复到任意其他分组。",
126
131
  backupMeta: "{n} 份 · {size} · {range}",
127
132
  backupDeleteBtn: "删除备份",
128
133
  backupDeleteConfirm: "删除「{title}」的 {n} 份备份?删除后无法再从备份恢复。",
129
134
  backupDeletedMsg: "✓ 已删除 {n} 份备份",
135
+ backupOrphanBadge: "孤儿",
136
+ backupOrphanHelp: "这个会话的档案已经不存在了(通常是被彻底删除)。这些备份不会被任何操作再引用,可以安全清理。",
137
+ backupOrphanSummary: "孤儿备份:{n} 组 · {size}(会话已不存在)",
138
+ backupOrphanCleanup: "清理孤儿备份",
139
+ backupOrphanConfirm: "删除 {n} 组孤儿备份({size})?这些会话已不存在,删除后无法从备份恢复。",
140
+ backupOrphanPartial: "✓ 已删除 {n} 份,{f} 组失败(可重试)",
130
141
  residentRefuse: "该会话当前没有焦点也可能仍驻留在 Harness 内存中(归档只隐藏,不会卸载)。DSH 0.1.5 没有公开的按 ID 卸载接口,请重启 Harness 释放常驻会话后再删除。",
131
142
  sessionBusy: "该会话正在处理中(可能有迁移 / 还原 / 删除操作尚未完成),请稍后再试。",
132
143
  contextStale: "插件上下文已失效(客户端热重载导致)。请刷新页面(或重启应用)后重试,功能会自动恢复。",
@@ -146,6 +157,7 @@ window.__ModuleLoader__.load({
146
157
  doctorDataHistory: "移动历史",
147
158
  doctorDataTasks: "任务记录",
148
159
  doctorRecovery: "恢复记录",
160
+ doctorDataLayout: "存储布局",
149
161
  doctorWorkspaces: "工作区路径",
150
162
  repairAllBtn: "一键修复",
151
163
  repairAllDone: "✓ 已修复 {n} · 跳过 {s} · 失败 {f}",
@@ -175,7 +187,6 @@ window.__ModuleLoader__.load({
175
187
  rolledBack: "Rolled back automatically: {msg}",
176
188
  failed: "Move failed: {msg}",
177
189
  noTarget: "Cannot resolve target workspace (sidebar layout changed?)",
178
- ungroupedUnsupported: "Moving to \"Ungrouped\" is not supported yet",
179
190
  staleList: "Workspace list out of sync with the sidebar; try again",
180
191
  restartHint: "Cache invalidation failed: grouping may need a harness restart",
181
192
  rescueSection: "Session Repair",
@@ -205,6 +216,10 @@ window.__ModuleLoader__.load({
205
216
  helpPath: "The folder this session lived in was moved, renamed or deleted, so the sidebar cannot show it. Pick a group — history moves over as-is and the session ID stays the same.",
206
217
  helpUnreg: "The session file is fine but no workspace claims it yet. Click to file it into the matching group; files are not moved.",
207
218
  pickTarget: "Pick a target group in the dropdown first",
219
+ idUnresolved: "Could not identify the session to move (sidebar structure or titles changed); this move was cancelled. Refresh the page and try again.",
220
+ idUnresolvedPartial: "{n} row(s) could not be identified and will be skipped — this move only carries the sessions whose identity was confirmed.",
221
+ batchTooMany: "At most {max} sessions per move (this one had {n})",
222
+ dragPayloadBad: "Could not read the dragged content (host version change?); this move was cancelled",
208
223
  relinkBtn: "Move there",
209
224
  attachBtn: "File into group",
210
225
  relinked: "✓ Moved into \"{title}\"; history preserved as-is",
@@ -238,13 +253,13 @@ window.__ModuleLoader__.load({
238
253
  batchUndoPartial: "; {n} not undone (retry available)",
239
254
  pickHint: "{n} sessions picked · Ctrl+click rows to multi-select · drag onto a workspace header to move in bulk · Esc to clear",
240
255
  pickCleared: "Selection cleared",
241
- pickEscHint: "Press Esc to clear the multi-selection (not while typing in the composer)",
242
256
  groupMoveTitle: "Move sessions of \"{title}\" to…",
243
257
  groupMoveHint: "The whole group will be truly moved to the target; running sessions are skipped and noted in the result.",
244
258
  groupMoveEmpty: "No movable sessions in this group.",
245
259
  openFolderMenu: "Open folder",
246
260
  archivedCaption: "Archived sessions",
247
261
  archivedHelp: "These sessions were hidden by the official \"archive\" action, but their files and group membership are intact. \"Restore\" sends them back to the original group, or \"Restore to…\" picks another one; restoring never touches files.",
262
+ archiveTruncated: "Note: the scan parsed only the newest {parsed} of {total} session archives, so older archived sessions may be missing from this list.",
248
263
  restoreBtn: "Restore",
249
264
  restoreToBtn: "Restore to…",
250
265
  restorePickTitle: "Restore \"{title}\" to…",
@@ -268,12 +283,20 @@ window.__ModuleLoader__.load({
268
283
  purgeAllBtn: "Empty recycle bin",
269
284
  purgeAllConfirm: "Purge all {n} sessions in the recycle bin? This cannot be undone.",
270
285
  purgedMsg: "✓ Purged {n} item(s)",
286
+ purgeBackupsToo: "This session still has {n} migration backup copy(ies). Delete them too?\n\n(The session no longer exists, so those backups will not be used again; keeping them lets you restore or clean them separately from the backup list.)",
287
+ purgeBackupsDone: " (including {n} backup copy(ies))",
271
288
  backupsCaption: "Backups",
272
289
  backupsHelp: "Every move creates a byte-level backup first; they are all listed here. Restore a session back to where the backup was taken (if no session with the same id lives there), or into any other group.",
273
290
  backupMeta: "{n} copies · {size} · {range}",
274
291
  backupDeleteBtn: "Delete backups",
275
292
  backupDeleteConfirm: "Delete the {n} backup copies of \"{title}\"? They cannot be restored from afterwards.",
276
293
  backupDeletedMsg: "✓ Deleted {n} backup copy(ies)",
294
+ backupOrphanBadge: "Orphan",
295
+ backupOrphanHelp: "This session's archive no longer exists (usually purged). These backups are no longer referenced by any operation and can be cleaned safely.",
296
+ backupOrphanSummary: "Orphan backups: {n} group(s) · {size} (session no longer exists)",
297
+ backupOrphanCleanup: "Clean orphan backups",
298
+ backupOrphanConfirm: "Delete {n} orphan backup group(s) ({size})? Those sessions no longer exist, so they cannot be restored from these backups afterwards.",
299
+ backupOrphanPartial: "✓ Deleted {n}; {f} group(s) failed (retry available)",
277
300
  residentRefuse: "This session may remain resident in harness memory even when it is not focused (archiving hides it but does not unload it). DSH 0.1.5 exposes no public unload-by-ID API; restart the harness to release it, then delete it again.",
278
301
  sessionBusy: "This session is busy: a move / restore / delete is still in flight. Try again in a moment.",
279
302
  contextStale: "The plugin context went stale (client-side hot reload). Refresh the page (or restart the app) and the features will work again.",
@@ -293,6 +316,7 @@ window.__ModuleLoader__.load({
293
316
  doctorDataHistory: "Move history",
294
317
  doctorDataTasks: "Task records",
295
318
  doctorRecovery: "Recovery records",
319
+ doctorDataLayout: "Storage layout",
296
320
  doctorWorkspaces: "Workspace paths",
297
321
  repairAllBtn: "Fix all",
298
322
  repairAllDone: "✓ Fixed {n} · skipped {s} · failed {f}",
@@ -312,6 +336,18 @@ window.__ModuleLoader__.load({
312
336
  }
313
337
  };
314
338
  const lang = (navigator.language || "en").toLowerCase().startsWith("zh") ? "zh" : "en";
339
+ // 词条漂移防护:新增文案只补一种语言时,缺失的那一侧会静默回退成 key
340
+ //(界面上直接显示 idUnresolved 这样的标识符)。启动时对账一次并告警。
341
+ (() => {
342
+ const keysOf = (l) => new Set(Object.keys(STRINGS[l] ?? {}));
343
+ const zhKeys = keysOf("zh");
344
+ const enKeys = keysOf("en");
345
+ const onlyZh = [...zhKeys].filter((k) => !enKeys.has(k));
346
+ const onlyEn = [...enKeys].filter((k) => !zhKeys.has(k));
347
+ if (onlyZh.length || onlyEn.length) {
348
+ console.warn("[workspace-mover] i18n key drift — missing in en:", onlyZh, "missing in zh:", onlyEn);
349
+ }
350
+ })();
315
351
  const t = (key, vars) => {
316
352
  let s = STRINGS[lang][key] ?? key;
317
353
  for (const [k, v] of Object.entries(vars ?? {})) s = s.replaceAll(`{${k}}`, String(v));
@@ -510,12 +546,16 @@ window.__ModuleLoader__.load({
510
546
  }
511
547
  }
512
548
 
549
+ /** 弹窗实例计数:为 aria-labelledby 生成稳定且唯一的关联 id。 */
550
+ let overlaySeq = 0;
551
+
513
552
  function injectOverlay() {
553
+ const uid = `wsm-title-${++overlaySeq}`;
514
554
  const overlay = document.createElement("div");
515
555
  overlay.className = "wsm-overlay";
516
556
  overlay.innerHTML = `
517
- <div class="wsm-card" role="dialog" aria-modal="true">
518
- <div class="wsm-title"></div>
557
+ <div class="wsm-card" role="dialog" aria-modal="true" aria-labelledby="${uid}">
558
+ <div class="wsm-title" id="${uid}"></div>
519
559
  <div class="wsm-row"><span class="wsm-label"></span> <b class="wsm-session"></b></div>
520
560
  <div class="wsm-row"><span class="wsm-label"></span> <b class="wsm-target"></b></div>
521
561
  <div class="wsm-hint"></div>
@@ -532,7 +572,29 @@ window.__ModuleLoader__.load({
532
572
  return overlay;
533
573
  }
534
574
 
535
- function confirmMove({ sessionId, sessionTitle, workspace, batchCount }) {
575
+ /**
576
+ * 挂载弹窗并补齐可达性:焦点进入弹窗、Tab 在弹窗内循环、
577
+ * 背景点击关闭。`onKey` 是各调用方自己的 Escape 处理(用于释放监听器)。
578
+ * 必须**先 append 再 focus**——未挂到文档上的元素在浏览器里无法聚焦。
579
+ */
580
+ function openDialog(overlay, close) {
581
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(null); });
582
+ document.body.appendChild(overlay);
583
+ const card = overlay.querySelector(".wsm-card");
584
+ const focusables = [...card.querySelectorAll("button:not(:disabled)")];
585
+ (focusables.find((b) => b.classList.contains("primary")) ?? focusables[0] ?? card)?.focus?.();
586
+ overlay.addEventListener("keydown", (e) => {
587
+ if (e.key !== "Tab" || focusables.length === 0) return;
588
+ const at = focusables.indexOf(document.activeElement);
589
+ const next = e.shiftKey
590
+ ? (at <= 0 ? focusables.length - 1 : at - 1)
591
+ : (at === -1 || at === focusables.length - 1 ? 0 : at + 1);
592
+ // 自管焦点:避免 Tab 跑到弹窗背后的侧边栏/输入框
593
+ focusables[next]?.focus?.();
594
+ });
595
+ }
596
+
597
+ function confirmMove({ sessionId, sessionTitle, workspace, batchCount, unresolvedCount }) {
536
598
  return new Promise((resolve) => {
537
599
  const overlay = injectOverlay();
538
600
  const okBtn = overlay.querySelector(".wsm-ok");
@@ -541,15 +603,26 @@ window.__ModuleLoader__.load({
541
603
  ? t("scannedN", { n: batchCount })
542
604
  : (sessionTitle || t("unnamedSession"));
543
605
  overlay.querySelector(".wsm-target").textContent = `${workspace.title}(${workspace.path})`;
544
- overlay.querySelector(".wsm-hint").textContent = batchCount ? t("batchHint", { count: batchCount }) : t("hint");
606
+ const hint = overlay.querySelector(".wsm-hint");
607
+ hint.textContent = batchCount ? t("batchHint", { count: batchCount }) : t("hint");
608
+ // 有行无法识别时必须如实说明——用户点"确认"之前要知道这次只搬走了一部分。
609
+ if (unresolvedCount > 0) {
610
+ const warn = document.createElement("div");
611
+ warn.className = "wsm-hint";
612
+ warn.textContent = t("idUnresolvedPartial", { n: unresolvedCount });
613
+ hint.after(warn);
614
+ }
545
615
  okBtn.textContent = batchCount ? t("batchMove") : t("move");
546
- const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
547
- const onKey = (e) => { if (e.key === "Escape") close(null); };
548
616
  document.addEventListener("keydown", onKey);
549
- overlay.addEventListener("click", (e) => { if (e.target === overlay) close(null); });
617
+ function onKey(e) { if (e.key === "Escape") close(null); }
618
+ function close(value) {
619
+ overlay.remove();
620
+ document.removeEventListener("keydown", onKey);
621
+ resolve(value);
622
+ }
550
623
  overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
551
624
  okBtn.addEventListener("click", () => close(true));
552
- document.body.appendChild(overlay);
625
+ openDialog(overlay, close);
553
626
  });
554
627
  }
555
628
 
@@ -577,14 +650,17 @@ window.__ModuleLoader__.load({
577
650
  okBtn.disabled = true;
578
651
  const select = overlay.querySelector("select.wsm-select");
579
652
  select.addEventListener("change", () => { okBtn.disabled = !select.value; });
580
- const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
581
- const onKey = (e) => { if (e.key === "Escape") close(null); };
582
653
  document.addEventListener("keydown", onKey);
583
- overlay.addEventListener("click", (e) => { if (e.target === overlay) close(null); });
654
+ function onKey(e) { if (e.key === "Escape") close(null); }
655
+ function close(value) {
656
+ overlay.remove();
657
+ document.removeEventListener("keydown", onKey);
658
+ resolve(value);
659
+ }
584
660
  overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
585
661
  okBtn.addEventListener("click", () => close(select.value || null));
586
- document.body.appendChild(overlay);
587
- select.focus();
662
+ openDialog(overlay, close);
663
+ okBtn.focus();
588
664
  });
589
665
  }
590
666
 
@@ -612,14 +688,17 @@ window.__ModuleLoader__.load({
612
688
  okBtn.disabled = true;
613
689
  const select = overlay.querySelector("select.wsm-select");
614
690
  select.addEventListener("change", () => { okBtn.disabled = !select.value; });
615
- const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
616
- const onKey = (e) => { if (e.key === "Escape") close(null); };
617
691
  document.addEventListener("keydown", onKey);
618
- overlay.addEventListener("click", (e) => { if (e.target === overlay) close(null); });
692
+ function onKey(e) { if (e.key === "Escape") close(null); }
693
+ function close(value) {
694
+ overlay.remove();
695
+ document.removeEventListener("keydown", onKey);
696
+ resolve(value);
697
+ }
619
698
  overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
620
699
  okBtn.addEventListener("click", () => close(select.value || null));
621
- document.body.appendChild(overlay);
622
700
  if (select.value) okBtn.disabled = false;
701
+ openDialog(overlay, close);
623
702
  select.focus();
624
703
  });
625
704
  }
@@ -1028,11 +1107,18 @@ window.__ModuleLoader__.load({
1028
1107
 
1029
1108
  const purgeOne = async (item) => {
1030
1109
  if (!window.confirm(t("purgeConfirm", { title: item.title || t("unnamedSession") }))) return;
1110
+ // 彻底删除不可撤销:顺便问一句要不要把该会话的迁移备份也清掉
1111
+ //(这些备份此刻已无还原对象,留着只会变成孤儿字节)。
1112
+ const purgeBackups = item.backupCount > 0
1113
+ ? window.confirm(t("purgeBackupsToo", { n: item.backupCount }))
1114
+ : false;
1031
1115
  setBusy(true);
1032
1116
  setNote("");
1033
1117
  try {
1034
- const res = await call("mover.trash.purge", { sessionId: item.sessionId });
1035
- setNote(t("purgedMsg", { n: res?.purged ?? 1 }));
1118
+ const res = await call("mover.trash.purge", { sessionId: item.sessionId, purgeBackups });
1119
+ let msg = t("purgedMsg", { n: res?.purged ?? 1 });
1120
+ if ((res?.backupsDeleted ?? 0) > 0) msg += t("purgeBackupsDone", { n: res.backupsDeleted });
1121
+ setNote(msg);
1036
1122
  await runScan();
1037
1123
  } catch (err) {
1038
1124
  failNote(err);
@@ -1110,6 +1196,29 @@ window.__ModuleLoader__.load({
1110
1196
  }
1111
1197
  };
1112
1198
 
1199
+ /**
1200
+ * 清理全部孤儿备份组的字节。孤儿 = 会话档案已不存在,这些备份不会被
1201
+ * 任何操作再引用;逐组调用既有端点,不新增 RPC 面。
1202
+ */
1203
+ const backupDeleteOrphans = async () => {
1204
+ const orphans = (backupItems ?? []).filter((it) => it.orphan);
1205
+ if (orphans.length === 0) return;
1206
+ if (!window.confirm(t("backupOrphanConfirm", { n: orphans.length, size: fmtBytes(backups?.orphanBytes ?? 0) }))) return;
1207
+ setBusy(true);
1208
+ setNote("");
1209
+ let deleted = 0;
1210
+ let failed = 0;
1211
+ for (const item of orphans) {
1212
+ try {
1213
+ const res = await call("mover.backups.deleteOne", { sessionId: item.sessionId });
1214
+ deleted += res?.deleted ?? 0;
1215
+ } catch { failed++; }
1216
+ }
1217
+ setNote(failed > 0 ? t("backupOrphanPartial", { n: deleted, f: failed }) : t("backupDeletedMsg", { n: deleted }));
1218
+ setBusy(false);
1219
+ await runScan();
1220
+ };
1221
+
1113
1222
  // 一键修复:可自动修复项(挂错归位 / 未记账补账)逐项处理,只动记账不搬文件
1114
1223
  const repairAll = async () => {
1115
1224
  setBusy(true);
@@ -1202,8 +1311,13 @@ window.__ModuleLoader__.load({
1202
1311
  const brokenWorkspaces = (audit?.items ?? []).filter((it) => it.status !== "ok");
1203
1312
  const emptyWorkspaces = workspaces.filter((w) => (w.rawSessionCount ?? 0) === 0);
1204
1313
  const archivedItems = archived?.items ?? [];
1205
- const trashItems = trash?.items ?? [];
1206
1314
  const backupItems = backups?.items ?? [];
1315
+ // 回收站条目补上"该会话还有几份迁移备份":彻底删除时顺带清理它们。
1316
+ const backupCountById = new Map(backupItems.map((it) => [String(it.sessionId), it.count]));
1317
+ const trashItems = (trash?.items ?? []).map((it) => ({
1318
+ ...it,
1319
+ backupCount: backupCountById.get(String(it.sessionId)) ?? 0
1320
+ }));
1207
1321
  const taskItems = tasks?.items ?? [];
1208
1322
  // 面板筛选:标题 / 会话 ID / 路径 / 归属分组,命中任一即保留
1209
1323
  const q = query.trim().toLowerCase();
@@ -1323,6 +1437,10 @@ window.__ModuleLoader__.load({
1323
1437
  })
1324
1438
  ) : null,
1325
1439
  archivedItems.length > 0 ? h(Caption, { text: `${t("archivedCaption")} (${capCount(archivedV.length, archivedItems.length)})`, help: t("archivedHelp") }) : null,
1440
+ // 扫描只解析最新 400 条档案,而归档会话通常很旧:大库上这里可能不是全部。
1441
+ archived?.truncated && archivedV.length > 0
1442
+ ? h("div", { className: "wsm-note" }, t("archiveTruncated", { parsed: archived.scannedParsed ?? 0, total: archived.scannedTotal ?? 0 }))
1443
+ : null,
1326
1444
  archivedV.length > 0 ? h("div", { className: "wsm-list" },
1327
1445
  archivedV.map((it) => h("div", { className: "wsm-item", key: it.sessionId },
1328
1446
  h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
@@ -1387,9 +1505,17 @@ window.__ModuleLoader__.load({
1387
1505
  })) : h("div", { className: "wsm-note" }, t("historyEmpty"))
1388
1506
  ),
1389
1507
  backupItems.length > 0 ? h(Caption, { text: `${t("backupsCaption")} (${capCount(backupV.length, backupItems.length)})`, help: t("backupsHelp") }) : null,
1508
+ // 孤儿备份:会话已不存在,这些字节不会被任何操作再引用,只能手动清。
1509
+ backups?.orphanGroups > 0
1510
+ ? h("div", { className: "wsm-scanrow" },
1511
+ h("span", { style: { fontSize: "12.5px", color: "var(--dsw-alias-label-secondary,#666)" } },
1512
+ t("backupOrphanSummary", { n: backups.orphanGroups, size: fmtBytes(backups.orphanBytes ?? 0) })),
1513
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void backupDeleteOrphans() }, t("backupOrphanCleanup")))
1514
+ : null,
1390
1515
  backupV.length > 0 ? h("div", { className: "wsm-list" },
1391
1516
  backupV.map((it) => h("div", { className: "wsm-item", key: it.sessionId },
1392
1517
  h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
1518
+ it.orphan ? h("span", { className: "wsm-badge warn", title: t("backupOrphanHelp") }, t("backupOrphanBadge")) : null,
1393
1519
  h("span", { className: "wsm-cwd", title: it.cwd ?? "" }, t("backupMeta", { n: it.count, size: fmtBytes(it.totalBytes), range: fmtRange(it) })),
1394
1520
  h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void backupRestore(it) }, t("restoreBtn")),
1395
1521
  h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void backupDelete(it) }, t("backupDeleteBtn"))
@@ -1441,7 +1567,13 @@ window.__ModuleLoader__.load({
1441
1567
  // slots:设置页「会话救援」面板需要;未注入时访问 ctx.slots 会被宿主拒绝
1442
1568
  var inject = ["connection", "slots"];
1443
1569
 
1570
+ // 同一 bundle 被重复装载时(宿主热重载/重组)防止叠加实例。
1571
+ // 前一个实例在 teardown 里把它复位,因此"装载→卸载→再装载"仍能正常工作。
1572
+ let applied = false;
1573
+
1444
1574
  function apply(ctx) {
1575
+ if (applied) return;
1576
+ applied = true;
1445
1577
  // 在 apply(上下文必然活跃)时立即捕获 connection 服务实例本身,而不是持有 ctx
1446
1578
  // 做惰性访问:桌面版会在热重载 / 客户端重组时废弃本插件的旧 fiber,此后
1447
1579
  // ctx.connection 会抛 "cannot get required service 'connection' in inactive context"
@@ -1459,6 +1591,16 @@ window.__ModuleLoader__.load({
1459
1591
  let dragging = null; // {el, els, id, target} —— id/target 在 drop 阶段解析
1460
1592
  let wsCache = null; // {items, at}
1461
1593
 
1594
+ // 统一登记本插件挂到 document / window 上的监听器与 observer。
1595
+ // 必须成对释放:桌面版热重载会废弃旧 fiber 并重新装载客户端半,
1596
+ // 不释放就会叠加第二份监听器——重复确认框、重复 RPC,
1597
+ // 两份闭包还会互相擦掉对方的多选高亮。
1598
+ const disposers = [];
1599
+ const listen = (type, fn, options) => {
1600
+ document.addEventListener(type, fn, options);
1601
+ disposers.push(() => document.removeEventListener(type, fn, options));
1602
+ };
1603
+
1462
1604
  ensureStyle();
1463
1605
 
1464
1606
  // 状态仅作诊断:不硬性禁用拖拽(避免启动期竞态导致永久失效)。
@@ -1480,9 +1622,9 @@ window.__ModuleLoader__.load({
1480
1622
 
1481
1623
  //#region 插件自建多选(宿主侧边栏没有多选模型)
1482
1624
  // Ctrl/Cmd+点击加入/移出、Shift+点击组内范围选择;拖动任一选中行 = 整批拖动。
1483
- // 选中集存行元素(判定同步、零延迟);会话 id 在点击/投放时经「分组对齐 +
1484
- // 标题消歧」解析:组内 DOM 行顺序对齐 workspace.sessionIds 顺序,行文本与
1485
- // mover.scan 的标题贪心匹配——躲开隐藏空白会话造成的纯序错位,失配退化为顺序。
1625
+ // 选中集存行元素(判定同步、零延迟);会话 id 在点击/投放时解析:
1626
+ // 优先走权威通道 rowSessionId(React fiber 上的 node.id),不可用时退化为
1627
+ // 「组内对齐 + 标题匹配」——且**匹配不上就不猜**,无法确认的行会被跳过并告知用户。
1486
1628
  let pickBadge = null;
1487
1629
  const pickedRows = new Set();
1488
1630
  let lastPickedRow = null;
@@ -1564,35 +1706,48 @@ window.__ModuleLoader__.load({
1564
1706
  }
1565
1707
 
1566
1708
  /**
1567
- * 把一组会话行对齐到 workspace.sessionIds:两指针顺序消费 + 行文本与
1568
- * scan 标题贪心匹配。仅作 rowSessionId 不可用时的兜底(官方隐藏空白/
1569
- * 归档会话时可能错位,v0.6.x 的"移错会话"即源于此)。结果缓存进
1570
- * row.dataset.wsmId;返回 row→id 映射。
1709
+ * 把一组会话行对齐到 ws.sessionIds。仅在权威通道(rowSessionId)不可用时使用。
1710
+ *
1711
+ * 关键安全约定:**匹配不上就不猜**。旧实现在标题匹配失败时直接取
1712
+ * `remaining` 里的下一个 id,而官方侧边栏会隐藏空白/归档行,于是整组错位一格、
1713
+ * 批量拖拽会搬走另一组合法会话。现在无法确定的行一律计入 unresolved,
1714
+ * 由调用方决定跳过还是中止。
1715
+ *
1716
+ * 返回 { map, unresolved };map 为 row → id。
1571
1717
  */
1572
1718
  async function mapGroupRows(ws, groupRows) {
1573
1719
  const titleOf = new Map();
1574
1720
  try {
1575
1721
  const scan = await fetchScan();
1576
1722
  for (const it of scan.items ?? []) titleOf.set(String(it.sessionId), String(it.title ?? "").replace(/\s+/g, " ").trim());
1577
- } catch { /* scan 不可用时退化为纯顺序对齐 */ }
1723
+ } catch { /* scan 不可用时无法按标题匹配,只能精确匹配或放弃 */ }
1578
1724
  const remaining = (ws.sessionIds ?? []).map(String);
1579
1725
  const out = new Map();
1726
+ const unresolved = [];
1580
1727
  for (const row of groupRows) {
1581
- if (remaining.length === 0) break;
1728
+ if (remaining.length === 0) { unresolved.push(row); continue; }
1582
1729
  const text = (row.textContent ?? "").replace(/\s+/g, " ").trim();
1583
1730
  let hit = -1;
1584
1731
  if (text && text !== t("unnamedSession")) {
1585
- hit = remaining.findIndex((id) => { const tt = titleOf.get(id); return tt && tt.length > 0 && (tt === text || text.includes(tt) || tt.includes(text)); });
1732
+ // 先精确,再"标题被行文本包含"(行文本通常更短,只带徽章/时间)
1733
+ hit = remaining.findIndex((id) => titleOf.get(id) === text);
1734
+ if (hit < 0) {
1735
+ hit = remaining.findIndex((id) => {
1736
+ const tt = titleOf.get(id);
1737
+ return tt && tt.length > 0 && text.includes(tt);
1738
+ });
1739
+ }
1586
1740
  }
1587
- if (hit < 0) hit = 0;
1741
+ if (hit < 0) { unresolved.push(row); continue; }
1588
1742
  const id = remaining.splice(hit, 1)[0];
1743
+ if (id === undefined) { unresolved.push(row); continue; }
1589
1744
  row.dataset.wsmId = id;
1590
1745
  out.set(row, id);
1591
1746
  }
1592
- return out;
1747
+ return { map: out, unresolved };
1593
1748
  }
1594
1749
 
1595
- /** 解析单个行元素(含所在组)的会话 id;权威走 rowSessionId,失败退回组内对齐。 */
1750
+ /** 解析单个行元素(含所在组)的会话 id;权威走 rowSessionId,失败退回组内对齐(不猜)。 */
1596
1751
  async function resolveRowId(rowEl) {
1597
1752
  const authoritative = rowSessionId(rowEl);
1598
1753
  if (authoritative) return authoritative;
@@ -1607,12 +1762,12 @@ window.__ModuleLoader__.load({
1607
1762
  if (!header) return null;
1608
1763
  const ws = resolveWorkspace(header, items);
1609
1764
  if (!ws) return null;
1610
- const mapping = await mapGroupRows(ws, groupSessionRows(header));
1611
- return mapping.get(rowEl) ?? null;
1765
+ const { map } = await mapGroupRows(ws, groupSessionRows(header));
1766
+ return map.get(rowEl) ?? null;
1612
1767
  }
1613
1768
 
1614
1769
  // Ctrl/Cmd+点击:加入/移出;Shift+点击:组内范围选择。普通点击不干预。
1615
- document.addEventListener("click", async (e) => {
1770
+ listen("click", async (e) => {
1616
1771
  const row = sessionRow(e.target);
1617
1772
  if (!row) return;
1618
1773
  const meta = e.ctrlKey || e.metaKey;
@@ -1632,9 +1787,9 @@ window.__ModuleLoader__.load({
1632
1787
  return null;
1633
1788
  })();
1634
1789
  const ws = header ? resolveWorkspace(header, items) : null;
1635
- if (!ws) return void toast(t("noTarget"), true);
1790
+ if (!ws) return void toast(t("staleList"), true);
1636
1791
  const groupRows = groupSessionRows(header);
1637
- const mapping = await mapGroupRows(ws, groupRows);
1792
+ const { map: mapping } = await mapGroupRows(ws, groupRows);
1638
1793
  if (meta) {
1639
1794
  if (pickedRows.has(row)) pickedRows.delete(row);
1640
1795
  else if (mapping.has(row) || rowSessionId(row)) {
@@ -1664,9 +1819,13 @@ window.__ModuleLoader__.load({
1664
1819
  }
1665
1820
  }, true);
1666
1821
 
1667
- // Esc 清空多选;不消费事件,输入框聚焦时也照常清空(官方输入的取消行为不受影响)
1668
- document.addEventListener("keydown", (e) => {
1822
+ // Esc 清空多选:输入框聚焦时不清空(与 pickHint 的文案一致),
1823
+ // 也不消费事件——官方输入的取消行为不受影响。
1824
+ listen("keydown", (e) => {
1669
1825
  if (e.key !== "Escape" || pickedRows.size === 0) return;
1826
+ const el = document.activeElement;
1827
+ const tag = el?.tagName?.toLowerCase?.();
1828
+ if (el?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") return;
1670
1829
  clearSelection();
1671
1830
  });
1672
1831
  //#endregion
@@ -1693,30 +1852,36 @@ window.__ModuleLoader__.load({
1693
1852
  .filter((el) => el.offsetParent !== null && !isUngroupedHeader(el));
1694
1853
  }
1695
1854
 
1696
- function headerIndex(rowEl) {
1697
- return visibleWorkspaceHeaders().indexOf(rowEl);
1855
+ /** 行标题文本是否命中某个注册表工作区的 title 或 path(大小写不敏感)。 */
1856
+ function headerMatchesWorkspace(text, item) {
1857
+ const lower = String(text ?? "").toLocaleLowerCase();
1858
+ const title = normalizeHeaderText(item?.title).toLocaleLowerCase();
1859
+ const path = normalizeHeaderText(item?.path).toLocaleLowerCase();
1860
+ return Boolean((title && lower.includes(title)) || (path && lower.includes(path)));
1698
1861
  }
1699
1862
 
1863
+ /**
1864
+ * 行元素 → 注册表工作区。
1865
+ * 只用**可自证**的凭据:标题/路径命中,或标题与路径同时为空时的序号等价
1866
+ * (两者都无值意味着该实体没有可显示的标识,序号是唯一可用信息)。
1867
+ * 官方侧边栏会隐藏目录已失效的分组,而 registry.list() 仍包含它们——
1868
+ * 因此「DOM 第 i 项 = 注册表第 i 项」是错的,绝不能作为无条件兜底,
1869
+ * 否则侧边栏少显示一项就会把会话搬进相邻分组。
1870
+ */
1700
1871
  function resolveWorkspace(rowEl, items) {
1701
1872
  const list = Array.isArray(items) ? items : [];
1702
1873
  if (!rowEl || isUngroupedHeader(rowEl)) return null;
1703
1874
  const text = headerText(rowEl);
1704
- const lowerText = text.toLocaleLowerCase();
1705
- const byText = list.find((item) => {
1706
- const title = normalizeHeaderText(item.title);
1707
- const path = normalizeHeaderText(item.path);
1708
- return (title && lowerText.includes(title.toLocaleLowerCase()))
1709
- || (path && lowerText.includes(path.toLocaleLowerCase()));
1710
- });
1875
+ const byText = list.find((item) => headerMatchesWorkspace(text, item));
1711
1876
  if (byText) return byText;
1712
- const idx = headerIndex(rowEl);
1713
- const byIndex = idx >= 0 && idx < list.length ? list[idx] : null;
1714
- return byIndex;
1877
+ const unidentifiable = list.filter((item) => !normalizeHeaderText(item?.title) && !normalizeHeaderText(item?.path));
1878
+ if (unidentifiable.length === 1) return unidentifiable[0];
1879
+ return null;
1715
1880
  }
1716
1881
 
1717
1882
  // dragstart:只做「元素判定」——拖起已多选的行 = 整批拖动,否则单选。
1718
1883
  // id 一律留到 drop 阶段解析(dataTransfer 只携带拖起这一行的 id)。
1719
- document.addEventListener("dragstart", (e) => {
1884
+ listen("dragstart", (e) => {
1720
1885
  try {
1721
1886
  const row = sessionRow(e.target);
1722
1887
  if (!row) { dragging = null; return; }
@@ -1726,7 +1891,7 @@ window.__ModuleLoader__.load({
1726
1891
  } catch { dragging = null; }
1727
1892
  });
1728
1893
 
1729
- document.addEventListener("dragover", (e) => {
1894
+ listen("dragover", (e) => {
1730
1895
  clearHints();
1731
1896
  if (!dragging) return;
1732
1897
  const header = headerRow(e.target);
@@ -1743,7 +1908,7 @@ window.__ModuleLoader__.load({
1743
1908
  }
1744
1909
  }, true);
1745
1910
 
1746
- document.addEventListener("drop", async (e) => {
1911
+ listen("drop", async (e) => {
1747
1912
  const current = dragging;
1748
1913
  dragging = null;
1749
1914
  clearHints();
@@ -1766,7 +1931,7 @@ window.__ModuleLoader__.load({
1766
1931
  draggedId = candidates.map((value) => String(value).trim().split(/[\r\n]/)[0]).find(Boolean) || null;
1767
1932
  } catch { draggedId = null; }
1768
1933
  if (!draggedId) {
1769
- return void toast(t("failed", { msg: "unrecognized drag payload: empty" }), true);
1934
+ return void toast(t("failed", { msg: t("dragPayloadBad") }), true);
1770
1935
  }
1771
1936
 
1772
1937
  let workspace = null;
@@ -1787,12 +1952,15 @@ window.__ModuleLoader__.load({
1787
1952
  } catch (err) {
1788
1953
  return void toast(t("failed", { msg: errMsg(err) }), true);
1789
1954
  }
1790
- if (!workspace) return void toast(t("noTarget"), true);
1955
+ if (!workspace) return void toast(t("staleList"), true);
1791
1956
 
1792
1957
  // ---- 组装移动清单:拖起行必有 id;多选行逐个经分组对齐解析 ----
1958
+ // 解析不出 id 的行**绝不猜测**(见 mapGroupRows 的安全约定):
1959
+ // 要么整体中止,要么在确认框里如实说明跳过了几行。
1793
1960
  const picked = (current.els ?? [current.el]).filter((el) => el?.isConnected);
1794
1961
  const sessions = [];
1795
1962
  const seen = new Set();
1963
+ let unresolvedCount = 0;
1796
1964
  const push = (id, title) => {
1797
1965
  const key = String(id);
1798
1966
  if (!key || seen.has(key)) return;
@@ -1802,20 +1970,30 @@ window.__ModuleLoader__.load({
1802
1970
  if (picked.length > 1) {
1803
1971
  for (const el of picked) {
1804
1972
  if (el === current.el) continue; // 拖起行用 dataTransfer 的权威 id
1805
- try { push(await resolveRowId(el), rowTitle(el, "")); } catch { /* 解析失败跳过 */ }
1973
+ try {
1974
+ const id = await resolveRowId(el);
1975
+ if (id) push(id, rowTitle(el, ""));
1976
+ else unresolvedCount += 1;
1977
+ } catch { unresolvedCount += 1; }
1806
1978
  }
1807
1979
  }
1808
1980
  // 权威 id 放首位;若某选中行被误映射到同一 id 会被去重吞掉
1981
+ const beforePush = sessions.length;
1809
1982
  push(draggedId, rowTitle(current.el, draggedId));
1983
+ if (sessions.length === beforePush) unresolvedCount += 1; // 连拖起行都没解析出来
1984
+
1985
+ if (unresolvedCount > 0 && sessions.length === 0) {
1986
+ return void toast(t("idUnresolved"), true);
1987
+ }
1810
1988
 
1811
1989
  if (sessions.length > 50) {
1812
- return void toast(t("failed", { msg: `too many sessions in one batch (max 50, got ${sessions.length})` }), true);
1990
+ return void toast(t("failed", { msg: t("batchTooMany", { max: 50, n: sessions.length }) }), true);
1813
1991
  }
1814
1992
 
1815
1993
  // ---- 单选:原有确认与 mover.move 路径 ----
1816
1994
  if (sessions.length === 1) {
1817
1995
  const { sessionId, sessionTitle } = sessions[0];
1818
- const confirmed = await confirmMove({ sessionId, sessionTitle, workspace });
1996
+ const confirmed = await confirmMove({ sessionId, sessionTitle, workspace, unresolvedCount });
1819
1997
  if (!confirmed) return;
1820
1998
  try {
1821
1999
  const res = await rpcCall("mover.move", { sessionId, sessionTitle, targetWorkspaceId: workspace.workspaceId });
@@ -1837,7 +2015,7 @@ window.__ModuleLoader__.load({
1837
2015
  }
1838
2016
 
1839
2017
  // ---- 多选:批量确认 + mover.moveMany(逐条独立备份回滚)----
1840
- const confirmed = await confirmMove({ workspace, batchCount: sessions.length });
2018
+ const confirmed = await confirmMove({ workspace, batchCount: sessions.length, unresolvedCount });
1841
2019
  if (!confirmed) return;
1842
2020
  try {
1843
2021
  const res = await rpcCall("mover.moveMany", { sessions, targetWorkspaceId: workspace.workspaceId });
@@ -1860,7 +2038,7 @@ window.__ModuleLoader__.load({
1860
2038
  }
1861
2039
  }, true);
1862
2040
 
1863
- document.addEventListener("dragend", () => { dragging = null; clearHints(); });
2041
+ listen("dragend", () => { dragging = null; clearHints(); });
1864
2042
 
1865
2043
  // 整组迁移 + 分组合并:入口在官方组标题的「⋯」菜单(注入「整组迁移…」项)。
1866
2044
  // 不再拦截浏览器右键——官方菜单同样在右键/⋯按钮打开,拦截会与之冲突。
@@ -1912,7 +2090,7 @@ window.__ModuleLoader__.load({
1912
2090
  // 记录「⋯」按钮点击所在的标题行(标题行内唯一带 aria-label 的按钮),
1913
2091
  // 随后出现的菜单 portal 即属于这个分组;1 秒内未出现菜单则作废。
1914
2092
  let menuHeaderRow = null;
1915
- document.addEventListener("click", (e) => {
2093
+ listen("click", (e) => {
1916
2094
  const header = headerRow(e.target);
1917
2095
  if (!header) return;
1918
2096
  const btn = e.target.closest?.("button[aria-label]");
@@ -1947,7 +2125,7 @@ window.__ModuleLoader__.load({
1947
2125
  try {
1948
2126
  const list = await fetchWorkspaces();
1949
2127
  const ws = resolveWorkspace(header, list);
1950
- if (!ws) return void toast(t("noTarget"), true);
2128
+ if (!ws) return void toast(t("staleList"), true);
1951
2129
  const res = await rpcCall("mover.openFolder", { workspaceId: ws.workspaceId, path: ws.path });
1952
2130
  if (!res?.ok) toast(t("failed", { msg: res?.error?.message ?? "open failed" }), true);
1953
2131
  } catch (err) {
@@ -1980,8 +2158,10 @@ window.__ModuleLoader__.load({
1980
2158
  }
1981
2159
  });
1982
2160
  menuObserver.observe(document.body, { childList: true, subtree: true });
2161
+ disposers.push(() => { try { menuObserver.disconnect(); } catch { /* ignore */ } });
1983
2162
 
1984
2163
  // 诊断句柄:控制台用 window.__wsmDebug 检查「最近更新」排序修复通道(排障用)
2164
+ const priorDebug = window.__wsmDebug;
1985
2165
  try {
1986
2166
  window.__wsmDebug = {
1987
2167
  view: () => { const v = resolveViewStore(ctx); return v ? v.getSnapshot() : null; },
@@ -1994,6 +2174,19 @@ window.__ModuleLoader__.load({
1994
2174
  } catch { /* ignore */ }
1995
2175
 
1996
2176
  registerRescuePanel(ctx, rpcCall);
2177
+
2178
+ // teardown:宿主废弃本 fiber(桌面版热重载 / 客户端重组)时释放全部监听器。
2179
+ // 没有这一步,重新装载会叠加第二份监听器,且旧闭包连同 pickedRows /
2180
+ // dragging / 缓存永远不会被回收。
2181
+ if (typeof ctx.effect === "function") {
2182
+ ctx.effect(() => () => {
2183
+ applied = false;
2184
+ for (const dispose of disposers.splice(0)) {
2185
+ try { dispose(); } catch { /* 单个失败不影响其余 */ }
2186
+ }
2187
+ try { if (window.__wsmDebug && priorDebug === undefined) delete window.__wsmDebug; } catch { /* ignore */ }
2188
+ }, "workspace-mover: client listeners");
2189
+ }
1997
2190
  }
1998
2191
 
1999
2192
  module.exports = { inject, apply };