@anionex/dsh-turn-rewind 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +19 -6
  2. package/README.zh.md +19 -6
  3. package/SECURITY.md +3 -1
  4. package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
  5. package/docs/assets/turn-rewind-dialog.png +0 -0
  6. package/lib/client.js +291 -22
  7. package/lib/client.js.map +1 -1
  8. package/lib/deadline.js +13 -0
  9. package/lib/engine.js +622 -121
  10. package/lib/git-checkpoint.js +739 -0
  11. package/lib/git.js +295 -22
  12. package/lib/index.js +18 -2
  13. package/lib/path-utils.js +9 -0
  14. package/lib/rewind-host.js +250 -48
  15. package/lib/settings.js +58 -0
  16. package/lib/snapshot.js +113 -13
  17. package/lib/store.js +1361 -119
  18. package/lib/types/client/index.d.ts +74 -7
  19. package/lib/types/deadline.d.ts +6 -0
  20. package/lib/types/engine.d.ts +48 -2
  21. package/lib/types/git-checkpoint.d.ts +46 -0
  22. package/lib/types/git.d.ts +31 -0
  23. package/lib/types/index.d.ts +7 -0
  24. package/lib/types/path-utils.d.ts +2 -0
  25. package/lib/types/rewind-host.d.ts +8 -1
  26. package/lib/types/settings.d.ts +44 -0
  27. package/lib/types/snapshot.d.ts +6 -1
  28. package/lib/types/store.d.ts +90 -6
  29. package/lib/types/types.d.ts +63 -1
  30. package/lib/types.js +2 -0
  31. package/lib/validate.js +68 -9
  32. package/package.json +21 -3
  33. package/scripts/benchmark-git-checkpoint.mjs +149 -0
  34. package/scripts/wrap-client.mjs +18 -0
  35. package/src/client/index.tsx +536 -41
  36. package/src/deadline.ts +18 -0
  37. package/src/engine.ts +695 -126
  38. package/src/git-checkpoint.ts +853 -0
  39. package/src/git.ts +337 -22
  40. package/src/index.ts +21 -2
  41. package/src/path-utils.ts +10 -0
  42. package/src/rewind-host.ts +278 -50
  43. package/src/settings.ts +93 -0
  44. package/src/snapshot.ts +122 -12
  45. package/src/store.ts +1502 -104
  46. package/src/types.ts +69 -1
  47. package/src/validate.ts +67 -7
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from 'react'
2
2
  import { createPortal } from 'react-dom'
