@anionex/dsh-turn-rewind 0.1.2 → 0.2.1
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/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +295 -25
- package/lib/client.js.map +1 -1
- package/lib/engine.js +140 -3
- package/lib/index.js +17 -1
- package/lib/rewind-host.js +104 -9
- package/lib/settings.js +58 -0
- package/lib/store.js +90 -23
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/engine.d.ts +21 -2
- package/lib/types/index.d.ts +7 -0
- package/lib/types/rewind-host.d.ts +16 -2
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/store.d.ts +20 -0
- package/lib/types/types.d.ts +19 -0
- package/package.json +19 -3
- package/src/client/index.tsx +540 -45
- package/src/engine.ts +154 -3
- package/src/index.ts +20 -1
- package/src/rewind-host.ts +122 -9
- package/src/settings.ts +93 -0
- package/src/store.ts +109 -23
- package/src/types.ts +21 -0
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 {
|
|
@@ -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
|
|
251
|
-
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
|
+
}
|
|
252
360
|
} catch (caught) {
|
|
253
|
-
if (!controller.signal.aborted)
|
|
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 =
|
|
401
|
+
const canApply = preview !== null
|
|
289
402
|
&& !loading
|
|
290
403
|
&& !applying
|
|
291
404
|
&& !loadingDetails
|
|
292
405
|
&& completed === null
|
|
293
|
-
&&
|
|
294
|
-
|
|
295
|
-
|
|
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 (
|
|
443
|
+
if (preview === null || !canApply || applyPending.current) return
|
|
333
444
|
const body: Record<string, unknown> = {
|
|
334
445
|
mode,
|
|
335
446
|
sessionId,
|
|
336
|
-
messageSeq:
|
|
337
|
-
|
|
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"
|
|
411
|
-
{preview?.status === 'skipped' && <p className="dcl-rewind-status">为避免阻塞消息发送,本轮没有自动保存文件:{preview.reason}
|
|
412
|
-
{preview?.status === 'failed' && <p className="dcl-rewind-error">没能保存这条消息发送前的文件:{preview.error}
|
|
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
|
-
|
|
427
|
-
|
|
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"
|
|
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')
|
|
@@ -525,9 +1017,12 @@ function collectPortalTargets(nodes: readonly RewindNodeLike[]): readonly Rewind
|
|
|
525
1017
|
const target = selectRewindMessageTarget(value)
|
|
526
1018
|
if (target === null) continue
|
|
527
1019
|
const row = rows.get(target.rowKey)
|
|
528
|
-
const messageRoot = row?.querySelector
|
|
529
|
-
|
|
530
|
-
|
|
1020
|
+
const messageRoot = row?.querySelector?.<HTMLElement>('[data-time-hover-root="true"]')
|
|
1021
|
+
// Match the actions container by class or last child element
|
|
1022
|
+
const actions = (typeof messageRoot?.querySelector === 'function'
|
|
1023
|
+
? messageRoot.querySelector<HTMLElement>('[class*="actions"]')
|
|
1024
|
+
: undefined) ?? messageRoot?.lastElementChild
|
|
1025
|
+
if (!(actions instanceof HTMLElement)) continue
|
|
531
1026
|
targets.push({ container: actions, matched: target.matched })
|
|
532
1027
|
}
|
|
533
1028
|
return targets
|
|
@@ -639,7 +1134,7 @@ function friendlyError(error: unknown): string {
|
|
|
639
1134
|
case 'REPOSITORY_CHANGED': return '这个项目目录已不属于原来的 Git 工作区,无法恢复。'
|
|
640
1135
|
case 'GIT_OPERATION_CHANGED': return 'Git 正在执行其他操作。请先完成或取消该操作,再重新检查。'
|
|
641
1136
|
case 'RESTORE_POINT_NOT_FOUND': return '没有找到对应的文件状态,可能已被清理。'
|
|
642
|
-
case 'NO_CHANGES': return '
|
|
1137
|
+
case 'NO_CHANGES': return '项目文件已经是这条消息发送前的状态,无需恢复文件。可选择「只回溯消息」重新开始这段对话。'
|
|
643
1138
|
case 'RESTORE_FAILED_ROLLED_BACK': return '恢复未能完成,项目文件已自动还原到操作前的状态。'
|
|
644
1139
|
case 'CONVERSATION_REWIND_FAILED': return '文件已恢复,但无法创建新对话;项目文件已自动还原。'
|
|
645
1140
|
default: return error.message
|