dsh-workspace-mover 2.0.3

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.
@@ -0,0 +1,1918 @@
1
+ // dsh-workspace-mover — client half(source-as-product,无需构建)
2
+ //
3
+ // 在侧边栏工作区树上启用「跨工作区拖拽迁移会话」:
4
+ // - 拖起一个会话行(div[role=treeitem][aria-selected])→ 悬停到其他工作区的标题行
5
+ // (div[role=treeitem][aria-expanded])→ 松手弹出确认框 → 调用 host RPC 执行真迁移。
6
+ // - 只依赖语义化 ARIA 属性定位行,不碰官方 CSS-module 哈希类名,升级更抗造。
7
+ // - 官方同组内拖拽排序完全不受影响(我们的 drop 处理只拦截「跨组」场景)。
8
+ window.__ModuleLoader__.load({
9
+ id: "dsh-workspace-mover",
10
+ factory: (require) => {
11
+ var module = { exports: {} };
12
+ var exports = module.exports;
13
+ const React = require("react");
14
+
15
+ const CHANNEL = "/workspace-mover";
16
+ const STYLE_ID = "dsh-workspace-mover-style";
17
+
18
+ const STRINGS = {
19
+ zh: {
20
+ confirmTitle: "跨工作区移动会话",
21
+ target: "目标工作区",
22
+ session: "会话",
23
+ hint: "将物理搬移原始会话档案并改写归属;历史记录原样保留,不产生副本。",
24
+ move: "移动",
25
+ cancel: "取消",
26
+ moving: "正在移动…",
27
+ done: "✓ 已移动到「{title}」;若侧边栏未自动刷新,请手动刷新页面",
28
+ rolledBack: "已自动回滚:{msg}",
29
+ failed: "移动失败:{msg}",
30
+ noTarget: "无法识别目标工作区(侧边栏结构变化?)",
31
+ ungroupedUnsupported: "暂不支持移动到「未分组」",
32
+ staleList: "工作区列表与侧边栏不一致,请重试",
33
+ restartHint: "缓存失效失败:归属可能需要重启 Harness 后才刷新",
34
+ rescueSection: "会话修复",
35
+ rescueTitle: "会话修复",
36
+ rescueShort: "扫描全部会话档案,找回在侧边栏里「消失」或没有分组的会话。",
37
+ rescueDetail: "两种常见情况:\n\n① 路径失效 —— 会话原来所在的文件夹被移动、改名或删除,侧边栏里就看不到它了(文件其实还在)。选一个分组点「移入该分组」,完整历史会原样搬过去。\n\n② 未归组 —— 会话文件完好,只是还没归入任何工作区分组。点「归入分组」即可修好,文件不会移动。\n\n每次操作前都会自动备份,失败自动回滚。",
38
+ scan: "重新扫描",
39
+ scanning: "扫描中…",
40
+ scannedN: "共 {n} 个会话档案",
41
+ orphanedCaption: "找不到原文件夹的会话",
42
+ unregCaption: "还没归入分组的会话",
43
+ misfiledCaption: "挂错分组的会话",
44
+ misfiledHelp: "这些会话的真实文件夹属于另一个分组,却被记在别的分组名下(常见于克隆式迁移工具或改名后重建分组)。点「归位」只修正归属记录,文件不动。",
45
+ misfiledLabel: "挂错分组",
46
+ homeBtn: "归位",
47
+ homeAllBtn: "全部归位",
48
+ homeAllDone: "✓ 已归位 {n} 个会话",
49
+ homedMsg: "✓ 已归位到「{ws}」",
50
+ mergeDeleteConfirm: "整组 {count} 个会话已迁入「{target}」,源分组「{title}」已空。删除这个空分组吗?",
51
+ mergeDone: "已删除空分组「{title}」",
52
+ groupMoveMenu: "整组迁移…",
53
+ stalePath: "路径失效",
54
+ ungroupedBadge: "未归组",
55
+ unreadable: "无法读取",
56
+ ghostsTitle: "名单里有、但文件已不在的记录",
57
+ ghostsHelp: "侧边栏的分组名单里记着这些会话,但对应的记录文件已经不在磁盘上(通常是被手动删除的)。一般不影响使用;重启 Harness 后通常会自动清理。",
58
+ helpPath: "会话原来的文件夹被移动、改名或删除,所以侧边栏看不到它。选择一个分组,历史会原样迁移过去,会话 ID 保持不变。",
59
+ helpUnreg: "会话文件完好,只是还没有归属到任何工作区分组。点击按钮即可归入匹配的分组,文件不会移动。",
60
+ pickTarget: "请先在下拉框选择要移入的分组",
61
+ relinkBtn: "移入该分组",
62
+ attachBtn: "归入分组",
63
+ relinked: "✓ 已迁入「{title}」,历史原样保留",
64
+ attachedMsg: "✓ 已归入「{ws}」",
65
+ selfHealed: "✓ 它本来就属于这个分组,已自动补好归属记录",
66
+ allClear: "一切正常,没有需要修复的会话。",
67
+ allWorkspaces: "选择要移入的分组…",
68
+ historySection: "最近移动",
69
+ historyHelp: "这里记录最近的跨工作区移动。点「撤回」会把会话移回原来的分组;撤回也会自动备份,失败不会丢数据。",
70
+ undoBtn: "撤回",
71
+ undoConfirm: "确定把这个会话移回原来的分组吗?",
72
+ undone: "✓ 已撤回移动",
73
+ historyEmpty: "还没有移动记录。",
74
+ unnamedSession: "未命名会话",
75
+ wsSection: "工作区体检",
76
+ wsHelp: "「路径失效」表示这个分组登记的文件夹已经在磁盘上被移动、改名或删除——它名下的会话也因此从侧边栏消失。在新路径栏填入文件夹现在的位置并点「搬家」:工作区原地改指向,名下全部会话连同仍留在旧位置的失联散件一起原样搬过去;每一步都先备份、失败自动回滚。中途被打断也不必重来:空闲后用同样的新路径再跑一次,向导只处理剩余部分。",
77
+ wsBadges: "路径失效",
78
+ wsMemberLine: "{n} 个会话记账在此",
79
+ wsNewPathPh: "新路径:文件夹现在所在的完整路径",
80
+ wizardBtn: "搬家",
81
+ wsNeedPath: "请先在右侧输入框填写新的文件夹路径",
82
+ wizardConfirm: "把「{title}」搬到这里吗?\n\n{from}\n→ {to}\n\n将原样迁移 {count} 个会话(ID 与历史不变),全程有备份保护。",
83
+ wizardDone: "✓ 「{title}」已搬家,{n} 个会话已就位",
84
+ wizardSkippedTail: ";另有 {n} 个本次跳过(运行中或异常),稍后用同样操作可续跑",
85
+ batchConfirmTitle: "批量移动会话",
86
+ batchHint: "将把 {count} 个会话真迁移到目标工作区;每个会话独立备份,失败自动回滚且不影响其余。",
87
+ batchMove: "全部移动",
88
+ batchDone: "✓ 成功 {n} 个",
89
+ batchFailTail: ";{n} 个未移动(详见结果)",
90
+ batchEntryTitle: "批量移动 {n} 个会话",
91
+ batchUndoPartial: ";{n} 个未能撤回(可重试)",
92
+ pickHint: "已选 {n} 个会话 · Ctrl+点击行可多选 · 拖到目标工作区标题行批量移动 · Esc 清空",
93
+ pickCleared: "已清空多选",
94
+ pickEscHint: "按 Esc 清空多选(输入框聚焦时无效)",
95
+ groupMoveTitle: "把「{title}」的会话移到…",
96
+ groupMoveHint: "整组会话将真迁移到目标分组;运行中的会话会被跳过并在结果中说明。",
97
+ groupMoveEmpty: "这个分组下没有可移动的会话。",
98
+ openFolderMenu: "打开文件夹",
99
+ archivedCaption: "已归档的会话",
100
+ archivedHelp: "这些会话被官方「归档」后从侧边栏消失,但文件和分组归属都还在。点「恢复」回到原来的分组,或选「恢复到…」换一个分组;恢复不会动文件。",
101
+ restoreBtn: "恢复",
102
+ restoreToBtn: "恢复到…",
103
+ restorePickTitle: "把「{title}」恢复到…",
104
+ restoreDone: "✓ 已恢复到「{ws}」",
105
+ ownerGoneBadge: "原分组已删除",
106
+ emptyWsCaption: "空分组",
107
+ emptyWsHelp: "这些分组名下一个会话都没有(归档的、名单里的幽灵记录都算成员,绝不会误报)。删除只移除分组登记,不影响任何会话。",
108
+ emptyWsDelete: "删除",
109
+ emptyWsDeleteAll: "全部删除",
110
+ emptyWsConfirmOne: "删除空分组「{title}」?\n\n{path}\n\n只移除分组登记,不影响任何会话。",
111
+ emptyWsConfirmAll: "删除全部 {n} 个空分组?\n\n只移除分组登记,不影响任何会话。",
112
+ emptyWsDone: "✓ 已删除 {n} 个空分组",
113
+ emptyWsStale: "「{title}」刚刚有了新成员,已跳过",
114
+ deleteBtn: "删除",
115
+ deleteConfirm: "把「{title}」移入回收站?\n\n回收站里的会话随时可以还原或彻底删除。",
116
+ deletedMsg: "✓ 已移入回收站",
117
+ trashCaption: "回收站",
118
+ trashHelp: "被删除的会话完整保留在这里(文件、标题、归属信息),可还原到原位置或任意其他分组;只有「彻底删除」才会真正清掉磁盘文件。",
119
+ purgeBtn: "彻底删除",
120
+ purgeConfirm: "彻底删除「{title}」?此操作不可撤销。",
121
+ purgeAllBtn: "清空回收站",
122
+ purgeAllConfirm: "彻底删除回收站里的全部 {n} 个会话?此操作不可撤销。",
123
+ purgedMsg: "✓ 已彻底删除 {n} 项",
124
+ backupsCaption: "备份",
125
+ backupsHelp: "每次迁移前自动生成的字节级备份都在这里。可以把会话恢复回备份时的原位置(若那里没有同 id 会话),或恢复到任意其他分组。",
126
+ backupMeta: "{n} 份 · {size} · {range}",
127
+ backupDeleteBtn: "删除备份",
128
+ backupDeleteConfirm: "删除「{title}」的 {n} 份备份?删除后无法再从备份恢复。",
129
+ backupDeletedMsg: "✓ 已删除 {n} 份备份",
130
+ residentRefuse: "该会话当前没有焦点也可能仍驻留在 Harness 内存中(归档只隐藏,不会卸载)。DSH 0.1.5 没有公开的按 ID 卸载接口,请重启 Harness 释放常驻会话后再删除。",
131
+ sessionBusy: "该会话正在处理中(可能有迁移 / 还原 / 删除操作尚未完成),请稍后再试。",
132
+ recoveryNote: "检测到 {n} 条自动回滚也失败的记录,需人工确认:会话文件与备份均保留在原处,未丢失。请勿手动清理 pluginData 目录,查看 recovery.json 或联系开发者处理。",
133
+ repairAllBtn: "一键修复",
134
+ repairAllDone: "✓ 已修复 {n} · 跳过 {s} · 失败 {f}",
135
+ skipNeedsTarget: "需选择目标分组",
136
+ filterPh: "筛选:标题 / 会话 ID / 路径…",
137
+ tasksCaption: "迁移任务",
138
+ tasksHelp: "批量迁移的持久化记录:完成 / 失败逐项可查。失败项可一键重试——按会话当前位置重新迁移,不会用记录里的旧路径;清除记录不影响已迁移的会话。",
139
+ taskRetryBtn: "重试失败项",
140
+ taskRetryDone: "✓ 重试完成:成功 {n} · 失败 {f}",
141
+ taskForgetBtn: "清除记录",
142
+ taskForgetConfirm: "清除这条任务记录?(不影响已迁移的会话)",
143
+ taskForgottenMsg: "✓ 已清除记录",
144
+ dataSummary: "数据保护:回收站 {r} 项({rSize})· 备份 {b} 份({bSize})",
145
+ dataCleanupBtn: "清理 30 天前的数据",
146
+ dataCleanupConfirm: "将清理 {r} 个回收站条目和 {b} 份备份(删除于 30 天前),释放 {size}。继续?",
147
+ dataCleanedMsg: "✓ 已清理 {r} 个回收站条目、{b} 份备份,释放 {size}"
148
+ },
149
+ en: {
150
+ confirmTitle: "Move session across workspaces",
151
+ target: "Target workspace",
152
+ session: "Session",
153
+ hint: "Physically relocates the original session archive; history stays intact, no copy is made.",
154
+ move: "Move",
155
+ cancel: "Cancel",
156
+ moving: "Moving…",
157
+ done: "✓ Moved to \"{title}\"; refresh the page if the sidebar does not update",
158
+ rolledBack: "Rolled back automatically: {msg}",
159
+ failed: "Move failed: {msg}",
160
+ noTarget: "Cannot resolve target workspace (sidebar layout changed?)",
161
+ ungroupedUnsupported: "Moving to \"Ungrouped\" is not supported yet",
162
+ staleList: "Workspace list out of sync with the sidebar; try again",
163
+ restartHint: "Cache invalidation failed: grouping may need a harness restart",
164
+ rescueSection: "Session Repair",
165
+ rescueTitle: "Session Repair",
166
+ rescueShort: "Scan all session archives and recover conversations that vanished from the sidebar or lack a group.",
167
+ rescueDetail: "Two common cases:\n\n① Stale path — the folder a session lived in was moved, renamed or deleted, so the sidebar can no longer show it (the file is still there). Pick a group and click \"Move there\"; the full history travels over with its ID intact.\n\n② Ungrouped — the session file is fine but belongs to no workspace group. One click files it into the matching group without touching any file.\n\nEvery operation is backed up first and rolled back automatically on failure.",
168
+ scan: "Rescan",
169
+ scanning: "Scanning…",
170
+ scannedN: "{n} session archives",
171
+ orphanedCaption: "Sessions whose original folder is gone",
172
+ unregCaption: "Sessions not in any group yet",
173
+ misfiledCaption: "Sessions filed under the wrong group",
174
+ misfiledHelp: "These sessions live in a folder that belongs to another group but are recorded elsewhere (common with clone-style movers or groups recreated after a folder rename). \"Home\" only fixes the bookkeeping; files are not touched.",
175
+ misfiledLabel: "Misfiled",
176
+ homeBtn: "Home",
177
+ homeAllBtn: "Home all",
178
+ homeAllDone: "✓ {n} sessions homed",
179
+ homedMsg: "✓ Homed to \"{ws}\"",
180
+ mergeDeleteConfirm: "{count} sessions moved to \"{target}\" and the source group \"{title}\" is now empty. Delete this empty group?",
181
+ mergeDone: "Deleted empty group \"{title}\"",
182
+ groupMoveMenu: "Move whole group…",
183
+ stalePath: "Stale path",
184
+ ungroupedBadge: "Ungrouped",
185
+ unreadable: "Unreadable",
186
+ ghostsTitle: "Listed in groups but missing on disk",
187
+ 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.",
188
+ 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.",
189
+ helpUnreg: "The session file is fine but no workspace claims it yet. Click to file it into the matching group; files are not moved.",
190
+ pickTarget: "Pick a target group in the dropdown first",
191
+ relinkBtn: "Move there",
192
+ attachBtn: "File into group",
193
+ relinked: "✓ Moved into \"{title}\"; history preserved as-is",
194
+ attachedMsg: "✓ Filed under \"{ws}\"",
195
+ selfHealed: "✓ It already belonged to this group; its accounting was repaired automatically",
196
+ allClear: "All good — nothing to repair.",
197
+ allWorkspaces: "Pick a group to move into…",
198
+ historySection: "Recent moves",
199
+ historyHelp: "These are recent cross-workspace moves. Undo sends a session back to its original group and uses the same backup and rollback protection.",
200
+ undoBtn: "Undo",
201
+ undoConfirm: "Send this session back to its original group?",
202
+ undone: "✓ Move undone",
203
+ historyEmpty: "No move history yet.",
204
+ unnamedSession: "Untitled session",
205
+ wsSection: "Workspace health",
206
+ wsHelp: "\"Missing folder\" means the directory this group points to was moved, renamed or deleted on disk — which is also why its sessions vanished from the sidebar. Type the folder's current location and press \"Move home\": the workspace is re-pointed in place and every session under it — including stranded ones still at the old path — travels over intact. Each step backs up first and rolls back automatically. If the run is ever interrupted, simply run it again with the same new path; the wizard picks up only what remains.",
207
+ wsBadges: "Missing folder",
208
+ wsMemberLine: "{n} sessions accounted here",
209
+ wsNewPathPh: "New path: full path of the folder's current location",
210
+ wizardBtn: "Move home",
211
+ wsNeedPath: "Type the new folder path into the input first",
212
+ wizardConfirm: "Move \"{title}\" here?\n\n{from}\n→ {to}\n\n{count} sessions will be migrated as-is (IDs and history unchanged), with backup protection throughout.",
213
+ wizardDone: "✓ \"{title}\" moved home — {n} sessions in place",
214
+ wizardSkippedTail: "; {n} skipped this run (running or unusual) — repeat later with the same inputs to finish",
215
+ batchConfirmTitle: "Move sessions in bulk",
216
+ batchHint: "{count} sessions will be truly moved to the target workspace; each is backed up independently and failures roll back without touching the rest.",
217
+ batchMove: "Move all",
218
+ batchDone: "✓ {n} moved",
219
+ batchFailTail: "; {n} not moved (see results)",
220
+ batchEntryTitle: "{n} sessions (batch move)",
221
+ batchUndoPartial: "; {n} not undone (retry available)",
222
+ pickHint: "{n} sessions picked · Ctrl+click rows to multi-select · drag onto a workspace header to move in bulk · Esc to clear",
223
+ pickCleared: "Selection cleared",
224
+ pickEscHint: "Press Esc to clear the multi-selection (not while typing in the composer)",
225
+ groupMoveTitle: "Move sessions of \"{title}\" to…",
226
+ groupMoveHint: "The whole group will be truly moved to the target; running sessions are skipped and noted in the result.",
227
+ groupMoveEmpty: "No movable sessions in this group.",
228
+ openFolderMenu: "Open folder",
229
+ archivedCaption: "Archived sessions",
230
+ 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.",
231
+ restoreBtn: "Restore",
232
+ restoreToBtn: "Restore to…",
233
+ restorePickTitle: "Restore \"{title}\" to…",
234
+ restoreDone: "✓ Restored to \"{ws}\"",
235
+ ownerGoneBadge: "Original group deleted",
236
+ emptyWsCaption: "Empty groups",
237
+ emptyWsHelp: "These groups hold no sessions at all (archived ones and ghost roster entries count as members, so this never misreports). Deleting only removes the group registration; no session is touched.",
238
+ emptyWsDelete: "Delete",
239
+ emptyWsDeleteAll: "Delete all",
240
+ emptyWsConfirmOne: "Delete empty group \"{title}\"?\n\n{path}\n\nOnly the group registration is removed; no session is touched.",
241
+ emptyWsConfirmAll: "Delete all {n} empty groups?\n\nOnly the group registration is removed; no session is touched.",
242
+ emptyWsDone: "✓ Deleted {n} empty group(s)",
243
+ emptyWsStale: "\"{title}\" gained members just now; skipped",
244
+ deleteBtn: "Delete",
245
+ deleteConfirm: "Move \"{title}\" to the recycle bin?\n\nTrashed sessions can be restored or purged at any time.",
246
+ deletedMsg: "✓ Moved to the recycle bin",
247
+ trashCaption: "Recycle bin",
248
+ trashHelp: "Deleted sessions are kept here whole (files, title, membership). Restore them to the original spot or any other group; only \"Purge\" actually deletes files from disk.",
249
+ purgeBtn: "Purge",
250
+ purgeConfirm: "Purge \"{title}\"? This cannot be undone.",
251
+ purgeAllBtn: "Empty recycle bin",
252
+ purgeAllConfirm: "Purge all {n} sessions in the recycle bin? This cannot be undone.",
253
+ purgedMsg: "✓ Purged {n} item(s)",
254
+ backupsCaption: "Backups",
255
+ 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.",
256
+ backupMeta: "{n} copies · {size} · {range}",
257
+ backupDeleteBtn: "Delete backups",
258
+ backupDeleteConfirm: "Delete the {n} backup copies of \"{title}\"? They cannot be restored from afterwards.",
259
+ backupDeletedMsg: "✓ Deleted {n} backup copy(ies)",
260
+ 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.",
261
+ sessionBusy: "This session is busy: a move / restore / delete is still in flight. Try again in a moment.",
262
+ 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.",
263
+ repairAllBtn: "Fix all",
264
+ repairAllDone: "✓ Fixed {n} · skipped {s} · failed {f}",
265
+ skipNeedsTarget: "needs a target group",
266
+ filterPh: "Filter: title / session id / path…",
267
+ tasksCaption: "Move tasks",
268
+ 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.",
269
+ taskRetryBtn: "Retry failed",
270
+ taskRetryDone: "✓ Retry finished: {n} succeeded · {f} failed",
271
+ taskForgetBtn: "Clear record",
272
+ taskForgetConfirm: "Clear this task record? (moved sessions are not touched)",
273
+ taskForgottenMsg: "✓ Record cleared",
274
+ dataSummary: "Data protection: recycle bin {r} items ({rSize}) · backups {b} copies ({bSize})",
275
+ dataCleanupBtn: "Clean data older than 30 days",
276
+ dataCleanupConfirm: "This will purge {r} recycle entries and {b} backups (older than 30 days), freeing {size}. Continue?",
277
+ dataCleanedMsg: "✓ Purged {r} recycle entries and {b} backups, freed {size}"
278
+ }
279
+ };
280
+ const lang = (navigator.language || "en").toLowerCase().startsWith("zh") ? "zh" : "en";
281
+ const t = (key, vars) => {
282
+ let s = STRINGS[lang][key] ?? key;
283
+ for (const [k, v] of Object.entries(vars ?? {})) s = s.replaceAll(`{${k}}`, String(v));
284
+ return s;
285
+ };
286
+
287
+ function ensureStyle() {
288
+ if (document.getElementById(STYLE_ID)) return;
289
+ const style = document.createElement("style");
290
+ style.id = STYLE_ID;
291
+ // 全部颜色走宿主 dsw 设计令牌(定义在 body 上、随设置里的外观切换即时
292
+ // 重解析),仅在令牌缺失时回退到浅色常量;无需监听主题变化。
293
+ style.textContent = `
294
+ .wsm-drop-hint{outline:2px dashed var(--dsw-alias-state-business-primary,#4176e6)!important;outline-offset:-2px;border-radius:8px}
295
+ .wsm-picked{outline:2px solid var(--dsw-alias-state-business-primary,#4176e6)!important;outline-offset:-2px;border-radius:8px;background:var(--dsw-alias-interactive-bg-selected,rgba(65,118,230,.12))!important}
296
+ .wsm-pick-badge{flex:none;font-size:10px;line-height:1;padding:2px 6px;border-radius:99px;background:var(--dsw-alias-state-business-primary,#4176e6);color:var(--dsw-alias-label-primary-foreground,#fff);pointer-events:none}
297
+ .wsm-batch-count{position:fixed;left:12px;bottom:12px;z-index:2147483500;background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#111);border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:10px;padding:8px 14px;font-size:12.5px;box-shadow:0 8px 24px rgba(0,0,0,.15)}
298
+ .wsm-overlay{position:fixed;inset:0;background:var(--dsw-alias-bg-mask-1,rgba(0,0,0,.35));z-index:2147483000;display:flex;align-items:center;justify-content:center;font-family:inherit}
299
+ .wsm-card{background:var(--dsw-specific-menu,#fff);color:var(--dsw-alias-label-primary,#111);
300
+ border:1px solid var(--dsw-alias-border-l3,rgba(0,0,0,.12));border-radius:14px;min-width:340px;max-width:440px;
301
+ padding:18px 20px;box-shadow:0 12px 40px rgba(0,0,0,.25)}
302
+ .wsm-title{font-size:15px;font-weight:600;margin-bottom:12px}
303
+ .wsm-row{font-size:13px;line-height:20px;margin:6px 0;word-break:break-all}
304
+ .wsm-label{color:var(--dsw-alias-label-secondary,#777)}
305
+ .wsm-hint{font-size:12px;color:var(--dsw-alias-label-tertiary,#999);margin-top:10px;line-height:17px}
306
+ .wsm-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
307
+ .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);
308
+ border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer}
309
+ .wsm-btn:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05))}
310
+ .wsm-btn.primary{background:var(--dsw-alias-button-primary-fill,#111);color:var(--dsw-alias-label-primary-foreground,#fff);border-color:transparent}
311
+ .wsm-btn.primary:hover{background:var(--dsw-alias-button-primary-hover,#333)}
312
+ .wsm-btn:disabled{opacity:.5;cursor:default}
313
+ .wsm-toast{position:fixed;left:50%;transform:translateX(-50%);bottom:28px;z-index:2147483600;
314
+ background:var(--dsw-alias-bg-layer-2,#fff);color:var(--dsw-alias-label-primary,#111);
315
+ border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:10px;padding:9px 16px;font-size:13px;
316
+ box-shadow:0 8px 24px rgba(0,0,0,.2);max-width:70vw}
317
+ .wsm-toast.err{border-color:var(--dsw-alias-state-error-primary,#dc2626)}
318
+ .wsm-panel{color:var(--dsw-alias-label-primary,#111)}
319
+ .wsm-panel h3{font-size:14px;font-weight:600;margin:0 0 8px}
320
+ .wsm-scanrow{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:10px 0 12px}
321
+ .wsm-btn.small{padding:4px 10px;font-size:12px}
322
+ .wsm-list{display:flex;flex-direction:column;gap:6px;margin-bottom:14px}
323
+ .wsm-item{display:flex;gap:8px;align-items:center;font-size:12.5px;padding:6px 9px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:8px;background:var(--dsw-alias-bg-layer-1,#fafafa)}
324
+ .wsm-mono{font-family:ui-monospace,Consolas,Menlo,monospace}
325
+ .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)}
326
+ .wsm-badge.err{color:var(--dsw-alias-state-error-primary,#dc2626);border-color:currentColor}
327
+ .wsm-cwd{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,#999)}
328
+ .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}
329
+ .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}
330
+ .wsm-input::placeholder{color:var(--dsw-alias-label-tertiary,#999)}
331
+ .wsm-note{margin-top:8px;font-size:12.5px;color:var(--dsw-alias-label-secondary,#666);white-space:pre-wrap;word-break:break-all}
332
+ .wsm-caption{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#666);margin-bottom:6px}
333
+ .wsm-help{flex:none;display:inline-flex;width:15px;height:15px;border-radius:50%;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.2));align-items:center;justify-content:center;font-size:10px;font-weight:400;color:var(--dsw-alias-label-tertiary,#999);cursor:help;user-select:none}
334
+ .wsm-history{margin-top:16px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1))}
335
+ `;
336
+ document.head.appendChild(style);
337
+ }
338
+
339
+ function toast(text, isError) {
340
+ const el = document.createElement("div");
341
+ el.className = "wsm-toast" + (isError ? " err" : "");
342
+ el.textContent = text;
343
+ document.body.appendChild(el);
344
+ setTimeout(() => el.remove(), isError ? 7000 : 4200);
345
+ }
346
+
347
+ /** 行元素判定:会话行 vs 工作区标题行(基于 ARIA 语义,非哈希类名)。 */
348
+ const sessionRow = (el) => el?.closest?.('div[role="treeitem"][aria-selected]');
349
+ const headerRow = (el) => el?.closest?.('div[role="treeitem"][aria-expanded]');
350
+
351
+ /**
352
+ * 解析官方侧边栏的视图 store 实例(含 actions.syncSessionOrderAccount 与 getSnapshot)。
353
+ * 正式通道:sidebar.workspaces 槽位注册项自带 store 句柄,经 ctx.slots.resolveStore 取实例;
354
+ * 槽位通道不可用时兜底走 React fiber 爬升找官方组件 props。全部 fail-soft。
355
+ */
356
+ function resolveViewStore(ctx) {
357
+ try {
358
+ const entry = ctx.slots?.entries?.("sidebar.workspaces")?.find?.((e) => e?.store);
359
+ if (entry) return ctx.slots.resolveStore(entry.store);
360
+ } catch { /* ignore */ }
361
+ try {
362
+ const anchor = document.querySelector('div[role="treeitem"][aria-expanded]');
363
+ const fiberKey = anchor && Object.keys(anchor).find((k) => k.startsWith("__reactFiber$"));
364
+ if (!fiberKey) return null;
365
+ for (let fiber = anchor[fiberKey]; fiber; fiber = fiber.return) {
366
+ const p = fiber.memoizedProps;
367
+ if (p && typeof p.syncSessionOrderAccount === "function") {
368
+ return {
369
+ actions: { syncSessionOrderAccount: p.syncSessionOrderAccount },
370
+ getSnapshot: () => ({
371
+ orderBy: p.orderBy,
372
+ groupBy: undefined,
373
+ sessionOrderByAccount: p.sessionOrderByAccount,
374
+ sessionUpdatedAtByAccount: p.sessionUpdatedAtByAccount
375
+ })
376
+ };
377
+ }
378
+ }
379
+ } catch { /* ignore */ }
380
+ return null;
381
+ }
382
+
383
+ /** 复刻官方 compareSessionRecency:updatedAt 降序,id 升序平局裁决。 */
384
+ function recencyCompare(stamps) {
385
+ return (a, b) => {
386
+ const av = stamps?.[a] ?? Number.NEGATIVE_INFINITY;
387
+ const bv = stamps?.[b] ?? Number.NEGATIVE_INFINITY;
388
+ if (av !== bv) return bv - av;
389
+ return a < b ? -1 : 1;
390
+ };
391
+ }
392
+
393
+ /**
394
+ * 官方侧边栏为每个工作区维护「最近更新」排序账本(sessionOrderByAccount / sessionUpdatedAtByAccount);
395
+ * 新移入的会话必然不在账本里,会被官方"活跃提升"策略错误置顶,且账本记住该顺序。
396
+ * 经官方 store action 修正该工作区的账本:成员时间戳齐全时直接写入正确的最近更新顺序
397
+ * (成员 = 账本现有顺序 ∪ 本次移入的会话;官方同步后账本时间戳覆盖全部非空白成员),
398
+ * 否则清空账本让官方 reconcile 全量重排(等价于用户手动切换一次排序)。
399
+ */
400
+ function scheduleRecencyFix(ctx, workspaceId, movedIds = []) {
401
+ if (!workspaceId) return;
402
+ const attempt = () => {
403
+ try {
404
+ const view = resolveViewStore(ctx);
405
+ if (!view) {
406
+ console.debug("[workspace-mover] recency fix: view store not found");
407
+ return;
408
+ }
409
+ const snap = view.getSnapshot();
410
+ // flat 模式没有跨组"提升"行为;手动排序绝不能动用户自定义顺序
411
+ if (snap.orderBy !== "updated" || snap.groupBy === "flat") return;
412
+ const stamps = snap.sessionUpdatedAtByAccount?.[workspaceId];
413
+ const members = [...new Set([...(snap.sessionOrderByAccount?.[workspaceId] ?? []), ...movedIds])];
414
+ if (members.length > 0 && stamps && members.every((id) => Object.hasOwn(stamps, id))) {
415
+ // 时间戳齐全:直接写入正确的「最近更新」顺序
416
+ const order = [...members].sort(recencyCompare(stamps));
417
+ const updatedAt = {};
418
+ for (const id of members) updatedAt[id] = stamps[id];
419
+ view.actions.syncSessionOrderAccount(workspaceId, order, updatedAt);
420
+ console.debug("[workspace-mover] recency fix: rewrote order for", workspaceId);
421
+ } else {
422
+ // 变更帧尚未处理完(账本缺新会话的时间戳):清空账本,官方 reconcile 全量重排
423
+ view.actions.syncSessionOrderAccount(workspaceId, [], {});
424
+ console.debug("[workspace-mover] recency fix: wiped account for", workspaceId);
425
+ }
426
+ } catch (err) {
427
+ console.debug("[workspace-mover] recency fix failed:", err?.message ?? err);
428
+ }
429
+ };
430
+ // 工作区变更帧可能晚于 RPC 返回;多试几次覆盖竞态,最后一试必然在帧落地后
431
+ attempt();
432
+ setTimeout(attempt, 400);
433
+ setTimeout(attempt, 1200);
434
+ }
435
+
436
+ /**
437
+ * DSH 0.1.5 的工作区 feed 是最终来源,但批量迁移会先收到源组移除帧,
438
+ * 目标组 upsert 可能在下一个微任务才到达。用公开的 no-op reorder API
439
+ * 取回宿主确认的完整目标投影,避免界面短暂把会话放进“未分组”。
440
+ */
441
+ async function reconcileWorkspaceProjection(ctx, workspaceId, sessionIds = []) {
442
+ const ids = [...new Set((Array.isArray(sessionIds) ? sessionIds : [])
443
+ .map((id) => String(id ?? "")).filter(Boolean))];
444
+ if (!workspaceId || ids.length === 0) return false;
445
+ const workspaces = ctx?.get?.("workspaces");
446
+ const list = workspaces?.list;
447
+ const ready = () => {
448
+ try {
449
+ const snapshot = list?.getSnapshot?.();
450
+ const item = snapshot?.items?.find?.((candidate) => String(candidate.workspaceId) === String(workspaceId));
451
+ const members = new Set((item?.sessionIds ?? []).map((id) => String(id)));
452
+ return Boolean(item) && ids.every((id) => members.has(id));
453
+ } catch {
454
+ return false;
455
+ }
456
+ };
457
+ if (ready()) return true;
458
+ if (typeof workspaces?.insertSessionBefore !== "function") return false;
459
+ try {
460
+ // beforeSessionId === sessionId 是官方定义的幂等 no-op,仍返回完整 WorkspaceView。
461
+ await workspaces.insertSessionBefore(workspaceId, ids[0], ids[0]);
462
+ return ready();
463
+ } catch (err) {
464
+ console.debug("[workspace-mover] projection reconcile skipped:", err?.message ?? err);
465
+ return false;
466
+ }
467
+ }
468
+
469
+ function injectOverlay() {
470
+ const overlay = document.createElement("div");
471
+ overlay.className = "wsm-overlay";
472
+ overlay.innerHTML = `
473
+ <div class="wsm-card" role="dialog" aria-modal="true">
474
+ <div class="wsm-title"></div>
475
+ <div class="wsm-row"><span class="wsm-label"></span> <b class="wsm-session"></b></div>
476
+ <div class="wsm-row"><span class="wsm-label"></span> <b class="wsm-target"></b></div>
477
+ <div class="wsm-hint"></div>
478
+ <div class="wsm-actions">
479
+ <button class="wsm-btn wsm-cancel"></button>
480
+ <button class="wsm-btn primary wsm-ok"></button>
481
+ </div>
482
+ </div>`;
483
+ const [rowS, rowT] = overlay.querySelectorAll(".wsm-row > .wsm-label");
484
+ rowS.textContent = t("session") + ":";
485
+ rowT.textContent = t("target") + ":";
486
+ overlay.querySelector(".wsm-cancel").textContent = t("cancel");
487
+ // 标题/正文/确认按钮文案由 confirmMove 按单条或批量场景写入
488
+ return overlay;
489
+ }
490
+
491
+ function confirmMove({ sessionId, sessionTitle, workspace, batchCount }) {
492
+ return new Promise((resolve) => {
493
+ const overlay = injectOverlay();
494
+ const okBtn = overlay.querySelector(".wsm-ok");
495
+ overlay.querySelector(".wsm-title").textContent = batchCount ? t("batchConfirmTitle") : t("confirmTitle");
496
+ overlay.querySelector(".wsm-session").textContent = batchCount
497
+ ? t("scannedN", { n: batchCount })
498
+ : (sessionTitle || t("unnamedSession"));
499
+ overlay.querySelector(".wsm-target").textContent = `${workspace.title}(${workspace.path})`;
500
+ overlay.querySelector(".wsm-hint").textContent = batchCount ? t("batchHint", { count: batchCount }) : t("hint");
501
+ okBtn.textContent = batchCount ? t("batchMove") : t("move");
502
+ const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
503
+ const onKey = (e) => { if (e.key === "Escape") close(null); };
504
+ document.addEventListener("keydown", onKey);
505
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(null); });
506
+ overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
507
+ okBtn.addEventListener("click", () => close(true));
508
+ document.body.appendChild(overlay);
509
+ });
510
+ }
511
+
512
+ /** 整组搬移对话框:选目标分组 → 确认。resolve(workspaceId) 或 resolve(null)。 */
513
+ function confirmGroupMove({ workspace, workspaces, count }) {
514
+ return new Promise((resolve) => {
515
+ const overlay = injectOverlay();
516
+ overlay.querySelector(".wsm-title").textContent = t("groupMoveTitle", { title: workspace.title });
517
+ overlay.querySelector(".wsm-session").textContent = t("scannedN", { n: count });
518
+ overlay.querySelector(".wsm-hint").textContent = t("groupMoveHint");
519
+ const targetRow = overlay.querySelectorAll(".wsm-row")[1];
520
+ targetRow.querySelector(".wsm-target").replaceWith((() => {
521
+ const select = document.createElement("select");
522
+ select.className = "wsm-select";
523
+ select.style.maxWidth = "260px";
524
+ select.append(new Option(t("allWorkspaces"), ""));
525
+ for (const w of workspaces) {
526
+ if (w.workspaceId === workspace.workspaceId) continue;
527
+ select.append(new Option(`${w.title}(${w.path})`, w.workspaceId));
528
+ }
529
+ return select;
530
+ })());
531
+ const okBtn = overlay.querySelector(".wsm-ok");
532
+ okBtn.textContent = t("batchMove");
533
+ okBtn.disabled = true;
534
+ const select = overlay.querySelector("select.wsm-select");
535
+ select.addEventListener("change", () => { okBtn.disabled = !select.value; });
536
+ const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
537
+ const onKey = (e) => { if (e.key === "Escape") close(null); };
538
+ document.addEventListener("keydown", onKey);
539
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(null); });
540
+ overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
541
+ okBtn.addEventListener("click", () => close(select.value || null));
542
+ document.body.appendChild(overlay);
543
+ select.focus();
544
+ });
545
+ }
546
+
547
+ /** 归档恢复目标选择对话框:列出全部分组(默认选中归位建议)。resolve(workspaceId) 或 resolve(null)。 */
548
+ function pickRestoreTarget({ item, workspaces, suggestId }) {
549
+ return new Promise((resolve) => {
550
+ const overlay = injectOverlay();
551
+ overlay.querySelector(".wsm-title").textContent = t("restorePickTitle", { title: item.title || t("unnamedSession") });
552
+ overlay.querySelector(".wsm-session").textContent = item.ownerTitle ?? t("ownerGoneBadge");
553
+ overlay.querySelector(".wsm-hint").textContent = item.cwd ?? "";
554
+ const targetRow = overlay.querySelectorAll(".wsm-row")[1];
555
+ targetRow.querySelector(".wsm-target").replaceWith((() => {
556
+ const select = document.createElement("select");
557
+ select.className = "wsm-select";
558
+ select.style.maxWidth = "260px";
559
+ select.append(new Option(t("allWorkspaces"), ""));
560
+ for (const w of workspaces) {
561
+ select.append(new Option(`${w.title}(${w.path})`, w.workspaceId));
562
+ }
563
+ if (suggestId) select.value = suggestId;
564
+ return select;
565
+ })());
566
+ const okBtn = overlay.querySelector(".wsm-ok");
567
+ okBtn.textContent = t("restoreBtn");
568
+ okBtn.disabled = true;
569
+ const select = overlay.querySelector("select.wsm-select");
570
+ select.addEventListener("change", () => { okBtn.disabled = !select.value; });
571
+ const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
572
+ const onKey = (e) => { if (e.key === "Escape") close(null); };
573
+ document.addEventListener("keydown", onKey);
574
+ overlay.addEventListener("click", (e) => { if (e.target === overlay) close(null); });
575
+ overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
576
+ okBtn.addEventListener("click", () => close(select.value || null));
577
+ document.body.appendChild(overlay);
578
+ if (select.value) okBtn.disabled = false;
579
+ select.focus();
580
+ });
581
+ }
582
+
583
+ //#region v0.3 设置页「会话修复」面板
584
+ const { useState, useEffect } = React;
585
+ const h = (tag, props, ...children) => React.createElement(tag, props ?? null, ...children);
586
+
587
+ function shortId(id) {
588
+ return String(id ?? "?").slice(0, 13) + "…";
589
+ }
590
+
591
+ function rowTitle(row, sessionId) {
592
+ const text = row?.textContent?.replace(/\s+/g, " ").trim();
593
+ return text && text !== sessionId ? text : t("unnamedSession");
594
+ }
595
+
596
+ function fmtBytes(n) {
597
+ if (!Number.isFinite(n) || n <= 0) return "0 B";
598
+ if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`;
599
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
600
+ return `${n} B`;
601
+ }
602
+
603
+ function fmtRange(item) {
604
+ const from = item.oldest ? new Date(item.oldest).toLocaleDateString() : "";
605
+ const to = item.newest ? new Date(item.newest).toLocaleDateString() : "";
606
+ return from === to ? from : `${from} ~ ${to}`;
607
+ }
608
+
609
+ /** 「?」悬浮说明按钮:hover/长按显示白话解释,不占版面。 */
610
+ const HelpDot = ({ text }) => h("span", { className: "wsm-help", title: text }, "?");
611
+
612
+ function Caption({ text, help }) {
613
+ return h("div", { className: "wsm-caption" }, text, help ? h(HelpDot, { text: help }) : null);
614
+ }
615
+
616
+ function RescuePanel({ rpcCall, ctx }) {
617
+ const [scan, setScan] = useState(null);
618
+ const [workspaces, setWorkspaces] = useState([]);
619
+ const [audit, setAudit] = useState(null);
620
+ const [wsInputs, setWsInputs] = useState({});
621
+ const [busy, setBusy] = useState(false);
622
+ const [picked, setPicked] = useState({});
623
+ const [note, setNote] = useState("");
624
+ const [history, setHistory] = useState([]);
625
+ const [archived, setArchived] = useState(null);
626
+ const [trash, setTrash] = useState(null);
627
+ const [backups, setBackups] = useState(null);
628
+ const [tasks, setTasks] = useState(null);
629
+ const [query, setQuery] = useState("");
630
+
631
+ const call = async (endpoint, payload) => {
632
+ const res = await rpcCall(endpoint, payload ?? {});
633
+ if (!res?.ok) {
634
+ // v1.4 错误码协议:code 随异常一起抛出,客户端按 code 决定交互(文案仅兜底)
635
+ const err = new Error(res?.error?.message ?? endpoint);
636
+ err.code = res?.error?.code;
637
+ throw err;
638
+ }
639
+ return res.value;
640
+ };
641
+
642
+ // 面板改动落账后主动重拉工作区基线(公开 API):实时变更帧链路偶发不落地,
643
+ // 不重拉的话侧边栏可能把会话先归到「未分组」,要刷新网页才归位。
644
+ const refreshWorkspaces = () => { try { ctx?.get?.("workspaces")?.refresh?.(); } catch { /* ignore */ } };
645
+
646
+ const runScan = async () => {
647
+ setBusy(true);
648
+ try {
649
+ 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")]);
650
+ setScan(s);
651
+ setWorkspaces(w.items ?? []);
652
+ setHistory(h.items ?? []);
653
+ setAudit(a);
654
+ setArchived(ar);
655
+ setTrash(tr);
656
+ setBackups(bp);
657
+ setTasks(tk);
658
+ } catch (err) {
659
+ setNote(t("failed", { msg: err?.message ?? err }));
660
+ } finally {
661
+ setBusy(false);
662
+ }
663
+ };
664
+
665
+ useEffect(() => { void runScan(); }, []);
666
+
667
+ const relink = async (item) => {
668
+ const target = picked[item.sessionId];
669
+ if (!target) return void setNote(t("pickTarget"));
670
+ setBusy(true);
671
+ setNote("");
672
+ try {
673
+ const res = await rpcCall("mover.move", { sessionId: item.sessionId, sessionTitle: item.title, targetWorkspaceId: target });
674
+ if (!res?.ok) {
675
+ const err = new Error(res?.error?.message ?? "move failed");
676
+ err.code = res?.error?.code;
677
+ throw err;
678
+ }
679
+ const wsTitle = workspaces.find((w) => w.workspaceId === target)?.title ?? "";
680
+ setNote(res.value?.attached ? t("selfHealed") : t("relinked", { title: wsTitle }));
681
+ refreshWorkspaces();
682
+ scheduleRecencyFix(ctx, target, [item.sessionId]);
683
+ await runScan();
684
+ } catch (err) {
685
+ failNote(err);
686
+ } finally {
687
+ setBusy(false);
688
+ }
689
+ };
690
+
691
+ const attach = async (item) => {
692
+ setBusy(true);
693
+ setNote("");
694
+ try {
695
+ const res = await call("mover.repair", { actions: [{ sessionId: item.sessionId, kind: "attach" }] });
696
+ const r = res.results?.[0];
697
+ if (!r?.ok) throw new Error(r?.error ?? "attach failed");
698
+ const wsTitle = workspaces.find((w) => w.workspaceId === r.attachedTo)?.title ?? r.attachedTo ?? "";
699
+ setNote(t("attachedMsg", { ws: wsTitle }));
700
+ refreshWorkspaces();
701
+ scheduleRecencyFix(ctx, r.attachedTo, [item.sessionId]);
702
+ await runScan();
703
+ } catch (err) {
704
+ failNote(err);
705
+ } finally {
706
+ setBusy(false);
707
+ }
708
+ };
709
+
710
+ // 误放归位:只修正记账归属(磁盘文件本就在正确位置),不动文件
711
+ const homeOne = async (it) => {
712
+ setBusy(true);
713
+ setNote("");
714
+ try {
715
+ const res = await call("mover.repair", { actions: [{ sessionId: it.sessionId, kind: "home" }] });
716
+ const r = res.results?.[0];
717
+ if (!r?.ok) throw new Error(r?.error ?? "home failed");
718
+ const wsTitle = workspaces.find((w) => w.workspaceId === r.homedTo)?.title ?? r.homedTo ?? "";
719
+ setNote(t("homedMsg", { ws: wsTitle }));
720
+ refreshWorkspaces();
721
+ scheduleRecencyFix(ctx, r.homedTo, [it.sessionId]);
722
+ await runScan();
723
+ } catch (err) {
724
+ failNote(err);
725
+ } finally {
726
+ setBusy(false);
727
+ }
728
+ };
729
+
730
+ const homeAll = async (list) => {
731
+ setBusy(true);
732
+ setNote("");
733
+ try {
734
+ const res = await call("mover.repair", { actions: list.map((it) => ({ sessionId: it.sessionId, kind: "home" })) });
735
+ const okN = (res.results ?? []).filter((r) => r.ok).length;
736
+ const failN = (res.results ?? []).length - okN;
737
+ let msg = t("homeAllDone", { n: okN });
738
+ if (failN > 0) msg += t("batchFailTail", { n: failN });
739
+ setNote(msg);
740
+ refreshWorkspaces();
741
+ const homes = [...new Set((res.results ?? []).filter((r) => r.ok).map((r) => r.homedTo))];
742
+ for (const wid of homes) scheduleRecencyFix(ctx, wid, list.map((it) => it.sessionId));
743
+ await runScan();
744
+ } catch (err) {
745
+ failNote(err);
746
+ } finally {
747
+ setBusy(false);
748
+ }
749
+ };
750
+
751
+ const undo = async (entry) => {
752
+ if (!window.confirm(t("undoConfirm"))) return;
753
+ setBusy(true);
754
+ setNote("");
755
+ try {
756
+ const result = await call("mover.undo", { historyId: entry.id });
757
+ let noteText = t("undone");
758
+ if ((result?.failedCount ?? 0) > 0) noteText += t("batchUndoPartial", { n: result.failedCount });
759
+ setNote(noteText);
760
+ // 撤回同样会把会话变回原分组的"账本新面孔",一并修正「最近更新」排序
761
+ const movedBack = new Set((result?.results ?? []).filter((r) => r.ok).map((r) => String(r.sessionId)));
762
+ const perWorkspace = new Map();
763
+ const addBack = (wid, sids) => {
764
+ if (!wid) return;
765
+ const arr = perWorkspace.get(wid) ?? [];
766
+ for (const sid of sids) arr.push(String(sid));
767
+ perWorkspace.set(wid, arr);
768
+ };
769
+ if (entry.batch) {
770
+ for (const s of entry.sessions ?? []) {
771
+ if (movedBack.has(String(s.sessionId))) addBack(s.sourceWorkspaceId, [s.sessionId]);
772
+ }
773
+ } else {
774
+ addBack(result?.to?.workspaceId ?? entry.sourceWorkspaceId, [result?.sessionId ?? entry.sessionId]);
775
+ }
776
+ for (const [wid, sids] of perWorkspace) scheduleRecencyFix(ctx, wid, sids);
777
+ refreshWorkspaces();
778
+ await runScan();
779
+ } catch (err) {
780
+ failNote(err);
781
+ } finally {
782
+ setBusy(false);
783
+ }
784
+ };
785
+
786
+ // 工作区搬家向导:dryRun 盘点 → 原生确认框亮出起讫与数量 → 执行
787
+ const repoint = async (row) => {
788
+ const nextPath = String(wsInputs[row.workspaceId] ?? "").trim();
789
+ if (!nextPath) return void setNote(t("wsNeedPath"));
790
+ setBusy(true);
791
+ setNote("");
792
+ try {
793
+ const dry = await call("mover.repoint", { workspaceId: row.workspaceId, fromPath: row.path, newPath: nextPath, dryRun: true });
794
+ if (!window.confirm(t("wizardConfirm", { title: row.title, from: row.path, to: nextPath, count: dry.count }))) return;
795
+ const res = await call("mover.repoint", { workspaceId: row.workspaceId, fromPath: row.path, newPath: nextPath });
796
+ let message = t("wizardDone", { title: row.title, n: res.movedCount });
797
+ if (res.skipped?.length > 0) message += t("wizardSkippedTail", { n: res.skipped.length });
798
+ setNote(message);
799
+ await runScan();
800
+ } catch (err) {
801
+ failNote(err);
802
+ } finally {
803
+ setBusy(false);
804
+ }
805
+ };
806
+
807
+ // 归档恢复:从 registry 全局归档集移除(记账槽未动,自动回原分组原位置);
808
+ // 带 targetWorkspaceId 且 ≠ 归属时,宿主顺路走常规迁移(备份/撤销全套保护)。
809
+ const restoreOne = async (item, targetWorkspaceId) => {
810
+ setBusy(true);
811
+ setNote("");
812
+ try {
813
+ await call("mover.unarchive", { sessionId: item.sessionId, targetWorkspaceId: targetWorkspaceId || undefined });
814
+ const wid = targetWorkspaceId || item.ownerWorkspaceId;
815
+ const wsTitle = workspaces.find((w) => w.workspaceId === wid)?.title ?? "";
816
+ setNote(t("restoreDone", { ws: wsTitle || "?" }));
817
+ if (wid) scheduleRecencyFix(ctx, wid, [item.sessionId]);
818
+ refreshWorkspaces();
819
+ await runScan();
820
+ } catch (err) {
821
+ failNote(err);
822
+ } finally {
823
+ setBusy(false);
824
+ }
825
+ };
826
+
827
+ const restoreTo = async (item) => {
828
+ const targetId = await pickRestoreTarget({ item, workspaces, suggestId: item.suggestedWorkspaceId });
829
+ if (!targetId) return;
830
+ await restoreOne(item, targetId);
831
+ };
832
+
833
+ // 空分组删除:动手前重拉一次原始记账,仍为空才删(防扫描与点击之间刚建了会话)。
834
+ // 官方 workspaces.delete() 成功即 resolve(void)、失败 reject,没有 {ok} 信封。
835
+ const deleteEmptyOne = async (ws) => {
836
+ if (!window.confirm(t("emptyWsConfirmOne", { title: ws.title, path: ws.path }))) return;
837
+ setBusy(true);
838
+ setNote("");
839
+ try {
840
+ const fresh = await call("mover.workspaces");
841
+ const current = (fresh.items ?? []).find((it) => it.workspaceId === ws.workspaceId);
842
+ if (!current || (current.rawSessionCount ?? 0) !== 0) {
843
+ setNote(t("emptyWsStale", { title: ws.title }));
844
+ return;
845
+ }
846
+ await ctx?.get?.("workspaces")?.delete?.(ws.workspaceId);
847
+ setNote(t("emptyWsDone", { n: 1 }));
848
+ refreshWorkspaces();
849
+ await runScan();
850
+ } catch (err) {
851
+ failNote(err);
852
+ } finally {
853
+ setBusy(false);
854
+ }
855
+ };
856
+
857
+ const deleteEmptyAll = async (list) => {
858
+ if (!window.confirm(t("emptyWsConfirmAll", { n: list.length }))) return;
859
+ setBusy(true);
860
+ setNote("");
861
+ try {
862
+ const fresh = await call("mover.workspaces");
863
+ let n = 0;
864
+ for (const ws of list) {
865
+ const current = (fresh.items ?? []).find((it) => it.workspaceId === ws.workspaceId);
866
+ if (!current || (current.rawSessionCount ?? 0) !== 0) continue;
867
+ try {
868
+ await ctx?.get?.("workspaces")?.delete?.(ws.workspaceId);
869
+ n++;
870
+ } catch { /* 单个失败不影响其余 */ }
871
+ }
872
+ setNote(n > 0 ? t("emptyWsDone", { n }) : t("emptyWsStale", { title: list[0]?.title ?? "?" }));
873
+ refreshWorkspaces();
874
+ await runScan();
875
+ } catch (err) {
876
+ failNote(err);
877
+ } finally {
878
+ setBusy(false);
879
+ }
880
+ };
881
+
882
+ // 失败双通道:面板底部 note + toast 浮层(note 在长列表里容易被错过)
883
+ const failNote = (err) => {
884
+ const raw = String(err?.message ?? err);
885
+ const busy = err?.code === "busy" || /resident in memory/.test(raw);
886
+ const msg = !busy ? t("failed", { msg: raw })
887
+ : /resident in memory/.test(raw) ? t("residentRefuse") : t("sessionBusy");
888
+ setNote(msg);
889
+ toast(msg, true);
890
+ };
891
+
892
+ // 删除会话 → 回收站(四件套清理,manifest 可还原)
893
+ const deleteToTrash = async (item) => {
894
+ if (!window.confirm(t("deleteConfirm", { title: item.title || t("unnamedSession") }))) return;
895
+ setBusy(true);
896
+ setNote("");
897
+ try {
898
+ await call("mover.session.delete", { sessionId: item.sessionId });
899
+ setNote(t("deletedMsg"));
900
+ refreshWorkspaces();
901
+ await runScan();
902
+ } catch (err) {
903
+ failNote(err);
904
+ } finally {
905
+ setBusy(false);
906
+ }
907
+ };
908
+
909
+ // 回收站还原:先试原位置(最忠实),原位置被占/无分组时打开选择器换目标
910
+ const trashRestore = async (item) => {
911
+ setBusy(true);
912
+ setNote("");
913
+ try {
914
+ const res = await call("mover.trash.restore", { sessionId: item.sessionId });
915
+ const wsTitle = workspaces.find((w) => w.workspaceId === res?.workspaceId)?.title ?? res?.workspaceId ?? "";
916
+ setNote(t("restoreDone", { ws: wsTitle }));
917
+ if (res?.workspaceId) scheduleRecencyFix(ctx, res.workspaceId, [item.sessionId]);
918
+ refreshWorkspaces();
919
+ await runScan();
920
+ } catch (err) {
921
+ const msg = String(err?.message ?? err);
922
+ // code 优先(conflict/not-found),英文文案正则保留为旧宿主兜底
923
+ const needsPicker = err?.code === "conflict" || /no registered workspace|already exists/.test(msg);
924
+ if (!needsPicker) {
925
+ setNote(t("failed", { msg }));
926
+ return;
927
+ }
928
+ setBusy(false);
929
+ const targetId = await pickRestoreTarget({ item, workspaces, suggestId: item.ownerWorkspaceId });
930
+ if (!targetId) return;
931
+ setBusy(true);
932
+ try {
933
+ const res2 = await call("mover.trash.restore", { sessionId: item.sessionId, targetWorkspaceId: targetId });
934
+ const wsTitle = workspaces.find((w) => w.workspaceId === res2?.workspaceId)?.title ?? res2?.workspaceId ?? "";
935
+ setNote(t("restoreDone", { ws: wsTitle }));
936
+ if (res2?.workspaceId) scheduleRecencyFix(ctx, res2.workspaceId, [item.sessionId]);
937
+ refreshWorkspaces();
938
+ await runScan();
939
+ } catch (err2) {
940
+ failNote(err2);
941
+ } finally {
942
+ setBusy(false);
943
+ }
944
+ }
945
+ };
946
+
947
+ const trashRestoreTo = async (item) => {
948
+ const targetId = await pickRestoreTarget({ item, workspaces, suggestId: item.ownerWorkspaceId });
949
+ if (!targetId) return;
950
+ setBusy(true);
951
+ setNote("");
952
+ try {
953
+ const res = await call("mover.trash.restore", { sessionId: item.sessionId, targetWorkspaceId: targetId });
954
+ const wsTitle = workspaces.find((w) => w.workspaceId === res?.workspaceId)?.title ?? res?.workspaceId ?? "";
955
+ setNote(t("restoreDone", { ws: wsTitle }));
956
+ if (res?.workspaceId) scheduleRecencyFix(ctx, res.workspaceId, [item.sessionId]);
957
+ refreshWorkspaces();
958
+ await runScan();
959
+ } catch (err) {
960
+ failNote(err);
961
+ } finally {
962
+ setBusy(false);
963
+ }
964
+ };
965
+
966
+ const purgeOne = async (item) => {
967
+ if (!window.confirm(t("purgeConfirm", { title: item.title || t("unnamedSession") }))) return;
968
+ setBusy(true);
969
+ setNote("");
970
+ try {
971
+ const res = await call("mover.trash.purge", { sessionId: item.sessionId });
972
+ setNote(t("purgedMsg", { n: res?.purged ?? 1 }));
973
+ await runScan();
974
+ } catch (err) {
975
+ failNote(err);
976
+ } finally {
977
+ setBusy(false);
978
+ }
979
+ };
980
+
981
+ const purgeAll = async (list) => {
982
+ if (!window.confirm(t("purgeAllConfirm", { n: list.length }))) return;
983
+ setBusy(true);
984
+ setNote("");
985
+ try {
986
+ const res = await call("mover.trash.purge", { all: true });
987
+ setNote(t("purgedMsg", { n: res?.purged ?? list.length }));
988
+ await runScan();
989
+ } catch (err) {
990
+ failNote(err);
991
+ } finally {
992
+ setBusy(false);
993
+ }
994
+ };
995
+
996
+ // 备份恢复:默认回备份时的原位置;原位置缺分组/被占时打开选择器换目标
997
+ const backupRestore = async (item) => {
998
+ setBusy(true);
999
+ setNote("");
1000
+ const finish = (res) => {
1001
+ const wsTitle = workspaces.find((w) => w.workspaceId === res?.workspaceId)?.title ?? res?.workspaceId ?? "";
1002
+ setNote(t("restoreDone", { ws: wsTitle }));
1003
+ if (res?.workspaceId) scheduleRecencyFix(ctx, res.workspaceId, [item.sessionId]);
1004
+ refreshWorkspaces();
1005
+ return runScan();
1006
+ };
1007
+ try {
1008
+ finish(await call("mover.backups.restore", { sessionId: item.sessionId }));
1009
+ } catch (err) {
1010
+ const msg = String(err?.message ?? err);
1011
+ // code 优先(conflict/not-found),英文文案正则保留为旧宿主兜底
1012
+ const needsPicker = err?.code === "conflict" || /no registered workspace|already exists/.test(msg);
1013
+ if (!needsPicker) {
1014
+ setNote(t("failed", { msg }));
1015
+ return;
1016
+ }
1017
+ setBusy(false);
1018
+ const normalized = (p) => String(p ?? "").replace(/[\\/]+$/, "").toLowerCase();
1019
+ const suggestId = workspaces.find((w) => normalized(w.path) === normalized(item.cwd))?.workspaceId ?? null;
1020
+ const targetId = await pickRestoreTarget({ item, workspaces, suggestId });
1021
+ if (!targetId) return;
1022
+ setBusy(true);
1023
+ try {
1024
+ finish(await call("mover.backups.restore", { sessionId: item.sessionId, targetWorkspaceId: targetId }));
1025
+ } catch (err2) {
1026
+ failNote(err2);
1027
+ } finally {
1028
+ setBusy(false);
1029
+ }
1030
+ return;
1031
+ }
1032
+ setBusy(false);
1033
+ };
1034
+
1035
+ const backupDelete = async (item) => {
1036
+ if (!window.confirm(t("backupDeleteConfirm", { title: item.title || item.sessionId, n: item.count }))) return;
1037
+ setBusy(true);
1038
+ setNote("");
1039
+ try {
1040
+ const res = await call("mover.backups.deleteOne", { sessionId: item.sessionId });
1041
+ setNote(t("backupDeletedMsg", { n: res?.deleted ?? 0 }));
1042
+ await runScan();
1043
+ } catch (err) {
1044
+ failNote(err);
1045
+ } finally {
1046
+ setBusy(false);
1047
+ }
1048
+ };
1049
+
1050
+ // 一键修复:可自动修复项(挂错归位 / 未记账补账)逐项处理,只动记账不搬文件
1051
+ const repairAll = async () => {
1052
+ setBusy(true);
1053
+ setNote("");
1054
+ try {
1055
+ const res = await call("mover.repairAll");
1056
+ setNote(t("repairAllDone", { n: res.fixedCount, s: res.skippedCount, f: res.failedCount }));
1057
+ const perWs = new Map();
1058
+ for (const r of res.fixed ?? []) {
1059
+ const wid = r.homedTo ?? r.attachedTo;
1060
+ if (wid) perWs.set(wid, [...(perWs.get(wid) ?? []), r.sessionId]);
1061
+ }
1062
+ for (const [wid, ids] of perWs) scheduleRecencyFix(ctx, wid, ids);
1063
+ refreshWorkspaces();
1064
+ await runScan();
1065
+ } catch (err) {
1066
+ failNote(err);
1067
+ } finally {
1068
+ setBusy(false);
1069
+ }
1070
+ };
1071
+
1072
+ // 任务重试:只处理记录里未成功的项(当前位置实时解析,逐项隔离失败)
1073
+ const taskRetry = async (task) => {
1074
+ setBusy(true);
1075
+ setNote("");
1076
+ try {
1077
+ const res = await call("mover.tasks.retry", { taskId: task.id });
1078
+ setNote(t("taskRetryDone", { n: res.fixedCount, f: res.failedCount }));
1079
+ const perWs = new Map();
1080
+ for (const r of res.results ?? []) {
1081
+ if (!r.ok) continue;
1082
+ const item = (task.items ?? []).find((i) => i.sessionId === r.sessionId);
1083
+ const wid = task.targetWorkspaceId;
1084
+ if (wid) perWs.set(wid, [...(perWs.get(wid) ?? []), r.sessionId]);
1085
+ }
1086
+ for (const [wid, ids] of perWs) scheduleRecencyFix(ctx, wid, ids);
1087
+ refreshWorkspaces();
1088
+ await runScan();
1089
+ } catch (err) {
1090
+ failNote(err);
1091
+ } finally {
1092
+ setBusy(false);
1093
+ }
1094
+ };
1095
+
1096
+ const taskForget = async (task) => {
1097
+ if (!window.confirm(t("taskForgetConfirm"))) return;
1098
+ setBusy(true);
1099
+ setNote("");
1100
+ try {
1101
+ await call("mover.tasks.forget", { taskId: task.id });
1102
+ setNote(t("taskForgottenMsg"));
1103
+ await runScan();
1104
+ } catch (err) {
1105
+ failNote(err);
1106
+ } finally {
1107
+ setBusy(false);
1108
+ }
1109
+ };
1110
+
1111
+ // 数据保护:按时间清理回收站与备份(dryRun 先出数字,确认后才真删)
1112
+ const dataCleanup = async () => {
1113
+ setBusy(true);
1114
+ setNote("");
1115
+ try {
1116
+ const preview = await call("mover.data.cleanup", { days: 30, dryRun: true });
1117
+ if (!preview.recyclePurged && !preview.backupDeleted) {
1118
+ setNote(t("dataCleanedMsg", { r: 0, b: 0, size: fmtBytes(0) }));
1119
+ return;
1120
+ }
1121
+ setBusy(false);
1122
+ const size = fmtBytes(preview.freedBytes);
1123
+ if (!window.confirm(t("dataCleanupConfirm", { r: preview.recyclePurged, b: preview.backupDeleted, size }))) return;
1124
+ setBusy(true);
1125
+ const res = await call("mover.data.cleanup", { days: 30 });
1126
+ setNote(t("dataCleanedMsg", { r: res.recyclePurged, b: res.backupDeleted, size: fmtBytes(res.freedBytes) }));
1127
+ await runScan();
1128
+ } catch (err) {
1129
+ failNote(err);
1130
+ } finally {
1131
+ setBusy(false);
1132
+ }
1133
+ };
1134
+
1135
+ const items = scan?.items ?? [];
1136
+ const orphaned = items.filter((it) => it.status === "orphaned");
1137
+ const unregistered = items.filter((it) => it.status === "unregistered");
1138
+ const misfiled = items.filter((it) => it.status === "misfiled");
1139
+ const brokenWorkspaces = (audit?.items ?? []).filter((it) => it.status !== "ok");
1140
+ const emptyWorkspaces = workspaces.filter((w) => (w.rawSessionCount ?? 0) === 0);
1141
+ const archivedItems = archived?.items ?? [];
1142
+ const trashItems = trash?.items ?? [];
1143
+ const backupItems = backups?.items ?? [];
1144
+ const taskItems = tasks?.items ?? [];
1145
+ // 面板筛选:标题 / 会话 ID / 路径 / 归属分组,命中任一即保留
1146
+ const q = query.trim().toLowerCase();
1147
+ const matchesQuery = (it) => {
1148
+ if (!q) return true;
1149
+ return [it.title, it.sessionId, it.cwd, it.homeTitle, it.ownerTitle].some((v) => String(v ?? "").toLowerCase().includes(q));
1150
+ };
1151
+ const orphanedV = orphaned.filter(matchesQuery);
1152
+ const unregisteredV = unregistered.filter(matchesQuery);
1153
+ const misfiledV = misfiled.filter(matchesQuery);
1154
+ const archivedV = archivedItems.filter(matchesQuery);
1155
+ const trashV = trashItems.filter(matchesQuery);
1156
+ const backupV = backupItems.filter(matchesQuery);
1157
+ const taskV = taskItems.filter((it) => matchesQuery({ ...it, title: null, cwd: it.targetPath }));
1158
+ const capCount = (shown, total) => (q ? `${shown}/${total}` : `${total}`);
1159
+ const counts = scan?.counts ?? {};
1160
+ const summaryParts = [
1161
+ ["orphaned", t("stalePath")],
1162
+ ["unregistered", t("ungroupedBadge")],
1163
+ ["misfiled", t("misfiledLabel")],
1164
+ ["unreadable", t("unreadable")]
1165
+ ].filter(([k]) => (counts[k] ?? 0) > 0).map(([k, label]) => `${label} ${counts[k]}`);
1166
+
1167
+ return h("div", { className: "wsm-panel" },
1168
+ h("h3", null, t("rescueTitle"), " ", h(HelpDot, { text: t("rescueDetail") })),
1169
+ h("div", { style: { fontSize: "12.5px", lineHeight: "19px", color: "var(--dsw-alias-label-secondary,#666)" } },
1170
+ t("rescueShort")),
1171
+ h("div", { className: "wsm-scanrow" },
1172
+ h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void runScan() }, busy ? t("scanning") : t("scan")),
1173
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void repairAll() }, t("repairAllBtn")),
1174
+ h("input",
1175
+ {
1176
+ className: "wsm-input", placeholder: t("filterPh"), value: query, disabled: busy,
1177
+ onChange: (e) => setQuery(e.target.value), style: { maxWidth: "170px" }
1178
+ }
1179
+ ),
1180
+ scan ? h("span", { style: { fontSize: "12.5px", color: "var(--dsw-alias-label-tertiary,#999)" } },
1181
+ t("scannedN", { n: scan.scanned }) + (summaryParts.length ? ` · ${summaryParts.join(" · ")}` : ""))
1182
+ : null
1183
+ ),
1184
+ brokenWorkspaces.length > 0 ? h(Caption, { text: t("wsSection"), help: t("wsHelp") }) : null,
1185
+ brokenWorkspaces.length > 0 ? h("div", { className: "wsm-list" },
1186
+ brokenWorkspaces.map((row) => h("div", { className: "wsm-item", key: row.workspaceId },
1187
+ h("span", { className: "wsm-badge err" }, t("wsBadges")),
1188
+ h("b", { title: t("wsMemberLine", { n: row.memberCount }) }, row.title),
1189
+ h("span", { className: "wsm-cwd", title: row.path }, row.path),
1190
+ h("input",
1191
+ {
1192
+ className: "wsm-input", placeholder: t("wsNewPathPh"), disabled: busy,
1193
+ value: wsInputs[row.workspaceId] ?? "",
1194
+ onChange: (e) => setWsInputs((p) => ({ ...p, [row.workspaceId]: e.target.value }))
1195
+ }
1196
+ ),
1197
+ h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void repoint(row) }, t("wizardBtn"))
1198
+ ))
1199
+ ) : null,
1200
+ orphaned.length > 0 ? h(Caption, { text: `${t("orphanedCaption")} (${capCount(orphanedV.length, orphaned.length)})`, help: t("helpPath") }) : null,
1201
+ orphanedV.length > 0 ? h("div", { className: "wsm-list" },
1202
+ orphanedV.map((it) => h("div", { className: "wsm-item", key: it.sessionId },
1203
+ h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
1204
+ it.archived ? h("span", { className: "wsm-badge" }, "archived") : null,
1205
+ h("span", { className: "wsm-cwd" }, it.cwd ?? "?"),
1206
+ h("select",
1207
+ { className: "wsm-select", value: picked[it.sessionId] ?? "", onChange: (e) => setPicked((p) => ({ ...p, [it.sessionId]: e.target.value })) },
1208
+ h("option", { value: "" }, t("allWorkspaces")),
1209
+ workspaces.map((w) => h("option", { key: w.workspaceId, value: w.workspaceId }, `${w.title}(${w.path})`))
1210
+ ),
1211
+ h("button", { className: "wsm-btn small primary", disabled: busy || !picked[it.sessionId], onClick: () => void relink(it) }, t("relinkBtn")),
1212
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void deleteToTrash(it) }, t("deleteBtn"))
1213
+ ))
1214
+ ) : null,
1215
+ unregistered.length > 0 ? h(Caption, { text: `${t("unregCaption")} (${capCount(unregisteredV.length, unregistered.length)})`, help: t("helpUnreg") }) : null,
1216
+ unregisteredV.length > 0 ? h("div", { className: "wsm-list" },
1217
+ unregisteredV.map((it) => h("div", { className: "wsm-item", key: it.sessionId },
1218
+ h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
1219
+ it.archived ? h("span", { className: "wsm-badge" }, "archived") : null,
1220
+ h("span", { className: "wsm-cwd" }, it.cwd ?? "?"),
1221
+ it.targetWorkspaceId
1222
+ // 有路径匹配的分组:一键原地补账
1223
+ ? h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void attach(it) }, t("attachBtn"))
1224
+ // 分组本身已被删除(或从未注册):和失联会话一样真迁移到任意分组
1225
+ : h(React.Fragment, null,
1226
+ h("select",
1227
+ { className: "wsm-select", value: picked[it.sessionId] ?? "", onChange: (e) => setPicked((p) => ({ ...p, [it.sessionId]: e.target.value })) },
1228
+ h("option", { value: "" }, t("allWorkspaces")),
1229
+ workspaces.map((w) => h("option", { key: w.workspaceId, value: w.workspaceId }, `${w.title}(${w.path})`))
1230
+ ),
1231
+ h("button", { className: "wsm-btn small primary", disabled: busy || !picked[it.sessionId], onClick: () => void relink(it) }, t("relinkBtn"))
1232
+ ),
1233
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void deleteToTrash(it) }, t("deleteBtn"))
1234
+ ))
1235
+ ) : null,
1236
+ misfiled.length > 0 ? h(Caption, { text: `${t("misfiledCaption")} (${capCount(misfiledV.length, misfiled.length)})`, help: t("misfiledHelp") }) : null,
1237
+ misfiled.length > 1 ? h("div", { className: "wsm-scanrow" },
1238
+ h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void homeAll(misfiledV.length > 0 ? misfiledV : misfiled) }, t("homeAllBtn"))
1239
+ ) : null,
1240
+ misfiledV.length > 0 ? h("div", { className: "wsm-list" },
1241
+ misfiledV.map((it) => {
1242
+ const ownerNames = (it.ownerWorkspaceIds ?? [])
1243
+ .map((wid) => workspaces.find((w) => w.workspaceId === wid)?.title ?? wid.slice(0, 8))
1244
+ .join("/") || "?";
1245
+ return h("div", { className: "wsm-item", key: it.sessionId },
1246
+ h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
1247
+ it.archived ? h("span", { className: "wsm-badge" }, "archived") : null,
1248
+ h("span", { className: "wsm-cwd", title: `${it.homePath ?? it.cwd ?? "?"}` }, `${ownerNames} → ${it.homeTitle ?? "?"}`),
1249
+ h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void homeOne(it) }, t("homeBtn")),
1250
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void deleteToTrash(it) }, t("deleteBtn"))
1251
+ );
1252
+ })
1253
+ ) : null,
1254
+ archivedItems.length > 0 ? h(Caption, { text: `${t("archivedCaption")} (${capCount(archivedV.length, archivedItems.length)})`, help: t("archivedHelp") }) : null,
1255
+ archivedV.length > 0 ? h("div", { className: "wsm-list" },
1256
+ archivedV.map((it) => h("div", { className: "wsm-item", key: it.sessionId },
1257
+ h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
1258
+ it.suggestedWorkspaceId ? h("span", { className: "wsm-badge" }, t("misfiledLabel")) : null,
1259
+ h("span", { className: "wsm-cwd", title: it.cwd ?? "" },
1260
+ it.ownerTitle ? it.ownerTitle : t("ownerGoneBadge")),
1261
+ h("button",
1262
+ {
1263
+ className: "wsm-btn small primary", disabled: busy || !it.ownerWorkspaceId,
1264
+ title: it.ownerWorkspaceId ? undefined : t("ownerGoneBadge"),
1265
+ onClick: () => void restoreOne(it)
1266
+ },
1267
+ t("restoreBtn")),
1268
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void restoreTo(it) }, t("restoreToBtn")),
1269
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void deleteToTrash(it) }, t("deleteBtn"))
1270
+ ))
1271
+ ) : null,
1272
+ trashItems.length + backupItems.length > 0 ? h("div", { className: "wsm-scanrow" },
1273
+ h("span", { style: { fontSize: "12.5px", color: "var(--dsw-alias-label-secondary,#666)" } },
1274
+ t("dataSummary", { r: trashItems.length, rSize: fmtBytes(trashItems.reduce((s, it) => s + (it.sizeBytes ?? 0), 0)), b: backupItems.length, bSize: fmtBytes(backupItems.reduce((s, it) => s + (it.totalBytes ?? 0), 0)) })),
1275
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void dataCleanup() }, t("dataCleanupBtn"))
1276
+ ) : null,
1277
+ trashItems.length > 0 ? h(Caption, { text: `${t("trashCaption")} (${capCount(trashV.length, trashItems.length)})`, help: t("trashHelp") }) : null,
1278
+ trashItems.length > 1 ? h("div", { className: "wsm-scanrow" },
1279
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void purgeAll(trashItems) }, t("purgeAllBtn"))
1280
+ ) : null,
1281
+ trashV.length > 0 ? h("div", { className: "wsm-list" },
1282
+ trashV.map((it) => h("div", { className: "wsm-item", key: it.entry },
1283
+ h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
1284
+ h("span", { className: "wsm-cwd", title: it.cwd ?? "" }, it.ownerTitle || t("ownerGoneBadge")),
1285
+ h("span", { className: "wsm-cwd" }, it.deletedAt ? new Date(it.deletedAt).toLocaleDateString() : ""),
1286
+ h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void trashRestore(it) }, t("restoreBtn")),
1287
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void trashRestoreTo(it) }, t("restoreToBtn")),
1288
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void purgeOne(it) }, t("purgeBtn"))
1289
+ ))
1290
+ ) : null,
1291
+ emptyWorkspaces.length > 0 ? h(Caption, { text: `${t("emptyWsCaption")} (${emptyWorkspaces.length})`, help: t("emptyWsHelp") }) : null,
1292
+ emptyWorkspaces.length > 1 ? h("div", { className: "wsm-scanrow" },
1293
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void deleteEmptyAll(emptyWorkspaces) }, t("emptyWsDeleteAll"))
1294
+ ) : null,
1295
+ emptyWorkspaces.length > 0 ? h("div", { className: "wsm-list" },
1296
+ emptyWorkspaces.map((w) => h("div", { className: "wsm-item", key: w.workspaceId },
1297
+ h("b", null, w.title),
1298
+ h("span", { className: "wsm-cwd", title: w.path }, w.path),
1299
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void deleteEmptyOne(w) }, t("emptyWsDelete"))
1300
+ ))
1301
+ ) : null,
1302
+ h("div", { className: "wsm-history" },
1303
+ h(Caption, { text: t("historySection"), help: t("historyHelp") }),
1304
+ history.length > 0 ? h("div", { className: "wsm-list" }, history.slice(0, 20).map((entry) => {
1305
+ const isBatch = Boolean(entry.batch);
1306
+ const undoable = isBatch
1307
+ ? (entry.sessions ?? []).some((s) => s.sourceWorkspaceId)
1308
+ : Boolean(entry.sourceWorkspaceId);
1309
+ return h("div", { className: "wsm-item", key: entry.id },
1310
+ h("span", { className: "wsm-mono" },
1311
+ isBatch ? t("batchEntryTitle", { n: entry.sessions?.length ?? 0 }) : (entry.title || t("unnamedSession"))),
1312
+ h("span", { className: "wsm-cwd", title: isBatch ? t("batchEntryTitle", { n: entry.sessions?.length ?? 0 }) : `${entry.sessionId}: ${entry.from} → ${entry.to}` },
1313
+ isBatch ? (entry.to ? `→ ${entry.to}` : "") : `${entry.from} → ${entry.to}`),
1314
+ h("button", { className: "wsm-btn small", disabled: busy || !undoable, title: undoable ? undefined : t("historyHelp"), onClick: () => void undo(entry) }, t("undoBtn"))
1315
+ );
1316
+ })) : h("div", { className: "wsm-note" }, t("historyEmpty"))
1317
+ ),
1318
+ backupItems.length > 0 ? h(Caption, { text: `${t("backupsCaption")} (${capCount(backupV.length, backupItems.length)})`, help: t("backupsHelp") }) : null,
1319
+ backupV.length > 0 ? h("div", { className: "wsm-list" },
1320
+ backupV.map((it) => h("div", { className: "wsm-item", key: it.sessionId },
1321
+ h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
1322
+ h("span", { className: "wsm-cwd", title: it.cwd ?? "" }, t("backupMeta", { n: it.count, size: fmtBytes(it.totalBytes), range: fmtRange(it) })),
1323
+ h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void backupRestore(it) }, t("restoreBtn")),
1324
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void backupDelete(it) }, t("backupDeleteBtn"))
1325
+ ))
1326
+ ) : null,
1327
+ taskItems.length > 0 ? h(Caption, { text: `${t("tasksCaption")} (${capCount(taskV.length, taskItems.length)})`, help: t("tasksHelp") }) : null,
1328
+ taskV.length > 0 ? h("div", { className: "wsm-list" },
1329
+ taskV.map((it) => h("div", { className: "wsm-item", key: it.id },
1330
+ h("span", { className: "wsm-cwd" }, it.createdAt ? new Date(it.createdAt).toLocaleString() : ""),
1331
+ h("span", { className: "wsm-cwd", title: it.targetPath ?? "" }, `✓${it.done} ✗${it.failed} ⏸${it.skipped} / ${it.total}`),
1332
+ it.failed + it.skipped > 0
1333
+ ? h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void taskRetry(it) }, t("taskRetryBtn"))
1334
+ : null,
1335
+ h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void taskForget(it) }, t("taskForgetBtn"))
1336
+ ))
1337
+ ) : null,
1338
+ scan && (scan.recoveryCount ?? 0) > 0
1339
+ ? h("div", { className: "wsm-note", style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: "10px" } }, t("recoveryNote", { n: scan.recoveryCount }))
1340
+ : null,
1341
+ scan && orphaned.length === 0 && unregistered.length === 0 && items.length > 0 && (scan.recoveryCount ?? 0) === 0
1342
+ ? h("div", { className: "wsm-note" }, t("allClear"))
1343
+ : null,
1344
+ scan && scan.ghosts.length > 0 ? h("div", { style: { marginTop: "4px" } },
1345
+ h(Caption, { text: `${t("ghostsTitle")} (${scan.ghosts.length})`, help: t("ghostsHelp") }),
1346
+ h("div", { className: "wsm-list" },
1347
+ scan.ghosts.slice(0, 20).map((g) => h("div", { className: "wsm-item", key: `${g.workspaceId}/${g.sessionId}` },
1348
+ h("span", { className: "wsm-badge" }, g.workspaceId.slice(0, 8)),
1349
+ h("span", { className: "wsm-mono" }, shortId(g.sessionId))
1350
+ ))
1351
+ )
1352
+ ) : null,
1353
+ note ? h("div", { className: "wsm-note" }, note) : null
1354
+ );
1355
+ }
1356
+
1357
+ function registerRescuePanel(ctx, rpcCall) {
1358
+ // 宿主无槽位系统时静默跳过(拖拽功能不受影响)
1359
+ if (typeof ctx.slots?.inject !== "function" || typeof ctx.slots?.register !== "function") return;
1360
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
1361
+ name: "settings.section",
1362
+ id: "workspace-mover-rescue",
1363
+ order: 60,
1364
+ label: () => t("rescueSection"),
1365
+ inject: () => ({ rpcCall, ctx })
1366
+ }, RescuePanel));
1367
+ }
1368
+ //#endregion
1369
+
1370
+ // slots:设置页「会话救援」面板需要;未注入时访问 ctx.slots 会被宿主拒绝
1371
+ var inject = ["connection", "slots"];
1372
+
1373
+ function apply(ctx) {
1374
+ const rpcCall = (endpoint, payload) => ctx.connection.rpc.call(CHANNEL, endpoint, payload ?? {});
1375
+ let dragging = null; // {el, els, id, target} —— id/target 在 drop 阶段解析
1376
+ let wsCache = null; // {items, at}
1377
+
1378
+ ensureStyle();
1379
+
1380
+ // 状态仅作诊断:不硬性禁用拖拽(避免启动期竞态导致永久失效)。
1381
+ void rpcCall("mover.status").catch((err) => {
1382
+ console.warn("[workspace-mover] status probe failed (will retry per move):", err);
1383
+ });
1384
+
1385
+ async function fetchWorkspaces() {
1386
+ if (wsCache && Date.now() - wsCache.at < 3000) return wsCache.items;
1387
+ const res = await rpcCall("mover.workspaces");
1388
+ if (!res?.ok) throw new Error(res?.error?.message ?? "workspace list unavailable");
1389
+ wsCache = { items: res.value.items, at: Date.now() };
1390
+ return wsCache.items;
1391
+ }
1392
+
1393
+ function clearHints() {
1394
+ for (const el of document.querySelectorAll(".wsm-drop-hint")) el.classList.remove("wsm-drop-hint");
1395
+ }
1396
+
1397
+ //#region 插件自建多选(宿主侧边栏没有多选模型)
1398
+ // Ctrl/Cmd+点击加入/移出、Shift+点击组内范围选择;拖动任一选中行 = 整批拖动。
1399
+ // 选中集存行元素(判定同步、零延迟);会话 id 在点击/投放时经「分组对齐 +
1400
+ // 标题消歧」解析:组内 DOM 行顺序对齐 workspace.sessionIds 顺序,行文本与
1401
+ // mover.scan 的标题贪心匹配——躲开隐藏空白会话造成的纯序错位,失配退化为顺序。
1402
+ let pickBadge = null;
1403
+ const pickedRows = new Set();
1404
+ let lastPickedRow = null;
1405
+ let scanCache = null;
1406
+
1407
+ async function fetchScan() {
1408
+ if (scanCache && Date.now() - scanCache.at < 5000) return scanCache.value;
1409
+ const res = await rpcCall("mover.scan");
1410
+ if (!res?.ok) throw new Error(res?.error?.message ?? "scan unavailable");
1411
+ scanCache = { value: res.value, at: Date.now() };
1412
+ return scanCache.value;
1413
+ }
1414
+
1415
+ function visibleTreeitems() {
1416
+ return [...document.querySelectorAll('div[role="treeitem"]')].filter((el) => el.offsetParent !== null);
1417
+ }
1418
+
1419
+ function validPickedRows() {
1420
+ for (const el of [...pickedRows]) {
1421
+ if (!el.isConnected || el.offsetParent === null) pickedRows.delete(el);
1422
+ }
1423
+ return [...pickedRows];
1424
+ }
1425
+
1426
+ function refreshPickVisuals() {
1427
+ for (const el of document.querySelectorAll(".wsm-picked")) el.classList.remove("wsm-picked");
1428
+ for (const el of document.querySelectorAll(".wsm-pick-badge")) el.remove();
1429
+ const rows = validPickedRows();
1430
+ if (rows.length === 0) {
1431
+ pickBadge?.remove(); pickBadge = null;
1432
+ return;
1433
+ }
1434
+ for (const row of rows) row.classList.add("wsm-picked");
1435
+ pickBadge?.remove();
1436
+ pickBadge = document.createElement("div");
1437
+ pickBadge.className = "wsm-batch-count";
1438
+ pickBadge.textContent = t("pickHint", { n: rows.length });
1439
+ document.body.appendChild(pickBadge);
1440
+ }
1441
+
1442
+ function clearSelection(quiet) {
1443
+ pickedRows.clear();
1444
+ lastPickedRow = null;
1445
+ refreshPickVisuals();
1446
+ if (!quiet) toast(t("pickCleared"));
1447
+ }
1448
+
1449
+ /** 组标题行 → 其下可见会话行(DOM 顺序)。 */
1450
+ function groupSessionRows(header) {
1451
+ const all = visibleTreeitems();
1452
+ const at = all.indexOf(header);
1453
+ if (at < 0) return [];
1454
+ let end = all.length;
1455
+ for (let i = at + 1; i < all.length; i++) {
1456
+ if (headerRow(all[i])) { end = i; break; }
1457
+ }
1458
+ return all.slice(at + 1, end).filter((el) => sessionRow(el));
1459
+ }
1460
+
1461
+ /**
1462
+ * 行元素 → 会话 id(权威通道):官方 SessionNodeItem 渲染行时把整个
1463
+ * node 对象(含 id)作为 prop 传入,dragstart 写入 dataTransfer 的
1464
+ * 正是这个 node.id。沿 fiber 爬升取最近一个带 node.id 的组件 props,
1465
+ * 不依赖任何顺序对齐——空白/归档会话被官方隐藏也不会错位。
1466
+ */
1467
+ function rowSessionId(rowEl) {
1468
+ try {
1469
+ const fiberKey = Object.keys(rowEl).find((k) => k.startsWith("__reactFiber$"));
1470
+ if (!fiberKey) return null;
1471
+ for (let fiber = rowEl[fiberKey]; fiber; fiber = fiber.return) {
1472
+ const node = fiber.memoizedProps?.node;
1473
+ if (node && typeof node.id === "string" && node.id.length > 0
1474
+ && (node.blank !== undefined || node.updatedAt !== undefined)) {
1475
+ return node.id;
1476
+ }
1477
+ }
1478
+ } catch { /* ignore */ }
1479
+ return null;
1480
+ }
1481
+
1482
+ /**
1483
+ * 把一组会话行对齐到 workspace.sessionIds:两指针顺序消费 + 行文本与
1484
+ * scan 标题贪心匹配。仅作 rowSessionId 不可用时的兜底(官方隐藏空白/
1485
+ * 归档会话时可能错位,v0.6.x 的"移错会话"即源于此)。结果缓存进
1486
+ * row.dataset.wsmId;返回 row→id 映射。
1487
+ */
1488
+ async function mapGroupRows(ws, groupRows) {
1489
+ const titleOf = new Map();
1490
+ try {
1491
+ const scan = await fetchScan();
1492
+ for (const it of scan.items ?? []) titleOf.set(String(it.sessionId), String(it.title ?? "").replace(/\s+/g, " ").trim());
1493
+ } catch { /* scan 不可用时退化为纯顺序对齐 */ }
1494
+ const remaining = (ws.sessionIds ?? []).map(String);
1495
+ const out = new Map();
1496
+ for (const row of groupRows) {
1497
+ if (remaining.length === 0) break;
1498
+ const text = (row.textContent ?? "").replace(/\s+/g, " ").trim();
1499
+ let hit = -1;
1500
+ if (text && text !== t("unnamedSession")) {
1501
+ hit = remaining.findIndex((id) => { const tt = titleOf.get(id); return tt && tt.length > 0 && (tt === text || text.includes(tt) || tt.includes(text)); });
1502
+ }
1503
+ if (hit < 0) hit = 0;
1504
+ const id = remaining.splice(hit, 1)[0];
1505
+ row.dataset.wsmId = id;
1506
+ out.set(row, id);
1507
+ }
1508
+ return out;
1509
+ }
1510
+
1511
+ /** 解析单个行元素(含所在组)的会话 id;权威走 rowSessionId,失败退回组内对齐。 */
1512
+ async function resolveRowId(rowEl) {
1513
+ const authoritative = rowSessionId(rowEl);
1514
+ if (authoritative) return authoritative;
1515
+ if (rowEl?.dataset?.wsmId) return rowEl.dataset.wsmId;
1516
+ const items = await fetchWorkspaces();
1517
+ const header = (() => {
1518
+ const all = visibleTreeitems();
1519
+ const at = all.indexOf(rowEl);
1520
+ for (let i = at - 1; i >= 0; i--) if (headerRow(all[i])) return all[i];
1521
+ return null;
1522
+ })();
1523
+ if (!header) return null;
1524
+ const ws = resolveWorkspace(header, items);
1525
+ if (!ws) return null;
1526
+ const mapping = await mapGroupRows(ws, groupSessionRows(header));
1527
+ return mapping.get(rowEl) ?? null;
1528
+ }
1529
+
1530
+ // Ctrl/Cmd+点击:加入/移出;Shift+点击:组内范围选择。普通点击不干预。
1531
+ document.addEventListener("click", async (e) => {
1532
+ const row = sessionRow(e.target);
1533
+ if (!row) return;
1534
+ const meta = e.ctrlKey || e.metaKey;
1535
+ if (!meta && !e.shiftKey) {
1536
+ // 普通点击会话行 = 官方跳转语义;顺带静默退出多选,避免残留"已选 X 个"
1537
+ if (pickedRows.size > 0) clearSelection(true);
1538
+ return;
1539
+ }
1540
+ e.preventDefault();
1541
+ e.stopImmediatePropagation();
1542
+ try {
1543
+ const items = await fetchWorkspaces();
1544
+ const header = (() => {
1545
+ const all = visibleTreeitems();
1546
+ const at = all.indexOf(row);
1547
+ for (let i = at - 1; i >= 0; i--) if (headerRow(all[i])) return all[i];
1548
+ return null;
1549
+ })();
1550
+ const ws = header ? resolveWorkspace(header, items) : null;
1551
+ if (!ws) return void toast(t("noTarget"), true);
1552
+ const groupRows = groupSessionRows(header);
1553
+ const mapping = await mapGroupRows(ws, groupRows);
1554
+ if (meta) {
1555
+ if (pickedRows.has(row)) pickedRows.delete(row);
1556
+ else if (mapping.has(row) || rowSessionId(row)) {
1557
+ // 开始新一轮选择时自动带上当前打开的会话(官方仅给打开行标 aria-selected),
1558
+ // 免得"正开着 A、Ctrl+点 B"要多点一次 A
1559
+ if (pickedRows.size === 0) {
1560
+ const open = [...document.querySelectorAll('div[role="treeitem"][aria-selected="true"]')]
1561
+ .find((el) => el.offsetParent !== null && el !== row);
1562
+ if (open) pickedRows.add(open);
1563
+ }
1564
+ pickedRows.add(row);
1565
+ lastPickedRow = row;
1566
+ }
1567
+ } else if (e.shiftKey && lastPickedRow && groupRows.includes(lastPickedRow)) {
1568
+ const from = groupRows.indexOf(lastPickedRow);
1569
+ const to = groupRows.indexOf(row);
1570
+ for (const r of groupRows.slice(Math.min(from, to), Math.max(from, to) + 1)) {
1571
+ if (mapping.has(r) || rowSessionId(r)) pickedRows.add(r);
1572
+ }
1573
+ } else if (mapping.has(row) || rowSessionId(row)) {
1574
+ pickedRows.add(row);
1575
+ lastPickedRow = row;
1576
+ }
1577
+ refreshPickVisuals();
1578
+ } catch (err) {
1579
+ toast(t("failed", { msg: err?.message ?? err }), true);
1580
+ }
1581
+ }, true);
1582
+
1583
+ // Esc 清空多选;不消费事件,输入框聚焦时也照常清空(官方输入的取消行为不受影响)
1584
+ document.addEventListener("keydown", (e) => {
1585
+ if (e.key !== "Escape" || pickedRows.size === 0) return;
1586
+ clearSelection();
1587
+ });
1588
+ //#endregion
1589
+
1590
+ /** 把 DOM 里第 i 个工作区标题行映射到注册表第 i 项(渲染顺序即注册表顺序)。 */
1591
+ function normalizeHeaderText(value) {
1592
+ return String(value ?? "").replace(/\s+/g, " ").trim();
1593
+ }
1594
+
1595
+ function headerText(rowEl) {
1596
+ return normalizeHeaderText([
1597
+ rowEl?.getAttribute?.("aria-label"),
1598
+ rowEl?.getAttribute?.("title"),
1599
+ rowEl?.textContent
1600
+ ].filter(Boolean).join(" "));
1601
+ }
1602
+
1603
+ function isUngroupedHeader(rowEl) {
1604
+ return /^(?:ungrouped|未分组)(?:\s|$)/i.test(headerText(rowEl));
1605
+ }
1606
+
1607
+ function visibleWorkspaceHeaders() {
1608
+ return [...document.querySelectorAll('div[role="treeitem"][aria-expanded]')]
1609
+ .filter((el) => el.offsetParent !== null && !isUngroupedHeader(el));
1610
+ }
1611
+
1612
+ function headerIndex(rowEl) {
1613
+ return visibleWorkspaceHeaders().indexOf(rowEl);
1614
+ }
1615
+
1616
+ function resolveWorkspace(rowEl, items) {
1617
+ const list = Array.isArray(items) ? items : [];
1618
+ if (!rowEl || isUngroupedHeader(rowEl)) return null;
1619
+ const text = headerText(rowEl);
1620
+ const lowerText = text.toLocaleLowerCase();
1621
+ const byText = list.find((item) => {
1622
+ const title = normalizeHeaderText(item.title);
1623
+ const path = normalizeHeaderText(item.path);
1624
+ return (title && lowerText.includes(title.toLocaleLowerCase()))
1625
+ || (path && lowerText.includes(path.toLocaleLowerCase()));
1626
+ });
1627
+ if (byText) return byText;
1628
+ const idx = headerIndex(rowEl);
1629
+ const byIndex = idx >= 0 && idx < list.length ? list[idx] : null;
1630
+ return byIndex;
1631
+ }
1632
+
1633
+ // dragstart:只做「元素判定」——拖起已多选的行 = 整批拖动,否则单选。
1634
+ // id 一律留到 drop 阶段解析(dataTransfer 只携带拖起这一行的 id)。
1635
+ document.addEventListener("dragstart", (e) => {
1636
+ try {
1637
+ const row = sessionRow(e.target);
1638
+ if (!row) { dragging = null; return; }
1639
+ const picked = validPickedRows();
1640
+ const els = picked.length > 1 && picked.includes(row) ? picked : [row];
1641
+ dragging = { els, el: row, id: null, target: null };
1642
+ } catch { dragging = null; }
1643
+ });
1644
+
1645
+ document.addEventListener("dragover", (e) => {
1646
+ clearHints();
1647
+ if (!dragging) return;
1648
+ const header = headerRow(e.target);
1649
+ if (!header || isUngroupedHeader(header)) return;
1650
+ e.preventDefault(); // 允许在此投放(没有它 drop 根本不会触发)
1651
+ header.classList.add("wsm-drop-hint");
1652
+ if (!dragging.target || dragging.target.header !== header) {
1653
+ dragging.target = { header, workspaceId: null };
1654
+ void fetchWorkspaces().then((items) => {
1655
+ if (dragging?.target?.header !== header) return;
1656
+ const target = resolveWorkspace(header, items);
1657
+ dragging.target.workspaceId = target?.workspaceId ?? null;
1658
+ }).catch(() => { /* drop 阶段会再次解析并报告错误 */ });
1659
+ }
1660
+ }, true);
1661
+
1662
+ document.addEventListener("drop", async (e) => {
1663
+ const current = dragging;
1664
+ dragging = null;
1665
+ clearHints();
1666
+ if (!current) return;
1667
+ const header = headerRow(e.target) ?? current.target?.header;
1668
+ if (!header || isUngroupedHeader(header)) return;
1669
+ // 只拦截「拖到某个工作区标题行」的场景;其余交还官方逻辑
1670
+ e.preventDefault();
1671
+ e.stopImmediatePropagation();
1672
+
1673
+ // drop 事件的 dataTransfer 处于 readonly 模式,读取有保证(拖起这一行的 id)
1674
+ let draggedId = null;
1675
+ try {
1676
+ const transfer = e.dataTransfer;
1677
+ const candidates = [
1678
+ transfer?.getData("text/plain"),
1679
+ transfer?.getData("text/uri-list"),
1680
+ transfer?.getData("application/x-dsh-session-id")
1681
+ ].filter(Boolean);
1682
+ draggedId = candidates.map((value) => String(value).trim().split(/[\r\n]/)[0]).find(Boolean) || null;
1683
+ } catch { draggedId = null; }
1684
+ if (!draggedId) {
1685
+ return void toast(t("failed", { msg: "unrecognized drag payload: empty" }), true);
1686
+ }
1687
+
1688
+ let workspace = null;
1689
+ try {
1690
+ let items = await fetchWorkspaces();
1691
+ workspace = resolveWorkspace(header, items);
1692
+ if (!workspace && current.target?.workspaceId) {
1693
+ workspace = items.find((item) => String(item.workspaceId) === String(current.target.workspaceId)) ?? null;
1694
+ }
1695
+ if (!workspace) {
1696
+ wsCache = null;
1697
+ items = await fetchWorkspaces();
1698
+ workspace = resolveWorkspace(header, items);
1699
+ if (!workspace && current.target?.workspaceId) {
1700
+ workspace = items.find((item) => String(item.workspaceId) === String(current.target.workspaceId)) ?? null;
1701
+ }
1702
+ }
1703
+ } catch (err) {
1704
+ return void toast(t("failed", { msg: err?.message ?? err }), true);
1705
+ }
1706
+ if (!workspace) return void toast(t("noTarget"), true);
1707
+
1708
+ // ---- 组装移动清单:拖起行必有 id;多选行逐个经分组对齐解析 ----
1709
+ const picked = (current.els ?? [current.el]).filter((el) => el?.isConnected);
1710
+ const sessions = [];
1711
+ const seen = new Set();
1712
+ const push = (id, title) => {
1713
+ const key = String(id);
1714
+ if (!key || seen.has(key)) return;
1715
+ seen.add(key);
1716
+ sessions.push({ sessionId: key, sessionTitle: title || t("unnamedSession") });
1717
+ };
1718
+ if (picked.length > 1) {
1719
+ for (const el of picked) {
1720
+ if (el === current.el) continue; // 拖起行用 dataTransfer 的权威 id
1721
+ try { push(await resolveRowId(el), rowTitle(el, "")); } catch { /* 解析失败跳过 */ }
1722
+ }
1723
+ }
1724
+ // 权威 id 放首位;若某选中行被误映射到同一 id 会被去重吞掉
1725
+ push(draggedId, rowTitle(current.el, draggedId));
1726
+
1727
+ if (sessions.length > 50) {
1728
+ return void toast(t("failed", { msg: `too many sessions in one batch (max 50, got ${sessions.length})` }), true);
1729
+ }
1730
+
1731
+ // ---- 单选:原有确认与 mover.move 路径 ----
1732
+ if (sessions.length === 1) {
1733
+ const { sessionId, sessionTitle } = sessions[0];
1734
+ const confirmed = await confirmMove({ sessionId, sessionTitle, workspace });
1735
+ if (!confirmed) return;
1736
+ try {
1737
+ const res = await rpcCall("mover.move", { sessionId, sessionTitle, targetWorkspaceId: workspace.workspaceId });
1738
+ if (res?.ok) {
1739
+ await reconcileWorkspaceProjection(ctx, workspace.workspaceId, [sessionId]);
1740
+ toast(res.value?.attached ? t("selfHealed") : t("done", { title: workspace.title }));
1741
+ scheduleRecencyFix(ctx, workspace.workspaceId, [sessionId]);
1742
+ if (res.value?.restartHint) setTimeout(() => toast(t("restartHint"), true), 1200);
1743
+ } else {
1744
+ const msg = res?.error?.message ?? "unknown";
1745
+ const rolled = res?.error?.code === "rollback-failed" || /roll/i.test(msg);
1746
+ const text = (rolled ? t("rolledBack", { msg }) : t("failed", { msg }));
1747
+ toast(text, true);
1748
+ }
1749
+ } catch (err) {
1750
+ toast(t("failed", { msg: err?.message ?? err }), true);
1751
+ }
1752
+ return;
1753
+ }
1754
+
1755
+ // ---- 多选:批量确认 + mover.moveMany(逐条独立备份回滚)----
1756
+ const confirmed = await confirmMove({ workspace, batchCount: sessions.length });
1757
+ if (!confirmed) return;
1758
+ try {
1759
+ const res = await rpcCall("mover.moveMany", { sessions, targetWorkspaceId: workspace.workspaceId });
1760
+ if (!res?.ok) {
1761
+ const msg = res?.error?.message ?? "unknown";
1762
+ const rolled = res?.error?.code === "rollback-failed" || /roll/i.test(msg);
1763
+ return void toast((rolled ? t("rolledBack", { msg }) : t("failed", { msg })), true);
1764
+ }
1765
+ const { movedCount, attachedCount, failedCount } = res.value ?? {};
1766
+ const okCount = (movedCount ?? 0) + (attachedCount ?? 0);
1767
+ const movedIds = (res.value?.results ?? []).filter((item) => item?.ok).map((item) => item.sessionId).filter(Boolean);
1768
+ await reconcileWorkspaceProjection(ctx, workspace.workspaceId, movedIds);
1769
+ let message = t("batchDone", { n: okCount });
1770
+ if ((failedCount ?? 0) > 0) message += t("batchFailTail", { n: failedCount });
1771
+ toast(message);
1772
+ clearSelection(true);
1773
+ scheduleRecencyFix(ctx, workspace.workspaceId, sessions.map((s) => s.sessionId));
1774
+ } catch (err) {
1775
+ toast(t("failed", { msg: err?.message ?? err }), true);
1776
+ }
1777
+ }, true);
1778
+
1779
+ document.addEventListener("dragend", () => { dragging = null; clearHints(); });
1780
+
1781
+ // 整组迁移 + 分组合并:入口在官方组标题的「⋯」菜单(注入「整组迁移…」项)。
1782
+ // 不再拦截浏览器右键——官方菜单同样在右键/⋯按钮打开,拦截会与之冲突。
1783
+
1784
+ /** 从指定标题行发起整组迁移;全部成员迁入且源分组已空时,提供删除空分组(= 合并)。 */
1785
+ async function runGroupMoveFlow(header) {
1786
+ try {
1787
+ const items = await fetchWorkspaces();
1788
+ const workspace = resolveWorkspace(header, items);
1789
+ if (!workspace) return void toast(t("groupMoveEmpty"), true);
1790
+ const ids = (workspace.sessionIds ?? []).map(String);
1791
+ if (ids.length === 0) return void toast(t("groupMoveEmpty"), true);
1792
+ const targetId = await confirmGroupMove({ workspace, workspaces: items, count: ids.length });
1793
+ if (!targetId) return;
1794
+ const res = await rpcCall("mover.moveMany", {
1795
+ sessions: ids.map((id) => ({ sessionId: id })),
1796
+ targetWorkspaceId: targetId
1797
+ });
1798
+ if (!res?.ok) {
1799
+ const msg = res?.error?.message ?? "unknown";
1800
+ const rolled = res?.error?.code === "rollback-failed" || /roll/i.test(msg);
1801
+ return void toast((rolled ? t("rolledBack", { msg }) : t("failed", { msg })), true);
1802
+ }
1803
+ const { movedCount, attachedCount, failedCount } = res.value ?? {};
1804
+ const okCount = (movedCount ?? 0) + (attachedCount ?? 0);
1805
+ const movedIds = (res.value?.results ?? []).filter((item) => item?.ok).map((item) => item.sessionId).filter(Boolean);
1806
+ await reconcileWorkspaceProjection(ctx, targetId, movedIds);
1807
+ let message = t("batchDone", { n: okCount });
1808
+ if ((failedCount ?? 0) > 0) message += t("batchFailTail", { n: failedCount });
1809
+ toast(message);
1810
+ scheduleRecencyFix(ctx, targetId, ids);
1811
+ // 合并:全部成员迁入且源分组已空时,提供删除空分组(逐级确认,失败不影响迁移结果)
1812
+ try {
1813
+ const fresh = await fetchWorkspaces();
1814
+ const source = fresh.find((w) => w.workspaceId === workspace.workspaceId);
1815
+ const targetTitle = fresh.find((w) => w.workspaceId === targetId)?.title ?? targetId;
1816
+ if (source && (source.sessionIds ?? []).length === 0
1817
+ && window.confirm(t("mergeDeleteConfirm", { count: okCount, title: workspace.title, target: targetTitle }))) {
1818
+ // 官方 delete 成功即 resolve(void)、失败 reject
1819
+ await ctx.get?.("workspaces")?.delete?.(workspace.workspaceId);
1820
+ toast(t("mergeDone", { title: workspace.title }));
1821
+ }
1822
+ } catch { /* 合并删除为可选步骤,失败静默 */ }
1823
+ } catch (err) {
1824
+ toast(t("failed", { msg: err?.message ?? err }), true);
1825
+ }
1826
+ }
1827
+
1828
+ // 记录「⋯」按钮点击所在的标题行(标题行内唯一带 aria-label 的按钮),
1829
+ // 随后出现的菜单 portal 即属于这个分组;1 秒内未出现菜单则作废。
1830
+ let menuHeaderRow = null;
1831
+ document.addEventListener("click", (e) => {
1832
+ const header = headerRow(e.target);
1833
+ if (!header) return;
1834
+ const btn = e.target.closest?.("button[aria-label]");
1835
+ if (!btn || !header.contains(btn)) return;
1836
+ menuHeaderRow = header;
1837
+ setTimeout(() => { menuHeaderRow = null; }, 1000);
1838
+ }, true);
1839
+
1840
+ // 向官方菜单 portal 注入两项(克隆官方项的样式类,插在危险项之前):
1841
+ // 「整组迁移…」= 批量移动 + 可选合并删除;「打开文件夹」= 系统文件管理器打开该分组目录。
1842
+ function injectGroupMoveItem(menu, items, header) {
1843
+ const model = items[0];
1844
+ const makeItem = (label, onClick) => {
1845
+ const el = document.createElement(model.tagName === "BUTTON" ? "button" : "div");
1846
+ el.type = "button";
1847
+ el.className = model.className;
1848
+ el.setAttribute("role", "menuitem");
1849
+ el.textContent = label;
1850
+ el.addEventListener("click", (e) => {
1851
+ e.preventDefault();
1852
+ e.stopPropagation();
1853
+ menuHeaderRow = null;
1854
+ // 关闭官方菜单:Escape + 菜单外部 pointerdown 双保险
1855
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
1856
+ document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
1857
+ onClick();
1858
+ }, true);
1859
+ return el;
1860
+ };
1861
+ const groupItem = makeItem(t("groupMoveMenu"), () => void runGroupMoveFlow(header));
1862
+ const openItem = makeItem(t("openFolderMenu"), async () => {
1863
+ try {
1864
+ const list = await fetchWorkspaces();
1865
+ const ws = resolveWorkspace(header, list);
1866
+ if (!ws) return void toast(t("noTarget"), true);
1867
+ const res = await rpcCall("mover.openFolder", { workspaceId: ws.workspaceId, path: ws.path });
1868
+ if (!res?.ok) toast(t("failed", { msg: res?.error?.message ?? "open failed" }), true);
1869
+ } catch (err) {
1870
+ toast(t("failed", { msg: err?.message ?? err }), true);
1871
+ }
1872
+ });
1873
+ const danger = items.find((it) => /danger/i.test(it.className || ""));
1874
+ const anchor = danger && danger !== items[0] ? danger : items[items.length - 1];
1875
+ anchor.before(groupItem);
1876
+ groupItem.after(openItem);
1877
+ }
1878
+
1879
+ const menuObserver = new MutationObserver((muts) => {
1880
+ if (!menuHeaderRow || !menuHeaderRow.isConnected) return;
1881
+ for (const m of muts) {
1882
+ for (const node of m.addedNodes) {
1883
+ if (!(node instanceof Element)) continue;
1884
+ const candidates = node.getAttribute?.("role") === "menu" ? [node]
1885
+ : [...(node.querySelectorAll?.('[role="menu"]') ?? [])];
1886
+ const menu = candidates[candidates.length - 1];
1887
+ if (!menu || menu.dataset.wsmInjected) continue;
1888
+ const items = [...menu.querySelectorAll('[role="menuitem"]')];
1889
+ if (items.length < 2) continue;
1890
+ menu.dataset.wsmInjected = "1";
1891
+ const header = menuHeaderRow;
1892
+ menuHeaderRow = null;
1893
+ injectGroupMoveItem(menu, items, header);
1894
+ return;
1895
+ }
1896
+ }
1897
+ });
1898
+ menuObserver.observe(document.body, { childList: true, subtree: true });
1899
+
1900
+ // 诊断句柄:控制台用 window.__wsmDebug 检查「最近更新」排序修复通道(排障用)
1901
+ try {
1902
+ window.__wsmDebug = {
1903
+ view: () => { const v = resolveViewStore(ctx); return v ? v.getSnapshot() : null; },
1904
+ resolveViewStore: () => resolveViewStore(ctx),
1905
+ fix: (id, moved) => scheduleRecencyFix(ctx, id, moved),
1906
+ rowId: (el) => rowSessionId(el),
1907
+ trash: () => rpcCall(CHANNEL, "mover.trash.list"),
1908
+ backups: () => rpcCall(CHANNEL, "mover.backups.list")
1909
+ };
1910
+ } catch { /* ignore */ }
1911
+
1912
+ registerRescuePanel(ctx, rpcCall);
1913
+ }
1914
+
1915
+ module.exports = { inject, apply };
1916
+ return module.exports;
1917
+ }
1918
+ });