dsh-workspace-mover 2.2.0 → 2.4.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: "已排队:等待会话停止运行并释放驻留写入句柄;可能需要重启 DSH",
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 or resident sessions wait. Once generation stops, a resident writer still needs session unloading or a DSH restart. The queue then retries automatically; 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: waiting for idle and writer release; a DSH restart may be required",
385
+ batchQueuedTail: "; {n} session(s) queued for safe migration",
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 enter migration queue (restart may be required)",
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,99 @@ 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
+ if (!batchCount && typeof preview.title === 'string' && preview.title.trim()) {
756
+ overlay.querySelector('.wsm-session').textContent = preview.title;
757
+ }
758
+ const statusLabels = {
759
+ safe: t("previewSafe"),
760
+ queued: t("previewQueued"),
761
+ blocked: t("previewBlocked"),
762
+ warning: t("previewWarningState")
763
+ };
764
+ const source = preview.source?.title || preview.source?.cwd || "—";
765
+ const target = preview.target?.path || selectedWorkspace.path || "—";
766
+ const warnings = Array.isArray(preview.warnings) ? preview.warnings.filter(Boolean).map(diagnosticText) : [];
767
+ const previewBox = overlay.querySelector(".wsm-preview");
768
+ previewBox.hidden = false;
769
+ previewBox.textContent = [
770
+ `${t("previewStatus")}: ${statusLabels[preview.status] || preview.status || "—"}`,
771
+ `${t("previewSource")}: ${source}`,
772
+ `${t("previewTarget")}: ${target}`,
773
+ `${t("previewBackup")}: ${formatPreviewBytes(preview.estimatedBackupBytes)}${preview.backup?.reused ? ` (${t("previewBackupReused")})` : ""}`,
774
+ warnings.length ? `${t("previewWarning")}: ${warnings.join("; ")}` : ""
775
+ ].filter(Boolean).join("\n");
776
+ okBtn.disabled = preview.status === "blocked";
777
+ }
778
+ renderPreview(preview);
779
+ async function refreshPreview() {
780
+ const request = ++previewRequest;
781
+ selectedWorkspace = workspaces.find((item) => item.workspaceId === targetSelect.value);
782
+ okBtn.disabled = true;
783
+ const box = overlay.querySelector('.wsm-preview');
784
+ box.hidden = false;
785
+ box.textContent = t('doctorChecking');
786
+ try {
787
+ const plan = await loadPreview(selectedWorkspace.workspaceId, forkMode.value || 'current-only');
788
+ if (!closed && request === previewRequest) renderPreview(plan);
789
+ } catch (err) {
790
+ if (!closed && request === previewRequest) box.textContent = t('failed', { msg: errMsg(err) });
791
+ }
792
+ }
793
+ if (targetSelect) {
794
+ targetSelect.addEventListener('change', () => void refreshPreview());
795
+ forkMode.addEventListener('change', () => void refreshPreview());
796
+ }
608
797
  // 有行无法识别时必须如实说明——用户点"确认"之前要知道这次只搬走了一部分。
609
798
  if (unresolvedCount > 0) {
610
799
  const warn = document.createElement("div");
@@ -616,12 +805,15 @@ window.__ModuleLoader__.load({
616
805
  document.addEventListener("keydown", onKey);
617
806
  function onKey(e) { if (e.key === "Escape") close(null); }
618
807
  function close(value) {
808
+ closed = true;
619
809
  overlay.remove();
620
810
  document.removeEventListener("keydown", onKey);
621
811
  resolve(value);
622
812
  }
623
813
  overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
624
- okBtn.addEventListener("click", () => close(true));
814
+ okBtn.addEventListener("click", () => {
815
+ if (!okBtn.disabled) close({ mode: forkMode.value || "current-only", targetWorkspaceId: selectedWorkspace.workspaceId });
816
+ });
625
817
  openDialog(overlay, close);
626
818
  });
627
819
  }
