dsh-config-manager 0.1.73 → 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.
Files changed (49) hide show
  1. package/lib/client.d.ts +8 -4
  2. package/lib/client.js +814 -682
  3. package/lib/core/rollback-ledger.d.ts +29 -3
  4. package/lib/core/rollback-ledger.js +125 -7
  5. package/lib/core/rollback-point.d.ts +221 -3
  6. package/lib/core/rollback-point.js +790 -153
  7. package/lib/index.d.ts +1 -1
  8. package/lib/index.js +1 -1
  9. package/lib/sync/messages.d.ts +1 -0
  10. package/lib/sync/messages.js +3 -0
  11. package/lib/sync/sync-engine.js +56 -7
  12. package/lib/ui/i18n.d.ts +1 -1
  13. package/lib/ui/i18n.js +2 -2
  14. package/lib/utils/bundle-scan.d.ts +5 -3
  15. package/lib/utils/bundle-scan.js +5 -3
  16. package/lib/utils/line-diff.d.ts +7 -1
  17. package/lib/utils/line-diff.js +8 -2
  18. package/package.json +1 -1
  19. package/src/client/chat/ChatEntryView.tsx +38 -9
  20. package/src/client/chat/RollbackFileIcon.tsx +7 -45
  21. package/src/client/chat/chat-entry-view.test.ts +164 -1
  22. package/src/client/chat/chat-entry-view.ts +127 -10
  23. package/src/client/chat/chat-node-registration.ts +40 -6
  24. package/src/client/common/DiffTable.tsx +12 -23
  25. package/src/client/common/host-primitives.ts +78 -0
  26. package/src/client/config-manager.module.css +33 -12
  27. package/src/client/environment/maintenance/DiskUsageCard.tsx +20 -3
  28. package/src/client/index.ts +7 -1
  29. package/src/client/library/RestorePlanView.tsx +0 -1
  30. package/src/client/locales.ts +0 -2
  31. package/src/client/recovery/RollbackDiffPane.tsx +13 -6
  32. package/src/client/recovery/recovery-locales.ts +2 -4
  33. package/src/client/recovery/rollback-file-changes.test.ts +37 -1
  34. package/src/client/sync/PushPreviewCard.tsx +57 -0
  35. package/src/client/sync/SyncPanel.tsx +2 -37
  36. package/src/client/sync/SyncSettingsView.test.ts +3 -0
  37. package/src/client/sync/sync-push-preview.test.ts +14 -0
  38. package/src/client/sync/sync-view.ts +9 -1
  39. package/src/core/rollback-ledger.ts +123 -7
  40. package/src/core/rollback-point.test.ts +592 -17
  41. package/src/core/rollback-point.ts +861 -155
  42. package/src/index.ts +1 -1
  43. package/src/sync/messages.ts +3 -0
  44. package/src/sync/sync-engine.ts +55 -7
  45. package/src/sync/sync-preview-push.test.ts +158 -0
  46. package/src/ui/i18n.ts +2 -2
  47. package/src/utils/bundle-scan.ts +5 -3
  48. package/src/utils/line-diff.test.ts +12 -0
  49. package/src/utils/line-diff.ts +9 -3
