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/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: "每次迁移前自动生成的字节级备份都在这里。可以把会话恢复回备份时的原位置(若那里没有同 id 会话),或恢复到任意其他分组。",
131
- backupMeta: "{n} 份 · {size} · {range}",
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, no copy is made.",
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: "Every move creates a byte-level backup first; they are all listed here. Restore a session back to where the backup was taken (if no session with the same id lives there), or into any other group.",
290
- backupMeta: "{n} copies · {size} · {range}",
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 confirmMove({ sessionId, sessionTitle, workspace, batchCount, unresolvedCount }) {
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", () => close(true));
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.keys(rowEl).find((k) => k.startsWith("__reactFiber$"));
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?.textContent
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 || isUngroupedHeader(rowEl)) return null;
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.find((item) => headerMatchesWorkspace(text, item));
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) ?? current.target?.header;
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
- const confirmed = await confirmMove({ sessionId, sessionTitle, workspace, unresolvedCount });
1997
- if (!confirmed) return;
2294
+ let preview = null;
1998
2295
  try {
1999
- const res = await rpcCall("mover.move", { sessionId, sessionTitle, targetWorkspaceId: workspace.workspaceId });
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
- await reconcileWorkspaceProjection(ctx, workspace.workspaceId, [sessionId]);
2002
- toast(res.value?.attached ? t("selfHealed") : t("done", { title: workspace.title }));
2003
- scheduleRecencyFix(ctx, workspace.workspaceId, [sessionId]);
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
- const confirmed = await confirmMove({ workspace, batchCount: sessions.length, unresolvedCount });
2019
- if (!confirmed) return;
2337
+ let preview = null;
2020
2338
  try {
2021
- const res = await rpcCall("mover.moveMany", { sessions, targetWorkspaceId: workspace.workspaceId });
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));