dsh-config-manager 0.1.72 → 0.1.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -4
- package/README.zh-CN.md +40 -4
- package/lib/adapters/index.d.ts +1 -0
- package/lib/adapters/index.js +5 -0
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-data.d.ts +114 -0
- package/lib/adapters/plugin-data.js +309 -0
- package/lib/adapters/self.d.ts +1 -1
- package/lib/adapters/self.js +2 -1
- package/lib/adapters/test-helpers.d.ts +9 -0
- package/lib/adapters/test-helpers.js +14 -0
- package/lib/client.d.ts +103 -10
- package/lib/client.js +10748 -10119
- package/lib/core/analyzer.d.ts +11 -2
- package/lib/core/analyzer.js +31 -8
- package/lib/core/backup-plan.d.ts +10 -0
- package/lib/core/backup-plan.js +21 -3
- package/lib/core/backup.d.ts +20 -1
- package/lib/core/backup.js +48 -3
- package/lib/core/command-rollback.d.ts +6 -2
- package/lib/core/command-rollback.js +6 -2
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +2 -1
- package/lib/core/messages.js +4 -2
- package/lib/core/plugin-data-registry.d.ts +124 -0
- package/lib/core/plugin-data-registry.js +198 -0
- package/lib/core/rollback-gate.d.ts +33 -0
- package/lib/core/rollback-gate.js +57 -0
- package/lib/core/rollback-ledger.d.ts +274 -0
- package/lib/core/rollback-ledger.js +1400 -0
- package/lib/core/rollback-point.d.ts +406 -5
- package/lib/core/rollback-point.js +1453 -306
- package/lib/core/rollback.js +6 -4
- package/lib/core/types.d.ts +19 -0
- package/lib/foreign/registry.d.ts +1 -1
- package/lib/foreign/registry.js +1 -1
- package/lib/index.d.ts +5 -1
- package/lib/index.js +83 -13
- package/lib/routes/messages.d.ts +1 -0
- package/lib/routes/messages.js +2 -0
- package/lib/routes/prefs.js +26 -3
- package/lib/routes/recovery.d.ts +21 -0
- package/lib/routes/recovery.js +127 -7
- package/lib/routes/sync.js +11 -1
- package/lib/schema/section-registry.d.ts +1 -1
- package/lib/schema/section-registry.js +32 -3
- package/lib/schema/types.d.ts +1 -1
- package/lib/sync/messages.d.ts +1 -0
- package/lib/sync/messages.js +3 -0
- package/lib/sync/sync-engine.d.ts +21 -7
- package/lib/sync/sync-engine.js +108 -18
- package/lib/sync/ui-prefs.d.ts +22 -0
- package/lib/sync/ui-prefs.js +28 -0
- package/lib/ui/diff-view.d.ts +10 -0
- package/lib/ui/diff-view.js +17 -0
- package/lib/ui/export-flow.js +2 -0
- package/lib/ui/i18n.d.ts +5 -3
- package/lib/ui/i18n.js +10 -6
- package/lib/ui/rollback-view.d.ts +59 -0
- package/lib/ui/rollback-view.js +14 -0
- package/lib/ui/test-helpers.js +2 -1
- package/lib/utils/bundle-scan.d.ts +5 -3
- package/lib/utils/bundle-scan.js +5 -3
- package/lib/utils/line-diff.d.ts +7 -1
- package/lib/utils/line-diff.js +8 -2
- package/package.json +1 -1
- package/src/adapters/index.ts +5 -0
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-data.test.ts +225 -0
- package/src/adapters/plugin-data.ts +351 -0
- package/src/adapters/self.ts +2 -1
- package/src/adapters/test-helpers.ts +15 -0
- package/src/client/chat/ChatEntryView.tsx +59 -10
- package/src/client/chat/RollbackDiffTab.tsx +49 -2
- package/src/client/chat/RollbackFileIcon.tsx +7 -45
- package/src/client/chat/RollbackReviewDialog.tsx +20 -4
- package/src/client/chat/chat-entry-view.test.ts +164 -1
- package/src/client/chat/chat-entry-view.ts +127 -10
- package/src/client/chat/chat-node-registration.ts +40 -6
- package/src/client/chat/chat-point-window.ts +11 -1
- package/src/client/chat/rollback-diff-tab.ts +13 -3
- package/src/client/chat/rollback-entry-view.ts +5 -0
- package/src/client/chat/sidebar-slots.ts +24 -2
- package/src/client/common/DiffTable.tsx +54 -29
- package/src/client/common/client-route-parity.test.ts +6 -0
- package/src/client/common/host-primitives.ts +78 -0
- package/src/client/common/routes.ts +10 -0
- package/src/client/common/section-labels.test.ts +1 -0
- package/src/client/common/section-labels.ts +2 -0
- package/src/client/config-manager.module.css +138 -14
- package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
- package/src/client/index.ts +54 -2
- package/src/client/library/RestorePlanView.tsx +0 -1
- package/src/client/locales.ts +8 -6
- package/src/client/recovery/RollbackCard.tsx +201 -7
- package/src/client/recovery/RollbackDiffPane.tsx +26 -8
- package/src/client/recovery/recovery-api.ts +28 -0
- package/src/client/recovery/recovery-locales.ts +36 -6
- package/src/client/recovery/rollback-enabled.ts +41 -0
- package/src/client/recovery/rollback-file-changes.test.ts +77 -0
- package/src/client/sync/PushPreviewCard.tsx +57 -0
- package/src/client/sync/SyncPanel.tsx +2 -37
- package/src/client/sync/SyncSettingsView.test.ts +3 -0
- package/src/client/sync/sync-push-preview.test.ts +14 -0
- package/src/client/sync/sync-view.test.ts +1 -1
- package/src/client/sync/sync-view.ts +9 -1
- package/src/core/analyzer.ts +31 -9
- package/src/core/backup-plan.test.ts +20 -5
- package/src/core/backup-plan.ts +22 -3
- package/src/core/backup.ts +50 -4
- package/src/core/command-rollback.ts +6 -2
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +4 -2
- package/src/core/plugin-data-registry.test.ts +113 -0
- package/src/core/plugin-data-registry.ts +261 -0
- package/src/core/rollback-gate.test.ts +66 -0
- package/src/core/rollback-gate.ts +61 -0
- package/src/core/rollback-ledger.test.ts +655 -0
- package/src/core/rollback-ledger.ts +1430 -0
- package/src/core/rollback-point.test.ts +866 -41
- package/src/core/rollback-point.ts +1582 -291
- package/src/core/rollback.ts +5 -3
- package/src/core/types.ts +19 -0
- package/src/foreign/registry.test.ts +1 -1
- package/src/foreign/registry.ts +1 -1
- package/src/index.ts +73 -10
- package/src/routes/messages.ts +2 -0
- package/src/routes/prefs.ts +31 -2
- package/src/routes/recovery.ts +122 -7
- package/src/routes/sync.ts +15 -1
- package/src/schema/manifest-schema.test.ts +4 -4
- package/src/schema/registry.test.ts +9 -2
- package/src/schema/section-registry.ts +32 -3
- package/src/schema/types.ts +2 -1
- package/src/sync/messages.ts +3 -0
- package/src/sync/sync-credentials.test.ts +119 -0
- package/src/sync/sync-engine.ts +106 -18
- package/src/sync/sync-preview-push.test.ts +158 -0
- package/src/sync/transport.test.ts +1 -0
- package/src/sync/ui-prefs.test.ts +44 -1
- package/src/sync/ui-prefs.ts +39 -0
- package/src/ui/diff-view.test.ts +9 -1
- package/src/ui/diff-view.ts +22 -0
- package/src/ui/export-flow.ts +2 -0
- package/src/ui/i18n.ts +10 -6
- package/src/ui/rollback-view.test.ts +17 -0
- package/src/ui/rollback-view.ts +66 -0
- package/src/ui/test-helpers.ts +2 -1
- package/src/utils/bundle-scan.ts +5 -3
- package/src/utils/line-diff.test.ts +12 -0
- package/src/utils/line-diff.ts +9 -3
|
@@ -326,6 +326,21 @@ export const zh = {
|
|
|
326
326
|
'rollback.badge.incomplete': '两态不全',
|
|
327
327
|
'rollback.badge.protected': '受保护(只进不出)',
|
|
328
328
|
'rollback.apiMissing': '当前宿主没有回滚点能力(插件版本过旧):界面不假装可用',
|
|
329
|
+
/* —— issue #88 / #89:总开关(缺省关闭)+ 台账占用与清理 —— */
|
|
330
|
+
'rollback.switch.label': '启用回合自动留点',
|
|
331
|
+
'rollback.switch.hint': '关闭后不再订阅会话事件、不再写台账,聊天里的「↺ 回退」入口一并收起;已有回滚点仍可查看、回退与清理。',
|
|
332
|
+
'rollback.switch.unknown': '正在读取开关状态(还没读到之前不显示成「已关闭」)。',
|
|
333
|
+
'rollback.switch.diverged': '磁盘上的偏好是「开启」,但本进程没有启用留点(通常是被另一个 DSH 实例改过)。重新打开一次开关即可让本进程生效。',
|
|
334
|
+
'rollback.ledger.summary': '台账 {records} 个点 · {size}',
|
|
335
|
+
'rollback.ledger.budget': '字节预算 {budget},已按预算淘汰 {evicted} 个最旧自动点',
|
|
336
|
+
'rollback.ledger.overBudget': '台账已到 {size},超过 {budget} 的字节预算,但已经没有可自动淘汰的点了(通常是单个回滚点本身大于预算)—— 再增长请手动清理。',
|
|
337
|
+
'rollback.cleanup': '清理台账',
|
|
338
|
+
'rollback.cleanup.confirmTitle': '清理回滚点台账',
|
|
339
|
+
'rollback.cleanup.confirmMessage': '删除全部自动回滚点(救援点与受保护点按下面的勾选处置),并回收它们的内容副本。工作区文件与会话日志不会被改动。',
|
|
340
|
+
'rollback.cleanup.includeGuards': '同时删除救援点(回退前的自动救援点,删了不可撤销)',
|
|
341
|
+
'rollback.cleanup.done': '已删除 {records} 个回滚点,释放 {size}',
|
|
342
|
+
'rollback.cleanup.none': '没有可删除的回滚点(自动点为空)',
|
|
343
|
+
'rollback.switch.saveFailed': '开关保存失败,仍按原状态运行:',
|
|
329
344
|
'rollback.retention.hint': '自动点:每会话最多 {perSession} 个、全局最多 {total} 个,超出只淘汰最旧的自动点。回退前的救援点不占这两个额度、也不被它们淘汰,但用户删不掉,所以另有自己的两道上限:每会话最多 {guardsPerSession} 个、全局最多 {guards} 个(无论按哪道算,超出都淘汰最旧的救援点)。',
|
|
330
345
|
'rollback.autopoint.hint': '回滚点由每个回合结束时自动留:工作区快照 + 会话游标(没有「手动拍点」入口)。',
|
|
331
346
|
'rollback.state.workspace': '工作区',
|
|
@@ -373,6 +388,7 @@ export const zh = {
|
|
|
373
388
|
// t7 定点修正(captain 授权;既有键措辞修正,不是新增键):该码现在有两种用法 ——
|
|
374
389
|
// ① preview.session.reasonCode(仅供参考);② 结果码:运行中的对话带文件段的回退被硬拒(工作区零改动)。
|
|
375
390
|
'rollback.code.session-active': '这条对话正在运行(会话日志刚被写过):此时带文件段的回退会被拒绝、工作区一个字节都不动;只回溯消息不受影响(回退只 fork 子会话,不改写原日志)',
|
|
391
|
+
'rollback.code.subagent-session': '这是子代理会话(origin=subagent),插件不为它自动留回滚点:它的工作区与父对话相同,留点只会挤占你自己对话的台账空间。',
|
|
376
392
|
// t8(只追加,不动上面那条既有键):对话**正在运行**时 —— 默认只回溯消息,带工作区段的回退在路由层被拒
|
|
377
393
|
'rollback.preview.sessionActive': '这条对话正在运行:本轮结束前只回溯消息(不改动工作区),避免与正在写盘的工具抢文件。等本轮结束后重新打开本弹窗,即可连文件一起回退。',
|
|
378
394
|
'rollback.sessionActive.denied': '这条对话正在运行:本次一个文件都没有改动。请等本轮结束,或改成「只回溯消息」再试。',
|
|
@@ -380,7 +396,7 @@ export const zh = {
|
|
|
380
396
|
'rollback.code.fork-failed': 'fork 子会话失败,会话段未回退',
|
|
381
397
|
'rollback.code.capture-failed': '该态留点失败',
|
|
382
398
|
'rollback.code.capture-truncated': '该态留点到达上限被截断(已如实标注)',
|
|
383
|
-
'rollback.code.capture-degraded': '
|
|
399
|
+
'rollback.code.capture-degraded': '该态留点不完整(有读不到或期间被改写的内容,已逐条留痕)',
|
|
384
400
|
'rollback.code.no-chunks': '没有给出工作区路径分块',
|
|
385
401
|
'rollback.code.chunk-not-absolute': '工作区分块必须是绝对路径',
|
|
386
402
|
'rollback.code.chunk-not-found': '工作区分块不存在或不可用',
|
|
@@ -399,7 +415,7 @@ export const zh = {
|
|
|
399
415
|
'rollback.warn.trackDegraded': '有一态留得不完整(回退可能不完整)',
|
|
400
416
|
'rollback.warn.protected': '该回滚点受保护(只进不出):不可回退、不可删除',
|
|
401
417
|
'rollback.warn.guardFailed': '回退前的救援点没留成(已记告警后继续)',
|
|
402
|
-
'rollback.warn.createdRefused': '本次不删除任何「留点后新建」的路径:捕获时的证明名单不完整(有整目录被忽略 / 路径索引缺失 /
|
|
418
|
+
'rollback.warn.createdRefused': '本次不删除任何「留点后新建」的路径:捕获时的证明名单不完整(有整目录被忽略 / 路径索引缺失 / 目录读不出 / 留点被时间预算中断),无法证明哪些是新建的',
|
|
403
419
|
'rollback.warn.unknown': '未识别的告警(技术细节见下方)',
|
|
404
420
|
'rollback.preview': '预览回退',
|
|
405
421
|
/* 2026-10-10 用户第三条:预览对话框改简洁版,标题就叫「回退预览」(不再带副标题)。
|
|
@@ -450,7 +466,6 @@ export const zh = {
|
|
|
450
466
|
'rollback.diff.after': '回退后',
|
|
451
467
|
'rollback.diff.identical': '该文件与回退点内容一致,回退不会改动它',
|
|
452
468
|
'rollback.diff.degraded': '差异规模较大,已按整块替换展示(仍是完整结果,只是不是最小的编辑脚本)',
|
|
453
|
-
'rollback.diff.truncated': '只画了前 {shown} 行(共 {total} 行),数据本身是完整的',
|
|
454
469
|
'rollback.diff.reason.missingBlob': '这份内容副本读不出来,无法逐行对照(回退仍会尝试写回它)',
|
|
455
470
|
'rollback.diff.reason.binary': '不是文本文件,没有逐行差异可看',
|
|
456
471
|
'rollback.diff.reason.tooLarge': '文件超过展示上限,没有逐行差异可看(回退仍会照常覆盖它)',
|
|
@@ -895,6 +910,21 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
895
910
|
'rollback.badge.incomplete': 'One of two states missing',
|
|
896
911
|
'rollback.badge.protected': 'Protected (kept forever)',
|
|
897
912
|
'rollback.apiMissing': 'This host has no rollback-point support (plugin too old): the UI does not pretend otherwise',
|
|
913
|
+
/* —— issues #88 / #89: master switch (off by default) + ledger footprint and cleanup —— */
|
|
914
|
+
'rollback.switch.label': 'Enable automatic per-turn rollback points',
|
|
915
|
+
'rollback.switch.hint': 'When off, the plugin no longer subscribes to session events or writes the ledger, and the rewind entry is removed from chat. Existing points can still be viewed, rewound and cleaned up.',
|
|
916
|
+
'rollback.switch.unknown': 'Reading the switch state (it is not shown as "off" until it has been read).',
|
|
917
|
+
'rollback.switch.diverged': 'The stored preference says "on", but this process has rollback points disabled (usually because another DSH instance changed it). Toggle the switch once to apply it here.',
|
|
918
|
+
'rollback.ledger.summary': 'Ledger: {records} points · {size}',
|
|
919
|
+
'rollback.ledger.budget': 'Byte budget {budget}; {evicted} oldest automatic point(s) already evicted by the budget',
|
|
920
|
+
'rollback.ledger.overBudget': 'The ledger is at {size}, over the {budget} byte budget, and no automatic point is left to evict (usually a single point is larger than the budget) — clean up manually to shrink it.',
|
|
921
|
+
'rollback.cleanup': 'Clean up ledger',
|
|
922
|
+
'rollback.cleanup.confirmTitle': 'Clean up the rollback-point ledger',
|
|
923
|
+
'rollback.cleanup.confirmMessage': 'Delete every automatic rollback point (rescue and protected points follow the checkbox below) and reclaim their content copies. Workspace files and session logs are not touched.',
|
|
924
|
+
'rollback.cleanup.includeGuards': 'Also delete rescue points (pre-rewind rescue points; deletion is irreversible)',
|
|
925
|
+
'rollback.cleanup.done': 'Deleted {records} rollback point(s), reclaimed {size}',
|
|
926
|
+
'rollback.cleanup.none': 'Nothing to delete (no automatic points)',
|
|
927
|
+
'rollback.switch.saveFailed': 'Saving the switch failed; the previous state is still in effect:',
|
|
898
928
|
'rollback.retention.hint': 'Automatic points: at most {perSession} per session and {total} in total; eviction only drops the oldest automatic points. Pre-rewind rescue points do not count against those two budgets, and those budgets do not evict them; but you cannot delete them, so they have their own two limits: at most {guardsPerSession} per session and {guards} in total (whichever limit is passed, the oldest rescue point is evicted).',
|
|
899
929
|
'rollback.autopoint.hint': 'Rollback points are kept automatically at the end of each turn: workspace snapshot plus session cursor (there is no manual capture entry).',
|
|
900
930
|
'rollback.state.workspace': 'Workspace',
|
|
@@ -938,6 +968,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
938
968
|
// t7 targeted wording fix (captain-authorized; existing key, not a new one): the code now has two uses —
|
|
939
969
|
// (1) preview.session.reasonCode (informational); (2) a *result* code: a files-including rewind is refused while the conversation runs.
|
|
940
970
|
'rollback.code.session-active': 'This conversation is running (its session log was written recently): a rewind that includes files is refused and the workspace stays untouched; rewinding the conversation only is unaffected (the rewind forks a child session and never rewrites the log)',
|
|
971
|
+
'rollback.code.subagent-session': 'This is a subagent session (origin=subagent), so no automatic rollback point is kept for it: it shares the workspace with its parent, and points here would only crowd out the ledger space of your own conversation.',
|
|
941
972
|
// t8 (append-only; the key above keeps its own wording): the conversation is running
|
|
942
973
|
'rollback.preview.sessionActive': 'This conversation is running: until the current turn finishes you can rewind the conversation only (no workspace changes), so nothing races with the tool that is writing files. Reopen this dialog after the turn to rewind files as well.',
|
|
943
974
|
'rollback.sessionActive.denied': 'This conversation is running: no files were changed. Wait for the current turn to finish, or rewind the conversation only.',
|
|
@@ -945,7 +976,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
945
976
|
'rollback.code.fork-failed': 'Forking the child session failed; the session segment was not rolled back',
|
|
946
977
|
'rollback.code.capture-failed': 'Failed to capture this state',
|
|
947
978
|
'rollback.code.capture-truncated': 'This state hit the capture limit and was truncated (recorded honestly)',
|
|
948
|
-
'rollback.code.capture-degraded': 'This state was captured incompletely (
|
|
979
|
+
'rollback.code.capture-degraded': 'This state was captured incompletely (unreadable entries, or files rewritten while capturing, are recorded)',
|
|
949
980
|
'rollback.code.no-chunks': 'No workspace path chunks were provided',
|
|
950
981
|
'rollback.code.chunk-not-absolute': 'Workspace chunks must be absolute paths',
|
|
951
982
|
'rollback.code.chunk-not-found': 'Workspace chunk is missing or unusable',
|
|
@@ -964,7 +995,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
964
995
|
'rollback.warn.trackDegraded': 'A state was captured incompletely (the rewind may be incomplete)',
|
|
965
996
|
'rollback.warn.protected': 'This rollback point is protected (kept forever): no rewind, no delete',
|
|
966
997
|
'rollback.warn.guardFailed': 'The pre-rewind rescue point failed (continued with a warning)',
|
|
967
|
-
'rollback.warn.createdRefused': 'Nothing created after this point was deleted: the capture-time proof list is incomplete (a whole directory was ignored / the path index is missing / a directory was unreadable), so newly created paths cannot be proven',
|
|
998
|
+
'rollback.warn.createdRefused': 'Nothing created after this point was deleted: the capture-time proof list is incomplete (a whole directory was ignored / the path index is missing / a directory was unreadable / the capture was cut short by its time budget), so newly created paths cannot be proven',
|
|
968
999
|
'rollback.warn.unknown': 'Unrecognized warning (technical detail below)',
|
|
969
1000
|
'rollback.preview': 'Preview rewind',
|
|
970
1001
|
'rollback.preview.title': 'Rewind preview',
|
|
@@ -1000,7 +1031,6 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
1000
1031
|
'rollback.diff.after': 'After rewind',
|
|
1001
1032
|
'rollback.diff.identical': 'This file already matches the rollback point, so the rewind will not change it',
|
|
1002
1033
|
'rollback.diff.degraded': 'The diff is large, so it is shown as a whole-block replacement (still complete, just not a minimal edit script)',
|
|
1003
|
-
'rollback.diff.truncated': 'Only the first {shown} of {total} rows are drawn (the data itself is complete)',
|
|
1004
1034
|
'rollback.diff.reason.missingBlob': 'The stored content copy cannot be read, so no line-by-line comparison is possible (the rewind will still try to write it back)',
|
|
1005
1035
|
'rollback.diff.reason.binary': 'Not a text file, so there is no line-by-line diff to show',
|
|
1006
1036
|
'rollback.diff.reason.tooLarge': 'The file exceeds the display limit, so there is no line-by-line diff (the rewind will still overwrite it)',
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 聊天内「↺ 回退」入口的**可见性信号**(issue #88)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这个模块:入口注册(`conversation.chat.node` 的 user key 遮蔽)必须在挂载时就发生,
|
|
5
|
+
* 但「功能开着没有」要到拿到宿主状态才知道;而开关可以在不重载插件的前提下改变。
|
|
6
|
+
* 所以注册本身就是无常的,可见性由这个**进程内信号**驱动:读一次状态 → 写进信号 → 组件订阅重渲染。
|
|
7
|
+
*
|
|
8
|
+
* 约定:
|
|
9
|
+
* · **缺省 = 关闭**(`false`):与宿主侧「缺省不启用」同一条口径,绝不因为「还没读到」就把入口画出来;
|
|
10
|
+
* · 读失败**不改信号**(保持上一次已知值):一次网络抖动不该让入口闪没;
|
|
11
|
+
* · 只存一个布尔,不落 storage(不涉及敏感值,也没有跨会话保留的必要)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
let enabled = false
|
|
15
|
+
const listeners = new Set<() => void>()
|
|
16
|
+
|
|
17
|
+
export function isRollbackEntryEnabled(): boolean {
|
|
18
|
+
return enabled
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 设置可见性并通知订阅者;值未变时不通知(避免无意义的重渲染)。 */
|
|
22
|
+
export function setRollbackEntryEnabled(value: boolean): void {
|
|
23
|
+
const next = value === true
|
|
24
|
+
if (next === enabled) return
|
|
25
|
+
enabled = next
|
|
26
|
+
for (const listener of [...listeners]) {
|
|
27
|
+
try {
|
|
28
|
+
listener()
|
|
29
|
+
} catch {
|
|
30
|
+
/* 单个订阅者自己的问题不影响信号 */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 订阅可见性变化(React 组件用;返回解绑函数)。 */
|
|
36
|
+
export function subscribeRollbackEntryEnabled(listener: () => void): () => void {
|
|
37
|
+
listeners.add(listener)
|
|
38
|
+
return () => {
|
|
39
|
+
listeners.delete(listener)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -17,6 +17,14 @@ const HERE = dirname(fileURLToPath(import.meta.url))
|
|
|
17
17
|
const CHANGES = join(HERE, 'RollbackFileChanges.tsx')
|
|
18
18
|
const CARD = join(HERE, 'RollbackCard.tsx')
|
|
19
19
|
const DIALOG = join(HERE, '../chat/RollbackReviewDialog.tsx')
|
|
20
|
+
const PANE = join(HERE, 'RollbackDiffPane.tsx')
|
|
21
|
+
const TABLE = join(HERE, '../common/DiffTable.tsx')
|
|
22
|
+
const TAB = join(HERE, '../chat/RollbackDiffTab.tsx')
|
|
23
|
+
const CSS = join(HERE, '../config-manager.module.css')
|
|
24
|
+
const PLAN = join(HERE, '../library/RestorePlanView.tsx')
|
|
25
|
+
const RECOVERY_LOCALES = join(HERE, 'recovery-locales.ts')
|
|
26
|
+
const CLIENT_LOCALES = join(HERE, '../locales.ts')
|
|
27
|
+
const PROXY = join(HERE, '../index.ts')
|
|
20
28
|
|
|
21
29
|
test('清单结构:容器 + 两类行 + 图标 + 目录弱化 + 右对齐统计(源级)', () => {
|
|
22
30
|
const src = readFileSync(CHANGES, 'utf8')
|
|
@@ -71,3 +79,72 @@ test('两处调用同一份渲染:聊天预览 + 设置页回滚卡预览(
|
|
|
71
79
|
assert.match(card, /css\.planScroll/, '几百个文件的清单必须限高内滚')
|
|
72
80
|
assert.doesNotMatch(card, /rollbackPreviewRows\(/, '预览弹窗不再画工作区/会话计数面板(与聊天预览同一屏)')
|
|
73
81
|
})
|
|
82
|
+
|
|
83
|
+
test('差异表全量渲染:渲染层不再有行数上限,也不再有「只画了前 N 行」话术(源级,2026-10-15)', () => {
|
|
84
|
+
const table = readFileSync(TABLE, 'utf8')
|
|
85
|
+
assert.doesNotMatch(table, /DIFF_ROW_CAP|rowCap|truncatedNotice/, '渲染层不得再留行数上限(用户 2026-10-15:我需要能完整的加载)')
|
|
86
|
+
assert.match(table, /group\.rows\.map\(/, '每个块的行必须全部画出来')
|
|
87
|
+
const pane = readFileSync(PANE, 'utf8')
|
|
88
|
+
assert.doesNotMatch(pane, /truncatedNotice/, '回滚预览不再传截断文案')
|
|
89
|
+
assert.match(pane, /rollback\.diff\.degraded/, '「整块替换」提示保留:那是算法保真度的事实,不是渲染截断')
|
|
90
|
+
const plan = readFileSync(PLAN, 'utf8')
|
|
91
|
+
assert.doesNotMatch(plan, /truncatedNotice/, '恢复计划不再传截断文案')
|
|
92
|
+
for (const file of [RECOVERY_LOCALES, CLIENT_LOCALES]) {
|
|
93
|
+
assert.doesNotMatch(readFileSync(file, 'utf8'), /diffTruncated|diff\.truncated/, '截断话术必须删除:没有上限就不该留这句话')
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('差异表文件头:只画路径文字,不画文件类型图标(源级,2026-10-14 用户第 2 次改口径)', () => {
|
|
98
|
+
const table = readFileSync(TABLE, 'utf8')
|
|
99
|
+
assert.doesNotMatch(table, /diffFileIcon/, '文件头不再有图标格子')
|
|
100
|
+
assert.doesNotMatch(table, /icon\?: ReactNode/, '共享原语不再收图标节点(那颗链整个撤掉)')
|
|
101
|
+
assert.match(table, /className=\{css\.diffFilePath \+ ' ' \+ css\.mono\}/, '路径文字仍是文件头的唯一内容')
|
|
102
|
+
const pane = readFileSync(PANE, 'utf8')
|
|
103
|
+
assert.doesNotMatch(pane, /RollbackFileIcon/, '差异面板不再引文件类型图标')
|
|
104
|
+
assert.doesNotMatch(pane, /renderFileIcon/, '差异面板不再收外部图标来源')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('侧栏标签页吃满宿主高度:不再自带一道 vh/px 上限(源级)', () => {
|
|
108
|
+
const tab = readFileSync(TAB, 'utf8')
|
|
109
|
+
assert.match(tab, /title=\{target\.path\} fill \/>/, '侧栏标签页:撑满那一屏')
|
|
110
|
+
const dialog = readFileSync(DIALOG, 'utf8')
|
|
111
|
+
assert.match(dialog, /<RollbackDiffPane state=\{inlineDiff\.state\} rt=\{rt\} title=\{inlineDiff\.path\} \/>/, '弹窗内的降级通道照旧画差异')
|
|
112
|
+
assert.doesNotMatch(dialog, /RollbackDiffPane[^>]*\bfill\b/, '弹窗内的就地差异不给 fill(宿主面板高度不是它那一屏)')
|
|
113
|
+
const css = readFileSync(CSS, 'utf8')
|
|
114
|
+
assert.match(css, /\.diffPaneFill \{[\s\S]{0,120}?height: 100%/, 'fill 档必须 height:100% 才谈得上「= 侧栏大小」')
|
|
115
|
+
assert.match(css, /\.diffPaneFill \.diffScroll \{[\s\S]{0,200}?min-height: 0/, 'flex 链必须允许收缩,否则内容顶破父容器(min-height:auto 陷阱)')
|
|
116
|
+
// 撑满档整块取出来判定(块里现在还要容下 flex:auto 的依据注释,按字符窗口量距离会被注释挤爆)
|
|
117
|
+
const fillBlock = css.slice(css.indexOf('.diffPaneFill {'), css.indexOf('.diffPaneFill .diffScroll'))
|
|
118
|
+
assert.match(fillBlock, /\bmargin: 0/, '撑满档必须归零外边距:否则 100% 之外多出 10px,宿主面板自己冒一条滚动条')
|
|
119
|
+
// 行盒 = 12×1.5 + 上下 1px ⇒ 20px/行:845px 宽的侧栏里 24 行折成 35 个视觉行,22px 差一行就溢出
|
|
120
|
+
assert.match(css, /\.diffCell \{[\s\S]{0,80}?padding: 1px 6px/, '差异单元格上下内边距必须是 1px(20px 行高才装得进侧栏那一屏)')
|
|
121
|
+
})
|
|
122
|
+
test('侧栏标签页吃满宿主高度:整块高度跟侧栏走(源级,2026-10-15)', () => {
|
|
123
|
+
const css = readFileSync(CSS, 'utf8')
|
|
124
|
+
// ① 正文限高必须排在「列表量级」那条**之后**:两条特异性同为 (0,2,0),顺序决定胜负;
|
|
125
|
+
// 写在前面时 min(80vh, 800px) 会盖掉它 —— 侧栏比它高时差异区就停在那儿,
|
|
126
|
+
// 正是用户 2026-10-15 报的「预览高度不跟侧栏走」。
|
|
127
|
+
const listCap = css.indexOf('.diffPane .diffScroll')
|
|
128
|
+
const fillCap = css.indexOf('.diffPaneFill .diffScroll')
|
|
129
|
+
assert.ok(listCap >= 0 && fillCap >= 0, '两条限高规则都必须存在')
|
|
130
|
+
assert.ok(fillCap > listCap, 'fill 档的限高必须排在 .diffPane .diffScroll 之后(顺序决定胜负)')
|
|
131
|
+
assert.doesNotMatch(css.slice(fillCap, fillCap + 400), /min\(80vh, 800px\)/, 'fill 档不得再吃列表量级的 min(80vh, 800px) 上限')
|
|
132
|
+
// ② 撑满档下**每个出口**都要套同一个整块容器:只让「就绪且有差异」撑满时,
|
|
133
|
+
// 加载 / 一致 / 看不了 / 读失败会缩成一行,一屏高度来回跳(同样是「高度不跟侧栏走」)。
|
|
134
|
+
const pane = readFileSync(PANE, 'utf8')
|
|
135
|
+
assert.equal([...pane.matchAll(/return shell\(/g)].length, 5, '加载 / 读失败 / 结构化拒绝 / 看不了 / 一致 五个出口都要经同一个撑满容器')
|
|
136
|
+
assert.match(pane, /fill === true \? <div className=\{css\.diffPane \+ ' ' \+ css\.diffPaneFill\}>/, '撑满容器必须是 .diffPane + .diffPaneFill 那一对类名')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('标签页芯片:[文件类型图标][文件名],只经 title 位注册(源级)', () => {
|
|
140
|
+
const tab = readFileSync(TAB, 'utf8')
|
|
141
|
+
assert.match(tab, /export function RollbackDiffTabTitle/, '芯片必须是独立组件(正文位与芯片位两份注册)')
|
|
142
|
+
assert.match(tab, /className=\{css\.rollbackTabIcon\} aria-hidden="true"/, "芯片图标必须有独立格子且是装饰性的(可访问名由文件名文字承担)")
|
|
143
|
+
assert.match(tab, /<RollbackFileIcon path=\{target\.path\} size=\{14\} render=\{fileIcon\} \/>/, '芯片图标走共享的文件类型图标(与 DSH 侧栏同一画法)')
|
|
144
|
+
assert.match(tab, /if \(target === null\) return/, '地址读不出来时退回宿主给的标题文字(不空着、也不自己编名字)')
|
|
145
|
+
const index = readFileSync(PROXY, 'utf8')
|
|
146
|
+
assert.match(index, /ROLLBACK_DIFF_TITLE_SLOT/, '装配点必须注册芯片位(宿主的 tab 定义没有 icon 字段)')
|
|
147
|
+
assert.match(index, /name: ROLLBACK_DIFF_TITLE_SLOT,[\s\S]{0,80}?key: ROLLBACK_DIFF_TAB_ID/, '芯片位必须按正文同一个实现 id 注册(同一个 tab 才认领得到)')
|
|
148
|
+
const css = readFileSync(CSS, 'utf8')
|
|
149
|
+
assert.match(css, /\.rollbackTabIcon \{[\s\S]{0,160}?margin-right: 6px/, '芯片图标与文件名之间要有间距(与 better-sidebar 的 chipIcon 同档)')
|
|
150
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* t42 物理拆分(从 SyncPanel.tsx 抽出的渲染段;同领域目录平铺)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么它值得单独一个文件:这张卡有**四条**并列提示带(只读说明 / 首次基线 / 加密 /
|
|
5
|
+
* 含凭据),issue #91 又补了第五条「引擎告警」——留在主文件里只会把
|
|
6
|
+
* `SyncSettingsView.test.ts` 的行数棘轮一节节往上抬(该棘轮的口径:新增行必须是
|
|
7
|
+
* 确实无法下沉的装配;能抽就抽,而不是调上限)。
|
|
8
|
+
*
|
|
9
|
+
* 约定(与 AutosyncCard / WorkDirCard 一致):只接收「渲染所需的数据」;
|
|
10
|
+
* 渲染模型仍来自 `./sync-view.ts` 的 `pushPreviewView`(纯函数、node 单测覆盖),本文件零逻辑。
|
|
11
|
+
*/
|
|
12
|
+
import type { TranslateNS } from '../client-types.ts'
|
|
13
|
+
import type { SyncPushPreview } from '../../sync/sync-engine.ts'
|
|
14
|
+
import type { UiT } from '../../ui/i18n.ts'
|
|
15
|
+
import type { SectionId } from '../../schema/types.ts'
|
|
16
|
+
import { Banner, Card } from '../common/ui.tsx'
|
|
17
|
+
import { pushPreviewView } from './sync-view.ts'
|
|
18
|
+
import css from '../config-manager.module.css'
|
|
19
|
+
|
|
20
|
+
/** P0-②:push 预览内容(绑 src/ui/i18n.ts 的 UiT 文案;纯展示,无敏感字段)。 */
|
|
21
|
+
export function PushPreviewCard({ preview, t, uiT, sectionName }: {
|
|
22
|
+
preview: SyncPushPreview | null
|
|
23
|
+
t: TranslateNS<'config-manager-sync'>
|
|
24
|
+
uiT: UiT
|
|
25
|
+
sectionName: (id: SectionId) => string
|
|
26
|
+
}) {
|
|
27
|
+
const view = pushPreviewView(preview, uiT)
|
|
28
|
+
if (view === null) return null
|
|
29
|
+
if (view.error !== null) return <Banner kind="error">{view.error}</Banner>
|
|
30
|
+
return (
|
|
31
|
+
<div>
|
|
32
|
+
<Banner kind="info">{view.headline}</Banner>
|
|
33
|
+
{view.previewHint !== '' && <div className={css.hint}>{view.previewHint}</div>}
|
|
34
|
+
{view.remoteSnapshotCount === 0 && (
|
|
35
|
+
<Banner kind="warn">{t('syncflow.pushFirstBaseline')}</Banner>
|
|
36
|
+
)}
|
|
37
|
+
{view.encryptedHint !== '' && <Banner kind="warn">{view.encryptedHint}</Banner>}
|
|
38
|
+
{view.credentialsHint !== '' && <Banner kind="warn">{view.credentialsHint}</Banner>}
|
|
39
|
+
{/* 引擎告警(分区导出失败已跳过 / 本地源插件未打包 ⇒ 该分区不做「无变化」断言,issue #91):
|
|
40
|
+
如实上屏 —— 否则预览的分区数会静静地少,或者某个分区永远显示「有变化」而无解释 */}
|
|
41
|
+
{view.hint !== '' && <Banner kind="warn">{view.hint}</Banner>}
|
|
42
|
+
<Card className={css.card}>
|
|
43
|
+
<div className={css.groupLabel}>{t('syncflow.pushPreviewSections')}</div>
|
|
44
|
+
<div className={css.planScroll}>
|
|
45
|
+
<ul className={css.reportList}>
|
|
46
|
+
{view.rows.map((row) => (
|
|
47
|
+
<li key={row.section}>
|
|
48
|
+
<span className={css.kindTag}>{row.changed ? t('syncflow.pushChanged') : t('syncflow.pushUnchanged')}</span>
|
|
49
|
+
{' '}{sectionName(row.section)} · {row.count}
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
</div>
|
|
54
|
+
</Card>
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -43,7 +43,6 @@ import { useEffect, useRef, useState } from 'react'
|
|
|
43
43
|
import type { ReactNode } from 'react'
|
|
44
44
|
import type { TranslateNS } from '../client-types.ts'
|
|
45
45
|
import type { SyncPullReport, SyncPushPreview, SyncPushReport } from '../../sync/sync-engine.ts'
|
|
46
|
-
import type { UiT } from '../../ui/i18n.ts'
|
|
47
46
|
import type { SectionId } from '../../schema/types.ts'
|
|
48
47
|
import { Badge, Banner, Button, Card, SectionTitle, Spinner } from '../common/ui.tsx'
|
|
49
48
|
import { ErrorBanner } from '../common/ErrorBanner.tsx'
|
|
@@ -61,7 +60,7 @@ import type {
|
|
|
61
60
|
} from './sync-api.ts'
|
|
62
61
|
import {
|
|
63
62
|
channelMapOf, channelStateMap, computeGithubLoginView, computeSyncButtons, defaultChannelSyncState,
|
|
64
|
-
githubPollMessage, isClientChannel, kindLabel, pullReportView,
|
|
63
|
+
githubPollMessage, isClientChannel, kindLabel, pullReportView, pushReportView, readStoredChannel,
|
|
65
64
|
gistSessionsRiskKey,
|
|
66
65
|
initialSyncSections, normalizeSessionsLimit, severityLabel, syncSectionOptions, writeStoredChannel,
|
|
67
66
|
} from './sync-view.ts'
|
|
@@ -92,6 +91,7 @@ import { WorkDirCard } from './WorkDirCard.tsx'
|
|
|
92
91
|
import { Collapse } from '../common/Motion.tsx'
|
|
93
92
|
import { SyncLogList } from './SyncLogList.tsx'
|
|
94
93
|
import { UnreadableSnapshotsBanner } from './UnreadableSnapshotsBanner.tsx'
|
|
94
|
+
import { PushPreviewCard } from './PushPreviewCard.tsx'
|
|
95
95
|
import { SyncConfirmView } from './SyncConfirmView.tsx'
|
|
96
96
|
import css from '../config-manager.module.css'
|
|
97
97
|
|
|
@@ -1544,40 +1544,5 @@ export function SyncPanel({ api, t, cmT }: SyncPanelProps) {
|
|
|
1544
1544
|
)
|
|
1545
1545
|
}
|
|
1546
1546
|
|
|
1547
|
-
/** P0-②:push 预览内容(绑 src/ui/i18n.ts 的 UiT 文案;纯展示,无敏感字段)。 */
|
|
1548
|
-
function PushPreviewCard({ preview, t, uiT, sectionName }: {
|
|
1549
|
-
preview: SyncPushPreview | null
|
|
1550
|
-
t: TranslateNS<'config-manager-sync'>
|
|
1551
|
-
uiT: UiT
|
|
1552
|
-
sectionName: (id: SectionId) => string
|
|
1553
|
-
}) {
|
|
1554
|
-
const view = pushPreviewView(preview, uiT)
|
|
1555
|
-
if (view === null) return null
|
|
1556
|
-
if (view.error !== null) return <Banner kind="error">{view.error}</Banner>
|
|
1557
|
-
return (
|
|
1558
|
-
<div>
|
|
1559
|
-
<Banner kind="info">{view.headline}</Banner>
|
|
1560
|
-
{view.previewHint !== '' && <div className={css.hint}>{view.previewHint}</div>}
|
|
1561
|
-
{view.remoteSnapshotCount === 0 && (
|
|
1562
|
-
<Banner kind="warn">{t('syncflow.pushFirstBaseline')}</Banner>
|
|
1563
|
-
)}
|
|
1564
|
-
{view.encryptedHint !== '' && <Banner kind="warn">{view.encryptedHint}</Banner>}
|
|
1565
|
-
{view.credentialsHint !== '' && <Banner kind="warn">{view.credentialsHint}</Banner>}
|
|
1566
|
-
<Card className={css.card}>
|
|
1567
|
-
<div className={css.groupLabel}>{t('syncflow.pushPreviewSections')}</div>
|
|
1568
|
-
<div className={css.planScroll}>
|
|
1569
|
-
<ul className={css.reportList}>
|
|
1570
|
-
{view.rows.map((row) => (
|
|
1571
|
-
<li key={row.section}>
|
|
1572
|
-
<span className={css.kindTag}>{row.changed ? t('syncflow.pushChanged') : t('syncflow.pushUnchanged')}</span>
|
|
1573
|
-
{' '}{sectionName(row.section)} · {row.count}
|
|
1574
|
-
</li>
|
|
1575
|
-
))}
|
|
1576
|
-
</ul>
|
|
1577
|
-
</div>
|
|
1578
|
-
</Card>
|
|
1579
|
-
</div>
|
|
1580
|
-
)
|
|
1581
|
-
}
|
|
1582
1547
|
|
|
1583
1548
|
|
|
@@ -62,6 +62,9 @@ test('t42: 组件按职责物理拆分成独立文件(各自导出 + 主文件
|
|
|
62
62
|
['AutosyncCard', 'AutosyncCard.tsx'],
|
|
63
63
|
// issue #79:工作副本卡(含确认弹窗 + 宿主调用 + 回执)自成文件,主文件只装配 —— 行数棘轮因此不必上调
|
|
64
64
|
['WorkDirCard', 'WorkDirCard.tsx'],
|
|
65
|
+
// issue #91:推送预览卡(五条并列提示带:只读 / 首次基线 / 加密 / 含凭据 / 引擎告警)自成文件,
|
|
66
|
+
// 主文件只装配 —— 同样以「抽出去」而不是「抬上限」满足棘轮
|
|
67
|
+
['PushPreviewCard', 'PushPreviewCard.tsx'],
|
|
65
68
|
]
|
|
66
69
|
for (const [name, file] of units) {
|
|
67
70
|
// 主文件:必须 import 并渲染该单元
|
|
@@ -74,4 +74,18 @@ test('pushPreviewView: 含凭据推送 → 显式提示「本次含真实凭据
|
|
|
74
74
|
|
|
75
75
|
test('pushPreviewView: null 预览 → null(无弹窗内容)', () => {
|
|
76
76
|
assert.equal(pushPreviewView(null, t), null);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('pushPreviewView: 引擎告警(分区导出失败已跳过 / 本地源插件未打包 ⇒ 不做「无变化」断言)如实上屏(issue #91)', () => {
|
|
80
|
+
const withHint = pushPreviewView(okPreview({ message: 'plugins 与基线不可比' }), t);
|
|
81
|
+
assert.ok(withHint !== null && withHint.ok);
|
|
82
|
+
assert.equal(withHint.hint, 'plugins 与基线不可比');
|
|
83
|
+
|
|
84
|
+
const noHint = pushPreviewView(okPreview(), t);
|
|
85
|
+
assert.ok(noHint !== null);
|
|
86
|
+
assert.equal(noHint.hint, '', '无告警时不占版面');
|
|
87
|
+
|
|
88
|
+
const failed = pushPreviewView({ ok: false, sections: [], remoteSnapshotCount: 0, encrypted: false, credentialsIncluded: false, message: 'boom' }, t);
|
|
89
|
+
assert.ok(failed !== null && !failed.ok);
|
|
90
|
+
assert.equal(failed.hint, '', '错误分支只用 error,不重复显示同一句');
|
|
77
91
|
});
|
|
@@ -197,7 +197,7 @@ test('sync-view: summarizePullChanges 空数组 → total 0 且不需决策', ()
|
|
|
197
197
|
test('sync-view: kindLabel / severityLabel 覆盖关键类型', () => {
|
|
198
198
|
assert.equal(kindLabel('Conflict', zhUiT), '冲突')
|
|
199
199
|
assert.equal(kindLabel('Install', zhUiT), '安装')
|
|
200
|
-
assert.equal(kindLabel('MissingSecret', zhUiT), '
|
|
200
|
+
assert.equal(kindLabel('MissingSecret', zhUiT), '凭据迁移')
|
|
201
201
|
assert.equal(severityLabel('error', zhUiT), '错误')
|
|
202
202
|
assert.equal(severityLabel('warning', zhUiT), '警告')
|
|
203
203
|
assert.equal(severityLabel('info', zhUiT), '信息')
|
|
@@ -614,6 +614,12 @@ export interface PushPreviewView {
|
|
|
614
614
|
credentialsHint: string;
|
|
615
615
|
/** 只读提示(预览不写远端) */
|
|
616
616
|
previewHint: string;
|
|
617
|
+
/**
|
|
618
|
+
* 引擎随预览回传的告警(分区导出失败、本地源插件未打包导致该分区无法与基线比对等);
|
|
619
|
+
* 为空串 = 无告警(不占版面)。ok 预览也需要显示它:否则「某分区导出失败已跳过」只存在于
|
|
620
|
+
* 响应体里,用户看到的分区数会静静变少。
|
|
621
|
+
*/
|
|
622
|
+
hint: string;
|
|
617
623
|
headline: string;
|
|
618
624
|
error: string | null;
|
|
619
625
|
}
|
|
@@ -624,7 +630,8 @@ export function pushPreviewView(preview: SyncPushPreview | null, t: UiT): PushPr
|
|
|
624
630
|
if (!preview.ok) {
|
|
625
631
|
return {
|
|
626
632
|
ok: false, rows: [], changedCount: 0, remoteSnapshotCount: preview.remoteSnapshotCount,
|
|
627
|
-
encryptedHint: '', credentialsHint: '', previewHint: '',
|
|
633
|
+
encryptedHint: '', credentialsHint: '', previewHint: '', hint: '', headline: '',
|
|
634
|
+
error: preview.message ?? t('sync.pushFailed'),
|
|
628
635
|
};
|
|
629
636
|
}
|
|
630
637
|
const rows = preview.sections.map((s) => ({ section: s.section, count: s.count, changed: s.changed }));
|
|
@@ -637,6 +644,7 @@ export function pushPreviewView(preview: SyncPushPreview | null, t: UiT): PushPr
|
|
|
637
644
|
encryptedHint: preview.encrypted ? t('sync.pushPreviewEncrypted') : '',
|
|
638
645
|
credentialsHint: preview.credentialsIncluded ? t('sync.pushPreviewCredentials') : '',
|
|
639
646
|
previewHint: t('sync.pushPreviewHint'),
|
|
647
|
+
hint: preview.message ?? '',
|
|
640
648
|
headline: t('sync.pushPreviewHeadline', {
|
|
641
649
|
total: String(preview.sections.length),
|
|
642
650
|
changed: String(changedCount),
|
package/src/core/analyzer.ts
CHANGED
|
@@ -26,7 +26,7 @@ import type { FilesSection, Manifest, SectionId } from '../schema/types.ts';
|
|
|
26
26
|
import { loadTombstones, isTombstoned } from '../schema/tombstones.ts';
|
|
27
27
|
import type { Tombstone, TombstoneKind } from '../schema/tombstones.ts';
|
|
28
28
|
import { DEFAULT_SENSITIVE_RELS, restoreVaultFiles } from '../security/vault.ts';
|
|
29
|
-
import { createSnapshot, planItemWritesTarget, resolveFileTarget, resolveFileTargetRel } from './backup.ts';
|
|
29
|
+
import { createSnapshot, fsForSection, planItemWritesTarget, resolveFileTarget, resolveFileTargetRel } from './backup.ts';
|
|
30
30
|
import { isCredentialConfigured } from './credential-status.ts';
|
|
31
31
|
import { rollback } from './rollback.ts';
|
|
32
32
|
import { compatibilityReasons, computeCompatibility } from './validator.ts';
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
ImportNotConfirmedError, ImportUserSkippedError, type ApplyResult, type ConfigAdapter,
|
|
38
38
|
type ExecutedItem, type HostContext, type ImportAnalysis, type ImportContext,
|
|
39
39
|
type ImportDecisions, type ImportPlan, type ImportResult, type PathIssue,
|
|
40
|
-
type PathMapping, type PlanItem, type SessionFormatProbeResult, type SkippedTombstone,
|
|
40
|
+
type FileSystemFacade, type PathMapping, type PlanItem, type SessionFormatProbeResult, type SkippedTombstone,
|
|
41
41
|
type Snapshot, type SnapshotStore, type TransactionSnapshotContext,
|
|
42
42
|
} from './types.ts';
|
|
43
43
|
|
|
@@ -953,7 +953,7 @@ export class Analyzer {
|
|
|
953
953
|
let beforeFp: string | null = null;
|
|
954
954
|
if (Analyzer.fileRelFor(item, importCtx.target.profile) !== null) {
|
|
955
955
|
rel = Analyzer.fileRelFor(item, importCtx.target.profile) as string;
|
|
956
|
-
beforeFp = await this.fileFp(importCtx,
|
|
956
|
+
beforeFp = await this.fileFp(importCtx, item);
|
|
957
957
|
}
|
|
958
958
|
beforeFpByItem.set(item.id, beforeFp);
|
|
959
959
|
await sb.recordStep({
|
|
@@ -1055,7 +1055,7 @@ export class Analyzer {
|
|
|
1055
1055
|
const rel = Analyzer.fileRelFor(item, importCtx.target.profile);
|
|
1056
1056
|
let afterFp: string | null = null;
|
|
1057
1057
|
if (rel !== null && outcome.executed.status === 'ok') {
|
|
1058
|
-
afterFp = await this.fileFp(importCtx,
|
|
1058
|
+
afterFp = await this.fileFp(importCtx, item);
|
|
1059
1059
|
}
|
|
1060
1060
|
await sb.recordStep({
|
|
1061
1061
|
id: item.id,
|
|
@@ -1382,7 +1382,7 @@ export class Analyzer {
|
|
|
1382
1382
|
return planItemWritesTarget(item);
|
|
1383
1383
|
}
|
|
1384
1384
|
|
|
1385
|
-
/** 文件类且可指纹 →
|
|
1385
|
+
/** 文件类且可指纹 → 返回「根相对路径」(journal 的 ref 身份);否则 null(不可指纹外部项)。
|
|
1386
1386
|
* profile 必须传入:plugins 分区的 pnpm-workspace.yaml / patch 文件位于
|
|
1387
1387
|
* `profiles/<profile>/` 下,缺了它算出的相对路径不存在 → 指纹恒 null(issue #35)。 */
|
|
1388
1388
|
private static fileRelFor(item: PlanItem, profile?: string): string | null {
|
|
@@ -1392,11 +1392,33 @@ export class Analyzer {
|
|
|
1392
1392
|
return resolveFileTargetRel(item.adapter, ref, profile);
|
|
1393
1393
|
}
|
|
1394
1394
|
|
|
1395
|
-
/**
|
|
1396
|
-
|
|
1395
|
+
/**
|
|
1396
|
+
* 文件类且可指纹 → 返回「该分区自己的门面 + 绝对路径」。
|
|
1397
|
+
*
|
|
1398
|
+
* 为什么不能只拿 home-relative 路径去读:`pluginData` / `pluginAssets` 的根是**系统 home**
|
|
1399
|
+
* (见 `core/backup.ts` 的 `fsForSection`),用 `ctx.fs` 读会落到 $DSH_HOME 下的错误位置
|
|
1400
|
+
* 或被越界守卫拦下 —— 指纹会静默变成「读不到」,reconcile 因此永远判不出是否已应用。
|
|
1401
|
+
* 宿主未提供该门面 / 路径不可解析 → null(保守:不可证明)。
|
|
1402
|
+
*/
|
|
1403
|
+
private static fileTargetFor(
|
|
1404
|
+
ctx: ImportContext, item: PlanItem,
|
|
1405
|
+
): { fs: FileSystemFacade; abs: string } | null {
|
|
1406
|
+
if (!isFileSection(item.adapter)) return null;
|
|
1407
|
+
const ref = item.target?.ref;
|
|
1408
|
+
if (ref === undefined || ref === '') return null;
|
|
1409
|
+
try {
|
|
1410
|
+
return { fs: fsForSection(ctx.target, item.adapter), abs: resolveFileTarget(ctx.target, item.adapter, ref) };
|
|
1411
|
+
} catch {
|
|
1412
|
+
return null;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** 读目标文件算 sha256(经该分区自己的门面,测试 mock 可注入)。失败返回 null。 */
|
|
1417
|
+
private async fileFp(ctx: ImportContext, item: PlanItem): Promise<string | null> {
|
|
1418
|
+
const target = Analyzer.fileTargetFor(ctx, item);
|
|
1419
|
+
if (target === null) return null;
|
|
1397
1420
|
try {
|
|
1398
|
-
|
|
1399
|
-
return sha256Hex(data);
|
|
1421
|
+
return sha256Hex(await target.fs.readFile(target.abs));
|
|
1400
1422
|
} catch {
|
|
1401
1423
|
return null;
|
|
1402
1424
|
}
|
|
@@ -16,7 +16,8 @@ import fs from 'node:fs/promises';
|
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
|
-
DEFAULT_BACKUP_SECTIONS, OFFLINE_BACKUP_SECTIONS,
|
|
19
|
+
DEFAULT_BACKUP_SECTIONS, OFFLINE_BACKUP_SECTIONS, OFFLINE_EXCLUDED_SECTIONS, OFFLINE_UNAVAILABLE_SECTIONS,
|
|
20
|
+
OPT_IN_BACKUP_SECTIONS,
|
|
20
21
|
buildSectionFlags,
|
|
21
22
|
} from './backup-plan.ts';
|
|
22
23
|
import { APPLY_ORDER } from './analyzer.ts';
|
|
@@ -105,20 +106,34 @@ test('离线分区清单:注册表所有分区都必须显式归类(可收
|
|
|
105
106
|
// 「明确不参与离线」:sessions 体量巨大且属用户数据(GUI 侧单独 opt-in)、secrets 无分区载荷
|
|
106
107
|
// (凭据值走独立加密容器)。二者都不在 CLI 离线备份的目标集合内 —— 但必须**显式**列出,
|
|
107
108
|
// 否则新增分区会被静默漏收/漏报。
|
|
108
|
-
|
|
109
|
+
// pluginData / pluginAssets 也在此列(第三方插件数据的收集依赖 adapter 的来源注册表;
|
|
110
|
+
// 见 backup-plan.ts 的 OFFLINE_EXCLUDED_SECTIONS —— 直接引用产品常量,避免测试与实现各抄一份)
|
|
111
|
+
const NOT_OFFLINE_APPLICABLE: readonly SectionId[] = [
|
|
112
|
+
'sessions', 'secrets', ...OFFLINE_EXCLUDED_SECTIONS,
|
|
113
|
+
];
|
|
109
114
|
const classified = new Set<string>([...OFFLINE_BACKUP_SECTIONS, ...OFFLINE_UNAVAILABLE_SECTIONS, ...NOT_OFFLINE_APPLICABLE]);
|
|
110
115
|
assert.deepEqual([...classified].sort(), [...SECTION_IDS].sort(), '新增分区必须在 backup-plan 里显式归类');
|
|
111
116
|
});
|
|
112
117
|
|
|
113
118
|
/* ------------------------------------------------------------ FILE_BASES */
|
|
114
119
|
|
|
115
|
-
test('resolveFileTarget
|
|
116
|
-
//
|
|
120
|
+
test('resolveFileTarget:文件类分区都有明确根($DSH_HOME 内,或系统 home 根的那两个分区)', () => {
|
|
121
|
+
// 文件本来就在 $DSH_HOME 根的分区:AGENTS.md / 插件自有文件白名单
|
|
117
122
|
const HOME_ROOT_SECTIONS: readonly SectionId[] = ['agentInstructions', 'pluginFiles'];
|
|
118
|
-
|
|
123
|
+
// **系统 home 为根**的分区(第三方插件数据):$DSH_HOME 之外的第二个根,见 backup.ts 的 fsForSection
|
|
124
|
+
const OS_HOME_SECTIONS: readonly SectionId[] = ['pluginData', 'pluginAssets'];
|
|
125
|
+
const ctx = {
|
|
126
|
+
homeDir: path.join('C:', 'home', '.dsh'),
|
|
127
|
+
osHomeDir: path.join('C:', 'home'),
|
|
128
|
+
profile: 'web',
|
|
129
|
+
} as unknown as HostContext;
|
|
119
130
|
const atHomeRoot = path.join(ctx.homeDir, 'probe.txt');
|
|
120
131
|
for (const id of SECTION_IDS.filter((s) => isFileSection(s))) {
|
|
121
132
|
const target = resolveFileTarget(ctx, id, 'probe.txt');
|
|
133
|
+
if (OS_HOME_SECTIONS.includes(id)) {
|
|
134
|
+
assert.equal(target, path.join(ctx.osHomeDir as string, 'probe.txt'), `${id} 的根必须是系统 home`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
122
137
|
assert.ok(target.startsWith(ctx.homeDir), `${id} 的目标必须在 homeDir 下: ${target}`);
|
|
123
138
|
if (HOME_ROOT_SECTIONS.includes(id)) {
|
|
124
139
|
assert.equal(target, atHomeRoot, `${id} 是 home 根分区`);
|