dsh-workspace-mover 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +285 -0
- package/README.md +357 -304
- package/README_EN.md +346 -0
- package/client/client.js +632 -100
- package/docs/ARCHITECTURE.md +81 -22
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-04.md +39 -0
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-13.md +45 -0
- package/lib/config.js +74 -0
- package/lib/index.js +1756 -172
- package/package.json +102 -63
- package/test/client-dom.test.mjs +796 -0
- package/test/e2e-sandbox.test.mjs +705 -60
- package/test/policy.test.mjs +415 -0
package/client/client.js
CHANGED
|
@@ -20,7 +20,7 @@ window.__ModuleLoader__.load({
|
|
|
20
20
|
confirmTitle: "跨工作区移动会话",
|
|
21
21
|
target: "目标工作区",
|
|
22
22
|
session: "会话",
|
|
23
|
-
hint: "
|
|
23
|
+
hint: "将物理搬移原始会话档案并改写归属,历史记录原样保留。",
|
|
24
24
|
move: "移动",
|
|
25
25
|
cancel: "取消",
|
|
26
26
|
moving: "正在移动…",
|
|
@@ -28,7 +28,6 @@ window.__ModuleLoader__.load({
|
|
|
28
28
|
rolledBack: "已自动回滚:{msg}",
|
|
29
29
|
failed: "移动失败:{msg}",
|
|
30
30
|
noTarget: "无法识别目标工作区(侧边栏结构变化?)",
|
|
31
|
-
ungroupedUnsupported: "暂不支持移动到「未分组」",
|
|
32
31
|
staleList: "工作区列表与侧边栏不一致,请重试",
|
|
33
32
|
restartHint: "缓存失效失败:归属可能需要重启 Harness 后才刷新",
|
|
34
33
|
rescueSection: "会话修复",
|
|
@@ -53,11 +52,18 @@ window.__ModuleLoader__.load({
|
|
|
53
52
|
stalePath: "路径失效",
|
|
54
53
|
ungroupedBadge: "未归组",
|
|
55
54
|
unreadable: "无法读取",
|
|
55
|
+
unsupportedFormat: "当前格式不受支持",
|
|
56
|
+
layoutUnknown: "存储布局未知",
|
|
57
|
+
corruptData: "档案数据损坏或无法解析",
|
|
56
58
|
ghostsTitle: "名单里有、但文件已不在的记录",
|
|
57
59
|
ghostsHelp: "侧边栏的分组名单里记着这些会话,但对应的记录文件已经不在磁盘上(通常是被手动删除的)。一般不影响使用;重启 Harness 后通常会自动清理。",
|
|
58
60
|
helpPath: "会话原来的文件夹被移动、改名或删除,所以侧边栏看不到它。选择一个分组,历史会原样迁移过去,会话 ID 保持不变。",
|
|
59
61
|
helpUnreg: "会话文件完好,只是还没有归属到任何工作区分组。点击按钮即可归入匹配的分组,文件不会移动。",
|
|
60
62
|
pickTarget: "请先在下拉框选择要移入的分组",
|
|
63
|
+
idUnresolved: "无法识别要移动的会话(侧边栏结构或标题有变化),已取消本次移动。请刷新页面后重试。",
|
|
64
|
+
idUnresolvedPartial: "有 {n} 行无法识别,将被跳过——本次只移动能确认身份的会话。",
|
|
65
|
+
batchTooMany: "单次最多移动 {max} 个会话(本次 {n} 个)",
|
|
66
|
+
dragPayloadBad: "无法读取拖拽内容(宿主版本变化?),已取消本次移动",
|
|
61
67
|
relinkBtn: "移入该分组",
|
|
62
68
|
attachBtn: "归入分组",
|
|
63
69
|
relinked: "✓ 已迁入「{title}」,历史原样保留",
|
|
@@ -91,13 +97,13 @@ window.__ModuleLoader__.load({
|
|
|
91
97
|
batchUndoPartial: ";{n} 个未能撤回(可重试)",
|
|
92
98
|
pickHint: "已选 {n} 个会话 · Ctrl+点击行可多选 · 拖到目标工作区标题行批量移动 · Esc 清空",
|
|
93
99
|
pickCleared: "已清空多选",
|
|
94
|
-
pickEscHint: "按 Esc 清空多选(输入框聚焦时无效)",
|
|
95
100
|
groupMoveTitle: "把「{title}」的会话移到…",
|
|
96
101
|
groupMoveHint: "整组会话将真迁移到目标分组;运行中的会话会被跳过并在结果中说明。",
|
|
97
102
|
groupMoveEmpty: "这个分组下没有可移动的会话。",
|
|
98
103
|
openFolderMenu: "打开文件夹",
|
|
99
104
|
archivedCaption: "已归档的会话",
|
|
100
105
|
archivedHelp: "这些会话被官方「归档」后从侧边栏消失,但文件和分组归属都还在。点「恢复」回到原来的分组,或选「恢复到…」换一个分组;恢复不会动文件。",
|
|
106
|
+
archiveTruncated: "注意:扫描只解析了最新 {parsed} / {total} 个会话档案,较旧的归档会话可能不在此列表中。",
|
|
101
107
|
restoreBtn: "恢复",
|
|
102
108
|
restoreToBtn: "恢复到…",
|
|
103
109
|
restorePickTitle: "把「{title}」恢复到…",
|
|
@@ -121,12 +127,20 @@ window.__ModuleLoader__.load({
|
|
|
121
127
|
purgeAllBtn: "清空回收站",
|
|
122
128
|
purgeAllConfirm: "彻底删除回收站里的全部 {n} 个会话?此操作不可撤销。",
|
|
123
129
|
purgedMsg: "✓ 已彻底删除 {n} 项",
|
|
130
|
+
purgeBackupsToo: "这个会话还有 {n} 份迁移备份。一并删除吗?\n\n(会话已不存在,这些备份不会再被用到;保留则可在备份列表里单独恢复或清理。)",
|
|
131
|
+
purgeBackupsDone: "(含 {n} 份备份)",
|
|
124
132
|
backupsCaption: "备份",
|
|
125
|
-
backupsHelp: "
|
|
126
|
-
backupMeta: "{n}
|
|
133
|
+
backupsHelp: "这里列出迁移恢复快照。相同会话内容只保留一份物理快照,但每次迁移仍会记录历史;可恢复到原位置或任意其他分组。",
|
|
134
|
+
backupMeta: "{n} 个快照 · {size} · {range}",
|
|
127
135
|
backupDeleteBtn: "删除备份",
|
|
128
136
|
backupDeleteConfirm: "删除「{title}」的 {n} 份备份?删除后无法再从备份恢复。",
|
|
129
137
|
backupDeletedMsg: "✓ 已删除 {n} 份备份",
|
|
138
|
+
backupOrphanBadge: "孤儿",
|
|
139
|
+
backupOrphanHelp: "这个会话的档案已经不存在了(通常是被彻底删除)。这些备份不会被任何操作再引用,可以安全清理。",
|
|
140
|
+
backupOrphanSummary: "孤儿备份:{n} 组 · {size}(会话已不存在)",
|
|
141
|
+
backupOrphanCleanup: "清理孤儿备份",
|
|
142
|
+
backupOrphanConfirm: "删除 {n} 组孤儿备份({size})?这些会话已不存在,删除后无法从备份恢复。",
|
|
143
|
+
backupOrphanPartial: "✓ 已删除 {n} 份,{f} 组失败(可重试)",
|
|
130
144
|
residentRefuse: "该会话当前没有焦点也可能仍驻留在 Harness 内存中(归档只隐藏,不会卸载)。DSH 0.1.5 没有公开的按 ID 卸载接口,请重启 Harness 释放常驻会话后再删除。",
|
|
131
145
|
sessionBusy: "该会话正在处理中(可能有迁移 / 还原 / 删除操作尚未完成),请稍后再试。",
|
|
132
146
|
contextStale: "插件上下文已失效(客户端热重载导致)。请刷新页面(或重启应用)后重试,功能会自动恢复。",
|
|
@@ -145,7 +159,13 @@ window.__ModuleLoader__.load({
|
|
|
145
159
|
doctorDataBackups: "备份目录",
|
|
146
160
|
doctorDataHistory: "移动历史",
|
|
147
161
|
doctorDataTasks: "任务记录",
|
|
162
|
+
doctorDataMigrations: "迁移队列",
|
|
148
163
|
doctorRecovery: "恢复记录",
|
|
164
|
+
doctorDataLayout: "存储布局",
|
|
165
|
+
doctorStorageAdapter: "存储适配器",
|
|
166
|
+
adapterFilesystemFallback: "当前 DSH 未提供原始档案路径接口,使用文件路径适配器;存储布局自检通过后可迁移。",
|
|
167
|
+
adapterOfficialHandle: "官方会话接口可用,但未提供原始档案路径,当前无法迁移档案目录。",
|
|
168
|
+
adapterUnavailable: "当前持久化服务缺少可用接口。",
|
|
149
169
|
doctorWorkspaces: "工作区路径",
|
|
150
170
|
repairAllBtn: "一键修复",
|
|
151
171
|
repairAllDone: "✓ 已修复 {n} · 跳过 {s} · 失败 {f}",
|
|
@@ -153,6 +173,39 @@ window.__ModuleLoader__.load({
|
|
|
153
173
|
filterPh: "筛选:标题 / 会话 ID / 路径…",
|
|
154
174
|
tasksCaption: "迁移任务",
|
|
155
175
|
tasksHelp: "批量迁移的持久化记录:完成 / 失败逐项可查。失败项可一键重试——按会话当前位置重新迁移,不会用记录里的旧路径;清除记录不影响已迁移的会话。",
|
|
176
|
+
queueCaption: "等待迁移",
|
|
177
|
+
queueHelp: "运行中的会话不会触碰文件;会话空闲后自动尝试,失败项可以重试或取消。",
|
|
178
|
+
queueRetryBtn: "重试",
|
|
179
|
+
queueRetryAllBtn: "重试全部失败项",
|
|
180
|
+
queueCancelBtn: "取消",
|
|
181
|
+
queueRetryDone: "✓ 已重新尝试迁移",
|
|
182
|
+
queueRetryAllDone: "✓ 已逐项重试 {n} 个失败任务",
|
|
183
|
+
queueCancelled: "✓ 已取消迁移任务",
|
|
184
|
+
queuedMove: "已排队:会话空闲后自动迁移",
|
|
185
|
+
batchQueuedTail: ";{n} 个会话已排队等待空闲",
|
|
186
|
+
previewStatus: "预览状态",
|
|
187
|
+
previewSource: "来源",
|
|
188
|
+
previewTarget: "目标路径",
|
|
189
|
+
previewBackup: "本次新增备份",
|
|
190
|
+
previewBackupReused: "复用已有快照,不新增磁盘占用",
|
|
191
|
+
previewBackupHelp: "迁移前保存恢复快照,用于失败回滚和备份恢复;相同会话内容会复用已有快照,主档案仍只移动一份。",
|
|
192
|
+
previewSameWorkspace: "会话已在目标工作区,无需移动。",
|
|
193
|
+
previewSelfHeal: "档案已在目标工作区,将补齐归属记录。",
|
|
194
|
+
previewRunning: "会话正在运行,将排队等待空闲。",
|
|
195
|
+
previewNoPhysicalPath: "当前 DSH 未提供原始档案路径,无法迁移档案目录。",
|
|
196
|
+
previewSourceMissing: "来源会话档案缺失或无法读取。",
|
|
197
|
+
previewCollision: "目标工作区已有同 ID 档案,已阻止覆盖。",
|
|
198
|
+
previewGenerations: "发现多个格式代际,选择 v{n} 并保留旧文件。",
|
|
199
|
+
previewDescendants: "已选择 {n} 个后代会话。",
|
|
200
|
+
forkMode: "分支范围",
|
|
201
|
+
forkCurrentOnly: "仅当前会话",
|
|
202
|
+
forkIncludeDescendants: "包含所有后代分支",
|
|
203
|
+
forkHint: "默认只迁移当前会话;选择后代分支会逐项迁移并独立记录结果。",
|
|
204
|
+
previewWarning: "提示",
|
|
205
|
+
previewSafe: "可安全移动",
|
|
206
|
+
previewQueued: "会进入空闲队列",
|
|
207
|
+
previewBlocked: "当前不可移动",
|
|
208
|
+
previewWarningState: "需要确认",
|
|
156
209
|
taskRetryBtn: "重试失败项",
|
|
157
210
|
taskRetryDone: "✓ 重试完成:成功 {n} · 失败 {f}",
|
|
158
211
|
taskForgetBtn: "清除记录",
|
|
@@ -167,7 +220,7 @@ window.__ModuleLoader__.load({
|
|
|
167
220
|
confirmTitle: "Move session across workspaces",
|
|
168
221
|
target: "Target workspace",
|
|
169
222
|
session: "Session",
|
|
170
|
-
hint: "Physically relocates the original session archive; history stays intact
|
|
223
|
+
hint: "Physically relocates the original session archive and updates its workspace; history stays intact.",
|
|
171
224
|
move: "Move",
|
|
172
225
|
cancel: "Cancel",
|
|
173
226
|
moving: "Moving…",
|
|
@@ -175,7 +228,6 @@ window.__ModuleLoader__.load({
|
|
|
175
228
|
rolledBack: "Rolled back automatically: {msg}",
|
|
176
229
|
failed: "Move failed: {msg}",
|
|
177
230
|
noTarget: "Cannot resolve target workspace (sidebar layout changed?)",
|
|
178
|
-
ungroupedUnsupported: "Moving to \"Ungrouped\" is not supported yet",
|
|
179
231
|
staleList: "Workspace list out of sync with the sidebar; try again",
|
|
180
232
|
restartHint: "Cache invalidation failed: grouping may need a harness restart",
|
|
181
233
|
rescueSection: "Session Repair",
|
|
@@ -200,11 +252,18 @@ window.__ModuleLoader__.load({
|
|
|
200
252
|
stalePath: "Stale path",
|
|
201
253
|
ungroupedBadge: "Ungrouped",
|
|
202
254
|
unreadable: "Unreadable",
|
|
255
|
+
unsupportedFormat: "Unsupported format",
|
|
256
|
+
layoutUnknown: "Unknown storage layout",
|
|
257
|
+
corruptData: "Archive data is damaged or cannot be parsed",
|
|
203
258
|
ghostsTitle: "Listed in groups but missing on disk",
|
|
204
259
|
ghostsHelp: "These sessions are still recorded in group rosters, but their files are gone from disk (usually deleted by hand). Harmless in general; a harness restart normally cleans them up.",
|
|
205
260
|
helpPath: "The folder this session lived in was moved, renamed or deleted, so the sidebar cannot show it. Pick a group — history moves over as-is and the session ID stays the same.",
|
|
206
261
|
helpUnreg: "The session file is fine but no workspace claims it yet. Click to file it into the matching group; files are not moved.",
|
|
207
262
|
pickTarget: "Pick a target group in the dropdown first",
|
|
263
|
+
idUnresolved: "Could not identify the session to move (sidebar structure or titles changed); this move was cancelled. Refresh the page and try again.",
|
|
264
|
+
idUnresolvedPartial: "{n} row(s) could not be identified and will be skipped — this move only carries the sessions whose identity was confirmed.",
|
|
265
|
+
batchTooMany: "At most {max} sessions per move (this one had {n})",
|
|
266
|
+
dragPayloadBad: "Could not read the dragged content (host version change?); this move was cancelled",
|
|
208
267
|
relinkBtn: "Move there",
|
|
209
268
|
attachBtn: "File into group",
|
|
210
269
|
relinked: "✓ Moved into \"{title}\"; history preserved as-is",
|
|
@@ -238,13 +297,13 @@ window.__ModuleLoader__.load({
|
|
|
238
297
|
batchUndoPartial: "; {n} not undone (retry available)",
|
|
239
298
|
pickHint: "{n} sessions picked · Ctrl+click rows to multi-select · drag onto a workspace header to move in bulk · Esc to clear",
|
|
240
299
|
pickCleared: "Selection cleared",
|
|
241
|
-
pickEscHint: "Press Esc to clear the multi-selection (not while typing in the composer)",
|
|
242
300
|
groupMoveTitle: "Move sessions of \"{title}\" to…",
|
|
243
301
|
groupMoveHint: "The whole group will be truly moved to the target; running sessions are skipped and noted in the result.",
|
|
244
302
|
groupMoveEmpty: "No movable sessions in this group.",
|
|
245
303
|
openFolderMenu: "Open folder",
|
|
246
304
|
archivedCaption: "Archived sessions",
|
|
247
305
|
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.",
|
|
306
|
+
archiveTruncated: "Note: the scan parsed only the newest {parsed} of {total} session archives, so older archived sessions may be missing from this list.",
|
|
248
307
|
restoreBtn: "Restore",
|
|
249
308
|
restoreToBtn: "Restore to…",
|
|
250
309
|
restorePickTitle: "Restore \"{title}\" to…",
|
|
@@ -268,12 +327,20 @@ window.__ModuleLoader__.load({
|
|
|
268
327
|
purgeAllBtn: "Empty recycle bin",
|
|
269
328
|
purgeAllConfirm: "Purge all {n} sessions in the recycle bin? This cannot be undone.",
|
|
270
329
|
purgedMsg: "✓ Purged {n} item(s)",
|
|
330
|
+
purgeBackupsToo: "This session still has {n} migration backup copy(ies). Delete them too?\n\n(The session no longer exists, so those backups will not be used again; keeping them lets you restore or clean them separately from the backup list.)",
|
|
331
|
+
purgeBackupsDone: " (including {n} backup copy(ies))",
|
|
271
332
|
backupsCaption: "Backups",
|
|
272
|
-
backupsHelp: "
|
|
273
|
-
backupMeta: "{n}
|
|
333
|
+
backupsHelp: "Migration recovery snapshots are listed here. Identical session content keeps one physical snapshot while every move remains in history; restore to the original location or any other group.",
|
|
334
|
+
backupMeta: "{n} snapshots · {size} · {range}",
|
|
274
335
|
backupDeleteBtn: "Delete backups",
|
|
275
336
|
backupDeleteConfirm: "Delete the {n} backup copies of \"{title}\"? They cannot be restored from afterwards.",
|
|
276
337
|
backupDeletedMsg: "✓ Deleted {n} backup copy(ies)",
|
|
338
|
+
backupOrphanBadge: "Orphan",
|
|
339
|
+
backupOrphanHelp: "This session's archive no longer exists (usually purged). These backups are no longer referenced by any operation and can be cleaned safely.",
|
|
340
|
+
backupOrphanSummary: "Orphan backups: {n} group(s) · {size} (session no longer exists)",
|
|
341
|
+
backupOrphanCleanup: "Clean orphan backups",
|
|
342
|
+
backupOrphanConfirm: "Delete {n} orphan backup group(s) ({size})? Those sessions no longer exist, so they cannot be restored from these backups afterwards.",
|
|
343
|
+
backupOrphanPartial: "✓ Deleted {n}; {f} group(s) failed (retry available)",
|
|
277
344
|
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.",
|
|
278
345
|
sessionBusy: "This session is busy: a move / restore / delete is still in flight. Try again in a moment.",
|
|
279
346
|
contextStale: "The plugin context went stale (client-side hot reload). Refresh the page (or restart the app) and the features will work again.",
|
|
@@ -292,7 +359,13 @@ window.__ModuleLoader__.load({
|
|
|
292
359
|
doctorDataBackups: "Backups",
|
|
293
360
|
doctorDataHistory: "Move history",
|
|
294
361
|
doctorDataTasks: "Task records",
|
|
362
|
+
doctorDataMigrations: "Migration queue",
|
|
295
363
|
doctorRecovery: "Recovery records",
|
|
364
|
+
doctorDataLayout: "Storage layout",
|
|
365
|
+
doctorStorageAdapter: "Storage adapter",
|
|
366
|
+
adapterFilesystemFallback: "This DSH build has no public raw archive path API. The filesystem adapter is active; migration is available when the storage layout check passes.",
|
|
367
|
+
adapterOfficialHandle: "Official session APIs are available without a raw archive path; archive directory migration is unavailable.",
|
|
368
|
+
adapterUnavailable: "The persistence provider does not expose enough usable APIs.",
|
|
296
369
|
doctorWorkspaces: "Workspace paths",
|
|
297
370
|
repairAllBtn: "Fix all",
|
|
298
371
|
repairAllDone: "✓ Fixed {n} · skipped {s} · failed {f}",
|
|
@@ -300,6 +373,39 @@ window.__ModuleLoader__.load({
|
|
|
300
373
|
filterPh: "Filter: title / session id / path…",
|
|
301
374
|
tasksCaption: "Move tasks",
|
|
302
375
|
tasksHelp: "Persistent records of bulk moves: every done / failed item is inspectable. Failed items can be retried in one click — retried from where each session currently lives, never from a stale recorded path; clearing a record never touches moved sessions.",
|
|
376
|
+
queueCaption: "Waiting migrations",
|
|
377
|
+
queueHelp: "Running sessions are never touched. The migration is retried automatically after the session becomes idle; failed items can be retried or cancelled.",
|
|
378
|
+
queueRetryBtn: "Retry",
|
|
379
|
+
queueRetryAllBtn: "Retry all failed",
|
|
380
|
+
queueCancelBtn: "Cancel",
|
|
381
|
+
queueRetryDone: "✓ Migration retried",
|
|
382
|
+
queueRetryAllDone: "✓ Retried {n} failed migration(s) independently",
|
|
383
|
+
queueCancelled: "✓ Migration cancelled",
|
|
384
|
+
queuedMove: "Queued: it will move automatically when the session is idle",
|
|
385
|
+
batchQueuedTail: "; {n} session(s) queued until idle",
|
|
386
|
+
previewStatus: "Preview status",
|
|
387
|
+
previewSource: "Source",
|
|
388
|
+
previewTarget: "Target path",
|
|
389
|
+
previewBackup: "New backup bytes",
|
|
390
|
+
previewBackupReused: "Reusing an existing snapshot; no new disk space",
|
|
391
|
+
previewBackupHelp: "A recovery snapshot is saved before migration for rollback and restoration. Identical session content reuses an existing snapshot while the primary archive still moves only once.",
|
|
392
|
+
previewSameWorkspace: "The session is already in the target workspace; no move is needed.",
|
|
393
|
+
previewSelfHeal: "The archive is already in the target workspace; its membership will be repaired.",
|
|
394
|
+
previewRunning: "The session is running and will wait until idle.",
|
|
395
|
+
previewNoPhysicalPath: "This DSH build exposes no raw archive path; archive directory migration is unavailable.",
|
|
396
|
+
previewSourceMissing: "The source session archive is missing or unreadable.",
|
|
397
|
+
previewCollision: "The target already contains an archive with this ID; overwriting is blocked.",
|
|
398
|
+
previewGenerations: "Multiple format generations found; v{n} is selected and older files are preserved.",
|
|
399
|
+
previewDescendants: "{n} descendant sessions selected.",
|
|
400
|
+
forkMode: "Fork scope",
|
|
401
|
+
forkCurrentOnly: "Current session only",
|
|
402
|
+
forkIncludeDescendants: "Include descendants",
|
|
403
|
+
forkHint: "Only the current session moves by default. Descendants are migrated independently and reported one by one.",
|
|
404
|
+
previewWarning: "Note",
|
|
405
|
+
previewSafe: "Safe to move",
|
|
406
|
+
previewQueued: "Will wait until idle",
|
|
407
|
+
previewBlocked: "Cannot move now",
|
|
408
|
+
previewWarningState: "Review before moving",
|
|
303
409
|
taskRetryBtn: "Retry failed",
|
|
304
410
|
taskRetryDone: "✓ Retry finished: {n} succeeded · {f} failed",
|
|
305
411
|
taskForgetBtn: "Clear record",
|
|
@@ -312,6 +418,18 @@ window.__ModuleLoader__.load({
|
|
|
312
418
|
}
|
|
313
419
|
};
|
|
314
420
|
const lang = (navigator.language || "en").toLowerCase().startsWith("zh") ? "zh" : "en";
|
|
421
|
+
// 词条漂移防护:新增文案只补一种语言时,缺失的那一侧会静默回退成 key
|
|
422
|
+
//(界面上直接显示 idUnresolved 这样的标识符)。启动时对账一次并告警。
|
|
423
|
+
(() => {
|
|
424
|
+
const keysOf = (l) => new Set(Object.keys(STRINGS[l] ?? {}));
|
|
425
|
+
const zhKeys = keysOf("zh");
|
|
426
|
+
const enKeys = keysOf("en");
|
|
427
|
+
const onlyZh = [...zhKeys].filter((k) => !enKeys.has(k));
|
|
428
|
+
const onlyEn = [...enKeys].filter((k) => !zhKeys.has(k));
|
|
429
|
+
if (onlyZh.length || onlyEn.length) {
|
|
430
|
+
console.warn("[workspace-mover] i18n key drift — missing in en:", onlyZh, "missing in zh:", onlyEn);
|
|
431
|
+
}
|
|
432
|
+
})();
|
|
315
433
|
const t = (key, vars) => {
|
|
316
434
|
let s = STRINGS[lang][key] ?? key;
|
|
317
435
|
for (const [k, v] of Object.entries(vars ?? {})) s = s.replaceAll(`{${k}}`, String(v));
|
|
@@ -323,6 +441,28 @@ window.__ModuleLoader__.load({
|
|
|
323
441
|
const raw = String(err?.message ?? err);
|
|
324
442
|
return /inactive context/.test(raw) ? t("contextStale") : raw;
|
|
325
443
|
};
|
|
444
|
+
const diagnosticText = (raw) => {
|
|
445
|
+
const text = String(raw ?? "");
|
|
446
|
+
const keys = {
|
|
447
|
+
'official session handles are available only for metadata; physical JSONL path adapter is active': 'adapterFilesystemFallback',
|
|
448
|
+
'public raw-byte path API is unavailable; physical JSONL path adapter is active': 'adapterFilesystemFallback',
|
|
449
|
+
'provider exposes public list/stat/open APIs but no public raw-byte path; physical moves are unavailable': 'adapterOfficialHandle',
|
|
450
|
+
'provider does not expose enough public persistence APIs': 'adapterUnavailable',
|
|
451
|
+
'physical move uses the JSONL path fallback; official raw-byte path API is unavailable': 'adapterFilesystemFallback',
|
|
452
|
+
'session is currently running and will wait for idle': 'previewRunning',
|
|
453
|
+
'provider exposes public handles but no physical raw-byte path; this version cannot relocate the archive directory': 'previewNoPhysicalPath',
|
|
454
|
+
'source session archive is missing or unreadable': 'previewSourceMissing',
|
|
455
|
+
'target workspace already contains an archive with this session id': 'previewCollision',
|
|
456
|
+
'session already belongs to the target workspace': 'previewSameWorkspace',
|
|
457
|
+
'session archive is already at target; workspace membership will be repaired': 'previewSelfHeal'
|
|
458
|
+
};
|
|
459
|
+
if (keys[text]) return t(keys[text]);
|
|
460
|
+
const generations = /^multiple generations found; v(\d+) is selected and older files are preserved$/.exec(text);
|
|
461
|
+
if (generations) return t('previewGenerations', { n: generations[1] });
|
|
462
|
+
const descendants = /^(\d+) descendant session\(s\) selected$/.exec(text);
|
|
463
|
+
if (descendants) return t('previewDescendants', { n: descendants[1] });
|
|
464
|
+
return text;
|
|
465
|
+
};
|
|
326
466
|
|
|
327
467
|
function ensureStyle() {
|
|
328
468
|
if (document.getElementById(STYLE_ID)) return;
|
|
@@ -346,6 +486,7 @@ window.__ModuleLoader__.load({
|
|
|
346
486
|
.wsm-row{font-size:13px;line-height:20px;margin:6px 0;word-break:break-all}
|
|
347
487
|
.wsm-label{color:var(--dsw-alias-label-secondary,#777)}
|
|
348
488
|
.wsm-hint{font-size:12px;color:var(--dsw-alias-label-tertiary,#999);margin-top:10px;line-height:17px}
|
|
489
|
+
.wsm-preview{font-size:12px;line-height:18px;white-space:pre-wrap;word-break:break-word;margin-top:10px;padding:8px 10px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:8px;background:var(--dsw-alias-bg-layer-1,#fafafa);color:var(--dsw-alias-label-secondary,#666)}
|
|
349
490
|
.wsm-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
|
350
491
|
.wsm-btn{appearance:none;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.15));background:transparent;color:var(--dsw-alias-label-primary,#111);
|
|
351
492
|
border-radius:8px;padding:6px 14px;font-size:13px;cursor:pointer}
|
|
@@ -510,15 +651,21 @@ window.__ModuleLoader__.load({
|
|
|
510
651
|
}
|
|
511
652
|
}
|
|
512
653
|
|
|
654
|
+
/** 弹窗实例计数:为 aria-labelledby 生成稳定且唯一的关联 id。 */
|
|
655
|
+
let overlaySeq = 0;
|
|
656
|
+
|
|
513
657
|
function injectOverlay() {
|
|
658
|
+
const uid = `wsm-title-${++overlaySeq}`;
|
|
514
659
|
const overlay = document.createElement("div");
|
|
515
660
|
overlay.className = "wsm-overlay";
|
|
516
661
|
overlay.innerHTML = `
|
|
517
|
-
<div class="wsm-card" role="dialog" aria-modal="true">
|
|
518
|
-
<div class="wsm-title"></div>
|
|
662
|
+
<div class="wsm-card" role="dialog" aria-modal="true" aria-labelledby="${uid}">
|
|
663
|
+
<div class="wsm-title" id="${uid}"></div>
|
|
519
664
|
<div class="wsm-row"><span class="wsm-label"></span> <b class="wsm-session"></b></div>
|
|
520
665
|
<div class="wsm-row"><span class="wsm-label"></span> <b class="wsm-target"></b></div>
|
|
666
|
+
<div class="wsm-row wsm-fork-row" hidden><span class="wsm-label"></span> <select class="wsm-select wsm-fork-mode"></select></div>
|
|
521
667
|
<div class="wsm-hint"></div>
|
|
668
|
+
<div class="wsm-preview" hidden></div>
|
|
522
669
|
<div class="wsm-actions">
|
|
523
670
|
<button class="wsm-btn wsm-cancel"></button>
|
|
524
671
|
<button class="wsm-btn primary wsm-ok"></button>
|
|
@@ -532,24 +679,139 @@ window.__ModuleLoader__.load({
|
|
|
532
679
|
return overlay;
|
|
533
680
|
}
|
|
534
681
|
|
|
535
|
-
|
|
682
|
+
/**
|
|
683
|
+
* 挂载弹窗并补齐可达性:焦点进入弹窗、Tab 在弹窗内循环、
|
|
684
|
+
* 背景点击关闭。`onKey` 是各调用方自己的 Escape 处理(用于释放监听器)。
|
|
685
|
+
* 必须**先 append 再 focus**——未挂到文档上的元素在浏览器里无法聚焦。
|
|
686
|
+
*/
|
|
687
|
+
function openDialog(overlay, close) {
|
|
688
|
+
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(null); });
|
|
689
|
+
document.body.appendChild(overlay);
|
|
690
|
+
const card = overlay.querySelector(".wsm-card");
|
|
691
|
+
const focusables = [...card.querySelectorAll("button:not(:disabled)")];
|
|
692
|
+
(focusables.find((b) => b.classList.contains("primary")) ?? focusables[0] ?? card)?.focus?.();
|
|
693
|
+
overlay.addEventListener("keydown", (e) => {
|
|
694
|
+
if (e.key !== "Tab" || focusables.length === 0) return;
|
|
695
|
+
const at = focusables.indexOf(document.activeElement);
|
|
696
|
+
const next = e.shiftKey
|
|
697
|
+
? (at <= 0 ? focusables.length - 1 : at - 1)
|
|
698
|
+
: (at === -1 || at === focusables.length - 1 ? 0 : at + 1);
|
|
699
|
+
// 自管焦点:避免 Tab 跑到弹窗背后的侧边栏/输入框
|
|
700
|
+
focusables[next]?.focus?.();
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function formatPreviewBytes(value) {
|
|
705
|
+
const n = Number(value);
|
|
706
|
+
if (!Number.isFinite(n) || n <= 0) return "0 B";
|
|
707
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
708
|
+
const at = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1);
|
|
709
|
+
return `${(n / (1024 ** at)).toFixed(at === 0 ? 0 : 1)} ${units[at]}`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function confirmMove({ sessionId, sessionTitle, workspace, workspaces, loadPreview, batchCount, unresolvedCount, preview }) {
|
|
536
713
|
return new Promise((resolve) => {
|
|
537
714
|
const overlay = injectOverlay();
|
|
538
715
|
const okBtn = overlay.querySelector(".wsm-ok");
|
|
716
|
+
let selectedWorkspace = workspace;
|
|
717
|
+
let previewRequest = 0;
|
|
718
|
+
let closed = false;
|
|
539
719
|
overlay.querySelector(".wsm-title").textContent = batchCount ? t("batchConfirmTitle") : t("confirmTitle");
|
|
540
720
|
overlay.querySelector(".wsm-session").textContent = batchCount
|
|
541
721
|
? t("scannedN", { n: batchCount })
|
|
542
722
|
: (sessionTitle || t("unnamedSession"));
|
|
543
723
|
overlay.querySelector(".wsm-target").textContent = `${workspace.title}(${workspace.path})`;
|
|
544
|
-
|
|
724
|
+
let targetSelect = null;
|
|
725
|
+
if (Array.isArray(workspaces) && typeof loadPreview === 'function') {
|
|
726
|
+
targetSelect = document.createElement('select');
|
|
727
|
+
targetSelect.className = 'wsm-select wsm-target-select';
|
|
728
|
+
targetSelect.setAttribute('aria-label', t('target'));
|
|
729
|
+
for (const item of workspaces) {
|
|
730
|
+
const option = document.createElement('option');
|
|
731
|
+
option.value = item.workspaceId;
|
|
732
|
+
option.textContent = `${item.title} (${item.path})`;
|
|
733
|
+
targetSelect.append(option);
|
|
734
|
+
}
|
|
735
|
+
targetSelect.value = workspace.workspaceId;
|
|
736
|
+
overlay.querySelector('.wsm-target').textContent = '';
|
|
737
|
+
overlay.querySelector('.wsm-target').append(targetSelect);
|
|
738
|
+
}
|
|
739
|
+
const forkRow = overlay.querySelector(".wsm-fork-row");
|
|
740
|
+
const forkMode = overlay.querySelector(".wsm-fork-mode");
|
|
741
|
+
forkRow.hidden = false;
|
|
742
|
+
forkRow.querySelector(".wsm-label").textContent = t("forkMode") + ":";
|
|
743
|
+
for (const [label, value] of [[t("forkCurrentOnly"), "current-only"], [t("forkIncludeDescendants"), "include-descendants"]]) {
|
|
744
|
+
const option = document.createElement("option");
|
|
745
|
+
option.value = value;
|
|
746
|
+
option.textContent = label;
|
|
747
|
+
forkMode.append(option);
|
|
748
|
+
}
|
|
749
|
+
forkMode.value = "current-only";
|
|
750
|
+
const hint = overlay.querySelector(".wsm-hint");
|
|
751
|
+
hint.textContent = `${batchCount ? t("batchHint", { count: batchCount }) : t("hint")} ${t("previewBackupHelp")} ${t("forkHint")}`;
|
|
752
|
+
function renderPreview(plan) {
|
|
753
|
+
preview = plan;
|
|
754
|
+
if (!preview) return;
|
|
755
|
+
const statusLabels = {
|
|
756
|
+
safe: t("previewSafe"),
|
|
757
|
+
queued: t("previewQueued"),
|
|
758
|
+
blocked: t("previewBlocked"),
|
|
759
|
+
warning: t("previewWarningState")
|
|
760
|
+
};
|
|
761
|
+
const source = preview.source?.title || preview.source?.cwd || "—";
|
|
762
|
+
const target = preview.target?.path || selectedWorkspace.path || "—";
|
|
763
|
+
const warnings = Array.isArray(preview.warnings) ? preview.warnings.filter(Boolean).map(diagnosticText) : [];
|
|
764
|
+
const previewBox = overlay.querySelector(".wsm-preview");
|
|
765
|
+
previewBox.hidden = false;
|
|
766
|
+
previewBox.textContent = [
|
|
767
|
+
`${t("previewStatus")}: ${statusLabels[preview.status] || preview.status || "—"}`,
|
|
768
|
+
`${t("previewSource")}: ${source}`,
|
|
769
|
+
`${t("previewTarget")}: ${target}`,
|
|
770
|
+
`${t("previewBackup")}: ${formatPreviewBytes(preview.estimatedBackupBytes)}${preview.backup?.reused ? ` (${t("previewBackupReused")})` : ""}`,
|
|
771
|
+
warnings.length ? `${t("previewWarning")}: ${warnings.join("; ")}` : ""
|
|
772
|
+
].filter(Boolean).join("\n");
|
|
773
|
+
okBtn.disabled = preview.status === "blocked";
|
|
774
|
+
}
|
|
775
|
+
renderPreview(preview);
|
|
776
|
+
async function refreshPreview() {
|
|
777
|
+
const request = ++previewRequest;
|
|
778
|
+
selectedWorkspace = workspaces.find((item) => item.workspaceId === targetSelect.value);
|
|
779
|
+
okBtn.disabled = true;
|
|
780
|
+
const box = overlay.querySelector('.wsm-preview');
|
|
781
|
+
box.hidden = false;
|
|
782
|
+
box.textContent = t('doctorChecking');
|
|
783
|
+
try {
|
|
784
|
+
const plan = await loadPreview(selectedWorkspace.workspaceId, forkMode.value || 'current-only');
|
|
785
|
+
if (!closed && request === previewRequest) renderPreview(plan);
|
|
786
|
+
} catch (err) {
|
|
787
|
+
if (!closed && request === previewRequest) box.textContent = t('failed', { msg: errMsg(err) });
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
if (targetSelect) {
|
|
791
|
+
targetSelect.addEventListener('change', () => void refreshPreview());
|
|
792
|
+
forkMode.addEventListener('change', () => void refreshPreview());
|
|
793
|
+
}
|
|
794
|
+
// 有行无法识别时必须如实说明——用户点"确认"之前要知道这次只搬走了一部分。
|
|
795
|
+
if (unresolvedCount > 0) {
|
|
796
|
+
const warn = document.createElement("div");
|
|
797
|
+
warn.className = "wsm-hint";
|
|
798
|
+
warn.textContent = t("idUnresolvedPartial", { n: unresolvedCount });
|
|
799
|
+
hint.after(warn);
|
|
800
|
+
}
|
|
545
801
|
okBtn.textContent = batchCount ? t("batchMove") : t("move");
|
|
546
|
-
const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
|
|
547
|
-
const onKey = (e) => { if (e.key === "Escape") close(null); };
|
|
548
802
|
document.addEventListener("keydown", onKey);
|
|
549
|
-
|
|
803
|
+
function onKey(e) { if (e.key === "Escape") close(null); }
|
|
804
|
+
function close(value) {
|
|
805
|
+
closed = true;
|
|
806
|
+
overlay.remove();
|
|
807
|
+
document.removeEventListener("keydown", onKey);
|
|
808
|
+
resolve(value);
|
|
809
|
+
}
|
|
550
810
|
overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
|
|
551
|
-
okBtn.addEventListener("click", () =>
|
|
552
|
-
|
|
811
|
+
okBtn.addEventListener("click", () => {
|
|
812
|
+
if (!okBtn.disabled) close({ mode: forkMode.value || "current-only", targetWorkspaceId: selectedWorkspace.workspaceId });
|
|
813
|
+
});
|
|
814
|
+
openDialog(overlay, close);
|
|
553
815
|
});
|
|
554
816
|
}
|
|
555
817
|
|
|
@@ -577,14 +839,17 @@ window.__ModuleLoader__.load({
|
|
|
577
839
|
okBtn.disabled = true;
|
|
578
840
|
const select = overlay.querySelector("select.wsm-select");
|
|
579
841
|
select.addEventListener("change", () => { okBtn.disabled = !select.value; });
|
|
580
|
-
const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
|
|
581
|
-
const onKey = (e) => { if (e.key === "Escape") close(null); };
|
|
582
842
|
document.addEventListener("keydown", onKey);
|
|
583
|
-
|
|
843
|
+
function onKey(e) { if (e.key === "Escape") close(null); }
|
|
844
|
+
function close(value) {
|
|
845
|
+
overlay.remove();
|
|
846
|
+
document.removeEventListener("keydown", onKey);
|
|
847
|
+
resolve(value);
|
|
848
|
+
}
|
|
584
849
|
overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
|
|
585
850
|
okBtn.addEventListener("click", () => close(select.value || null));
|
|
586
|
-
|
|
587
|
-
|
|
851
|
+
openDialog(overlay, close);
|
|
852
|
+
okBtn.focus();
|
|
588
853
|
});
|
|
589
854
|
}
|
|
590
855
|
|
|
@@ -612,14 +877,17 @@ window.__ModuleLoader__.load({
|
|
|
612
877
|
okBtn.disabled = true;
|
|
613
878
|
const select = overlay.querySelector("select.wsm-select");
|
|
614
879
|
select.addEventListener("change", () => { okBtn.disabled = !select.value; });
|
|
615
|
-
const close = (value) => { overlay.remove(); document.removeEventListener("keydown", onKey); resolve(value); };
|
|
616
|
-
const onKey = (e) => { if (e.key === "Escape") close(null); };
|
|
617
880
|
document.addEventListener("keydown", onKey);
|
|
618
|
-
|
|
881
|
+
function onKey(e) { if (e.key === "Escape") close(null); }
|
|
882
|
+
function close(value) {
|
|
883
|
+
overlay.remove();
|
|
884
|
+
document.removeEventListener("keydown", onKey);
|
|
885
|
+
resolve(value);
|
|
886
|
+
}
|
|
619
887
|
overlay.querySelector(".wsm-cancel").addEventListener("click", () => close(null));
|
|
620
888
|
okBtn.addEventListener("click", () => close(select.value || null));
|
|
621
|
-
document.body.appendChild(overlay);
|
|
622
889
|
if (select.value) okBtn.disabled = false;
|
|
890
|
+
openDialog(overlay, close);
|
|
623
891
|
select.focus();
|
|
624
892
|
});
|
|
625
893
|
}
|
|
@@ -670,6 +938,7 @@ window.__ModuleLoader__.load({
|
|
|
670
938
|
const [trash, setTrash] = useState(null);
|
|
671
939
|
const [backups, setBackups] = useState(null);
|
|
672
940
|
const [tasks, setTasks] = useState(null);
|
|
941
|
+
const [migrations, setMigrations] = useState(null);
|
|
673
942
|
const [doctor, setDoctor] = useState(null);
|
|
674
943
|
const [query, setQuery] = useState("");
|
|
675
944
|
|
|
@@ -691,7 +960,7 @@ window.__ModuleLoader__.load({
|
|
|
691
960
|
const runScan = async () => {
|
|
692
961
|
setBusy(true);
|
|
693
962
|
try {
|
|
694
|
-
const [s, w, h, a, ar, tr, bp, tk] = await Promise.all([call("mover.scan"), call("mover.workspaces"), call("mover.history"), call("mover.ws.audit"), call("mover.archived"), call("mover.trash.list"), call("mover.backups.list"), call("mover.tasks.list")]);
|
|
963
|
+
const [s, w, h, a, ar, tr, bp, tk, mq] = await Promise.all([call("mover.scan"), call("mover.workspaces"), call("mover.history"), call("mover.ws.audit"), call("mover.archived"), call("mover.trash.list"), call("mover.backups.list"), call("mover.tasks.list"), call("mover.migrations.list")]);
|
|
695
964
|
setScan(s);
|
|
696
965
|
setWorkspaces(w.items ?? []);
|
|
697
966
|
setHistory(h.items ?? []);
|
|
@@ -700,6 +969,7 @@ window.__ModuleLoader__.load({
|
|
|
700
969
|
setTrash(tr);
|
|
701
970
|
setBackups(bp);
|
|
702
971
|
setTasks(tk);
|
|
972
|
+
setMigrations(mq);
|
|
703
973
|
} catch (err) {
|
|
704
974
|
setNote(t("failed", { msg: errMsg(err) }));
|
|
705
975
|
} finally {
|
|
@@ -1028,11 +1298,18 @@ window.__ModuleLoader__.load({
|
|
|
1028
1298
|
|
|
1029
1299
|
const purgeOne = async (item) => {
|
|
1030
1300
|
if (!window.confirm(t("purgeConfirm", { title: item.title || t("unnamedSession") }))) return;
|
|
1301
|
+
// 彻底删除不可撤销:顺便问一句要不要把该会话的迁移备份也清掉
|
|
1302
|
+
//(这些备份此刻已无还原对象,留着只会变成孤儿字节)。
|
|
1303
|
+
const purgeBackups = item.backupCount > 0
|
|
1304
|
+
? window.confirm(t("purgeBackupsToo", { n: item.backupCount }))
|
|
1305
|
+
: false;
|
|
1031
1306
|
setBusy(true);
|
|
1032
1307
|
setNote("");
|
|
1033
1308
|
try {
|
|
1034
|
-
const res = await call("mover.trash.purge", { sessionId: item.sessionId });
|
|
1035
|
-
|
|
1309
|
+
const res = await call("mover.trash.purge", { sessionId: item.sessionId, purgeBackups });
|
|
1310
|
+
let msg = t("purgedMsg", { n: res?.purged ?? 1 });
|
|
1311
|
+
if ((res?.backupsDeleted ?? 0) > 0) msg += t("purgeBackupsDone", { n: res.backupsDeleted });
|
|
1312
|
+
setNote(msg);
|
|
1036
1313
|
await runScan();
|
|
1037
1314
|
} catch (err) {
|
|
1038
1315
|
failNote(err);
|
|
@@ -1110,6 +1387,29 @@ window.__ModuleLoader__.load({
|
|
|
1110
1387
|
}
|
|
1111
1388
|
};
|
|
1112
1389
|
|
|
1390
|
+
/**
|
|
1391
|
+
* 清理全部孤儿备份组的字节。孤儿 = 会话档案已不存在,这些备份不会被
|
|
1392
|
+
* 任何操作再引用;逐组调用既有端点,不新增 RPC 面。
|
|
1393
|
+
*/
|
|
1394
|
+
const backupDeleteOrphans = async () => {
|
|
1395
|
+
const orphans = (backupItems ?? []).filter((it) => it.orphan);
|
|
1396
|
+
if (orphans.length === 0) return;
|
|
1397
|
+
if (!window.confirm(t("backupOrphanConfirm", { n: orphans.length, size: fmtBytes(backups?.orphanBytes ?? 0) }))) return;
|
|
1398
|
+
setBusy(true);
|
|
1399
|
+
setNote("");
|
|
1400
|
+
let deleted = 0;
|
|
1401
|
+
let failed = 0;
|
|
1402
|
+
for (const item of orphans) {
|
|
1403
|
+
try {
|
|
1404
|
+
const res = await call("mover.backups.deleteOne", { sessionId: item.sessionId });
|
|
1405
|
+
deleted += res?.deleted ?? 0;
|
|
1406
|
+
} catch { failed++; }
|
|
1407
|
+
}
|
|
1408
|
+
setNote(failed > 0 ? t("backupOrphanPartial", { n: deleted, f: failed }) : t("backupDeletedMsg", { n: deleted }));
|
|
1409
|
+
setBusy(false);
|
|
1410
|
+
await runScan();
|
|
1411
|
+
};
|
|
1412
|
+
|
|
1113
1413
|
// 一键修复:可自动修复项(挂错归位 / 未记账补账)逐项处理,只动记账不搬文件
|
|
1114
1414
|
const repairAll = async () => {
|
|
1115
1415
|
setBusy(true);
|
|
@@ -1171,6 +1471,50 @@ window.__ModuleLoader__.load({
|
|
|
1171
1471
|
}
|
|
1172
1472
|
};
|
|
1173
1473
|
|
|
1474
|
+
const migrationRetry = async (task) => {
|
|
1475
|
+
setBusy(true);
|
|
1476
|
+
setNote("");
|
|
1477
|
+
try {
|
|
1478
|
+
await call("mover.migrations.retry", { taskId: task.id });
|
|
1479
|
+
setNote(t("queueRetryDone"));
|
|
1480
|
+
refreshWorkspaces();
|
|
1481
|
+
await runScan();
|
|
1482
|
+
} catch (err) {
|
|
1483
|
+
failNote(err);
|
|
1484
|
+
} finally {
|
|
1485
|
+
setBusy(false);
|
|
1486
|
+
}
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
const migrationRetryAll = async (taskIds) => {
|
|
1490
|
+
setBusy(true);
|
|
1491
|
+
setNote("");
|
|
1492
|
+
try {
|
|
1493
|
+
const result = await call("mover.migrations.retry", { taskIds });
|
|
1494
|
+
setNote(t("queueRetryAllDone", { n: result.retried ?? 0 }));
|
|
1495
|
+
refreshWorkspaces();
|
|
1496
|
+
await runScan();
|
|
1497
|
+
} catch (err) {
|
|
1498
|
+
failNote(err);
|
|
1499
|
+
} finally {
|
|
1500
|
+
setBusy(false);
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
|
|
1504
|
+
const migrationCancel = async (task) => {
|
|
1505
|
+
setBusy(true);
|
|
1506
|
+
setNote("");
|
|
1507
|
+
try {
|
|
1508
|
+
await call("mover.migrations.cancel", { taskId: task.id });
|
|
1509
|
+
setNote(t("queueCancelled"));
|
|
1510
|
+
await runScan();
|
|
1511
|
+
} catch (err) {
|
|
1512
|
+
failNote(err);
|
|
1513
|
+
} finally {
|
|
1514
|
+
setBusy(false);
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
|
|
1174
1518
|
// 数据保护:按时间清理回收站与备份(dryRun 先出数字,确认后才真删)
|
|
1175
1519
|
const dataCleanup = async () => {
|
|
1176
1520
|
setBusy(true);
|
|
@@ -1199,11 +1543,17 @@ window.__ModuleLoader__.load({
|
|
|
1199
1543
|
const orphaned = items.filter((it) => it.status === "orphaned");
|
|
1200
1544
|
const unregistered = items.filter((it) => it.status === "unregistered");
|
|
1201
1545
|
const misfiled = items.filter((it) => it.status === "misfiled");
|
|
1546
|
+
const unreadable = items.filter((it) => it.status === "unreadable");
|
|
1202
1547
|
const brokenWorkspaces = (audit?.items ?? []).filter((it) => it.status !== "ok");
|
|
1203
1548
|
const emptyWorkspaces = workspaces.filter((w) => (w.rawSessionCount ?? 0) === 0);
|
|
1204
1549
|
const archivedItems = archived?.items ?? [];
|
|
1205
|
-
const trashItems = trash?.items ?? [];
|
|
1206
1550
|
const backupItems = backups?.items ?? [];
|
|
1551
|
+
// 回收站条目补上"该会话还有几份迁移备份":彻底删除时顺带清理它们。
|
|
1552
|
+
const backupCountById = new Map(backupItems.map((it) => [String(it.sessionId), it.count]));
|
|
1553
|
+
const trashItems = (trash?.items ?? []).map((it) => ({
|
|
1554
|
+
...it,
|
|
1555
|
+
backupCount: backupCountById.get(String(it.sessionId)) ?? 0
|
|
1556
|
+
}));
|
|
1207
1557
|
const taskItems = tasks?.items ?? [];
|
|
1208
1558
|
// 面板筛选:标题 / 会话 ID / 路径 / 归属分组,命中任一即保留
|
|
1209
1559
|
const q = query.trim().toLowerCase();
|
|
@@ -1214,10 +1564,14 @@ window.__ModuleLoader__.load({
|
|
|
1214
1564
|
const orphanedV = orphaned.filter(matchesQuery);
|
|
1215
1565
|
const unregisteredV = unregistered.filter(matchesQuery);
|
|
1216
1566
|
const misfiledV = misfiled.filter(matchesQuery);
|
|
1567
|
+
const unreadableV = unreadable.filter(matchesQuery);
|
|
1217
1568
|
const archivedV = archivedItems.filter(matchesQuery);
|
|
1218
1569
|
const trashV = trashItems.filter(matchesQuery);
|
|
1219
1570
|
const backupV = backupItems.filter(matchesQuery);
|
|
1220
1571
|
const taskV = taskItems.filter((it) => matchesQuery({ ...it, title: null, cwd: it.targetPath }));
|
|
1572
|
+
const migrationItems = migrations?.items ?? [];
|
|
1573
|
+
const migrationV = migrationItems.filter((it) => !["done", "cancelled"].includes(it.state)).filter((it) => matchesQuery({ ...it, cwd: it.targetWorkspaceId }));
|
|
1574
|
+
const failedMigrationIds = migrationV.filter((it) => it.state === "failed").map((it) => it.id);
|
|
1221
1575
|
const capCount = (shown, total) => (q ? `${shown}/${total}` : `${total}`);
|
|
1222
1576
|
const counts = scan?.counts ?? {};
|
|
1223
1577
|
const summaryParts = [
|
|
@@ -1249,7 +1603,7 @@ window.__ModuleLoader__.load({
|
|
|
1249
1603
|
doctor.checks.map((c) => h("div", { className: "wsm-item", key: c.id },
|
|
1250
1604
|
h("span", { className: "wsm-badge" + (c.state === "fail" ? " err" : c.state === "warn" ? " warn" : "") },
|
|
1251
1605
|
c.state === "pass" ? "✓" : c.state === "warn" ? "⚠" : "✗"),
|
|
1252
|
-
h("span", { className: "wsm-cwd", title: c.detail }, t(`doctor${c.id.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("")}`) + (c.detail ? ` — ${c.detail}` : ""))
|
|
1606
|
+
h("span", { className: "wsm-cwd", title: diagnosticText(c.detail), style: { whiteSpace: "normal", overflow: "visible", overflowWrap: "anywhere" } }, t(`doctor${c.id.split("-").map((w) => w[0].toUpperCase() + w.slice(1)).join("")}`) + (c.detail ? ` — ${diagnosticText(c.detail)}` : ""))
|
|
1253
1607
|
))
|
|
1254
1608
|
) : null,
|
|
1255
1609
|
brokenWorkspaces.length > 0 ? h(Caption, { text: t("wsSection"), help: t("wsHelp") }) : null,
|
|
@@ -1322,7 +1676,27 @@ window.__ModuleLoader__.load({
|
|
|
1322
1676
|
);
|
|
1323
1677
|
})
|
|
1324
1678
|
) : null,
|
|
1679
|
+
unreadable.length > 0 ? h(Caption, { text: `${t("unreadable")} (${capCount(unreadableV.length, unreadable.length)})` }) : null,
|
|
1680
|
+
unreadableV.length > 0 ? h("div", { className: "wsm-list" },
|
|
1681
|
+
unreadableV.map((it) => {
|
|
1682
|
+
const labels = {
|
|
1683
|
+
unsupported: t("unsupportedFormat"),
|
|
1684
|
+
"layout-unknown": t("layoutUnknown"),
|
|
1685
|
+
"corrupt-data": t("corruptData")
|
|
1686
|
+
};
|
|
1687
|
+
return h("div", { className: "wsm-item", key: it.sessionId },
|
|
1688
|
+
h("span", { className: "wsm-badge err" }, t("unreadable")),
|
|
1689
|
+
h("span", { className: "wsm-mono" }, it.sessionId),
|
|
1690
|
+
h("span", { className: "wsm-cwd", title: it.diagnosticDetail ?? "" },
|
|
1691
|
+
`${labels[it.diagnostic] ?? t("layoutUnknown")}${it.diagnosticDetail ? ` — ${it.diagnosticDetail}` : ""}`)
|
|
1692
|
+
);
|
|
1693
|
+
})
|
|
1694
|
+
) : null,
|
|
1325
1695
|
archivedItems.length > 0 ? h(Caption, { text: `${t("archivedCaption")} (${capCount(archivedV.length, archivedItems.length)})`, help: t("archivedHelp") }) : null,
|
|
1696
|
+
// 扫描只解析最新 400 条档案,而归档会话通常很旧:大库上这里可能不是全部。
|
|
1697
|
+
archived?.truncated && archivedV.length > 0
|
|
1698
|
+
? h("div", { className: "wsm-note" }, t("archiveTruncated", { parsed: archived.scannedParsed ?? 0, total: archived.scannedTotal ?? 0 }))
|
|
1699
|
+
: null,
|
|
1326
1700
|
archivedV.length > 0 ? h("div", { className: "wsm-list" },
|
|
1327
1701
|
archivedV.map((it) => h("div", { className: "wsm-item", key: it.sessionId },
|
|
1328
1702
|
h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
|
|
@@ -1387,9 +1761,17 @@ window.__ModuleLoader__.load({
|
|
|
1387
1761
|
})) : h("div", { className: "wsm-note" }, t("historyEmpty"))
|
|
1388
1762
|
),
|
|
1389
1763
|
backupItems.length > 0 ? h(Caption, { text: `${t("backupsCaption")} (${capCount(backupV.length, backupItems.length)})`, help: t("backupsHelp") }) : null,
|
|
1764
|
+
// 孤儿备份:会话已不存在,这些字节不会被任何操作再引用,只能手动清。
|
|
1765
|
+
backups?.orphanGroups > 0
|
|
1766
|
+
? h("div", { className: "wsm-scanrow" },
|
|
1767
|
+
h("span", { style: { fontSize: "12.5px", color: "var(--dsw-alias-label-secondary,#666)" } },
|
|
1768
|
+
t("backupOrphanSummary", { n: backups.orphanGroups, size: fmtBytes(backups.orphanBytes ?? 0) })),
|
|
1769
|
+
h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void backupDeleteOrphans() }, t("backupOrphanCleanup")))
|
|
1770
|
+
: null,
|
|
1390
1771
|
backupV.length > 0 ? h("div", { className: "wsm-list" },
|
|
1391
1772
|
backupV.map((it) => h("div", { className: "wsm-item", key: it.sessionId },
|
|
1392
1773
|
h("span", { className: "wsm-mono" }, it.title || t("unnamedSession")),
|
|
1774
|
+
it.orphan ? h("span", { className: "wsm-badge warn", title: t("backupOrphanHelp") }, t("backupOrphanBadge")) : null,
|
|
1393
1775
|
h("span", { className: "wsm-cwd", title: it.cwd ?? "" }, t("backupMeta", { n: it.count, size: fmtBytes(it.totalBytes), range: fmtRange(it) })),
|
|
1394
1776
|
h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void backupRestore(it) }, t("restoreBtn")),
|
|
1395
1777
|
h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void backupDelete(it) }, t("backupDeleteBtn"))
|
|
@@ -1406,6 +1788,22 @@ window.__ModuleLoader__.load({
|
|
|
1406
1788
|
h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void taskForget(it) }, t("taskForgetBtn"))
|
|
1407
1789
|
))
|
|
1408
1790
|
) : null,
|
|
1791
|
+
migrationV.length > 0 ? h(Caption, { text: `${t("queueCaption")} (${capCount(migrationV.length, migrationItems.filter((it) => !["done", "cancelled"].includes(it.state)).length)})`, help: t("queueHelp") }) : null,
|
|
1792
|
+
failedMigrationIds.length > 1 ? h("div", { className: "wsm-scanrow" },
|
|
1793
|
+
h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void migrationRetryAll(failedMigrationIds) }, t("queueRetryAllBtn"))) : null,
|
|
1794
|
+
migrationV.length > 0 ? h("div", { className: "wsm-list" },
|
|
1795
|
+
migrationV.map((it) => h("div", { className: "wsm-item", key: it.id },
|
|
1796
|
+
h("span", { className: "wsm-mono", title: it.sessionId }, it.title || shortId(it.sessionId)),
|
|
1797
|
+
h("span", { className: `wsm-badge ${it.state === "failed" ? "err" : "warn"}` }, it.state),
|
|
1798
|
+
h("span", { className: "wsm-cwd", title: it.lastError ?? "" }, it.lastError || `${t("previewTarget")}: ${it.targetWorkspaceId}`),
|
|
1799
|
+
!["done", "cancelled", "retrying"].includes(it.state)
|
|
1800
|
+
? h("button", { className: "wsm-btn small primary", disabled: busy, onClick: () => void migrationRetry(it) }, t("queueRetryBtn"))
|
|
1801
|
+
: null,
|
|
1802
|
+
!["done", "cancelled", "retrying"].includes(it.state)
|
|
1803
|
+
? h("button", { className: "wsm-btn small", disabled: busy, onClick: () => void migrationCancel(it) }, t("queueCancelBtn"))
|
|
1804
|
+
: null
|
|
1805
|
+
))
|
|
1806
|
+
) : null,
|
|
1409
1807
|
scan && (scan.recoveryCount ?? 0) > 0
|
|
1410
1808
|
? h("div", { className: "wsm-note", style: { color: "var(--dsw-alias-state-error-primary,#dc2626)", marginTop: "10px" } }, t("recoveryNote", { n: scan.recoveryCount }))
|
|
1411
1809
|
: null,
|
|
@@ -1441,7 +1839,13 @@ window.__ModuleLoader__.load({
|
|
|
1441
1839
|
// slots:设置页「会话救援」面板需要;未注入时访问 ctx.slots 会被宿主拒绝
|
|
1442
1840
|
var inject = ["connection", "slots"];
|
|
1443
1841
|
|
|
1842
|
+
// 同一 bundle 被重复装载时(宿主热重载/重组)防止叠加实例。
|
|
1843
|
+
// 前一个实例在 teardown 里把它复位,因此"装载→卸载→再装载"仍能正常工作。
|
|
1844
|
+
let applied = false;
|
|
1845
|
+
|
|
1444
1846
|
function apply(ctx) {
|
|
1847
|
+
if (applied) return;
|
|
1848
|
+
applied = true;
|
|
1445
1849
|
// 在 apply(上下文必然活跃)时立即捕获 connection 服务实例本身,而不是持有 ctx
|
|
1446
1850
|
// 做惰性访问:桌面版会在热重载 / 客户端重组时废弃本插件的旧 fiber,此后
|
|
1447
1851
|
// ctx.connection 会抛 "cannot get required service 'connection' in inactive context"
|
|
@@ -1459,6 +1863,16 @@ window.__ModuleLoader__.load({
|
|
|
1459
1863
|
let dragging = null; // {el, els, id, target} —— id/target 在 drop 阶段解析
|
|
1460
1864
|
let wsCache = null; // {items, at}
|
|
1461
1865
|
|
|
1866
|
+
// 统一登记本插件挂到 document / window 上的监听器与 observer。
|
|
1867
|
+
// 必须成对释放:桌面版热重载会废弃旧 fiber 并重新装载客户端半,
|
|
1868
|
+
// 不释放就会叠加第二份监听器——重复确认框、重复 RPC,
|
|
1869
|
+
// 两份闭包还会互相擦掉对方的多选高亮。
|
|
1870
|
+
const disposers = [];
|
|
1871
|
+
const listen = (type, fn, options) => {
|
|
1872
|
+
document.addEventListener(type, fn, options);
|
|
1873
|
+
disposers.push(() => document.removeEventListener(type, fn, options));
|
|
1874
|
+
};
|
|
1875
|
+
|
|
1462
1876
|
ensureStyle();
|
|
1463
1877
|
|
|
1464
1878
|
// 状态仅作诊断:不硬性禁用拖拽(避免启动期竞态导致永久失效)。
|
|
@@ -1466,8 +1880,8 @@ window.__ModuleLoader__.load({
|
|
|
1466
1880
|
console.warn("[workspace-mover] status probe failed (will retry per move):", err);
|
|
1467
1881
|
});
|
|
1468
1882
|
|
|
1469
|
-
async function fetchWorkspaces() {
|
|
1470
|
-
if (wsCache && Date.now() - wsCache.at < 3000) return wsCache.items;
|
|
1883
|
+
async function fetchWorkspaces(fresh = false) {
|
|
1884
|
+
if (!fresh && wsCache && Date.now() - wsCache.at < 3000) return wsCache.items;
|
|
1471
1885
|
const res = await rpcCall("mover.workspaces");
|
|
1472
1886
|
if (!res?.ok) throw new Error(res?.error?.message ?? "workspace list unavailable");
|
|
1473
1887
|
wsCache = { items: res.value.items, at: Date.now() };
|
|
@@ -1480,9 +1894,9 @@ window.__ModuleLoader__.load({
|
|
|
1480
1894
|
|
|
1481
1895
|
//#region 插件自建多选(宿主侧边栏没有多选模型)
|
|
1482
1896
|
// Ctrl/Cmd+点击加入/移出、Shift+点击组内范围选择;拖动任一选中行 = 整批拖动。
|
|
1483
|
-
// 选中集存行元素(判定同步、零延迟);会话 id
|
|
1484
|
-
//
|
|
1485
|
-
//
|
|
1897
|
+
// 选中集存行元素(判定同步、零延迟);会话 id 在点击/投放时解析:
|
|
1898
|
+
// 优先走权威通道 rowSessionId(React fiber 上的 node.id),不可用时退化为
|
|
1899
|
+
// 「组内对齐 + 标题匹配」——且**匹配不上就不猜**,无法确认的行会被跳过并告知用户。
|
|
1486
1900
|
let pickBadge = null;
|
|
1487
1901
|
const pickedRows = new Set();
|
|
1488
1902
|
let lastPickedRow = null;
|
|
@@ -1550,7 +1964,7 @@ window.__ModuleLoader__.load({
|
|
|
1550
1964
|
*/
|
|
1551
1965
|
function rowSessionId(rowEl) {
|
|
1552
1966
|
try {
|
|
1553
|
-
const fiberKey = Object.
|
|
1967
|
+
const fiberKey = Object.getOwnPropertyNames(rowEl).find((k) => k.startsWith("__reactFiber$"));
|
|
1554
1968
|
if (!fiberKey) return null;
|
|
1555
1969
|
for (let fiber = rowEl[fiberKey]; fiber; fiber = fiber.return) {
|
|
1556
1970
|
const node = fiber.memoizedProps?.node;
|
|
@@ -1564,35 +1978,48 @@ window.__ModuleLoader__.load({
|
|
|
1564
1978
|
}
|
|
1565
1979
|
|
|
1566
1980
|
/**
|
|
1567
|
-
* 把一组会话行对齐到
|
|
1568
|
-
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1981
|
+
* 把一组会话行对齐到 ws.sessionIds。仅在权威通道(rowSessionId)不可用时使用。
|
|
1982
|
+
*
|
|
1983
|
+
* 关键安全约定:**匹配不上就不猜**。旧实现在标题匹配失败时直接取
|
|
1984
|
+
* `remaining` 里的下一个 id,而官方侧边栏会隐藏空白/归档行,于是整组错位一格、
|
|
1985
|
+
* 批量拖拽会搬走另一组合法会话。现在无法确定的行一律计入 unresolved,
|
|
1986
|
+
* 由调用方决定跳过还是中止。
|
|
1987
|
+
*
|
|
1988
|
+
* 返回 { map, unresolved };map 为 row → id。
|
|
1571
1989
|
*/
|
|
1572
1990
|
async function mapGroupRows(ws, groupRows) {
|
|
1573
1991
|
const titleOf = new Map();
|
|
1574
1992
|
try {
|
|
1575
1993
|
const scan = await fetchScan();
|
|
1576
1994
|
for (const it of scan.items ?? []) titleOf.set(String(it.sessionId), String(it.title ?? "").replace(/\s+/g, " ").trim());
|
|
1577
|
-
} catch { /* scan
|
|
1995
|
+
} catch { /* scan 不可用时无法按标题匹配,只能精确匹配或放弃 */ }
|
|
1578
1996
|
const remaining = (ws.sessionIds ?? []).map(String);
|
|
1579
1997
|
const out = new Map();
|
|
1998
|
+
const unresolved = [];
|
|
1580
1999
|
for (const row of groupRows) {
|
|
1581
|
-
if (remaining.length === 0)
|
|
2000
|
+
if (remaining.length === 0) { unresolved.push(row); continue; }
|
|
1582
2001
|
const text = (row.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
1583
2002
|
let hit = -1;
|
|
1584
2003
|
if (text && text !== t("unnamedSession")) {
|
|
1585
|
-
|
|
2004
|
+
// 先精确,再"标题被行文本包含"(行文本通常更短,只带徽章/时间)
|
|
2005
|
+
hit = remaining.findIndex((id) => titleOf.get(id) === text);
|
|
2006
|
+
if (hit < 0) {
|
|
2007
|
+
hit = remaining.findIndex((id) => {
|
|
2008
|
+
const tt = titleOf.get(id);
|
|
2009
|
+
return tt && tt.length > 0 && text.includes(tt);
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
1586
2012
|
}
|
|
1587
|
-
if (hit < 0)
|
|
2013
|
+
if (hit < 0) { unresolved.push(row); continue; }
|
|
1588
2014
|
const id = remaining.splice(hit, 1)[0];
|
|
2015
|
+
if (id === undefined) { unresolved.push(row); continue; }
|
|
1589
2016
|
row.dataset.wsmId = id;
|
|
1590
2017
|
out.set(row, id);
|
|
1591
2018
|
}
|
|
1592
|
-
return out;
|
|
2019
|
+
return { map: out, unresolved };
|
|
1593
2020
|
}
|
|
1594
2021
|
|
|
1595
|
-
/** 解析单个行元素(含所在组)的会话 id;权威走 rowSessionId
|
|
2022
|
+
/** 解析单个行元素(含所在组)的会话 id;权威走 rowSessionId,失败退回组内对齐(不猜)。 */
|
|
1596
2023
|
async function resolveRowId(rowEl) {
|
|
1597
2024
|
const authoritative = rowSessionId(rowEl);
|
|
1598
2025
|
if (authoritative) return authoritative;
|
|
@@ -1607,12 +2034,12 @@ window.__ModuleLoader__.load({
|
|
|
1607
2034
|
if (!header) return null;
|
|
1608
2035
|
const ws = resolveWorkspace(header, items);
|
|
1609
2036
|
if (!ws) return null;
|
|
1610
|
-
const
|
|
1611
|
-
return
|
|
2037
|
+
const { map } = await mapGroupRows(ws, groupSessionRows(header));
|
|
2038
|
+
return map.get(rowEl) ?? null;
|
|
1612
2039
|
}
|
|
1613
2040
|
|
|
1614
2041
|
// Ctrl/Cmd+点击:加入/移出;Shift+点击:组内范围选择。普通点击不干预。
|
|
1615
|
-
|
|
2042
|
+
listen("click", async (e) => {
|
|
1616
2043
|
const row = sessionRow(e.target);
|
|
1617
2044
|
if (!row) return;
|
|
1618
2045
|
const meta = e.ctrlKey || e.metaKey;
|
|
@@ -1632,9 +2059,9 @@ window.__ModuleLoader__.load({
|
|
|
1632
2059
|
return null;
|
|
1633
2060
|
})();
|
|
1634
2061
|
const ws = header ? resolveWorkspace(header, items) : null;
|
|
1635
|
-
if (!ws) return void toast(t("
|
|
2062
|
+
if (!ws) return void toast(t("staleList"), true);
|
|
1636
2063
|
const groupRows = groupSessionRows(header);
|
|
1637
|
-
const mapping = await mapGroupRows(ws, groupRows);
|
|
2064
|
+
const { map: mapping } = await mapGroupRows(ws, groupRows);
|
|
1638
2065
|
if (meta) {
|
|
1639
2066
|
if (pickedRows.has(row)) pickedRows.delete(row);
|
|
1640
2067
|
else if (mapping.has(row) || rowSessionId(row)) {
|
|
@@ -1664,9 +2091,13 @@ window.__ModuleLoader__.load({
|
|
|
1664
2091
|
}
|
|
1665
2092
|
}, true);
|
|
1666
2093
|
|
|
1667
|
-
// Esc
|
|
1668
|
-
|
|
2094
|
+
// Esc 清空多选:输入框聚焦时不清空(与 pickHint 的文案一致),
|
|
2095
|
+
// 也不消费事件——官方输入的取消行为不受影响。
|
|
2096
|
+
listen("keydown", (e) => {
|
|
1669
2097
|
if (e.key !== "Escape" || pickedRows.size === 0) return;
|
|
2098
|
+
const el = document.activeElement;
|
|
2099
|
+
const tag = el?.tagName?.toLowerCase?.();
|
|
2100
|
+
if (el?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") return;
|
|
1670
2101
|
clearSelection();
|
|
1671
2102
|
});
|
|
1672
2103
|
//#endregion
|
|
@@ -1677,10 +2108,15 @@ window.__ModuleLoader__.load({
|
|
|
1677
2108
|
}
|
|
1678
2109
|
|
|
1679
2110
|
function headerText(rowEl) {
|
|
2111
|
+
const ownText = (node) => {
|
|
2112
|
+
if (node !== rowEl && node?.getAttribute?.('role') === 'treeitem') return '';
|
|
2113
|
+
if (node?.nodeType === 3) return node.textContent ?? node._text ?? '';
|
|
2114
|
+
return [...(node?.childNodes ?? [])].map(ownText).join(' ');
|
|
2115
|
+
};
|
|
1680
2116
|
return normalizeHeaderText([
|
|
1681
2117
|
rowEl?.getAttribute?.("aria-label"),
|
|
1682
2118
|
rowEl?.getAttribute?.("title"),
|
|
1683
|
-
rowEl
|
|
2119
|
+
ownText(rowEl)
|
|
1684
2120
|
].filter(Boolean).join(" "));
|
|
1685
2121
|
}
|
|
1686
2122
|
|
|
@@ -1693,30 +2129,60 @@ window.__ModuleLoader__.load({
|
|
|
1693
2129
|
.filter((el) => el.offsetParent !== null && !isUngroupedHeader(el));
|
|
1694
2130
|
}
|
|
1695
2131
|
|
|
1696
|
-
|
|
1697
|
-
|
|
2132
|
+
/** 行标题文本是否命中某个注册表工作区的 title 或 path(大小写不敏感)。 */
|
|
2133
|
+
function headerMatchesWorkspace(text, item) {
|
|
2134
|
+
const lower = String(text ?? "").toLocaleLowerCase();
|
|
2135
|
+
const title = normalizeHeaderText(item?.title).toLocaleLowerCase();
|
|
2136
|
+
const path = normalizeHeaderText(item?.path).toLocaleLowerCase();
|
|
2137
|
+
return Boolean((title && lower.includes(title)) || (path && lower.includes(path)));
|
|
1698
2138
|
}
|
|
1699
2139
|
|
|
2140
|
+
/**
|
|
2141
|
+
* 行元素 → 注册表工作区。
|
|
2142
|
+
* 只用**可自证**的凭据:标题/路径命中,或标题与路径同时为空时的序号等价
|
|
2143
|
+
* (两者都无值意味着该实体没有可显示的标识,序号是唯一可用信息)。
|
|
2144
|
+
* 官方侧边栏会隐藏目录已失效的分组,而 registry.list() 仍包含它们——
|
|
2145
|
+
* 因此「DOM 第 i 项 = 注册表第 i 项」是错的,绝不能作为无条件兜底,
|
|
2146
|
+
* 否则侧边栏少显示一项就会把会话搬进相邻分组。
|
|
2147
|
+
*/
|
|
1700
2148
|
function resolveWorkspace(rowEl, items) {
|
|
1701
2149
|
const list = Array.isArray(items) ? items : [];
|
|
1702
|
-
if (!rowEl
|
|
2150
|
+
if (!rowEl) return null;
|
|
2151
|
+
// Read the rendered identity first: React may retain old fiber props after updates.
|
|
2152
|
+
const rowKey = rowEl.getAttribute?.('data-row-key');
|
|
2153
|
+
if (rowKey?.startsWith('workspace:')) {
|
|
2154
|
+
return list.find((item) => `workspace:${item.workspaceId}` === rowKey) ?? null;
|
|
2155
|
+
}
|
|
2156
|
+
if (isUngroupedHeader(rowEl)) return null;
|
|
2157
|
+
// DSH 0.2 ProjectRowItem carries the actual workspace ID in group props.
|
|
2158
|
+
// Prefer identity over visible text, which can contain nested session titles.
|
|
2159
|
+
try {
|
|
2160
|
+
const fiberKey = Object.getOwnPropertyNames(rowEl).find((key) => key.startsWith('__reactFiber$'));
|
|
2161
|
+
for (let fiber = fiberKey ? rowEl[fiberKey] : null; fiber; fiber = fiber.return) {
|
|
2162
|
+
const group = fiber.memoizedProps?.group;
|
|
2163
|
+
if (group && Object.hasOwn(group, 'workspaceId')) {
|
|
2164
|
+
return list.find((item) => item.workspaceId === group.workspaceId) ?? null;
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
} catch { /* Older clients can use unique text evidence below. */ }
|
|
1703
2168
|
const text = headerText(rowEl);
|
|
1704
|
-
const
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
const
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
const
|
|
1714
|
-
return
|
|
2169
|
+
const byText = list.filter((item) => headerMatchesWorkspace(text, item));
|
|
2170
|
+
if (byText.length === 1) return byText[0];
|
|
2171
|
+
if (byText.length > 1) {
|
|
2172
|
+
const labels = [rowEl.getAttribute?.('aria-label'), rowEl.getAttribute?.('title'), text]
|
|
2173
|
+
.map((value) => normalizeHeaderText(value).toLocaleLowerCase()).filter(Boolean);
|
|
2174
|
+
const exact = byText.filter((item) => [item.title, item.path].some((value) =>
|
|
2175
|
+
value && labels.includes(normalizeHeaderText(value).toLocaleLowerCase())));
|
|
2176
|
+
return exact.length === 1 ? exact[0] : null;
|
|
2177
|
+
}
|
|
2178
|
+
const unidentifiable = list.filter((item) => !normalizeHeaderText(item?.title) && !normalizeHeaderText(item?.path));
|
|
2179
|
+
if (unidentifiable.length === 1) return unidentifiable[0];
|
|
2180
|
+
return null;
|
|
1715
2181
|
}
|
|
1716
2182
|
|
|
1717
2183
|
// dragstart:只做「元素判定」——拖起已多选的行 = 整批拖动,否则单选。
|
|
1718
2184
|
// id 一律留到 drop 阶段解析(dataTransfer 只携带拖起这一行的 id)。
|
|
1719
|
-
|
|
2185
|
+
listen("dragstart", (e) => {
|
|
1720
2186
|
try {
|
|
1721
2187
|
const row = sessionRow(e.target);
|
|
1722
2188
|
if (!row) { dragging = null; return; }
|
|
@@ -1726,11 +2192,11 @@ window.__ModuleLoader__.load({
|
|
|
1726
2192
|
} catch { dragging = null; }
|
|
1727
2193
|
});
|
|
1728
2194
|
|
|
1729
|
-
|
|
2195
|
+
listen("dragover", (e) => {
|
|
1730
2196
|
clearHints();
|
|
1731
2197
|
if (!dragging) return;
|
|
1732
2198
|
const header = headerRow(e.target);
|
|
1733
|
-
if (!header || isUngroupedHeader(header)) return;
|
|
2199
|
+
if (!header || isUngroupedHeader(header)) { dragging.target = null; return; }
|
|
1734
2200
|
e.preventDefault(); // 允许在此投放(没有它 drop 根本不会触发)
|
|
1735
2201
|
header.classList.add("wsm-drop-hint");
|
|
1736
2202
|
if (!dragging.target || dragging.target.header !== header) {
|
|
@@ -1743,12 +2209,12 @@ window.__ModuleLoader__.load({
|
|
|
1743
2209
|
}
|
|
1744
2210
|
}, true);
|
|
1745
2211
|
|
|
1746
|
-
|
|
2212
|
+
listen("drop", async (e) => {
|
|
1747
2213
|
const current = dragging;
|
|
1748
2214
|
dragging = null;
|
|
1749
2215
|
clearHints();
|
|
1750
2216
|
if (!current) return;
|
|
1751
|
-
const header = headerRow(e.target)
|
|
2217
|
+
const header = headerRow(e.target);
|
|
1752
2218
|
if (!header || isUngroupedHeader(header)) return;
|
|
1753
2219
|
// 只拦截「拖到某个工作区标题行」的场景;其余交还官方逻辑
|
|
1754
2220
|
e.preventDefault();
|
|
@@ -1766,33 +2232,33 @@ window.__ModuleLoader__.load({
|
|
|
1766
2232
|
draggedId = candidates.map((value) => String(value).trim().split(/[\r\n]/)[0]).find(Boolean) || null;
|
|
1767
2233
|
} catch { draggedId = null; }
|
|
1768
2234
|
if (!draggedId) {
|
|
1769
|
-
return void toast(t("failed", { msg: "
|
|
2235
|
+
return void toast(t("failed", { msg: t("dragPayloadBad") }), true);
|
|
1770
2236
|
}
|
|
1771
2237
|
|
|
1772
2238
|
let workspace = null;
|
|
2239
|
+
let workspaceItems = [];
|
|
1773
2240
|
try {
|
|
1774
|
-
let items = await fetchWorkspaces();
|
|
2241
|
+
let items = await fetchWorkspaces(true);
|
|
2242
|
+
workspaceItems = items;
|
|
1775
2243
|
workspace = resolveWorkspace(header, items);
|
|
1776
|
-
if (!workspace && current.target?.workspaceId) {
|
|
1777
|
-
workspace = items.find((item) => String(item.workspaceId) === String(current.target.workspaceId)) ?? null;
|
|
1778
|
-
}
|
|
1779
2244
|
if (!workspace) {
|
|
1780
2245
|
wsCache = null;
|
|
1781
2246
|
items = await fetchWorkspaces();
|
|
2247
|
+
workspaceItems = items;
|
|
1782
2248
|
workspace = resolveWorkspace(header, items);
|
|
1783
|
-
if (!workspace && current.target?.workspaceId) {
|
|
1784
|
-
workspace = items.find((item) => String(item.workspaceId) === String(current.target.workspaceId)) ?? null;
|
|
1785
|
-
}
|
|
1786
2249
|
}
|
|
1787
2250
|
} catch (err) {
|
|
1788
2251
|
return void toast(t("failed", { msg: errMsg(err) }), true);
|
|
1789
2252
|
}
|
|
1790
|
-
if (!workspace) return void toast(t("
|
|
2253
|
+
if (!workspace) return void toast(t("staleList"), true);
|
|
1791
2254
|
|
|
1792
2255
|
// ---- 组装移动清单:拖起行必有 id;多选行逐个经分组对齐解析 ----
|
|
2256
|
+
// 解析不出 id 的行**绝不猜测**(见 mapGroupRows 的安全约定):
|
|
2257
|
+
// 要么整体中止,要么在确认框里如实说明跳过了几行。
|
|
1793
2258
|
const picked = (current.els ?? [current.el]).filter((el) => el?.isConnected);
|
|
1794
2259
|
const sessions = [];
|
|
1795
2260
|
const seen = new Set();
|
|
2261
|
+
let unresolvedCount = 0;
|
|
1796
2262
|
const push = (id, title) => {
|
|
1797
2263
|
const key = String(id);
|
|
1798
2264
|
if (!key || seen.has(key)) return;
|
|
@@ -1802,30 +2268,61 @@ window.__ModuleLoader__.load({
|
|
|
1802
2268
|
if (picked.length > 1) {
|
|
1803
2269
|
for (const el of picked) {
|
|
1804
2270
|
if (el === current.el) continue; // 拖起行用 dataTransfer 的权威 id
|
|
1805
|
-
try {
|
|
2271
|
+
try {
|
|
2272
|
+
const id = await resolveRowId(el);
|
|
2273
|
+
if (id) push(id, rowTitle(el, ""));
|
|
2274
|
+
else unresolvedCount += 1;
|
|
2275
|
+
} catch { unresolvedCount += 1; }
|
|
1806
2276
|
}
|
|
1807
2277
|
}
|
|
1808
2278
|
// 权威 id 放首位;若某选中行被误映射到同一 id 会被去重吞掉
|
|
2279
|
+
const beforePush = sessions.length;
|
|
1809
2280
|
push(draggedId, rowTitle(current.el, draggedId));
|
|
2281
|
+
if (sessions.length === beforePush) unresolvedCount += 1; // 连拖起行都没解析出来
|
|
2282
|
+
|
|
2283
|
+
if (unresolvedCount > 0 && sessions.length === 0) {
|
|
2284
|
+
return void toast(t("idUnresolved"), true);
|
|
2285
|
+
}
|
|
1810
2286
|
|
|
1811
2287
|
if (sessions.length > 50) {
|
|
1812
|
-
return void toast(t("failed", { msg:
|
|
2288
|
+
return void toast(t("failed", { msg: t("batchTooMany", { max: 50, n: sessions.length }) }), true);
|
|
1813
2289
|
}
|
|
1814
2290
|
|
|
1815
2291
|
// ---- 单选:原有确认与 mover.move 路径 ----
|
|
1816
2292
|
if (sessions.length === 1) {
|
|
1817
2293
|
const { sessionId, sessionTitle } = sessions[0];
|
|
1818
|
-
|
|
1819
|
-
if (!confirmed) return;
|
|
2294
|
+
let preview = null;
|
|
1820
2295
|
try {
|
|
1821
|
-
const res = await rpcCall("mover.
|
|
2296
|
+
const res = await rpcCall("mover.preview", { sessionId, targetWorkspaceId: workspace.workspaceId });
|
|
2297
|
+
if (res?.ok) preview = res.value;
|
|
2298
|
+
} catch (err) {
|
|
2299
|
+
console.debug("[workspace-mover] preview unavailable:", err?.message ?? err);
|
|
2300
|
+
}
|
|
2301
|
+
const choice = await confirmMove({ sessionId, sessionTitle, workspace, workspaces: workspaceItems, unresolvedCount, preview,
|
|
2302
|
+
loadPreview: async (targetWorkspaceId, mode) => {
|
|
2303
|
+
const res = await rpcCall('mover.preview', { sessionId, targetWorkspaceId, mode });
|
|
2304
|
+
if (!res?.ok || !res.value?.status) throw new Error(res?.error?.message ?? 'preview unavailable');
|
|
2305
|
+
return res.value;
|
|
2306
|
+
}
|
|
2307
|
+
});
|
|
2308
|
+
if (!choice) return;
|
|
2309
|
+
workspace = workspaceItems.find((item) => item.workspaceId === choice.targetWorkspaceId);
|
|
2310
|
+
try {
|
|
2311
|
+
const res = await rpcCall("mover.move", { sessionId, sessionTitle, targetWorkspaceId: workspace.workspaceId, mode: choice.mode });
|
|
1822
2312
|
if (res?.ok) {
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
2313
|
+
wsCache = null;
|
|
2314
|
+
scanCache = null;
|
|
2315
|
+
const movedIds = (res.value?.results ?? []).filter((item) => item?.ok).map((item) => item.sessionId).filter(Boolean);
|
|
2316
|
+
await reconcileWorkspaceProjection(ctx, workspace.workspaceId, movedIds.length > 0 ? movedIds : [sessionId]);
|
|
2317
|
+
toast(res.value?.queued ? t("queuedMove") : (res.value?.attached ? t("selfHealed") : t("done", { title: workspace.title })));
|
|
2318
|
+
scheduleRecencyFix(ctx, workspace.workspaceId, movedIds.length > 0 ? movedIds : [sessionId]);
|
|
1826
2319
|
if (res.value?.restartHint) setTimeout(() => toast(t("restartHint"), true), 1200);
|
|
1827
2320
|
} else {
|
|
1828
2321
|
const msg = res?.error?.message ?? "unknown";
|
|
2322
|
+
if (res?.error?.code === "busy") {
|
|
2323
|
+
toast(t("queuedMove"));
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
1829
2326
|
const rolled = res?.error?.code === "rollback-failed" || /roll/i.test(msg);
|
|
1830
2327
|
const text = (rolled ? t("rolledBack", { msg }) : t("failed", { msg }));
|
|
1831
2328
|
toast(text, true);
|
|
@@ -1837,21 +2334,41 @@ window.__ModuleLoader__.load({
|
|
|
1837
2334
|
}
|
|
1838
2335
|
|
|
1839
2336
|
// ---- 多选:批量确认 + mover.moveMany(逐条独立备份回滚)----
|
|
1840
|
-
|
|
1841
|
-
|
|
2337
|
+
let preview = null;
|
|
2338
|
+
try {
|
|
2339
|
+
const plans = (await Promise.all(sessions.map(async (item) => {
|
|
2340
|
+
const res = await rpcCall("mover.preview", { sessionId: item.sessionId, targetWorkspaceId: workspace.workspaceId });
|
|
2341
|
+
return res?.ok ? res.value : null;
|
|
2342
|
+
}))).filter(Boolean);
|
|
2343
|
+
const blocked = plans.filter((item) => item.status === "blocked").length;
|
|
2344
|
+
const queued = plans.filter((item) => item.status === "queued").length;
|
|
2345
|
+
const warnings = plans.flatMap((item) => item.warnings ?? []).filter(Boolean);
|
|
2346
|
+
preview = {
|
|
2347
|
+
...plans[0],
|
|
2348
|
+
status: blocked === plans.length && plans.length > 0 ? "blocked" : (blocked > 0 || queued > 0 ? "warning" : "safe"),
|
|
2349
|
+
source: { title: t("scannedN", { n: sessions.length }) },
|
|
2350
|
+
estimatedBackupBytes: plans.reduce((sum, item) => sum + Number(item.estimatedBackupBytes || 0), 0),
|
|
2351
|
+
warnings: [...new Set([...(blocked ? [`${blocked} blocked`] : []), ...(queued ? [`${queued} queued`] : []), ...warnings])]
|
|
2352
|
+
};
|
|
2353
|
+
} catch (err) {
|
|
2354
|
+
console.debug("[workspace-mover] batch preview unavailable:", err?.message ?? err);
|
|
2355
|
+
}
|
|
2356
|
+
const choice = await confirmMove({ workspace, batchCount: sessions.length, unresolvedCount, preview });
|
|
2357
|
+
if (!choice) return;
|
|
1842
2358
|
try {
|
|
1843
|
-
const res = await rpcCall("mover.moveMany", { sessions, targetWorkspaceId: workspace.workspaceId });
|
|
2359
|
+
const res = await rpcCall("mover.moveMany", { sessions, targetWorkspaceId: workspace.workspaceId, mode: choice.mode });
|
|
1844
2360
|
if (!res?.ok) {
|
|
1845
2361
|
const msg = res?.error?.message ?? "unknown";
|
|
1846
2362
|
const rolled = res?.error?.code === "rollback-failed" || /roll/i.test(msg);
|
|
1847
2363
|
return void toast((rolled ? t("rolledBack", { msg }) : t("failed", { msg })), true);
|
|
1848
2364
|
}
|
|
1849
|
-
const { movedCount, attachedCount, failedCount } = res.value ?? {};
|
|
2365
|
+
const { movedCount, attachedCount, failedCount, queuedCount } = res.value ?? {};
|
|
1850
2366
|
const okCount = (movedCount ?? 0) + (attachedCount ?? 0);
|
|
1851
2367
|
const movedIds = (res.value?.results ?? []).filter((item) => item?.ok).map((item) => item.sessionId).filter(Boolean);
|
|
1852
2368
|
await reconcileWorkspaceProjection(ctx, workspace.workspaceId, movedIds);
|
|
1853
2369
|
let message = t("batchDone", { n: okCount });
|
|
1854
2370
|
if ((failedCount ?? 0) > 0) message += t("batchFailTail", { n: failedCount });
|
|
2371
|
+
if ((queuedCount ?? 0) > 0) message += t("batchQueuedTail", { n: queuedCount });
|
|
1855
2372
|
toast(message);
|
|
1856
2373
|
clearSelection(true);
|
|
1857
2374
|
scheduleRecencyFix(ctx, workspace.workspaceId, sessions.map((s) => s.sessionId));
|
|
@@ -1860,7 +2377,7 @@ window.__ModuleLoader__.load({
|
|
|
1860
2377
|
}
|
|
1861
2378
|
}, true);
|
|
1862
2379
|
|
|
1863
|
-
|
|
2380
|
+
listen("dragend", () => { dragging = null; clearHints(); });
|
|
1864
2381
|
|
|
1865
2382
|
// 整组迁移 + 分组合并:入口在官方组标题的「⋯」菜单(注入「整组迁移…」项)。
|
|
1866
2383
|
// 不再拦截浏览器右键——官方菜单同样在右键/⋯按钮打开,拦截会与之冲突。
|
|
@@ -1912,7 +2429,7 @@ window.__ModuleLoader__.load({
|
|
|
1912
2429
|
// 记录「⋯」按钮点击所在的标题行(标题行内唯一带 aria-label 的按钮),
|
|
1913
2430
|
// 随后出现的菜单 portal 即属于这个分组;1 秒内未出现菜单则作废。
|
|
1914
2431
|
let menuHeaderRow = null;
|
|
1915
|
-
|
|
2432
|
+
listen("click", (e) => {
|
|
1916
2433
|
const header = headerRow(e.target);
|
|
1917
2434
|
if (!header) return;
|
|
1918
2435
|
const btn = e.target.closest?.("button[aria-label]");
|
|
@@ -1947,7 +2464,7 @@ window.__ModuleLoader__.load({
|
|
|
1947
2464
|
try {
|
|
1948
2465
|
const list = await fetchWorkspaces();
|
|
1949
2466
|
const ws = resolveWorkspace(header, list);
|
|
1950
|
-
if (!ws) return void toast(t("
|
|
2467
|
+
if (!ws) return void toast(t("staleList"), true);
|
|
1951
2468
|
const res = await rpcCall("mover.openFolder", { workspaceId: ws.workspaceId, path: ws.path });
|
|
1952
2469
|
if (!res?.ok) toast(t("failed", { msg: res?.error?.message ?? "open failed" }), true);
|
|
1953
2470
|
} catch (err) {
|
|
@@ -1980,8 +2497,10 @@ window.__ModuleLoader__.load({
|
|
|
1980
2497
|
}
|
|
1981
2498
|
});
|
|
1982
2499
|
menuObserver.observe(document.body, { childList: true, subtree: true });
|
|
2500
|
+
disposers.push(() => { try { menuObserver.disconnect(); } catch { /* ignore */ } });
|
|
1983
2501
|
|
|
1984
2502
|
// 诊断句柄:控制台用 window.__wsmDebug 检查「最近更新」排序修复通道(排障用)
|
|
2503
|
+
const priorDebug = window.__wsmDebug;
|
|
1985
2504
|
try {
|
|
1986
2505
|
window.__wsmDebug = {
|
|
1987
2506
|
view: () => { const v = resolveViewStore(ctx); return v ? v.getSnapshot() : null; },
|
|
@@ -1994,6 +2513,19 @@ window.__ModuleLoader__.load({
|
|
|
1994
2513
|
} catch { /* ignore */ }
|
|
1995
2514
|
|
|
1996
2515
|
registerRescuePanel(ctx, rpcCall);
|
|
2516
|
+
|
|
2517
|
+
// teardown:宿主废弃本 fiber(桌面版热重载 / 客户端重组)时释放全部监听器。
|
|
2518
|
+
// 没有这一步,重新装载会叠加第二份监听器,且旧闭包连同 pickedRows /
|
|
2519
|
+
// dragging / 缓存永远不会被回收。
|
|
2520
|
+
if (typeof ctx.effect === "function") {
|
|
2521
|
+
ctx.effect(() => () => {
|
|
2522
|
+
applied = false;
|
|
2523
|
+
for (const dispose of disposers.splice(0)) {
|
|
2524
|
+
try { dispose(); } catch { /* 单个失败不影响其余 */ }
|
|
2525
|
+
}
|
|
2526
|
+
try { if (window.__wsmDebug && priorDebug === undefined) delete window.__wsmDebug; } catch { /* ignore */ }
|
|
2527
|
+
}, "workspace-mover: client listeners");
|
|
2528
|
+
}
|
|
1997
2529
|
}
|
|
1998
2530
|
|
|
1999
2531
|
module.exports = { inject, apply };
|