dsh-workspace-mover 2.4.1 → 2.4.3

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
@@ -24,7 +24,8 @@ window.__ModuleLoader__.load({
24
24
  move: "移动",
25
25
  cancel: "取消",
26
26
  moving: "正在移动…",
27
- done: "✓ 已移动到「{title}」;若侧边栏未自动刷新,请手动刷新页面",
27
+ done: "✓ 已移动到「{title}」;若侧边栏未更新,请彻底退出并重启应用",
28
+ reopenFailed: "迁移操作已结束,但当前对话未能重新连接。请彻底退出并重启应用;无需重复迁移。",
28
29
  rolledBack: "已自动回滚:{msg}",
29
30
  failed: "移动失败:{msg}",
30
31
  noTarget: "无法识别目标工作区(侧边栏结构变化?)",
@@ -56,7 +57,9 @@ window.__ModuleLoader__.load({
56
57
  layoutUnknown: "存储布局未知",
57
58
  corruptData: "档案数据损坏或无法解析",
58
59
  ghostsTitle: "名单里有、但文件已不在的记录",
59
- ghostsHelp: "侧边栏的分组名单里记着这些会话,但对应的记录文件已经不在磁盘上(通常是被手动删除的)。一般不影响使用;重启 Harness 后通常会自动清理。",
60
+ ghostFound: "当前存储根的其他位置找到档案",
61
+ ghostUnknown: "档案位置未知(不代表已删除)",
62
+ ghostsHelp: "这些会话在分组记账指定的位置缺少档案,可能已被外部工具搬走。不能据此认定文件已删除,重启也不保证清理;本插件保留归属记录,不会自动清账。",
60
63
  helpPath: "会话原来的文件夹被移动、改名或删除,所以侧边栏看不到它。选择一个分组,历史会原样迁移过去,会话 ID 保持不变。",
61
64
  helpUnreg: "会话文件完好,只是还没有归属到任何工作区分组。点击按钮即可归入匹配的分组,文件不会移动。",
62
65
  pickTarget: "请先在下拉框选择要移入的分组",
@@ -108,6 +111,8 @@ window.__ModuleLoader__.load({
108
111
  restoreToBtn: "恢复到…",
109
112
  restorePickTitle: "把「{title}」恢复到…",
110
113
  restoreDone: "✓ 已恢复到「{ws}」",
114
+ trashRestoredArchived: "✓ 已从回收站还原至「{ws}」,仍为已归档状态;请在“已归档会话”中点“恢复”,返回侧栏。",
115
+ restoreRefreshFailed: "还原已完成,但会话列表刷新失败。请彻底退出并重启应用;无需重复还原。",
111
116
  ownerGoneBadge: "原分组已删除",
112
117
  emptyWsCaption: "空分组",
113
118
  emptyWsHelp: "这些分组名下一个会话都没有(归档的、名单里的幽灵记录都算成员,绝不会误报)。删除只移除分组登记,不影响任何会话。",
@@ -141,7 +146,7 @@ window.__ModuleLoader__.load({
141
146
  backupOrphanCleanup: "清理孤儿备份",
142
147
  backupOrphanConfirm: "删除 {n} 组孤儿备份({size})?这些会话已不存在,删除后无法从备份恢复。",
143
148
  backupOrphanPartial: "✓ 已删除 {n} 份,{f} 组失败(可重试)",
144
- residentRefuse: "该会话当前没有焦点也可能仍驻留在 Harness 内存中(归档只隐藏,不会卸载)。DSH 0.1.5 没有公开的按 ID 卸载接口,请重启 Harness 释放常驻会话后再删除。",
149
+ residentRefuse: "该会话仍驻留内存,但插件未捕获到它的生命周期句柄,无法安全释放。请彻底退出并重启 Harness 后重试删除。归档只会隐藏会话,不会释放句柄。",
145
150
  sessionBusy: "该会话正在处理中(可能有迁移 / 还原 / 删除操作尚未完成),请稍后再试。",
146
151
  contextStale: "插件上下文已失效(客户端热重载导致)。请刷新页面(或重启应用)后重试,功能会自动恢复。",
147
152
  recoveryNote: "检测到 {n} 条自动回滚也失败的记录,需人工确认:会话文件与备份均保留在原处,未丢失。请勿手动清理 pluginData 目录,查看 recovery.json 或联系开发者处理。",
@@ -191,7 +196,9 @@ window.__ModuleLoader__.load({
191
196
  previewBackupHelp: "迁移前保存恢复快照,用于失败回滚和备份恢复;相同会话内容会复用已有快照,主档案仍只移动一份。",
192
197
  previewSameWorkspace: "会话已在目标工作区,无需移动。",
193
198
  previewSelfHeal: "档案已在目标工作区,将补齐归属记录。",
194
- previewRunning: "会话正在运行,将排队等待空闲。",
199
+ previewRunning: "会话正在运行。确认后将停止当前任务,保存记录并迁移;取消则继续当前任务。",
200
+ stopAndMove: "停止任务并迁移",
201
+ lifecycleUnavailable: "此会话尚未接入即时迁移,请彻底重启 DSH 后重试。",
195
202
  previewNoPhysicalPath: "当前 DSH 未提供原始档案路径,无法迁移档案目录。",
196
203
  previewSourceMissing: "来源会话档案缺失或无法读取。",
197
204
  previewCollision: "目标工作区已有同 ID 档案,已阻止覆盖。",
@@ -203,7 +210,7 @@ window.__ModuleLoader__.load({
203
210
  forkHint: "默认只迁移当前会话;选择后代分支会逐项迁移并独立记录结果。",
204
211
  previewWarning: "提示",
205
212
  previewSafe: "可安全移动",
206
- previewQueued: "会进入迁移等待队列(可能需重启)",
213
+ previewQueued: "无法立即迁移,请重启后重试",
207
214
  previewBlocked: "当前不可移动",
208
215
  previewWarningState: "需要确认",
209
216
  taskRetryBtn: "重试失败项",
@@ -224,7 +231,8 @@ window.__ModuleLoader__.load({
224
231
  move: "Move",
225
232
  cancel: "Cancel",
226
233
  moving: "Moving…",
227
- done: "✓ Moved to \"{title}\"; refresh the page if the sidebar does not update",
234
+ done: "✓ Moved to \"{title}\"; fully quit and restart the app if the sidebar does not update",
235
+ reopenFailed: "The migration operation has finished, but the conversation could not reconnect. Fully quit and restart the app; do not repeat the move.",
228
236
  rolledBack: "Rolled back automatically: {msg}",
229
237
  failed: "Move failed: {msg}",
230
238
  noTarget: "Cannot resolve target workspace (sidebar layout changed?)",
@@ -256,7 +264,9 @@ window.__ModuleLoader__.load({
256
264
  layoutUnknown: "Unknown storage layout",
257
265
  corruptData: "Archive data is damaged or cannot be parsed",
258
266
  ghostsTitle: "Listed in groups but missing on disk",
259
- ghostsHelp: "These sessions are still recorded in group rosters, but their files are gone from disk (usually deleted by hand). Harmless in general; a harness restart normally cleans them up.",
267
+ ghostFound: "Archive found elsewhere in the current storage root",
268
+ ghostUnknown: "Archive location unknown (not proof of deletion)",
269
+ ghostsHelp: "Archives are missing at the locations recorded by these groups; external tools may have moved them. This does not prove deletion, and restarting does not guarantee cleanup. Ownership records are preserved without automatic removal.",
260
270
  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.",
261
271
  helpUnreg: "The session file is fine but no workspace claims it yet. Click to file it into the matching group; files are not moved.",
262
272
  pickTarget: "Pick a target group in the dropdown first",
@@ -308,6 +318,8 @@ window.__ModuleLoader__.load({
308
318
  restoreToBtn: "Restore to…",
309
319
  restorePickTitle: "Restore \"{title}\" to…",
310
320
  restoreDone: "✓ Restored to \"{ws}\"",
321
+ trashRestoredArchived: "✓ Recovered from the recycle bin to \"{ws}\", still archived. Click Restore under Archived sessions to return it to the sidebar.",
322
+ restoreRefreshFailed: "Restoration completed, but the session list could not refresh. Fully quit and restart the app; do not repeat the restoration.",
311
323
  ownerGoneBadge: "Original group deleted",
312
324
  emptyWsCaption: "Empty groups",
313
325
  emptyWsHelp: "These groups hold no sessions at all (archived ones and ghost roster entries count as members, so this never misreports). Deleting only removes the group registration; no session is touched.",
@@ -341,7 +353,7 @@ window.__ModuleLoader__.load({
341
353
  backupOrphanCleanup: "Clean orphan backups",
342
354
  backupOrphanConfirm: "Delete {n} orphan backup group(s) ({size})? Those sessions no longer exist, so they cannot be restored from these backups afterwards.",
343
355
  backupOrphanPartial: "✓ Deleted {n}; {f} group(s) failed (retry available)",
344
- 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.",
356
+ residentRefuse: "This session is resident, but the plugin did not capture its lifecycle handle and cannot safely release it. Fully quit and restart Harness, then retry deletion. Archiving only hides a session; it does not release its handle.",
345
357
  sessionBusy: "This session is busy: a move / restore / delete is still in flight. Try again in a moment.",
346
358
  contextStale: "The plugin context went stale (client-side hot reload). Refresh the page (or restart the app) and the features will work again.",
347
359
  recoveryNote: "{n} record(s) need manual recovery: automatic rollback also failed. Session files and backups are all kept in place — nothing was lost. Do not clean the pluginData directory by hand; inspect recovery.json or contact the developer.",
@@ -391,7 +403,9 @@ window.__ModuleLoader__.load({
391
403
  previewBackupHelp: "A recovery snapshot is saved before migration for rollback and restoration. Identical session content reuses an existing snapshot while the primary archive still moves only once.",
392
404
  previewSameWorkspace: "The session is already in the target workspace; no move is needed.",
393
405
  previewSelfHeal: "The archive is already in the target workspace; its membership will be repaired.",
394
- previewRunning: "The session is running and will wait until idle.",
406
+ previewRunning: "The session is running. Confirm to stop the task, save its history and move; cancel to keep it running.",
407
+ stopAndMove: "Stop task and move",
408
+ lifecycleUnavailable: "Immediate migration is unavailable for this session. Fully restart DSH and retry.",
395
409
  previewNoPhysicalPath: "This DSH build exposes no raw archive path; archive directory migration is unavailable.",
396
410
  previewSourceMissing: "The source session archive is missing or unreadable.",
397
411
  previewCollision: "The target already contains an archive with this ID; overwriting is blocked.",
@@ -403,7 +417,7 @@ window.__ModuleLoader__.load({
403
417
  forkHint: "Only the current session moves by default. Descendants are migrated independently and reported one by one.",
404
418
  previewWarning: "Note",
405
419
  previewSafe: "Safe to move",
406
- previewQueued: "Will enter migration queue (restart may be required)",
420
+ previewQueued: "Immediate migration unavailable; restart and retry",
407
421
  previewBlocked: "Cannot move now",
408
422
  previewWarningState: "Review before moving",
409
423
  taskRetryBtn: "Retry failed",
@@ -417,7 +431,22 @@ window.__ModuleLoader__.load({
417
431
  dataCleanedMsg: "✓ Purged {r} recycle entries and {b} backups, freed {size}"
418
432
  }
419
433
  };
420
- const lang = (navigator.language || "en").toLowerCase().startsWith("zh") ? "zh" : "en";
434
+ // —— 语言:优先跟随 DSH 全局语言设置(官方 locale 服务),
435
+ // 宿主没有 locale 服务(旧版本 / 测试环境)时退回浏览器语言。 ——
436
+ // 命名空间在官方 locale 注册表里唯一标识本插件的词条。
437
+ const LOCALE_NS = "workspace-mover";
438
+ // 浏览器语言只作兜底。不要在模块装载时求值:宿主语言可能晚于插件就绪,
439
+ // 每次回退重新读 navigator,也便于测试覆盖不同浏览器语言。
440
+ const browserLang = () => {
441
+ const tag = String((typeof navigator !== "undefined" && navigator?.language) || "en").toLowerCase();
442
+ return tag.startsWith("zh") ? "zh" : "en";
443
+ };
444
+ // 宿主 locale 服务注册后绑定 ctx.locale.bind(LOCALE_NS):
445
+ // LocaleRuntime 的 bind 每次调用都按「当前」语言解析,切换语言无需重载插件;
446
+ // 未注册的语言(含未来的外部语言包)按官方 fallback 链落到 en。
447
+ let boundT = null;
448
+ const localeListeners = new Set();
449
+ const notifyLocale = () => { for (const listener of localeListeners) listener(); };
421
450
  // 词条漂移防护:新增文案只补一种语言时,缺失的那一侧会静默回退成 key
422
451
  //(界面上直接显示 idUnresolved 这样的标识符)。启动时对账一次并告警。
423
452
  (() => {
@@ -431,10 +460,33 @@ window.__ModuleLoader__.load({
431
460
  }
432
461
  })();
433
462
  const t = (key, vars) => {
434
- let s = STRINGS[lang][key] ?? key;
463
+ if (boundT) {
464
+ try { return boundT(key, vars); } catch { boundT = null; /* locale 服务已卸载:退回浏览器语言 */ }
465
+ }
466
+ let s = STRINGS[browserLang()][key] ?? key;
435
467
  for (const [k, v] of Object.entries(vars ?? {})) s = s.replaceAll(`{${k}}`, String(v));
436
468
  return s;
437
469
  };
470
+ /**
471
+ * 把本插件的 zh/en 词条注册进宿主 locale 命名空间,并绑定官方 t。
472
+ * 注册失败(同名命名空间已被上一实例占用等)不抛错:能 bind 就继续用它的词条,
473
+ * 否则保持浏览器语言回退——语言能力缺失绝不能影响迁移主流程。
474
+ * @param {any} locale 宿主 locale 服务(ctx.locale / ctx.get('locale'))
475
+ * @returns {(() => void)|null} 注销注册的函数(若有)
476
+ */
477
+ function attachHostLocale(locale) {
478
+ if (!locale || typeof locale.register !== "function" || typeof locale.bind !== "function") return null;
479
+ let dispose = null;
480
+ try {
481
+ dispose = locale.register(LOCALE_NS, { zh: STRINGS.zh, en: STRINGS.en });
482
+ } catch { /* 命名空间已被占用:沿用已注册词条 */ }
483
+ try {
484
+ const candidate = locale.bind(LOCALE_NS);
485
+ // 只有词条真的能解析时才绑定;否则保持浏览器回退,避免界面露出 key。
486
+ boundT = candidate("confirmTitle") === "confirmTitle" ? null : candidate;
487
+ } catch { boundT = null; }
488
+ return typeof dispose === "function" ? dispose : null;
489
+ }
438
490
  // 桌面版热重载后旧 fiber 上的服务访问会抛 cordis 的 "inactive context"——
439
491
  // 换成可操作的提示文案,而不是让用户面对内部术语。
440
492
  const errMsg = (err) => {
@@ -450,6 +502,7 @@ window.__ModuleLoader__.load({
450
502
  'provider does not expose enough public persistence APIs': 'adapterUnavailable',
451
503
  'physical move uses the JSONL path fallback; official raw-byte path API is unavailable': 'adapterFilesystemFallback',
452
504
  'session is currently running and will wait for idle': 'previewRunning',
505
+ 'session is running; moving requires explicit stop confirmation': 'previewRunning',
453
506
  'provider exposes public handles but no physical raw-byte path; this version cannot relocate the archive directory': 'previewNoPhysicalPath',
454
507
  'source session archive is missing or unreadable': 'previewSourceMissing',
455
508
  'target workspace already contains an archive with this session id': 'previewCollision',
@@ -774,6 +827,11 @@ window.__ModuleLoader__.load({
774
827
  warnings.length ? `${t("previewWarning")}: ${warnings.join("; ")}` : ""
775
828
  ].filter(Boolean).join("\n");
776
829
  okBtn.disabled = preview.status === "blocked";
830
+ okBtn.textContent = preview.running ? t('stopAndMove') : (batchCount ? t('batchMove') : t('move'));
831
+ if (preview.status === 'queued' && !preview.liveMigration) {
832
+ okBtn.disabled = true;
833
+ previewBox.textContent += `\n${t('lifecycleUnavailable')}`;
834
+ }
777
835
  }
778
836
  renderPreview(preview);
779
837
  async function refreshPreview() {
@@ -801,7 +859,7 @@ window.__ModuleLoader__.load({
801
859
  warn.textContent = t("idUnresolvedPartial", { n: unresolvedCount });
802
860
  hint.after(warn);
803
861
  }
804
- okBtn.textContent = batchCount ? t("batchMove") : t("move");
862
+ okBtn.textContent = preview?.running ? t('stopAndMove') : (batchCount ? t("batchMove") : t("move"));
805
863
  document.addEventListener("keydown", onKey);
806
864
  function onKey(e) { if (e.key === "Escape") close(null); }
807
865
  function close(value) {
@@ -812,7 +870,7 @@ window.__ModuleLoader__.load({
812
870
  }
813
871
  overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
814
872
  okBtn.addEventListener("click", () => {
815
- if (!okBtn.disabled) close({ mode: forkMode.value || "current-only", targetWorkspaceId: selectedWorkspace.workspaceId });
873
+ if (!okBtn.disabled) close({ mode: forkMode.value || "current-only", targetWorkspaceId: selectedWorkspace.workspaceId, stopRunning: preview?.running === true });
816
874
  });
817
875
  openDialog(overlay, close);
818
876
  });
@@ -936,6 +994,12 @@ window.__ModuleLoader__.load({
936
994
  }
937
995
 
938
996
  function RescuePanel({ rpcCall, ctx }) {
997
+ const [, setLocaleRevision] = useState(0);
998
+ useEffect(() => {
999
+ const update = () => setLocaleRevision((value) => value + 1);
1000
+ localeListeners.add(update);
1001
+ return () => localeListeners.delete(update);
1002
+ }, []);
939
1003
  const [scan, setScan] = useState(null);
940
1004
  const [workspaces, setWorkspaces] = useState([]);
941
1005
  const [audit, setAudit] = useState(null);
@@ -1256,7 +1320,7 @@ window.__ModuleLoader__.load({
1256
1320
  try {
1257
1321
  const res = await call("mover.trash.restore", { sessionId: item.sessionId });
1258
1322
  const wsTitle = workspaces.find((w) => w.workspaceId === res?.workspaceId)?.title ?? res?.workspaceId ?? "";
1259
- setNote(t("restoreDone", { ws: wsTitle }));
1323
+ setNote(t(res?.archived ? "trashRestoredArchived" : "restoreDone", { ws: wsTitle }));
1260
1324
  if (res?.workspaceId) scheduleRecencyFix(ctx, res.workspaceId, [item.sessionId]);
1261
1325
  refreshWorkspaces();
1262
1326
  await runScan();
@@ -1275,7 +1339,7 @@ window.__ModuleLoader__.load({
1275
1339
  try {
1276
1340
  const res2 = await call("mover.trash.restore", { sessionId: item.sessionId, targetWorkspaceId: targetId });
1277
1341
  const wsTitle = workspaces.find((w) => w.workspaceId === res2?.workspaceId)?.title ?? res2?.workspaceId ?? "";
1278
- setNote(t("restoreDone", { ws: wsTitle }));
1342
+ setNote(t(res2?.archived ? "trashRestoredArchived" : "restoreDone", { ws: wsTitle }));
1279
1343
  if (res2?.workspaceId) scheduleRecencyFix(ctx, res2.workspaceId, [item.sessionId]);
1280
1344
  refreshWorkspaces();
1281
1345
  await runScan();
@@ -1295,7 +1359,7 @@ window.__ModuleLoader__.load({
1295
1359
  try {
1296
1360
  const res = await call("mover.trash.restore", { sessionId: item.sessionId, targetWorkspaceId: targetId });
1297
1361
  const wsTitle = workspaces.find((w) => w.workspaceId === res?.workspaceId)?.title ?? res?.workspaceId ?? "";
1298
- setNote(t("restoreDone", { ws: wsTitle }));
1362
+ setNote(t(res?.archived ? "trashRestoredArchived" : "restoreDone", { ws: wsTitle }));
1299
1363
  if (res?.workspaceId) scheduleRecencyFix(ctx, res.workspaceId, [item.sessionId]);
1300
1364
  refreshWorkspaces();
1301
1365
  await runScan();
@@ -1825,7 +1889,8 @@ window.__ModuleLoader__.load({
1825
1889
  h("div", { className: "wsm-list" },
1826
1890
  scan.ghosts.slice(0, 20).map((g) => h("div", { className: "wsm-item", key: `${g.workspaceId}/${g.sessionId}` },
1827
1891
  h("span", { className: "wsm-badge" }, g.workspaceId.slice(0, 8)),
1828
- h("span", { className: "wsm-mono" }, shortId(g.sessionId))
1892
+ h("span", { className: "wsm-mono" }, shortId(g.sessionId)),
1893
+ h("span", { title: (g.locations ?? []).join("\n") }, t(g.status === "found-elsewhere" ? "ghostFound" : "ghostUnknown"))
1829
1894
  ))
1830
1895
  )
1831
1896
  ) : null,
@@ -1856,6 +1921,34 @@ window.__ModuleLoader__.load({
1856
1921
  function apply(ctx) {
1857
1922
  if (applied) return;
1858
1923
  applied = true;
1924
+ let active = true;
1925
+ const viewLifetime = new AbortController();
1926
+ // React releases its Provider reference after clearMain publishes absence,
1927
+ // not during clearMain itself. Reopening synchronously retains the removed
1928
+ // generation again. Wait for the Controller to retire that exact binding.
1929
+ const waitForViewRelease = (sessions, id, previous) => {
1930
+ const source = sessions.retainInfo?.(id);
1931
+ if (typeof source?.subscribe !== 'function') throw new Error('Session retention observation is unavailable');
1932
+ return new Promise((resolve, reject) => {
1933
+ let unsubscribe;
1934
+ let settled = false;
1935
+ const finish = (error) => {
1936
+ if (settled) return;
1937
+ settled = true;
1938
+ clearTimeout(timer);
1939
+ unsubscribe?.();
1940
+ viewLifetime.signal.removeEventListener('abort', aborted);
1941
+ error ? reject(error) : resolve();
1942
+ };
1943
+ const check = () => { if (sessions.binding(id) !== previous) finish(); };
1944
+ const aborted = () => finish(new Error('Plugin unloaded'));
1945
+ const timer = setTimeout(() => finish(new Error('Previous conversation is still retained')), 3000);
1946
+ unsubscribe = source.subscribe(check);
1947
+ viewLifetime.signal.addEventListener('abort', aborted, { once: true });
1948
+ if (viewLifetime.signal.aborted) aborted();
1949
+ else check();
1950
+ });
1951
+ };
1859
1952
  // 在 apply(上下文必然活跃)时立即捕获 connection 服务实例本身,而不是持有 ctx
1860
1953
  // 做惰性访问:桌面版会在热重载 / 客户端重组时废弃本插件的旧 fiber,此后
1861
1954
  // ctx.connection 会抛 "cannot get required service 'connection' in inactive context"
@@ -1863,12 +1956,67 @@ window.__ModuleLoader__.load({
1863
1956
  // 插件(dsh-client-connection)持有,不受本插件 fiber 存亡影响。
1864
1957
  let connection = null;
1865
1958
  try { connection = ctx.connection; } catch { /* 上下文未就绪:rpcCall 内兜底重取 */ }
1866
- const rpcCall = (endpoint, payload) => {
1959
+ const rpcCall = async (endpoint, payload) => {
1867
1960
  if (!connection) {
1868
1961
  try { connection = ctx.connection; } catch { /* 仍然失活:走下面的友好报错 */ }
1869
1962
  }
1870
1963
  if (!connection) throw new Error(t("contextStale"));
1871
- return connection.rpc.call(CHANNEL, endpoint, payload ?? {});
1964
+ const deleting = endpoint === 'mover.session.delete';
1965
+ const migrating = endpoint === 'mover.move' || endpoint === 'mover.moveMany';
1966
+ let ui, currentId;
1967
+ try {
1968
+ ui = migrating || deleting ? ctx.get?.('uiWorkspace') : null;
1969
+ currentId = ui?.selection?.getSnapshot?.()?.sessionId;
1970
+ } catch { /* Optional view service may already be unloading. */ }
1971
+ let response;
1972
+ try {
1973
+ response = await connection.rpc.call(CHANNEL, endpoint, payload ?? {});
1974
+ if (response?.ok && ['mover.trash.restore', 'mover.backups.restore', 'mover.unarchive'].includes(endpoint)) {
1975
+ // Restoring files does not emit session/created. Workspace refresh
1976
+ // alone cannot repopulate the separate client Session baseline.
1977
+ try {
1978
+ const sessions = ctx.get?.('sessions');
1979
+ if (typeof sessions?.refresh === 'function') await sessions.refresh();
1980
+ } catch (error) {
1981
+ console.warn('[workspace-mover] restored session list refresh failed:', error);
1982
+ if (active) toast(t('restoreRefreshFailed'), true);
1983
+ }
1984
+ }
1985
+ if (deleting && response?.ok && String(payload?.sessionId) === String(currentId)
1986
+ && ui?.selection?.getSnapshot?.()?.sessionId === currentId) {
1987
+ ui.clearMain?.();
1988
+ currentId = null;
1989
+ }
1990
+ return response;
1991
+ } finally {
1992
+ // Official dispose emits api-session/removed. The client keeps that
1993
+ // flag permanently on its old Session, even after the host resumes it.
1994
+ // Release/reopen only the still-selected removed view via host APIs.
1995
+ // Also repair rollback failures; never steal a later user selection.
1996
+ if (active && currentId) {
1997
+ try {
1998
+ const sessions = ctx.get?.('sessions');
1999
+ const previous = sessions?.binding?.(currentId);
2000
+ if (ui.selection.getSnapshot()?.sessionId === currentId
2001
+ && previous?.session?.getSnapshot?.()?.removed) {
2002
+ if (typeof ui.clearMain !== 'function' || typeof ui.openSession !== 'function') throw new Error('Session reopening is unavailable');
2003
+ ui.clearMain();
2004
+ await waitForViewRelease(sessions, currentId, previous);
2005
+ // Selection can change while React is releasing the old view.
2006
+ if (active && !ui.selection.getSnapshot()?.sessionId) {
2007
+ ui.openSession(currentId);
2008
+ const session = sessions.binding(currentId)?.session;
2009
+ await session?.open?.();
2010
+ const snapshot = session?.getSnapshot?.();
2011
+ if (!session || snapshot?.removed || snapshot?.openError) throw new Error('Session reopening failed');
2012
+ }
2013
+ }
2014
+ } catch (error) {
2015
+ console.debug('[workspace-mover] conversation reconnect failed:', error?.message ?? error);
2016
+ if (active) toast(t('reopenFailed'), true);
2017
+ }
2018
+ }
2019
+ }
1872
2020
  };
1873
2021
  let dragging = null; // {el, els, id, target} —— id/target 在 drop 阶段解析
1874
2022
  let wsCache = null; // {items, at}
@@ -1923,6 +2071,48 @@ window.__ModuleLoader__.load({
1923
2071
  // 两份闭包还会互相擦掉对方的多选高亮。
1924
2072
  const disposers = [];
1925
2073
  disposers.push(stopAutoscroll);
2074
+
2075
+ // —— 跟随 DSH 全局语言设置(官方 locale 服务) ——
2076
+ // 同步读取已就绪的服务让首次渲染就用对语言;再用 cordis 注入兜住两种时序:
2077
+ // locale 服务晚于本插件注册,或宿主热重载后服务被替换。两条路径共用同一绑定。
2078
+ // 旧宿主没有 locale 服务时保持浏览器语言回退,插件仍完整可用。
2079
+ let localeBound = false;
2080
+ let localeDispose = null;
2081
+ let localeUnsubscribe = null;
2082
+ const releaseHostLocale = () => {
2083
+ localeBound = false;
2084
+ boundT = null;
2085
+ localeUnsubscribe?.();
2086
+ localeUnsubscribe = null;
2087
+ if (typeof localeDispose === "function") {
2088
+ try { localeDispose(); } catch { /* 宿主 locale 已卸载 */ }
2089
+ }
2090
+ localeDispose = null;
2091
+ notifyLocale();
2092
+ };
2093
+ const useHostLocale = (locale) => {
2094
+ if (localeBound) return;
2095
+ if (typeof locale?.register !== "function" || typeof locale?.bind !== "function") return;
2096
+ localeBound = true;
2097
+ localeDispose = attachHostLocale(locale);
2098
+ if (typeof locale.subscribe === "function") localeUnsubscribe = locale.subscribe(notifyLocale);
2099
+ notifyLocale();
2100
+ };
2101
+ let readyLocale = null;
2102
+ try { readyLocale = ctx.get?.("locale") ?? null; } catch { readyLocale = null; }
2103
+ if (readyLocale) useHostLocale(readyLocale);
2104
+ if (typeof ctx.inject === "function") {
2105
+ try {
2106
+ ctx.inject(["locale"], (child) => {
2107
+ let locale = null;
2108
+ try { locale = child.get?.("locale") ?? null; } catch { locale = null; }
2109
+ if (locale) useHostLocale(locale);
2110
+ child.effect(() => releaseHostLocale, "workspace-mover: host locale");
2111
+ });
2112
+ } catch { /* 该宿主没有 locale 服务:保留浏览器语言回退 */ }
2113
+ }
2114
+ disposers.push(releaseHostLocale);
2115
+
1926
2116
  const listen = (type, fn, options) => {
1927
2117
  document.addEventListener(type, fn, options);
1928
2118
  disposers.push(() => document.removeEventListener(type, fn, options));
@@ -2370,7 +2560,7 @@ window.__ModuleLoader__.load({
2370
2560
  if (!choice) return;
2371
2561
  workspace = workspaceItems.find((item) => item.workspaceId === choice.targetWorkspaceId);
2372
2562
  try {
2373
- const res = await rpcCall("mover.move", { sessionId, sessionTitle, targetWorkspaceId: workspace.workspaceId, mode: choice.mode });
2563
+ const res = await rpcCall("mover.move", { sessionId, sessionTitle, targetWorkspaceId: workspace.workspaceId, mode: choice.mode, stopRunning: choice.stopRunning, allowQueue: false });
2374
2564
  if (res?.ok) {
2375
2565
  wsCache = null;
2376
2566
  scanCache = null;
@@ -2382,7 +2572,7 @@ window.__ModuleLoader__.load({
2382
2572
  } else {
2383
2573
  const msg = res?.error?.message ?? "unknown";
2384
2574
  if (res?.error?.code === "busy") {
2385
- toast(t("queuedMove"));
2575
+ toast(res.error?.details?.queueTaskId ? t("queuedMove") : t('failed', { msg }), true);
2386
2576
  return;
2387
2577
  }
2388
2578
  const rolled = res?.error?.code === "rollback-failed" || /roll/i.test(msg);
@@ -2407,6 +2597,7 @@ window.__ModuleLoader__.load({
2407
2597
  const warnings = plans.flatMap((item) => item.warnings ?? []).filter(Boolean);
2408
2598
  preview = {
2409
2599
  ...plans[0],
2600
+ running: plans.some((item) => item.running),
2410
2601
  status: blocked === plans.length && plans.length > 0 ? "blocked" : (blocked > 0 || queued > 0 ? "warning" : "safe"),
2411
2602
  source: { title: t("scannedN", { n: sessions.length }) },
2412
2603
  estimatedBackupBytes: plans.reduce((sum, item) => sum + Number(item.estimatedBackupBytes || 0), 0),
@@ -2418,7 +2609,7 @@ window.__ModuleLoader__.load({
2418
2609
  const choice = await confirmMove({ workspace, batchCount: sessions.length, unresolvedCount, preview });
2419
2610
  if (!choice) return;
2420
2611
  try {
2421
- const res = await rpcCall("mover.moveMany", { sessions, targetWorkspaceId: workspace.workspaceId, mode: choice.mode });
2612
+ const res = await rpcCall("mover.moveMany", { sessions, targetWorkspaceId: workspace.workspaceId, mode: choice.mode, stopRunning: choice.stopRunning, allowQueue: false });
2422
2613
  if (!res?.ok) {
2423
2614
  const msg = res?.error?.message ?? "unknown";
2424
2615
  const rolled = res?.error?.code === "rollback-failed" || /roll/i.test(msg);
@@ -2584,6 +2775,8 @@ window.__ModuleLoader__.load({
2584
2775
  // dragging / 缓存永远不会被回收。
2585
2776
  if (typeof ctx.effect === "function") {
2586
2777
  ctx.effect(() => () => {
2778
+ active = false;
2779
+ viewLifetime.abort();
2587
2780
  applied = false;
2588
2781
  for (const dispose of disposers.splice(0)) {
2589
2782
  try { dispose(); } catch { /* 单个失败不影响其余 */ }