@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.
- package/README.md +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +291 -22
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +622 -121
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +295 -22
- package/lib/index.js +18 -2
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +250 -48
- package/lib/settings.js +58 -0
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1361 -119
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +48 -2
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +8 -1
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +90 -6
- package/lib/types/types.d.ts +63 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +21 -3
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +536 -41
- package/src/deadline.ts +18 -0
- package/src/engine.ts +695 -126
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +337 -22
- package/src/index.ts +21 -2
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +278 -50
- package/src/settings.ts +93 -0
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1502 -104
- package/src/types.ts +69 -1
- package/src/validate.ts +67 -7
package/src/client/index.tsx
CHANGED
|
@@ -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
|
|
60
|
-
readonly
|
|
61
|
-
readonly
|
|
62
|
-
|
|
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:
|
|
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
|
|
250
|
-
if (loadAbort.current === controller)
|
|
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)
|
|
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 =
|
|
401
|
+
const canApply = preview !== null
|
|
288
402
|
&& !loading
|
|
289
403
|
&& !applying
|
|
290
404
|
&& !loadingDetails
|
|
291
405
|
&& completed === null
|
|
292
|
-
&&
|
|
293
|
-
|
|
294
|
-
|
|
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 (
|
|
443
|
+
if (preview === null || !canApply || applyPending.current) return
|
|
332
444
|
const body: Record<string, unknown> = {
|
|
333
445
|
mode,
|
|
334
446
|
sessionId,
|
|
335
|
-
messageSeq:
|
|
336
|
-
|
|
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"
|
|
410
|
-
{preview?.status === '
|
|
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
|
-
|
|
425
|
-
|
|
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"
|
|
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
|