@anionex/dsh-turn-rewind 0.1.2 → 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.
@@ -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 {
@@ -114,7 +117,69 @@ type Preview = ReadyPreview
114
117
  | { readonly status: 'skipped'; readonly reason: string }
115
118
  | { readonly status: 'failed'; readonly error: string }
116
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
+
117
181
  const PATH = '/turn-rewind'
182
+ const MANAGE_PATH = '/turn-rewind/manage'
118
183
  const STYLE_ID = '@anionex/dsh-turn-rewind'
119
184
  const styles = `
120
185
  .dcl-rewind-tail{display:inline-flex;align-items:center;align-self:center;order:0;height:24px;margin-left:2px}
@@ -143,6 +208,36 @@ const styles = `
143
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)}
144
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}
145
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}
146
241
  `
147
242
 
148
243
  /** Return the rewind anchor and editable text owned by one direct user message. */
@@ -177,6 +272,13 @@ export function apply(ctx: ClientContextLike): void {
177
272
  },
178
273
  }),
179
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))
180
282
  }
181
283
 
182
284
  /** Session-scoped bridge that portals rewind controls into direct user-message action rows. */
@@ -229,6 +331,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
229
331
  const [completed, setCompleted] = useState<string | null>(null)
230
332
  const loadAbort = useRef<AbortController | null>(null)
231
333
  const applyPending = useRef(false)
334
+ const modeTouched = useRef(false)
232
335
 
233
336
  useEffect(() => () => {
234
337
  loadAbort.current?.abort()
@@ -247,10 +350,18 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
247
350
  const response = await fetch(`${PATH}?sessionId=${encodeURIComponent(sessionId)}&messageSeq=${String(matched.messageSeq)}`, {
248
351
  method: 'GET', headers: { accept: 'application/json' }, cache: 'no-store', signal: controller.signal,
249
352
  })
250
- const value = await responseJson(response)
251
- 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
+ }
252
360
  } catch (caught) {
253
- 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
+ }
254
365
  } finally {
255
366
  if (loadAbort.current === controller) {
256
367
  loadAbort.current = null
@@ -263,6 +374,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
263
374
  setOpen(true)
264
375
  setPreview(null)
265
376
  setMode('both')
377
+ modeTouched.current = false
266
378
  setStale(false)
267
379
  void load()
268
380
  }
@@ -275,6 +387,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
275
387
  }
276
388
  const chooseMode = (next: RewindMode): void => {
277
389
  if (applying) return
390
+ modeTouched.current = true
278
391
  setMode(next)
279
392
  setError(null)
280
393
  setCompleted(null)
@@ -285,16 +398,14 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
285
398
  const sharedBlocked = (ready?.activeSessionIds.length ?? 0) > 0
286
399
  const planMissing = hasFileChanges && ready !== null && !sharedBlocked && !driftBlocked
287
400
  && (ready.planId === undefined || ready.confirmation === undefined)
288
- const canApply = ready !== null
401
+ const canApply = preview !== null
289
402
  && !loading
290
403
  && !applying
291
404
  && !loadingDetails
292
405
  && completed === null
293
- && hasFileChanges
294
- && !driftBlocked
295
- && !sharedBlocked
296
- && !planMissing
297
- && !stale
406
+ && (mode === 'messages'
407
+ ? true
408
+ : ready !== null && hasFileChanges && !driftBlocked && !sharedBlocked && !planMissing && !stale)
298
409
 
299
410
  const loadAllChanges = async (): Promise<void> => {
300
411
  if (ready === null || loadingDetails || !ready.truncated) return
@@ -329,16 +440,18 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
329
440
  }
330
441
 
331
442
  const applyRestore = async (): Promise<void> => {
332
- if (ready === null || !canApply || applyPending.current) return
443
+ if (preview === null || !canApply || applyPending.current) return
333
444
  const body: Record<string, unknown> = {
334
445
  mode,
335
446
  sessionId,
336
- messageSeq: ready.messageSeq,
337
- 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
338
454
  }
339
- if (ready.planId === undefined || ready.confirmation === undefined) return
340
- body.planId = ready.planId
341
- body.confirmation = ready.confirmation
342
455
  applyPending.current = true
343
456
  setApplying(true)
344
457
  setError(null)
@@ -351,6 +464,17 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
351
464
  const result = recordOf(await responseJson(response))
352
465
  const resultMode = requiredString(result.mode, 'mode')
353
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
+ }
354
478
  if (mode === 'code') {
355
479
  requiredString(result.rescuePointId, 'rescuePointId')
356
480
  setCompleted('项目文件已恢复;当前对话保持不变。恢复前的文件已自动备份。')
@@ -376,7 +500,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
376
500
  }
377
501
  }
378
502
 
379
- const actionLabel = mode === 'both' ? '恢复并从这里继续' : '恢复文件'
503
+ const actionLabel = mode === 'both' ? '恢复并从这里继续' : mode === 'code' ? '恢复文件' : '只回溯消息'
380
504
  const radioName = `dcl-rewind-${sessionId}-${String(matched.messageSeq)}`
381
505
  const branchChanged = ready !== null && ready.checkpointBranch !== ready.currentBranch
382
506
 
@@ -407,24 +531,32 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
407
531
  <div className="dcl-rewind-body">
408
532
  {loading && <p className="dcl-rewind-status">正在检查可以恢复的项目文件…</p>}
409
533
  {preview?.status === 'pending' && <p className="dcl-rewind-status">这条消息发送前的文件还在保存,请稍后再试。</p>}
410
- {preview?.status === 'missing' && <p className="dcl-rewind-error">没有保存这条消息发送前的文件。可能是当时还没启用回退功能,或记录已超过保留期限。</p>}
411
- {preview?.status === 'skipped' && <p className="dcl-rewind-status">为避免阻塞消息发送,本轮没有自动保存文件:{preview.reason}</p>}
412
- {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
+ )}
413
553
  {ready !== null && (
414
554
  <>
415
- <div className="dcl-rewind-options">
416
- <label className="dcl-rewind-option" data-selected={mode === 'both'} data-disabled={applying}>
417
- <input type="radio" name={radioName} checked={mode === 'both'} disabled={applying} onChange={() => { chooseMode('both') }} />
418
- <span className="dcl-rewind-option-content"><strong>恢复文件并从这里继续</strong><span className="dcl-rewind-option-description">创建一个从这里开始的新会话(当前对话会保留)</span></span>
419
- </label>
420
- <label className="dcl-rewind-option" data-selected={mode === 'code'} data-disabled={applying}>
421
- <input type="radio" name={radioName} checked={mode === 'code'} disabled={applying} onChange={() => { chooseMode('code') }} />
422
- <span className="dcl-rewind-option-content"><strong>只恢复文件</strong><span className="dcl-rewind-option-description">恢复这条消息发送前的文件,当前对话保持不变。</span></span>
423
- </label>
424
- </div>
425
555
  <div className="dcl-rewind-summary">
426
- <strong>将恢复 {String(ready.totalChanges)} 个文件</strong>
427
- <span>{mode === 'both' ? '恢复后在新对话里继续' : '当前对话保持不变'}</span>
556
+ {mode === 'messages'
557
+ ? <strong>项目文件保持不变</strong>
558
+ : <strong>将恢复 {String(ready.totalChanges)} 个文件</strong>}
559
+ <span>{mode === 'both' ? '恢复后在新对话里继续' : mode === 'code' ? '当前对话保持不变' : '仅创建从这里继续的新对话'}</span>
428
560
  </div>
429
561
  {sharedBlocked && (
430
562
  <p className="dcl-rewind-error">这个项目目录还有别的对话正在运行。恢复文件会影响到它们,因此本次操作已被阻止。请等那些对话结束或停止后,再重新检查。</p>
@@ -437,7 +569,7 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
437
569
  {driftBlocked && <p className="dcl-rewind-warning">Git 正在进行合并、变基或类似操作。请先完成或取消这次 Git 操作,再重新检查。</p>}
438
570
  {planMissing && <p className="dcl-rewind-error">恢复信息已经失效,请重新检查。</p>}
439
571
  {stale && <p className="dcl-rewind-error">项目文件在检查后又发生了变化。为避免覆盖新修改,这次恢复已失效,请重新检查。</p>}
440
- {ready.totalChanges === 0 && <p className="dcl-rewind-status">项目文件已经是这条消息发送前的状态,无需恢复。想重新开始时,可以使用“分支新对话”。</p>}
572
+ {ready.totalChanges === 0 && <p className="dcl-rewind-status">项目文件已经是这条消息发送前的状态,无需恢复文件。可选择「只回溯消息」重新开始这段对话。</p>}
441
573
  {ready.changes.length > 0 && (
442
574
  <div className="dcl-rewind-files">
443
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>)}
@@ -458,6 +590,366 @@ export function RewindMessageAction({ matched, sessionId, openRestoredSession }:
458
590
  )
459
591
  }
460
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
+
461
953
  function decodePreview(value: unknown): Preview {
462
954
  const record = recordOf(value)
463
955
  const status = requiredString(record.status, 'status')
@@ -639,7 +1131,7 @@ function friendlyError(error: unknown): string {
639
1131
  case 'REPOSITORY_CHANGED': return '这个项目目录已不属于原来的 Git 工作区,无法恢复。'
640
1132
  case 'GIT_OPERATION_CHANGED': return 'Git 正在执行其他操作。请先完成或取消该操作,再重新检查。'
641
1133
  case 'RESTORE_POINT_NOT_FOUND': return '没有找到对应的文件状态,可能已被清理。'
642
- case 'NO_CHANGES': return '项目文件已经是这条消息发送前的状态,无需恢复。想重新开始时,可以使用“分支新对话”。'
1134
+ case 'NO_CHANGES': return '项目文件已经是这条消息发送前的状态,无需恢复文件。可选择「只回溯消息」重新开始这段对话。'
643
1135
  case 'RESTORE_FAILED_ROLLED_BACK': return '恢复未能完成,项目文件已自动还原到操作前的状态。'
644
1136
  case 'CONVERSATION_REWIND_FAILED': return '文件已恢复,但无法创建新对话;项目文件已自动还原。'
645
1137
  default: return error.message