dsh-workspace-mover 2.1.0 → 2.2.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/README.md +325 -267
- package/client/client.js +318 -68
- package/docs/ARCHITECTURE.md +68 -17
- package/lib/config.js +74 -0
- package/lib/index.js +423 -70
- package/package.json +99 -63
- package/test/client-dom.test.mjs +667 -0
- package/test/e2e-sandbox.test.mjs +289 -10
- package/test/policy.test.mjs +415 -0
- package/test/traffic-badge.test.mjs +18 -4
package/client/client.js
CHANGED
|
@@ -28,7 +28,6 @@ window.__ModuleLoader__.load({
|
|
|
28
28
|
rolledBack: "已自动回滚:{msg}",
|
|
29
29
|
failed: "移动失败:{msg}",
|
|
30
30
|
noTarget: "无法识别目标工作区(侧边栏结构变化?)",
|
|
31
|
-
ungroupedUnsupported: "暂不支持移动到「未分组」",
|
|
32
31
|
staleList: "工作区列表与侧边栏不一致,请重试",
|
|
33
32
|
restartHint: "缓存失效失败:归属可能需要重启 Harness 后才刷新",
|
|
34
33
|
rescueSection: "会话修复",
|
|
@@ -58,6 +57,10 @@ window.__ModuleLoader__.load({
|
|
|
58
57
|
helpPath: "会话原来的文件夹被移动、改名或删除,所以侧边栏看不到它。选择一个分组,历史会原样迁移过去,会话 ID 保持不变。",
|
|
59
58
|
helpUnreg: "会话文件完好,只是还没有归属到任何工作区分组。点击按钮即可归入匹配的分组,文件不会移动。",
|
|
60
59
|
pickTarget: "请先在下拉框选择要移入的分组",
|
|
60
|
+
idUnresolved: "无法识别要移动的会话(侧边栏结构或标题有变化),已取消本次移动。请刷新页面后重试。",
|
|
61
|
+
idUnresolvedPartial: "有 {n} 行无法识别,将被跳过——本次只移动能确认身份的会话。",
|
|
62
|
+
batchTooMany: "单次最多移动 {max} 个会话(本次 {n} 个)",
|
|
63
|
+
dragPayloadBad: "无法读取拖拽内容(宿主版本变化?),已取消本次移动",
|
|
61
64
|
relinkBtn: "移入该分组",
|
|
62
65
|
attachBtn: "归入分组",
|
|
63
66
|
relinked: "✓ 已迁入「{title}」,历史原样保留",
|
|
@@ -91,13 +94,13 @@ window.__ModuleLoader__.load({
|
|
|
91
94
|
batchUndoPartial: ";{n} 个未能撤回(可重试)",
|
|
92
95
|
pickHint: "已选 {n} 个会话 · Ctrl+点击行可多选 · 拖到目标工作区标题行批量移动 · Esc 清空",
|
|
93
96
|
pickCleared: "已清空多选",
|
|
94
|
-
pickEscHint: "按 Esc 清空多选(输入框聚焦时无效)",
|
|
95
97
|
groupMoveTitle: "把「{title}」的会话移到…",
|
|
96
98
|
groupMoveHint: "整组会话将真迁移到目标分组;运行中的会话会被跳过并在结果中说明。",
|
|
97
99
|
groupMoveEmpty: "这个分组下没有可移动的会话。",
|
|
98
100
|
openFolderMenu: "打开文件夹",
|
|
99
101
|
archivedCaption: "已归档的会话",
|
|
100
102
|
archivedHelp: "这些会话被官方「归档」后从侧边栏消失,但文件和分组归属都还在。点「恢复」回到原来的分组,或选「恢复到…」换一个分组;恢复不会动文件。",
|
|
103
|
+
archiveTruncated: "注意:扫描只解析了最新 {parsed} / {total} 个会话档案,较旧的归档会话可能不在此列表中。",
|
|
101
104
|
restoreBtn: "恢复",
|
|
102
105
|
restoreToBtn: "恢复到…",
|
|
103
106
|
restorePickTitle: "把「{title}」恢复到…",
|
|
@@ -121,16 +124,41 @@ window.__ModuleLoader__.load({
|
|
|
121
124
|
purgeAllBtn: "清空回收站",
|
|
122
125
|
purgeAllConfirm: "彻底删除回收站里的全部 {n} 个会话?此操作不可撤销。",
|
|
123
126
|
purgedMsg: "✓ 已彻底删除 {n} 项",
|
|
127
|
+
purgeBackupsToo: "这个会话还有 {n} 份迁移备份。一并删除吗?\n\n(会话已不存在,这些备份不会再被用到;保留则可在备份列表里单独恢复或清理。)",
|
|
128
|
+
purgeBackupsDone: "(含 {n} 份备份)",
|
|
124
129
|
backupsCaption: "备份",
|
|
125
130
|
backupsHelp: "每次迁移前自动生成的字节级备份都在这里。可以把会话恢复回备份时的原位置(若那里没有同 id 会话),或恢复到任意其他分组。",
|
|
126
131
|
backupMeta: "{n} 份 · {size} · {range}",
|
|
127
132
|
backupDeleteBtn: "删除备份",
|
|
128
133
|
backupDeleteConfirm: "删除「{title}」的 {n} 份备份?删除后无法再从备份恢复。",
|
|
129
134
|
backupDeletedMsg: "✓ 已删除 {n} 份备份",
|
|
135
|
+
backupOrphanBadge: "孤儿",
|
|
136
|
+
backupOrphanHelp: "这个会话的档案已经不存在了(通常是被彻底删除)。这些备份不会被任何操作再引用,可以安全清理。",
|
|
137
|
+
backupOrphanSummary: "孤儿备份:{n} 组 · {size}(会话已不存在)",
|
|
138
|
+
backupOrphanCleanup: "清理孤儿备份",
|
|
139
|
+
backupOrphanConfirm: "删除 {n} 组孤儿备份({size})?这些会话已不存在,删除后无法从备份恢复。",
|
|
140
|
+
backupOrphanPartial: "✓ 已删除 {n} 份,{f} 组失败(可重试)",
|
|
130
141
|
residentRefuse: "该会话当前没有焦点也可能仍驻留在 Harness 内存中(归档只隐藏,不会卸载)。DSH 0.1.5 没有公开的按 ID 卸载接口,请重启 Harness 释放常驻会话后再删除。",
|
|
131
142
|
sessionBusy: "该会话正在处理中(可能有迁移 / 还原 / 删除操作尚未完成),请稍后再试。",
|
|
132
143
|
contextStale: "插件上下文已失效(客户端热重载导致)。请刷新页面(或重启应用)后重试,功能会自动恢复。",
|
|
133
144
|
recoveryNote: "检测到 {n} 条自动回滚也失败的记录,需人工确认:会话文件与备份均保留在原处,未丢失。请勿手动清理 pluginData 目录,查看 recovery.json 或联系开发者处理。",
|
|
145
|
+
doctorBtn: "自检",
|
|
146
|
+
doctorChecking: "自检中…",
|
|
147
|
+
doctorSummary: "自检完成:{pass} 通过 · {warn} 警告 · {fail} 失败",
|
|
148
|
+
doctorHostRegistry: "工作区注册表服务",
|
|
149
|
+
doctorHostPersistence: "会话持久化服务",
|
|
150
|
+
doctorHostProjection: "投影缓存",
|
|
151
|
+
doctorHostArchive: "归档通道",
|
|
152
|
+
doctorHostAgents: "Agents 服务",
|
|
153
|
+
doctorHostApproval: "审批接缝",
|
|
154
|
+
doctorAgentTools: "Agent 工具",
|
|
155
|
+
doctorDataRecycle: "回收站",
|
|
156
|
+
doctorDataBackups: "备份目录",
|
|
157
|
+
doctorDataHistory: "移动历史",
|
|
158
|
+
doctorDataTasks: "任务记录",
|
|
159
|
+
doctorRecovery: "恢复记录",
|
|
160
|
+
doctorDataLayout: "存储布局",
|
|
161
|
+
doctorWorkspaces: "工作区路径",
|
|
134
162
|
repairAllBtn: "一键修复",
|
|
135
163
|
repairAllDone: "✓ 已修复 {n} · 跳过 {s} · 失败 {f}",
|
|
136
164
|
skipNeedsTarget: "需选择目标分组",
|
|
@@ -159,7 +187,6 @@ window.__ModuleLoader__.load({
|
|
|
159
187
|
rolledBack: "Rolled back automatically: {msg}",
|
|
160
188
|
failed: "Move failed: {msg}",
|
|
161
189
|
noTarget: "Cannot resolve target workspace (sidebar layout changed?)",
|
|
162
|
-
ungroupedUnsupported: "Moving to \"Ungrouped\" is not supported yet",
|
|
163
190
|
staleList: "Workspace list out of sync with the sidebar; try again",
|
|
164
191
|
restartHint: "Cache invalidation failed: grouping may need a harness restart",
|
|
165
192
|
rescueSection: "Session Repair",
|
|
@@ -189,6 +216,10 @@ window.__ModuleLoader__.load({
|
|
|
189
216
|
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.",
|
|
190
217
|
helpUnreg: "The session file is fine but no workspace claims it yet. Click to file it into the matching group; files are not moved.",
|
|
191
218
|
pickTarget: "Pick a target group in the dropdown first",
|
|
219
|
+
idUnresolved: "Could not identify the session to move (sidebar structure or titles changed); this move was cancelled. Refresh the page and try again.",
|
|
220
|
+
idUnresolvedPartial: "{n} row(s) could not be identified and will be skipped — this move only carries the sessions whose identity was confirmed.",
|
|
221
|
+
batchTooMany: "At most {max} sessions per move (this one had {n})",
|
|
222
|
+
dragPayloadBad: "Could not read the dragged content (host version change?); this move was cancelled",
|
|
192
223
|
relinkBtn: "Move there",
|
|
193
224
|
attachBtn: "File into group",
|
|
194
225
|
relinked: "✓ Moved into \"{title}\"; history preserved as-is",
|
|
@@ -222,13 +253,13 @@ window.__ModuleLoader__.load({
|
|
|
222
253
|
batchUndoPartial: "; {n} not undone (retry available)",
|
|
223
254
|
pickHint: "{n} sessions picked · Ctrl+click rows to multi-select · drag onto a workspace header to move in bulk · Esc to clear",
|
|
224
255
|
pickCleared: "Selection cleared",
|
|
225
|
-
pickEscHint: "Press Esc to clear the multi-selection (not while typing in the composer)",
|
|
226
256
|
groupMoveTitle: "Move sessions of \"{title}\" to…",
|
|
227
257
|
groupMoveHint: "The whole group will be truly moved to the target; running sessions are skipped and noted in the result.",
|
|
228
258
|
groupMoveEmpty: "No movable sessions in this group.",
|
|
229
259
|
openFolderMenu: "Open folder",
|
|
230
260
|
archivedCaption: "Archived sessions",
|
|
231
261
|
archivedHelp: "These sessions were hidden by the official \"archive\" action, but their files and group membership are intact. \"Restore\" sends them back to the original group, or \"Restore to…\" picks another one; restoring never touches files.",
|
|
262
|
+
archiveTruncated: "Note: the scan parsed only the newest {parsed} of {total} session archives, so older archived sessions may be missing from this list.",
|
|
232
263
|
restoreBtn: "Restore",
|
|
233
264
|
restoreToBtn: "Restore to…",
|
|
234
265
|
restorePickTitle: "Restore \"{title}\" to…",
|
|
@@ -252,16 +283,41 @@ window.__ModuleLoader__.load({
|
|
|
252
283
|
purgeAllBtn: "Empty recycle bin",
|
|
253
284
|
purgeAllConfirm: "Purge all {n} sessions in the recycle bin? This cannot be undone.",
|
|
254
285
|
purgedMsg: "✓ Purged {n} item(s)",
|
|
286
|
+
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
|
+
purgeBackupsDone: " (including {n} backup copy(ies))",
|
|
255
288
|
backupsCaption: "Backups",
|
|
256
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.",
|
|
257
290
|
backupMeta: "{n} copies · {size} · {range}",
|
|
258
291
|
backupDeleteBtn: "Delete backups",
|
|
259
292
|
backupDeleteConfirm: "Delete the {n} backup copies of \"{title}\"? They cannot be restored from afterwards.",
|
|
260
293
|
backupDeletedMsg: "✓ Deleted {n} backup copy(ies)",
|
|
294
|
+
backupOrphanBadge: "Orphan",
|
|
295
|
+
backupOrphanHelp: "This session's archive no longer exists (usually purged). These backups are no longer referenced by any operation and can be cleaned safely.",
|
|
296
|
+
backupOrphanSummary: "Orphan backups: {n} group(s) · {size} (session no longer exists)",
|
|
297
|
+
backupOrphanCleanup: "Clean orphan backups",
|
|
298
|
+
backupOrphanConfirm: "Delete {n} orphan backup group(s) ({size})? Those sessions no longer exist, so they cannot be restored from these backups afterwards.",
|
|
299
|
+
backupOrphanPartial: "✓ Deleted {n}; {f} group(s) failed (retry available)",
|
|
261
300
|
residentRefuse: "This session may remain resident in harness memory even when it is not focused (archiving hides it but does not unload it). DSH 0.1.5 exposes no public unload-by-ID API; restart the harness to release it, then delete it again.",
|
|
262
301
|
sessionBusy: "This session is busy: a move / restore / delete is still in flight. Try again in a moment.",
|
|
263
302
|
contextStale: "The plugin context went stale (client-side hot reload). Refresh the page (or restart the app) and the features will work again.",
|
|
264
303
|
recoveryNote: "{n} record(s) need manual recovery: automatic rollback also failed. Session files and backups are all kept in place — nothing was lost. Do not clean the pluginData directory by hand; inspect recovery.json or contact the developer.",
|
|
304
|
+
doctorBtn: "Doctor",
|
|
305
|
+
doctorChecking: "Running checks…",
|
|
306
|
+
doctorSummary: "Doctor finished: {pass} pass · {warn} warn · {fail} fail",
|
|
307
|
+
doctorHostRegistry: "Workspace registry service",
|
|
308
|
+
doctorHostPersistence: "Session persistence service",
|
|
309
|
+
doctorHostProjection: "Projection cache",
|
|
310
|
+
doctorHostArchive: "Archive channel",
|
|
311
|
+
doctorHostAgents: "Agents service",
|
|
312
|
+
doctorHostApproval: "Approval seam",
|
|
313
|
+
doctorAgentTools: "Agent tools",
|
|
314
|
+
doctorDataRecycle: "Recycle bin",
|
|
315
|
+
doctorDataBackups: "Backups",
|
|
316
|
+
doctorDataHistory: "Move history",
|
|
317
|
+
doctorDataTasks: "Task records",
|
|
318
|
+
doctorRecovery: "Recovery records",
|
|
319
|
+
doctorDataLayout: "Storage layout",
|
|
320
|
+
doctorWorkspaces: "Workspace paths",
|
|
265
321
|
repairAllBtn: "Fix all",
|
|
266
322
|
repairAllDone: "✓ Fixed {n} · skipped {s} · failed {f}",
|
|
267
323
|
skipNeedsTarget: "needs a target group",
|
|
@@ -280,6 +336,18 @@ window.__ModuleLoader__.load({
|
|
|
280
336
|
}
|
|
281
337
|
};
|
|
282
338
|
const lang = (navigator.language || "en").toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
339
|
+
// 词条漂移防护:新增文案只补一种语言时,缺失的那一侧会静默回退成 key
|
|
340
|
+
//(界面上直接显示 idUnresolved 这样的标识符)。启动时对账一次并告警。
|
|
341
|
+
(() => {
|
|
342
|
+
const keysOf = (l) => new Set(Object.keys(STRINGS[l] ?? {}));
|
|
343
|
+
const zhKeys = keysOf("zh");
|
|
344
|
+
const enKeys = keysOf("en");
|
|
345
|
+
const onlyZh = [...zhKeys].filter((k) => !enKeys.has(k));
|
|
346
|
+
const onlyEn = [...enKeys].filter((k) => !zhKeys.has(k));
|
|
347
|
+
if (onlyZh.length || onlyEn.length) {
|
|
348
|
+
console.warn("[workspace-mover] i18n key drift — missing in en:", onlyZh, "missing in zh:", onlyEn);
|
|
349
|
+
}
|
|
350
|
+
})();
|
|
283
351
|
const t = (key, vars) => {
|
|
284
352
|
let s = STRINGS[lang][key] ?? key;
|
|
285
353
|
for (const [k, v] of Object.entries(vars ?? {})) s = s.replaceAll(`{${k}}`, String(v));
|
|
@@ -335,6 +403,7 @@ window.__ModuleLoader__.load({
|
|
|
335
403
|
.wsm-mono{font-family:ui-monospace,Consolas,Menlo,monospace}
|
|
336
404
|
.wsm-badge{flex:none;font-size:11px;padding:1px 7px;border-radius:99px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.15));color:var(--dsw-alias-label-secondary,#666)}
|
|
337
405
|
.wsm-badge.err{color:var(--dsw-alias-state-error-primary,#dc2626);border-color:currentColor}
|
|
406
|
+
.wsm-badge.warn{color:var(--dsw-alias-state-warning-primary,#d97706);border-color:currentColor}
|
|
338
407
|
.wsm-cwd{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#999)}
|
|
339
408
|
.wsm-select{font:inherit;font-size:12px;background:var(--dsw-specific-input-major,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.15));border-radius:6px;padding:3px 6px;max-width:220px}
|
|
340
409
|
.wsm-input{flex:1.4;min-width:170px;font:inherit;font-size:12px;background:var(--dsw-specific-input-major,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.15));border-radius:6px;padding:3px 7px}
|
|
@@ -477,12 +546,16 @@ window.__ModuleLoader__.load({
|
|
|
477
546
|
}
|
|
478
547
|
}
|
|
479
548
|
|
|
549
|
+
/** 弹窗实例计数:为 aria-labelledby 生成稳定且唯一的关联 id。 */
|
|
550
|
+
let overlaySeq = 0;
|
|
551
|
+
|
|
480
552
|
function injectOverlay() {
|
|
553
|
+
const uid = `wsm-title-${++overlaySeq}`;
|
|
481
554
|
const overlay = document.createElement("div");
|
|
482
555
|
overlay.className = "wsm-overlay";
|
|
483
556
|
overlay.innerHTML = `
|
|
484
|
-
<div class="wsm-card" role="dialog" aria-modal="true">
|
|
485
|
-
<div class="wsm-title"></div>
|
|
557
|
+
<div class="wsm-card" role="dialog" aria-modal="true" aria-labelledby="${uid}">
|
|
558
|
+
<div class="wsm-title" id="${uid}"></div>
|
|
486
559
|
<div class="wsm-row"><span class="wsm-label"></span> <b class="wsm-session"></b></div>
|
|
487
560
|
<div class="wsm-row"><span class="wsm-label"></span> <b class="wsm-target"></b></div>
|
|
488
561
|
<div class="wsm-hint"></div>
|
|
@@ -499,7 +572,29 @@ window.__ModuleLoader__.load({
|
|
|
499
572
|
return overlay;
|
|
500
573
|
}
|
|
501
574
|
|
|
502
|
-
|
|
575
|
+
/**
|
|
576
|
+
* 挂载弹窗并补齐可达性:焦点进入弹窗、Tab 在弹窗内循环、
|
|
577
|
+
* 背景点击关闭。`onKey` 是各调用方自己的 Escape 处理(用于释放监听器)。
|
|
578
|
+
* 必须**先 append 再 focus**——未挂到文档上的元素在浏览器里无法聚焦。
|
|
579
|
+
*/
|
|
580
|
+
function openDialog(overlay, close) {
|
|
581
|
+
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(null); });
|
|
582
|
+
document.body.appendChild(overlay);
|
|
583
|
+
const card = overlay.querySelector(".wsm-card");
|
|
584
|
+
const focusables = [...card.querySelectorAll("button:not(:disabled)")];
|
|
585
|
+
(focusables.find((b) => b.classList.contains("primary")) ?? focusables[0] ?? card)?.focus?.();
|
|
586
|
+
overlay.addEventListener("keydown", (e) => {
|
|
587
|
+
if (e.key !== "Tab" || focusables.length === 0) return;
|
|
588
|
+
const at = focusables.indexOf(document.activeElement);
|
|
589
|
+
const next = e.shiftKey
|
|
590
|
+
? (at <= 0 ? focusables.length - 1 : at - 1)
|
|
591
|
+
: (at === -1 || at === focusables.length - 1 ? 0 : at + 1);
|
|
592
|
+
// 自管焦点:避免 Tab 跑到弹窗背后的侧边栏/输入框
|
|
593
|
+
focusables[next]?.focus?.();
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function confirmMove({ sessionId, sessionTitle, workspace, batchCount, unresolvedCount }) {
|
|
503
598
|
return new Promise((resolve) => {
|
|
504
599
|
const overlay = injectOverlay();
|
|
505
600
|
const okBtn = overlay.querySelector(".wsm-ok");
|
|
@@ -508,15 +603,26 @@ window.__ModuleLoader__.load({
|
|
|
508
603
|
? t("scannedN", { n: batchCount })
|
|
509
604
|
: (sessionTitle || t("unnamedSession"));
|
|
510
605
|
overlay.querySelector(".wsm-target").textContent = `${workspace.title}(${workspace.path})`;
|
|
511
|
-
overlay.querySelector(".wsm-hint")
|
|
606
|
+
const hint = overlay.querySelector(".wsm-hint");
|
|
607
|
+
hint.textContent = batchCount ? t("batchHint", { count: batchCount }) : t("hint");
|
|
608
|
+
// 有行无法识别时必须如实说明——用户点"确认"之前要知道这次只搬走了一部分。
|
|
609
|
+
if (unresolvedCount > 0) {
|
|
610
|
+
const warn = document.createElement("div");
|
|
611
|
+
warn.className = "wsm-hint";
|
|
612
|
+
warn.textContent = t("idUnresolvedPartial", { n: unresolvedCount });
|
|
613
|
+
hint.after(warn);
|
|
614
|
+
}
|
|
512
615
|
okBtn.textContent = batchCount ? t("batchMove") : t("move");
|
|
513
|
-
const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
|
|
514
|
-
const onKey = (e) => { if (e.key === "Escape") close(null); };
|
|
515
616
|
document.addEventListener("keydown", onKey);
|
|
516
|
-
|
|
617
|
+
function onKey(e) { if (e.key === "Escape") close(null); }
|
|
618
|
+
function close(value) {
|
|
619
|
+
overlay.remove();
|
|
620
|
+
document.removeEventListener("keydown", onKey);
|
|
621
|
+
resolve(value);
|
|
622
|
+
}
|
|
517
623
|
overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
|
|
518
624
|
okBtn.addEventListener("click", () => close(true));
|
|
519
|
-
|
|
625
|
+
openDialog(overlay, close);
|
|
520
626
|
});
|
|
521
627
|
}
|
|
522
628
|
|
|
@@ -544,14 +650,17 @@ window.__ModuleLoader__.load({
|
|
|
544
650
|
okBtn.disabled = true;
|
|
545
651
|
const select = overlay.querySelector("select.wsm-select");
|
|
546
652
|
select.addEventListener("change", () => { okBtn.disabled = !select.value; });
|
|
547
|
-
const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
|
|
548
|
-
const onKey = (e) => { if (e.key === "Escape") close(null); };
|
|
549
653
|
document.addEventListener("keydown", onKey);
|
|
550
|
-
|
|
654
|
+
function onKey(e) { if (e.key === "Escape") close(null); }
|
|
655
|
+
function close(value) {
|
|
656
|
+
overlay.remove();
|
|
657
|
+
document.removeEventListener("keydown", onKey);
|
|
658
|
+
resolve(value);
|
|
659
|
+
}
|
|
551
660
|
overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
|
|
552
661
|
okBtn.addEventListener("click", () => close(select.value || null));
|
|
553
|
-
|
|
554
|
-
|
|
662
|
+
openDialog(overlay, close);
|
|
663
|
+
okBtn.focus();
|
|
555
664
|
});
|
|
556
665
|
}
|
|
557
666
|
|
|
@@ -579,14 +688,17 @@ window.__ModuleLoader__.load({
|
|
|
579
688
|
okBtn.disabled = true;
|
|
580
689
|
const select = overlay.querySelector("select.wsm-select");
|
|
581
690
|
select.addEventListener("change", () => { okBtn.disabled = !select.value; });
|
|
582
|
-
const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
|
|
583
|
-
const onKey = (e) => { if (e.key === "Escape") close(null); };
|
|
584
691
|
document.addEventListener("keydown", onKey);
|
|
585
|
-
|
|
692
|
+
function onKey(e) { if (e.key === "Escape") close(null); }
|
|
693
|
+
function close(value) {
|
|
694
|
+
overlay.remove();
|
|
695
|
+
document.removeEventListener("keydown", onKey);
|
|
696
|
+
resolve(value);
|
|
697
|
+
}
|
|
586
698
|
overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
|
|
587
699
|
okBtn.addEventListener("click", () => close(select.value || null));
|
|
588
|
-
document.body.appendChild(overlay);
|
|
589
700
|
if (select.value) okBtn.disabled = false;
|
|
701
|
+
openDialog(overlay, close);
|
|
590
702
|
select.focus();
|
|
591
703
|
});
|
|
592
704
|
}
|
|
@@ -637,6 +749,7 @@ window.__ModuleLoader__.load({
|
|
|
637
749
|
const [trash, setTrash] = useState(null);
|
|
638
750
|
const [backups, setBackups] = useState(null);
|
|
639
751
|
const [tasks, setTasks] = useState(null);
|
|
752
|
+
const [doctor, setDoctor] = useState(null);
|
|
640
753
|
const [query, setQuery] = useState("");
|
|
641
754
|
|
|
642
755
|
const call = async (endpoint, payload) => {
|
|
@@ -675,6 +788,21 @@ window.__ModuleLoader__.load({
|
|
|
675
788
|
|
|
676
789
|
useEffect(() => { void runScan(); }, []);
|
|
677
790
|
|
|
791
|
+
// 自检(v2.2):只读诊断,结果常驻面板直到下次运行或重新扫描
|
|
792
|
+
const runDoctorNow = async () => {
|
|
793
|
+
setBusy(true);
|
|
794
|
+
setNote(t("doctorChecking"));
|
|
795
|
+
try {
|
|
796
|
+
const value = await call("mover.doctor");
|
|
797
|
+
setDoctor(value);
|
|
798
|
+
setNote(t("doctorSummary", value.summary));
|
|
799
|
+
} catch (err) {
|
|
800
|
+
setNote(t("failed", { msg: errMsg(err) }));
|
|
801
|
+
} finally {
|
|
802
|
+
setBusy(false);
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
|
|
678
806
|
const relink = async (item) => {
|
|
679
807
|
const target = picked[item.sessionId];
|
|
680
808
|
if (!target) return void setNote(t("pickTarget"));
|
|
@@ -979,11 +1107,18 @@ window.__ModuleLoader__.load({
|
|
|
979
1107
|
|
|
980
1108
|
const purgeOne = async (item) => {
|
|
981
1109
|
if (!window.confirm(t("purgeConfirm", { title: item.title || t("unnamedSession") }))) return;
|
|
1110
|
+
// 彻底删除不可撤销:顺便问一句要不要把该会话的迁移备份也清掉
|
|
1111
|
+
//(这些备份此刻已无还原对象,留着只会变成孤儿字节)。
|
|
1112
|
+
const purgeBackups = item.backupCount > 0
|
|
1113
|
+
? window.confirm(t("purgeBackupsToo", { n: item.backupCount }))
|
|
1114
|
+
: false;
|
|
982
1115
|
setBusy(true);
|
|
983
1116
|
setNote("");
|
|
984
1117
|
try {
|
|
985
|
-
const res = await call("mover.trash.purge", { sessionId: item.sessionId });
|
|
986
|
-
|
|
1118
|
+
const res = await call("mover.trash.purge", { sessionId: item.sessionId, purgeBackups });
|
|
1119
|
+
let msg = t("purgedMsg", { n: res?.purged ?? 1 });
|
|
1120
|
+
if ((res?.backupsDeleted ?? 0) > 0) msg += t("purgeBackupsDone", { n: res.backupsDeleted });
|
|
1121
|
+
setNote(msg);
|
|
987
1122
|
await runScan();
|
|
988
1123
|
} catch (err) {
|
|
989
1124
|
failNote(err);
|
|
@@ -1061,6 +1196,29 @@ window.__ModuleLoader__.load({
|
|
|
1061
1196
|
}
|
|
1062
1197
|
};
|
|
1063
1198
|
|
|
1199
|
+
/**
|
|
1200
|
+
* 清理全部孤儿备份组的字节。孤儿 = 会话档案已不存在,这些备份不会被
|
|
1201
|
+
* 任何操作再引用;逐组调用既有端点,不新增 RPC 面。
|
|
1202
|
+
*/
|
|
1203
|
+
const backupDeleteOrphans = async () => {
|
|
1204
|
+
const orphans = (backupItems ?? []).filter((it) => it.orphan);
|
|
1205
|
+
if (orphans.length === 0) return;
|
|
1206
|
+
if (!window.confirm(t("backupOrphanConfirm", { n: orphans.length, size: fmtBytes(backups?.orphanBytes ?? 0) }))) return;
|
|
1207
|
+
setBusy(true);
|
|
1208
|
+
setNote("");
|
|
1209
|
+
let deleted = 0;
|
|
1210
|
+
let failed = 0;
|
|
1211
|
+
for (const item of orphans) {
|
|
1212
|
+
try {
|
|
1213
|
+
const res = await call("mover.backups.deleteOne", { sessionId: item.sessionId });
|
|
1214
|
+
deleted += res?.deleted ?? 0;
|
|
1215
|
+
} catch { failed++; }
|
|
1216
|
+
}
|
|
1217
|
+
setNote(failed > 0 ? t("backupOrphanPartial", { n: deleted, f: failed }) : t("backupDeletedMsg", { n: deleted }));
|
|
1218
|
+
setBusy(false);
|
|
1219
|
+
await runScan();
|
|
1220
|
+
};
|
|
1221
|
+
|
|
1064
1222
|
// 一键修复:可自动修复项(挂错归位 / 未记账补账)逐项处理,只动记账不搬文件
|
|
1065
1223
|
const repairAll = async () => {
|
|
1066
1224
|
setBusy(true);
|
|
@@ -1153,8 +1311,13 @@ window.__ModuleLoader__.load({
|
|
|
1153
1311
|
const brokenWorkspaces = (audit?.items ?? []).filter((it) => it.status !== "ok");
|
|
1154
1312
|
const emptyWorkspaces = workspaces.filter((w) => (w.rawSessionCount ?? 0) === 0);
|
|
1155
1313
|
const archivedItems = archived?.items ?? [];
|
|
1156
|
-
const trashItems = trash?.items ?? [];
|
|
1157
1314
|
const backupItems = backups?.items ?? [];
|
|
1315
|
+
// 回收站条目补上"该会话还有几份迁移备份":彻底删除时顺带清理它们。
|
|
1316
|
+
const backupCountById = new Map(backupItems.map((it) => [String(it.sessionId), it.count]));
|
|
1317
|
+
const trashItems = (trash?.items ?? []).map((it) => ({
|
|
1318
|
+
...it,
|
|
1319
|
+
backupCount: backupCountById.get(String(it.sessionId)) ?? 0
|
|
1320
|
+
}));
|
|
1158
1321
|
const taskItems = tasks?.items ?? [];
|
|
1159
1322
|
// 面板筛选:标题 / 会话 ID / 路径 / 归属分组,命中任一即保留
|
|
1160
1323
|
const q = query.trim().toLowerCase();
|
|
@@ -1185,6 +1348,7 @@ window.__ModuleLoader__.load({
|
|
|
1185
1348
|
h("div", { className: "wsm-scanrow" },
|
|
1186
1349
|
h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void runScan() }, busy ? t("scanning") : t("scan")),
|
|
1187
1350
|
h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void repairAll() }, t("repairAllBtn")),
|
|
1351
|
+
h("button", { className: "wsm-btn small", disabled: busy, onClick: () => { setDoctor(null); void runDoctorNow(); } }, busy ? t("doctorChecking") : t("doctorBtn")),
|
|
1188
1352
|
h("input",
|
|
1189
1353
|
{
|
|
1190
1354
|
className: "wsm-input", placeholder: t("filterPh"), value: query, disabled: busy,
|
|
@@ -1195,6 +1359,13 @@ window.__ModuleLoader__.load({
|
|
|
1195
1359
|
t("scannedN", { n: scan.scanned }) + (summaryParts.length ? ` · ${summaryParts.join(" · ")}` : ""))
|
|
1196
1360
|
: null
|
|
1197
1361
|
),
|
|
1362
|
+
doctor ? h("div", { className: "wsm-list", style: { marginTop: "8px" } },
|
|
1363
|
+
doctor.checks.map((c) => h("div", { className: "wsm-item", key: c.id },
|
|
1364
|
+
h("span", { className: "wsm-badge" + (c.state === "fail" ? " err" : c.state === "warn" ? " warn" : "") },
|
|
1365
|
+
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}` : ""))
|
|
1367
|
+
))
|
|
1368
|
+
) : null,
|
|
1198
1369
|
brokenWorkspaces.length > 0 ? h(Caption, { text: t("wsSection"), help: t("wsHelp") }) : null,
|
|
1199
1370
|
brokenWorkspaces.length > 0 ? h("div", { className: "wsm-list" },
|
|
1200
1371
|
brokenWorkspaces.map((row) => h("div", { className: "wsm-item", key: row.workspaceId },
|
|
@@ -1266,6 +1437,10 @@ window.__ModuleLoader__.load({
|
|
|
1266
1437
|
})
|
|
1267
1438
|
) : null,
|
|
1268
1439
|
archivedItems.length > 0 ? h(Caption, { text: `${t("archivedCaption")} (${capCount(archivedV.length, archivedItems.length)})`, help: t("archivedHelp") }) : null,
|
|
1440
|
+
// 扫描只解析最新 400 条档案,而归档会话通常很旧:大库上这里可能不是全部。
|
|
1441
|
+
archived?.truncated && archivedV.length > 0
|
|
1442
|
+
? h("div", { className: "wsm-note" }, t("archiveTruncated", { parsed: archived.scannedParsed ?? 0, total: archived.scannedTotal ?? 0 }))
|
|
1443
|
+
: null,
|
|
1269
1444
|
archivedV.length > 0 ? h("div", { className: "wsm-list" },
|
|
1270
1445
|
archivedV.map((it) => h("div", { className: "wsm-item", key: it.sessionId },
|
|
1271
1446
|
h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
|
|
@@ -1330,9 +1505,17 @@ window.__ModuleLoader__.load({
|
|
|
1330
1505
|
})) : h("div", { className: "wsm-note" }, t("historyEmpty"))
|
|
1331
1506
|
),
|
|
1332
1507
|
backupItems.length > 0 ? h(Caption, { text: `${t("backupsCaption")} (${capCount(backupV.length, backupItems.length)})`, help: t("backupsHelp") }) : null,
|
|
1508
|
+
// 孤儿备份:会话已不存在,这些字节不会被任何操作再引用,只能手动清。
|
|
1509
|
+
backups?.orphanGroups > 0
|
|
1510
|
+
? h("div", { className: "wsm-scanrow" },
|
|
1511
|
+
h("span", { style: { fontSize: "12.5px", color: "var(--dsw-alias-label-secondary,#666)" } },
|
|
1512
|
+
t("backupOrphanSummary", { n: backups.orphanGroups, size: fmtBytes(backups.orphanBytes ?? 0) })),
|
|
1513
|
+
h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void backupDeleteOrphans() }, t("backupOrphanCleanup")))
|
|
1514
|
+
: null,
|
|
1333
1515
|
backupV.length > 0 ? h("div", { className: "wsm-list" },
|
|
1334
1516
|
backupV.map((it) => h("div", { className: "wsm-item", key: it.sessionId },
|
|
1335
1517
|
h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
|
|
1518
|
+
it.orphan ? h("span", { className: "wsm-badge warn", title: t("backupOrphanHelp") }, t("backupOrphanBadge")) : null,
|
|
1336
1519
|
h("span", { className: "wsm-cwd", title: it.cwd ?? "" }, t("backupMeta", { n: it.count, size: fmtBytes(it.totalBytes), range: fmtRange(it) })),
|
|
1337
1520
|
h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void backupRestore(it) }, t("restoreBtn")),
|
|
1338
1521
|
h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void backupDelete(it) }, t("backupDeleteBtn"))
|
|
@@ -1384,7 +1567,13 @@ window.__ModuleLoader__.load({
|
|
|
1384
1567
|
// slots:设置页「会话救援」面板需要;未注入时访问 ctx.slots 会被宿主拒绝
|
|
1385
1568
|
var inject = ["connection", "slots"];
|
|
1386
1569
|
|
|
1570
|
+
// 同一 bundle 被重复装载时(宿主热重载/重组)防止叠加实例。
|
|
1571
|
+
// 前一个实例在 teardown 里把它复位,因此"装载→卸载→再装载"仍能正常工作。
|
|
1572
|
+
let applied = false;
|
|
1573
|
+
|
|
1387
1574
|
function apply(ctx) {
|
|
1575
|
+
if (applied) return;
|
|
1576
|
+
applied = true;
|
|
1388
1577
|
// 在 apply(上下文必然活跃)时立即捕获 connection 服务实例本身,而不是持有 ctx
|
|
1389
1578
|
// 做惰性访问:桌面版会在热重载 / 客户端重组时废弃本插件的旧 fiber,此后
|
|
1390
1579
|
// ctx.connection 会抛 "cannot get required service 'connection' in inactive context"
|
|
@@ -1402,6 +1591,16 @@ window.__ModuleLoader__.load({
|
|
|
1402
1591
|
let dragging = null; // {el, els, id, target} —— id/target 在 drop 阶段解析
|
|
1403
1592
|
let wsCache = null; // {items, at}
|
|
1404
1593
|
|
|
1594
|
+
// 统一登记本插件挂到 document / window 上的监听器与 observer。
|
|
1595
|
+
// 必须成对释放:桌面版热重载会废弃旧 fiber 并重新装载客户端半,
|
|
1596
|
+
// 不释放就会叠加第二份监听器——重复确认框、重复 RPC,
|
|
1597
|
+
// 两份闭包还会互相擦掉对方的多选高亮。
|
|
1598
|
+
const disposers = [];
|
|
1599
|
+
const listen = (type, fn, options) => {
|
|
1600
|
+
document.addEventListener(type, fn, options);
|
|
1601
|
+
disposers.push(() => document.removeEventListener(type, fn, options));
|
|
1602
|
+
};
|
|
1603
|
+
|
|
1405
1604
|
ensureStyle();
|
|
1406
1605
|
|
|
1407
1606
|
// 状态仅作诊断:不硬性禁用拖拽(避免启动期竞态导致永久失效)。
|
|
@@ -1423,9 +1622,9 @@ window.__ModuleLoader__.load({
|
|
|
1423
1622
|
|
|
1424
1623
|
//#region 插件自建多选(宿主侧边栏没有多选模型)
|
|
1425
1624
|
// Ctrl/Cmd+点击加入/移出、Shift+点击组内范围选择;拖动任一选中行 = 整批拖动。
|
|
1426
|
-
// 选中集存行元素(判定同步、零延迟);会话 id
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1625
|
+
// 选中集存行元素(判定同步、零延迟);会话 id 在点击/投放时解析:
|
|
1626
|
+
// 优先走权威通道 rowSessionId(React fiber 上的 node.id),不可用时退化为
|
|
1627
|
+
// 「组内对齐 + 标题匹配」——且**匹配不上就不猜**,无法确认的行会被跳过并告知用户。
|
|
1429
1628
|
let pickBadge = null;
|
|
1430
1629
|
const pickedRows = new Set();
|
|
1431
1630
|
let lastPickedRow = null;
|
|
@@ -1507,35 +1706,48 @@ window.__ModuleLoader__.load({
|
|
|
1507
1706
|
}
|
|
1508
1707
|
|
|
1509
1708
|
/**
|
|
1510
|
-
* 把一组会话行对齐到
|
|
1511
|
-
*
|
|
1512
|
-
*
|
|
1513
|
-
*
|
|
1709
|
+
* 把一组会话行对齐到 ws.sessionIds。仅在权威通道(rowSessionId)不可用时使用。
|
|
1710
|
+
*
|
|
1711
|
+
* 关键安全约定:**匹配不上就不猜**。旧实现在标题匹配失败时直接取
|
|
1712
|
+
* `remaining` 里的下一个 id,而官方侧边栏会隐藏空白/归档行,于是整组错位一格、
|
|
1713
|
+
* 批量拖拽会搬走另一组合法会话。现在无法确定的行一律计入 unresolved,
|
|
1714
|
+
* 由调用方决定跳过还是中止。
|
|
1715
|
+
*
|
|
1716
|
+
* 返回 { map, unresolved };map 为 row → id。
|
|
1514
1717
|
*/
|
|
1515
1718
|
async function mapGroupRows(ws, groupRows) {
|
|
1516
1719
|
const titleOf = new Map();
|
|
1517
1720
|
try {
|
|
1518
1721
|
const scan = await fetchScan();
|
|
1519
1722
|
for (const it of scan.items ?? []) titleOf.set(String(it.sessionId), String(it.title ?? "").replace(/\s+/g, " ").trim());
|
|
1520
|
-
} catch { /* scan
|
|
1723
|
+
} catch { /* scan 不可用时无法按标题匹配,只能精确匹配或放弃 */ }
|
|
1521
1724
|
const remaining = (ws.sessionIds ?? []).map(String);
|
|
1522
1725
|
const out = new Map();
|
|
1726
|
+
const unresolved = [];
|
|
1523
1727
|
for (const row of groupRows) {
|
|
1524
|
-
if (remaining.length === 0)
|
|
1728
|
+
if (remaining.length === 0) { unresolved.push(row); continue; }
|
|
1525
1729
|
const text = (row.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
1526
1730
|
let hit = -1;
|
|
1527
1731
|
if (text && text !== t("unnamedSession")) {
|
|
1528
|
-
|
|
1732
|
+
// 先精确,再"标题被行文本包含"(行文本通常更短,只带徽章/时间)
|
|
1733
|
+
hit = remaining.findIndex((id) => titleOf.get(id) === text);
|
|
1734
|
+
if (hit < 0) {
|
|
1735
|
+
hit = remaining.findIndex((id) => {
|
|
1736
|
+
const tt = titleOf.get(id);
|
|
1737
|
+
return tt && tt.length > 0 && text.includes(tt);
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1529
1740
|
}
|
|
1530
|
-
if (hit < 0)
|
|
1741
|
+
if (hit < 0) { unresolved.push(row); continue; }
|
|
1531
1742
|
const id = remaining.splice(hit, 1)[0];
|
|
1743
|
+
if (id === undefined) { unresolved.push(row); continue; }
|
|
1532
1744
|
row.dataset.wsmId = id;
|
|
1533
1745
|
out.set(row, id);
|
|
1534
1746
|
}
|
|
1535
|
-
return out;
|
|
1747
|
+
return { map: out, unresolved };
|
|
1536
1748
|
}
|
|
1537
1749
|
|
|
1538
|
-
/** 解析单个行元素(含所在组)的会话 id;权威走 rowSessionId
|
|
1750
|
+
/** 解析单个行元素(含所在组)的会话 id;权威走 rowSessionId,失败退回组内对齐(不猜)。 */
|
|
1539
1751
|
async function resolveRowId(rowEl) {
|
|
1540
1752
|
const authoritative = rowSessionId(rowEl);
|
|
1541
1753
|
if (authoritative) return authoritative;
|
|
@@ -1550,12 +1762,12 @@ window.__ModuleLoader__.load({
|
|
|
1550
1762
|
if (!header) return null;
|
|
1551
1763
|
const ws = resolveWorkspace(header, items);
|
|
1552
1764
|
if (!ws) return null;
|
|
1553
|
-
const
|
|
1554
|
-
return
|
|
1765
|
+
const { map } = await mapGroupRows(ws, groupSessionRows(header));
|
|
1766
|
+
return map.get(rowEl) ?? null;
|
|
1555
1767
|
}
|
|
1556
1768
|
|
|
1557
1769
|
// Ctrl/Cmd+点击:加入/移出;Shift+点击:组内范围选择。普通点击不干预。
|
|
1558
|
-
|
|
1770
|
+
listen("click", async (e) => {
|
|
1559
1771
|
const row = sessionRow(e.target);
|
|
1560
1772
|
if (!row) return;
|
|
1561
1773
|
const meta = e.ctrlKey || e.metaKey;
|
|
@@ -1575,9 +1787,9 @@ window.__ModuleLoader__.load({
|
|
|
1575
1787
|
return null;
|
|
1576
1788
|
})();
|
|
1577
1789
|
const ws = header ? resolveWorkspace(header, items) : null;
|
|
1578
|
-
if (!ws) return void toast(t("
|
|
1790
|
+
if (!ws) return void toast(t("staleList"), true);
|
|
1579
1791
|
const groupRows = groupSessionRows(header);
|
|
1580
|
-
const mapping = await mapGroupRows(ws, groupRows);
|
|
1792
|
+
const { map: mapping } = await mapGroupRows(ws, groupRows);
|
|
1581
1793
|
if (meta) {
|
|
1582
1794
|
if (pickedRows.has(row)) pickedRows.delete(row);
|
|
1583
1795
|
else if (mapping.has(row) || rowSessionId(row)) {
|
|
@@ -1607,9 +1819,13 @@ window.__ModuleLoader__.load({
|
|
|
1607
1819
|
}
|
|
1608
1820
|
}, true);
|
|
1609
1821
|
|
|
1610
|
-
// Esc
|
|
1611
|
-
|
|
1822
|
+
// Esc 清空多选:输入框聚焦时不清空(与 pickHint 的文案一致),
|
|
1823
|
+
// 也不消费事件——官方输入的取消行为不受影响。
|
|
1824
|
+
listen("keydown", (e) => {
|
|
1612
1825
|
if (e.key !== "Escape" || pickedRows.size === 0) return;
|
|
1826
|
+
const el = document.activeElement;
|
|
1827
|
+
const tag = el?.tagName?.toLowerCase?.();
|
|
1828
|
+
if (el?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") return;
|
|
1613
1829
|
clearSelection();
|
|
1614
1830
|
});
|
|
1615
1831
|
//#endregion
|
|
@@ -1636,30 +1852,36 @@ window.__ModuleLoader__.load({
|
|
|
1636
1852
|
.filter((el) => el.offsetParent !== null && !isUngroupedHeader(el));
|
|
1637
1853
|
}
|
|
1638
1854
|
|
|
1639
|
-
|
|
1640
|
-
|
|
1855
|
+
/** 行标题文本是否命中某个注册表工作区的 title 或 path(大小写不敏感)。 */
|
|
1856
|
+
function headerMatchesWorkspace(text, item) {
|
|
1857
|
+
const lower = String(text ?? "").toLocaleLowerCase();
|
|
1858
|
+
const title = normalizeHeaderText(item?.title).toLocaleLowerCase();
|
|
1859
|
+
const path = normalizeHeaderText(item?.path).toLocaleLowerCase();
|
|
1860
|
+
return Boolean((title && lower.includes(title)) || (path && lower.includes(path)));
|
|
1641
1861
|
}
|
|
1642
1862
|
|
|
1863
|
+
/**
|
|
1864
|
+
* 行元素 → 注册表工作区。
|
|
1865
|
+
* 只用**可自证**的凭据:标题/路径命中,或标题与路径同时为空时的序号等价
|
|
1866
|
+
* (两者都无值意味着该实体没有可显示的标识,序号是唯一可用信息)。
|
|
1867
|
+
* 官方侧边栏会隐藏目录已失效的分组,而 registry.list() 仍包含它们——
|
|
1868
|
+
* 因此「DOM 第 i 项 = 注册表第 i 项」是错的,绝不能作为无条件兜底,
|
|
1869
|
+
* 否则侧边栏少显示一项就会把会话搬进相邻分组。
|
|
1870
|
+
*/
|
|
1643
1871
|
function resolveWorkspace(rowEl, items) {
|
|
1644
1872
|
const list = Array.isArray(items) ? items : [];
|
|
1645
1873
|
if (!rowEl || isUngroupedHeader(rowEl)) return null;
|
|
1646
1874
|
const text = headerText(rowEl);
|
|
1647
|
-
const
|
|
1648
|
-
const byText = list.find((item) => {
|
|
1649
|
-
const title = normalizeHeaderText(item.title);
|
|
1650
|
-
const path = normalizeHeaderText(item.path);
|
|
1651
|
-
return (title && lowerText.includes(title.toLocaleLowerCase()))
|
|
1652
|
-
|| (path && lowerText.includes(path.toLocaleLowerCase()));
|
|
1653
|
-
});
|
|
1875
|
+
const byText = list.find((item) => headerMatchesWorkspace(text, item));
|
|
1654
1876
|
if (byText) return byText;
|
|
1655
|
-
const
|
|
1656
|
-
|
|
1657
|
-
return
|
|
1877
|
+
const unidentifiable = list.filter((item) => !normalizeHeaderText(item?.title) && !normalizeHeaderText(item?.path));
|
|
1878
|
+
if (unidentifiable.length === 1) return unidentifiable[0];
|
|
1879
|
+
return null;
|
|
1658
1880
|
}
|
|
1659
1881
|
|
|
1660
1882
|
// dragstart:只做「元素判定」——拖起已多选的行 = 整批拖动,否则单选。
|
|
1661
1883
|
// id 一律留到 drop 阶段解析(dataTransfer 只携带拖起这一行的 id)。
|
|
1662
|
-
|
|
1884
|
+
listen("dragstart", (e) => {
|
|
1663
1885
|
try {
|
|
1664
1886
|
const row = sessionRow(e.target);
|
|
1665
1887
|
if (!row) { dragging = null; return; }
|
|
@@ -1669,7 +1891,7 @@ window.__ModuleLoader__.load({
|
|
|
1669
1891
|
} catch { dragging = null; }
|
|
1670
1892
|
});
|
|
1671
1893
|
|
|
1672
|
-
|
|
1894
|
+
listen("dragover", (e) => {
|
|
1673
1895
|
clearHints();
|
|
1674
1896
|
if (!dragging) return;
|
|
1675
1897
|
const header = headerRow(e.target);
|
|
@@ -1686,7 +1908,7 @@ window.__ModuleLoader__.load({
|
|
|
1686
1908
|
}
|
|
1687
1909
|
}, true);
|
|
1688
1910
|
|
|
1689
|
-
|
|
1911
|
+
listen("drop", async (e) => {
|
|
1690
1912
|
const current = dragging;
|
|
1691
1913
|
dragging = null;
|
|
1692
1914
|
clearHints();
|
|
@@ -1709,7 +1931,7 @@ window.__ModuleLoader__.load({
|
|
|
1709
1931
|
draggedId = candidates.map((value) => String(value).trim().split(/[\r\n]/)[0]).find(Boolean) || null;
|
|
1710
1932
|
} catch { draggedId = null; }
|
|
1711
1933
|
if (!draggedId) {
|
|
1712
|
-
return void toast(t("failed", { msg: "
|
|
1934
|
+
return void toast(t("failed", { msg: t("dragPayloadBad") }), true);
|
|
1713
1935
|
}
|
|
1714
1936
|
|
|
1715
1937
|
let workspace = null;
|
|
@@ -1730,12 +1952,15 @@ window.__ModuleLoader__.load({
|
|
|
1730
1952
|
} catch (err) {
|
|
1731
1953
|
return void toast(t("failed", { msg: errMsg(err) }), true);
|
|
1732
1954
|
}
|
|
1733
|
-
if (!workspace) return void toast(t("
|
|
1955
|
+
if (!workspace) return void toast(t("staleList"), true);
|
|
1734
1956
|
|
|
1735
1957
|
// ---- 组装移动清单:拖起行必有 id;多选行逐个经分组对齐解析 ----
|
|
1958
|
+
// 解析不出 id 的行**绝不猜测**(见 mapGroupRows 的安全约定):
|
|
1959
|
+
// 要么整体中止,要么在确认框里如实说明跳过了几行。
|
|
1736
1960
|
const picked = (current.els ?? [current.el]).filter((el) => el?.isConnected);
|
|
1737
1961
|
const sessions = [];
|
|
1738
1962
|
const seen = new Set();
|
|
1963
|
+
let unresolvedCount = 0;
|
|
1739
1964
|
const push = (id, title) => {
|
|
1740
1965
|
const key = String(id);
|
|
1741
1966
|
if (!key || seen.has(key)) return;
|
|
@@ -1745,20 +1970,30 @@ window.__ModuleLoader__.load({
|
|
|
1745
1970
|
if (picked.length > 1) {
|
|
1746
1971
|
for (const el of picked) {
|
|
1747
1972
|
if (el === current.el) continue; // 拖起行用 dataTransfer 的权威 id
|
|
1748
|
-
try {
|
|
1973
|
+
try {
|
|
1974
|
+
const id = await resolveRowId(el);
|
|
1975
|
+
if (id) push(id, rowTitle(el, ""));
|
|
1976
|
+
else unresolvedCount += 1;
|
|
1977
|
+
} catch { unresolvedCount += 1; }
|
|
1749
1978
|
}
|
|
1750
1979
|
}
|
|
1751
1980
|
// 权威 id 放首位;若某选中行被误映射到同一 id 会被去重吞掉
|
|
1981
|
+
const beforePush = sessions.length;
|
|
1752
1982
|
push(draggedId, rowTitle(current.el, draggedId));
|
|
1983
|
+
if (sessions.length === beforePush) unresolvedCount += 1; // 连拖起行都没解析出来
|
|
1984
|
+
|
|
1985
|
+
if (unresolvedCount > 0 && sessions.length === 0) {
|
|
1986
|
+
return void toast(t("idUnresolved"), true);
|
|
1987
|
+
}
|
|
1753
1988
|
|
|
1754
1989
|
if (sessions.length > 50) {
|
|
1755
|
-
return void toast(t("failed", { msg:
|
|
1990
|
+
return void toast(t("failed", { msg: t("batchTooMany", { max: 50, n: sessions.length }) }), true);
|
|
1756
1991
|
}
|
|
1757
1992
|
|
|
1758
1993
|
// ---- 单选:原有确认与 mover.move 路径 ----
|
|
1759
1994
|
if (sessions.length === 1) {
|
|
1760
1995
|
const { sessionId, sessionTitle } = sessions[0];
|
|
1761
|
-
const confirmed = await confirmMove({ sessionId, sessionTitle, workspace });
|
|
1996
|
+
const confirmed = await confirmMove({ sessionId, sessionTitle, workspace, unresolvedCount });
|
|
1762
1997
|
if (!confirmed) return;
|
|
1763
1998
|
try {
|
|
1764
1999
|
const res = await rpcCall("mover.move", { sessionId, sessionTitle, targetWorkspaceId: workspace.workspaceId });
|
|
@@ -1780,7 +2015,7 @@ window.__ModuleLoader__.load({
|
|
|
1780
2015
|
}
|
|
1781
2016
|
|
|
1782
2017
|
// ---- 多选:批量确认 + mover.moveMany(逐条独立备份回滚)----
|
|
1783
|
-
const confirmed = await confirmMove({ workspace, batchCount: sessions.length });
|
|
2018
|
+
const confirmed = await confirmMove({ workspace, batchCount: sessions.length, unresolvedCount });
|
|
1784
2019
|
if (!confirmed) return;
|
|
1785
2020
|
try {
|
|
1786
2021
|
const res = await rpcCall("mover.moveMany", { sessions, targetWorkspaceId: workspace.workspaceId });
|
|
@@ -1803,7 +2038,7 @@ window.__ModuleLoader__.load({
|
|
|
1803
2038
|
}
|
|
1804
2039
|
}, true);
|
|
1805
2040
|
|
|
1806
|
-
|
|
2041
|
+
listen("dragend", () => { dragging = null; clearHints(); });
|
|
1807
2042
|
|
|
1808
2043
|
// 整组迁移 + 分组合并:入口在官方组标题的「⋯」菜单(注入「整组迁移…」项)。
|
|
1809
2044
|
// 不再拦截浏览器右键——官方菜单同样在右键/⋯按钮打开,拦截会与之冲突。
|
|
@@ -1855,7 +2090,7 @@ window.__ModuleLoader__.load({
|
|
|
1855
2090
|
// 记录「⋯」按钮点击所在的标题行(标题行内唯一带 aria-label 的按钮),
|
|
1856
2091
|
// 随后出现的菜单 portal 即属于这个分组;1 秒内未出现菜单则作废。
|
|
1857
2092
|
let menuHeaderRow = null;
|
|
1858
|
-
|
|
2093
|
+
listen("click", (e) => {
|
|
1859
2094
|
const header = headerRow(e.target);
|
|
1860
2095
|
if (!header) return;
|
|
1861
2096
|
const btn = e.target.closest?.("button[aria-label]");
|
|
@@ -1890,7 +2125,7 @@ window.__ModuleLoader__.load({
|
|
|
1890
2125
|
try {
|
|
1891
2126
|
const list = await fetchWorkspaces();
|
|
1892
2127
|
const ws = resolveWorkspace(header, list);
|
|
1893
|
-
if (!ws) return void toast(t("
|
|
2128
|
+
if (!ws) return void toast(t("staleList"), true);
|
|
1894
2129
|
const res = await rpcCall("mover.openFolder", { workspaceId: ws.workspaceId, path: ws.path });
|
|
1895
2130
|
if (!res?.ok) toast(t("failed", { msg: res?.error?.message ?? "open failed" }), true);
|
|
1896
2131
|
} catch (err) {
|
|
@@ -1923,8 +2158,10 @@ window.__ModuleLoader__.load({
|
|
|
1923
2158
|
}
|
|
1924
2159
|
});
|
|
1925
2160
|
menuObserver.observe(document.body, { childList: true, subtree: true });
|
|
2161
|
+
disposers.push(() => { try { menuObserver.disconnect(); } catch { /* ignore */ } });
|
|
1926
2162
|
|
|
1927
2163
|
// 诊断句柄:控制台用 window.__wsmDebug 检查「最近更新」排序修复通道(排障用)
|
|
2164
|
+
const priorDebug = window.__wsmDebug;
|
|
1928
2165
|
try {
|
|
1929
2166
|
window.__wsmDebug = {
|
|
1930
2167
|
view: () => { const v = resolveViewStore(ctx); return v ? v.getSnapshot() : null; },
|
|
@@ -1937,6 +2174,19 @@ window.__ModuleLoader__.load({
|
|
|
1937
2174
|
} catch { /* ignore */ }
|
|
1938
2175
|
|
|
1939
2176
|
registerRescuePanel(ctx, rpcCall);
|
|
2177
|
+
|
|
2178
|
+
// teardown:宿主废弃本 fiber(桌面版热重载 / 客户端重组)时释放全部监听器。
|
|
2179
|
+
// 没有这一步,重新装载会叠加第二份监听器,且旧闭包连同 pickedRows /
|
|
2180
|
+
// dragging / 缓存永远不会被回收。
|
|
2181
|
+
if (typeof ctx.effect === "function") {
|
|
2182
|
+
ctx.effect(() => () => {
|
|
2183
|
+
applied = false;
|
|
2184
|
+
for (const dispose of disposers.splice(0)) {
|
|
2185
|
+
try { dispose(); } catch { /* 单个失败不影响其余 */ }
|
|
2186
|
+
}
|
|
2187
|
+
try { if (window.__wsmDebug && priorDebug === undefined) delete window.__wsmDebug; } catch { /* ignore */ }
|
|
2188
|
+
}, "workspace-mover: client listeners");
|
|
2189
|
+
}
|
|
1940
2190
|
}
|
|
1941
2191
|
|
|
1942
2192
|
module.exports = { inject, apply };
|