@@ -712,8 +904,15 @@ window.__ModuleLoader__.load({
712
904
  }
713
905
 
714
906
  function rowTitle(row, sessionId) {
715
- const text = row?.textContent?.replace(/\s+/g, " ").trim();
716
- return text && text !== sessionId ? text : t("unnamedSession");
907
+ // Whole-row text also contains relative timestamps and status labels.
908
+ try {
909
+ const key = Object.getOwnPropertyNames(row ?? {}).find((name) => name.startsWith('__reactFiber$'));
910
+ for (let fiber = key ? row[key] : null; fiber; fiber = fiber.return) {
911
+ const node = fiber.memoizedProps?.node;
912
+ if (node && String(node.id) === String(sessionId) && typeof node.title === 'string' && node.title.trim()) return node.title.trim();
913
+ }
914
+ } catch { /* authoritative preview can supply the title */ }
915
+ return '';
717
916
  }
718
917
 
719
918
  function fmtBytes(n) {
@@ -749,6 +948,7 @@ window.__ModuleLoader__.load({
749
948
  const [trash, setTrash] = useState(null);
750
949
  const [backups, setBackups] = useState(null);
751
950
  const [tasks, setTasks] = useState(null);
951
+ const [migrations, setMigrations] = useState(null);
752
952
  const [doctor, setDoctor] = useState(null);
753
953
  const [query, setQuery] = useState("");
754
954
 
@@ -770,7 +970,7 @@ window.__ModuleLoader__.load({
770
970
  const runScan = async () => {
771
971
  setBusy(true);
772
972
  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")]);
973
+ 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
974
  setScan(s);
775
975
  setWorkspaces(w.items ?? []);
776
976
  setHistory(h.items ?? []);
@@ -779,6 +979,7 @@ window.__ModuleLoader__.load({
779
979
  setTrash(tr);
780
980
  setBackups(bp);
781
981
  setTasks(tk);
982
+ setMigrations(mq);
782
983
  } catch (err) {
783
984
  setNote(t("failed", { msg: errMsg(err) }));
784
985
  } finally {
@@ -795,7 +996,7 @@ window.__ModuleLoader__.load({
795
996
  try {
796
997
  const value = await call("mover.doctor");
797
998
  setDoctor(value);
798
- setNote(t("doctorSummary", value.summary));
999
+ setNote(`${t("doctorSummary", value.summary)}${value.pluginVersion ? ` · v${value.pluginVersion}` : ""}`);
799
1000
  } catch (err) {
800
1001
  setNote(t("failed", { msg: errMsg(err) }));
801
1002
  } finally {
@@ -1280,6 +1481,50 @@ window.__ModuleLoader__.load({
1280
1481
  }
1281
1482
  };
1282
1483
 
1484
+ const migrationRetry = async (task) => {
1485
+ setBusy(true);
1486
+ setNote("");
1487
+ try {
1488
+ await call("mover.migrations.retry", { taskId: task.id });
1489
+ setNote(t("queueRetryDone"));
1490
+ refreshWorkspaces();
1491
+ await runScan();
1492
+ } catch (err) {
1493
+ failNote(err);
1494
+ } finally {
1495
+ setBusy(false);
1496
+ }
1497
+ };
1498
+
1499
+ const migrationRetryAll = async (taskIds) => {
1500
+ setBusy(true);
1501
+ setNote("");
1502
+ try {
1503
+ const result = await call("mover.migrations.retry", { taskIds });
1504
+ setNote(t("queueRetryAllDone", { n: result.retried ?? 0 }));
1505
+ refreshWorkspaces();
1506
+ await runScan();
1507
+ } catch (err) {
1508
+ failNote(err);
1509
+ } finally {
1510
+ setBusy(false);
1511
+ }
1512
+ };
1513
+
1514
+ const migrationCancel = async (task) => {
1515
+ setBusy(true);
1516
+ setNote("");
1517
+ try {
1518
+ await call("mover.migrations.cancel", { taskId: task.id });
1519
+ setNote(t("queueCancelled"));
1520
+ await runScan();
1521
+ } catch (err) {
1522
+ failNote(err);
1523
+ } finally {
1524
+ setBusy(false);
1525
+ }
1526
+ };
1527
+
1283
1528
  // 数据保护:按时间清理回收站与备份(dryRun 先出数字,确认后才真删)
1284
1529
  const dataCleanup = async () => {
1285
1530
  setBusy(true);
@@ -1308,6 +1553,7 @@ window.__ModuleLoader__.load({
1308
1553
  const orphaned = items.filter((it) => it.status === "orphaned");
1309
1554
  const unregistered = items.filter((it) => it.status === "unregistered");
1310
1555
  const misfiled = items.filter((it) => it.status === "misfiled");
1556
+ const unreadable = items.filter((it) => it.status === "unreadable");
1311
1557
  const brokenWorkspaces = (audit?.items ?? []).filter((it) => it.status !== "ok");
1312
1558
  const emptyWorkspaces = workspaces.filter((w) => (w.rawSessionCount ?? 0) === 0);
1313
1559
  const archivedItems = archived?.items ?? [];
@@ -1328,10 +1574,14 @@ window.__ModuleLoader__.load({
1328
1574
  const orphanedV = orphaned.filter(matchesQuery);
1329
1575
  const unregisteredV = unregistered.filter(matchesQuery);
1330
1576
  const misfiledV = misfiled.filter(matchesQuery);
1577
+ const unreadableV = unreadable.filter(matchesQuery);
1331
1578
  const archivedV = archivedItems.filter(matchesQuery);
1332
1579
  const trashV = trashItems.filter(matchesQuery);
1333
1580
  const backupV = backupItems.filter(matchesQuery);
1334
1581
  const taskV = taskItems.filter((it) => matchesQuery({ ...it, title: null, cwd: it.targetPath }));
1582
+ const migrationItems = migrations?.items ?? [];
1583
+ const migrationV = migrationItems.filter((it) => !["done", "cancelled"].includes(it.state)).filter((it) => matchesQuery({ ...it, cwd: it.targetWorkspaceId }));
1584
+ const failedMigrationIds = migrationV.filter((it) => it.state === "failed").map((it) => it.id);
1335
1585
  const capCount = (shown, total) => (q ? `${shown}/${total}` : `${total}`);
1336
1586
  const counts = scan?.counts ?? {};
1337
1587
  const summaryParts = [
@@ -1363,7 +1613,7 @@ window.__ModuleLoader__.load({
1363
1613
  doctor.checks.map((c) => h("div", { className: "wsm-item", key: c.id },
1364
1614
  h("span", { className: "wsm-badge" + (c.state === "fail" ? " err" : c.state === "warn" ? " warn" : "") },
1365
1615
  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}` : ""))
1616
+ 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
1617
  ))
1368
1618
  ) : null,
1369
1619
  brokenWorkspaces.length > 0 ? h(Caption, { text: t("wsSection"), help: t("wsHelp") }) : null,
@@ -1436,6 +1686,22 @@ window.__ModuleLoader__.load({
1436
1686
  );
1437
1687
  })
1438
1688
  ) : null,
1689
+ unreadable.length > 0 ? h(Caption, { text: `${t("unreadable")} (${capCount(unreadableV.length, unreadable.length)})` }) : null,
1690
+ unreadableV.length > 0 ? h("div", { className: "wsm-list" },
1691
+ unreadableV.map((it) => {
1692
+ const labels = {
1693
+ unsupported: t("unsupportedFormat"),
1694
+ "layout-unknown": t("layoutUnknown"),
1695
+ "corrupt-data": t("corruptData")
1696
+ };
1697
+ return h("div", { className: "wsm-item", key: it.sessionId },
1698
+ h("span", { className: "wsm-badge err" }, t("unreadable")),
1699
+ h("span", { className: "wsm-mono" }, it.sessionId),
1700
+ h("span", { className: "wsm-cwd", title: it.diagnosticDetail ?? "" },
1701
+ `${labels[it.diagnostic] ?? t("layoutUnknown")}${it.diagnosticDetail ? ` — ${it.diagnosticDetail}` : ""}`)
1702
+ );
1703
+ })
1704
+ ) : null,
1439
1705
  archivedItems.length > 0 ? h(Caption, { text: `${t("archivedCaption")} (${capCount(archivedV.length, archivedItems.length)})`, help: t("archivedHelp") }) : null,
1440
1706
  // 扫描只解析最新 400 条档案,而归档会话通常很旧:大库上这里可能不是全部。
1441
1707
  archived?.truncated && archivedV.length > 0
@@ -1532,6 +1798,22 @@ window.__ModuleLoader__.load({
1532
1798
  h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void taskForget(it) }, t("taskForgetBtn"))
1533
1799
  ))
1534
1800
  ) : null,
1801
+ migrationV.length > 0 ? h(Caption, { text: `${t("queueCaption")} (${capCount(migrationV.length, migrationItems.filter((it) => !["done", "cancelled"].includes(it.state)).length)})`, help: t("queueHelp") }) : null,
1802
+ failedMigrationIds.length > 1 ? h("div", { className: "wsm-scanrow" },
1803
+ h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void migrationRetryAll(failedMigrationIds) }, t("queueRetryAllBtn"))) : null,
1804
+ migrationV.length > 0 ? h("div", { className: "wsm-list" },
1805
+ migrationV.map((it) => h("div", { className: "wsm-item", key: it.id },
1806
+ h("span", { className: "wsm-mono", title: it.sessionId }, it.title || shortId(it.sessionId)),
1807
+ h("span", { className: `wsm-badge ${it.state === "failed" ? "err" : "warn"}` }, it.state),
1808
+ h("span", { className: "wsm-cwd", title: it.lastError ?? "" }, it.lastError || `${t("previewTarget")}: ${it.targetWorkspaceId}`),
1809
+ !["done", "cancelled", "retrying"].includes(it.state)
1810
+ ? h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void migrationRetry(it) }, t("queueRetryBtn"))
1811
+ : null,
1812
+ !["done", "cancelled", "retrying"].includes(it.state)
1813
+ ? h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void migrationCancel(it) }, t("queueCancelBtn"))
1814
+ : null
1815
+ ))
1816
+ ) : null,
1535
1817
  scan && (scan.recoveryCount ?? 0) > 0
1536
1818
  ? h("div", { className: "wsm-note", style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: "10px" } }, t("recoveryNote", { n: scan.recoveryCount }))
1537
1819
  : null,
@@ -1590,12 +1872,57 @@ window.__ModuleLoader__.load({
1590
1872
  };
1591
1873
  let dragging = null; // {el, els, id, target} —— id/target 在 drop 阶段解析
1592
1874
  let wsCache = null; // {items, at}
1875
+ // —— drag autoscroll:工作区列表长时,拖到视口外的目标工作区,指针靠近滚动容器边缘即持续滚动 ——
1876
+ let autoscrollRaf = null;
1877
+ let autoscrollState = null; // { scroller, clientX, clientY }
1878
+ /** 从 el 向上找最近的可纵向滚动的祖先(侧栏滚动区),不依赖具体 class,与行定位的 ARIA 策略一致。 */
1879
+ function findScrollableAncestor(el) {
1880
+ let node = el && el.parentElement;
1881
+ while (node) {
1882
+ try {
1883
+ const oy = getComputedStyle(node).overflowY;
1884
+ if ((oy === "auto" || oy === "scroll") && node.scrollHeight > node.clientHeight + 1) return node;
1885
+ } catch { /* getComputedStyle 不可用时跳过 */ }
1886
+ node = node.parentElement;
1887
+ }
1888
+ return null;
1889
+ }
1890
+ /** 停止 autoscroll raf 并清空状态。 */
1891
+ function stopAutoscroll() {
1892
+ if (autoscrollRaf !== null) { cancelAnimationFrame(autoscrollRaf); autoscrollRaf = null; }
1893
+ autoscrollState = null;
1894
+ }
1895
+ /** 每次 dragover 调用:更新指针 Y + 滚动容器;靠近边缘则 raf 持续滚动,离开边缘则停。 */
1896
+ function startAutoscroll(scroller, clientX, clientY) {
1897
+ if (!scroller || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return stopAutoscroll();
1898
+ autoscrollState = { scroller, clientX, clientY };
1899
+ if (autoscrollRaf !== null) return; // 已在跑:tick 读最新 autoscrollState
1900
+ const tick = () => {
1901
+ autoscrollRaf = null;
1902
+ if (!dragging || !autoscrollState || !autoscrollState.scroller.isConnected) { stopAutoscroll(); return; }
1903
+ const sc = autoscrollState.scroller;
1904
+ const r = sc.getBoundingClientRect();
1905
+ const { clientX: x, clientY: y } = autoscrollState;
1906
+ if (x < r.left || x > r.right || y < r.top || y > r.bottom) return stopAutoscroll();
1907
+ const EDGE = Math.min(64, (r.bottom - r.top) / 3), STEP = 12;
1908
+ let delta = 0;
1909
+ if (y < r.top + EDGE) delta = -STEP;
1910
+ else if (y > r.bottom - EDGE) delta = STEP;
1911
+ if (!delta) return stopAutoscroll();
1912
+ const previous = sc.scrollTop;
1913
+ sc.scrollTop = Math.max(0, Math.min(sc.scrollHeight - sc.clientHeight, previous + delta));
1914
+ if (sc.scrollTop === previous) return stopAutoscroll();
1915
+ autoscrollRaf = requestAnimationFrame(tick);
1916
+ };
1917
+ autoscrollRaf = requestAnimationFrame(tick);
1918
+ }
1593
1919
 
1594
1920
  // 统一登记本插件挂到 document / window 上的监听器与 observer。
1595
1921
  // 必须成对释放:桌面版热重载会废弃旧 fiber 并重新装载客户端半,
1596
1922
  // 不释放就会叠加第二份监听器——重复确认框、重复 RPC,
1597
1923
  // 两份闭包还会互相擦掉对方的多选高亮。
1598
1924
  const disposers = [];
1925
+ disposers.push(stopAutoscroll);
1599
1926
  const listen = (type, fn, options) => {
1600
1927
  document.addEventListener(type, fn, options);
1601
1928
  disposers.push(() => document.removeEventListener(type, fn, options));
@@ -1608,8 +1935,8 @@ window.__ModuleLoader__.load({
1608
1935
  console.warn("[workspace-mover] status probe failed (will retry per move):", err);
1609
1936
  });
1610
1937
 
1611
- async function fetchWorkspaces() {
1612
- if (wsCache && Date.now() - wsCache.at < 3000) return wsCache.items;
1938
+ async function fetchWorkspaces(fresh = false) {
1939
+ if (!fresh && wsCache && Date.now() - wsCache.at < 3000) return wsCache.items;
1613
1940
  const res = await rpcCall("mover.workspaces");
1614
1941
  if (!res?.ok) throw new Error(res?.error?.message ?? "workspace list unavailable");
1615
1942
  wsCache = { items: res.value.items, at: Date.now() };
@@ -1692,7 +2019,7 @@ window.__ModuleLoader__.load({
1692
2019
  */
1693
2020
  function rowSessionId(rowEl) {
1694
2021
  try {
1695
- const fiberKey = Object.keys(rowEl).find((k) => k.startsWith("__reactFiber$"));
2022
+ const fiberKey = Object.getOwnPropertyNames(rowEl).find((k) => k.startsWith("__reactFiber$"));
1696
2023
  if (!fiberKey) return null;
1697
2024
  for (let fiber = rowEl[fiberKey]; fiber; fiber = fiber.return) {
1698
2025
  const node = fiber.memoizedProps?.node;
@@ -1836,10 +2163,15 @@ window.__ModuleLoader__.load({
1836
2163
  }
1837
2164
 
1838
2165
  function headerText(rowEl) {
2166
+ const ownText = (node) => {
2167
+ if (node !== rowEl && node?.getAttribute?.('role') === 'treeitem') return '';
2168
+ if (node?.nodeType === 3) return node.textContent ?? node._text ?? '';
2169
+ return [...(node?.childNodes ?? [])].map(ownText).join(' ');
2170
+ };
1839
2171
  return normalizeHeaderText([
1840
2172
  rowEl?.getAttribute?.("aria-label"),
1841
2173
  rowEl?.getAttribute?.("title"),
1842
- rowEl?.textContent
2174
+ ownText(rowEl)
1843
2175
  ].filter(Boolean).join(" "));
1844
2176
  }
1845
2177
 
@@ -1870,10 +2202,34 @@ window.__ModuleLoader__.load({
1870
2202
  */
1871
2203
  function resolveWorkspace(rowEl, items) {
1872
2204
  const list = Array.isArray(items) ? items : [];
1873
- if (!rowEl || isUngroupedHeader(rowEl)) return null;
2205
+ if (!rowEl) return null;
2206
+ // Read the rendered identity first: React may retain old fiber props after updates.
2207
+ const rowKey = rowEl.getAttribute?.('data-row-key');
2208
+ if (rowKey?.startsWith('workspace:')) {
2209
+ return list.find((item) => `workspace:${item.workspaceId}` === rowKey) ?? null;
2210
+ }
2211
+ if (isUngroupedHeader(rowEl)) return null;
2212
+ // DSH 0.2 ProjectRowItem carries the actual workspace ID in group props.
2213
+ // Prefer identity over visible text, which can contain nested session titles.
2214
+ try {
2215
+ const fiberKey = Object.getOwnPropertyNames(rowEl).find((key) => key.startsWith('__reactFiber$'));
2216
+ for (let fiber = fiberKey ? rowEl[fiberKey] : null; fiber; fiber = fiber.return) {
2217
+ const group = fiber.memoizedProps?.group;
2218
+ if (group && Object.hasOwn(group, 'workspaceId')) {
2219
+ return list.find((item) => item.workspaceId === group.workspaceId) ?? null;
2220
+ }
2221
+ }
2222
+ } catch { /* Older clients can use unique text evidence below. */ }
1874
2223
  const text = headerText(rowEl);
1875
- const byText = list.find((item) => headerMatchesWorkspace(text, item));
1876
- if (byText) return byText;
2224
+ const byText = list.filter((item) => headerMatchesWorkspace(text, item));
2225
+ if (byText.length === 1) return byText[0];
2226
+ if (byText.length > 1) {
2227
+ const labels = [rowEl.getAttribute?.('aria-label'), rowEl.getAttribute?.('title'), text]
2228
+ .map((value) => normalizeHeaderText(value).toLocaleLowerCase()).filter(Boolean);
2229
+ const exact = byText.filter((item) => [item.title, item.path].some((value) =>
2230
+ value && labels.includes(normalizeHeaderText(value).toLocaleLowerCase())));
2231
+ return exact.length === 1 ? exact[0] : null;
2232
+ }
1877
2233
  const unidentifiable = list.filter((item) => !normalizeHeaderText(item?.title) && !normalizeHeaderText(item?.path));
1878
2234
  if (unidentifiable.length === 1) return unidentifiable[0];
1879
2235
  return null;
@@ -1894,8 +2250,10 @@ window.__ModuleLoader__.load({
1894
2250
  listen("dragover", (e) => {
1895
2251
  clearHints();
1896
2252
  if (!dragging) return;
2253
+ // autoscroll:指针靠近侧栏滚动容器上下边缘时持续滚动,让远处目标工作区进入视口
2254
+ startAutoscroll(findScrollableAncestor(dragging.el), e.clientX, e.clientY);
1897
2255
  const header = headerRow(e.target);
1898
- if (!header || isUngroupedHeader(header)) return;
2256
+ if (!header || isUngroupedHeader(header)) { dragging.target = null; return; }
1899
2257
  e.preventDefault(); // 允许在此投放(没有它 drop 根本不会触发)
1900
2258
  header.classList.add("wsm-drop-hint");
1901
2259
  if (!dragging.target || dragging.target.header !== header) {
@@ -1911,9 +2269,10 @@ window.__ModuleLoader__.load({
1911
2269
  listen("drop", async (e) => {
1912
2270
  const current = dragging;
1913
2271
  dragging = null;
2272
+ stopAutoscroll();
1914
2273
  clearHints();
1915
2274
  if (!current) return;
1916
- const header = headerRow(e.target) ?? current.target?.header;
2275
+ const header = headerRow(e.target);
1917
2276
  if (!header || isUngroupedHeader(header)) return;
1918
2277
  // 只拦截「拖到某个工作区标题行」的场景;其余交还官方逻辑
1919
2278
  e.preventDefault();
@@ -1935,19 +2294,16 @@ window.__ModuleLoader__.load({
1935
2294
  }
1936
2295
 
1937
2296
  let workspace = null;
2297
+ let workspaceItems = [];
1938
2298
  try {
1939
- let items = await fetchWorkspaces();
2299
+ let items = await fetchWorkspaces(true);
2300
+ workspaceItems = items;
1940
2301
  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
2302
  if (!workspace) {
1945
2303
  wsCache = null;
1946
2304
  items = await fetchWorkspaces();
2305
+ workspaceItems = items;
1947
2306
  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
2307
  }
1952
2308
  } catch (err) {
1953
2309
  return void toast(t("failed", { msg: errMsg(err) }), true);
@@ -1972,7 +2328,7 @@ window.__ModuleLoader__.load({
1972
2328
  if (el === current.el) continue; // 拖起行用 dataTransfer 的权威 id
1973
2329
  try {
1974
2330
  const id = await resolveRowId(el);
1975
- if (id) push(id, rowTitle(el, ""));
2331
+ if (id) push(id, rowTitle(el, id));
1976
2332
  else unresolvedCount += 1;
1977
2333
  } catch { unresolvedCount += 1; }
1978
2334
  }
@@ -1992,18 +2348,43 @@ window.__ModuleLoader__.load({
1992
2348
 
1993
2349
  // ---- 单选:原有确认与 mover.move 路径 ----
1994
2350
  if (sessions.length === 1) {
1995
- const { sessionId, sessionTitle } = sessions[0];
1996
- const confirmed = await confirmMove({ sessionId, sessionTitle, workspace, unresolvedCount });
1997
- if (!confirmed) return;
2351
+ const { sessionId } = sessions[0];
2352
+ let sessionTitle = sessions[0].sessionTitle;
2353
+ let preview = null;
2354
+ try {
2355
+ const res = await rpcCall("mover.preview", { sessionId, targetWorkspaceId: workspace.workspaceId });
2356
+ if (res?.ok) {
2357
+ preview = res.value;
2358
+ if (typeof preview?.title === 'string' && preview.title.trim()) sessionTitle = preview.title;
2359
+ }
2360
+ } catch (err) {
2361
+ console.debug("[workspace-mover] preview unavailable:", err?.message ?? err);
2362
+ }
2363
+ const choice = await confirmMove({ sessionId, sessionTitle, workspace, workspaces: workspaceItems, unresolvedCount, preview,
2364
+ loadPreview: async (targetWorkspaceId, mode) => {
2365
+ const res = await rpcCall('mover.preview', { sessionId, targetWorkspaceId, mode });
2366
+ if (!res?.ok || !res.value?.status) throw new Error(res?.error?.message ?? 'preview unavailable');
2367
+ return res.value;
2368
+ }
2369
+ });
2370
+ if (!choice) return;
2371
+ workspace = workspaceItems.find((item) => item.workspaceId === choice.targetWorkspaceId);
1998
2372
  try {
1999
- const res = await rpcCall("mover.move", { sessionId, sessionTitle, targetWorkspaceId: workspace.workspaceId });
2373
+ const res = await rpcCall("mover.move", { sessionId, sessionTitle, targetWorkspaceId: workspace.workspaceId, mode: choice.mode });
2000
2374
  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]);
2375
+ wsCache = null;
2376
+ scanCache = null;
2377
+ const movedIds = (res.value?.results ?? []).filter((item) => item?.ok).map((item) => item.sessionId).filter(Boolean);
2378
+ await reconcileWorkspaceProjection(ctx, workspace.workspaceId, movedIds.length > 0 ? movedIds : [sessionId]);
2379
+ toast(res.value?.queued ? t("queuedMove") : (res.value?.attached ? t("selfHealed") : t("done", { title: workspace.title })));
2380
+ scheduleRecencyFix(ctx, workspace.workspaceId, movedIds.length > 0 ? movedIds : [sessionId]);
2004
2381
  if (res.value?.restartHint) setTimeout(() => toast(t("restartHint"), true), 1200);
2005
2382
  } else {
2006
2383
  const msg = res?.error?.message ?? "unknown";
2384
+ if (res?.error?.code === "busy") {
2385
+ toast(t("queuedMove"));
2386
+ return;
2387
+ }
2007
2388
  const rolled = res?.error?.code === "rollback-failed" || /roll/i.test(msg);
2008
2389
  const text = (rolled ? t("rolledBack", { msg }) : t("failed", { msg }));
2009
2390
  toast(text, true);
@@ -2015,21 +2396,41 @@ window.__ModuleLoader__.load({
2015
2396
  }
2016
2397
 
2017
2398
  // ---- 多选:批量确认 + mover.moveMany(逐条独立备份回滚)----
2018
- const confirmed = await confirmMove({ workspace, batchCount: sessions.length, unresolvedCount });
2019
- if (!confirmed) return;
2399
+ let preview = null;
2020
2400
  try {
2021
- const res = await rpcCall("mover.moveMany", { sessions, targetWorkspaceId: workspace.workspaceId });
2401
+ const plans = (await Promise.all(sessions.map(async (item) => {
2402
+ const res = await rpcCall("mover.preview", { sessionId: item.sessionId, targetWorkspaceId: workspace.workspaceId });
2403
+ return res?.ok ? res.value : null;
2404
+ }))).filter(Boolean);
2405
+ const blocked = plans.filter((item) => item.status === "blocked").length;
2406
+ const queued = plans.filter((item) => item.status === "queued").length;
2407
+ const warnings = plans.flatMap((item) => item.warnings ?? []).filter(Boolean);
2408
+ preview = {
2409
+ ...plans[0],
2410
+ status: blocked === plans.length && plans.length > 0 ? "blocked" : (blocked > 0 || queued > 0 ? "warning" : "safe"),
2411
+ source: { title: t("scannedN", { n: sessions.length }) },
2412
+ estimatedBackupBytes: plans.reduce((sum, item) => sum + Number(item.estimatedBackupBytes || 0), 0),
2413
+ warnings: [...new Set([...(blocked ? [`${blocked} blocked`] : []), ...(queued ? [`${queued} queued`] : []), ...warnings])]
2414
+ };
2415
+ } catch (err) {
2416
+ console.debug("[workspace-mover] batch preview unavailable:", err?.message ?? err);
2417
+ }
2418
+ const choice = await confirmMove({ workspace, batchCount: sessions.length, unresolvedCount, preview });
2419
+ if (!choice) return;
2420
+ try {
2421
+ const res = await rpcCall("mover.moveMany", { sessions, targetWorkspaceId: workspace.workspaceId, mode: choice.mode });
2022
2422
  if (!res?.ok) {
2023
2423
  const msg = res?.error?.message ?? "unknown";
2024
2424
  const rolled = res?.error?.code === "rollback-failed" || /roll/i.test(msg);
2025
2425
  return void toast((rolled ? t("rolledBack", { msg }) : t("failed", { msg })), true);
2026
2426
  }
2027
- const { movedCount, attachedCount, failedCount } = res.value ?? {};
2427
+ const { movedCount, attachedCount, failedCount, queuedCount } = res.value ?? {};
2028
2428
  const okCount = (movedCount ?? 0) + (attachedCount ?? 0);
2029
2429
  const movedIds = (res.value?.results ?? []).filter((item) => item?.ok).map((item) => item.sessionId).filter(Boolean);
2030
2430
  await reconcileWorkspaceProjection(ctx, workspace.workspaceId, movedIds);
2031
2431
  let message = t("batchDone", { n: okCount });
2032
2432
  if ((failedCount ?? 0) > 0) message += t("batchFailTail", { n: failedCount });
2433
+ if ((queuedCount ?? 0) > 0) message += t("batchQueuedTail", { n: queuedCount });
2033
2434
  toast(message);
2034
2435
  clearSelection(true);
2035
2436
  scheduleRecencyFix(ctx, workspace.workspaceId, sessions.map((s) => s.sessionId));
@@ -2038,7 +2439,10 @@ window.__ModuleLoader__.load({
2038
2439
  }
2039
2440
  }, true);
2040
2441
 
2041
- listen("dragend", () => { dragging = null; clearHints(); });
2442
+ listen("dragend", () => { dragging = null; stopAutoscroll(); clearHints(); });
2443
+ listen("dragleave", (e) => {
2444
+ if (autoscrollState && !autoscrollState.scroller.contains(e.relatedTarget)) stopAutoscroll();
2445
+ });
2042
2446
 
2043
2447
  // 整组迁移 + 分组合并:入口在官方组标题的「⋯」菜单(注入「整组迁移…」项)。
2044
2448
  // 不再拦截浏览器右键——官方菜单同样在右键/⋯按钮打开,拦截会与之冲突。