dsh-turn-undo 0.0.4 → 0.0.6
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/client.js +32 -52
- package/index.js +314 -125
- package/package.json +1 -1
package/client.js
CHANGED
|
@@ -146,6 +146,10 @@ window.__ModuleLoader__.load({
|
|
|
146
146
|
'.dtu-trigger{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--dsw-alias-label-tertiary);cursor:pointer}',
|
|
147
147
|
'.dtu-trigger:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary)}',
|
|
148
148
|
'.dtu-trigger:disabled{cursor:not-allowed;opacity:.5}',
|
|
149
|
+
// 点击后浏览器保留 :focus,hover 底色会「粘住」不恢复 —— 鼠标移开时
|
|
150
|
+
// 显式清掉 focus/active 态的背景与描边。
|
|
151
|
+
'.dtu-trigger:focus{outline:none}',
|
|
152
|
+
'.dtu-trigger:focus:not(:hover),.dtu-trigger:focus-visible:not(:hover),.dtu-trigger:active:not(:hover){background:transparent;color:var(--dsw-alias-label-tertiary)}',
|
|
149
153
|
'.dtu-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:10000}',
|
|
150
154
|
'.dtu-dialog{box-sizing:border-box;width:min(560px,100%);max-height:calc(100dvh - 48px);overflow:auto;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,.2)}',
|
|
151
155
|
'.dtu-body{display:flex;flex-direction:column;gap:14px;width:100%;min-width:0;max-width:100%;box-sizing:border-box;padding:18px}',
|
|
@@ -197,33 +201,7 @@ window.__ModuleLoader__.load({
|
|
|
197
201
|
document.head.appendChild(styleEl)
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
function
|
|
201
|
-
try {
|
|
202
|
-
ctx.sessions.open(sessionId)
|
|
203
|
-
} catch (error) {
|
|
204
|
-
console.warn('[turn-undo] openRestoredSession open failed:', error.message)
|
|
205
|
-
return
|
|
206
|
-
}
|
|
207
|
-
if (!draftText) return
|
|
208
|
-
function trySetDraft(remaining) {
|
|
209
|
-
if (remaining <= 0) return
|
|
210
|
-
try {
|
|
211
|
-
var scope = ctx.sessions.scope(sessionId)
|
|
212
|
-
if (scope !== undefined) {
|
|
213
|
-
ctx.conversation.input.for(scope).setDraft(draftText)
|
|
214
|
-
return
|
|
215
|
-
}
|
|
216
|
-
} catch (error) {
|
|
217
|
-
if (remaining <= 1) {
|
|
218
|
-
console.warn('[turn-undo] openRestoredSession setDraft failed:', error.message)
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
setTimeout(function () { trySetDraft(remaining - 1) }, 100)
|
|
222
|
-
}
|
|
223
|
-
trySetDraft(20)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
ctx.inject(['slots', 'sessions', 'conversation'], function (scope) {
|
|
204
|
+
ctx.inject(['slots', 'conversation', 'uiWorkspace'], function (scope) {
|
|
227
205
|
scope.effect(function () {
|
|
228
206
|
return scope.slots.inject('conversation.session.header.actions', function () {
|
|
229
207
|
return scope.slots.register({
|
|
@@ -231,13 +209,21 @@ window.__ModuleLoader__.load({
|
|
|
231
209
|
id: 'turn-undo-portals',
|
|
232
210
|
order: 100,
|
|
233
211
|
inject: function () {
|
|
234
|
-
var
|
|
212
|
+
var workspaceSvc = scope.uiWorkspace
|
|
235
213
|
var conversationSvc = scope.conversation
|
|
236
214
|
return {
|
|
237
215
|
openRestoredSession: function (newSessionId, draftText) {
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
216
|
+
// 导航必须走 uiWorkspace:ISessions 没有 open(),
|
|
217
|
+
// 旧的 sessionsSvc.open(...) 永远被 if 守卫静默跳过 —— 这正是
|
|
218
|
+
// 「改名了但新会话没打开」的根因。
|
|
219
|
+
try {
|
|
220
|
+
if (workspaceSvc && typeof workspaceSvc.openSession === 'function') {
|
|
221
|
+
workspaceSvc.openSession(newSessionId)
|
|
222
|
+
} else {
|
|
223
|
+
console.warn('[turn-undo] uiWorkspace.openSession unavailable; new session not opened')
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
console.warn('[turn-undo] Failed to open restored session:', e.message)
|
|
241
227
|
}
|
|
242
228
|
if (!conversationSvc || !conversationSvc.input || !draftText) return
|
|
243
229
|
function trySetDraft(remaining) {
|
|
@@ -395,7 +381,7 @@ window.__ModuleLoader__.load({
|
|
|
395
381
|
var previewState = useState(null)
|
|
396
382
|
var preview = previewState[0]
|
|
397
383
|
var setPreview = previewState[1]
|
|
398
|
-
var loadingState = useState(
|
|
384
|
+
var loadingState = useState(false)
|
|
399
385
|
var loading = loadingState[0]
|
|
400
386
|
var setLoading = loadingState[1]
|
|
401
387
|
var errorState = useState(null)
|
|
@@ -408,28 +394,17 @@ window.__ModuleLoader__.load({
|
|
|
408
394
|
var done = doneState[0]
|
|
409
395
|
var setDone = doneState[1]
|
|
410
396
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
setError(null)
|
|
415
|
-
fetch(API_PATH + '?sessionId=' + encodeURIComponent(sessionId) + '&messageSeq=' + messageSeq + '&promptText=' + encodeURIComponent(messageText), {
|
|
416
|
-
method: 'GET', headers: { 'Accept': 'application/json' }, cache: 'no-store',
|
|
417
|
-
})
|
|
418
|
-
.then(function (res) { return res.json() })
|
|
419
|
-
.then(function (data) { if (!cancelled) setPreview(data) })
|
|
420
|
-
.catch(function (err) { if (!cancelled) setError(err.message || '请求失败') })
|
|
421
|
-
.finally(function () { if (!cancelled) setLoading(false) })
|
|
422
|
-
return function () { cancelled = true }
|
|
423
|
-
}, [])
|
|
397
|
+
// NOTE: preview 只在用户点开弹框时请求(见 show())。挂载时预取毫无
|
|
398
|
+
// 意义——按钮只渲染图标,不消费 preview——却会让每个会话在打开瞬间
|
|
399
|
+
// 为每条用户消息各发一次全工作区扫描请求(实测单次 1.2–2.7 秒)。
|
|
424
400
|
|
|
425
401
|
function show() {
|
|
426
402
|
setOpen(true)
|
|
427
403
|
setPreview(null)
|
|
428
404
|
setDone(false)
|
|
429
405
|
setLoading(true)
|
|
430
|
-
// NOTE:
|
|
431
|
-
//
|
|
432
|
-
// 且未捕获错误可能导致 React 卸下 portal(按钮消失)。必须无条件收尾。
|
|
406
|
+
// NOTE: 必须无条件收尾:任何未捕获错误都会让 setLoading(false) 不执行,
|
|
407
|
+
// 弹框会永远停在"正在检查…",且可能让 React 卸下 portal(按钮消失)。
|
|
433
408
|
fetch(API_PATH + '?sessionId=' + encodeURIComponent(sessionId) + '&messageSeq=' + messageSeq + '&promptText=' + encodeURIComponent(messageText), {
|
|
434
409
|
method: 'GET', headers: { 'Accept': 'application/json' }, cache: 'no-store',
|
|
435
410
|
})
|
|
@@ -468,6 +443,7 @@ window.__ModuleLoader__.load({
|
|
|
468
443
|
|
|
469
444
|
var changes = (preview && Array.isArray(preview.changes)) ? preview.changes : []
|
|
470
445
|
var noSnapshot = (preview && preview.noSnapshot) === true
|
|
446
|
+
var noBaseline = (preview && preview.noBaseline) === true
|
|
471
447
|
var previewError = (preview && preview.error) ? preview.error : null
|
|
472
448
|
|
|
473
449
|
return h('div', { className: 'dtu-container' },
|
|
@@ -491,7 +467,8 @@ window.__ModuleLoader__.load({
|
|
|
491
467
|
sessionId: sessionId, messageText: messageText,
|
|
492
468
|
onClose: close, preview: preview, loading: loading, error: error,
|
|
493
469
|
applying: applying, done: done, changes: changes,
|
|
494
|
-
previewError: previewError, noSnapshot: noSnapshot,
|
|
470
|
+
previewError: previewError, noSnapshot: noSnapshot, noBaseline: noBaseline,
|
|
471
|
+
canApply: canApply, applyRestore: applyRestore,
|
|
495
472
|
}) : null,
|
|
496
473
|
)
|
|
497
474
|
}
|
|
@@ -629,6 +606,7 @@ window.__ModuleLoader__.load({
|
|
|
629
606
|
var changes = props.changes
|
|
630
607
|
var previewError = props.previewError
|
|
631
608
|
var noSnapshot = props.noSnapshot
|
|
609
|
+
var noBaseline = props.noBaseline
|
|
632
610
|
var canApply = props.canApply
|
|
633
611
|
var applyRestore = props.applyRestore
|
|
634
612
|
|
|
@@ -662,9 +640,11 @@ window.__ModuleLoader__.load({
|
|
|
662
640
|
previewError ? h('p', { className: 'dtu-error' }, previewError) : null,
|
|
663
641
|
(!loading && noSnapshot)
|
|
664
642
|
? h('p', { className: 'dtu-status' }, '该时点没有可用快照,将仅创建新会话。') : null,
|
|
665
|
-
(!loading && !
|
|
643
|
+
(!loading && !noSnapshot && noBaseline)
|
|
644
|
+
? h('p', { className: 'dtu-status' }, '这条消息之前没有可用快照,无法恢复文件,将仅创建新会话。') : null,
|
|
645
|
+
(!loading && !previewError && !noSnapshot && !noBaseline && changes.length === 0)
|
|
666
646
|
? h('p', { className: 'dtu-status' }, '这条消息之前没有需要恢复的文件。') : null,
|
|
667
|
-
(!loading && !previewError && changes.length > 0)
|
|
647
|
+
(!loading && !previewError && !noSnapshot && !noBaseline && changes.length > 0)
|
|
668
648
|
? h('div', { className: 'dtu-section' },
|
|
669
649
|
h('div', { className: 'dtu-section-label' }, '将影响的文件 (' + changes.length + ' 个)'),
|
|
670
650
|
h('div', { className: 'dtu-files' },
|
|
@@ -779,7 +759,7 @@ window.__ModuleLoader__.load({
|
|
|
779
759
|
}
|
|
780
760
|
|
|
781
761
|
exports.apply = apply
|
|
782
|
-
exports.inject = ['slots', '
|
|
762
|
+
exports.inject = ['slots', 'conversation', 'uiWorkspace']
|
|
783
763
|
return module.exports
|
|
784
764
|
},
|
|
785
765
|
})
|
package/index.js
CHANGED
|
@@ -41,8 +41,16 @@ export const name = 'turn-undo'
|
|
|
41
41
|
export { SnapshotStore }
|
|
42
42
|
|
|
43
43
|
// Injected services (via ctx.inject in apply()): webServer, sessions,
|
|
44
|
-
// sessionQuery, agents, sessionController.
|
|
45
|
-
export const inject = [
|
|
44
|
+
// sessionQuery, agents, sessionController, workspaceRegistry.
|
|
45
|
+
export const inject = [
|
|
46
|
+
'webServer',
|
|
47
|
+
'sessions',
|
|
48
|
+
'sessionQuery',
|
|
49
|
+
'agents',
|
|
50
|
+
'sessionTitle',
|
|
51
|
+
'sessionController',
|
|
52
|
+
'workspaceRegistry',
|
|
53
|
+
]
|
|
46
54
|
|
|
47
55
|
// ---- Configuration defaults ----
|
|
48
56
|
const DEFAULT_SNAPSHOT_TTL_DAYS = 7
|
|
@@ -51,6 +59,12 @@ const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024 // 10 MB
|
|
|
51
59
|
const DEFAULT_MAX_FILES_PER_SNAPSHOT = 10000
|
|
52
60
|
const DEFAULT_MAX_SNAPSHOT_BYTES = 500 * 1024 * 1024 // 500 MB
|
|
53
61
|
const DEFAULT_SNAPSHOT_DELAY_MS = 250
|
|
62
|
+
// Object-store GC: unreferenced objects are only reclaimed once they are older
|
|
63
|
+
// than the grace window (a capture writes objects before its manifest, so a
|
|
64
|
+
// young unreferenced object may still be in flight). The sweep itself is
|
|
65
|
+
// throttled because it stats every object and parses every surviving manifest.
|
|
66
|
+
const DEFAULT_OBJECT_GC_GRACE_MS = 60 * 60 * 1000
|
|
67
|
+
const DEFAULT_OBJECT_GC_INTERVAL_MS = 6 * 60 * 60 * 1000
|
|
54
68
|
const DEFAULT_EXCLUDES = [
|
|
55
69
|
'node_modules/',
|
|
56
70
|
'.git/',
|
|
@@ -187,6 +201,9 @@ class SnapshotStore {
|
|
|
187
201
|
this.maxBytes = cfg.maxSnapshotBytes ?? DEFAULT_MAX_SNAPSHOT_BYTES
|
|
188
202
|
this.ttlDays = cfg.snapshotTtlDays ?? DEFAULT_SNAPSHOT_TTL_DAYS
|
|
189
203
|
this.maxSnapshots = cfg.maxSnapshotsPerSession ?? DEFAULT_MAX_SNAPSHOTS_PER_SESSION
|
|
204
|
+
this.objectGcGraceMs = cfg.objectGcGraceMs ?? DEFAULT_OBJECT_GC_GRACE_MS
|
|
205
|
+
this.objectGcIntervalMs = cfg.objectGcIntervalMs ?? DEFAULT_OBJECT_GC_INTERVAL_MS
|
|
206
|
+
this.lastObjectSweep = 0
|
|
190
207
|
this.objDir = join(this.base, 'objects')
|
|
191
208
|
this.snapDir = join(this.base, 'snapshots')
|
|
192
209
|
}
|
|
@@ -511,20 +528,6 @@ class SnapshotStore {
|
|
|
511
528
|
return result
|
|
512
529
|
}
|
|
513
530
|
|
|
514
|
-
/** Read manifest entries into a map of path -> content */
|
|
515
|
-
readManifestContents(manifest) {
|
|
516
|
-
const contents = {}
|
|
517
|
-
for (const [rel, entry] of Object.entries(manifest)) {
|
|
518
|
-
if (entry.kind === 'file' && entry.hash) {
|
|
519
|
-
const content = this.readObjectContent(entry.hash)
|
|
520
|
-
if (content !== null) {
|
|
521
|
-
contents[rel] = content
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
return contents
|
|
526
|
-
}
|
|
527
|
-
|
|
528
531
|
/** Load snapshot manifests for a session, oldest first. */
|
|
529
532
|
loadChain(sessionId) {
|
|
530
533
|
const dir = join(this.snapDir, sessionId)
|
|
@@ -649,6 +652,86 @@ class SnapshotStore {
|
|
|
649
652
|
try { unlinkSync(join(dir, remaining[i])) } catch {}
|
|
650
653
|
}
|
|
651
654
|
}
|
|
655
|
+
// Manifests are pruned above; reclaim the objects they no longer reference.
|
|
656
|
+
// Without this the content-addressed store grows forever (an observed store
|
|
657
|
+
// reached 983 MB / 40k files whose manifests had long been deleted).
|
|
658
|
+
try { this.sweepObjects() } catch (e) {
|
|
659
|
+
console.warn('[turn-undo] object sweep failed:', e.message)
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Mark-and-sweep the content-addressed object store.
|
|
665
|
+
*
|
|
666
|
+
* Every hash reachable from a surviving manifest is marked; unreferenced
|
|
667
|
+
* objects older than the grace window are deleted. Objects younger than the
|
|
668
|
+
* grace window are kept because capture() writes objects before it writes
|
|
669
|
+
* the manifest that references them — deleting those would corrupt an
|
|
670
|
+
* in-flight snapshot.
|
|
671
|
+
*
|
|
672
|
+
* Throttled to `objectGcIntervalMs` since the mark phase parses every
|
|
673
|
+
* surviving manifest.
|
|
674
|
+
* @param opts - `force` bypasses the throttle (tests / explicit invocation);
|
|
675
|
+
* `dryRun` counts what would be reclaimed without deleting anything.
|
|
676
|
+
* @returns Counts, or null when the throttle skipped this call.
|
|
677
|
+
*/
|
|
678
|
+
sweepObjects(opts = {}) {
|
|
679
|
+
const now = Date.now()
|
|
680
|
+
if (!opts.force && now - this.lastObjectSweep < this.objectGcIntervalMs) return null
|
|
681
|
+
this.lastObjectSweep = now
|
|
682
|
+
|
|
683
|
+
// ── Mark ──────────────────────────────────────────────────────────
|
|
684
|
+
const referenced = new Set()
|
|
685
|
+
let unreadable = 0
|
|
686
|
+
if (existsSync(this.snapDir)) {
|
|
687
|
+
for (const sessionId of readdirSync(this.snapDir)) {
|
|
688
|
+
const dir = join(this.snapDir, sessionId)
|
|
689
|
+
let dirStat
|
|
690
|
+
try { dirStat = statSync(dir) } catch { continue }
|
|
691
|
+
if (!dirStat.isDirectory()) continue
|
|
692
|
+
for (const f of readdirSync(dir)) {
|
|
693
|
+
if (!f.endsWith('.json')) continue
|
|
694
|
+
const data = readJsonFile(join(dir, f))
|
|
695
|
+
if (!data || !data.manifest) { unreadable++; continue }
|
|
696
|
+
for (const entry of Object.values(data.manifest)) {
|
|
697
|
+
if (entry && typeof entry.hash === 'string') referenced.add(entry.hash)
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Fail-safe: an unparseable manifest would contribute no marks, so its
|
|
704
|
+
// objects would look unreferenced and be deleted — silently destroying a
|
|
705
|
+
// snapshot that is merely unreadable. Abort rather than sweep on a
|
|
706
|
+
// possibly-incomplete mark set.
|
|
707
|
+
if (unreadable > 0) {
|
|
708
|
+
console.warn(`[turn-undo] object sweep skipped: ${unreadable} manifest(s) unreadable`)
|
|
709
|
+
return { removed: 0, freedBytes: 0, keptYoung: 0, referenced: referenced.size, aborted: 'unreadable-manifest' }
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── Sweep ─────────────────────────────────────────────────────────
|
|
713
|
+
let removed = 0
|
|
714
|
+
let freedBytes = 0
|
|
715
|
+
let keptYoung = 0
|
|
716
|
+
if (!existsSync(this.objDir)) return { removed, freedBytes, keptYoung, referenced: referenced.size }
|
|
717
|
+
for (const name of readdirSync(this.objDir)) {
|
|
718
|
+
if (referenced.has(name)) continue
|
|
719
|
+
const abs = join(this.objDir, name)
|
|
720
|
+
let st
|
|
721
|
+
try { st = statSync(abs) } catch { continue }
|
|
722
|
+
if (!st.isFile()) continue
|
|
723
|
+
if (now - st.mtimeMs < this.objectGcGraceMs) { keptYoung++; continue }
|
|
724
|
+
if (opts.dryRun) { removed++; freedBytes += st.size; continue }
|
|
725
|
+
try {
|
|
726
|
+
unlinkSync(abs)
|
|
727
|
+
removed++
|
|
728
|
+
freedBytes += st.size
|
|
729
|
+
} catch {}
|
|
730
|
+
}
|
|
731
|
+
if (removed > 0) {
|
|
732
|
+
console.info(`[turn-undo] object sweep reclaimed ${removed} objects (${Math.round(freedBytes / 1048576)} MB), kept ${keptYoung} young, ${referenced.size} referenced`)
|
|
733
|
+
}
|
|
734
|
+
return { removed, freedBytes, keptYoung, referenced: referenced.size }
|
|
652
735
|
}
|
|
653
736
|
}
|
|
654
737
|
|
|
@@ -693,23 +776,44 @@ function resolveForkBoundary(source, messageSeq, promptText) {
|
|
|
693
776
|
return { boundary: null, turn: null, cwd, reason: 'no-turn-start' }
|
|
694
777
|
}
|
|
695
778
|
const previousEnd = events.findLast(e => e.type === 'turn/end' && e.seq < start.seq)
|
|
779
|
+
|
|
780
|
+
// 第一条消息(无前一个 turn/end)也必须 fork:边界取 turn/start 前
|
|
781
|
+
// 最近的一个「索引==seq」有效事件(会话初始化完成处),子会话继承
|
|
782
|
+
// workspace/preset/cwd,而不是 create() 一个空白会话 —— 这正是
|
|
783
|
+
// 「点击撤销后直接创建新会话、没有 fork 出会话」的根因。
|
|
784
|
+
let boundary = previousEnd ? previousEnd.seq : null
|
|
785
|
+
if (boundary !== null && !(events[boundary] && events[boundary].seq === boundary)) {
|
|
786
|
+
boundary = null
|
|
787
|
+
}
|
|
788
|
+
if (boundary === null) {
|
|
789
|
+
const startIdx = events.indexOf(start)
|
|
790
|
+
for (let i = startIdx - 1; i >= 0; i--) {
|
|
791
|
+
if (events[i] && events[i].seq === i) { boundary = i; break }
|
|
792
|
+
}
|
|
793
|
+
}
|
|
696
794
|
return {
|
|
697
|
-
boundary
|
|
795
|
+
boundary,
|
|
698
796
|
turn,
|
|
699
797
|
cwd,
|
|
700
|
-
reason:
|
|
798
|
+
reason: boundary !== null ? 'ok' : 'no-previous-end',
|
|
701
799
|
}
|
|
702
800
|
}
|
|
703
801
|
|
|
704
802
|
// ── Strategy 2: text-based fallback ────────────────────────────────
|
|
803
|
+
// Only reached for event streams that carry no usable `seq` (persisted
|
|
804
|
+
// sessions). When the stream DOES carry seqs but none is this user message,
|
|
805
|
+
// the requested point genuinely does not exist — guessing a different
|
|
806
|
+
// message here would silently restore the workspace to the wrong turn.
|
|
807
|
+
const seqsAvailable = events.some(e => typeof e.seq === 'number')
|
|
808
|
+
if (seqsAvailable && typeof messageSeq === 'number') {
|
|
809
|
+
return { boundary: null, turn: null, cwd, reason: 'no-matching-message' }
|
|
810
|
+
}
|
|
811
|
+
|
|
705
812
|
// Use promptText to match the user message, avoiding the bug of
|
|
706
813
|
// treating messageSeq as an array index when seq is unavailable.
|
|
707
|
-
//
|
|
708
|
-
// If promptText is not provided, find the latest user message as a
|
|
709
|
-
// safe fallback.
|
|
710
814
|
if (promptText) {
|
|
711
815
|
const normalizedPromptText = promptText.trim().substring(0, 200).toLowerCase()
|
|
712
|
-
|
|
816
|
+
const candidates = events.filter(e => (
|
|
713
817
|
e.type === 'user/message'
|
|
714
818
|
&& e.data?.source && typeof e.data.source === 'object'
|
|
715
819
|
&& e.data.source.kind === 'user'
|
|
@@ -721,20 +825,13 @@ function resolveForkBoundary(source, messageSeq, promptText) {
|
|
|
721
825
|
return text.includes(normalizedPromptText) || normalizedPromptText.includes(text)
|
|
722
826
|
})
|
|
723
827
|
))
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
const e = events[i]
|
|
730
|
-
if (e.type === 'turn/start') break
|
|
731
|
-
if (e.type === 'user/message'
|
|
732
|
-
&& e.data?.source && typeof e.data.source === 'object'
|
|
733
|
-
&& e.data.source.kind === 'user') {
|
|
734
|
-
message = e
|
|
735
|
-
break
|
|
736
|
-
}
|
|
828
|
+
// Ambiguous text must not be resolved by position: the first match is
|
|
829
|
+
// routinely an earlier prompt that merely contains the same words
|
|
830
|
+
// ("继续" and the like).
|
|
831
|
+
if (candidates.length > 1) {
|
|
832
|
+
return { boundary: null, turn: null, cwd, reason: 'ambiguous-message' }
|
|
737
833
|
}
|
|
834
|
+
if (candidates.length === 1) message = candidates[0]
|
|
738
835
|
}
|
|
739
836
|
|
|
740
837
|
if (!message) return { boundary: null, turn: null, cwd, reason: 'no-user-message' }
|
|
@@ -964,20 +1061,55 @@ async function executeSurfaceRewind(agent, targetSeq) {
|
|
|
964
1061
|
* no completed turn before it (e.g. the very first user message).
|
|
965
1062
|
* @returns the new child session id.
|
|
966
1063
|
*/
|
|
1064
|
+
/**
|
|
1065
|
+
* Find the workspace that owns `sessionId`, so a `cwd`-less `create()` can pass
|
|
1066
|
+
* `workspaceId` and get attached to the workspace catalogue (visible in the
|
|
1067
|
+
* sidebar). Mirrors upstream `SessionController.forkWorkspace`: only sessions
|
|
1068
|
+
* already indexed as live are visible through `WorkspaceRegistry.list()`.
|
|
1069
|
+
* @returns the owning workspace id, or `undefined` when the session is not
|
|
1070
|
+
* indexed (caller then falls back to a `cwd`-only create).
|
|
1071
|
+
*/
|
|
1072
|
+
function findWorkspaceIdForSession(ctx, sessionId) {
|
|
1073
|
+
try {
|
|
1074
|
+
const registry = ctx.workspaceRegistry
|
|
1075
|
+
if (!registry || typeof registry.list !== 'function') return undefined
|
|
1076
|
+
for (const workspace of registry.list()) {
|
|
1077
|
+
const ids = workspace && workspace.sessionIds
|
|
1078
|
+
if (Array.isArray(ids) && ids.includes(sessionId)) return workspace.id
|
|
1079
|
+
}
|
|
1080
|
+
} catch (e) {
|
|
1081
|
+
console.warn('[turn-undo] Workspace lookup failed (falling back to cwd):', e.message)
|
|
1082
|
+
}
|
|
1083
|
+
return undefined
|
|
1084
|
+
}
|
|
1085
|
+
|
|
967
1086
|
async function forkAndMarkUndone(ctx, sessionId, messageSeq, promptText) {
|
|
968
1087
|
const source = await readSession(ctx, sessionId)
|
|
969
1088
|
if (!source) throw new Error('source session not found')
|
|
970
1089
|
const b = resolveForkBoundary(source, messageSeq, promptText)
|
|
971
1090
|
|
|
1091
|
+
console.info(
|
|
1092
|
+
'[turn-undo] resolveBoundary session=' + sessionId,
|
|
1093
|
+
'messageSeq=' + messageSeq,
|
|
1094
|
+
'boundary=' + String(b.boundary),
|
|
1095
|
+
'turn=' + String(b.turn),
|
|
1096
|
+
'reason=' + b.reason,
|
|
1097
|
+
)
|
|
972
1098
|
let childId
|
|
973
1099
|
if (typeof b.boundary === 'number') {
|
|
1100
|
+
// fork() 会自动把子会话 attach 到 source 所在的 workspace(见上游
|
|
1101
|
+
// commands.ts forkWorkspace),所以 fork 分支在侧栏可见。
|
|
974
1102
|
const { sessionId: cid } = await ctx.sessionController.fork({ sessionId, atSeq: b.boundary })
|
|
975
1103
|
childId = cid
|
|
976
1104
|
} else {
|
|
1105
|
+
// create() 只有显式给 workspaceId 才会 attachSession;只给 cwd 的会话
|
|
1106
|
+
// 不会进 workspace 目录,GUI 侧栏看不到 —— 这正是「原会话改名了但没有
|
|
1107
|
+
// 新会话出现」的根因。所以先反查 source 所属 workspace。
|
|
977
1108
|
const cwd = source.header?.cwd
|
|
978
1109
|
if (!cwd) throw new Error('cannot undo first message without a cwd')
|
|
1110
|
+
const workspaceId = findWorkspaceIdForSession(ctx, sessionId)
|
|
979
1111
|
const created = await ctx.sessionController.create({
|
|
980
|
-
cwd,
|
|
1112
|
+
...(workspaceId !== undefined ? { workspaceId } : { cwd }),
|
|
981
1113
|
...(source.header?.agentPreset ? { agentPreset: source.header.agentPreset } : {}),
|
|
982
1114
|
})
|
|
983
1115
|
childId = created.sessionId
|
|
@@ -1017,23 +1149,52 @@ function createHandler(ctx, runtime, sessions, agents) {
|
|
|
1017
1149
|
|
|
1018
1150
|
if (!sessionId) return json(response, 400, { error: 'Missing sessionId' })
|
|
1019
1151
|
|
|
1152
|
+
// 单次 readSession 即可:live 会话的 snapshotEvents() 会重放事件,
|
|
1153
|
+
// 每个请求读两遍是纯浪费。
|
|
1154
|
+
const source = await readSession(ctx, sessionId)
|
|
1155
|
+
if (!source) return json(response, 404, { error: 'Session not found' })
|
|
1156
|
+
|
|
1020
1157
|
let targetTurn = null
|
|
1158
|
+
let unresolved = null
|
|
1021
1159
|
if (messageSeqParam) {
|
|
1022
|
-
const
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
// The preview function will compare this turn's snapshot with the previous one.
|
|
1027
|
-
targetTurn = b.turn !== null && b.turn > 0 ? b.turn : null
|
|
1028
|
-
}
|
|
1160
|
+
const b = resolveForkBoundary(source, parseInt(messageSeqParam, 10), promptTextParam)
|
|
1161
|
+
// For preview, pass the turn that the user wants to undo (b.turn).
|
|
1162
|
+
if (b.turn !== null && b.turn > 0) targetTurn = b.turn
|
|
1163
|
+
else unresolved = b.reason ?? 'no-turn'
|
|
1029
1164
|
}
|
|
1030
1165
|
|
|
1031
1166
|
// Wait for any in-flight snapshot capture so the preview reflects the
|
|
1032
1167
|
// latest committed workspace state (avoids showing a stale turn/end).
|
|
1033
1168
|
try { await runtime.waitForSnapshots() } catch {}
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1169
|
+
|
|
1170
|
+
// 定位不到消息时返回明确失败,而不是 targetTurn=null 的"0 变更"
|
|
1171
|
+
// —— 后者会让弹框显示"无变化",掩盖真实的解析失败。
|
|
1172
|
+
if (unresolved !== null) {
|
|
1173
|
+
return json(response, 200, {
|
|
1174
|
+
ok: false,
|
|
1175
|
+
status: 'unresolved',
|
|
1176
|
+
reason: unresolved,
|
|
1177
|
+
error: '定位不到这条消息对应的对话位置,无法撤销',
|
|
1178
|
+
targetTurn: null,
|
|
1179
|
+
totalChanges: 0,
|
|
1180
|
+
changes: [],
|
|
1181
|
+
})
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
let preview
|
|
1185
|
+
try {
|
|
1186
|
+
preview = runtime.store.preview(sessionId, targetTurn, getCwd(source))
|
|
1187
|
+
} catch (e) {
|
|
1188
|
+
logger?.warn?.('[turn-undo] preview failed:', e.message)
|
|
1189
|
+
preview = {
|
|
1190
|
+
ok: false,
|
|
1191
|
+
status: 'preview-failed',
|
|
1192
|
+
error: '预览失败:' + e.message,
|
|
1193
|
+
targetTurn,
|
|
1194
|
+
totalChanges: 0,
|
|
1195
|
+
changes: [],
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1037
1198
|
return json(response, 200, preview)
|
|
1038
1199
|
}
|
|
1039
1200
|
|
|
@@ -1053,15 +1214,25 @@ function createHandler(ctx, runtime, sessions, agents) {
|
|
|
1053
1214
|
const source = await readSession(ctx, sessionId)
|
|
1054
1215
|
if (!source) return json(response, 400, { error: 'Session not found' })
|
|
1055
1216
|
const cwd = getCwd(source)
|
|
1056
|
-
const b = messageSeq !== null
|
|
1217
|
+
const b = messageSeq !== null
|
|
1218
|
+
? resolveForkBoundary(source, messageSeq, promptText)
|
|
1219
|
+
: { boundary: null, turn: null, cwd, reason: 'no-message-seq' }
|
|
1057
1220
|
|
|
1058
1221
|
// Determine restore target: "recover to before this message" means
|
|
1059
1222
|
// the state at turn/start, i.e. turn T - 0.5. That snapshot captures
|
|
1060
1223
|
// the workspace before the user sent this message and before any AI work.
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1224
|
+
//
|
|
1225
|
+
// 解析不到目标消息时必须中止:继续执行会 fork 出新会话却跳过文件恢复,
|
|
1226
|
+
// 却仍返回 ok:true —— 用户会以为撤销成功。
|
|
1227
|
+
if (b.turn === null || b.turn <= 0) {
|
|
1228
|
+
return json(response, 200, {
|
|
1229
|
+
ok: false,
|
|
1230
|
+
status: 'unresolved',
|
|
1231
|
+
reason: b.reason ?? 'no-turn',
|
|
1232
|
+
error: '定位不到这条消息对应的对话位置,未做任何修改',
|
|
1233
|
+
})
|
|
1064
1234
|
}
|
|
1235
|
+
const restoreTurn = b.turn - 0.5
|
|
1065
1236
|
|
|
1066
1237
|
// 3. Wait for any pending snapshot to settle so the manifest is on disk.
|
|
1067
1238
|
// Without this, a restore issued right after turn/end may read an
|
|
@@ -1081,12 +1252,7 @@ function createHandler(ctx, runtime, sessions, agents) {
|
|
|
1081
1252
|
}
|
|
1082
1253
|
|
|
1083
1254
|
// 4. Restore files to the best snapshot at/before restoreTurn.
|
|
1084
|
-
|
|
1085
|
-
if (restoreTurn !== null) {
|
|
1086
|
-
restoreResult = runtime.store.restore(cwd, sessionId, restoreTurn)
|
|
1087
|
-
} else {
|
|
1088
|
-
restoreResult = { ok: false, error: 'NO_SNAPSHOT' }
|
|
1089
|
-
}
|
|
1255
|
+
const restoreResult = runtime.store.restore(cwd, sessionId, restoreTurn)
|
|
1090
1256
|
|
|
1091
1257
|
// 5. Fork 新会话(DSH 原生,web 的在此分叉按钮同款:ctx.agents.create),
|
|
1092
1258
|
// 并把旧会话标题加上 (已撤销) 前缀后保留。
|
|
@@ -1141,6 +1307,8 @@ export function apply(ctx, config = {}) {
|
|
|
1141
1307
|
maxSnapshotBytes: config.maxSnapshotBytes,
|
|
1142
1308
|
snapshotTtlDays: config.snapshotTtlDays,
|
|
1143
1309
|
maxSnapshotsPerSession: config.maxSnapshotsPerSession,
|
|
1310
|
+
objectGcGraceMs: config.objectGcGraceMs,
|
|
1311
|
+
objectGcIntervalMs: config.objectGcIntervalMs,
|
|
1144
1312
|
})
|
|
1145
1313
|
const runtime = new SnapshotRuntime({
|
|
1146
1314
|
store,
|
|
@@ -1163,70 +1331,92 @@ export function apply(ctx, config = {}) {
|
|
|
1163
1331
|
})
|
|
1164
1332
|
|
|
1165
1333
|
// HTTP API endpoints.
|
|
1166
|
-
ctx.inject(
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
scope.
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1334
|
+
ctx.inject(
|
|
1335
|
+
['webServer', 'sessions', 'sessionQuery', 'agents', 'sessionTitle', 'sessionController', 'workspaceRegistry'],
|
|
1336
|
+
(scope) => {
|
|
1337
|
+
scope.effect(() => {
|
|
1338
|
+
const handler = createHandler(scope, runtime, scope.sessions, scope.agents)
|
|
1339
|
+
scope.webServer.register({
|
|
1340
|
+
kind: 'exact',
|
|
1341
|
+
path: API_PATH,
|
|
1342
|
+
handler,
|
|
1343
|
+
})
|
|
1344
|
+
return () => {}
|
|
1345
|
+
}, 'turn-undo: http-api')
|
|
1346
|
+
},
|
|
1347
|
+
)
|
|
1177
1348
|
}
|
|
1178
1349
|
|
|
1179
1350
|
/**
|
|
1180
1351
|
* Build a manifest from the current workspace state without persisting it.
|
|
1181
|
-
*
|
|
1352
|
+
*
|
|
1353
|
+
* Entries whose size+mtime+mode already match `referenceManifest` reuse the
|
|
1354
|
+
* recorded hash instead of re-reading the bytes — the same short-circuit
|
|
1355
|
+
* capture() uses. Without it every preview re-hashes the whole workspace.
|
|
1356
|
+
* @param cwd - workspace root being scanned.
|
|
1357
|
+
* @param referenceManifest - a prior manifest (usually the latest snapshot).
|
|
1182
1358
|
*/
|
|
1183
|
-
SnapshotStore.prototype.buildLiveManifest = function (cwd) {
|
|
1359
|
+
SnapshotStore.prototype.buildLiveManifest = function (cwd, referenceManifest) {
|
|
1184
1360
|
const ignore = makeIgnore(cwd, this.excludes)
|
|
1185
1361
|
const files = this.scan(cwd, ignore)
|
|
1186
1362
|
if (!files) return null
|
|
1187
1363
|
const manifest = {}
|
|
1188
|
-
for (const
|
|
1189
|
-
const abs = p
|
|
1364
|
+
for (const abs of files) {
|
|
1190
1365
|
let st
|
|
1191
1366
|
try { st = lstatSync(abs) } catch { continue }
|
|
1192
1367
|
if (!st.isFile()) continue
|
|
1193
1368
|
const rel = relative(cwd, abs).split('\\').join('/')
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1369
|
+
const mode = st.mode.toString(8).padStart(4, '0')
|
|
1370
|
+
const prev = referenceManifest && referenceManifest[rel]
|
|
1371
|
+
if (prev && prev.kind === 'file' && prev.hash
|
|
1372
|
+
&& prev.size === st.size && prev.mtime === st.mtimeMs && prev.mode === mode) {
|
|
1373
|
+
manifest[rel] = prev
|
|
1374
|
+
} else {
|
|
1375
|
+
manifest[rel] = {
|
|
1376
|
+
kind: 'file',
|
|
1377
|
+
hash: hashFile(abs),
|
|
1378
|
+
size: st.size,
|
|
1379
|
+
mtime: st.mtimeMs,
|
|
1380
|
+
mode,
|
|
1381
|
+
}
|
|
1200
1382
|
}
|
|
1201
1383
|
}
|
|
1202
1384
|
return manifest
|
|
1203
1385
|
}
|
|
1204
1386
|
|
|
1387
|
+
/** Read a workspace file's text; '' when unreadable (binary, deleted, permission). */
|
|
1388
|
+
SnapshotStore.prototype.readWorkspaceContent = function (cwd, rel) {
|
|
1389
|
+
try { return readFileSync(resolve(cwd, rel), 'utf-8') } catch { return '' }
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1205
1392
|
// Add preview helper to SnapshotStore prototype.
|
|
1206
|
-
// Returns the files that
|
|
1393
|
+
// Returns the files that undo (restoring to the target's baseline) will affect.
|
|
1207
1394
|
SnapshotStore.prototype.preview = function (sessionId, targetTurn, cwd) {
|
|
1208
|
-
const chain = this.loadChain(sessionId)
|
|
1209
1395
|
if (targetTurn === null) {
|
|
1210
1396
|
return { ok: true, status: 'ready', targetTurn: null, totalChanges: 0, changes: [] }
|
|
1211
1397
|
}
|
|
1398
|
+
const chain = this.loadChain(sessionId)
|
|
1399
|
+
if (chain.length === 0) {
|
|
1400
|
+
return { ok: true, status: 'no-snapshot', targetTurn, totalChanges: 0, changes: [], noSnapshot: true }
|
|
1401
|
+
}
|
|
1212
1402
|
|
|
1213
1403
|
// 撤销"发送这条消息之前"会一并回退该消息之后的所有改动,因此影响范围 =
|
|
1214
1404
|
// baseline(targetTurn 之前最近的快照,即恢复到什么状态)与 latest
|
|
1215
1405
|
// (会话最新快照,即撤销点之后累积到当前的状态终点)之差。
|
|
1216
|
-
// baseline 取小于 targetTurn 的最新快照:正常是该 turn/start 快照
|
|
1217
|
-
// (T - 0.5),若中间某 turn 无文件变化没写 manifest,则回退到更早的最近
|
|
1218
|
-
// 快照;对于没有前置快照的首条消息,用空对象 {} 作为基线(即回到空状态)。
|
|
1219
1406
|
let baseline = null
|
|
1220
1407
|
for (const m of chain) {
|
|
1221
1408
|
if (m.turn < targetTurn && (baseline === null || m.turn > baseline.turn)) {
|
|
1222
1409
|
baseline = m
|
|
1223
1410
|
}
|
|
1224
1411
|
}
|
|
1225
|
-
const baselineManifest = baseline ? baseline.manifest : {}
|
|
1226
1412
|
|
|
1227
|
-
|
|
1228
|
-
|
|
1413
|
+
// 目标消息早于最早可用快照:既无法描述也无法执行恢复。把当前所有文件
|
|
1414
|
+
// 都列成 "created" 会宣称"恢复到空工作区",而 restore() 随后必以
|
|
1415
|
+
// NO_SNAPSHOT 拒绝 —— 必须明确报告无基线,而不是给出误导性的文件清单。
|
|
1416
|
+
if (baseline === null) {
|
|
1417
|
+
return { ok: true, status: 'no-baseline', targetTurn, totalChanges: 0, changes: [], noBaseline: true }
|
|
1229
1418
|
}
|
|
1419
|
+
const baselineManifest = baseline.manifest
|
|
1230
1420
|
|
|
1231
1421
|
// 会话最新快照 = 撤销点之后所有改动的累积终点。
|
|
1232
1422
|
const latest = chain[chain.length - 1]
|
|
@@ -1235,55 +1425,54 @@ SnapshotStore.prototype.preview = function (sessionId, targetTurn, cwd) {
|
|
|
1235
1425
|
// yet or snapshots were skipped), compare baseline against the CURRENT
|
|
1236
1426
|
// workspace state instead of the stale latest snapshot. This ensures the
|
|
1237
1427
|
// preview shows meaningful changes even when the active turn has no snapshot.
|
|
1238
|
-
let latestManifest
|
|
1428
|
+
let latestManifest = latest.manifest
|
|
1429
|
+
let latestIsLive = false
|
|
1239
1430
|
if (targetTurn > latest.turn && cwd) {
|
|
1240
|
-
const live = this.buildLiveManifest(cwd)
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1431
|
+
const live = this.buildLiveManifest(cwd, latest.manifest)
|
|
1432
|
+
if (live) {
|
|
1433
|
+
latestManifest = live
|
|
1434
|
+
latestIsLive = true
|
|
1435
|
+
}
|
|
1244
1436
|
}
|
|
1245
1437
|
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
1249
|
-
const changes = []
|
|
1250
|
-
|
|
1251
|
-
// Read manifest contents for diff computation
|
|
1252
|
-
const baselineContents = this.readManifestContents(baselineManifest)
|
|
1253
|
-
const latestContents = this.readManifestContents(latestManifest)
|
|
1254
|
-
|
|
1438
|
+
// ── Collect changed paths FIRST ───────────────────────────────────────
|
|
1439
|
+
// Contents are read only for files whose hash differs; reading every object
|
|
1440
|
+
// in both manifests made one preview cost hundreds of file reads.
|
|
1255
1441
|
const deleted = []
|
|
1256
1442
|
const created = []
|
|
1257
|
-
|
|
1258
|
-
// Collect deleted files
|
|
1443
|
+
const modified = []
|
|
1259
1444
|
for (const rel of Object.keys(baselineManifest)) {
|
|
1260
|
-
if (!latestManifest[rel]) {
|
|
1261
|
-
changes.push({ path: rel, kind: 'deleted' })
|
|
1262
|
-
deleted.push({ path: rel, hash: baselineManifest[rel].hash })
|
|
1263
|
-
}
|
|
1445
|
+
if (!latestManifest[rel]) deleted.push({ path: rel, hash: baselineManifest[rel].hash })
|
|
1264
1446
|
}
|
|
1265
|
-
|
|
1266
|
-
// Collect created and modified files
|
|
1267
1447
|
for (const rel of Object.keys(latestManifest)) {
|
|
1268
1448
|
const entry = latestManifest[rel]
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1449
|
+
const prev = baselineManifest[rel]
|
|
1450
|
+
if (!prev) created.push({ path: rel, hash: entry.hash })
|
|
1451
|
+
else if (entry.hash !== prev.hash) modified.push({ path: rel, oldHash: prev.hash, newHash: entry.hash })
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const changes = []
|
|
1455
|
+
for (const d of deleted) changes.push({ path: d.path, kind: 'deleted' })
|
|
1456
|
+
for (const c of created) changes.push({ path: c.path, kind: 'created' })
|
|
1457
|
+
for (const m of modified) {
|
|
1458
|
+
const oldContent = this.readObjectContent(m.oldHash) || ''
|
|
1459
|
+
// A live entry's bytes were never written to the object store; read the
|
|
1460
|
+
// working tree instead, or the diff would show every old line as removed.
|
|
1461
|
+
const newContent = latestIsLive
|
|
1462
|
+
? this.readWorkspaceContent(cwd, m.path)
|
|
1463
|
+
: (this.readObjectContent(m.newHash) || '')
|
|
1464
|
+
const oldLines = oldContent.split('\n')
|
|
1465
|
+
const newLines = newContent.split('\n')
|
|
1466
|
+
// generateDiff is O(n*m) DP; skip hunks for pathologically large files
|
|
1467
|
+
// rather than allocating a multi-hundred-MB table on a preview click.
|
|
1468
|
+
const tooLarge = oldLines.length * newLines.length > 4_000_000
|
|
1469
|
+
changes.push({
|
|
1470
|
+
path: m.path,
|
|
1471
|
+
kind: 'modified',
|
|
1472
|
+
diff: tooLarge
|
|
1473
|
+
? { oldLines: oldLines.length, newLines: newLines.length, hunks: [], truncated: true }
|
|
1474
|
+
: { oldLines: oldLines.length, newLines: newLines.length, hunks: this.generateDiff(oldLines, newLines, 10) },
|
|
1475
|
+
})
|
|
1287
1476
|
}
|
|
1288
1477
|
|
|
1289
1478
|
// Detect rename/move: same content hash, different path
|