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/CHANGELOG.md +345 -317
- package/README.md +389 -373
- package/README_EN.md +376 -360
- package/client/client.js +216 -23
- package/docs/ARCHITECTURE.md +224 -224
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-13.md +45 -45
- package/docs/compatibility/immediate-migration-2026-10-26.md +68 -0
- package/docs/compatibility/issue-11-pr-12-review-2026-11-02.md +103 -0
- package/docs/compatibility/issue-5-desktop-acceptance-2026-10-22.md +106 -106
- package/docs/compatibility/pr-review-2026-10-22.md +56 -56
- package/lib/index.js +90 -29
- package/lib/lifecycle.js +104 -0
- package/package.json +104 -103
- package/test/client-dom.test.mjs +194 -4
- package/test/core.test.mjs +73 -73
- package/test/e2e-sandbox.test.mjs +82 -2
- package/test/live-migration.test.mjs +253 -0
- package/test/release-downloads.test.mjs +102 -102
- package/test/traffic-badge.test.mjs +80 -80
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
|
-
|
|
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: "
|
|
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}\";
|
|
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
|
-
|
|
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
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 { /* 单个失败不影响其余 */ }
|