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/CHANGELOG.md +311 -0
- package/README.md +53 -12
- package/README_EN.md +353 -0
- package/client/client.js +447 -43
- package/docs/ARCHITECTURE.md +224 -206
- 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/docs/compatibility/dsh-0.2.1-rc.2-2026-10-18.md +37 -0
- package/docs/compatibility/issue-5-desktop-acceptance-2026-10-22.md +106 -0
- package/docs/compatibility/pr-review-2026-10-22.md +56 -0
- package/lib/index.js +4507 -2939
- package/package.json +103 -99
- package/test/client-dom.test.mjs +208 -0
- package/test/e2e-sandbox.test.mjs +2724 -2200
- package/test/host-services.test.mjs +303 -0
- package/test/installed-host.test.mjs +119 -0
- package/test/workspace-tools.test.mjs +977 -0
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: "已排队:等待会话停止运行并释放驻留写入句柄;可能需要重启 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
|
|
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 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
|
|
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", () =>
|
|
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
|
-
|
|
716
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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)
|
|
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
|
|
1996
|
-
|
|
1997
|
-
|
|
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
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
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
|
-
|
|
2019
|
-
if (!confirmed) return;
|
|
2399
|
+
let preview = null;
|
|
2020
2400
|
try {
|
|
2021
|
-
const
|
|
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
|
// 不再拦截浏览器右键——官方菜单同样在右键/⋯按钮打开,拦截会与之冲突。
|