dsh-workspace-mover 2.2.0 → 2.3.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/CHANGELOG.md +285 -0
- package/README.md +40 -8
- package/README_EN.md +346 -0
- package/client/client.js +376 -37
- package/docs/ARCHITECTURE.md +17 -9
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-04.md +39 -0
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-13.md +45 -0
- package/lib/index.js +1465 -141
- package/package.json +102 -99
- package/test/client-dom.test.mjs +129 -0
- package/test/e2e-sandbox.test.mjs +417 -10
package/client/client.js
CHANGED
|
@@ -20,7 +20,7 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
confirmTitle: "跨工作区移动会话",
|
|
21
21
|
target: "目标工作区",
|
|
22
22
|
session: "会话",
|
|
23
|
-
hint: "
|
|
23
|
+
hint: "将物理搬移原始会话档案并改写归属,历史记录原样保留。",
|
|
24
24
|
move: "移动",
|
|
25
25
|
cancel: "取消",
|
|
26
26
|
moving: "正在移动…",
|
|
@@ -52,6 +52,9 @@ window.__ModuleLoader__.load({
|
|
|
52
52
|
stalePath: "路径失效",
|
|
53
53
|
ungroupedBadge: "未归组",
|
|
54
54
|
unreadable: "无法读取",
|
|
55
|
+
unsupportedFormat: "当前格式不受支持",
|
|
56
|
+
layoutUnknown: "存储布局未知",
|
|
57
|
+
corruptData: "档案数据损坏或无法解析",
|
|
55
58
|
ghostsTitle: "名单里有、但文件已不在的记录",
|
|
56
59
|
ghostsHelp: "侧边栏的分组名单里记着这些会话,但对应的记录文件已经不在磁盘上(通常是被手动删除的)。一般不影响使用;重启 Harness 后通常会自动清理。",
|
|
57
60
|
helpPath: "会话原来的文件夹被移动、改名或删除,所以侧边栏看不到它。选择一个分组,历史会原样迁移过去,会话 ID 保持不变。",
|
|
@@ -127,8 +130,8 @@ window.__ModuleLoader__.load({
|
|
|
127
130
|
purgeBackupsToo: "这个会话还有 {n} 份迁移备份。一并删除吗?\n\n(会话已不存在,这些备份不会再被用到;保留则可在备份列表里单独恢复或清理。)",
|
|
128
131
|
purgeBackupsDone: "(含 {n} 份备份)",
|
|
129
132
|
backupsCaption: "备份",
|
|
130
|
-
backupsHelp: "
|
|
131
|
-
backupMeta: "{n}
|
|
133
|
+
backupsHelp: "这里列出迁移恢复快照。相同会话内容只保留一份物理快照,但每次迁移仍会记录历史;可恢复到原位置或任意其他分组。",
|
|
134
|
+
backupMeta: "{n} 个快照 · {size} · {range}",
|
|
132
135
|
backupDeleteBtn: "删除备份",
|
|
133
136
|
backupDeleteConfirm: "删除「{title}」的 {n} 份备份?删除后无法再从备份恢复。",
|
|
134
137
|
backupDeletedMsg: "✓ 已删除 {n} 份备份",
|
|
@@ -156,8 +159,13 @@ window.__ModuleLoader__.load({
|
|
|
156
159
|
doctorDataBackups: "备份目录",
|
|
157
160
|
doctorDataHistory: "移动历史",
|
|
158
161
|
doctorDataTasks: "任务记录",
|
|
162
|
+
doctorDataMigrations: "迁移队列",
|
|
159
163
|
doctorRecovery: "恢复记录",
|
|
160
164
|
doctorDataLayout: "存储布局",
|
|
165
|
+
doctorStorageAdapter: "存储适配器",
|
|
166
|
+
adapterFilesystemFallback: "当前 DSH 未提供原始档案路径接口,使用文件路径适配器;存储布局自检通过后可迁移。",
|
|
167
|
+
adapterOfficialHandle: "官方会话接口可用,但未提供原始档案路径,当前无法迁移档案目录。",
|
|
168
|
+
adapterUnavailable: "当前持久化服务缺少可用接口。",
|
|
161
169
|
doctorWorkspaces: "工作区路径",
|
|
162
170
|
repairAllBtn: "一键修复",
|
|
163
171
|
repairAllDone: "✓ 已修复 {n} · 跳过 {s} · 失败 {f}",
|
|
@@ -165,6 +173,39 @@ window.__ModuleLoader__.load({
|
|
|
165
173
|
filterPh: "筛选:标题 / 会话 ID / 路径…",
|
|
166
174
|
tasksCaption: "迁移任务",
|
|
167
175
|
tasksHelp: "批量迁移的持久化记录:完成 / 失败逐项可查。失败项可一键重试——按会话当前位置重新迁移,不会用记录里的旧路径;清除记录不影响已迁移的会话。",
|
|
176
|
+
queueCaption: "等待迁移",
|
|
177
|
+
queueHelp: "运行中的会话不会触碰文件;会话空闲后自动尝试,失败项可以重试或取消。",
|
|
178
|
+
queueRetryBtn: "重试",
|
|
179
|
+
queueRetryAllBtn: "重试全部失败项",
|
|
180
|
+
queueCancelBtn: "取消",
|
|
181
|
+
queueRetryDone: "✓ 已重新尝试迁移",
|
|
182
|
+
queueRetryAllDone: "✓ 已逐项重试 {n} 个失败任务",
|
|
183
|
+
queueCancelled: "✓ 已取消迁移任务",
|
|
184
|
+
queuedMove: "已排队:会话空闲后自动迁移",
|
|
185
|
+
batchQueuedTail: ";{n} 个会话已排队等待空闲",
|
|
186
|
+
previewStatus: "预览状态",
|
|
187
|
+
previewSource: "来源",
|
|
188
|
+
previewTarget: "目标路径",
|
|
189
|
+
previewBackup: "本次新增备份",
|
|
190
|
+
previewBackupReused: "复用已有快照,不新增磁盘占用",
|
|
191
|
+
previewBackupHelp: "迁移前保存恢复快照,用于失败回滚和备份恢复;相同会话内容会复用已有快照,主档案仍只移动一份。",
|
|
192
|
+
previewSameWorkspace: "会话已在目标工作区,无需移动。",
|
|
193
|
+
previewSelfHeal: "档案已在目标工作区,将补齐归属记录。",
|
|
194
|
+
previewRunning: "会话正在运行,将排队等待空闲。",
|
|
195
|
+
previewNoPhysicalPath: "当前 DSH 未提供原始档案路径,无法迁移档案目录。",
|
|
196
|
+
previewSourceMissing: "来源会话档案缺失或无法读取。",
|
|
197
|
+
previewCollision: "目标工作区已有同 ID 档案,已阻止覆盖。",
|
|
198
|
+
previewGenerations: "发现多个格式代际,选择 v{n} 并保留旧文件。",
|
|
199
|
+
previewDescendants: "已选择 {n} 个后代会话。",
|
|
200
|
+
forkMode: "分支范围",
|
|
201
|
+
forkCurrentOnly: "仅当前会话",
|
|
202
|
+
forkIncludeDescendants: "包含所有后代分支",
|
|
203
|
+
forkHint: "默认只迁移当前会话;选择后代分支会逐项迁移并独立记录结果。",
|
|
204
|
+
previewWarning: "提示",
|
|
205
|
+
previewSafe: "可安全移动",
|
|
206
|
+
previewQueued: "会进入空闲队列",
|
|
207
|
+
previewBlocked: "当前不可移动",
|
|
208
|
+
previewWarningState: "需要确认",
|
|
168
209
|
taskRetryBtn: "重试失败项",
|
|
169
210
|
taskRetryDone: "✓ 重试完成:成功 {n} · 失败 {f}",
|
|
170
211
|
taskForgetBtn: "清除记录",
|
|
@@ -179,7 +220,7 @@ window.__ModuleLoader__.load({
|
|
|
179
220
|
confirmTitle: "Move session across workspaces",
|
|
180
221
|
target: "Target workspace",
|
|
181
222
|
session: "Session",
|
|
182
|
-
hint: "Physically relocates the original session archive; history stays intact
|
|
223
|
+
hint: "Physically relocates the original session archive and updates its workspace; history stays intact.",
|
|
183
224
|
move: "Move",
|
|
184
225
|
cancel: "Cancel",
|
|
185
226
|
moving: "Moving…",
|
|
@@ -211,6 +252,9 @@ window.__ModuleLoader__.load({
|
|
|
211
252
|
stalePath: "Stale path",
|
|
212
253
|
ungroupedBadge: "Ungrouped",
|
|
213
254
|
unreadable: "Unreadable",
|
|
255
|
+
unsupportedFormat: "Unsupported format",
|
|
256
|
+
layoutUnknown: "Unknown storage layout",
|
|
257
|
+
corruptData: "Archive data is damaged or cannot be parsed",
|
|
214
258
|
ghostsTitle: "Listed in groups but missing on disk",
|
|
215
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.",
|
|
216
260
|
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.",
|
|
@@ -286,8 +330,8 @@ window.__ModuleLoader__.load({
|
|
|
286
330
|
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
331
|
purgeBackupsDone: " (including {n} backup copy(ies))",
|
|
288
332
|
backupsCaption: "Backups",
|
|
289
|
-
backupsHelp: "
|
|
290
|
-
backupMeta: "{n}
|
|
333
|
+
backupsHelp: "Migration recovery snapshots are listed here. Identical session content keeps one physical snapshot while every move remains in history; restore to the original location or any other group.",
|
|
334
|
+
backupMeta: "{n} snapshots · {size} · {range}",
|
|
291
335
|
backupDeleteBtn: "Delete backups",
|
|
292
336
|
backupDeleteConfirm: "Delete the {n} backup copies of \"{title}\"? They cannot be restored from afterwards.",
|
|
293
337
|
backupDeletedMsg: "✓ Deleted {n} backup copy(ies)",
|
|
@@ -315,8 +359,13 @@ window.__ModuleLoader__.load({
|
|
|
315
359
|
doctorDataBackups: "Backups",
|
|
316
360
|
doctorDataHistory: "Move history",
|
|
317
361
|
doctorDataTasks: "Task records",
|
|
362
|
+
doctorDataMigrations: "Migration queue",
|
|
318
363
|
doctorRecovery: "Recovery records",
|
|
319
364
|
doctorDataLayout: "Storage layout",
|
|
365
|
+
doctorStorageAdapter: "Storage adapter",
|
|
366
|
+
adapterFilesystemFallback: "This DSH build has no public raw archive path API. The filesystem adapter is active; migration is available when the storage layout check passes.",
|
|
367
|
+
adapterOfficialHandle: "Official session APIs are available without a raw archive path; archive directory migration is unavailable.",
|
|
368
|
+
adapterUnavailable: "The persistence provider does not expose enough usable APIs.",
|
|
320
369
|
doctorWorkspaces: "Workspace paths",
|
|
321
370
|
repairAllBtn: "Fix all",
|
|
322
371
|
repairAllDone: "✓ Fixed {n} · skipped {s} · failed {f}",
|
|
@@ -324,6 +373,39 @@ window.__ModuleLoader__.load({
|
|
|
324
373
|
filterPh: "Filter: title / session id / path…",
|
|
325
374
|
tasksCaption: "Move tasks",
|
|
326
375
|
tasksHelp: "Persistent records of bulk moves: every done / failed item is inspectable. Failed items can be retried in one click — retried from where each session currently lives, never from a stale recorded path; clearing a record never touches moved sessions.",
|
|
376
|
+
queueCaption: "Waiting migrations",
|
|
377
|
+
queueHelp: "Running sessions are never touched. The migration is retried automatically after the session becomes idle; failed items can be retried or cancelled.",
|
|
378
|
+
queueRetryBtn: "Retry",
|
|
379
|
+
queueRetryAllBtn: "Retry all failed",
|
|
380
|
+
queueCancelBtn: "Cancel",
|
|
381
|
+
queueRetryDone: "✓ Migration retried",
|
|
382
|
+
queueRetryAllDone: "✓ Retried {n} failed migration(s) independently",
|
|
383
|
+
queueCancelled: "✓ Migration cancelled",
|
|
384
|
+
queuedMove: "Queued: it will move automatically when the session is idle",
|
|
385
|
+
batchQueuedTail: "; {n} session(s) queued until idle",
|
|
386
|
+
previewStatus: "Preview status",
|
|
387
|
+
previewSource: "Source",
|
|
388
|
+
previewTarget: "Target path",
|
|
389
|
+
previewBackup: "New backup bytes",
|
|
390
|
+
previewBackupReused: "Reusing an existing snapshot; no new disk space",
|
|
391
|
+
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
|
+
previewSameWorkspace: "The session is already in the target workspace; no move is needed.",
|
|
393
|
+
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.",
|
|
395
|
+
previewNoPhysicalPath: "This DSH build exposes no raw archive path; archive directory migration is unavailable.",
|
|
396
|
+
previewSourceMissing: "The source session archive is missing or unreadable.",
|
|
397
|
+
previewCollision: "The target already contains an archive with this ID; overwriting is blocked.",
|
|
398
|
+
previewGenerations: "Multiple format generations found; v{n} is selected and older files are preserved.",
|
|
399
|
+
previewDescendants: "{n} descendant sessions selected.",
|
|
400
|
+
forkMode: "Fork scope",
|
|
401
|
+
forkCurrentOnly: "Current session only",
|
|
402
|
+
forkIncludeDescendants: "Include descendants",
|
|
403
|
+
forkHint: "Only the current session moves by default. Descendants are migrated independently and reported one by one.",
|
|
404
|
+
previewWarning: "Note",
|
|
405
|
+
previewSafe: "Safe to move",
|
|
406
|
+
previewQueued: "Will wait until idle",
|
|
407
|
+
previewBlocked: "Cannot move now",
|
|
408
|
+
previewWarningState: "Review before moving",
|
|
327
409
|
taskRetryBtn: "Retry failed",
|
|
328
410
|
taskRetryDone: "✓ Retry finished: {n} succeeded · {f} failed",
|
|
329
411
|
taskForgetBtn: "Clear record",
|
|
@@ -359,6 +441,28 @@ window.__ModuleLoader__.load({
|
|
|
359
441
|
const raw = String(err?.message ?? err);
|
|
360
442
|
return /inactive context/.test(raw) ? t("contextStale") : raw;
|
|
361
443
|
};
|
|
444
|
+
const diagnosticText = (raw) => {
|
|
445
|
+
const text = String(raw ?? "");
|
|
446
|
+
const keys = {
|
|
447
|
+
'official session handles are available only for metadata; physical JSONL path adapter is active': 'adapterFilesystemFallback',
|
|
448
|
+
'public raw-byte path API is unavailable; physical JSONL path adapter is active': 'adapterFilesystemFallback',
|
|
449
|
+
'provider exposes public list/stat/open APIs but no public raw-byte path; physical moves are unavailable': 'adapterOfficialHandle',
|
|
450
|
+
'provider does not expose enough public persistence APIs': 'adapterUnavailable',
|
|
451
|
+
'physical move uses the JSONL path fallback; official raw-byte path API is unavailable': 'adapterFilesystemFallback',
|
|
452
|
+
'session is currently running and will wait for idle': 'previewRunning',
|
|
453
|
+
'provider exposes public handles but no physical raw-byte path; this version cannot relocate the archive directory': 'previewNoPhysicalPath',
|
|
454
|
+
'source session archive is missing or unreadable': 'previewSourceMissing',
|
|
455
|
+
'target workspace already contains an archive with this session id': 'previewCollision',
|
|
456
|
+
'session already belongs to the target workspace': 'previewSameWorkspace',
|
|
457
|
+
'session archive is already at target; workspace membership will be repaired': 'previewSelfHeal'
|
|
458
|
+
};
|
|
459
|
+
if (keys[text]) return t(keys[text]);
|
|
460
|
+
const generations = /^multiple generations found; v(\d+) is selected and older files are preserved$/.exec(text);
|
|
461
|
+
if (generations) return t('previewGenerations', { n: generations[1] });
|
|
462
|
+
const descendants = /^(\d+) descendant session\(s\) selected$/.exec(text);
|
|
463
|
+
if (descendants) return t('previewDescendants', { n: descendants[1] });
|
|
464
|
+
return text;
|
|
465
|
+
};
|
|
362
466
|
|
|
363
467
|
function ensureStyle() {
|
|
364
468
|
if (document.getElementById(STYLE_ID)) return;
|
|
@@ -382,6 +486,7 @@ window.__ModuleLoader__.load({
|
|
|
382
486
|
.wsm-row{font-size:13px;line-height:20px;margin:6px 0;word-break:break-all}
|
|
383
487
|
.wsm-label{color:var(--dsw-alias-label-secondary,#777)}
|
|
384
488
|
.wsm-hint{font-size:12px;color:var(--dsw-alias-label-tertiary,#999);margin-top:10px;line-height:17px}
|
|
489
|
+
.wsm-preview{font-size:12px;line-height:18px;white-space:pre-wrap;word-break:break-word;margin-top:10px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px;background:var(--dsw-alias-bg-layer-1,#fafafa);color:var(--dsw-alias-label-secondary,#666)}
|
|
385
490
|
.wsm-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
|
386
491
|
.wsm-btn{appearance:none;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.15));background:transparent;color:var(--dsw-alias-label-primary,#111);
|
|
387
492
|
border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer}
|
|
@@ -558,7 +663,9 @@ window.__ModuleLoader__.load({
|
|
|
558
663
|
<div class="wsm-title" id="${uid}"></div>
|
|
559
664
|
<div class="wsm-row"><span class="wsm-label"></span> <b class="wsm-session"></b></div>
|
|
560
665
|
<div class="wsm-row"><span class="wsm-label"></span> <b class="wsm-target"></b></div>
|
|
666
|
+
<div class="wsm-row wsm-fork-row" hidden><span class="wsm-label"></span> <select class="wsm-select wsm-fork-mode"></select></div>
|
|
561
667
|
<div class="wsm-hint"></div>
|
|
668
|
+
<div class="wsm-preview" hidden></div>
|
|
562
669
|
<div class="wsm-actions">
|
|
563
670
|
<button class="wsm-btn wsm-cancel"></button>
|
|
564
671
|
<button class="wsm-btn primary wsm-ok"></button>
|
|
@@ -594,17 +701,96 @@ window.__ModuleLoader__.load({
|
|
|
594
701
|
});
|
|
595
702
|
}
|
|
596
703
|
|
|
597
|
-
function
|
|
704
|
+
function formatPreviewBytes(value) {
|
|
705
|
+
const n = Number(value);
|
|
706
|
+
if (!Number.isFinite(n) || n <= 0) return "0 B";
|
|
707
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
708
|
+
const at = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
|
|
709
|
+
return `${(n / (1024 ** at)).toFixed(at === 0 ? 0 : 1)} ${units[at]}`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function confirmMove({ sessionId, sessionTitle, workspace, workspaces, loadPreview, batchCount, unresolvedCount, preview }) {
|
|
598
713
|
return new Promise((resolve) => {
|
|
599
714
|
const overlay = injectOverlay();
|
|
600
715
|
const okBtn = overlay.querySelector(".wsm-ok");
|
|
716
|
+
let selectedWorkspace = workspace;
|
|
717
|
+
let previewRequest = 0;
|
|
718
|
+
let closed = false;
|
|
601
719
|
overlay.querySelector(".wsm-title").textContent = batchCount ? t("batchConfirmTitle") : t("confirmTitle");
|
|
602
720
|
overlay.querySelector(".wsm-session").textContent = batchCount
|
|
603
721
|
? t("scannedN", { n: batchCount })
|
|
604
722
|
: (sessionTitle || t("unnamedSession"));
|
|
605
723
|
overlay.querySelector(".wsm-target").textContent = `${workspace.title}(${workspace.path})`;
|
|
724
|
+
let targetSelect = null;
|
|
725
|
+
if (Array.isArray(workspaces) && typeof loadPreview === 'function') {
|
|
726
|
+
targetSelect = document.createElement('select');
|
|
727
|
+
targetSelect.className = 'wsm-select wsm-target-select';
|
|
728
|
+
targetSelect.setAttribute('aria-label', t('target'));
|
|
729
|
+
for (const item of workspaces) {
|
|
730
|
+
const option = document.createElement('option');
|
|
731
|
+
option.value = item.workspaceId;
|
|
732
|
+
option.textContent = `${item.title} (${item.path})`;
|
|
733
|
+
targetSelect.append(option);
|
|
734
|
+
}
|
|
735
|
+
targetSelect.value = workspace.workspaceId;
|
|
736
|
+
overlay.querySelector('.wsm-target').textContent = '';
|
|
737
|
+
overlay.querySelector('.wsm-target').append(targetSelect);
|
|
738
|
+
}
|
|
739
|
+
const forkRow = overlay.querySelector(".wsm-fork-row");
|
|
740
|
+
const forkMode = overlay.querySelector(".wsm-fork-mode");
|
|
741
|
+
forkRow.hidden = false;
|
|
742
|
+
forkRow.querySelector(".wsm-label").textContent = t("forkMode") + ":";
|
|
743
|
+
for (const [label, value] of [[t("forkCurrentOnly"), "current-only"], [t("forkIncludeDescendants"), "include-descendants"]]) {
|
|
744
|
+
const option = document.createElement("option");
|
|
745
|
+
option.value = value;
|
|
746
|
+
option.textContent = label;
|
|
747
|
+
forkMode.append(option);
|
|
748
|
+
}
|
|
749
|
+
forkMode.value = "current-only";
|
|
606
750
|
const hint = overlay.querySelector(".wsm-hint");
|
|
607
|
-
hint.textContent = batchCount ? t("batchHint", { count: batchCount }) : t("hint")
|
|
751
|
+
hint.textContent = `${batchCount ? t("batchHint", { count: batchCount }) : t("hint")} ${t("previewBackupHelp")} ${t("forkHint")}`;
|
|
752
|
+
function renderPreview(plan) {
|
|
753
|
+
preview = plan;
|
|
754
|
+
if (!preview) return;
|
|
755
|
+
const statusLabels = {
|
|
756
|
+
safe: t("previewSafe"),
|
|
757
|
+
queued: t("previewQueued"),
|
|
758
|
+
blocked: t("previewBlocked"),
|
|
759
|
+
warning: t("previewWarningState")
|
|
760
|
+
};
|
|
761
|
+
const source = preview.source?.title || preview.source?.cwd || "—";
|
|
762
|
+
const target = preview.target?.path || selectedWorkspace.path || "—";
|
|
763
|
+
const warnings = Array.isArray(preview.warnings) ? preview.warnings.filter(Boolean).map(diagnosticText) : [];
|
|
764
|
+
const previewBox = overlay.querySelector(".wsm-preview");
|
|
765
|
+
previewBox.hidden = false;
|
|
766
|
+
previewBox.textContent = [
|
|
767
|
+
`${t("previewStatus")}: ${statusLabels[preview.status] || preview.status || "—"}`,
|
|
768
|
+
`${t("previewSource")}: ${source}`,
|
|
769
|
+
`${t("previewTarget")}: ${target}`,
|
|
770
|
+
`${t("previewBackup")}: ${formatPreviewBytes(preview.estimatedBackupBytes)}${preview.backup?.reused ? ` (${t("previewBackupReused")})` : ""}`,
|
|
771
|
+
warnings.length ? `${t("previewWarning")}: ${warnings.join("; ")}` : ""
|
|
772
|
+
].filter(Boolean).join("\n");
|
|
773
|
+
okBtn.disabled = preview.status === "blocked";
|
|
774
|
+
}
|
|
775
|
+
renderPreview(preview);
|
|
776
|
+
async function refreshPreview() {
|
|
777
|
+
const request = ++previewRequest;
|
|
778
|
+
selectedWorkspace = workspaces.find((item) => item.workspaceId === targetSelect.value);
|
|
779
|
+
okBtn.disabled = true;
|
|
780
|
+
const box = overlay.querySelector('.wsm-preview');
|
|
781
|
+
box.hidden = false;
|
|
782
|
+
box.textContent = t('doctorChecking');
|
|
783
|
+
try {
|
|
784
|
+
const plan = await loadPreview(selectedWorkspace.workspaceId, forkMode.value || 'current-only');
|
|
785
|
+
if (!closed && request === previewRequest) renderPreview(plan);
|
|
786
|
+
} catch (err) {
|
|
787
|
+
if (!closed && request === previewRequest) box.textContent = t('failed', { msg: errMsg(err) });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (targetSelect) {
|
|
791
|
+
targetSelect.addEventListener('change', () => void refreshPreview());
|
|
792
|
+
forkMode.addEventListener('change', () => void refreshPreview());
|
|
793
|
+
}
|
|
608
794
|
// 有行无法识别时必须如实说明——用户点"确认"之前要知道这次只搬走了一部分。
|
|
609
795
|
if (unresolvedCount > 0) {
|
|
610
796
|
const warn = document.createElement("div");
|
|
@@ -616,12 +802,15 @@ window.__ModuleLoader__.load({
|
|
|
616
802
|
document.addEventListener("keydown", onKey);
|
|
617
803
|
function onKey(e) { if (e.key === "Escape") close(null); }
|
|
618
804
|
function close(value) {
|
|
805
|
+
closed = true;
|
|
619
806
|
overlay.remove();
|
|
620
807
|
document.removeEventListener("keydown", onKey);
|
|
621
808
|
resolve(value);
|
|
622
809
|
}
|
|
623
810
|
overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
|
|
624
|
-
okBtn.addEventListener("click", () =>
|
|
811
|
+
okBtn.addEventListener("click", () => {
|
|
812
|
+
if (!okBtn.disabled) close({ mode: forkMode.value || "current-only", targetWorkspaceId: selectedWorkspace.workspaceId });
|
|
813
|
+
});
|
|
625
814
|
openDialog(overlay, close);
|
|
626
815
|
});
|
|
627
816
|
}
|
|
@@ -749,6 +938,7 @@ window.__ModuleLoader__.load({
|
|
|
749
938
|
const [trash, setTrash] = useState(null);
|
|
750
939
|
const [backups, setBackups] = useState(null);
|
|
751
940
|
const [tasks, setTasks] = useState(null);
|
|
941
|
+
const [migrations, setMigrations] = useState(null);
|
|
752
942
|
const [doctor, setDoctor] = useState(null);
|
|
753
943
|
const [query, setQuery] = useState("");
|
|
754
944
|
|
|
@@ -770,7 +960,7 @@ window.__ModuleLoader__.load({
|
|
|
770
960
|
const runScan = async () => {
|
|
771
961
|
setBusy(true);
|
|
772
962
|
try {
|
|
773
|
-
const [s, w, h, a, ar, tr, bp, tk] = await Promise.all([call("mover.scan"), call("mover.workspaces"), call("mover.history"), call("mover.ws.audit"), call("mover.archived"), call("mover.trash.list"), call("mover.backups.list"), call("mover.tasks.list")]);
|
|
963
|
+
const [s, w, h, a, ar, tr, bp, tk, mq] = await Promise.all([call("mover.scan"), call("mover.workspaces"), call("mover.history"), call("mover.ws.audit"), call("mover.archived"), call("mover.trash.list"), call("mover.backups.list"), call("mover.tasks.list"), call("mover.migrations.list")]);
|
|
774
964
|
setScan(s);
|
|
775
965
|
setWorkspaces(w.items ?? []);
|
|
776
966
|
setHistory(h.items ?? []);
|
|
@@ -779,6 +969,7 @@ window.__ModuleLoader__.load({
|
|
|
779
969
|
setTrash(tr);
|
|
780
970
|
setBackups(bp);
|
|
781
971
|
setTasks(tk);
|
|
972
|
+
setMigrations(mq);
|
|
782
973
|
} catch (err) {
|
|
783
974
|
setNote(t("failed", { msg: errMsg(err) }));
|
|
784
975
|
} finally {
|
|
@@ -1280,6 +1471,50 @@ window.__ModuleLoader__.load({
|
|
|
1280
1471
|
}
|
|
1281
1472
|
};
|
|
1282
1473
|
|
|
1474
|
+
const migrationRetry = async (task) => {
|
|
1475
|
+
setBusy(true);
|
|
1476
|
+
setNote("");
|
|
1477
|
+
try {
|
|
1478
|
+
await call("mover.migrations.retry", { taskId: task.id });
|
|
1479
|
+
setNote(t("queueRetryDone"));
|
|
1480
|
+
refreshWorkspaces();
|
|
1481
|
+
await runScan();
|
|
1482
|
+
} catch (err) {
|
|
1483
|
+
failNote(err);
|
|
1484
|
+
} finally {
|
|
1485
|
+
setBusy(false);
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
const migrationRetryAll = async (taskIds) => {
|
|
1490
|
+
setBusy(true);
|
|
1491
|
+
setNote("");
|
|
1492
|
+
try {
|
|
1493
|
+
const result = await call("mover.migrations.retry", { taskIds });
|
|
1494
|
+
setNote(t("queueRetryAllDone", { n: result.retried ?? 0 }));
|
|
1495
|
+
refreshWorkspaces();
|
|
1496
|
+
await runScan();
|
|
1497
|
+
} catch (err) {
|
|
1498
|
+
failNote(err);
|
|
1499
|
+
} finally {
|
|
1500
|
+
setBusy(false);
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
|
|
1504
|
+
const migrationCancel = async (task) => {
|
|
1505
|
+
setBusy(true);
|
|
1506
|
+
setNote("");
|
|
1507
|
+
try {
|
|
1508
|
+
await call("mover.migrations.cancel", { taskId: task.id });
|
|
1509
|
+
setNote(t("queueCancelled"));
|
|
1510
|
+
await runScan();
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
failNote(err);
|
|
1513
|
+
} finally {
|
|
1514
|
+
setBusy(false);
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
|
|
1283
1518
|
// 数据保护:按时间清理回收站与备份(dryRun 先出数字,确认后才真删)
|
|
1284
1519
|
const dataCleanup = async () => {
|
|
1285
1520
|
setBusy(true);
|
|
@@ -1308,6 +1543,7 @@ window.__ModuleLoader__.load({
|
|
|
1308
1543
|
const orphaned = items.filter((it) => it.status === "orphaned");
|
|
1309
1544
|
const unregistered = items.filter((it) => it.status === "unregistered");
|
|
1310
1545
|
const misfiled = items.filter((it) => it.status === "misfiled");
|
|
1546
|
+
const unreadable = items.filter((it) => it.status === "unreadable");
|
|
1311
1547
|
const brokenWorkspaces = (audit?.items ?? []).filter((it) => it.status !== "ok");
|
|
1312
1548
|
const emptyWorkspaces = workspaces.filter((w) => (w.rawSessionCount ?? 0) === 0);
|
|
1313
1549
|
const archivedItems = archived?.items ?? [];
|
|
@@ -1328,10 +1564,14 @@ window.__ModuleLoader__.load({
|
|
|
1328
1564
|
const orphanedV = orphaned.filter(matchesQuery);
|
|
1329
1565
|
const unregisteredV = unregistered.filter(matchesQuery);
|
|
1330
1566
|
const misfiledV = misfiled.filter(matchesQuery);
|
|
1567
|
+
const unreadableV = unreadable.filter(matchesQuery);
|
|
1331
1568
|
const archivedV = archivedItems.filter(matchesQuery);
|
|
1332
1569
|
const trashV = trashItems.filter(matchesQuery);
|
|
1333
1570
|
const backupV = backupItems.filter(matchesQuery);
|
|
1334
1571
|
const taskV = taskItems.filter((it) => matchesQuery({ ...it, title: null, cwd: it.targetPath }));
|
|
1572
|
+
const migrationItems = migrations?.items ?? [];
|
|
1573
|
+
const migrationV = migrationItems.filter((it) => !["done", "cancelled"].includes(it.state)).filter((it) => matchesQuery({ ...it, cwd: it.targetWorkspaceId }));
|
|
1574
|
+
const failedMigrationIds = migrationV.filter((it) => it.state === "failed").map((it) => it.id);
|
|
1335
1575
|
const capCount = (shown, total) => (q ? `${shown}/${total}` : `${total}`);
|
|
1336
1576
|
const counts = scan?.counts ?? {};
|
|
1337
1577
|
const summaryParts = [
|
|
@@ -1363,7 +1603,7 @@ window.__ModuleLoader__.load({
|
|
|
1363
1603
|
doctor.checks.map((c) => h("div", { className: "wsm-item", key: c.id },
|
|
1364
1604
|
h("span", { className: "wsm-badge" + (c.state === "fail" ? " err" : c.state === "warn" ? " warn" : "") },
|
|
1365
1605
|
c.state === "pass" ? "✓" : c.state === "warn" ? "⚠" : "✗"),
|
|
1366
|
-
h("span", { className: "wsm-cwd", title: c.detail }, t(`doctor${c.id.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("")}`) + (c.detail ? ` — ${c.detail}` : ""))
|
|
1606
|
+
h("span", { className: "wsm-cwd", title: diagnosticText(c.detail), style: { whiteSpace: "normal", overflow: "visible", overflowWrap: "anywhere" } }, t(`doctor${c.id.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("")}`) + (c.detail ? ` — ${diagnosticText(c.detail)}` : ""))
|
|
1367
1607
|
))
|
|
1368
1608
|
) : null,
|
|
1369
1609
|
brokenWorkspaces.length > 0 ? h(Caption, { text: t("wsSection"), help: t("wsHelp") }) : null,
|
|
@@ -1436,6 +1676,22 @@ window.__ModuleLoader__.load({
|
|
|
1436
1676
|
);
|
|
1437
1677
|
})
|
|
1438
1678
|
) : null,
|
|
1679
|
+
unreadable.length > 0 ? h(Caption, { text: `${t("unreadable")} (${capCount(unreadableV.length, unreadable.length)})` }) : null,
|
|
1680
|
+
unreadableV.length > 0 ? h("div", { className: "wsm-list" },
|
|
1681
|
+
unreadableV.map((it) => {
|
|
1682
|
+
const labels = {
|
|
1683
|
+
unsupported: t("unsupportedFormat"),
|
|
1684
|
+
"layout-unknown": t("layoutUnknown"),
|
|
1685
|
+
"corrupt-data": t("corruptData")
|
|
1686
|
+
};
|
|
1687
|
+
return h("div", { className: "wsm-item", key: it.sessionId },
|
|
1688
|
+
h("span", { className: "wsm-badge err" }, t("unreadable")),
|
|
1689
|
+
h("span", { className: "wsm-mono" }, it.sessionId),
|
|
1690
|
+
h("span", { className: "wsm-cwd", title: it.diagnosticDetail ?? "" },
|
|
1691
|
+
`${labels[it.diagnostic] ?? t("layoutUnknown")}${it.diagnosticDetail ? ` — ${it.diagnosticDetail}` : ""}`)
|
|
1692
|
+
);
|
|
1693
|
+
})
|
|
1694
|
+
) : null,
|
|
1439
1695
|
archivedItems.length > 0 ? h(Caption, { text: `${t("archivedCaption")} (${capCount(archivedV.length, archivedItems.length)})`, help: t("archivedHelp") }) : null,
|
|
1440
1696
|
// 扫描只解析最新 400 条档案,而归档会话通常很旧:大库上这里可能不是全部。
|
|
1441
1697
|
archived?.truncated && archivedV.length > 0
|
|
@@ -1532,6 +1788,22 @@ window.__ModuleLoader__.load({
|
|
|
1532
1788
|
h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void taskForget(it) }, t("taskForgetBtn"))
|
|
1533
1789
|
))
|
|
1534
1790
|
) : null,
|
|
1791
|
+
migrationV.length > 0 ? h(Caption, { text: `${t("queueCaption")} (${capCount(migrationV.length, migrationItems.filter((it) => !["done", "cancelled"].includes(it.state)).length)})`, help: t("queueHelp") }) : null,
|
|
1792
|
+
failedMigrationIds.length > 1 ? h("div", { className: "wsm-scanrow" },
|
|
1793
|
+
h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void migrationRetryAll(failedMigrationIds) }, t("queueRetryAllBtn"))) : null,
|
|
1794
|
+
migrationV.length > 0 ? h("div", { className: "wsm-list" },
|
|
1795
|
+
migrationV.map((it) => h("div", { className: "wsm-item", key: it.id },
|
|
1796
|
+
h("span", { className: "wsm-mono", title: it.sessionId }, it.title || shortId(it.sessionId)),
|
|
1797
|
+
h("span", { className: `wsm-badge ${it.state === "failed" ? "err" : "warn"}` }, it.state),
|
|
1798
|
+
h("span", { className: "wsm-cwd", title: it.lastError ?? "" }, it.lastError || `${t("previewTarget")}: ${it.targetWorkspaceId}`),
|
|
1799
|
+
!["done", "cancelled", "retrying"].includes(it.state)
|
|
1800
|
+
? h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void migrationRetry(it) }, t("queueRetryBtn"))
|
|
1801
|
+
: null,
|
|
1802
|
+
!["done", "cancelled", "retrying"].includes(it.state)
|
|
1803
|
+
? h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void migrationCancel(it) }, t("queueCancelBtn"))
|
|
1804
|
+
: null
|
|
1805
|
+
))
|
|
1806
|
+
) : null,
|
|
1535
1807
|
scan && (scan.recoveryCount ?? 0) > 0
|
|
1536
1808
|
? h("div", { className: "wsm-note", style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: "10px" } }, t("recoveryNote", { n: scan.recoveryCount }))
|
|
1537
1809
|
: null,
|
|
@@ -1608,8 +1880,8 @@ window.__ModuleLoader__.load({
|
|
|
1608
1880
|
console.warn("[workspace-mover] status probe failed (will retry per move):", err);
|
|
1609
1881
|
});
|
|
1610
1882
|
|
|
1611
|
-
async function fetchWorkspaces() {
|
|
1612
|
-
if (wsCache && Date.now() - wsCache.at < 3000) return wsCache.items;
|
|
1883
|
+
async function fetchWorkspaces(fresh = false) {
|
|
1884
|
+
if (!fresh && wsCache && Date.now() - wsCache.at < 3000) return wsCache.items;
|
|
1613
1885
|
const res = await rpcCall("mover.workspaces");
|
|
1614
1886
|
if (!res?.ok) throw new Error(res?.error?.message ?? "workspace list unavailable");
|
|
1615
1887
|
wsCache = { items: res.value.items, at: Date.now() };
|
|
@@ -1692,7 +1964,7 @@ window.__ModuleLoader__.load({
|
|
|
1692
1964
|
*/
|
|
1693
1965
|
function rowSessionId(rowEl) {
|
|
1694
1966
|
try {
|
|
1695
|
-
const fiberKey = Object.
|
|
1967
|
+
const fiberKey = Object.getOwnPropertyNames(rowEl).find((k) => k.startsWith("__reactFiber$"));
|
|
1696
1968
|
if (!fiberKey) return null;
|
|
1697
1969
|
for (let fiber = rowEl[fiberKey]; fiber; fiber = fiber.return) {
|
|
1698
1970
|
const node = fiber.memoizedProps?.node;
|
|
@@ -1836,10 +2108,15 @@ window.__ModuleLoader__.load({
|
|
|
1836
2108
|
}
|
|
1837
2109
|
|
|
1838
2110
|
function headerText(rowEl) {
|
|
2111
|
+
const ownText = (node) => {
|
|
2112
|
+
if (node !== rowEl && node?.getAttribute?.('role') === 'treeitem') return '';
|
|
2113
|
+
if (node?.nodeType === 3) return node.textContent ?? node._text ?? '';
|
|
2114
|
+
return [...(node?.childNodes ?? [])].map(ownText).join(' ');
|
|
2115
|
+
};
|
|
1839
2116
|
return normalizeHeaderText([
|
|
1840
2117
|
rowEl?.getAttribute?.("aria-label"),
|
|
1841
2118
|
rowEl?.getAttribute?.("title"),
|
|
1842
|
-
rowEl
|
|
2119
|
+
ownText(rowEl)
|
|
1843
2120
|
].filter(Boolean).join(" "));
|
|
1844
2121
|
}
|
|
1845
2122
|
|
|
@@ -1870,10 +2147,34 @@ window.__ModuleLoader__.load({
|
|
|
1870
2147
|
*/
|
|
1871
2148
|
function resolveWorkspace(rowEl, items) {
|
|
1872
2149
|
const list = Array.isArray(items) ? items : [];
|
|
1873
|
-
if (!rowEl
|
|
2150
|
+
if (!rowEl) return null;
|
|
2151
|
+
// Read the rendered identity first: React may retain old fiber props after updates.
|
|
2152
|
+
const rowKey = rowEl.getAttribute?.('data-row-key');
|
|
2153
|
+
if (rowKey?.startsWith('workspace:')) {
|
|
2154
|
+
return list.find((item) => `workspace:${item.workspaceId}` === rowKey) ?? null;
|
|
2155
|
+
}
|
|
2156
|
+
if (isUngroupedHeader(rowEl)) return null;
|
|
2157
|
+
// DSH 0.2 ProjectRowItem carries the actual workspace ID in group props.
|
|
2158
|
+
// Prefer identity over visible text, which can contain nested session titles.
|
|
2159
|
+
try {
|
|
2160
|
+
const fiberKey = Object.getOwnPropertyNames(rowEl).find((key) => key.startsWith('__reactFiber$'));
|
|
2161
|
+
for (let fiber = fiberKey ? rowEl[fiberKey] : null; fiber; fiber = fiber.return) {
|
|
2162
|
+
const group = fiber.memoizedProps?.group;
|
|
2163
|
+
if (group && Object.hasOwn(group, 'workspaceId')) {
|
|
2164
|
+
return list.find((item) => item.workspaceId === group.workspaceId) ?? null;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
} catch { /* Older clients can use unique text evidence below. */ }
|
|
1874
2168
|
const text = headerText(rowEl);
|
|
1875
|
-
const byText = list.
|
|
1876
|
-
if (byText) return byText;
|
|
2169
|
+
const byText = list.filter((item) => headerMatchesWorkspace(text, item));
|
|
2170
|
+
if (byText.length === 1) return byText[0];
|
|
2171
|
+
if (byText.length > 1) {
|
|
2172
|
+
const labels = [rowEl.getAttribute?.('aria-label'), rowEl.getAttribute?.('title'), text]
|
|
2173
|
+
.map((value) => normalizeHeaderText(value).toLocaleLowerCase()).filter(Boolean);
|
|
2174
|
+
const exact = byText.filter((item) => [item.title, item.path].some((value) =>
|
|
2175
|
+
value && labels.includes(normalizeHeaderText(value).toLocaleLowerCase())));
|
|
2176
|
+
return exact.length === 1 ? exact[0] : null;
|
|
2177
|
+
}
|
|
1877
2178
|
const unidentifiable = list.filter((item) => !normalizeHeaderText(item?.title) && !normalizeHeaderText(item?.path));
|
|
1878
2179
|
if (unidentifiable.length === 1) return unidentifiable[0];
|
|
1879
2180
|
return null;
|
|
@@ -1895,7 +2196,7 @@ window.__ModuleLoader__.load({
|
|
|
1895
2196
|
clearHints();
|
|
1896
2197
|
if (!dragging) return;
|
|
1897
2198
|
const header = headerRow(e.target);
|
|
1898
|
-
if (!header || isUngroupedHeader(header)) return;
|
|
2199
|
+
if (!header || isUngroupedHeader(header)) { dragging.target = null; return; }
|
|
1899
2200
|
e.preventDefault(); // 允许在此投放(没有它 drop 根本不会触发)
|
|
1900
2201
|
header.classList.add("wsm-drop-hint");
|
|
1901
2202
|
if (!dragging.target || dragging.target.header !== header) {
|
|
@@ -1913,7 +2214,7 @@ window.__ModuleLoader__.load({
|
|
|
1913
2214
|
dragging = null;
|
|
1914
2215
|
clearHints();
|
|
1915
2216
|
if (!current) return;
|
|
1916
|
-
const header = headerRow(e.target)
|
|
2217
|
+
const header = headerRow(e.target);
|
|
1917
2218
|
if (!header || isUngroupedHeader(header)) return;
|
|
1918
2219
|
// 只拦截「拖到某个工作区标题行」的场景;其余交还官方逻辑
|
|
1919
2220
|
e.preventDefault();
|
|
@@ -1935,19 +2236,16 @@ window.__ModuleLoader__.load({
|
|
|
1935
2236
|
}
|
|
1936
2237
|
|
|
1937
2238
|
let workspace = null;
|
|
2239
|
+
let workspaceItems = [];
|
|
1938
2240
|
try {
|
|
1939
|
-
let items = await fetchWorkspaces();
|
|
2241
|
+
let items = await fetchWorkspaces(true);
|
|
2242
|
+
workspaceItems = items;
|
|
1940
2243
|
workspace = resolveWorkspace(header, items);
|
|
1941
|
-
if (!workspace && current.target?.workspaceId) {
|
|
1942
|
-
workspace = items.find((item) => String(item.workspaceId) === String(current.target.workspaceId)) ?? null;
|
|
1943
|
-
}
|
|
1944
2244
|
if (!workspace) {
|
|
1945
2245
|
wsCache = null;
|
|
1946
2246
|
items = await fetchWorkspaces();
|
|
2247
|
+
workspaceItems = items;
|
|
1947
2248
|
workspace = resolveWorkspace(header, items);
|
|
1948
|
-
if (!workspace && current.target?.workspaceId) {
|
|
1949
|
-
workspace = items.find((item) => String(item.workspaceId) === String(current.target.workspaceId)) ?? null;
|
|
1950
|
-
}
|
|
1951
2249
|
}
|
|
1952
2250
|
} catch (err) {
|
|
1953
2251
|
return void toast(t("failed", { msg: errMsg(err) }), true);
|
|
@@ -1993,17 +2291,38 @@ window.__ModuleLoader__.load({
|
|
|
1993
2291
|
// ---- 单选:原有确认与 mover.move 路径 ----
|
|
1994
2292
|
if (sessions.length === 1) {
|
|
1995
2293
|
const { sessionId, sessionTitle } = sessions[0];
|
|
1996
|
-
|
|
1997
|
-
if (!confirmed) return;
|
|
2294
|
+
let preview = null;
|
|
1998
2295
|
try {
|
|
1999
|
-
const res = await rpcCall("mover.
|
|
2296
|
+
const res = await rpcCall("mover.preview", { sessionId, targetWorkspaceId: workspace.workspaceId });
|
|
2297
|
+
if (res?.ok) preview = res.value;
|
|
2298
|
+
} catch (err) {
|
|
2299
|
+
console.debug("[workspace-mover] preview unavailable:", err?.message ?? err);
|
|
2300
|
+
}
|
|
2301
|
+
const choice = await confirmMove({ sessionId, sessionTitle, workspace, workspaces: workspaceItems, unresolvedCount, preview,
|
|
2302
|
+
loadPreview: async (targetWorkspaceId, mode) => {
|
|
2303
|
+
const res = await rpcCall('mover.preview', { sessionId, targetWorkspaceId, mode });
|
|
2304
|
+
if (!res?.ok || !res.value?.status) throw new Error(res?.error?.message ?? 'preview unavailable');
|
|
2305
|
+
return res.value;
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
if (!choice) return;
|
|
2309
|
+
workspace = workspaceItems.find((item) => item.workspaceId === choice.targetWorkspaceId);
|
|
2310
|
+
try {
|
|
2311
|
+
const res = await rpcCall("mover.move", { sessionId, sessionTitle, targetWorkspaceId: workspace.workspaceId, mode: choice.mode });
|
|
2000
2312
|
if (res?.ok) {
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2313
|
+
wsCache = null;
|
|
2314
|
+
scanCache = null;
|
|
2315
|
+
const movedIds = (res.value?.results ?? []).filter((item) => item?.ok).map((item) => item.sessionId).filter(Boolean);
|
|
2316
|
+
await reconcileWorkspaceProjection(ctx, workspace.workspaceId, movedIds.length > 0 ? movedIds : [sessionId]);
|
|
2317
|
+
toast(res.value?.queued ? t("queuedMove") : (res.value?.attached ? t("selfHealed") : t("done", { title: workspace.title })));
|
|
2318
|
+
scheduleRecencyFix(ctx, workspace.workspaceId, movedIds.length > 0 ? movedIds : [sessionId]);
|
|
2004
2319
|
if (res.value?.restartHint) setTimeout(() => toast(t("restartHint"), true), 1200);
|
|
2005
2320
|
} else {
|
|
2006
2321
|
const msg = res?.error?.message ?? "unknown";
|
|
2322
|
+
if (res?.error?.code === "busy") {
|
|
2323
|
+
toast(t("queuedMove"));
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
2007
2326
|
const rolled = res?.error?.code === "rollback-failed" || /roll/i.test(msg);
|
|
2008
2327
|
const text = (rolled ? t("rolledBack", { msg }) : t("failed", { msg }));
|
|
2009
2328
|
toast(text, true);
|
|
@@ -2015,21 +2334,41 @@ window.__ModuleLoader__.load({
|
|
|
2015
2334
|
}
|
|
2016
2335
|
|
|
2017
2336
|
// ---- 多选:批量确认 + mover.moveMany(逐条独立备份回滚)----
|
|
2018
|
-
|
|
2019
|
-
if (!confirmed) return;
|
|
2337
|
+
let preview = null;
|
|
2020
2338
|
try {
|
|
2021
|
-
const
|
|
2339
|
+
const plans = (await Promise.all(sessions.map(async (item) => {
|
|
2340
|
+
const res = await rpcCall("mover.preview", { sessionId: item.sessionId, targetWorkspaceId: workspace.workspaceId });
|
|
2341
|
+
return res?.ok ? res.value : null;
|
|
2342
|
+
}))).filter(Boolean);
|
|
2343
|
+
const blocked = plans.filter((item) => item.status === "blocked").length;
|
|
2344
|
+
const queued = plans.filter((item) => item.status === "queued").length;
|
|
2345
|
+
const warnings = plans.flatMap((item) => item.warnings ?? []).filter(Boolean);
|
|
2346
|
+
preview = {
|
|
2347
|
+
...plans[0],
|
|
2348
|
+
status: blocked === plans.length && plans.length > 0 ? "blocked" : (blocked > 0 || queued > 0 ? "warning" : "safe"),
|
|
2349
|
+
source: { title: t("scannedN", { n: sessions.length }) },
|
|
2350
|
+
estimatedBackupBytes: plans.reduce((sum, item) => sum + Number(item.estimatedBackupBytes || 0), 0),
|
|
2351
|
+
warnings: [...new Set([...(blocked ? [`${blocked} blocked`] : []), ...(queued ? [`${queued} queued`] : []), ...warnings])]
|
|
2352
|
+
};
|
|
2353
|
+
} catch (err) {
|
|
2354
|
+
console.debug("[workspace-mover] batch preview unavailable:", err?.message ?? err);
|
|
2355
|
+
}
|
|
2356
|
+
const choice = await confirmMove({ workspace, batchCount: sessions.length, unresolvedCount, preview });
|
|
2357
|
+
if (!choice) return;
|
|
2358
|
+
try {
|
|
2359
|
+
const res = await rpcCall("mover.moveMany", { sessions, targetWorkspaceId: workspace.workspaceId, mode: choice.mode });
|
|
2022
2360
|
if (!res?.ok) {
|
|
2023
2361
|
const msg = res?.error?.message ?? "unknown";
|
|
2024
2362
|
const rolled = res?.error?.code === "rollback-failed" || /roll/i.test(msg);
|
|
2025
2363
|
return void toast((rolled ? t("rolledBack", { msg }) : t("failed", { msg })), true);
|
|
2026
2364
|
}
|
|
2027
|
-
const { movedCount, attachedCount, failedCount } = res.value ?? {};
|
|
2365
|
+
const { movedCount, attachedCount, failedCount, queuedCount } = res.value ?? {};
|
|
2028
2366
|
const okCount = (movedCount ?? 0) + (attachedCount ?? 0);
|
|
2029
2367
|
const movedIds = (res.value?.results ?? []).filter((item) => item?.ok).map((item) => item.sessionId).filter(Boolean);
|
|
2030
2368
|
await reconcileWorkspaceProjection(ctx, workspace.workspaceId, movedIds);
|
|
2031
2369
|
let message = t("batchDone", { n: okCount });
|
|
2032
2370
|
if ((failedCount ?? 0) > 0) message += t("batchFailTail", { n: failedCount });
|
|
2371
|
+
if ((queuedCount ?? 0) > 0) message += t("batchQueuedTail", { n: queuedCount });
|
|
2033
2372
|
toast(message);
|
|
2034
2373
|
clearSelection(true);
|
|
2035
2374
|
scheduleRecencyFix(ctx, workspace.workspaceId, sessions.map((s) => s.sessionId));
|