@@ -0,0 +1,78 @@
1
+ /**
2
+ * 宿主共享原语(ui-primitives)的**帧内惰取门 —— 全仓唯一出口**。
3
+ *
4
+ * 为什么是「惰取 + try/catch」而不是顶层 import(真机教训 + 第三方实证):
5
+ * · `@deepseek-ai/dsh-client-ui-primitives` 由 DSH shell 的模块表作为 static module seed 预置,
6
+ * 插件 bundle 的 `require` 能直接命中(第三方插件 dsh-better-sidebar / dsh-agent-teams /
7
+ * dsh-mnemon / dsh-reedit 全都这么做;本仓的 RollbackFileIcon 自 2026-10-12 起也这么做);
8
+ * · 但**老宿主**没有那份模块表 —— 那一刻 `require` 抛错。顶层 `import` 会被打包器改写成
9
+ * 模块体顶层的 `require`,抛错会让**整个浏览器半加载失败**(设置页一起没)。
10
+ * 所以:只在**真要画 / 真要投影的那一刻**取,拿不到就回落本地实现。
11
+ *
12
+ * 该包因此不进 bundle:源码里只有 `require` 调用,白名单登记在 `src/utils/bundle-scan.ts`。
13
+ * 三态缓存:undefined = 还没试过;null = 试过了,这台宿主没有;对象 = 拿到了。
14
+ */
15
+ import type { ComponentType, ReactNode } from 'react'
16
+
17
+ /** `FileTypeIcon` 我们真的用到的入参面(只声明消费的字段,不复制第三方类型)。 */
18
+ export interface DshFileTypeIconProps {
19
+ /** 文件路径(按扩展名/文件名分类;路径越完整越准)。 */
20
+ path: string
21
+ /** 边长(px)。 */
22
+ size?: number
23
+ /** 追加类名(宿主组件自己的类名照旧生效)。 */
24
+ className?: string
25
+ }
26
+
27
+ /**
28
+ * 宿主 `projectUserText` 的入参面(与 ui-primitives 逐字同形,只声明我们传的那几个):
29
+ * 会话线格式与斜杠名在正文里渲染成 chip;`references` 给了打开器才可点开。
30
+ */
31
+ export type DshUserTextProjector = (
32
+ text: string,
33
+ sessionLabels: readonly string[],
34
+ slashNames: readonly string[],
35
+ slashKind: string,
36
+ references?: { openFile?: unknown; openSkill?: unknown },
37
+ ) => ReactNode
38
+
39
+ /** 宿主原语包里我们消费的那两个导出(其余一律不碰)。 */
40
+ interface HostPrimitivesFace {
41
+ FileTypeIcon?: unknown
42
+ projectUserText?: unknown
43
+ }
44
+
45
+ let cached: HostPrimitivesFace | null | undefined
46
+
47
+ /** 取一次宿主原语包(**每个进程只试一次**,失败记 null 不再重试)。 */
48
+ function hostPrimitives(): HostPrimitivesFace | null {
49
+ if (cached !== undefined) return cached
50
+ cached = null
51
+ try {
52
+ const loaded = (typeof require === 'function' ? require('@deepseek-ai/dsh-client-ui-primitives') : null) as
53
+ | HostPrimitivesFace
54
+ | null
55
+ if (loaded !== null && typeof loaded === 'object') cached = loaded
56
+ } catch {
57
+ cached = null
58
+ }
59
+ return cached
60
+ }
61
+
62
+ /** DSH 自带的文件类型图标(拿不到回 null,调用方回落自己的画法)。 */
63
+ export function hostFileTypeIcon(): ComponentType<DshFileTypeIconProps> | null {
64
+ const icon = hostPrimitives()?.FileTypeIcon
65
+ if (typeof icon === 'function' || typeof icon === 'object') return icon as ComponentType<DshFileTypeIconProps>
66
+ return null
67
+ }
68
+
69
+ /** DSH 自带的用户正文投影(把会话引用/斜杠名渲染成 chip;拿不到回 null)。 */
70
+ export function hostUserTextProjector(): DshUserTextProjector | null {
71
+ const project = hostPrimitives()?.projectUserText
72
+ return typeof project === 'function' ? (project as DshUserTextProjector) : null
73
+ }
74
+
75
+ /** 诊断 / 单测用:清掉缓存(生产路径不需要 —— 缓存就是为了「只试一次」)。 */
76
+ export function resetHostPrimitivesCache(): void {
77
+ cached = undefined
78
+ }
@@ -75,6 +75,16 @@ body {
75
75
  --cm-line-1: var(--dsw-alias-border-l1);
76
76
  --cm-line-2: var(--dsw-alias-border-l2);
77
77
  --cm-input-bg: var(--dsw-specific-input-major);
78
+ /* 聊天流「用户消息气泡」的底色(2026-10-15 修「浅色主题下气泡看不见」):
79
+ **必须跟随宿主自己的那一档** —— 宿主同一条气泡写的就是 --dsw-specific-bubble
80
+ (浅色 = --dsw-static-deepseek-50 = #edf3fe;深色 = --dsw-static-neutral-bluish-850)。
81
+ 此前我们复刻时借用了 --cm-surface-2(= --dsw-alias-bg-layer-2),浅色下它 = #fff,
82
+ 而页面底色 --dsw-alias-bg-base 也是 #fff ⇒ 气泡与页面同色、看起来「没有气泡」;
83
+ 深色下两者恰好同值(bluish-850)所以只在浅色暴露。
84
+ 兜底**不能**再用 bg-layer-2(浅色下它也是 #fff ⇒ 气泡照样消失,等于没兜底):改成一档
85
+ 中性 scrim(label 色的 12% tint,两个主题下都看得见)—— 做法抄 dsh-turn-rewind 生态里的
86
+ 同款兜底 `var(--dsw-specific-bubble, rgba(127,127,127,.14))`,只是按本仓规则走 color-mix + token。 */
87
+ --cm-chat-bubble: var(--dsw-specific-bubble, color-mix(in srgb, var(--cm-text-1) 12%, transparent));
78
88
  --cm-accent: var(--dsw-alias-state-business-primary);
79
89
  --cm-accent-fill: var(--dsw-alias-button-info-fill);
80
90
  --cm-accent-hover: var(--dsw-alias-button-info-hover);
@@ -4410,14 +4420,6 @@ body {
4410
4420
  margin: 0;
4411
4421
  }
4412
4422
 
4413
- .diffPaneFill .diffScroll {
4414
- flex: 1 1 auto;
4415
- min-height: 0;
4416
- /* 兜底:宿主没给定高时 height:100% 会退化成内容高度,那时至少别超过视口
4417
- (100vh 减标签条与面板内边距 ≈ 90px,与 .dialogCard 的 vh 口径同一风格)。定高宿主下这条不生效。 */
4418
- max-height: calc(100vh - 90px);
4419
- }
4420
-
4421
4423
  /* 差异正文的高度:**吃满可用高度**,不再套列表那条 380px 限高
4422
4424
  (2026-10-14 用户实测:22px 行高下一屏只看到 10 行 —— 380px 是给「清单」定的量级,
4423
4425
  差异视图是这一屏的主内容,尺寸本该随窗口走)。上限 800px 与市场审阅步内区同一档,
@@ -4432,6 +4434,17 @@ body {
4432
4434
  max-height: 380px;
4433
4435
  }
4434
4436
 
4437
+ /* 侧栏标签页(fill)的正文高度:**必须排在 .diffPane .diffScroll 之后** —— 两者特异性都是 (0,2,0),
4438
+ 顺序决定胜负;写在前面时那条 min(80vh, 800px) 会把这里的 max-height 盖掉,
4439
+ 侧栏比 800px / 80vh 高时差异区就停在那儿、不跟侧栏走(2026-10-15 用户:预览高度不随侧栏)。
4440
+ 这里只留「宿主没给高度」的视口兜底:宿主定高时 flex:1 自己吃满,max-height 不参与(100vh 减标签条
4441
+ 与面板内边距 ≈ 90px,与 .dialogCard 的 vh 口径同一风格)。 */
4442
+ .diffPaneFill .diffScroll {
4443
+ flex: 1 1 auto;
4444
+ min-height: 0;
4445
+ max-height: calc(100vh - 90px);
4446
+ }
4447
+
4435
4448
  .diffTable {
4436
4449
  width: 100%;
4437
4450
  border-collapse: collapse;
@@ -5118,8 +5131,9 @@ button.profileRowMain:hover {
5118
5131
  它渲染在**插件设置页子树之外**(聊天流里,不是 .section 的 DOM 子树),因此 §1 定义在 .section
5119
5132
  上的 --cm-* token 在这里**不会继承** —— .chatEntryScope 把用到的那一小撮就地重声明一次。
5120
5133
  ⚠️ 这里**不新增 token 档位**(字号/间距/圆角全部复用 §1 已冻结的档位):
5121
- 宿主用户气泡用的是 --dsw-radius-xl / --dsw-specific-bubble / 14px,本仓库 token 层没有这三档,
5122
- 所以观感差异是**已知且如实登记**的(圆角小一点、底色走 --cm-surface-2、字号 12.5px),
5134
+ 宿主用户气泡用的是 --dsw-radius-xl / --dsw-specific-bubble / 14px,本仓库 token 层没有这三档
5135
+ (**底色是唯一必须跟随宿主的**:2026-10-15 修「浅色下气泡看不见」—— 见 --cm-chat-bubble 的定义行),
5136
+ 剩余差异是**已知且如实登记**的(圆角 8px < 宿主 14px、字号 12.5px < 宿主 14px),
5123
5137
  如需逐像素一致必须先改 DESIGN.md §3–§5 —— 本任务明确不做。
5124
5138
 
5125
5139
  类名与审查口径:
@@ -5130,7 +5144,12 @@ button.profileRowMain:hover {
5130
5144
  --cm-text-1: var(--dsw-alias-label-primary);
5131
5145
  --cm-text-3: var(--dsw-alias-label-tertiary);
5132
5146
  --cm-surface-2: var(--dsw-alias-bg-layer-2);
5147
+ /* 气泡底色**不**走 --cm-surface-2(浅色下 = #fff = 页面底色 ⇒ 气泡消失),
5148
+ 跟随宿主的 --dsw-specific-bubble;兜底是可见的中性 scrim(不是 bg-layer-2)。
5149
+ 取值与理由见 §1 body 的同名定义行。 */
5150
+ --cm-chat-bubble: var(--dsw-specific-bubble, color-mix(in srgb, var(--cm-text-1) 12%, transparent));
5133
5151
  --cm-line-1: var(--dsw-alias-border-l1);
5152
+ --cm-line-2: var(--dsw-alias-border-l2);
5134
5153
  --cm-fs-meta: 11px;
5135
5154
  --cm-fs-base: 12.5px;
5136
5155
  --cm-sp-2: 4px;
@@ -5174,7 +5193,7 @@ button.profileRowMain:hover {
5174
5193
  max-width: 100%;
5175
5194
  padding: var(--cm-sp-5) var(--cm-sp-8);
5176
5195
  border-radius: var(--cm-r-3);
5177
- background: var(--cm-surface-2);
5196
+ background: var(--cm-chat-bubble);
5178
5197
  color: var(--cm-text-1);
5179
5198
  white-space: pre-wrap;
5180
5199
  word-break: break-word;
@@ -5208,7 +5227,9 @@ button.profileRowMain:hover {
5208
5227
  gap: var(--cm-sp-3);
5209
5228
  max-width: 100%;
5210
5229
  padding: var(--cm-sp-2) var(--cm-sp-4);
5211
- border: 1px solid var(--cm-line-1);
5230
+ /* 2026-10-15 与宿主对齐:宿主文件卡 = .5px solid --dsw-alias-border-l2(浅色 #0000001a ≈ 10% 黑),
5231
+ 我们此前用 --cm-line-1(= border-l1 = #0000000a ≈ 4% 黑)—— 卡片描边比宿主淡一档。 */
5232
+ border: 0.5px solid var(--cm-line-2);
5212
5233
  border-radius: var(--cm-r-2);
5213
5234
  background: var(--cm-surface-2);
5214
5235
  font-size: var(--cm-fs-meta);
@@ -53,7 +53,16 @@ export function DiskUsageCard({ api, infoHintLabel, onBackupsChanged }: DiskUsag
53
53
  const [confirmOpen, setConfirmOpen] = useState(false)
54
54
  const mountedRef = useRef(true)
55
55
 
56
- useEffect(() => () => { mountedRef.current = false }, [])
56
+ /**
57
+ * 挂载标记:只在**卸载**时置 false,并在每次挂载时显式写回 true。
58
+ * 必须写回:React 18 StrictMode 会走 `effect → cleanup → effect` 双调用,只挂 cleanup 的话
59
+ * 第二次挂载后标记永远是 false ⇒ 清理请求的 resolve/reject 被整段吞掉,
60
+ * 表现就是「点了清理,弹窗不关、按钮一直在转」。
61
+ */
62
+ useEffect(() => {
63
+ mountedRef.current = true
64
+ return () => { mountedRef.current = false }
65
+ }, [])
57
66
 
58
67
  const load = (): void => {
59
68
  setStatus('loading')
@@ -88,9 +97,19 @@ export function DiskUsageCard({ api, infoHintLabel, onBackupsChanged }: DiskUsag
88
97
  const reclaimTargets = (tmpMarket?.bytes ?? 0) + (includeExpired ? expiredExports?.bytes ?? 0 : 0)
89
98
  const nothingToReclaim = reclaimTargets === 0
90
99
 
100
+ /**
101
+ * 清理:**点确认的那一刻就关掉弹窗**,进度交给卡片上的「立即清理」按钮承载
102
+ * (loading 图标 + 文案「正在清理…」,见下方按钮)。
103
+ *
104
+ * 为什么不把 busy 留在弹窗里转圈:清理可能耗时数秒,而弹窗正好盖住卡片 ——
105
+ * 用户既看不到按钮进入 loading,也看不到释放量从有变没,观感就是「点了没反应」。
106
+ * 关窗**不等于操作可取消**:`busy` 期间刷新与清理按钮一律禁用、不会重复提交;
107
+ * 失败走 toast(卡片保持 ready,不重载列表、不掉回骨架)。
108
+ */
91
109
  const runCleanup = (): void => {
92
110
  if (busy) return
93
111
  setBusy(true)
112
+ setConfirmOpen(false)
94
113
  // categories 是**分区白名单**:没勾过期导出就一个导出文件都不碰(硬边界 ②)
95
114
  const categories: Array<'tmp' | 'expired-exports'> = ['tmp']
96
115
  if (includeExpired) categories.push('expired-exports')
@@ -98,7 +117,6 @@ export function DiskUsageCard({ api, infoHintLabel, onBackupsChanged }: DiskUsag
98
117
  (result) => {
99
118
  if (!mountedRef.current) return
100
119
  setBusy(false)
101
- setConfirmOpen(false)
102
120
  setReport(result.report)
103
121
  if (includeExpired) onBackupsChanged?.()
104
122
  // e2e-F3:呈现判定在 ui/disk-usage-view.ts(「目录不存在」不是失败);组件只按 kind 挑 toast
@@ -115,7 +133,6 @@ export function DiskUsageCard({ api, infoHintLabel, onBackupsChanged }: DiskUsag
115
133
  (err) => {
116
134
  if (!mountedRef.current) return
117
135
  setBusy(false)
118
- setConfirmOpen(false)
119
136
  toast.error(redact(err instanceof Error ? err.message : String(err)))
120
137
  },
121
138
  )
@@ -269,7 +269,13 @@ export function apply(ctx: ClientContext): void {
269
269
  * 都只丢这一个入口,绝不打挂本插件其余槽位与宿主启动。
270
270
  */
271
271
  ctx.slots.inject(CHAT_NODE_SLOT, () => registerChatNodeEntry({
272
- probe: { spec: (key) => ctx.slots.spec(key as typeof CHAT_NODE_SLOT) },
272
+ // 探测面两条都是只读:spec(形态)+ entriesOfSlot(user cell 当前赢家;老宿主可能没有该方法)。
273
+ // 必须写成**成员调用**(obj.method?.()):把方法抽出来单独调会丢 receiver —— 宿主实现里那句
274
+ // this._core.entriesOfSlot(...) 会直接抛(同类真机事故:uiWorkspace.openSession 丢 receiver,见 AGENTS.md)。
275
+ probe: {
276
+ spec: (key) => ctx.slots.spec(key as typeof CHAT_NODE_SLOT),
277
+ entriesOfSlot: (key) => (ctx.slots as unknown as { entriesOfSlot?: (slotKey: string) => unknown }).entriesOfSlot?.(key),
278
+ },
273
279
  register: () => ctx.slots.register({
274
280
  name: CHAT_NODE_SLOT,
275
281
  key: CHAT_NODE_USER_KEY,
@@ -116,7 +116,6 @@ function DiffPane({ state, t }: { state: DiffState; t: TranslateNS<'config-manag
116
116
  hunks={diff.hunks}
117
117
  beforeLabel={t('snapshots.plan.beforeLabel')}
118
118
  afterLabel={t('snapshots.plan.afterLabel')}
119
- truncatedNotice={(shown, total) => t('snapshots.plan.diffTruncated', { shown: String(shown), total: String(total) })}
120
119
  sanitize={redact}
121
120
  />
122
121
  </div>
@@ -353,7 +353,6 @@ export const zh = {
353
353
  'snapshots.plan.diffEscape': '路径越界,已拒绝读取',
354
354
  'snapshots.plan.diffNotApplicable': '该动作不是文件内容变更,无逐行对照',
355
355
  'snapshots.plan.diffDegraded': '差异过大,已按整块对比显示',
356
- 'snapshots.plan.diffTruncated': '仅显示前 {shown} 行(共 {total} 行)',
357
356
  'snapshots.plan.statSkipped.tooLarge': '体积超上限,未统计',
358
357
  'snapshots.plan.statSkipped.binary': '二进制',
359
358
  'snapshots.plan.statSkipped.budget': '未统计(文件数上限)',
@@ -1277,7 +1276,6 @@ export const en: Record<keyof typeof zh, string> = {
1277
1276
  'snapshots.plan.diffEscape': 'Path escapes the sandbox: read refused',
1278
1277
  'snapshots.plan.diffNotApplicable': 'This action is not a file content change',
1279
1278
  'snapshots.plan.diffDegraded': 'Diff too large: shown as a whole-block replacement',
1280
- 'snapshots.plan.diffTruncated': 'Showing first {shown} rows of {total}',
1281
1279
  'snapshots.plan.statSkipped.tooLarge': 'Too large to count',
1282
1280
  'snapshots.plan.statSkipped.binary': 'Binary',
1283
1281
  'snapshots.plan.statSkipped.budget': 'Not counted (file limit)',
@@ -7,6 +7,7 @@
7
7
  * · `identical` ⇒ 才说「一致」。
8
8
  * 安全:单元格正文是磁盘原文 ⇒ 展示前必过 redact()(由 DiffTable 的 sanitize 承接)。
9
9
  */
10
+ import type { ReactNode } from 'react'
10
11
  import type { TranslateNS } from '../client-types.ts'
11
12
  import type { RollbackFileDiffResult } from '../../ui/rollback-view.ts'
12
13
  import { rollbackFileDiffComparable, rollbackFileDiffReasonKey } from '../../ui/rollback-view.ts'
@@ -40,19 +41,26 @@ export interface RollbackDiffPaneProps {
40
41
  }
41
42
 
42
43
  export function RollbackDiffPane({ state, rt, title, fill }: RollbackDiffPaneProps) {
43
- if (state.status === 'loading') return <Spinner label={rt('rollback.diff.loading')} />
44
- if (state.status === 'error') return <Banner kind="error">{state.message === null ? rt('rollback.diff.failed') : redact(state.message)}</Banner>
44
+ /**
45
+ * 撑满档(只给侧栏标签页)下**每一个状态都套同一个整块容器**:只让「就绪且有差异」撑满时,
46
+ * 加载 / 一致 / 看不了 / 读失败会突然缩成一行 —— 那同样是「预览高度不跟侧栏走」,
47
+ * 而且一屏高度在四种状态间来回跳。非撑满(弹窗内的降级通道)保持原样:不留白、不加外边距。
48
+ */
49
+ const shell = (body: ReactNode): ReactNode =>
50
+ fill === true ? <div className={css.diffPane + ' ' + css.diffPaneFill}>{body}</div> : body
51
+ if (state.status === 'loading') return shell(<Spinner label={rt('rollback.diff.loading')} />)
52
+ if (state.status === 'error') return shell(<Banner kind="error">{state.message === null ? rt('rollback.diff.failed') : redact(state.message)}</Banner>)
45
53
  const diff = state.diff
46
54
  if (diff.ok !== true) {
47
55
  // 宿主结构化拒绝(未命中记录 / 存储栈不可用 …):给可读文案 + 真实 code(不裸渲染机器码,但也不藏)
48
56
  const code = diff.code === undefined || diff.code === '' ? '' : ' — ' + diff.code
49
- return <Banner kind="error">{rt('rollback.diff.failed') + code}</Banner>
57
+ return shell(<Banner kind="error">{rt('rollback.diff.failed') + code}</Banner>)
50
58
  }
51
59
  if (!rollbackFileDiffComparable(diff)) {
52
60
  const key = rollbackFileDiffReasonKey(diff.reason)
53
- return <div className={css.hint}>{key === null ? rt('rollback.diff.reason.unknown') : rt(rk(key))}</div>
61
+ return shell(<div className={css.hint}>{key === null ? rt('rollback.diff.reason.unknown') : rt(rk(key))}</div>)
54
62
  }
55
- if (diff.identical) return <div className={css.hint}>{rt('rollback.diff.identical')}</div>
63
+ if (diff.identical) return shell(<div className={css.hint}>{rt('rollback.diff.identical')}</div>)
56
64
  return (
57
65
  <div className={fill === true ? css.diffPane + ' ' + css.diffPaneFill : css.diffPane}>
58
66
  {diff.degraded && <div className={css.hint}>{rt('rollback.diff.degraded')}</div>}
@@ -61,7 +69,6 @@ export function RollbackDiffPane({ state, rt, title, fill }: RollbackDiffPanePro
61
69
  title={title}
62
70
  beforeLabel={rt('rollback.diff.before')}
63
71
  afterLabel={rt('rollback.diff.after')}
64
- truncatedNotice={(shown, total) => rt('rollback.diff.truncated', { shown: String(shown), total: String(total) })}
65
72
  sanitize={redact}
66
73
  />
67
74
  </div>
@@ -396,7 +396,7 @@ export const zh = {
396
396
  'rollback.code.fork-failed': 'fork 子会话失败,会话段未回退',
397
397
  'rollback.code.capture-failed': '该态留点失败',
398
398
  'rollback.code.capture-truncated': '该态留点到达上限被截断(已如实标注)',
399
- 'rollback.code.capture-degraded': '该态留点不完整(有链接或读不到的内容被跳过,已逐条留痕)',
399
+ 'rollback.code.capture-degraded': '该态留点不完整(有读不到或期间被改写的内容,已逐条留痕)',
400
400
  'rollback.code.no-chunks': '没有给出工作区路径分块',
401
401
  'rollback.code.chunk-not-absolute': '工作区分块必须是绝对路径',
402
402
  'rollback.code.chunk-not-found': '工作区分块不存在或不可用',
@@ -466,7 +466,6 @@ export const zh = {
466
466
  'rollback.diff.after': '回退后',
467
467
  'rollback.diff.identical': '该文件与回退点内容一致,回退不会改动它',
468
468
  'rollback.diff.degraded': '差异规模较大,已按整块替换展示(仍是完整结果,只是不是最小的编辑脚本)',
469
- 'rollback.diff.truncated': '只画了前 {shown} 行(共 {total} 行),数据本身是完整的',
470
469
  'rollback.diff.reason.missingBlob': '这份内容副本读不出来,无法逐行对照(回退仍会尝试写回它)',
471
470
  'rollback.diff.reason.binary': '不是文本文件,没有逐行差异可看',
472
471
  'rollback.diff.reason.tooLarge': '文件超过展示上限,没有逐行差异可看(回退仍会照常覆盖它)',
@@ -977,7 +976,7 @@ export const en: Record<keyof typeof zh, string> = {
977
976
  'rollback.code.fork-failed': 'Forking the child session failed; the session segment was not rolled back',
978
977
  'rollback.code.capture-failed': 'Failed to capture this state',
979
978
  'rollback.code.capture-truncated': 'This state hit the capture limit and was truncated (recorded honestly)',
980
- 'rollback.code.capture-degraded': 'This state was captured incompletely (skipped links or unreadable entries are recorded)',
979
+ 'rollback.code.capture-degraded': 'This state was captured incompletely (unreadable entries, or files rewritten while capturing, are recorded)',
981
980
  'rollback.code.no-chunks': 'No workspace path chunks were provided',
982
981
  'rollback.code.chunk-not-absolute': 'Workspace chunks must be absolute paths',
983
982
  'rollback.code.chunk-not-found': 'Workspace chunk is missing or unusable',
@@ -1032,7 +1031,6 @@ export const en: Record<keyof typeof zh, string> = {
1032
1031
  'rollback.diff.after': 'After rewind',
1033
1032
  'rollback.diff.identical': 'This file already matches the rollback point, so the rewind will not change it',
1034
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)',
1035
- 'rollback.diff.truncated': 'Only the first {shown} of {total} rows are drawn (the data itself is complete)',
1036
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)',
1037
1035
  'rollback.diff.reason.binary': 'Not a text file, so there is no line-by-line diff to show',
1038
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)',
@@ -21,6 +21,9 @@ const PANE = join(HERE, 'RollbackDiffPane.tsx')
21
21
  const TABLE = join(HERE, '../common/DiffTable.tsx')
22
22
  const TAB = join(HERE, '../chat/RollbackDiffTab.tsx')
23
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')
24
27
  const PROXY = join(HERE, '../index.ts')
25
28
 
26
29
  test('清单结构:容器 + 两类行 + 图标 + 目录弱化 + 右对齐统计(源级)', () => {
@@ -77,6 +80,20 @@ test('两处调用同一份渲染:聊天预览 + 设置页回滚卡预览(
77
80
  assert.doesNotMatch(card, /rollbackPreviewRows\(/, '预览弹窗不再画工作区/会话计数面板(与聊天预览同一屏)')
78
81
  })
79
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
+
80
97
  test('差异表文件头:只画路径文字,不画文件类型图标(源级,2026-10-14 用户第 2 次改口径)', () => {
81
98
  const table = readFileSync(TABLE, 'utf8')
82
99
  assert.doesNotMatch(table, /diffFileIcon/, '文件头不再有图标格子')
@@ -96,10 +113,29 @@ test('侧栏标签页吃满宿主高度:不再自带一道 vh/px 上限(源
96
113
  const css = readFileSync(CSS, 'utf8')
97
114
  assert.match(css, /\.diffPaneFill \{[\s\S]{0,120}?height: 100%/, 'fill 档必须 height:100% 才谈得上「= 侧栏大小」')
98
115
  assert.match(css, /\.diffPaneFill \.diffScroll \{[\s\S]{0,200}?min-height: 0/, 'flex 链必须允许收缩,否则内容顶破父容器(min-height:auto 陷阱)')
99
- assert.match(css, /\.diffPaneFill \{[\s\S]{0,400}?margin: 0/, '撑满档必须归零外边距:否则 100% 之外多出 10px,宿主面板自己冒一条滚动条')
116
+ // 撑满档整块取出来判定(块里现在还要容下 flex:auto 的依据注释,按字符窗口量距离会被注释挤爆)
117
+ const fillBlock = css.slice(css.indexOf('.diffPaneFill {'), css.indexOf('.diffPaneFill .diffScroll'))
118
+ assert.match(fillBlock, /\bmargin: 0/, '撑满档必须归零外边距:否则 100% 之外多出 10px,宿主面板自己冒一条滚动条')
100
119
  // 行盒 = 12×1.5 + 上下 1px ⇒ 20px/行:845px 宽的侧栏里 24 行折成 35 个视觉行,22px 差一行就溢出
101
120
  assert.match(css, /\.diffCell \{[\s\S]{0,80}?padding: 1px 6px/, '差异单元格上下内边距必须是 1px(20px 行高才装得进侧栏那一屏)')
102
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
+
103
139
  test('标签页芯片:[文件类型图标][文件名],只经 title 位注册(源级)', () => {
104
140
  const tab = readFileSync(TAB, 'utf8')
105
141
  assert.match(tab, /export function RollbackDiffTabTitle/, '芯片必须是独立组件(正文位与芯片位两份注册)')
@@ -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, pushPreviewView, pushReportView, readStoredChannel,
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
  });
@@ -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: '', headline: '', error: preview.message ?? t('sync.pushFailed'),
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),