3
3
  import {
4
4
  Button,
@@ -53,16 +53,16 @@ interface RewindPortalTarget {
53
53
 
54
54
  interface SlotsLike {
55
55
  inject(name: string, install: () => unknown): void
56
- register(
56
+ register<I, P>(
57
57
  entry: {
58
58
  readonly name: string
59
- readonly id: string
60
- readonly order: number
61
- readonly inject: () => {
62
- readonly openRestoredSession: (sessionId: string, promptText: string) => Promise<void>
63
- }
59
+ readonly id?: string
60
+ readonly key?: string
61
+ readonly order?: number
62
+ readonly locale?: string
63
+ readonly inject?: () => I
64
64
  },
65
- component: (props: RewindPortalBridgeProps) => ReactNode,
65
+ component: (props: P) => ReactNode,
66
66
  ): () => void
67
67
  }
68
68
 
@@ -77,10 +77,13 @@ interface ClientContextLike {
77
77
  for(scope: unknown): { setDraft(text: string): void }
78
78
  }
79
79
  }
80
+ readonly settingsScope?: {
81
+ bind<T>(spec: { readonly namespace: string }): SettingsScopeLike<T>
82
+ }
80
83
  effect(setup: () => (() => void), label?: string): unknown
81
84
  }
82
85
 
83
- type RewindMode = 'both' | 'code'
86
+ type RewindMode = 'both' | 'code' | 'messages'
84
87
  type ChangeKind = 'added' | 'deleted' | 'modified' | 'mode-changed' | 'type-changed'
85
88
 
86
89
  interface ReadyPreview {
@@ -111,9 +114,72 @@ interface ReadyPreview {
111
114
  type Preview = ReadyPreview
112
115
  | { readonly status: 'pending' }
113
116
  | { readonly status: 'missing' }
117
+ | { readonly status: 'skipped'; readonly reason: string }
114
118
  | { readonly status: 'failed'; readonly error: string }
115
119
 
120
+ /** Runtime-tunable Turn Rewind settings mirrored from the `turn-rewind` namespace. */
121
+ export interface TurnRewindSettingsValue {
122
+ readonly maxRestorePoints: number
123
+ readonly maxTurnCheckpointsPerSession: number
124
+ readonly maxFiles: number
125
+ readonly maxFileBytes: number
126
+ readonly maxSnapshotBytes: number
127
+ readonly planTtlMs: number
128
+ readonly staleLockMs: number
129
+ readonly turnCheckpointMode: 'off' | 'git-native' | 'legacy'
130
+ readonly turnCheckpointTimeoutMs: number
131
+ readonly turnCheckpointMaxNewBytes: number
132
+ readonly turnCheckpointTrust: 'fast' | 'strict'
133
+ }
134
+
135
+ /** Browser mirror of one settings namespace, as bound by `ctx.settingsScope`. */
136
+ export interface SettingsScopeLike<T> {
137
+ getSnapshot(): SettingsScopeSnapshotLike<T>
138
+ subscribe(listener: () => void): () => void
139
+ set(field: string, value: unknown): Promise<void>
140
+ unset(field: string): Promise<void>
141
+ }
142
+
143
+ /** Sync snapshot shape shared by every settings scope. */
144
+ export interface SettingsScopeSnapshotLike<T> {
145
+ readonly status: 'loading' | 'ready' | 'unavailable'
146
+ readonly value: T | undefined
147
+ readonly base: unknown
148
+ readonly user: unknown
149
+ readonly revision: number | undefined
150
+ readonly writable: boolean
151
+ readonly mode: 'host' | 'memory'
152
+ }
153
+
154
+ /** One checkpoint row in the storage-management overview. */
155
+ export interface ManageRestorePoint {
156
+ readonly id: string
157
+ readonly kind: string
158
+ readonly format: number
159
+ readonly createdAt: number
160
+ readonly totalBytes: number
161
+ readonly fileCount: number
162
+ readonly sessionId?: string
163
+ readonly label?: string
164
+ }
165
+
166
+ /** One workspace group in the storage-management overview. */
167
+ export interface ManageWorkspace {
168
+ readonly workspace: string
169
+ readonly totalBytes: number
170
+ readonly recoveryCount: number
171
+ readonly restorePoints: readonly ManageRestorePoint[]
172
+ }
173
+
174
+ /** Storage-management overview served by `/turn-rewind/manage`. */
175
+ export interface ManageOverview {
176
+ readonly storageDir: string
177
+ readonly totalBytes: number
178
+ readonly workspaces: readonly ManageWorkspace[]
179
+ }
180
+
116
181
  const PATH = '/turn-rewind'
182
+ const MANAGE_PATH = '/turn-rewind/manage'
117
183
  const STYLE_ID = '@anionex/dsh-turn-rewind'
118
184
  const styles = `
119
185
  .dcl-rewind-tail{display:inline-flex;align-items:center;align-self:center;order:0;height:24px;margin-left:2px}
@@ -142,6 +208,36 @@ const styles = `
142
208
  .dcl-rewind-error{border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 30%,transparent);color:var(--dsw-alias-state-error-primary)}
143
209
  .dcl-rewind-backup{box-sizing:border-box;margin:0;padding:10px 12px;border-radius:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
144
210
  .dcl-rewind-retry{align-self:flex-start}
211
+ .dcl-trs-card{display:flex;flex-direction:column;gap:16px;width:100%;min-width:0}
212
+ .dcl-trs-section{display:flex;flex-direction:column;gap:10px;min-width:0}
213
+ .dcl-trs-section-title{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0}
214
+ .dcl-trs-section-title strong{color:var(--dsw-alias-label-primary);font-size:14px}
215
+ .dcl-trs-section-title-actions{display:inline-flex;align-items:center;gap:8px}
216
+ .dcl-trs-field{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;padding:8px 0;border-bottom:1px solid var(--dsw-alias-border-l1);font-size:13px}
217
+ .dcl-trs-field:last-child{border-bottom:0}
218
+ .dcl-trs-field-label{flex:1 1 200px;min-width:0}
219
+ .dcl-trs-field-label strong{display:block;color:var(--dsw-alias-label-primary);font-size:13px;font-weight:600}
220
+ .dcl-trs-field-desc{display:block;margin-top:2px;color:var(--dsw-alias-label-tertiary);font-size:12px}
221
+ .dcl-trs-field-control{display:inline-flex;align-items:center;gap:8px;flex:none}
222
+ .dcl-trs-field-control input,.dcl-trs-field-control select{box-sizing:border-box;min-width:140px;padding:4px 8px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font-size:13px}
223
+ .dcl-trs-field-control input:disabled,.dcl-trs-field-control select:disabled{opacity:.52;cursor:not-allowed}
224
+ .dcl-trs-override{flex:none;padding:1px 8px;border-radius:999px;background:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-bg-layer-0);font-size:11px;line-height:18px}
225
+ .dcl-trs-manage-total{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;overflow-wrap:anywhere}
226
+ .dcl-trs-workspace{display:flex;flex-direction:column;gap:6px;padding:10px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1)}
227
+ .dcl-trs-workspace-head{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;min-width:0}
228
+ .dcl-trs-workspace-path{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary);font-size:12px}
229
+ .dcl-trs-workspace-meta{flex:none;color:var(--dsw-alias-label-tertiary);font-size:12px}
230
+ .dcl-trs-badge{flex:none;padding:1px 8px;border-radius:999px;background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-primary);font-size:11px;line-height:18px}
231
+ .dcl-trs-points{display:flex;flex-direction:column;gap:4px;min-width:0;margin:0;padding:0;list-style:none}
232
+ .dcl-trs-point{display:flex;flex-wrap:wrap;align-items:center;gap:4px 12px;padding:6px 8px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;font-size:12px}
233
+ .dcl-trs-point time{flex:1 1 140px;min-width:0;color:var(--dsw-alias-label-secondary)}
234
+ .dcl-trs-point-kind,.dcl-trs-point-size,.dcl-trs-point-files{flex:none;color:var(--dsw-alias-label-tertiary)}
235
+ .dcl-trs-point code{flex:1 1 140px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary)}
236
+ .dcl-trs-status{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
237
+ .dcl-trs-notice{box-sizing:border-box;max-width:100%;margin:0;padding:8px 10px;overflow-wrap:anywhere;border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
238
+ .dcl-trs-notice[data-warning="true"]{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-primary)}
239
+ .dcl-trs-error{box-sizing:border-box;max-width:100%;margin:0;padding:10px 12px;overflow-wrap:anywhere;word-break:break-word;border:1px solid color-mix(in srgb,var(--dsw-alias-state-error-primary) 30%,transparent);border-radius:10px;color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}
240
+ .dcl-trs-storage{margin:0;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-tertiary);font-size:12px;overflow-wrap:anywhere}
145
241
  `
146
242
 
147
243
  /** Return the rewind anchor and editable text owned by one direct user message. */
@@ -176,6 +272,13 @@ export function apply(ctx: ClientContextLike): void {
176
272
  },
177
273
  }),
178
274
  }, RewindMessagePortals))
275
+ ctx.slots.inject('settings.plugin.item', () => ctx.slots.register({
276
+ name: 'settings.plugin.item',
277
+ key: 'turn-rewind',
278
+ inject: () => ({
279
+ scope: ctx.settingsScope?.bind<TurnRewindSettingsValue>({ namespace: 'turn-rewind' }),
280
+ }),
281
+ }, TurnRewindSettingsCard))
179
282
  }
180
283
 
181
284
  /** Session-scoped bridge that portals rewind controls into direct user-message action rows. */
@@ -228,6 +331,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
228
331
  const [completed, setCompleted] = useState<string | null>(null)
229
332
  const loadAbort = useRef<AbortController | null>(null)
230
333
  const applyPending = useRef(false)
334
+ const modeTouched = useRef(false)
231
335
 
232
336
  useEffect(() => () => {
233
337
  loadAbort.current?.abort()
@@ -246,10 +350,18 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
246
350
  const response = await fetch(`${PATH}?sessionId=${encodeURIComponent(sessionId)}&messageSeq=${String(matched.messageSeq)}`, {
247
351
  method: 'GET', headers: { accept: 'application/json' }, cache: 'no-store', signal: controller.signal,
248
352
  })
249
- const value = await responseJson(response)
250
- if (loadAbort.current === controller) setPreview(decodePreview(value))
353
+ const decoded = decodePreview(await responseJson(response))
354
+ if (loadAbort.current === controller) {
355
+ setPreview(decoded)
356
+ if (!modeTouched.current) {
357
+ setMode(decoded.status === 'ready' && decoded.totalChanges > 0 ? 'both' : 'messages')
358
+ }
359
+ }
251
360
  } catch (caught) {
252
- if (!controller.signal.aborted) setError(messageOf(caught))
361
+ if (!controller.signal.aborted) {
362
+ setPreview({ status: 'failed', error: friendlyError(caught) })
363
+ if (!modeTouched.current) setMode('messages')
364
+ }
253
365
  } finally {
254
366
  if (loadAbort.current === controller) {
255
367
  loadAbort.current = null
@@ -262,6 +374,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
262
374
  setOpen(true)
263
375
  setPreview(null)
264
376
  setMode('both')
377
+ modeTouched.current = false
265
378
  setStale(false)
266
379
  void load()
267
380
  }
@@ -274,6 +387,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
274
387
  }
275
388
  const chooseMode = (next: RewindMode): void => {
276
389
  if (applying) return
390
+ modeTouched.current = true
277
391
  setMode(next)
278
392
  setError(null)
279
393
  setCompleted(null)
@@ -284,16 +398,14 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
284
398
  const sharedBlocked = (ready?.activeSessionIds.length ?? 0) > 0
285
399
  const planMissing = hasFileChanges && ready !== null && !sharedBlocked && !driftBlocked
286
400
  && (ready.planId === undefined || ready.confirmation === undefined)
287
- const canApply = ready !== null
401
+ const canApply = preview !== null
288
402
  && !loading
289
403
  && !applying
290
404
  && !loadingDetails
291
405
  && completed === null
292
- && hasFileChanges
293
- && !driftBlocked
294
- && !sharedBlocked
295
- && !planMissing
296
- && !stale
406
+ && (mode === 'messages'
407
+ ? true
408
+ : ready !== null && hasFileChanges && !driftBlocked && !sharedBlocked && !planMissing && !stale)
297
409
 
298
410
  const loadAllChanges = async (): Promise<void> => {
299
411
  if (ready === null || loadingDetails || !ready.truncated) return
@@ -328,16 +440,18 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
328
440
  }
329
441
 
330
442
  const applyRestore = async (): Promise<void> => {
331
- if (ready === null || !canApply || applyPending.current) return
443
+ if (preview === null || !canApply || applyPending.current) return
332
444
  const body: Record<string, unknown> = {
333
445
  mode,
334
446
  sessionId,
335
- messageSeq: ready.messageSeq,
336
- checkpointId: ready.checkpointId,
447
+ messageSeq: matched.messageSeq,
448
+ }
449
+ if (mode !== 'messages') {
450
+ if (ready === null || ready.planId === undefined || ready.confirmation === undefined) return
451
+ body.checkpointId = ready.checkpointId
452
+ body.planId = ready.planId
453
+ body.confirmation = ready.confirmation
337
454
  }
338
- if (ready.planId === undefined || ready.confirmation === undefined) return
339
- body.planId = ready.planId
340
- body.confirmation = ready.confirmation
341
455
  applyPending.current = true
342
456
  setApplying(true)
343
457
  setError(null)
@@ -350,6 +464,17 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
350
464
  const result = recordOf(await responseJson(response))
351
465
  const resultMode = requiredString(result.mode, 'mode')
352
466
  if (resultMode !== mode) throw new Error(`服务器返回了不匹配的回退模式:${resultMode}`)
467
+ if (mode === 'messages') {
468
+ const childSessionId = requiredString(result.sessionId, 'sessionId')
469
+ setCompleted('已创建从这里开始的新对话;项目文件保持不变。')
470
+ try {
471
+ await openRestoredSession(childSessionId, matched.promptText)
472
+ setOpen(false)
473
+ } catch (navigationError) {
474
+ setError(`新对话已创建,但没能自动打开:${messageOf(navigationError)}`)
475
+ }
476
+ return
477
+ }
353
478
  if (mode === 'code') {
354
479
  requiredString(result.rescuePointId, 'rescuePointId')
355
480
  setCompleted('项目文件已恢复;当前对话保持不变。恢复前的文件已自动备份。')
@@ -375,7 +500,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
375
500
  }
376
501
  }
377
502
 
378
- const actionLabel = mode === 'both' ? '恢复并从这里继续' : '恢复文件'
503
+ const actionLabel = mode === 'both' ? '恢复并从这里继续' : mode === 'code' ? '恢复文件' : '只回溯消息'
379
504
  const radioName = `dcl-rewind-${sessionId}-${String(matched.messageSeq)}`
380
505
  const branchChanged = ready !== null && ready.checkpointBranch !== ready.currentBranch
381
506
 
@@ -406,23 +531,32 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
406
531
  <div className="dcl-rewind-body">
407
532
  {loading && <p className="dcl-rewind-status">正在检查可以恢复的项目文件…</p>}
408
533
  {preview?.status === 'pending' && <p className="dcl-rewind-status">这条消息发送前的文件还在保存,请稍后再试。</p>}
409
- {preview?.status === 'missing' && <p className="dcl-rewind-error">没有保存这条消息发送前的文件。可能是当时还没启用回退功能,或记录已超过保留期限。</p>}
410
- {preview?.status === 'failed' && <p className="dcl-rewind-error">没能保存这条消息发送前的文件:{preview.error}</p>}
534
+ {preview?.status === 'missing' && <p className="dcl-rewind-error">没有保存这条消息发送前的文件。可能是当时还没启用回退功能、记录已超过保留期限,或已关闭自动检查点。仍可只回溯消息。</p>}
535
+ {preview?.status === 'skipped' && <p className="dcl-rewind-status">为避免阻塞消息发送,本轮没有自动保存文件:{preview.reason}仍可只回溯消息。</p>}
536
+ {preview?.status === 'failed' && <p className="dcl-rewind-error">没能保存这条消息发送前的文件:{preview.error}仍可只回溯消息。</p>}
537
+ {preview !== null && (
538
+ <div className="dcl-rewind-options">
539
+ <label className="dcl-rewind-option" data-selected={mode === 'both'} data-disabled={applying || !hasFileChanges}>
540
+ <input type="radio" name={radioName} checked={mode === 'both'} disabled={applying || !hasFileChanges} onChange={() => { chooseMode('both') }} />
541
+ <span className="dcl-rewind-option-content"><strong>恢复文件并从这里继续</strong><span className="dcl-rewind-option-description">创建一个从这里开始的新会话(当前对话会保留)</span></span>
542
+ </label>
543
+ <label className="dcl-rewind-option" data-selected={mode === 'code'} data-disabled={applying || !hasFileChanges}>
544
+ <input type="radio" name={radioName} checked={mode === 'code'} disabled={applying || !hasFileChanges} onChange={() => { chooseMode('code') }} />
545
+ <span className="dcl-rewind-option-content"><strong>只恢复文件</strong><span className="dcl-rewind-option-description">恢复这条消息发送前的文件,当前对话保持不变。</span></span>
546
+ </label>
547
+ <label className="dcl-rewind-option" data-selected={mode === 'messages'} data-disabled={applying}>
548
+ <input type="radio" name={radioName} checked={mode === 'messages'} disabled={applying} onChange={() => { chooseMode('messages') }} />
549
+ <span className="dcl-rewind-option-content"><strong>只回溯消息(不动文件)</strong><span className="dcl-rewind-option-description">创建一个从这里开始的新会话,项目文件保持当前状态。</span></span>
550
+ </label>
551
+ </div>
552
+ )}
411
553
  {ready !== null && (
412
554
  <>
413
- <div className="dcl-rewind-options">
414
- <label className="dcl-rewind-option" data-selected={mode === 'both'} data-disabled={applying}>
415
- <input type="radio" name={radioName} checked={mode === 'both'} disabled={applying} onChange={() => { chooseMode('both') }} />
416
- <span className="dcl-rewind-option-content"><strong>恢复文件并从这里继续</strong><span className="dcl-rewind-option-description">创建一个从这里开始的新会话(当前对话会保留)</span></span>
417
- </label>
418
- <label className="dcl-rewind-option" data-selected={mode === 'code'} data-disabled={applying}>
419
- <input type="radio" name={radioName} checked={mode === 'code'} disabled={applying} onChange={() => { chooseMode('code') }} />
420
- <span className="dcl-rewind-option-content"><strong>只恢复文件</strong><span className="dcl-rewind-option-description">恢复这条消息发送前的文件,当前对话保持不变。</span></span>
421
- </label>
422
- </div>
423
555
  <div className="dcl-rewind-summary">
424
- <strong>将恢复 {String(ready.totalChanges)} 个文件</strong>
425
- <span>{mode === 'both' ? '恢复后在新对话里继续' : '当前对话保持不变'}</span>
556
+ {mode === 'messages'
557
+ ? <strong>项目文件保持不变</strong>
558
+ : <strong>将恢复 {String(ready.totalChanges)} 个文件</strong>}
559
+ <span>{mode === 'both' ? '恢复后在新对话里继续' : mode === 'code' ? '当前对话保持不变' : '仅创建从这里继续的新对话'}</span>
426
560
  </div>
427
561
  {sharedBlocked && (
428
562
  <p className="dcl-rewind-error">这个项目目录还有别的对话正在运行。恢复文件会影响到它们,因此本次操作已被阻止。请等那些对话结束或停止后,再重新检查。</p>
@@ -435,7 +569,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
435
569
  {driftBlocked && <p className="dcl-rewind-warning">Git 正在进行合并、变基或类似操作。请先完成或取消这次 Git 操作,再重新检查。</p>}
436
570
  {planMissing && <p className="dcl-rewind-error">恢复信息已经失效,请重新检查。</p>}
437
571
  {stale && <p className="dcl-rewind-error">项目文件在检查后又发生了变化。为避免覆盖新修改,这次恢复已失效,请重新检查。</p>}
438
- {ready.totalChanges === 0 && <p className="dcl-rewind-status">项目文件已经是这条消息发送前的状态,无需恢复。想重新开始时,可以使用“分支新对话”。</p>}
572
+ {ready.totalChanges === 0 && <p className="dcl-rewind-status">项目文件已经是这条消息发送前的状态,无需恢复文件。可选择「只回溯消息」重新开始这段对话。</p>}
439
573
  {ready.changes.length > 0 && (
440
574
  <div className="dcl-rewind-files">
441
575
  {ready.changes.map(change => <div className="dcl-rewind-file" key={change.path}><code>{change.path}</code><span className="dcl-rewind-kind">{fileRecoveryLabel(change.kind)}</span></div>)}
@@ -456,10 +590,371 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
456
590
  )
457
591
  }
458
592
 
593
+ interface TurnRewindSettingsCardProps {
594
+ readonly scope: SettingsScopeLike<TurnRewindSettingsValue> | undefined
595
+ }
596
+
597
+ interface ManageActionNotice {
598
+ readonly warning: boolean
599
+ readonly message: string
600
+ }
601
+
602
+ const EMPTY_SETTINGS_SNAPSHOT: SettingsScopeSnapshotLike<TurnRewindSettingsValue> = {
603
+ status: 'unavailable', value: undefined, base: undefined, user: undefined, revision: undefined, writable: false, mode: 'host',
604
+ }
605
+
606
+ type NumberSettingsField = keyof Pick<TurnRewindSettingsValue,
607
+ 'maxRestorePoints' | 'maxTurnCheckpointsPerSession' | 'maxFiles' | 'maxFileBytes' | 'maxSnapshotBytes'
608
+ | 'planTtlMs' | 'staleLockMs' | 'turnCheckpointTimeoutMs' | 'turnCheckpointMaxNewBytes'>
609
+
610
+ const NUMBER_FIELDS: readonly { readonly key: NumberSettingsField; readonly label: string; readonly description: string }[] = [
611
+ { key: 'maxRestorePoints', label: '用户/救援恢复点上限', description: '每个工作区保留的最大手动与救援恢复点数量' },
612
+ { key: 'maxTurnCheckpointsPerSession', label: '轮次检查点上限', description: '每个会话保留的最大自动轮次检查点数量(最旧的先清理)' },
613
+ { key: 'maxFiles', label: '单恢复点文件数上限', description: '一个恢复点最多纳入的文件数量' },
614
+ { key: 'maxFileBytes', label: '单文件大小上限', description: '读取单个普通文件的最大字节数' },
615
+ { key: 'maxSnapshotBytes', label: '快照总量上限', description: '单个恢复点读取的最大字节总量' },
616
+ { key: 'planTtlMs', label: '恢复计划有效期(毫秒)', description: '回溯计划从创建到失效的时间' },
617
+ { key: 'staleLockMs', label: '锁回收时长(毫秒)', description: '锁属主消失多久后允许回收该锁' },
618
+ { key: 'turnCheckpointTimeoutMs', label: '检查点超时(毫秒)', description: '单次自动检查点最多阻塞消息发送的时间,超时记录跳过' },
619
+ { key: 'turnCheckpointMaxNewBytes', label: '检查点读取上限', description: '单次 Git 原生检查点最多读取的未缓存字节数' },
620
+ ]
621
+
622
+ const CHECKPOINT_MODE_LABELS: Readonly<Record<TurnRewindSettingsValue['turnCheckpointMode'], string>> = {
623
+ off: '关闭(不创建文件检查点)',
624
+ 'git-native': 'Git 原生(推荐大仓库)',
625
+ legacy: '完整快照(兼容模式)',
626
+ }
627
+
628
+ const TRUST_LABELS: Readonly<Record<TurnRewindSettingsValue['turnCheckpointTrust'], string>> = {
629
+ fast: '快速',
630
+ strict: '严格',
631
+ }
632
+
633
+ const POINT_KIND_LABELS: Readonly<Record<string, string>> = {
634
+ user: '手动',
635
+ rescue: '救援',
636
+ turn: '轮次',
637
+ }
638
+
639
+ /** Settings card for the `turn-rewind` namespace: runtime options plus checkpoint management. */
640
+ export function TurnRewindSettingsCard({ scope }: TurnRewindSettingsCardProps): ReactNode {
641
+ const snapshot = useSyncExternalStore(
642
+ useCallback((notify: () => void) => scope?.subscribe(notify) ?? (() => {}), [scope]),
643
+ useCallback(() => scope?.getSnapshot() ?? EMPTY_SETTINGS_SNAPSHOT, [scope]),
644
+ )
645
+ const [manage, setManage] = useState<ManageOverview | null>(null)
646
+ const [manageLoading, setManageLoading] = useState(false)
647
+ const [manageError, setManageError] = useState<string | null>(null)
648
+ const [manageNotice, setManageNotice] = useState<ManageActionNotice | null>(null)
649
+ const [formError, setFormError] = useState<string | null>(null)
650
+ const [busy, setBusy] = useState<string | null>(null)
651
+ const [drafts, setDrafts] = useState<Partial<Record<NumberSettingsField, string>>>({})
652
+ const [confirmClearAll, setConfirmClearAll] = useState(false)
653
+ const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
654
+
655
+ const refreshManage = useCallback(async (): Promise<void> => {
656
+ setManageLoading(true)
657
+ setManageError(null)
658
+ try {
659
+ const response = await fetch(MANAGE_PATH, { method: 'GET', headers: { accept: 'application/json' }, cache: 'no-store' })
660
+ setManage(decodeManageOverview(await responseJson(response)))
661
+ } catch (caught) {
662
+ setManageError(messageOf(caught))
663
+ } finally {
664
+ setManageLoading(false)
665
+ }
666
+ }, [])
667
+
668
+ useEffect(() => { void refreshManage() }, [refreshManage])
669
+
670
+ const userLayer = snapshot.user !== null && typeof snapshot.user === 'object' ? snapshot.user as Record<string, unknown> : {}
671
+ const value = snapshot.value
672
+ const writable = snapshot.writable && snapshot.status === 'ready'
673
+
674
+ const commitEnum = (field: string, next: string): void => {
675
+ if (scope === undefined || !writable) return
676
+ setFormError(null)
677
+ void scope.set(field, next).catch((caught: unknown) => { setFormError(messageOf(caught)) })
678
+ }
679
+
680
+ const commitNumber = (field: NumberSettingsField): void => {
681
+ if (scope === undefined || !writable) return
682
+ const draft = drafts[field]
683
+ if (draft === undefined) return
684
+ setDrafts((current) => { const next = { ...current }; delete next[field]; return next })
685
+ if (draft.trim() === '') {
686
+ void scope.unset(field).catch((caught: unknown) => { setFormError(messageOf(caught)) })
687
+ return
688
+ }
689
+ const parsed = Number.parseInt(draft, 10)
690
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
691
+ setFormError(`${field} 必须是正整数。`)
692
+ return
693
+ }
694
+ if (parsed === value?.[field]) return
695
+ setFormError(null)
696
+ void scope.set(field, parsed).catch((caught: unknown) => { setFormError(messageOf(caught)) })
697
+ }
698
+
699
+ const runManageAction = async (body: Record<string, unknown>, key: string): Promise<void> => {
700
+ if (busy !== null) return
701
+ setBusy(key)
702
+ setManageError(null)
703
+ setManageNotice(null)
704
+ setConfirmClearAll(false)
705
+ try {
706
+ const result = await responseJson(await fetch(MANAGE_PATH, {
707
+ method: 'POST',
708
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
709
+ body: JSON.stringify(body),
710
+ }))
711
+ setManageNotice(decodeManageActionNotice(result))
712
+ await refreshManage()
713
+ } catch (caught) {
714
+ setManageError(messageOf(caught))
715
+ } finally {
716
+ setBusy(null)
717
+ }
718
+ }
719
+
720
+ const toggleWorkspace = (workspace: string): void => {
721
+ setCollapsed((current) => {
722
+ const next = new Set(current)
723
+ if (next.has(workspace)) next.delete(workspace)
724
+ else next.add(workspace)
725
+ return next
726
+ })
727
+ }
728
+
729
+ return (
730
+ <div className="dcl-trs-card">
731
+ <section className="dcl-trs-section">
732
+ <div className="dcl-trs-section-title">
733
+ <strong>Turn Rewind 回退设置</strong>
734
+ </div>
735
+ {snapshot.status === 'loading' && <p className="dcl-trs-status">正在加载设置…</p>}
736
+ {snapshot.status === 'unavailable' && <p className="dcl-trs-status">当前部署未提供设置服务,以下选项不可用。</p>}
737
+ {snapshot.status === 'ready' && !snapshot.writable && <p className="dcl-trs-status">设置为只读(当前浏览器进程内保存),修改不可用。</p>}
738
+ {value !== undefined && (
739
+ <>
740
+ <div className="dcl-trs-field">
741
+ <span className="dcl-trs-field-label"><strong>自动文件检查点</strong>
742
+ <span className="dcl-trs-field-desc">关闭后不再为每条消息保存文件检查点;回退弹窗仍可只回溯消息</span></span>
743
+ <span className="dcl-trs-field-control">
744
+ <select
745
+ value={value.turnCheckpointMode}
746
+ disabled={!writable || busy !== null}
747
+ onChange={(event) => { commitEnum('turnCheckpointMode', event.target.value) }}
748
+ >
749
+ {(Object.keys(CHECKPOINT_MODE_LABELS) as TurnRewindSettingsValue['turnCheckpointMode'][]).map((option) => (
750
+ <option key={option} value={option}>{CHECKPOINT_MODE_LABELS[option]}</option>
751
+ ))}
752
+ </select>
753
+ {userLayer.turnCheckpointMode !== undefined && <span className="dcl-trs-override">已覆盖</span>}
754
+ {userLayer.turnCheckpointMode !== undefined && (
755
+ <Button variant="ghost" size="sm" disabled={!writable || busy !== null}
756
+ onClick={() => { setFormError(null); void scope?.unset('turnCheckpointMode').catch((caught: unknown) => { setFormError(messageOf(caught)) }) }}>恢复默认</Button>
757
+ )}
758
+ </span>
759
+ </div>
760
+ <div className="dcl-trs-field">
761
+ <span className="dcl-trs-field-label"><strong>检查点信任策略</strong>
762
+ <span className="dcl-trs-field-desc">快速信任 Git/stat 元数据;严格会逐一重读文件内容</span></span>
763
+ <span className="dcl-trs-field-control">
764
+ <select
765
+ value={value.turnCheckpointTrust}
766
+ disabled={!writable || busy !== null}
767
+ onChange={(event) => { commitEnum('turnCheckpointTrust', event.target.value) }}
768
+ >
769
+ {(Object.keys(TRUST_LABELS) as TurnRewindSettingsValue['turnCheckpointTrust'][]).map((option) => (
770
+ <option key={option} value={option}>{TRUST_LABELS[option]}</option>
771
+ ))}
772
+ </select>
773
+ {userLayer.turnCheckpointTrust !== undefined && <span className="dcl-trs-override">已覆盖</span>}
774
+ {userLayer.turnCheckpointTrust !== undefined && (
775
+ <Button variant="ghost" size="sm" disabled={!writable || busy !== null}
776
+ onClick={() => { setFormError(null); void scope?.unset('turnCheckpointTrust').catch((caught: unknown) => { setFormError(messageOf(caught)) }) }}>恢复默认</Button>
777
+ )}
778
+ </span>
779
+ </div>
780
+ {NUMBER_FIELDS.map((field) => (
781
+ <div className="dcl-trs-field" key={field.key}>
782
+ <span className="dcl-trs-field-label"><strong>{field.label}</strong>
783
+ <span className="dcl-trs-field-desc">{field.description}</span></span>
784
+ <span className="dcl-trs-field-control">
785
+ <input
786
+ type="number" min={1} step={1}
787
+ value={drafts[field.key] ?? String(value[field.key])}
788
+ disabled={!writable || busy !== null}
789
+ onChange={(event) => { setDrafts((current) => ({ ...current, [field.key]: event.target.value })) }}
790
+ onBlur={() => { commitNumber(field.key) }}
791
+ onKeyDown={(event) => { if (event.key === 'Enter') commitNumber(field.key) }}
792
+ />
793
+ {userLayer[field.key] !== undefined && <span className="dcl-trs-override">已覆盖</span>}
794
+ {userLayer[field.key] !== undefined && (
795
+ <Button variant="ghost" size="sm" disabled={!writable || busy !== null}
796
+ onClick={() => { setFormError(null); void scope?.unset(field.key).catch((caught: unknown) => { setFormError(messageOf(caught)) }) }}>恢复默认</Button>
797
+ )}
798
+ </span>
799
+ </div>
800
+ ))}
801
+ </>
802
+ )}
803
+ {formError !== null && <p className="dcl-trs-error">{formError}</p>}
804
+ <p className="dcl-trs-storage">存储目录(在 cordis.patch.yml 中配置,不可在线修改):{manage?.storageDir ?? '…'}</p>
805
+ </section>
806
+ <section className="dcl-trs-section">
807
+ <div className="dcl-trs-section-title">
808
+ <strong>检查点管理</strong>
809
+ <span className="dcl-trs-section-title-actions">
810
+ <Button variant="outline" size="sm" onClick={() => { void refreshManage() }} disabled={manageLoading}>
811
+ {manageLoading ? '正在刷新…' : '刷新'}
812
+ </Button>
813
+ {manage !== null && manage.workspaces.length > 0 && (
814
+ confirmClearAll
815
+ ? (
816
+ <>
817
+ <Button variant="outline" size="sm" onClick={() => { setConfirmClearAll(false) }} disabled={busy !== null}>取消</Button>
818
+ <Button variant="primary" size="sm" onClick={() => { void runManageAction({ action: 'clear-all' }, 'clear-all') }} disabled={busy !== null}>
819
+ {busy === 'clear-all' ? '正在清理…' : '确认清空全部'}
820
+ </Button>
821
+ </>
822
+ )
823
+ : <Button variant="outline" size="sm" onClick={() => { setConfirmClearAll(true) }} disabled={busy !== null}>一键清空全部</Button>
824
+ )}
825
+ </span>
826
+ </div>
827
+ <p className="dcl-trs-manage-total">
828
+ {manage === null
829
+ ? '正在读取检查点占用…'
830
+ : `共 ${String(manage.workspaces.length)} 个工作区,${String(manage.totalBytes >= 0 ? manage.workspaces.reduce((total, workspace) => total + workspace.restorePoints.length, 0) : 0)} 个检查点,约 ${formatBytes(manage.totalBytes)}(Git 原生检查点的实际磁盘占用以 Git 回收为准)。`}
831
+ </p>
832
+ {manageNotice !== null && <p className="dcl-trs-notice" data-warning={manageNotice.warning}>{manageNotice.message}</p>}
833
+ {manageError !== null && <p className="dcl-trs-error">{manageError}</p>}
834
+ {manage?.workspaces.map((workspace) => (
835
+ <div className="dcl-trs-workspace" key={workspace.workspace}>
836
+ <div className="dcl-trs-workspace-head">
837
+ <span className="dcl-trs-workspace-path" title={workspace.workspace}>{workspace.workspace}</span>
838
+ <span className="dcl-trs-workspace-meta">{String(workspace.restorePoints.length)} 个检查点 · {formatBytes(workspace.totalBytes)}</span>
839
+ {workspace.recoveryCount > 0 && <span className="dcl-trs-badge">{String(workspace.recoveryCount)} 个恢复待处理</span>}
840
+ <Button variant="ghost" size="sm" onClick={() => { toggleWorkspace(workspace.workspace) }}>
841
+ {collapsed.has(workspace.workspace) ? '展开' : '收起'}
842
+ </Button>
843
+ <Button variant="outline" size="sm"
844
+ onClick={() => { void runManageAction({ action: 'clear-workspace', workspace: workspace.workspace }, `clear:${workspace.workspace}`) }}
845
+ disabled={busy !== null || workspace.restorePoints.length === 0}>
846
+ {busy === `clear:${workspace.workspace}` ? '正在清理…' : '清空此项目'}
847
+ </Button>
848
+ </div>
849
+ {!collapsed.has(workspace.workspace) && workspace.restorePoints.length > 0 && (
850
+ <ul className="dcl-trs-points">
851
+ {workspace.restorePoints.map((point) => (
852
+ <li className="dcl-trs-point" key={point.id}>
853
+ <time>{formatTime(point.createdAt)}</time>
854
+ <span className="dcl-trs-point-kind">{POINT_KIND_LABELS[point.kind] ?? point.kind}</span>
855
+ <span className="dcl-trs-point-size">{formatBytes(point.totalBytes)}</span>
856
+ <span className="dcl-trs-point-files">{String(point.fileCount)} 个文件</span>
857
+ {point.sessionId !== undefined && <code>{point.sessionId}</code>}
858
+ <Button variant="ghost" size="sm"
859
+ onClick={() => { void runManageAction({ action: 'delete', workspace: workspace.workspace, restorePointId: point.id }, `delete:${point.id}`) }}
860
+ disabled={busy !== null}>
861
+ {busy === `delete:${point.id}` ? '正在删除…' : '删除'}
862
+ </Button>
863
+ </li>
864
+ ))}
865
+ </ul>
866
+ )}
867
+ </div>
868
+ ))}
869
+ {manage !== null && manage.workspaces.length === 0 && <p className="dcl-trs-status">还没有任何已保存的检查点。</p>}
870
+ </section>
871
+ </div>
872
+ )
873
+ }
874
+
875
+ function decodeManageOverview(value: unknown): ManageOverview {
876
+ const record = recordOf(value)
877
+ const workspacesValue = record.workspaces
878
+ if (!Array.isArray(workspacesValue)) throw new Error('管理数据缺少 workspaces')
879
+ return {
880
+ storageDir: requiredString(record.storageDir, 'storageDir'),
881
+ totalBytes: requiredInteger(record.totalBytes, 'totalBytes'),
882
+ workspaces: workspacesValue.map((entry) => {
883
+ const workspace = recordOf(entry)
884
+ const pointsValue = workspace.restorePoints
885
+ if (!Array.isArray(pointsValue)) throw new Error('管理数据缺少 restorePoints')
886
+ return {
887
+ workspace: requiredString(workspace.workspace, 'workspace'),
888
+ totalBytes: requiredInteger(workspace.totalBytes, 'totalBytes'),
889
+ recoveryCount: requiredInteger(workspace.recoveryCount, 'recoveryCount'),
890
+ restorePoints: pointsValue.map((pointEntry) => {
891
+ const point = recordOf(pointEntry)
892
+ return {
893
+ id: requiredString(point.id, 'id'),
894
+ kind: requiredString(point.kind, 'kind'),
895
+ format: requiredInteger(point.format, 'format'),
896
+ createdAt: requiredInteger(point.createdAt, 'createdAt'),
897
+ totalBytes: requiredInteger(point.totalBytes, 'totalBytes'),
898
+ fileCount: requiredInteger(point.fileCount, 'fileCount'),
899
+ ...optionalRecordString(point, 'sessionId'),
900
+ ...optionalRecordString(point, 'label'),
901
+ }
902
+ }),
903
+ }
904
+ }),
905
+ }
906
+ }
907
+
908
+ function decodeManageActionNotice(value: unknown): ManageActionNotice {
909
+ const record = recordOf(value)
910
+ const action = requiredString(record.action, 'action')
911
+ if (action === 'clear-all') {
912
+ if (!Array.isArray(record.reports)) throw new Error('清理结果缺少 reports')
913
+ const totals = record.reports.reduce((current, entry) => {
914
+ const report = recordOf(entry)
915
+ return {
916
+ deleted: current.deleted + requiredInteger(report.deletedRestorePoints, 'deletedRestorePoints'),
917
+ retained: current.retained + requiredInteger(report.retainedRestorePoints, 'retainedRestorePoints'),
918
+ }
919
+ }, { deleted: 0, retained: 0 })
920
+ const failures = Array.isArray(record.failures) ? record.failures.length : 0
921
+ const warning = record.status === 'partial' || totals.retained > 0 || failures > 0
922
+ return {
923
+ warning,
924
+ message: `已删除 ${String(totals.deleted)} 个检查点${totals.retained > 0 ? `,${String(totals.retained)} 个受保护检查点未删除` : ''}${failures > 0 ? `,${String(failures)} 个工作区清理失败` : ''}。`,
925
+ }
926
+ }
927
+ const deleted = requiredInteger(record.deletedRestorePoints, 'deletedRestorePoints')
928
+ const retained = requiredInteger(record.retainedRestorePoints, 'retainedRestorePoints')
929
+ return {
930
+ warning: retained > 0,
931
+ message: `已删除 ${String(deleted)} 个检查点${retained > 0 ? `,${String(retained)} 个受保护检查点未删除` : ''}。`,
932
+ }
933
+ }
934
+
935
+ /** Format one byte count with human-friendly units. */
936
+ export function formatBytes(bytes: number): string {
937
+ if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
938
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
939
+ let value = bytes
940
+ let unit = 0
941
+ while (value >= 1024 && unit < units.length - 1) {
942
+ value /= 1024
943
+ unit += 1
944
+ }
945
+ return `${unit === 0 ? String(Math.round(value)) : value.toFixed(value >= 100 ? 0 : 1)} ${units[unit]}`
946
+ }
947
+
948
+ function formatTime(timestamp: number): string {
949
+ const date = new Date(timestamp)
950
+ return Number.isNaN(date.getTime()) ? String(timestamp) : date.toLocaleString()
951
+ }
952
+
459
953
  function decodePreview(value: unknown): Preview {
460
954
  const record = recordOf(value)
461
955
  const status = requiredString(record.status, 'status')
462
956
  if (status === 'pending' || status === 'missing') return { status }
957
+ if (status === 'skipped') return { status, reason: requiredString(record.reason, 'reason') }
463
958
  if (status === 'failed') return { status, error: requiredString(record.error, 'error') }
464
959
  if (status !== 'ready') throw new Error(`未知回退状态:${status}`)
465
960
  const changesValue = record.changes
@@ -636,7 +1131,7 @@ function friendlyError(error: unknown): string {
636
1131
  case 'REPOSITORY_CHANGED': return '这个项目目录已不属于原来的 Git 工作区,无法恢复。'
637
1132
  case 'GIT_OPERATION_CHANGED': return 'Git 正在执行其他操作。请先完成或取消该操作,再重新检查。'
638
1133
  case 'RESTORE_POINT_NOT_FOUND': return '没有找到对应的文件状态,可能已被清理。'
639
- case 'NO_CHANGES': return '项目文件已经是这条消息发送前的状态,无需恢复。想重新开始时,可以使用“分支新对话”。'
1134
+ case 'NO_CHANGES': return '项目文件已经是这条消息发送前的状态,无需恢复文件。可选择「只回溯消息」重新开始这段对话。'
640
1135
  case 'RESTORE_FAILED_ROLLED_BACK': return '恢复未能完成,项目文件已自动还原到操作前的状态。'
641
1136
  case 'CONVERSATION_REWIND_FAILED': return '文件已恢复,但无法创建新对话;项目文件已自动还原。'
642
1137
  default: return error.message