@dsh-xhl/dsh-live-inspector 1.0.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/lib/client.js ADDED
@@ -0,0 +1,3019 @@
1
+ window.__ModuleLoader__.load({
2
+ // Must equal the package name: the loader derives the expected module id from the manifest
3
+ // and reports "loaded without registering <id>" when the two disagree.
4
+ id: '@dsh-xhl/dsh-live-inspector',
5
+ factory(require) {
6
+ const React = require('react');
7
+ const h = React.createElement;
8
+
9
+ // The slot cell key. Internal, and independent of the package name.
10
+ const TAB_ID = 'dsh-live-inspector';
11
+ const TAB_KIND = 'git-tree';
12
+ const FILE_ADDRESS_PREFIX = 'dsh-resource://file/';
13
+ const MAX_RENDERED_DIFF_ROWS = 150;
14
+ const MAX_LINE_CHARACTERS = 400;
15
+
16
+ // Shell tools whose results can imply file changes the file tools never reported.
17
+ const SHELL_TOOL_NAMES = new Set(['bash', 'pwsh', 'shell', 'terminal', 'run_command']);
18
+
19
+ // Global registry of per-session states
20
+ const sessionStates = new Map();
21
+ let currentMountedSessionId = null;
22
+ let globalCtx = null;
23
+
24
+ // ─── Undo engine state ────────────────────────────────────────────────────────
25
+ // Baselines are keyed by targetKey (which folds together the relative and absolute
26
+ // spellings of one file). Artifacts are the exact (before, after) text pairs pulled out
27
+ // of tool/result metadata, which is what makes a hunk-level revert exact, not a guess.
28
+ const baselines = new Map(); // targetKey -> { text, digest, bytes, at }
29
+
30
+ // The text a file had when each turn first touched it. This is what makes a per-turn
31
+ // undo safe: reverting turn N restores the state the file entered turn N with, so every
32
+ // earlier turn's work survives.
33
+ const turnStarts = new Map(); // targetKey -> Map(turn -> { text, digest, bytes })
34
+
35
+ const listeners = new Set();
36
+ let notifyTimer = null;
37
+ function notify() {
38
+ if (notifyTimer) return;
39
+ notifyTimer = setTimeout(() => {
40
+ notifyTimer = null;
41
+ for (const listener of listeners) {
42
+ try {
43
+ listener();
44
+ } catch (e) {
45
+ console.warn('[@dsh-xhl/dsh-live-inspector] listener error:', e);
46
+ }
47
+ }
48
+ }, 40);
49
+ }
50
+
51
+ function getSessionState(sid) {
52
+ if (!sid) return null;
53
+ let s = sessionStates.get(sid);
54
+ if (!s) {
55
+ s = {
56
+ sessionId: sid,
57
+ files: {}, // path -> { path, key, name, dir, ext, status, active, timestamp, line, count, phases, diffs, edits }
58
+ activePath: null,
59
+ filter: 'all',
60
+ searchQuery: '',
61
+ selectedPath: null,
62
+ processedSeqs: new Set(),
63
+ autoOpenOpenedThisTurn: false,
64
+ reviewUrl: null,
65
+ cwd: null,
66
+ toast: null,
67
+ confirmingAll: false,
68
+ currentTurn: null,
69
+ selectedTurn: null,
70
+ showAllTurns: false,
71
+ turns: [],
72
+ dirtyTimers: new Map()
73
+ };
74
+ sessionStates.set(sid, s);
75
+ }
76
+ return s;
77
+ }
78
+
79
+ function resolveCurrentSessionId() {
80
+ // 1. Session currently visible in Main View
81
+ try {
82
+ const list = globalCtx?.sessions?.list?.getSnapshot();
83
+ if (list && list.byId) {
84
+ for (const id in list.byId) {
85
+ if ((list.byId[id]?.retainedBy?.mainView ?? 0) > 0) {
86
+ return id;
87
+ }
88
+ }
89
+ }
90
+ } catch {}
91
+
92
+ // 2. sidebarRight mounted session
93
+ try {
94
+ const sid = globalCtx?.sidebarRight?.mounted?.getSnapshot();
95
+ if (sid) return sid;
96
+ } catch {}
97
+
98
+ // 3. Fallback to cached or first available session
99
+ if (currentMountedSessionId) return currentMountedSessionId;
100
+ try {
101
+ const ids = globalCtx?.sessions?.list?.getSnapshot()?.ids;
102
+ if (ids && ids.length > 0) return ids[0];
103
+ } catch {}
104
+
105
+ return null;
106
+ }
107
+
108
+ function encodeSegment(s) {
109
+ return encodeURIComponent(s).replace(/%3A/gi, ':');
110
+ }
111
+ function encodePath(p) {
112
+ return p.split('/').map(encodeSegment).join('/');
113
+ }
114
+ function sessionFileAddress(sessionId, path) {
115
+ const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '');
116
+ return FILE_ADDRESS_PREFIX + 'session/' + encodeSegment(sessionId) + '/' + encodePath(normalized);
117
+ }
118
+
119
+ // High-precision token/word-level diff with guard against huge lines
120
+ function wordDiff(oldStr, newStr) {
121
+ if (!oldStr && !newStr) return { oldParts: [], newParts: [] };
122
+ if (!oldStr) return { oldParts: [], newParts: [{ text: newStr, changed: true }] };
123
+ if (!newStr) return { oldParts: [{ text: oldStr, changed: true }], newParts: [] };
124
+ if (oldStr === newStr) {
125
+ return {
126
+ oldParts: [{ text: oldStr, changed: false }],
127
+ newParts: [{ text: newStr, changed: false }]
128
+ };
129
+ }
130
+
131
+ // Performance cap on extremely long minified lines
132
+ if (oldStr.length > MAX_LINE_CHARACTERS || newStr.length > MAX_LINE_CHARACTERS) {
133
+ return {
134
+ oldParts: [{ text: oldStr.slice(0, MAX_LINE_CHARACTERS) + (oldStr.length > MAX_LINE_CHARACTERS ? '…' : ''), changed: true }],
135
+ newParts: [{ text: newStr.slice(0, MAX_LINE_CHARACTERS) + (newStr.length > MAX_LINE_CHARACTERS ? '…' : ''), changed: true }]
136
+ };
137
+ }
138
+
139
+ const tokenRegex = /(\s+|[a-zA-Z0-9_$]+|[^\s\w])/g;
140
+ const oldTokens = oldStr.match(tokenRegex) || [oldStr];
141
+ const newTokens = newStr.match(tokenRegex) || [newStr];
142
+
143
+ let prefix = 0;
144
+ while (prefix < oldTokens.length && prefix < newTokens.length && oldTokens[prefix] === newTokens[prefix]) {
145
+ prefix++;
146
+ }
147
+
148
+ let suffix = 0;
149
+ while (
150
+ suffix < (oldTokens.length - prefix) &&
151
+ suffix < (newTokens.length - prefix) &&
152
+ oldTokens[oldTokens.length - 1 - suffix] === newTokens[newTokens.length - 1 - suffix]
153
+ ) {
154
+ suffix++;
155
+ }
156
+
157
+ const oldPrefix = oldTokens.slice(0, prefix).join('');
158
+ const oldMid = oldTokens.slice(prefix, oldTokens.length - suffix).join('');
159
+ const oldSuffix = oldTokens.slice(oldTokens.length - suffix).join('');
160
+
161
+ const newPrefix = newTokens.slice(0, prefix).join('');
162
+ const newMid = newTokens.slice(prefix, newTokens.length - suffix).join('');
163
+ const newSuffix = newTokens.slice(newTokens.length - suffix).join('');
164
+
165
+ return {
166
+ oldParts: [
167
+ { text: oldPrefix, changed: false },
168
+ { text: oldMid, changed: true },
169
+ { text: oldSuffix, changed: false }
170
+ ].filter(p => p.text.length > 0),
171
+ newParts: [
172
+ { text: newPrefix, changed: false },
173
+ { text: newMid, changed: true },
174
+ { text: newSuffix, changed: false }
175
+ ].filter(p => p.text.length > 0)
176
+ };
177
+ }
178
+
179
+ // Professional line-aligned split diff calculation
180
+ function computeSplitDiff(oldText, newText, startLine = 1) {
181
+ let rawOldLines = typeof oldText === 'string' ? oldText.split('\n') : [];
182
+ let rawNewLines = typeof newText === 'string' ? newText.split('\n') : [];
183
+
184
+ let prefixCount = 0;
185
+ while (prefixCount < rawOldLines.length && prefixCount < rawNewLines.length && rawOldLines[prefixCount] === rawNewLines[prefixCount]) {
186
+ prefixCount++;
187
+ }
188
+
189
+ let suffixCount = 0;
190
+ while (
191
+ suffixCount < (rawOldLines.length - prefixCount) &&
192
+ suffixCount < (rawNewLines.length - prefixCount) &&
193
+ rawOldLines[rawOldLines.length - 1 - suffixCount] === rawNewLines[rawNewLines.length - 1 - suffixCount]
194
+ ) {
195
+ suffixCount++;
196
+ }
197
+
198
+ const oldDiff = rawOldLines.slice(prefixCount, rawOldLines.length - suffixCount);
199
+ const newDiff = rawNewLines.slice(prefixCount, rawNewLines.length - suffixCount);
200
+
201
+ const rows = [];
202
+ let curOldNo = startLine;
203
+ let curNewNo = startLine;
204
+
205
+ // Prefix context lines (up to 3)
206
+ const contextPrefixStart = Math.max(0, prefixCount - 3);
207
+ for (let i = contextPrefixStart; i < prefixCount; i++) {
208
+ rows.push({
209
+ left: { no: curOldNo + i, text: rawOldLines[i], kind: 'context', parts: [{ text: rawOldLines[i], changed: false }] },
210
+ right: { no: curNewNo + i, text: rawNewLines[i], kind: 'context', parts: [{ text: rawNewLines[i], changed: false }] }
211
+ });
212
+ }
213
+ curOldNo += prefixCount;
214
+ curNewNo += prefixCount;
215
+
216
+ // Changed lines (aligned side-by-side with word-level diffing)
217
+ const maxDiff = Math.max(oldDiff.length, newDiff.length);
218
+ for (let i = 0; i < maxDiff; i++) {
219
+ const oldL = i < oldDiff.length ? oldDiff[i] : null;
220
+ const newL = i < newDiff.length ? newDiff[i] : null;
221
+
222
+ let parts = { oldParts: [], newParts: [] };
223
+ if (oldL !== null && newL !== null) {
224
+ parts = wordDiff(oldL, newL);
225
+ } else if (oldL !== null) {
226
+ parts.oldParts = [{ text: oldL, changed: true }];
227
+ } else if (newL !== null) {
228
+ parts.newParts = [{ text: newL, changed: true }];
229
+ }
230
+
231
+ const leftCell = oldL !== null ? {
232
+ no: curOldNo + i,
233
+ text: oldL,
234
+ kind: 'del',
235
+ parts: parts.oldParts
236
+ } : null;
237
+
238
+ const rightCell = newL !== null ? {
239
+ no: curNewNo + i,
240
+ text: newL,
241
+ kind: 'add',
242
+ parts: parts.newParts
243
+ } : null;
244
+
245
+ rows.push({ left: leftCell, right: rightCell });
246
+ }
247
+ curOldNo += oldDiff.length;
248
+ curNewNo += newDiff.length;
249
+
250
+ // Suffix context lines (up to 3)
251
+ const suffixLines = Math.min(3, suffixCount);
252
+ for (let i = 0; i < suffixLines; i++) {
253
+ const oldIdx = rawOldLines.length - suffixCount + i;
254
+ const newIdx = rawNewLines.length - suffixCount + i;
255
+ rows.push({
256
+ left: { no: curOldNo + i, text: rawOldLines[oldIdx], kind: 'context', parts: [{ text: rawOldLines[oldIdx], changed: false }] },
257
+ right: { no: curNewNo + i, text: rawNewLines[newIdx], kind: 'context', parts: [{ text: rawNewLines[newIdx], changed: false }] }
258
+ });
259
+ }
260
+
261
+ const truncated = rows.length > MAX_RENDERED_DIFF_ROWS;
262
+ const displayRows = truncated ? rows.slice(0, MAX_RENDERED_DIFF_ROWS) : rows;
263
+
264
+ return {
265
+ rows: displayRows,
266
+ totalRows: rows.length,
267
+ truncated: truncated,
268
+ oldLinesCount: rawOldLines.length,
269
+ newLinesCount: rawNewLines.length,
270
+ delCount: oldDiff.length,
271
+ addCount: newDiff.length
272
+ };
273
+ }
274
+
275
+ // GitHub-style 5-square diffstat mini bar
276
+ function DiffStatBar({ addCount, delCount }) {
277
+ const total = addCount + delCount;
278
+ if (total === 0) return null;
279
+ const greenBoxes = Math.min(5, Math.max(addCount > 0 ? 1 : 0, Math.round((addCount / total) * 5)));
280
+ const redBoxes = Math.min(5 - greenBoxes, Math.max(delCount > 0 ? 1 : 0, 5 - greenBoxes));
281
+ const grayBoxes = Math.max(0, 5 - greenBoxes - redBoxes);
282
+
283
+ return h('span', {
284
+ title: '+' + addCount + ' / -' + delCount,
285
+ style: { display: 'inline-flex', gap: '2px', alignItems: 'center' }
286
+ },
287
+ Array.from({ length: greenBoxes }).map((_, i) =>
288
+ h('span', { key: 'g' + i, style: { width: '5px', height: '5px', borderRadius: '1px', background: '#10b981' } })
289
+ ),
290
+ Array.from({ length: redBoxes }).map((_, i) =>
291
+ h('span', { key: 'r' + i, style: { width: '5px', height: '5px', borderRadius: '1px', background: '#ef4444' } })
292
+ ),
293
+ Array.from({ length: grayBoxes }).map((_, i) =>
294
+ h('span', { key: 'gr' + i, style: { width: '5px', height: '5px', borderRadius: '1px', background: 'rgba(255,255,255,0.15)' } })
295
+ )
296
+ );
297
+ }
298
+
299
+ // Extension Badge
300
+ function FileExtBadge({ ext }) {
301
+ let bg = 'rgba(255,255,255,0.06)';
302
+ let color = 'inherit';
303
+ let label = (ext || '').toUpperCase().slice(0, 4);
304
+
305
+ if (ext === 'ts' || ext === 'tsx') {
306
+ bg = 'rgba(49, 120, 198, 0.2)';
307
+ color = '#60a5fa';
308
+ } else if (ext === 'js' || ext === 'jsx') {
309
+ bg = 'rgba(247, 223, 30, 0.2)';
310
+ color = '#facc15';
311
+ } else if (ext === 'json') {
312
+ bg = 'rgba(16, 185, 129, 0.2)';
313
+ color = '#34d399';
314
+ } else if (ext === 'css' || ext === 'scss') {
315
+ bg = 'rgba(236, 72, 153, 0.2)';
316
+ color = '#f472b6';
317
+ } else if (ext === 'md') {
318
+ bg = 'rgba(168, 85, 247, 0.2)';
319
+ color = '#c084fc';
320
+ }
321
+
322
+ return h('span', {
323
+ style: {
324
+ padding: '1px 4px',
325
+ borderRadius: '3px',
326
+ fontSize: '9px',
327
+ fontWeight: 700,
328
+ fontFamily: 'monospace',
329
+ background: bg,
330
+ color: color,
331
+ flexShrink: 0
332
+ }
333
+ }, label || 'FILE');
334
+ }
335
+
336
+ // ─── Undo engine ──────────────────────────────────────────────────────────────
337
+
338
+ /** The session log records relative paths; anchor them on the session's own cwd. */
339
+ function normalizeCwd(value) {
340
+ if (typeof value !== 'string' || value.length === 0) return null;
341
+ const clean = value.replace(/\\/g, '/').replace(/\/+$/, '');
342
+ return clean.length > 0 ? clean : null;
343
+ }
344
+
345
+ function keyOfPath(sessionId, filePath) {
346
+ const clean = String(filePath || '').replace(/\\/g, '/').replace(/^(?:\.\/)+/, '');
347
+ const st = getSessionState(sessionId);
348
+ const cwd = normalizeCwd(st && st.cwd);
349
+ if (/^[a-zA-Z]:\//.test(clean) || clean.startsWith('/')) {
350
+ return clean.toLowerCase();
351
+ }
352
+ return cwd ? (cwd + '/' + clean).toLowerCase() : clean.toLowerCase();
353
+ }
354
+
355
+ /** Strip the session cwd so a baseline lookup can match a log path. */
356
+ function aliasOfPath(sessionId, filePath) {
357
+ const clean = String(filePath || '').replace(/\\/g, '/').replace(/^(?:\.\/)+/, '');
358
+ const st = getSessionState(sessionId);
359
+ const cwd = normalizeCwd(st && st.cwd);
360
+ if (cwd && clean.toLowerCase().startsWith(cwd + '/')) {
361
+ return clean.slice(cwd.length + 1);
362
+ }
363
+ return null;
364
+ }
365
+
366
+ /** Pull the working directory out of whatever entries happen to carry it. */
367
+ function readCwdFromEntries(sessionId, entries) {
368
+ for (const entry of entries) {
369
+ if (!entry) continue;
370
+ const ev = entry.type === 'event' ? entry.event : null;
371
+ if (!ev || !ev.data) continue;
372
+ const raw = ev.data.cwd;
373
+ if (typeof raw === 'string' && raw.length > 0) {
374
+ const st = getSessionState(sessionId);
375
+ const cwd = normalizeCwd(raw);
376
+ if (st && cwd) st.cwd = cwd;
377
+ return;
378
+ }
379
+ }
380
+ }
381
+
382
+ // ─── Host filesystem seam ─────────────────────────────────────────────────────
383
+ // A bundle Client module cannot reach its Host half two other ways, so this uses the one
384
+ // that works: the Host half mounts its own HTTP routes on the Harness web server and this
385
+ // half calls them with plain `fetch`.
386
+ //
387
+ // host.call(...) only exists for DYNAMIC packages (injected as a closure
388
+ // parameter); a bundle's `require` has no such module.
389
+ // ctx.remote.<namespace> the composition mounts a fixed set of Typert namespaces and
390
+ // offers no hook for a third-party bundle to add one.
391
+ // fetch('/liveinspector/api') a WebRoute this package owns — what shipped plugins use.
392
+ //
393
+ // Endpoints answer `{ ok: true, value }` or `{ ok: false, error }`; every failure is
394
+ // converted to the answered shape the rest of this file reasons about, so a refused path
395
+ // is a toast rather than an unhandled rejection.
396
+
397
+ const API_PREFIX = '/liveinspector/api';
398
+
399
+ /** Call one Host endpoint. Never throws: a transport or HTTP failure is an answer. */
400
+ async function callFs(method, payload) {
401
+ let response;
402
+ try {
403
+ response = await fetch(`${API_PREFIX}/${method}`, {
404
+ method: 'POST',
405
+ headers: { 'content-type': 'application/json' },
406
+ body: JSON.stringify(payload)
407
+ });
408
+ } catch (error) {
409
+ return { ok: false, error: `无法连接到 Host:${String((error && error.message) || error)}` };
410
+ }
411
+
412
+ let parsed = null;
413
+ try {
414
+ parsed = await response.json();
415
+ } catch {
416
+ return { ok: false, error: `Host 返回 ${response.status},但没有 JSON 响应体` };
417
+ }
418
+ if (!parsed || typeof parsed !== 'object') {
419
+ return { ok: false, error: `Host 返回 ${response.status},响应体格式不符合预期` };
420
+ }
421
+ if (parsed.ok !== true) {
422
+ const message = parsed.error && parsed.error.message ? parsed.error.message : `Host 返回 ${response.status}`;
423
+ return { ok: false, error: message };
424
+ }
425
+ const value = parsed.value;
426
+ if (value === null || typeof value !== 'object') {
427
+ return { ok: false, error: 'Host 没有返回结果' };
428
+ }
429
+ return value;
430
+ }
431
+
432
+ /** The session working directory, which relative tool paths resolve against. */
433
+ function cwdOf(sessionId) {
434
+ const st = getSessionState(sessionId);
435
+ return (st && st.cwd) || undefined;
436
+ }
437
+
438
+ /**
439
+ * Capture the pre-edit text of one file exactly once, before its first change.
440
+ *
441
+ * This runs on the first mutating tool call, when the file is still untouched — the only
442
+ * moment the "before" state exists on disk. A read before the first mutation also counts
443
+ * as untouched, which is what catches a write-after-read sequence.
444
+ */
445
+ async function ensureBaseline(sessionId, filePath) {
446
+ const st = getSessionState(sessionId);
447
+ if (!st) return null;
448
+ const key = keyOfPath(sessionId, filePath);
449
+ if (baselines.has(key)) return baselines.get(key);
450
+
451
+ // Claim the slot synchronously so concurrent tool events cannot race.
452
+ const pending = { text: null, digest: null, bytes: 0, at: Date.now(), loading: true };
453
+ baselines.set(key, pending);
454
+
455
+ let record;
456
+ try {
457
+ const answer = await callFs('fs.read', { path: filePath, cwd: cwdOf(sessionId) });
458
+ if (answer && answer.ok === true && !answer.omitted) {
459
+ record = { text: answer.text, digest: answer.digest || null, bytes: answer.bytes || 0, at: Date.now(), loading: false };
460
+ } else if (answer && answer.ok === true) {
461
+ // Oversized: the digest still proves identity, but a restore is refused.
462
+ record = { text: null, digest: answer.digest || null, bytes: answer.bytes || 0, at: Date.now(), loading: false, oversized: true };
463
+ } else {
464
+ record = { text: null, digest: null, bytes: 0, at: Date.now(), loading: false, unavailable: true };
465
+ }
466
+ } catch (error) {
467
+ record = { text: null, digest: null, bytes: 0, at: Date.now(), loading: false, unavailable: true };
468
+ console.debug('[@dsh-xhl/dsh-live-inspector] no revert baseline for', filePath, error);
469
+ }
470
+
471
+ baselines.set(key, record);
472
+ notify();
473
+ return record;
474
+ }
475
+
476
+ function baselineOf(sessionId, filePath) {
477
+ return baselines.get(keyOfPath(sessionId, filePath)) || null;
478
+ }
479
+
480
+ /**
481
+ * Snapshot a file's text the first time a given turn touches it.
482
+ *
483
+ * The read happens *before* that turn's first mutation, so the snapshot is exactly the
484
+ * state the file entered the turn with — every earlier turn's work included. Reverting a
485
+ * turn writes this back, which is why a turn-scoped undo cannot reach past its own turn.
486
+ */
487
+ async function ensureTurnStart(sessionId, filePath, turn) {
488
+ if (typeof turn !== 'number') return null;
489
+ const key = keyOfPath(sessionId, filePath);
490
+ let byTurn = turnStarts.get(key);
491
+ if (!byTurn) {
492
+ byTurn = new Map();
493
+ turnStarts.set(key, byTurn);
494
+ }
495
+ if (byTurn.has(turn)) return byTurn.get(turn);
496
+
497
+ const pending = { text: null, digest: null, bytes: 0, loading: true };
498
+ byTurn.set(turn, pending);
499
+
500
+ let record;
501
+ try {
502
+ const answer = await callFs('fs.read', { path: filePath, cwd: cwdOf(sessionId) });
503
+ if (answer && answer.ok === true && !answer.omitted) {
504
+ record = { text: answer.text, digest: answer.digest || null, bytes: answer.bytes || 0, loading: false };
505
+ } else if (answer && answer.ok === true) {
506
+ record = { text: null, digest: answer.digest || null, bytes: answer.bytes || 0, loading: false, oversized: true };
507
+ } else {
508
+ record = { text: null, digest: null, bytes: 0, loading: false, unavailable: true };
509
+ }
510
+ } catch (error) {
511
+ record = { text: null, digest: null, bytes: 0, loading: false, unavailable: true };
512
+ console.debug('[@dsh-xhl/dsh-live-inspector] no turn snapshot for', filePath, 'turn', turn, error);
513
+ }
514
+
515
+ byTurn.set(turn, record);
516
+ notify();
517
+ return record;
518
+ }
519
+
520
+ function turnStartOf(sessionId, filePath, turn) {
521
+ const byTurn = turnStarts.get(keyOfPath(sessionId, filePath));
522
+ return (byTurn && byTurn.get(turn)) || null;
523
+ }
524
+
525
+ /** Every turn that touched this file, ascending. */
526
+ function turnsOfFile(sessionId, file) {
527
+ const key = (file && file.key) || keyOfPath(sessionId, file.path);
528
+ const byTurn = turnStarts.get(key);
529
+ if (!byTurn) return [];
530
+ return Array.from(byTurn.keys()).sort((a, b) => a - b);
531
+ }
532
+
533
+ function hasRevertibleBaseline(sessionId, file) {
534
+ if (!file || file.status === 'R' || file.removed) return false;
535
+ const key = file.key || keyOfPath(sessionId, file.path);
536
+ if (baselines.has(key)) return true;
537
+ const alias = aliasOfPath(sessionId, file.path);
538
+ if (alias && baselines.has(alias)) return true;
539
+ // A file the agent created is revertible by deletion even with no baseline.
540
+ return file.status === 'A';
541
+ }
542
+
543
+ /** Narrow tool/result meta into the exact (before / after) pairs the diff cards use.
544
+ * A null `oldText` is meaningful: it means the file did not exist before the call. */
545
+ function extractArtifactsFromResult(meta) {
546
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return null;
547
+ const diffs = meta.diffs;
548
+ if (!Array.isArray(diffs) || diffs.length === 0) return null;
549
+ const artifacts = [];
550
+ for (const diff of diffs) {
551
+ if (!diff || typeof diff !== 'object') continue;
552
+ if (typeof diff.path !== 'string' || typeof diff.newText !== 'string') continue;
553
+ if (diff.oldText !== null && typeof diff.oldText !== 'string') continue;
554
+ artifacts.push({ path: diff.path, oldText: diff.oldText, newText: diff.newText });
555
+ }
556
+ return artifacts.length > 0 ? artifacts : null;
557
+ }
558
+
559
+ /** Whether a recorded artifact vouches for this hunk's original text. */
560
+ function artifactWitnesses(phases, oldText) {
561
+ if (oldText.length === 0) return false;
562
+ for (const phase of phases || []) {
563
+ for (const artifact of phase.artifacts || []) {
564
+ if (artifact.oldText === null) continue;
565
+ if (artifact.oldText.indexOf(oldText) !== -1) return true;
566
+ }
567
+ }
568
+ return false;
569
+ }
570
+
571
+ /**
572
+ * Compute the anchor that lets ONE hunk be undone on its own.
573
+ *
574
+ * A tool/result artifact is the (before, after) pair of one ENTIRE tool call, and one call
575
+ * can produce several hunks — so neither the artifact's `after` nor a slice of its `before`
576
+ * identifies a single hunk. The hunk itself does: it carries its exact old text and its
577
+ * exact replacement text, plus the diff's context lines on either side, and that whole
578
+ * shape is what makes it locatable in the LIVE file (which, by the time the user clicks,
579
+ * may already hold this call's other hunks and later edits too).
580
+ *
581
+ * Returns `{ anchor }` on success or `{ reason }` naming the precise refusal, so the panel
582
+ * can tell the user WHICH condition blocked them instead of one catch-all sentence.
583
+ */
584
+ function hunkAnchorOutcome(file, hunkIndex) {
585
+ const hunk = (file && file.diffs ? file.diffs : [])[hunkIndex];
586
+ if (!hunk) return { reason: '这处改动已不在记录中' };
587
+
588
+ const oldJoined = hunk.oldText.join('\n');
589
+ const newJoined = hunk.newText.join('\n');
590
+ if (oldJoined.length === 0) {
591
+ return { reason: '这处改动只是新增了若干行,没有可还原的原文' };
592
+ }
593
+ if (oldJoined === newJoined) {
594
+ return { reason: '这处改动实际没有改变任何文本' };
595
+ }
596
+ if (!hunk.replaceAll && hunk.newText.length === 0) {
597
+ return { reason: '这处改动只是删除了若干行,请改用「撤销整轮」' };
598
+ }
599
+ if (!artifactWitnesses(file && file.phases, oldJoined)) {
600
+ return {
601
+ reason: '没有记录这处改动确切的改前/改后文本' +
602
+ '(它来自 edit/write 之外的工具,或来自历史回放),' +
603
+ '因此无法单独撤销,请改用「撤销整轮」'
604
+ };
605
+ }
606
+
607
+ return { anchor: { sliceBefore: oldJoined, sliceAfter: newJoined, replaceAll: !!hunk.replaceAll } };
608
+ }
609
+
610
+ /** The anchor for one hunk, or null when it has none. See {@link hunkAnchorOutcome}. */
611
+ function hunkAnchorOf(file, hunkIndex) {
612
+ return hunkAnchorOutcome(file, hunkIndex).anchor || null;
613
+ }
614
+
615
+ /** Resolve an anchor against the live file text, refusing drift and ambiguity. */
616
+ function locateAnchor(anchor, currentText) {
617
+ // `replace_all` has no single span: it is revertible only while every occurrence the
618
+ // call produced is still present and the original text has not come back.
619
+ if (anchor.replaceAll) {
620
+ if (anchor.sliceBefore === anchor.sliceAfter) return null;
621
+ if (countOccurrences(currentText, anchor.sliceAfter) === 0) return null;
622
+ if (countOccurrences(currentText, anchor.sliceBefore) > 0) return null;
623
+ return { mode: 'all', from: anchor.sliceAfter, to: anchor.sliceBefore };
624
+ }
625
+
626
+ const afterCount = countOccurrences(currentText, anchor.sliceAfter);
627
+ const beforeCount = countOccurrences(currentText, anchor.sliceBefore);
628
+
629
+ // Normally the file holds this hunk's post-change shape. If instead only the original
630
+ // shape survives, the change is already reverted.
631
+ if (beforeCount === 1 && afterCount === 0) return { mode: 'already' };
632
+ if (afterCount !== 1) return null;
633
+ return { mode: 'single', from: anchor.sliceAfter, to: anchor.sliceBefore };
634
+ }
635
+
636
+ function countOccurrences(haystack, needle) {
637
+ if (!needle || needle.length === 0) return 0;
638
+ let count = 0;
639
+ let index = haystack.indexOf(needle);
640
+ while (index !== -1) {
641
+ count++;
642
+ index = haystack.indexOf(needle, index + needle.length);
643
+ }
644
+ return count;
645
+ }
646
+
647
+ /**
648
+ * Reconstruct the old/new text a diff card DISPLAYS.
649
+ *
650
+ * A tool/result artifact is the (before, after) pair of one ENTIRE tool call, and one call
651
+ * can produce several hunks — so the artifact's `after` is never the right "new text" for a
652
+ * single hunk. The hunk's own recorded lines are: they are exactly what that change did, in
653
+ * both directions. Only the whole-file view needs an artifact.
654
+ */
655
+ function resolveCardTexts(sessionId, file, artifactIndex, hunkIndex) {
656
+ const phases = (file && file.phases) || [];
657
+ const artifacts = [];
658
+ for (const item of phases) {
659
+ for (const artifact of item.artifacts || []) artifacts.push(artifact);
660
+ }
661
+
662
+ // Whole-file view: the file's first recorded baseline against its newest recorded text.
663
+ if (hunkIndex === undefined || hunkIndex === null) {
664
+ if (artifacts.length === 0) {
665
+ const last = phases[phases.length - 1];
666
+ if (!last) return null;
667
+ return { oldText: last.oldText, newText: last.newText, exact: false, whole: true };
668
+ }
669
+ const first = artifacts[0];
670
+ const newest = artifacts[artifacts.length - 1];
671
+ return { oldText: first.oldText, newText: newest.newText, exact: true, whole: true };
672
+ }
673
+
674
+ const hunk = (file.diffs || [])[hunkIndex];
675
+ if (!hunk) return null;
676
+ return {
677
+ oldText: hunk.oldText.join('\n'),
678
+ newText: hunk.newText.join('\n'),
679
+ exact: !!hunkAnchorOf(file, hunkIndex),
680
+ whole: false
681
+ };
682
+ }
683
+
684
+ function verifyEditFile(file) {
685
+ if (file.status === 'R') {
686
+ throw new Error('这个文件在本次会话中只被读取过');
687
+ }
688
+ if (file.dirty === true) {
689
+ throw new Error('智能体正在写入这个文件,请等这一步结束后再撤销');
690
+ }
691
+ }
692
+
693
+ function setToast(sessionId, message, tone) {
694
+ const st = getSessionState(sessionId);
695
+ if (!st) return;
696
+ st.toast = { message, tone: tone || 'info', at: Date.now() };
697
+ notify();
698
+ }
699
+
700
+ /** Read the live file through the Host half, refusing anything not plainly revertible. */
701
+ async function readCurrentText(sessionId, filePath) {
702
+ const answer = await callFs('fs.read', { path: filePath, cwd: cwdOf(sessionId) });
703
+ if (!answer || answer.ok !== true) {
704
+ throw new Error((answer && answer.error) || '读取文件失败');
705
+ }
706
+ if (answer.omitted) {
707
+ throw new Error('文件太大,无法从这个面板替换');
708
+ }
709
+ return { text: answer.text, digest: answer.digest };
710
+ }
711
+
712
+ async function writeText(sessionId, filePath, content) {
713
+ const answer = await callFs('fs.write', { path: filePath, content, cwd: cwdOf(sessionId) });
714
+ if (!answer || answer.ok !== true) {
715
+ throw new Error((answer && answer.error) || '写入文件失败');
716
+ }
717
+ return answer;
718
+ }
719
+
720
+ async function deleteFile(sessionId, filePath) {
721
+ const answer = await callFs('fs.delete', { path: filePath, cwd: cwdOf(sessionId) });
722
+ if (!answer || answer.ok !== true) {
723
+ throw new Error((answer && answer.error) || '删除文件失败');
724
+ }
725
+ return answer;
726
+ }
727
+
728
+ /**
729
+ * Files the session has recorded that no longer exist on disk.
730
+ *
731
+ * A shell command can delete a file with no file tool involved, and the shell result
732
+ * carries only command output — no structured file operations. So the panel probes the
733
+ * paths it already knows about and treats a vanished one as a deletion it can offer to
734
+ * undo, using the text it captured when the file was last read or written.
735
+ */
736
+ async function detectExternalDeletions(sessionId) {
737
+ const st = getSessionState(sessionId);
738
+ if (!st) return;
739
+
740
+ const candidates = Object.values(st.files).filter(f =>
741
+ !f.removed &&
742
+ f.status !== 'A' &&
743
+ !f.dirty &&
744
+ !f.missing &&
745
+ recoverableTextOf(f) !== null
746
+ );
747
+ if (candidates.length === 0) return;
748
+
749
+ for (const file of candidates) {
750
+ try {
751
+ const answer = await callFs('fs.stat', { path: file.path, cwd: cwdOf(sessionId) });
752
+ if (!answer || answer.ok !== true) continue;
753
+ if (answer.exists === false) {
754
+ const record = st.files[file.path];
755
+ if (!record) continue;
756
+ record.missing = true;
757
+ record.status = 'D';
758
+ record.removed = true;
759
+ record.removedBy = 'shell';
760
+ notify();
761
+ }
762
+ } catch (error) {
763
+ // A probe failure is not evidence of deletion.
764
+ console.debug('[@dsh-xhl/dsh-live-inspector] stat probe failed for', file.path, error);
765
+ }
766
+ }
767
+ }
768
+
769
+ /** The best text we hold for a file, used to write it back after a deletion. */
770
+ function recoverableTextOf(file) {
771
+ if (!file) return null;
772
+ const phases = file.phases || [];
773
+ // Prefer the newest recorded content.
774
+ for (let i = phases.length - 1; i >= 0; i--) {
775
+ const phase = phases[i];
776
+ for (let j = (phase.artifacts || []).length - 1; j >= 0; j--) {
777
+ const artifact = phase.artifacts[j];
778
+ if (typeof artifact.newText === 'string') return artifact.newText;
779
+ }
780
+ }
781
+ // Fall back to the pre-session baseline for a file that was only ever read.
782
+ return null;
783
+ }
784
+
785
+ /**
786
+ * Revert ONLY one turn's work in one file.
787
+ *
788
+ * Restores the snapshot taken when that turn first touched the file, so the file returns
789
+ * to exactly the state it entered the turn with — every earlier turn's changes survive,
790
+ * and any later turn's changes are reported as drift rather than silently discarded.
791
+ */
792
+ async function revertTurnForFile(sessionId, file, turn) {
793
+ if (!file) return { ok: false, reason: '没有文件' };
794
+ try {
795
+ verifyEditFile(file);
796
+ const snapshot = turnStartOf(sessionId, file.path, turn);
797
+ if (!snapshot) {
798
+ throw new Error('没有为这个文件捕获 ' + (turn + 1) + ' 轮的快照');
799
+ }
800
+ if (snapshot.text === null) {
801
+ throw new Error('第 ' + (turn + 1) + ' 轮没有文本快照(无法读取或超出大小上限)');
802
+ }
803
+
804
+ const current = await readCurrentText(sessionId, file.path);
805
+ if (current.text === snapshot.text) {
806
+ return { ok: true, unchanged: true };
807
+ }
808
+
809
+ // A later turn changed this file: reverting now would discard that work too.
810
+ if (typeof turn === 'number' && turn < latestTurnOfFile(sessionId, file)) {
811
+ const laterCurrent = turnStartOf(sessionId, file.path, nextTurnOfFile(sessionId, file, turn));
812
+ if (laterCurrent && laterCurrent.text !== null && current.text !== laterCurrent.text) {
813
+ throw new Error('更后面的轮次也改过这个文件,请先撤销最新的那一轮');
814
+ }
815
+ }
816
+
817
+ await writeText(sessionId, file.path, snapshot.text);
818
+
819
+ // The file now matches this turn's entry state; it still carries earlier turns.
820
+ const st = getSessionState(sessionId);
821
+ const record = st && st.files[file.path];
822
+ if (record) {
823
+ record.dirty = false;
824
+ record.confirming = null;
825
+ if (turn === earliestTurnOfFile(sessionId, file)) {
826
+ // Nothing earlier remains, so the file really is back to pre-session content.
827
+ record.status = record.removed ? record.status : 'R';
828
+ record.reverted = true;
829
+ }
830
+ }
831
+ return { ok: true };
832
+ } catch (error) {
833
+ return { ok: false, reason: String((error && error.message) || error) };
834
+ }
835
+ }
836
+
837
+ function turnsForFileWithChanges(sessionId, file) {
838
+ if (!file) return [];
839
+ const seen = new Set();
840
+ for (const phase of file.phases || []) {
841
+ if (typeof phase.turn === 'number') seen.add(phase.turn);
842
+ }
843
+ return Array.from(seen).sort((a, b) => a - b);
844
+ }
845
+
846
+ function earliestTurnOfFile(sessionId, file) {
847
+ const turns = turnsForFileWithChanges(sessionId, file);
848
+ return turns.length > 0 ? turns[0] : null;
849
+ }
850
+
851
+ function latestTurnOfFile(sessionId, file) {
852
+ const turns = turnsForFileWithChanges(sessionId, file);
853
+ return turns.length > 0 ? turns[turns.length - 1] : null;
854
+ }
855
+
856
+ function nextTurnOfFile(sessionId, file, turn) {
857
+ const turns = turnsForFileWithChanges(sessionId, file);
858
+ for (const t of turns) {
859
+ if (t > turn) return t;
860
+ }
861
+ return null;
862
+ }
863
+
864
+ /** Revert one hunk — one tool call, one contiguous change. */
865
+ async function revertChange(sessionId, file, cardIndex) {
866
+ if (!file) return;
867
+ try {
868
+ verifyEditFile(file);
869
+ const outcome = hunkAnchorOutcome(file, cardIndex);
870
+ const anchor = outcome.anchor;
871
+ if (!anchor) {
872
+ throw new Error(outcome.reason || '这处改动无法单独撤销');
873
+ }
874
+
875
+ const current = await readCurrentText(sessionId, file.path);
876
+ const located = locateAnchor(anchor, current.text);
877
+ if (!located) {
878
+ throw new Error('这个文件在这次改动之后又被修改过,已无法定位这处改动,请改用「撤销整轮」');
879
+ }
880
+ if (located.mode === 'already') throw new Error('这处改动已经被撤销');
881
+
882
+ const next = located.mode === 'all'
883
+ ? current.text.split(located.from).join(located.to)
884
+ : current.text.replace(located.from, located.to);
885
+ if (next === current.text) throw new Error('这处改动已经被撤销');
886
+
887
+ await writeText(sessionId, file.path, next);
888
+ setToast(sessionId, '已撤销 ' + file.name + ' 的第 ' + (cardIndex + 1) + ' 处改动', 'success');
889
+ } catch (error) {
890
+ setToast(sessionId, String((error && error.message) || error), 'error');
891
+ }
892
+ notify();
893
+ }
894
+
895
+ /** Discard a file the agent created in this session. */
896
+ async function discardNewFile(sessionId, file) {
897
+ if (!file) return;
898
+ try {
899
+ if (file.dirty === true) {
900
+ throw new Error('智能体正在写入这个文件,请等这一步结束后再撤销');
901
+ }
902
+ if (file.confirming !== 'delete') {
903
+ file.confirming = 'delete';
904
+ setToast(sessionId, '再点一次即从磁盘删除 ' + file.path, 'warn');
905
+ return;
906
+ }
907
+ file.confirming = null;
908
+ await deleteFile(sessionId, file.path);
909
+
910
+ const st = getSessionState(sessionId);
911
+ const record = st && st.files[file.path];
912
+ if (record) {
913
+ record.removed = true;
914
+ record.status = 'D';
915
+ }
916
+ setToast(sessionId, '已删除新建文件 ' + file.name, 'success');
917
+ } catch (error) {
918
+ file.confirming = null;
919
+ setToast(sessionId, String((error && error.message) || error), 'error');
920
+ }
921
+ notify();
922
+ }
923
+
924
+ /** Write a deleted file back from the newest content the session recorded. */
925
+ async function restoreRemovedFile(sessionId, file) {
926
+ if (!file) return;
927
+ try {
928
+ if (file.confirming !== 'restore') {
929
+ file.confirming = 'restore';
930
+ setToast(sessionId, '再点一次即把 ' + file.path + ' 写回磁盘', 'warn');
931
+ return;
932
+ }
933
+ file.confirming = null;
934
+
935
+ const phases = file.phases || [];
936
+ let newest = null;
937
+ for (const phase of phases) {
938
+ for (const artifact of phase.artifacts || []) {
939
+ if (typeof artifact.newText === 'string') newest = artifact;
940
+ }
941
+ }
942
+
943
+ // A file deleted by a shell command may never have been written by a file tool, so
944
+ // fall back to the baseline captured before the session first touched it.
945
+ let body = newest ? newest.newText : null;
946
+ let source = '本次会话记录的内容';
947
+ if (body === null) {
948
+ const baseline = baselineOf(sessionId, file.path) || baselines.get(file.key);
949
+ if (baseline && typeof baseline.text === 'string') {
950
+ body = baseline.text;
951
+ source = '会话前的原始内容';
952
+ }
953
+ }
954
+ if (body === null) {
955
+ throw new Error('没有可用来恢复的记录内容;这个文件在本次会话中从未被读取或写入过');
956
+ }
957
+
958
+ await writeText(sessionId, file.path, body);
959
+
960
+ const st = getSessionState(sessionId);
961
+ const record = st && st.files[file.path];
962
+ if (record) {
963
+ record.removed = false;
964
+ record.missing = false;
965
+ record.removedBy = null;
966
+ record.status = 'M';
967
+ record.reverted = false;
968
+ }
969
+ setToast(sessionId, '已恢复 ' + file.name + '(来源:' + source + ')', 'success');
970
+ } catch (error) {
971
+ file.confirming = null;
972
+ setToast(sessionId, String((error && error.message) || error), 'error');
973
+ }
974
+ notify();
975
+ }
976
+
977
+ /**
978
+ * Split one recorded edit into the diff cards the inspector shows, in file order.
979
+ *
980
+ * `edit`/`str_replace_editor` call arguments are literal, so they give byte-exact hunks
981
+ * even when replayed from history. `write`/`write_file` only carries the new content, so
982
+ * its hunks come from the tool/result artifact instead.
983
+ */
984
+ function buildDiffs(name, args) {
985
+ if (name === 'edit') {
986
+ const oldText = typeof args.old_string === 'string' ? args.old_string : '';
987
+ const newText = typeof args.new_string === 'string' ? args.new_string : '';
988
+ if (args.replace_all === true) {
989
+ return [{ kind: 'replace-all', oldText: splitLines(oldText), newText: splitLines(newText), replaceAll: true }];
990
+ }
991
+ return [{ kind: 'block', oldText: splitLines(oldText), newText: splitLines(newText) }];
992
+ }
993
+
994
+ if (name === 'str_replace_editor') {
995
+ const oldText = typeof args.old_str === 'string' ? args.old_str : '';
996
+ const newText = typeof args.new_str === 'string' ? args.new_str : '';
997
+ return [{ kind: 'block', oldText: splitLines(oldText), newText: splitLines(newText) }];
998
+ }
999
+
1000
+ return [];
1001
+ }
1002
+
1003
+ function splitLines(text) {
1004
+ return typeof text === 'string' ? text.split('\n') : [];
1005
+ }
1006
+
1007
+ function describeDiff(diff) {
1008
+ if (!diff) return '改动';
1009
+ if (diff.replaceAll) return '全部匹配项';
1010
+ const label = '−' + diff.oldText.length + ' / +' + diff.newText.length;
1011
+ if (diff.oldText.length === 0) return '新增 · ' + label;
1012
+ if (diff.newText.length === 0) return '删除 · ' + label;
1013
+ return label;
1014
+ }
1015
+
1016
+ function extractFilePathAndDiff(name, argsRaw) {
1017
+ if (!argsRaw || typeof argsRaw !== 'string') return null;
1018
+ let args;
1019
+ try {
1020
+ args = JSON.parse(argsRaw);
1021
+ } catch {
1022
+ return null;
1023
+ }
1024
+ if (!args || typeof args !== 'object') return null;
1025
+
1026
+ if (name === 'edit') {
1027
+ const fp = args.file_path || args.path;
1028
+ if (typeof fp === 'string' && fp.length > 0) {
1029
+ return {
1030
+ path: fp,
1031
+ status: 'M',
1032
+ line: typeof args.offset === 'number' ? args.offset : undefined,
1033
+ phase: { kind: 'edit', diffs: buildDiffs('edit', args), oldText: args.old_string, newText: args.new_string }
1034
+ };
1035
+ }
1036
+ }
1037
+
1038
+ if (name === 'str_replace_editor') {
1039
+ const fp = args.path;
1040
+ if (typeof fp === 'string' && fp.length > 0) {
1041
+ return {
1042
+ path: fp,
1043
+ status: 'M',
1044
+ phase: { kind: 'edit', diffs: buildDiffs('str_replace_editor', args), oldText: args.old_str, newText: args.new_str }
1045
+ };
1046
+ }
1047
+ }
1048
+
1049
+ if (name === 'write' || name === 'write_file') {
1050
+ const fp = args.file_path || args.path;
1051
+ if (typeof fp === 'string' && fp.length > 0) {
1052
+ const content = typeof args.content === 'string' ? args.content : null;
1053
+ return {
1054
+ path: fp,
1055
+ status: 'A',
1056
+ line: typeof args.offset === 'number' ? args.offset : undefined,
1057
+ phase: { kind: 'write', diffs: [], oldText: null, newText: content, awaitingArtifact: true }
1058
+ };
1059
+ }
1060
+ }
1061
+
1062
+ if (name === 'read') {
1063
+ const fp = args.file_path || args.path;
1064
+ if (typeof fp === 'string' && fp.length > 0) {
1065
+ return {
1066
+ path: fp,
1067
+ status: 'R',
1068
+ line: typeof args.offset === 'number' ? args.offset : undefined
1069
+ };
1070
+ }
1071
+ }
1072
+
1073
+ return null;
1074
+ }
1075
+
1076
+ function countLeaves(diffs) {
1077
+ let total = 0;
1078
+ for (const diff of diffs || []) {
1079
+ total += diff.replaceAll ? Math.max(1, diff.occurrences || 1) : 1;
1080
+ }
1081
+ return total;
1082
+ }
1083
+
1084
+ function recordFile(sessionId, filePath, status, line, phase, seq, options) {
1085
+ const state = getSessionState(sessionId);
1086
+ if (!state) return;
1087
+
1088
+ const cleanPath = filePath.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '');
1089
+ const parts = cleanPath.split('/');
1090
+ const name = parts.pop() || cleanPath;
1091
+ const dir = parts.join('/') || '.';
1092
+ const ext = name.includes('.') ? name.split('.').pop() : '';
1093
+ const key = keyOfPath(sessionId, cleanPath);
1094
+ const turn = options && typeof options.turn === 'number' ? options.turn : (state.currentTurn || null);
1095
+
1096
+ let nextStatus = status;
1097
+ const existing = state.files[cleanPath];
1098
+ const wasReadOnly = !!existing && existing.status === 'R';
1099
+ const justDiscovered = !existing;
1100
+ if (existing) {
1101
+ if (existing.status === 'M' || status === 'M') {
1102
+ nextStatus = 'M';
1103
+ } else if (existing.status === 'A' && status === 'R') {
1104
+ nextStatus = 'A';
1105
+ }
1106
+ }
1107
+
1108
+ for (const k in state.files) {
1109
+ state.files[k].active = false;
1110
+ }
1111
+
1112
+ const phases = existing && Array.isArray(existing.phases) ? existing.phases.slice() : [];
1113
+ if (phase) {
1114
+ // The call-side extraction is provisional for edits until the tool/result artifact
1115
+ // arrives; the artifact pass replaces it rather than stacking a duplicate phase.
1116
+ if (phase.awaitingArtifact && phases.length > 0) {
1117
+ const last = phases[phases.length - 1];
1118
+ if (last.awaitingArtifact) phases.pop();
1119
+ }
1120
+ phases.push({
1121
+ kind: phase.kind,
1122
+ diffs: phase.diffs || [],
1123
+ artifacts: phase.artifacts || [],
1124
+ oldText: phase.oldText === undefined ? null : phase.oldText,
1125
+ newText: phase.newText === undefined ? null : phase.newText,
1126
+ awaitingArtifact: !!phase.awaitingArtifact,
1127
+ turn: turn,
1128
+ seq: seq,
1129
+ time: Date.now()
1130
+ });
1131
+ }
1132
+
1133
+ const allDiffs = [];
1134
+ for (const item of phases) {
1135
+ for (const diff of item.diffs || []) {
1136
+ // A hunk inherits its phase's turn, which is what scopes a per-turn undo.
1137
+ if (diff.turn === undefined) diff.turn = item.turn === undefined ? null : item.turn;
1138
+ allDiffs.push(diff);
1139
+ }
1140
+ }
1141
+
1142
+ state.files[cleanPath] = {
1143
+ path: cleanPath,
1144
+ key: key,
1145
+ name: name,
1146
+ dir: dir,
1147
+ ext: ext,
1148
+ status: nextStatus,
1149
+ active: true,
1150
+ line: line,
1151
+ timestamp: Date.now(),
1152
+ count: (existing ? existing.count : 0) + 1,
1153
+ phases: phases,
1154
+ diffs: allDiffs,
1155
+ edits: countLeaves(allDiffs),
1156
+ removed: existing ? existing.removed : false,
1157
+ reverted: existing ? existing.reverted : false,
1158
+ confirming: null,
1159
+ dirty: !!phase
1160
+ };
1161
+ state.activePath = cleanPath;
1162
+
1163
+ if (!state.selectedPath || nextStatus === 'M' || nextStatus === 'A') {
1164
+ state.selectedPath = cleanPath;
1165
+ }
1166
+
1167
+ // The pre-session baseline can only be captured while the file is still untouched.
1168
+ const needsBaseline = !options || options.captureBaseline !== false;
1169
+ if (needsBaseline && (justDiscovered || wasReadOnly)) {
1170
+ ensureBaseline(sessionId, cleanPath);
1171
+ }
1172
+
1173
+ // Snapshot what this turn inherited, so reverting the turn can restore exactly that.
1174
+ if (phase && typeof turn === 'number') {
1175
+ ensureTurnStart(sessionId, cleanPath, turn);
1176
+ }
1177
+
1178
+ // The agent may be mid-edit; never offer a revert against a half-written file.
1179
+ if (phase) {
1180
+ if (state.dirtyTimers === undefined) state.dirtyTimers = new Map();
1181
+ const prior = state.dirtyTimers.get(cleanPath);
1182
+ if (prior) clearTimeout(prior);
1183
+ state.dirtyTimers.set(cleanPath, setTimeout(() => {
1184
+ const st = getSessionState(sessionId);
1185
+ if (!st || !st.files[cleanPath]) return;
1186
+ st.files[cleanPath].dirty = false;
1187
+ st.dirtyTimers.delete(cleanPath);
1188
+ notify();
1189
+ }, 1500));
1190
+ }
1191
+ }
1192
+
1193
+ function clearActive(sessionId) {
1194
+ const state = getSessionState(sessionId);
1195
+ if (!state) return;
1196
+ let changed = false;
1197
+ if (state.activePath !== null) {
1198
+ state.activePath = null;
1199
+ changed = true;
1200
+ }
1201
+ for (const k in state.files) {
1202
+ if (state.files[k].active) {
1203
+ state.files[k].active = false;
1204
+ changed = true;
1205
+ }
1206
+ }
1207
+ if (changed) notify();
1208
+ }
1209
+
1210
+ function openSingleFile(sessionId, filePath, line) {
1211
+ if (!sessionId) {
1212
+ console.warn('[@dsh-xhl/dsh-live-inspector] Cannot open file: no active session');
1213
+ return;
1214
+ }
1215
+ const url = sessionFileAddress(sessionId, filePath);
1216
+ const options = line ? { params: { line } } : undefined;
1217
+ console.info('[@dsh-xhl/dsh-live-inspector] Opening file:', url, options);
1218
+
1219
+ if (globalCtx?.sidebarRight && typeof globalCtx.sidebarRight.openResourceIn === 'function') {
1220
+ try {
1221
+ globalCtx.sidebarRight.openResourceIn(sessionId, url, options);
1222
+ return;
1223
+ } catch (e) {
1224
+ console.warn('[@dsh-xhl/dsh-live-inspector] openResourceIn error:', e);
1225
+ }
1226
+ }
1227
+ if (globalCtx?.sidebarRight && typeof globalCtx.sidebarRight.openResource === 'function') {
1228
+ try {
1229
+ globalCtx.sidebarRight.openResource(url, options);
1230
+ return;
1231
+ } catch (e) {
1232
+ console.warn('[@dsh-xhl/dsh-live-inspector] openResource error:', e);
1233
+ }
1234
+ }
1235
+ }
1236
+
1237
+ function openResourceUrl(sessionId, url) {
1238
+ if (globalCtx?.sidebarRight && typeof globalCtx.sidebarRight.openResourceIn === 'function') {
1239
+ try {
1240
+ globalCtx.sidebarRight.openResourceIn(sessionId, url);
1241
+ return;
1242
+ } catch (e) {
1243
+ console.warn('[@dsh-xhl/dsh-live-inspector] openResourceIn error:', e);
1244
+ }
1245
+ }
1246
+ if (globalCtx?.sidebarRight && typeof globalCtx.sidebarRight.openResource === 'function') {
1247
+ try {
1248
+ globalCtx.sidebarRight.openResource(url);
1249
+ return;
1250
+ } catch (e) {
1251
+ console.warn('[@dsh-xhl/dsh-live-inspector] openResource error:', e);
1252
+ }
1253
+ }
1254
+ }
1255
+
1256
+ function renderParts(parts, kind) {
1257
+ if (!parts || parts.length === 0) return null;
1258
+ return parts.map((p, i) => {
1259
+ if (!p.changed) return h('span', { key: i }, p.text);
1260
+ if (kind === 'del') {
1261
+ return h('span', {
1262
+ key: i,
1263
+ style: {
1264
+ background: 'rgba(239, 68, 68, 0.4)',
1265
+ color: '#fff',
1266
+ borderRadius: '2px',
1267
+ padding: '0 2px',
1268
+ fontWeight: 600
1269
+ }
1270
+ }, p.text);
1271
+ }
1272
+ return h('span', {
1273
+ key: i,
1274
+ style: {
1275
+ background: 'rgba(16, 185, 129, 0.4)',
1276
+ color: '#fff',
1277
+ borderRadius: '2px',
1278
+ padding: '0 2px',
1279
+ fontWeight: 600
1280
+ }
1281
+ }, p.text);
1282
+ });
1283
+ }
1284
+
1285
+ // Side-by-Side Split Diff View
1286
+ function SplitDiffView({ computed, wrapLines }) {
1287
+ const { rows, truncated, totalRows } = computed;
1288
+ const emptyHatch = 'repeating-linear-gradient(45deg, rgba(255,255,255,0.015), rgba(255,255,255,0.015) 6px, rgba(0,0,0,0.1) 6px, rgba(0,0,0,0.1) 12px)';
1289
+
1290
+ return h('div', {
1291
+ style: {
1292
+ display: 'grid',
1293
+ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
1294
+ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.1))',
1295
+ borderRadius: '6px',
1296
+ overflow: 'hidden',
1297
+ fontFamily: 'var(--ds-font-family-code, "JetBrains Mono", monospace)',
1298
+ fontSize: '11px',
1299
+ background: 'var(--dsw-alias-bg-layer-2, rgba(0,0,0,0.28))'
1300
+ }
1301
+ },
1302
+ // Left Column Header (Original / Before)
1303
+ h('div', {
1304
+ style: {
1305
+ gridColumn: '1 / 2',
1306
+ padding: '5px 10px',
1307
+ background: 'rgba(239, 68, 68, 0.12)',
1308
+ borderBottom: '1px solid rgba(239, 68, 68, 0.22)',
1309
+ borderRight: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.1))',
1310
+ color: '#f87171',
1311
+ fontWeight: 600,
1312
+ fontSize: '10px',
1313
+ display: 'flex',
1314
+ justifyContent: 'space-between',
1315
+ alignItems: 'center'
1316
+ }
1317
+ },
1318
+ h('span', null, '◀ 原始(改动前)'),
1319
+ h('span', { style: { opacity: 0.8, fontSize: '9px' } }, '-' + computed.delCount + ' 行')
1320
+ ),
1321
+
1322
+ // Right Column Header (Modified / After)
1323
+ h('div', {
1324
+ style: {
1325
+ gridColumn: '2 / 3',
1326
+ padding: '5px 10px',
1327
+ background: 'rgba(16, 185, 129, 0.12)',
1328
+ borderBottom: '1px solid rgba(16, 185, 129, 0.22)',
1329
+ color: '#34d399',
1330
+ fontWeight: 600,
1331
+ fontSize: '10px',
1332
+ display: 'flex',
1333
+ justifyContent: 'space-between',
1334
+ alignItems: 'center'
1335
+ }
1336
+ },
1337
+ h('span', null, '▶ 修改后'),
1338
+ h('span', { style: { opacity: 0.8, fontSize: '9px' } }, '+' + computed.addCount + ' 行')
1339
+ ),
1340
+
1341
+ // Synchronized Aligned Rows
1342
+ h('div', {
1343
+ style: {
1344
+ gridColumn: '1 / -1',
1345
+ maxHeight: '480px',
1346
+ overflowY: 'auto',
1347
+ overflowX: wrapLines ? 'hidden' : 'auto'
1348
+ }
1349
+ },
1350
+ rows.map((row, idx) => {
1351
+ const left = row.left;
1352
+ const right = row.right;
1353
+
1354
+ return h('div', {
1355
+ key: idx,
1356
+ style: {
1357
+ display: 'grid',
1358
+ gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)',
1359
+ minHeight: '22px',
1360
+ lineHeight: '22px',
1361
+ borderBottom: '0.5px solid rgba(255,255,255,0.02)'
1362
+ }
1363
+ },
1364
+ // LEFT CELL
1365
+ h('div', {
1366
+ style: {
1367
+ display: 'flex',
1368
+ minWidth: 0,
1369
+ borderRight: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.1))',
1370
+ background: left ? (left.kind === 'del' ? 'rgba(239, 68, 68, 0.08)' : 'transparent') : emptyHatch
1371
+ }
1372
+ },
1373
+ h('div', {
1374
+ style: {
1375
+ width: '36px',
1376
+ flexShrink: 0,
1377
+ textAlign: 'right',
1378
+ paddingRight: '6px',
1379
+ userSelect: 'none',
1380
+ fontSize: '10px',
1381
+ color: left?.kind === 'del' ? '#f87171' : 'var(--dsw-alias-label-tertiary, #666)',
1382
+ background: left?.kind === 'del' ? 'rgba(239, 68, 68, 0.16)' : 'transparent',
1383
+ borderRight: '1px solid rgba(255,255,255,0.05)'
1384
+ }
1385
+ }, left ? String(left.no) : ''),
1386
+ h('div', {
1387
+ style: {
1388
+ flex: 1,
1389
+ minWidth: 0,
1390
+ padding: '0 8px',
1391
+ whiteSpace: wrapLines ? 'pre-wrap' : 'pre',
1392
+ wordBreak: wrapLines ? 'break-all' : 'normal',
1393
+ color: left?.kind === 'del' ? '#fca5a5' : 'var(--dsw-alias-label-secondary, #bbb)'
1394
+ }
1395
+ }, left ? renderParts(left.parts, left.kind) : '')
1396
+ ),
1397
+
1398
+ // RIGHT CELL
1399
+ h('div', {
1400
+ style: {
1401
+ display: 'flex',
1402
+ minWidth: 0,
1403
+ background: right ? (right.kind === 'add' ? 'rgba(16, 185, 129, 0.08)' : 'transparent') : emptyHatch
1404
+ }
1405
+ },
1406
+ h('div', {
1407
+ style: {
1408
+ width: '36px',
1409
+ flexShrink: 0,
1410
+ textAlign: 'right',
1411
+ paddingRight: '6px',
1412
+ userSelect: 'none',
1413
+ fontSize: '10px',
1414
+ color: right?.kind === 'add' ? '#34d399' : 'var(--dsw-alias-label-tertiary, #666)',
1415
+ background: right?.kind === 'add' ? 'rgba(16, 185, 129, 0.16)' : 'transparent',
1416
+ borderRight: '1px solid rgba(255,255,255,0.05)'
1417
+ }
1418
+ }, right ? String(right.no) : ''),
1419
+ h('div', {
1420
+ style: {
1421
+ flex: 1,
1422
+ minWidth: 0,
1423
+ padding: '0 8px',
1424
+ whiteSpace: wrapLines ? 'pre-wrap' : 'pre',
1425
+ wordBreak: wrapLines ? 'break-all' : 'normal',
1426
+ color: right?.kind === 'add' ? '#86efac' : 'var(--dsw-alias-label-secondary, #bbb)'
1427
+ }
1428
+ }, right ? renderParts(right.parts, right.kind) : '')
1429
+ )
1430
+ );
1431
+ }),
1432
+
1433
+ truncated ? h('div', {
1434
+ style: {
1435
+ padding: '6px 12px',
1436
+ color: 'var(--dsw-alias-label-tertiary, #888)',
1437
+ fontStyle: 'italic',
1438
+ fontSize: '11px',
1439
+ textAlign: 'center',
1440
+ background: 'rgba(0,0,0,0.2)'
1441
+ }
1442
+ }, '…… 还有 ' + (totalRows - MAX_RENDERED_DIFF_ROWS) + ' 行(在编辑器中查看完整文件)') : null
1443
+ )
1444
+ );
1445
+ }
1446
+
1447
+ // Unified Diff View
1448
+ function UnifiedDiffView({ computed, wrapLines }) {
1449
+ const { rows, truncated, totalRows } = computed;
1450
+
1451
+ return h('div', {
1452
+ style: {
1453
+ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.1))',
1454
+ borderRadius: '6px',
1455
+ overflow: 'hidden',
1456
+ fontFamily: 'var(--ds-font-family-code, "JetBrains Mono", monospace)',
1457
+ fontSize: '11px',
1458
+ background: 'var(--dsw-alias-bg-layer-2, rgba(0,0,0,0.28))',
1459
+ maxHeight: '480px',
1460
+ overflowY: 'auto',
1461
+ overflowX: wrapLines ? 'hidden' : 'auto'
1462
+ }
1463
+ },
1464
+ rows.map((row, idx) => {
1465
+ if (row.left?.kind === 'context') {
1466
+ return h('div', {
1467
+ key: idx,
1468
+ style: {
1469
+ display: 'flex',
1470
+ lineHeight: '22px',
1471
+ minHeight: '22px',
1472
+ color: 'var(--dsw-alias-label-secondary, #bbb)',
1473
+ borderBottom: '0.5px solid rgba(255,255,255,0.02)'
1474
+ }
1475
+ },
1476
+ h('span', { style: { width: '36px', textAlign: 'right', paddingRight: '6px', opacity: 0.5, userSelect: 'none' } }, String(row.left.no)),
1477
+ h('span', { style: { width: '36px', textAlign: 'right', paddingRight: '6px', opacity: 0.5, userSelect: 'none' } }, String(row.right.no)),
1478
+ h('span', { style: { width: '18px', textAlign: 'center', opacity: 0.3 } }, ' '),
1479
+ h('span', { style: { flex: 1, paddingRight: '8px', whiteSpace: wrapLines ? 'pre-wrap' : 'pre', wordBreak: wrapLines ? 'break-all' : 'normal' } }, row.left.text)
1480
+ );
1481
+ }
1482
+
1483
+ const elements = [];
1484
+ if (row.left && row.left.kind === 'del') {
1485
+ elements.push(h('div', {
1486
+ key: 'del-' + idx,
1487
+ style: {
1488
+ display: 'flex',
1489
+ lineHeight: '22px',
1490
+ minHeight: '22px',
1491
+ background: 'rgba(239, 68, 68, 0.09)',
1492
+ color: '#fca5a5',
1493
+ borderBottom: '0.5px solid rgba(255,255,255,0.02)'
1494
+ }
1495
+ },
1496
+ h('span', { style: { width: '36px', textAlign: 'right', paddingRight: '6px', color: '#f87171', background: 'rgba(239, 68, 68, 0.16)', userSelect: 'none' } }, String(row.left.no)),
1497
+ h('span', { style: { width: '36px', textAlign: 'right', paddingRight: '6px', background: 'rgba(239, 68, 68, 0.16)', userSelect: 'none' } }, ''),
1498
+ h('span', { style: { width: '18px', textAlign: 'center', color: '#ef4444', fontWeight: 700 } }, '-'),
1499
+ h('span', { style: { flex: 1, paddingRight: '8px', whiteSpace: wrapLines ? 'pre-wrap' : 'pre', wordBreak: wrapLines ? 'break-all' : 'normal' } }, renderParts(row.left.parts, 'del'))
1500
+ ));
1501
+ }
1502
+ if (row.right && row.right.kind === 'add') {
1503
+ elements.push(h('div', {
1504
+ key: 'add-' + idx,
1505
+ style: {
1506
+ display: 'flex',
1507
+ lineHeight: '22px',
1508
+ minHeight: '22px',
1509
+ background: 'rgba(16, 185, 129, 0.09)',
1510
+ color: '#86efac',
1511
+ borderBottom: '0.5px solid rgba(255,255,255,0.02)'
1512
+ }
1513
+ },
1514
+ h('span', { style: { width: '36px', textAlign: 'right', paddingRight: '6px', background: 'rgba(16, 185, 129, 0.16)', userSelect: 'none' } }, ''),
1515
+ h('span', { style: { width: '36px', textAlign: 'right', paddingRight: '6px', color: '#34d399', background: 'rgba(16, 185, 129, 0.16)', userSelect: 'none' } }, String(row.right.no)),
1516
+ h('span', { style: { width: '18px', textAlign: 'center', color: '#10b981', fontWeight: 700 } }, '+'),
1517
+ h('span', { style: { flex: 1, paddingRight: '8px', whiteSpace: wrapLines ? 'pre-wrap' : 'pre', wordBreak: wrapLines ? 'break-all' : 'normal' } }, renderParts(row.right.parts, 'add'))
1518
+ ));
1519
+ }
1520
+ return elements;
1521
+ }),
1522
+
1523
+ truncated ? h('div', {
1524
+ style: {
1525
+ padding: '6px 12px',
1526
+ color: 'var(--dsw-alias-label-tertiary, #888)',
1527
+ fontStyle: 'italic',
1528
+ fontSize: '11px',
1529
+ textAlign: 'center',
1530
+ background: 'rgba(0,0,0,0.2)'
1531
+ }
1532
+ }, '…… 还有 ' + (totalRows - MAX_RENDERED_DIFF_ROWS) + ' 行(在编辑器中查看完整文件)') : null
1533
+ );
1534
+ }
1535
+
1536
+ // Detail Inspector for the Selected File
1537
+ function SelectedFileDiffInspector({ file, currentSid, selectedTurn, newestTurn, turnUndoable, turnLabelOf }) {
1538
+ const [viewMode, setViewMode] = React.useState('split'); // 'split' | 'unified'
1539
+ const [wrapLines, setWrapLines] = React.useState(true);
1540
+ const [activeDiffIdx, setActiveDiffIdx] = React.useState(0);
1541
+ const [busy, setBusy] = React.useState(false);
1542
+
1543
+ if (!file) {
1544
+ return h('div', {
1545
+ style: {
1546
+ padding: '48px 16px',
1547
+ textAlign: 'center',
1548
+ color: 'var(--dsw-alias-label-tertiary, #666)',
1549
+ fontSize: '12px'
1550
+ }
1551
+ },
1552
+ h('div', { style: { fontSize: '24px', marginBottom: '8px' } }, '🌿'),
1553
+ h('div', { style: { fontWeight: 500 } }, '未选择文件'),
1554
+ h('div', { style: { fontSize: '11px', marginTop: '4px', opacity: 0.8 } }, '在上方选择一个文件,即可左右对照查看它的差异。')
1555
+ );
1556
+ }
1557
+
1558
+ const phases = file.phases || [];
1559
+ const diffs = file.diffs || [];
1560
+ const hasDiffs = phases.length > 0;
1561
+
1562
+ // The hunks in scope: only the selected turn's. Each keeps its ORIGINAL index into
1563
+ // `diffs`, because that index selects the diff and addresses the undo handlers.
1564
+ // Hunks with no recorded turn (replayed history) stay visible: hiding them would hide
1565
+ // changes the panel cannot attribute.
1566
+ const inScopeDiffs = diffs
1567
+ .map((diff, idx) => ({ diff, idx }))
1568
+ .filter(({ diff }) => selectedTurn === null || typeof diff.turn !== 'number' || diff.turn === selectedTurn);
1569
+
1570
+ // The active hunk must be one the scope contains. Selecting a turn that does not include
1571
+ // the previously active hunk falls back to the first in-scope hunk, so the diff body can
1572
+ // never render a change from a turn the rest of the panel is not showing.
1573
+ const activeDiffIdxSafe = inScopeDiffs.length > 0
1574
+ ? (inScopeDiffs.some(({ idx }) => idx === activeDiffIdx) ? activeDiffIdx : inScopeDiffs[0].idx)
1575
+ : Math.min(activeDiffIdx, Math.max(0, diffs.length - 1));
1576
+ const currentDiff = diffs[activeDiffIdxSafe];
1577
+
1578
+ const card = React.useMemo(
1579
+ () => (hasDiffs ? resolveCardTexts(currentSid, file, file.selectedArtifact, diffs.length > 0 ? activeDiffIdxSafe : undefined) : null),
1580
+ [currentSid, file.key, file.phases, file.diffs, file.selectedArtifact, activeDiffIdxSafe, hasDiffs]
1581
+ );
1582
+
1583
+ const computed = React.useMemo(() => {
1584
+ if (!card) return null;
1585
+ return computeSplitDiff(card.oldText, card.newText, 1);
1586
+ }, [card && card.oldText, card && card.newText]);
1587
+
1588
+ // Undoing one change is offered only while its anchor still resolves exactly once.
1589
+ // The refusal reason is carried through so the tooltip can name the actual condition.
1590
+ const hunkOutcome = React.useMemo(
1591
+ () => (hasDiffs && diffs.length > 0
1592
+ ? hunkAnchorOutcome(file, activeDiffIdxSafe)
1593
+ : { reason: '未选中任何改动' }),
1594
+ [file.key, file.phases, file.diffs, activeDiffIdxSafe, hasDiffs]
1595
+ );
1596
+ const hunkAnchor = hunkOutcome.anchor || null;
1597
+
1598
+ // The active hunk's own turn, and whether the turn scope lets us undo it.
1599
+ // (Turn-level undo lives in the toolbar and the file rows, not this header.)
1600
+ const activeHunkTurn = currentDiff && typeof currentDiff.turn === 'number' ? currentDiff.turn : null;
1601
+ const hunkInTurnScope = activeHunkTurn === null || selectedTurn === null || activeHunkTurn === selectedTurn;
1602
+
1603
+ async function runRevert(action) {
1604
+ setBusy(true);
1605
+ try {
1606
+ await action();
1607
+ } finally {
1608
+ setBusy(false);
1609
+ }
1610
+ }
1611
+
1612
+ const revertBlocker = file.status === 'R'
1613
+ ? 'Read-only in this session — nothing to revert'
1614
+ : file.dirty
1615
+ ? '智能体正在写入这个文件'
1616
+ : null;
1617
+
1618
+ return h('div', {
1619
+ style: {
1620
+ display: 'flex',
1621
+ flexDirection: 'column',
1622
+ flex: 1,
1623
+ minHeight: 0,
1624
+ background: 'var(--dsw-alias-bg-base, transparent)'
1625
+ }
1626
+ },
1627
+ // Detail Header
1628
+ h('div', {
1629
+ style: {
1630
+ padding: '8px 14px',
1631
+ borderBottom: '1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.08))',
1632
+ background: 'var(--dsw-alias-bg-layer-1, rgba(255,255,255,0.03))',
1633
+ display: 'flex',
1634
+ alignItems: 'center',
1635
+ justifyContent: 'space-between',
1636
+ flexWrap: 'wrap',
1637
+ gap: '8px'
1638
+ }
1639
+ },
1640
+ // Left: Identity & Badges
1641
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 } },
1642
+ h(FileExtBadge, { ext: file.ext }),
1643
+ h('span', {
1644
+ style: {
1645
+ padding: '1px 6px',
1646
+ borderRadius: '3px',
1647
+ fontSize: '10px',
1648
+ fontWeight: 700,
1649
+ fontFamily: 'monospace',
1650
+ background: file.status === 'M' ? 'rgba(245, 158, 11, 0.2)' : file.status === 'A' ? 'rgba(16, 185, 129, 0.2)' : file.removed ? 'rgba(239, 68, 68, 0.2)' : 'rgba(59, 130, 246, 0.2)',
1651
+ color: file.status === 'M' ? '#f59e0b' : file.status === 'A' ? '#10b981' : file.removed ? '#ef4444' : '#60a5fa',
1652
+ border: '1px solid ' + (file.status === 'M' ? 'rgba(245, 158, 11, 0.4)' : file.status === 'A' ? 'rgba(16, 185, 129, 0.4)' : file.removed ? 'rgba(239, 68, 68, 0.4)' : 'rgba(59, 130, 246, 0.4)')
1653
+ }
1654
+ }, file.removed ? '已删除' : file.status === 'M' ? '已修改' : file.status === 'A' ? '已新建' : '已读取'),
1655
+
1656
+ h('div', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
1657
+ h('span', { style: { fontWeight: 600, fontSize: '13px', color: 'var(--dsw-alias-label-primary, inherit)' } }, file.name),
1658
+ file.dir !== '.' ? h('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary, #888)' } }, file.dir) : null
1659
+ ),
1660
+
1661
+ computed ? h(DiffStatBar, { addCount: computed.addCount, delCount: computed.delCount }) : null
1662
+ ),
1663
+
1664
+ // Right: view controls plus the undo actions
1665
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' } },
1666
+ hasDiffs ? h('button', {
1667
+ onClick: () => setWrapLines(v => !v),
1668
+ title: wrapLines ? '关闭自动换行' : '开启自动换行',
1669
+ style: {
1670
+ padding: '2px 6px',
1671
+ borderRadius: '4px',
1672
+ fontSize: '10px',
1673
+ cursor: 'pointer',
1674
+ border: '1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.1))',
1675
+ background: wrapLines ? 'rgba(255,255,255,0.08)' : 'transparent',
1676
+ color: 'var(--dsw-alias-label-secondary, #999)'
1677
+ }
1678
+ }, '换行') : null,
1679
+
1680
+ hasDiffs ? h('div', {
1681
+ style: {
1682
+ display: 'flex',
1683
+ background: 'var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06))',
1684
+ borderRadius: '4px',
1685
+ padding: '2px',
1686
+ border: '1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.1))'
1687
+ }
1688
+ },
1689
+ h('button', {
1690
+ onClick: () => setViewMode('split'),
1691
+ title: '左右对照两列',
1692
+ 'aria-pressed': viewMode === 'split',
1693
+ style: {
1694
+ padding: '2px 7px',
1695
+ borderRadius: '3px',
1696
+ fontSize: '10px',
1697
+ fontWeight: 600,
1698
+ cursor: 'pointer',
1699
+ border: 'none',
1700
+ // The selected half reads as a raised surface with primary text, not a filled
1701
+ // brand chip. The theme exposes no "on-brand" foreground token, so pairing
1702
+ // --dsw-alias-brand-primary with a hardcoded light foreground is unreadable
1703
+ // wherever that token resolves light. Both ends here are tokens, so the pair
1704
+ // stays legible in light and dark alike.
1705
+ background: viewMode === 'split' ? 'var(--dsw-alias-bg-overlay, rgba(127,127,127,0.22))' : 'transparent',
1706
+ color: viewMode === 'split' ? 'var(--dsw-alias-label-primary, inherit)' : 'var(--dsw-alias-label-secondary, #999)',
1707
+ boxShadow: viewMode === 'split' ? '0 1px 2px rgba(0,0,0,0.28)' : 'none'
1708
+ }
1709
+ }, '◫ 左右'),
1710
+ h('button', {
1711
+ onClick: () => setViewMode('unified'),
1712
+ title: '合并单列',
1713
+ 'aria-pressed': viewMode === 'unified',
1714
+ style: {
1715
+ padding: '2px 7px',
1716
+ borderRadius: '3px',
1717
+ fontSize: '10px',
1718
+ fontWeight: 600,
1719
+ cursor: 'pointer',
1720
+ border: 'none',
1721
+ background: viewMode === 'unified' ? 'var(--dsw-alias-bg-overlay, rgba(127,127,127,0.22))' : 'transparent',
1722
+ color: viewMode === 'unified' ? 'var(--dsw-alias-label-primary, inherit)' : 'var(--dsw-alias-label-secondary, #999)',
1723
+ boxShadow: viewMode === 'unified' ? '0 1px 2px rgba(0,0,0,0.28)' : 'none'
1724
+ }
1725
+ }, '☰ 合并')
1726
+ ) : null,
1727
+
1728
+ // ── Undo: one hunk of the newest turn ──
1729
+ // Undo controls are HIDDEN, not disabled, on any older turn: an older turn has
1730
+ // already been reviewed, and showing a dead button implies an action that the
1731
+ // panel will refuse. Turn-level undo lives in the toolbar and on the file rows,
1732
+ // so this header offers only the finer-grained hunk undo.
1733
+ turnUndoable && file.status === 'M' && diffs.length > 0 ? h('button', {
1734
+ onClick: () => runRevert(() => revertChange(currentSid, file, activeDiffIdxSafe)),
1735
+ disabled: busy || !!revertBlocker || !hunkAnchor || !hunkInTurnScope,
1736
+ title: revertBlocker
1737
+ ? revertBlocker
1738
+ : !hunkInTurnScope
1739
+ ? '这处改动属于 ' + turnLabelOf(activeHunkTurn) + ',而不是 ' + turnLabelOf(selectedTurn)
1740
+ : hunkAnchor
1741
+ ? '只撤销这处改动(' + describeDiff(currentDiff) + ')'
1742
+ : (hunkOutcome.reason || '这处改动无法单独撤销'),
1743
+ style: undoButtonStyle('warn', busy || !!revertBlocker || !hunkAnchor || !hunkInTurnScope, false)
1744
+ }, '↶ 撤销此改动') : null,
1745
+
1746
+ // ── The only destructive action: removing a created file from disk ──
1747
+ turnUndoable && file.status === 'A' && !file.removed ? h('button', {
1748
+ onClick: () => runRevert(() => discardNewFile(currentSid, file)),
1749
+ disabled: busy || !!file.dirty,
1750
+ title: file.dirty ? '智能体正在写入这个文件' : '把这个新建的文件从磁盘删除',
1751
+ style: undoButtonStyle('danger', busy || !!file.dirty, file.confirming === 'delete')
1752
+ }, file.confirming === 'delete' ? '⚠ 确认删除' : '🗑 删除文件') : null,
1753
+
1754
+ // ── Restore a deleted file (also an undo, so newest turn only) ──
1755
+ turnUndoable && file.removed ? h('button', {
1756
+ onClick: () => runRevert(() => restoreRemovedFile(currentSid, file)),
1757
+ disabled: busy,
1758
+ title: '用本次会话记录的内容把这个文件写回磁盘',
1759
+ style: undoButtonStyle('safe', busy, file.confirming === 'restore')
1760
+ }, file.confirming === 'restore' ? '⚠ 确认恢复' : '⤴ 恢复') : null,
1761
+
1762
+ h('button', {
1763
+ onClick: () => openSingleFile(currentSid, file.path, file.line),
1764
+ title: '在编辑器中打开完整文件',
1765
+ style: {
1766
+ padding: '3px 8px',
1767
+ borderRadius: '4px',
1768
+ fontSize: '11px',
1769
+ fontWeight: 500,
1770
+ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.18))',
1771
+ background: 'var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.08))',
1772
+ color: 'inherit',
1773
+ cursor: 'pointer'
1774
+ }
1775
+ }, '↗ 打开编辑器')
1776
+ )
1777
+ ),
1778
+
1779
+ // Change tabs: the hunks of the SELECTED TURN only. Hunks from other turns are not
1780
+ // listed at all — the turn scope is the panel's subject, so showing other turns'
1781
+ // changes here (even dimmed) would contradict the rest of the view.
1782
+ inScopeDiffs.length > 1 ? h('div', {
1783
+ style: {
1784
+ display: 'flex',
1785
+ alignItems: 'center',
1786
+ flexWrap: 'wrap',
1787
+ gap: '6px',
1788
+ padding: '6px 14px',
1789
+ borderBottom: '1px solid rgba(255,255,255,0.06)',
1790
+ background: 'rgba(0,0,0,0.12)'
1791
+ }
1792
+ },
1793
+ h('span', { style: { fontSize: '10px', color: 'var(--dsw-alias-label-tertiary, #777)' } }, '改动:'),
1794
+ // The index carried through is the ORIGINAL index into `diffs`, because that is what
1795
+ // selects the diff and what the undo handlers address — filtering must not renumber.
1796
+ inScopeDiffs.map(({ diff, idx }) => {
1797
+ const hunkTurn = typeof diff.turn === 'number' ? diff.turn : null;
1798
+ return h('button', {
1799
+ key: 'diff-' + idx,
1800
+ onClick: () => setActiveDiffIdx(idx),
1801
+ title: describeDiff(diff) + (hunkTurn === null ? '' : ' · ' + turnLabelOf(hunkTurn)),
1802
+ style: {
1803
+ padding: '2px 8px',
1804
+ borderRadius: '10px',
1805
+ fontSize: '10px',
1806
+ fontWeight: 500,
1807
+ cursor: 'pointer',
1808
+ border: activeDiffIdxSafe === idx ? '1px solid var(--dsw-alias-brand-primary, #3b82f6)' : '1px solid transparent',
1809
+ background: activeDiffIdxSafe === idx ? 'rgba(59, 130, 246, 0.18)' : 'transparent',
1810
+ color: activeDiffIdxSafe === idx ? '#60a5fa' : 'var(--dsw-alias-label-secondary, #888)'
1811
+ }
1812
+ }, '#' + (idx + 1) + ' ' + describeDiff(diff));
1813
+ })
1814
+ ) : null,
1815
+
1816
+ // A change that cannot be undone on its own says WHY, in the same words the button's
1817
+ // tooltip uses — one catch-all sentence would leave the reader guessing.
1818
+ file.status === 'M' && diffs.length > 0 && !hunkAnchor && hunkInTurnScope && !revertBlocker ? h('div', {
1819
+ style: {
1820
+ padding: '5px 14px',
1821
+ fontSize: '10px',
1822
+ color: '#f59e0b',
1823
+ background: 'rgba(245, 158, 11, 0.08)',
1824
+ borderBottom: '1px solid rgba(245, 158, 11, 0.18)'
1825
+ }
1826
+ }, '⚠ This change cannot be undone on its own. ' + (hunkOutcome.reason || '')) : null,
1827
+
1828
+ // Diff Viewer Body
1829
+ h('div', {
1830
+ style: {
1831
+ padding: '10px 14px',
1832
+ flex: 1,
1833
+ overflowY: 'auto'
1834
+ }
1835
+ },
1836
+ computed ? (
1837
+ viewMode === 'split' ? h(SplitDiffView, { computed, wrapLines }) : h(UnifiedDiffView, { computed, wrapLines })
1838
+ ) : (
1839
+ h('div', {
1840
+ style: {
1841
+ padding: '32px 16px',
1842
+ textAlign: 'center',
1843
+ color: 'var(--dsw-alias-label-tertiary, #666)',
1844
+ fontSize: '12px'
1845
+ }
1846
+ }, file.status === 'R' || file.reverted
1847
+ ? 'This file matches its pre-session content.'
1848
+ : (inScopeDiffs.length === 0 && diffs.length > 0
1849
+ ? turnLabelOf(selectedTurn) + ' made no change to this file.'
1850
+ : '没有记录到行级差异。'))
1851
+ )
1852
+ )
1853
+ );
1854
+ }
1855
+
1856
+ /** One-line undo feedback, dismissed automatically for terminal states. */
1857
+ function UndoToast({ toast, onDismiss }) {
1858
+ React.useEffect(() => {
1859
+ const life = toast.tone === 'success' ? 4500 : 10000;
1860
+ const timer = setTimeout(onDismiss, life);
1861
+ return () => clearTimeout(timer);
1862
+ }, [toast.at]);
1863
+
1864
+ const palette = toast.tone === 'error'
1865
+ ? { bg: 'rgba(239, 68, 68, 0.12)', border: 'rgba(239, 68, 68, 0.4)', fg: '#f87171' }
1866
+ : toast.tone === 'success'
1867
+ ? { bg: 'rgba(16, 185, 129, 0.12)', border: 'rgba(16, 185, 129, 0.4)', fg: '#34d399' }
1868
+ : toast.tone === 'warn'
1869
+ ? { bg: 'rgba(245, 158, 11, 0.12)', border: 'rgba(245, 158, 11, 0.4)', fg: '#f59e0b' }
1870
+ : { bg: 'rgba(59, 130, 246, 0.12)', border: 'rgba(59, 130, 246, 0.4)', fg: '#60a5fa' };
1871
+
1872
+ return h('div', {
1873
+ style: {
1874
+ marginTop: '6px',
1875
+ padding: '5px 9px',
1876
+ borderRadius: '5px',
1877
+ background: palette.bg,
1878
+ border: '1px solid ' + palette.border,
1879
+ color: palette.fg,
1880
+ fontSize: '11px',
1881
+ display: 'flex',
1882
+ alignItems: 'center',
1883
+ gap: '8px'
1884
+ }
1885
+ },
1886
+ h('span', { style: { flex: 1, minWidth: 0, wordBreak: 'break-word' } }, toast.message),
1887
+ h('button', {
1888
+ onClick: onDismiss,
1889
+ title: '关闭',
1890
+ style: {
1891
+ background: 'none',
1892
+ border: 'none',
1893
+ color: palette.fg,
1894
+ cursor: 'pointer',
1895
+ fontSize: '12px',
1896
+ padding: '0 2px',
1897
+ lineHeight: 1
1898
+ }
1899
+ }, '✕')
1900
+ );
1901
+ }
1902
+
1903
+ /** Shared button look for the undo controls. */
1904
+ /**
1905
+ * Shared look for the undo controls.
1906
+ *
1907
+ * `danger` is reserved for the single destructive action (deleting a created file from
1908
+ * disk) and is the only tone rendered as a solid fill, so it never reads as a peer of the
1909
+ * restorative buttons beside it. Everything that writes content back stays amber.
1910
+ */
1911
+ function undoButtonStyle(tone, disabled, confirming) {
1912
+ if (tone === 'danger') {
1913
+ return {
1914
+ padding: '3px 9px',
1915
+ borderRadius: '4px',
1916
+ fontSize: '11px',
1917
+ fontWeight: 700,
1918
+ border: '1px solid ' + (confirming ? 'rgba(239, 68, 68, 0.95)' : 'rgba(239, 68, 68, 0.75)'),
1919
+ background: confirming ? '#dc2626' : 'rgba(239, 68, 68, 0.82)',
1920
+ color: '#fff',
1921
+ cursor: disabled ? 'not-allowed' : 'pointer',
1922
+ opacity: disabled ? 0.4 : 1
1923
+ };
1924
+ }
1925
+
1926
+ const palette = tone === 'safe'
1927
+ ? { border: 'rgba(16, 185, 129, ', bg: 'rgba(16, 185, 129, ', fg: '#34d399' }
1928
+ : { border: 'rgba(245, 158, 11, ', bg: 'rgba(245, 158, 11, ', fg: '#f59e0b' };
1929
+
1930
+ return {
1931
+ padding: '3px 8px',
1932
+ borderRadius: '4px',
1933
+ fontSize: '11px',
1934
+ fontWeight: 600,
1935
+ border: '1px solid ' + palette.border + (confirming ? '0.7)' : '0.42)'),
1936
+ background: palette.bg + (confirming ? '0.26)' : '0.13)'),
1937
+ color: palette.fg,
1938
+ cursor: disabled ? 'not-allowed' : 'pointer',
1939
+ opacity: disabled ? 0.45 : 1
1940
+ };
1941
+ }
1942
+
1943
+ // GitTree Tab Title Chip
1944
+ function GitTreeTitle() {
1945
+ const [activeSid, setActiveSid] = React.useState(resolveCurrentSessionId);
1946
+ const [, setTick] = React.useState(0);
1947
+
1948
+ React.useEffect(() => {
1949
+ const sync = () => {
1950
+ const sid = resolveCurrentSessionId();
1951
+ setActiveSid(sid);
1952
+ setTick(t => t + 1);
1953
+ };
1954
+ const unsubMounted = globalCtx?.sidebarRight?.mounted?.subscribe(sync);
1955
+ listeners.add(sync);
1956
+ return () => {
1957
+ if (unsubMounted) unsubMounted();
1958
+ listeners.delete(sync);
1959
+ };
1960
+ }, []);
1961
+
1962
+ const sessionData = getSessionState(activeSid);
1963
+ const fileList = sessionData ? Object.values(sessionData.files) : [];
1964
+ const changedFiles = fileList.filter(f => f.status === 'M' || f.status === 'A');
1965
+ const changesCount = changedFiles.reduce((sum, f) => sum + Math.max(1, f.edits || 1), 0);
1966
+
1967
+ return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px' } },
1968
+ h('span', null, '改动'),
1969
+ changesCount > 0 ? h('span', {
1970
+ title: changedFiles.length + ' 个文件共有 ' + changesCount + ' 处可撤销改动',
1971
+ style: {
1972
+ fontSize: '11px',
1973
+ lineHeight: '14px',
1974
+ padding: '1px 5px',
1975
+ borderRadius: '10px',
1976
+ // A tinted accent with primary text: the theme has no "on-brand" foreground
1977
+ // token, so a filled brand chip would need a hardcoded colour that breaks in
1978
+ // whichever theme resolves --dsw-alias-brand-primary light.
1979
+ background: 'var(--dsw-alias-bg-overlay, rgba(127,127,127,0.22))',
1980
+ color: 'var(--dsw-alias-label-primary, inherit)',
1981
+ border: '1px solid var(--dsw-alias-brand-primary, #2563eb)',
1982
+ fontWeight: 600
1983
+ }
1984
+ }, String(changesCount)) : null
1985
+ );
1986
+ }
1987
+
1988
+ // Main Tab Body Component
1989
+ function GitTreeBody(props) {
1990
+ const propSid = props?.sessionId;
1991
+ const [activeSid, setActiveSid] = React.useState(() => propSid || resolveCurrentSessionId());
1992
+ const [, setTick] = React.useState(0);
1993
+ const [listCollapsed, setListCollapsed] = React.useState(false);
1994
+
1995
+ React.useEffect(() => {
1996
+ const sync = () => {
1997
+ const sid = propSid || resolveCurrentSessionId();
1998
+ setActiveSid(sid);
1999
+ setTick(t => t + 1);
2000
+ };
2001
+ const unsubMounted = globalCtx?.sidebarRight?.mounted?.subscribe(sync);
2002
+ listeners.add(sync);
2003
+ return () => {
2004
+ if (unsubMounted) unsubMounted();
2005
+ listeners.delete(sync);
2006
+ };
2007
+ }, [propSid]);
2008
+
2009
+ const currentSid = propSid || activeSid || resolveCurrentSessionId();
2010
+ const sessionData = getSessionState(currentSid);
2011
+
2012
+ const [filter, setFilter] = React.useState(sessionData ? sessionData.filter : 'all');
2013
+ const [search, setSearch] = React.useState(sessionData ? sessionData.searchQuery : '');
2014
+
2015
+ const fileList = sessionData ? Object.values(sessionData.files) : [];
2016
+ const modifiedFiles = fileList.filter(f => f.status === 'M');
2017
+ const addedFiles = fileList.filter(f => f.status === 'A');
2018
+ const readFiles = fileList.filter(f => f.status === 'R');
2019
+
2020
+ // ── Turn scope ──
2021
+ // The turn is the ONLY undo unit: undoing turn N restores what each file entered turn N
2022
+ // with, so earlier turns survive. Only the newest turn is undoable — an older turn may
2023
+ // already have later work stacked on top of it, so reverting it would silently discard
2024
+ // that newer work. Older turns stay selectable for reading their diff, but every undo
2025
+ // control is disabled while one is selected.
2026
+ const fileTurns = [];
2027
+ for (const f of fileList) {
2028
+ for (const t of turnsForFileWithChanges(currentSid, f)) {
2029
+ if (!fileTurns.includes(t)) fileTurns.push(t);
2030
+ }
2031
+ }
2032
+ fileTurns.sort((a, b) => a - b);
2033
+
2034
+ const newestTurn = fileTurns.length > 0 ? fileTurns[fileTurns.length - 1] : null;
2035
+
2036
+ // `selectedTurn: null` follows the newest turn; a number pins an explicit choice.
2037
+ const selectedTurn = sessionData && sessionData.selectedTurn !== null && fileTurns.includes(sessionData.selectedTurn)
2038
+ ? sessionData.selectedTurn
2039
+ : newestTurn;
2040
+
2041
+ const showingAllTurns = sessionData ? sessionData.showAllTurns === true : false;
2042
+
2043
+ /**
2044
+ * Whether undo may be offered at all.
2045
+ *
2046
+ * Two conditions, both required: the selected turn is the newest, AND the panel is not
2047
+ * showing "All turns". All turns is a cross-turn reading view, so there is no single
2048
+ * turn an undo would act on — every undo control is hidden while it is selected.
2049
+ */
2050
+ const turnUndoable = !showingAllTurns && selectedTurn !== null && selectedTurn === newestTurn;
2051
+
2052
+ function turnLabel(turn) {
2053
+ if (turn === null) return '—';
2054
+ return '第 ' + (turn + 1) + ' 轮';
2055
+ }
2056
+
2057
+ /** Files this turn changed, regardless of revertibility. */
2058
+ const filesInTurn = showingAllTurns || selectedTurn === null
2059
+ ? fileList
2060
+ : fileList.filter(f => turnsForFileWithChanges(currentSid, f).includes(selectedTurn));
2061
+
2062
+ /** Files this turn changed and that can still be undone, oldest-turn guard included. */
2063
+ const turnTargets = turnUndoable
2064
+ ? filesInTurn.filter(f => f.status === 'M' && !f.dirty)
2065
+ : [];
2066
+ const revertAllCount = turnTargets.length;
2067
+
2068
+ // Chip counts follow the turn scope, so the chips agree with the list they filter.
2069
+ const scopedChangedCount = filesInTurn.filter(f => f.status === 'M' || f.status === 'A').length;
2070
+ const scopedReadCount = filesInTurn.filter(f => f.status === 'R').length;
2071
+
2072
+ if (sessionData && !sessionData.selectedPath && filesInTurn.length > 0) {
2073
+ sessionData.selectedPath = (modifiedFiles[0] || addedFiles[0] || filesInTurn[0]).path;
2074
+ }
2075
+
2076
+ const selectedFile = sessionData && sessionData.selectedPath ? sessionData.files[sessionData.selectedPath] : null;
2077
+
2078
+ let visible = fileList;
2079
+ if (filter === 'changes') {
2080
+ visible = visible.filter(f => f.status === 'M' || f.status === 'A');
2081
+ } else if (filter === 'reads') {
2082
+ visible = visible.filter(f => f.status === 'R');
2083
+ }
2084
+
2085
+ // Scope the list to the selected turn unless the user asked to see everything.
2086
+ if (!showingAllTurns && selectedTurn !== null) {
2087
+ visible = visible.filter(f => turnsForFileWithChanges(currentSid, f).includes(selectedTurn));
2088
+ }
2089
+
2090
+ if (search.trim().length > 0) {
2091
+ const q = search.toLowerCase();
2092
+ visible = visible.filter(f => f.path.toLowerCase().includes(q));
2093
+ }
2094
+
2095
+ visible.sort((a, b) => {
2096
+ if (a.active !== b.active) return a.active ? -1 : 1;
2097
+ const rank = s => (s === 'M' ? 3 : s === 'A' ? 2 : 1);
2098
+ if (rank(a.status) !== rank(b.status)) return rank(b.status) - rank(a.status);
2099
+ return b.timestamp - a.timestamp;
2100
+ });
2101
+
2102
+ // Keep the inspector on a file the current scope actually contains, so selecting a
2103
+ // turn can never leave a diff from another turn on screen.
2104
+ const scopeForSelection = visible.length > 0 ? visible : filesInTurn;
2105
+ if (sessionData && scopeForSelection.length > 0) {
2106
+ const stillInScope = selectedFile && scopeForSelection.some(f => f.path === selectedFile.path);
2107
+ if (!stillInScope) {
2108
+ const pick = scopeForSelection.find(f => f.status === 'M' || f.status === 'A') || scopeForSelection[0];
2109
+ sessionData.selectedPath = pick.path;
2110
+ }
2111
+ }
2112
+ const scopedSelectedFile = sessionData && sessionData.selectedPath ? sessionData.files[sessionData.selectedPath] : null;
2113
+
2114
+ function handleClear() {
2115
+ if (sessionData) {
2116
+ sessionData.files = {};
2117
+ sessionData.activePath = null;
2118
+ sessionData.selectedPath = null;
2119
+ sessionData.reviewUrl = null;
2120
+ notify();
2121
+ }
2122
+ }
2123
+
2124
+ function selectFile(path) {
2125
+ if (sessionData) {
2126
+ sessionData.selectedPath = path;
2127
+ notify();
2128
+ }
2129
+ }
2130
+
2131
+ /** Row-level turn undo. Two clicks, since it writes to disk. */
2132
+ async function revertFileTurnFromRow(sid, file, undoable) {
2133
+ if (!file || selectedTurn === null) return;
2134
+ if (undoable === false) {
2135
+ setToast(sid, turnLabel(selectedTurn) + ' 不是最新轮次,无法撤销', 'info');
2136
+ return;
2137
+ }
2138
+ if (file.confirming !== 'turn') {
2139
+ file.confirming = 'turn';
2140
+ setToast(sid, '再点一次即只撤销 ' + file.path + ' 中 ' + turnLabel(selectedTurn) + ' 的改动', 'warn');
2141
+ return;
2142
+ }
2143
+ file.confirming = null;
2144
+ const result = await revertTurnForFile(sid, file, selectedTurn);
2145
+ setToast(
2146
+ sid,
2147
+ result.ok
2148
+ ? (result.unchanged
2149
+ ? file.name + ' already matched the start of ' + turnLabel(selectedTurn)
2150
+ : '已撤销 ' + file.name + ' 中 ' + turnLabel(selectedTurn) + ' 的改动(更早的轮次保持不动)')
2151
+ : result.reason,
2152
+ result.ok ? 'success' : 'error'
2153
+ );
2154
+ }
2155
+
2156
+ const hunkCount = fileList.reduce((sum, f) => sum + (f.edits || 0), 0);
2157
+
2158
+ async function revertAllFiles() {
2159
+ if (!sessionData) return;
2160
+ if (selectedTurn === null) {
2161
+ setToast(currentSid, '还没有任何轮次改动过文件', 'info');
2162
+ return;
2163
+ }
2164
+
2165
+ const targets = turnTargets;
2166
+
2167
+ if (sessionData.confirmingAll !== true) {
2168
+ if (targets.length === 0) {
2169
+ setToast(currentSid, turnLabel(selectedTurn) + ' 没有可撤销的文件改动', 'info');
2170
+ return;
2171
+ }
2172
+ sessionData.confirmingAll = true;
2173
+ setToast(
2174
+ currentSid,
2175
+ 'Click again to undo ' + targets.length + ' file(s) back to the start of ' + turnLabel(selectedTurn) +
2176
+ '. Earlier turns are kept.',
2177
+ 'warn'
2178
+ );
2179
+ return;
2180
+ }
2181
+ sessionData.confirmingAll = false;
2182
+
2183
+ let done = 0;
2184
+ let unchanged = 0;
2185
+ const failures = [];
2186
+ for (const file of targets) {
2187
+ const result = await revertTurnForFile(currentSid, file, selectedTurn);
2188
+ if (result.ok) {
2189
+ if (result.unchanged) unchanged++;
2190
+ else done++;
2191
+ } else {
2192
+ failures.push(file.name + ' (' + result.reason + ')');
2193
+ }
2194
+ }
2195
+
2196
+ const parts = ['已将 ' + done + ' 个文件还原到 ' + turnLabel(selectedTurn) + ' 开始时的状态'];
2197
+ if (unchanged > 0) parts.push(unchanged + ' 个本来就一致');
2198
+ if (failures.length > 0) parts.push('失败:' + failures.join(';'));
2199
+ setToast(currentSid, parts.join(' · '), failures.length === 0 ? 'success' : 'error');
2200
+ }
2201
+
2202
+ return h('div', {
2203
+ style: {
2204
+ display: 'flex',
2205
+ flexDirection: 'column',
2206
+ height: '100%',
2207
+ overflow: 'hidden',
2208
+ fontFamily: 'var(--dsw-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif)',
2209
+ color: 'var(--dsw-alias-label-primary, inherit)',
2210
+ fontSize: '13px',
2211
+ background: 'var(--dsw-alias-bg-base, transparent)'
2212
+ }
2213
+ },
2214
+ // Top Toolbar
2215
+ h('div', {
2216
+ style: {
2217
+ padding: '10px 14px',
2218
+ borderBottom: '1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.08))',
2219
+ background: 'var(--dsw-alias-bg-layer-1, rgba(255,255,255,0.025))'
2220
+ }
2221
+ },
2222
+ h('div', {
2223
+ style: {
2224
+ display: 'flex',
2225
+ alignItems: 'center',
2226
+ justifyContent: 'space-between',
2227
+ marginBottom: '8px'
2228
+ }
2229
+ },
2230
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '8px', fontWeight: 600, fontSize: '13px' } },
2231
+ h('span', null, '🌿 改动与差异检查器'),
2232
+ h('span', {
2233
+ style: {
2234
+ fontSize: '11px',
2235
+ color: 'var(--dsw-alias-label-tertiary, #888)',
2236
+ fontWeight: 'normal'
2237
+ }
2238
+ }, showingAllTurns
2239
+ ? '(' + (modifiedFiles.length + addedFiles.length) + ' 个改动 / 共 ' + fileList.length + ' 个访问过的文件' +
2240
+ (hunkCount > 0 ? ' · ' + hunkCount + ' 处可撤销' : '') + ')'
2241
+ : '(' + turnLabel(selectedTurn) + ':' + filesInTurn.length + ' 个文件' +
2242
+ '' +
2243
+ (revertAllCount > 0 ? ' · ' + revertAllCount + ' 处可撤销' : '') +
2244
+ ' — 本次会话共访问 ' + fileList.length + ' 个文件)')
2245
+ ),
2246
+
2247
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
2248
+ sessionData?.reviewUrl ? h('button', {
2249
+ onClick: () => openResourceUrl(currentSid, sessionData.reviewUrl),
2250
+ title: '在对比标签页中查看整轮改动',
2251
+ style: {
2252
+ background: 'rgba(37, 99, 235, 0.16)',
2253
+ border: '1px solid rgba(37, 99, 235, 0.4)',
2254
+ color: 'var(--dsw-alias-brand-primary, #60a5fa)',
2255
+ cursor: 'pointer',
2256
+ fontSize: '11px',
2257
+ fontWeight: 600,
2258
+ padding: '2px 8px',
2259
+ borderRadius: '4px'
2260
+ }
2261
+ }, '🔍 查看整轮') : null,
2262
+
2263
+ h('button', {
2264
+ onClick: () => setListCollapsed(v => !v),
2265
+ title: listCollapsed ? '展开文件列表' : '折叠文件列表',
2266
+ style: {
2267
+ background: 'none',
2268
+ border: 'none',
2269
+ color: 'var(--dsw-alias-label-secondary, #888)',
2270
+ cursor: 'pointer',
2271
+ fontSize: '11px',
2272
+ padding: '2px 6px'
2273
+ }
2274
+ }, listCollapsed ? '▼ 文件' : '▲ 折叠'),
2275
+
2276
+ h('button', {
2277
+ onClick: handleClear,
2278
+ title: '清空本次会话的改动记录',
2279
+ style: {
2280
+ background: 'none',
2281
+ border: 'none',
2282
+ color: 'var(--dsw-alias-label-secondary, #888)',
2283
+ cursor: 'pointer',
2284
+ fontSize: '11px',
2285
+ padding: '2px 6px'
2286
+ }
2287
+ }, '清空')
2288
+ )
2289
+ ),
2290
+
2291
+ // Undo feedback: one line, cleared automatically
2292
+ sessionData?.toast ? h(UndoToast, {
2293
+ toast: sessionData.toast,
2294
+ onDismiss: () => {
2295
+ if (sessionData) sessionData.toast = null;
2296
+ notify();
2297
+ }
2298
+ }) : null,
2299
+
2300
+ // Active indicator banner if running
2301
+ (sessionData && sessionData.activePath) ? h('div', {
2302
+ style: {
2303
+ padding: '6px 10px',
2304
+ borderRadius: '6px',
2305
+ background: 'rgba(16, 185, 129, 0.12)',
2306
+ border: '1px solid rgba(16, 185, 129, 0.3)',
2307
+ color: '#10b981',
2308
+ fontSize: '12px',
2309
+ display: 'flex',
2310
+ alignItems: 'center',
2311
+ gap: '6px',
2312
+ marginBottom: '6px'
2313
+ }
2314
+ },
2315
+ h('span', {
2316
+ style: {
2317
+ width: '7px',
2318
+ height: '7px',
2319
+ borderRadius: '50%',
2320
+ background: '#10b981',
2321
+ boxShadow: '0 0 6px #10b981',
2322
+ display: 'inline-block'
2323
+ }
2324
+ }),
2325
+ h('span', { style: { fontWeight: 500 } }, '智能体正在操作:'),
2326
+ h('span', {
2327
+ style: {
2328
+ fontFamily: 'var(--ds-font-family-code, monospace)',
2329
+ fontWeight: 600,
2330
+ textOverflow: 'ellipsis',
2331
+ overflow: 'hidden',
2332
+ whiteSpace: 'nowrap'
2333
+ }
2334
+ }, sessionData.activePath)
2335
+ ) : null,
2336
+
2337
+ // Filter chips, search, and the Undo all action
2338
+ !listCollapsed ? h('div', null,
2339
+ h('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
2340
+ h('button', {
2341
+ onClick: () => {
2342
+ setFilter('all');
2343
+ if (sessionData) sessionData.filter = 'all';
2344
+ },
2345
+ style: {
2346
+ padding: '2px 8px',
2347
+ borderRadius: '10px',
2348
+ border: filter === 'all' ? '1px solid var(--dsw-alias-brand-primary, #3b82f6)' : '1px solid transparent',
2349
+ background: filter === 'all' ? 'rgba(59, 130, 246, 0.15)' : 'transparent',
2350
+ color: 'inherit',
2351
+ cursor: 'pointer',
2352
+ fontSize: '11px'
2353
+ }
2354
+ }, '全部 (' + filesInTurn.length + ')'),
2355
+
2356
+ h('button', {
2357
+ onClick: () => {
2358
+ setFilter('changes');
2359
+ if (sessionData) sessionData.filter = 'changes';
2360
+ },
2361
+ style: {
2362
+ padding: '2px 8px',
2363
+ borderRadius: '10px',
2364
+ border: filter === 'changes' ? '1px solid #f59e0b' : '1px solid transparent',
2365
+ background: filter === 'changes' ? 'rgba(245, 158, 11, 0.15)' : 'transparent',
2366
+ color: filter === 'changes' ? '#f59e0b' : 'inherit',
2367
+ cursor: 'pointer',
2368
+ fontSize: '11px',
2369
+ fontWeight: scopedChangedCount > 0 ? 600 : 'normal'
2370
+ }
2371
+ }, '改动 (' + scopedChangedCount + ')'),
2372
+
2373
+ h('button', {
2374
+ onClick: () => {
2375
+ setFilter('reads');
2376
+ if (sessionData) sessionData.filter = 'reads';
2377
+ },
2378
+ style: {
2379
+ padding: '2px 8px',
2380
+ borderRadius: '10px',
2381
+ border: filter === 'reads' ? '1px solid #3b82f6' : '1px solid transparent',
2382
+ background: filter === 'reads' ? 'rgba(59, 130, 246, 0.15)' : 'transparent',
2383
+ color: filter === 'reads' ? '#3b82f6' : 'inherit',
2384
+ cursor: 'pointer',
2385
+ fontSize: '11px'
2386
+ }
2387
+ }, '读取 (' + scopedReadCount + ')'),
2388
+
2389
+ // Turn scope: drives the file list, the diff inspector, and Undo together
2390
+ h('div', {
2391
+ style: { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '6px' }
2392
+ },
2393
+ h('select', {
2394
+ value: showingAllTurns ? 'all' : String(selectedTurn),
2395
+ onChange: (e) => {
2396
+ if (!sessionData) return;
2397
+ const raw = e.target.value;
2398
+ if (raw === 'all') {
2399
+ sessionData.showAllTurns = true;
2400
+ } else {
2401
+ sessionData.showAllTurns = false;
2402
+ sessionData.selectedTurn = Number(raw);
2403
+ }
2404
+ sessionData.confirmingAll = false;
2405
+ notify();
2406
+ },
2407
+ title: '选择列表、差异和撤销所针对的轮次',
2408
+ style: {
2409
+ fontSize: '11px',
2410
+ padding: '2px 6px',
2411
+ borderRadius: '10px',
2412
+ border: '1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.14))',
2413
+ background: 'var(--dsw-alias-bg-layer-2, rgba(0,0,0,0.2))',
2414
+ color: 'inherit',
2415
+ cursor: 'pointer',
2416
+ outline: 'none',
2417
+ maxWidth: '150px'
2418
+ }
2419
+ },
2420
+ fileTurns.map(t => h('option', {
2421
+ key: 'turn-' + t,
2422
+ value: String(t)
2423
+ }, turnLabel(t) +
2424
+ (t === fileTurns[fileTurns.length - 1] ? ' · 最新' : '') +
2425
+ ' (' + fileList.filter(f =>
2426
+ f.status === 'M' && turnsForFileWithChanges(currentSid, f).includes(t)
2427
+ ).length + ' 个文件)')),
2428
+ h('option', { key: 'turn-all', value: 'all' }, '全部轮次 (' + fileList.length + ')')
2429
+ ),
2430
+
2431
+ // Hidden on older turns and under All turns, like every other undo control.
2432
+ turnUndoable ? h('button', {
2433
+ onClick: revertAllFiles,
2434
+ disabled: revertAllCount === 0,
2435
+ title: revertAllCount === 0
2436
+ ? turnLabel(selectedTurn) + ' has no undoable file change'
2437
+ : '撤销 ' + turnLabel(selectedTurn) + ' 在所有文件中的改动,更早的轮次保持不动',
2438
+ style: {
2439
+ background: sessionData?.confirmingAll ? 'rgba(245, 158, 11, 0.28)' : 'rgba(245, 158, 11, 0.12)',
2440
+ border: '1px solid ' + (sessionData?.confirmingAll ? 'rgba(245, 158, 11, 0.7)' : 'rgba(245, 158, 11, 0.4)'),
2441
+ color: '#f59e0b',
2442
+ cursor: revertAllCount === 0 ? 'not-allowed' : 'pointer',
2443
+ fontSize: '11px',
2444
+ fontWeight: 600,
2445
+ padding: '2px 8px',
2446
+ borderRadius: '10px',
2447
+ opacity: revertAllCount === 0 ? 0.45 : 1,
2448
+ whiteSpace: 'nowrap'
2449
+ }
2450
+ }, sessionData?.confirmingAll
2451
+ ? '⚠ 确认撤销'
2452
+ : '⟲ 撤销' + turnLabel(selectedTurn) + ' (' + revertAllCount + ' 个文件)') : null
2453
+ )
2454
+ ),
2455
+
2456
+ h('input', {
2457
+ type: 'text',
2458
+ placeholder: '筛选本次会话中的文件…',
2459
+ value: search,
2460
+ onChange: e => {
2461
+ setSearch(e.target.value);
2462
+ if (sessionData) sessionData.searchQuery = e.target.value;
2463
+ },
2464
+ style: {
2465
+ width: '100%',
2466
+ boxSizing: 'border-box',
2467
+ marginTop: '6px',
2468
+ padding: '4px 8px',
2469
+ borderRadius: '4px',
2470
+ fontSize: '11px',
2471
+ border: '1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.12))',
2472
+ background: 'var(--dsw-alias-bg-layer-2, rgba(0,0,0,0.15))',
2473
+ color: 'inherit',
2474
+ outline: 'none'
2475
+ }
2476
+ })
2477
+ ) : null
2478
+ ),
2479
+
2480
+ // Files Tree List Pane (Top)
2481
+ !listCollapsed ? h('div', {
2482
+ style: {
2483
+ maxHeight: '190px',
2484
+ overflowY: 'auto',
2485
+ padding: '6px 8px',
2486
+ borderBottom: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.1))',
2487
+ background: 'var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.1))'
2488
+ }
2489
+ },
2490
+ visible.length === 0 ? h('div', {
2491
+ style: {
2492
+ padding: '16px 8px',
2493
+ textAlign: 'center',
2494
+ color: 'var(--dsw-alias-label-tertiary, #666)',
2495
+ fontSize: '11px'
2496
+ }
2497
+ }, fileList.length === 0
2498
+ ? '本次会话还没有改动过任何文件。'
2499
+ : (showingAllTurns
2500
+ ? '没有文件符合当前的筛选条件。'
2501
+ : turnLabel(selectedTurn) + ' 没有改动任何符合筛选条件的文件。')) :
2502
+ visible.map(f => {
2503
+ const isSelected = scopedSelectedFile?.path === f.path;
2504
+ const badgeStyle = f.removed
2505
+ ? { bg: 'rgba(239, 68, 68, 0.16)', text: '#ef4444', border: 'rgba(239, 68, 68, 0.35)', label: 'D' }
2506
+ : f.status === 'M'
2507
+ ? { bg: 'rgba(245, 158, 11, 0.16)', text: '#f59e0b', border: 'rgba(245, 158, 11, 0.35)', label: 'M' }
2508
+ : f.status === 'A'
2509
+ ? { bg: 'rgba(16, 185, 129, 0.16)', text: '#10b981', border: 'rgba(16, 185, 129, 0.35)', label: 'A' }
2510
+ : { bg: 'rgba(59, 130, 246, 0.12)', text: '#60a5fa', border: 'rgba(59, 130, 246, 0.25)', label: 'R' };
2511
+
2512
+ const canRestore = f.removed && (recoverableTextOf(f) !== null ||
2513
+ !!baselineOf(currentSid, f.path) || !!baselines.get(f.key));
2514
+
2515
+ const editCount = f.edits || 0;
2516
+ const revertible = hasRevertibleBaseline(currentSid, f);
2517
+ const fileTurnList = turnsForFileWithChanges(currentSid, f);
2518
+ const turnText = fileTurnList.length === 0
2519
+ ? ''
2520
+ : fileTurnList.length === 1
2521
+ ? turnLabel(fileTurnList[0])
2522
+ : turnLabel(fileTurnList[0]) + '–' + turnLabel(fileTurnList[fileTurnList.length - 1]);
2523
+
2524
+ return h('div', {
2525
+ key: f.path,
2526
+ onClick: () => selectFile(f.path),
2527
+ style: {
2528
+ display: 'flex',
2529
+ alignItems: 'center',
2530
+ justifyContent: 'space-between',
2531
+ padding: '4px 8px',
2532
+ margin: '1px 0',
2533
+ borderRadius: '4px',
2534
+ cursor: 'pointer',
2535
+ borderLeft: isSelected ? '3px solid var(--dsw-alias-brand-primary, #3b82f6)' : (f.active ? '3px solid #10b981' : '3px solid transparent'),
2536
+ background: isSelected ? 'rgba(59, 130, 246, 0.14)' : (f.active ? 'rgba(16, 185, 129, 0.08)' : 'transparent'),
2537
+ transition: 'background 0.1s ease'
2538
+ }
2539
+ },
2540
+ h('div', {
2541
+ style: {
2542
+ display: 'flex',
2543
+ alignItems: 'center',
2544
+ gap: '6px',
2545
+ minWidth: 0,
2546
+ flex: 1
2547
+ }
2548
+ },
2549
+ h('span', {
2550
+ title: f.removed
2551
+ ? (f.removedBy === 'shell' ? '在文件工具之外被删除(shell 命令)' : '在本次会话中被删除')
2552
+ : f.status === 'M' ? '已修改' : f.status === 'A' ? '已新建' : '已读取',
2553
+ style: {
2554
+ display: 'inline-flex',
2555
+ alignItems: 'center',
2556
+ justifyContent: 'center',
2557
+ width: '16px',
2558
+ height: '16px',
2559
+ borderRadius: '3px',
2560
+ fontSize: '10px',
2561
+ fontWeight: 700,
2562
+ fontFamily: 'monospace',
2563
+ background: badgeStyle.bg,
2564
+ color: badgeStyle.text,
2565
+ border: '1px solid ' + badgeStyle.border,
2566
+ flexShrink: 0
2567
+ }
2568
+ }, badgeStyle.label),
2569
+
2570
+ h('div', {
2571
+ style: {
2572
+ minWidth: 0,
2573
+ overflow: 'hidden',
2574
+ textOverflow: 'ellipsis',
2575
+ whiteSpace: 'nowrap'
2576
+ }
2577
+ },
2578
+ h('span', {
2579
+ style: {
2580
+ fontWeight: isSelected ? 600 : 500,
2581
+ fontFamily: 'var(--ds-font-family-code, monospace)',
2582
+ color: isSelected ? 'var(--dsw-alias-brand-primary, #60a5fa)' : 'inherit',
2583
+ fontSize: '12px',
2584
+ textDecoration: f.removed ? 'line-through' : 'none',
2585
+ opacity: f.removed ? 0.6 : 1
2586
+ }
2587
+ }, f.name),
2588
+ f.dir !== '.' ? h('span', {
2589
+ style: {
2590
+ marginLeft: '6px',
2591
+ fontSize: '10px',
2592
+ color: 'var(--dsw-alias-label-tertiary, #777)'
2593
+ }
2594
+ }, f.dir) : null
2595
+ )
2596
+ ),
2597
+
2598
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 } },
2599
+ f.dirty ? h('span', {
2600
+ title: '智能体正在写入这个文件',
2601
+ style: { fontSize: '10px', color: '#10b981' }
2602
+ }, '⚡') : null,
2603
+
2604
+ turnText ? h('span', {
2605
+ title: '改动过这个文件的轮次:' + fileTurnList.map(turnLabel).join('、'),
2606
+ style: {
2607
+ fontSize: '10px',
2608
+ color: 'var(--dsw-alias-label-tertiary, #888)',
2609
+ background: 'rgba(255,255,255,0.05)',
2610
+ padding: '1px 5px',
2611
+ borderRadius: '8px',
2612
+ whiteSpace: 'nowrap'
2613
+ }
2614
+ }, turnText) : null,
2615
+
2616
+ editCount > 0 ? h('span', {
2617
+ title: !turnUndoable
2618
+ ? 'Already reviewed — ' + turnLabel(selectedTurn) + ' is not the newest turn'
2619
+ : revertible
2620
+ ? 'Can be undone back to the start of ' + turnLabel(selectedTurn)
2621
+ : '没有捕获到撤销快照',
2622
+ style: {
2623
+ fontSize: '10px',
2624
+ color: turnUndoable && revertible ? '#f59e0b' : 'var(--dsw-alias-label-tertiary, #888)',
2625
+ background: turnUndoable && revertible ? 'rgba(245, 158, 11, 0.12)' : 'rgba(255,255,255,0.05)',
2626
+ padding: '1px 5px',
2627
+ borderRadius: '8px',
2628
+ whiteSpace: 'nowrap'
2629
+ }
2630
+ }, turnUndoable
2631
+ ? editCount + ' 处改动'
2632
+ : editCount + ' 处改动 · 已审核') : null,
2633
+
2634
+ turnUndoable && f.status === 'M' && fileTurnList.length > 0 ? h('button', {
2635
+ onClick: (e) => {
2636
+ e.stopPropagation();
2637
+ revertFileTurnFromRow(currentSid, f, turnUndoable);
2638
+ },
2639
+ disabled: !!f.dirty,
2640
+ title: f.dirty
2641
+ ? '智能体正在写入这个文件'
2642
+ : '只撤销这个文件中 ' + turnLabel(selectedTurn) + ' 的改动,更早的轮次保持不动',
2643
+ style: {
2644
+ padding: '1px 6px',
2645
+ borderRadius: '3px',
2646
+ fontSize: '10px',
2647
+ border: '1px solid ' + (f.confirming === 'turn' ? 'rgba(245, 158, 11, 0.7)' : 'rgba(245, 158, 11, 0.35)'),
2648
+ background: f.confirming === 'turn' ? 'rgba(245, 158, 11, 0.28)' : 'rgba(245, 158, 11, 0.12)',
2649
+ color: '#f59e0b',
2650
+ cursor: f.dirty ? 'not-allowed' : 'pointer',
2651
+ opacity: f.dirty ? 0.4 : 1,
2652
+ whiteSpace: 'nowrap'
2653
+ }
2654
+ }, f.confirming === 'turn' ? '确认' : '↶ 撤销' + turnLabel(selectedTurn)) : null,
2655
+
2656
+ turnUndoable && f.status === 'A' && !f.removed ? h('button', {
2657
+ onClick: (e) => {
2658
+ e.stopPropagation();
2659
+ discardNewFile(currentSid, f);
2660
+ },
2661
+ disabled: !!f.dirty,
2662
+ title: f.dirty ? '智能体正在写入这个文件' : '把这个新建的文件从磁盘删除',
2663
+ style: {
2664
+ padding: '1px 6px',
2665
+ borderRadius: '3px',
2666
+ fontSize: '10px',
2667
+ fontWeight: 700,
2668
+ border: '1px solid ' + (f.confirming === 'delete' ? 'rgba(239, 68, 68, 0.95)' : 'rgba(239, 68, 68, 0.7)'),
2669
+ background: f.confirming === 'delete' ? '#dc2626' : 'rgba(239, 68, 68, 0.8)',
2670
+ color: '#fff',
2671
+ cursor: f.dirty ? 'not-allowed' : 'pointer',
2672
+ opacity: f.dirty ? 0.4 : 1
2673
+ }
2674
+ }, f.confirming === 'delete' ? '确认' : '🗑 删除') : null,
2675
+
2676
+ turnUndoable && f.removed ? h('button', {
2677
+ onClick: (e) => {
2678
+ e.stopPropagation();
2679
+ restoreRemovedFile(currentSid, f);
2680
+ },
2681
+ disabled: !canRestore,
2682
+ title: !canRestore
2683
+ ? '没有记录这个文件的内容,因此无法恢复'
2684
+ : (f.removedBy === 'shell'
2685
+ ? '这个文件在文件工具之外被删除;可用记录的内容把它写回磁盘'
2686
+ : '用本次会话记录的内容把这个文件写回磁盘'),
2687
+ style: {
2688
+ padding: '1px 6px',
2689
+ borderRadius: '3px',
2690
+ fontSize: '10px',
2691
+ border: '1px solid rgba(16, 185, 129, 0.35)',
2692
+ background: 'rgba(16, 185, 129, 0.12)',
2693
+ color: '#34d399',
2694
+ cursor: !canRestore ? 'not-allowed' : 'pointer',
2695
+ opacity: !canRestore ? 0.45 : 1,
2696
+ cursor: 'pointer'
2697
+ }
2698
+ }, f.confirming === 'restore' ? '确认' : '⤴ 恢复') : null,
2699
+
2700
+ h('button', {
2701
+ onClick: (e) => {
2702
+ e.stopPropagation();
2703
+ openSingleFile(currentSid, f.path, f.line);
2704
+ },
2705
+ title: '在编辑器中打开',
2706
+ style: {
2707
+ padding: '1px 6px',
2708
+ borderRadius: '3px',
2709
+ fontSize: '10px',
2710
+ border: '1px solid var(--dsw-alias-border-l1, rgba(255,255,255,0.12))',
2711
+ background: 'transparent',
2712
+ color: 'var(--dsw-alias-label-secondary, #888)',
2713
+ cursor: 'pointer'
2714
+ }
2715
+ }, '查看')
2716
+ )
2717
+ );
2718
+ })
2719
+ ) : null,
2720
+
2721
+ // Selected File Diff Inspector (Bottom)
2722
+ h(SelectedFileDiffInspector, {
2723
+ file: scopedSelectedFile,
2724
+ currentSid,
2725
+ selectedTurn,
2726
+ newestTurn,
2727
+ turnUndoable,
2728
+ turnLabelOf: turnLabel
2729
+ })
2730
+ );
2731
+ }
2732
+
2733
+ // React Error Boundary Component to guarantee no white screens ever
2734
+ class SafeGitTreeBody extends React.Component {
2735
+ constructor(props) {
2736
+ super(props);
2737
+ this.state = { hasError: false, error: null };
2738
+ }
2739
+ static getDerivedStateFromError(error) {
2740
+ return { hasError: true, error: error };
2741
+ }
2742
+ componentDidCatch(error, info) {
2743
+ console.error('[@dsh-xhl/dsh-live-inspector] Caught render error:', error, info);
2744
+ }
2745
+ render() {
2746
+ if (this.state.hasError) {
2747
+ return h('div', {
2748
+ style: {
2749
+ padding: '24px 16px',
2750
+ fontFamily: 'sans-serif',
2751
+ color: 'var(--dsw-alias-label-primary, inherit)'
2752
+ }
2753
+ },
2754
+ h('div', { style: { color: '#ef4444', fontWeight: 600, marginBottom: '6px' } }, '⚠️ 实时检查器渲染出错'),
2755
+ h('div', { style: { fontSize: '11px', color: '#888', marginBottom: '12px' } }, String(this.state.error?.message || this.state.error)),
2756
+ h('button', {
2757
+ onClick: () => this.setState({ hasError: false, error: null }),
2758
+ style: {
2759
+ padding: '4px 12px',
2760
+ borderRadius: '4px',
2761
+ cursor: 'pointer',
2762
+ background: 'var(--dsw-alias-bg-overlay, rgba(127,127,127,0.22))',
2763
+ color: 'var(--dsw-alias-label-primary, inherit)',
2764
+ border: '1px solid var(--dsw-alias-brand-primary, #2563eb)',
2765
+ fontSize: '12px'
2766
+ }
2767
+ }, '重新加载')
2768
+ );
2769
+ }
2770
+ return h(GitTreeBody, this.props);
2771
+ }
2772
+ }
2773
+
2774
+ return {
2775
+ // No Host-facing service is declared: this half reaches its Host through the HTTP
2776
+ // routes described at the filesystem seam above, which needs no injected service.
2777
+ inject: ['sidebarRight', 'sidebarRightTabs', 'slots', 'sessions'],
2778
+ apply(ctx) {
2779
+ globalCtx = ctx;
2780
+ const sessionUnsubscribes = new Map();
2781
+
2782
+ // Register tab type into sidebarRightTabs
2783
+ if (ctx.sidebarRightTabs && typeof ctx.sidebarRightTabs.register === 'function') {
2784
+ ctx.effect(() => {
2785
+ return ctx.sidebarRightTabs.register({
2786
+ id: TAB_ID,
2787
+ kind: TAB_KIND,
2788
+ priority: 'builtin',
2789
+ title: () => '改动',
2790
+ guide: [{
2791
+ id: 'git-tree',
2792
+ order: 1,
2793
+ title: () => '改动',
2794
+ description: () => '源文件改动与左右对照差异视图'
2795
+ }]
2796
+ });
2797
+ }, '@dsh-xhl/dsh-live-inspector: git-tree tab registration');
2798
+ }
2799
+
2800
+ // Register tab body into sidebar.right.pane.tab
2801
+ ctx.effect(() => {
2802
+ return ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
2803
+ name: 'sidebar.right.pane.tab',
2804
+ key: TAB_ID
2805
+ }, SafeGitTreeBody));
2806
+ }, '@dsh-xhl/dsh-live-inspector: git-tree body slot');
2807
+
2808
+ // Register live tab title into sidebar.right.pane.tab.title
2809
+ ctx.effect(() => {
2810
+ return ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
2811
+ name: 'sidebar.right.pane.tab.title',
2812
+ key: TAB_ID
2813
+ }, GitTreeTitle));
2814
+ }, '@dsh-xhl/dsh-live-inspector: git-tree title slot');
2815
+
2816
+ function ensureTabOpen(sid) {
2817
+ const activeSid = resolveCurrentSessionId();
2818
+ if (sid && activeSid && sid !== activeSid) return;
2819
+
2820
+ const sessionData = getSessionState(sid || activeSid);
2821
+ if (sessionData && sessionData.autoOpenOpenedThisTurn) return;
2822
+ if (sessionData) sessionData.autoOpenOpenedThisTurn = true;
2823
+
2824
+ try {
2825
+ if (ctx.sidebarRight && typeof ctx.sidebarRight.openTab === 'function') {
2826
+ ctx.sidebarRight.openTab(TAB_KIND);
2827
+ }
2828
+ } catch (e) {
2829
+ console.debug('[@dsh-xhl/dsh-live-inspector] openTab deferred:', e);
2830
+ }
2831
+ }
2832
+
2833
+ function processEntries(sessionId, entries, isInitialScan = false) {
2834
+ if (!Array.isArray(entries) || entries.length === 0) return;
2835
+ const sessionData = getSessionState(sessionId);
2836
+ if (!sessionData) return;
2837
+
2838
+ for (const entry of entries) {
2839
+ if (!entry || entry.type !== 'event' || !entry.event) continue;
2840
+ const ev = entry.event;
2841
+
2842
+ // Strict sequence de-duplication: each event is processed exactly once
2843
+ if (typeof ev.seq === 'number') {
2844
+ if (sessionData.processedSeqs.has(ev.seq)) continue;
2845
+ sessionData.processedSeqs.add(ev.seq);
2846
+ }
2847
+
2848
+ if (ev.type === 'turn/start') {
2849
+ if (!isInitialScan) {
2850
+ sessionData.autoOpenOpenedThisTurn = false;
2851
+ }
2852
+ // turn/start carries the working directory the tool paths are relative to.
2853
+ const rawCwd = ev.data ? ev.data.cwd : null;
2854
+ if (typeof rawCwd === 'string' && rawCwd.length > 0) {
2855
+ const cwd = normalizeCwd(rawCwd);
2856
+ if (cwd) sessionData.cwd = cwd;
2857
+ }
2858
+ if (ev.data && typeof ev.data.turn === 'number') {
2859
+ sessionData.currentTurn = ev.data.turn;
2860
+ }
2861
+ clearActive(sessionId);
2862
+ }
2863
+
2864
+ if (ev.type === 'tool/call' && ev.data) {
2865
+ const toolName = ev.data.name;
2866
+ const extracted = extractFilePathAndDiff(toolName, ev.data.arguments);
2867
+ if (extracted && extracted.path) {
2868
+ const toolTurn = typeof ev.data.turn === 'number' ? ev.data.turn : undefined;
2869
+ recordFile(sessionId, extracted.path, extracted.status, extracted.line, extracted.phase, ev.seq, { turn: toolTurn });
2870
+ if (!isInitialScan) {
2871
+ ensureTabOpen(sessionId);
2872
+ }
2873
+ }
2874
+ }
2875
+
2876
+ if (ev.type === 'tool/result' && ev.data) {
2877
+ const artifacts = extractArtifactsFromResult(ev.data.meta);
2878
+ if (artifacts) {
2879
+ const resultTurn = typeof ev.data.turn === 'number' ? ev.data.turn : undefined;
2880
+ for (const artifact of artifacts) {
2881
+ recordFile(sessionId, artifact.path, 'M', undefined, {
2882
+ kind: 'artifact',
2883
+ diffs: [],
2884
+ artifacts: [artifact],
2885
+ oldText: artifact.oldText,
2886
+ newText: artifact.newText
2887
+ }, ev.seq, { captureBaseline: false, turn: resultTurn });
2888
+ }
2889
+ }
2890
+
2891
+ // A shell command can delete files with no file tool involved, so after one
2892
+ // runs, re-check the paths the panel already knows about.
2893
+ if (!isInitialScan && SHELL_TOOL_NAMES.has(ev.data.name)) {
2894
+ detectExternalDeletions(sessionId);
2895
+ }
2896
+ }
2897
+
2898
+ if (ev.type === 'step/end' || ev.type === 'turn/end' || ev.type === 'workspace/changes') {
2899
+ clearActive(sessionId);
2900
+ }
2901
+
2902
+ if (ev.type === 'workspace/changes' && ev.data && typeof ev.data.turn === 'number') {
2903
+ sessionData.reviewUrl = 'dsh-resource://changes-review/session/' + encodeSegment(sessionId) + '/' + ev.seq + '/' + ev.data.turn;
2904
+ }
2905
+ }
2906
+
2907
+ notify();
2908
+ }
2909
+
2910
+ function bindSession(sessionId) {
2911
+ if (!sessionId || sessionUnsubscribes.has(sessionId)) return;
2912
+
2913
+ try {
2914
+ const binding = ctx.sessions?.binding(sessionId);
2915
+ if (binding && binding.eventSource && typeof binding.eventSource.subscribe === 'function') {
2916
+ // Initial scan of historical entries
2917
+ const initialSnapshot = binding.eventSource.getSnapshot();
2918
+ if (initialSnapshot && Array.isArray(initialSnapshot.entries)) {
2919
+ processEntries(sessionId, initialSnapshot.entries, true);
2920
+ }
2921
+
2922
+ // A broadcast change can carry a turn/start from any session, and the cwd it
2923
+ // carries is what this session's relative tool paths resolve against.
2924
+ const huntForCwd = () => {
2925
+ const state = getSessionState(sessionId);
2926
+ if (state && state.cwd) return;
2927
+ const snap = binding.eventSource.getSnapshot();
2928
+ if (!snap || !Array.isArray(snap.entries)) return;
2929
+ readCwdFromEntries(sessionId, snap.entries.slice(-40));
2930
+ };
2931
+ huntForCwd();
2932
+
2933
+ // Subscribe to real-time events
2934
+ const unsub = binding.eventSource.subscribe(() => {
2935
+ const snapshot = binding.eventSource.getSnapshot();
2936
+ if (!snapshot) return;
2937
+ const change = snapshot.change;
2938
+ if (change && Array.isArray(change.entries)) {
2939
+ processEntries(sessionId, change.entries, false);
2940
+ } else if (change && change.kind === 'settle-assistant' && change.entry) {
2941
+ processEntries(sessionId, [change.entry], false);
2942
+ } else if (Array.isArray(snapshot.entries)) {
2943
+ // Fallback: processEntries automatically filters via processedSeqs
2944
+ processEntries(sessionId, snapshot.entries, false);
2945
+ }
2946
+ huntForCwd();
2947
+ });
2948
+ sessionUnsubscribes.set(sessionId, unsub);
2949
+ }
2950
+ } catch (e) {
2951
+ console.warn('[@dsh-xhl/dsh-live-inspector] Failed to bind session:', sessionId, e);
2952
+ }
2953
+ }
2954
+
2955
+ function syncAllSessions() {
2956
+ try {
2957
+ const list = ctx.sessions?.list?.getSnapshot();
2958
+ if (list && list.byId) {
2959
+ for (const id in list.byId) {
2960
+ bindSession(id);
2961
+ }
2962
+ }
2963
+ } catch (e) {
2964
+ console.warn('[@dsh-xhl/dsh-live-inspector] syncAllSessions error:', e);
2965
+ }
2966
+ }
2967
+
2968
+ // Subscribe to sessions.list to automatically bind all sessions
2969
+ if (ctx.sessions?.list) {
2970
+ ctx.effect(() => {
2971
+ const unsub = ctx.sessions.list.subscribe(() => {
2972
+ syncAllSessions();
2973
+ notify();
2974
+ });
2975
+ syncAllSessions();
2976
+ return () => {
2977
+ if (unsub) unsub();
2978
+ };
2979
+ }, '@dsh-xhl/dsh-live-inspector: all sessions list subscriber');
2980
+ }
2981
+
2982
+ // Watch active session on screen
2983
+ if (ctx.sidebarRight && ctx.sidebarRight.mounted) {
2984
+ ctx.effect(() => {
2985
+ const unsub = ctx.sidebarRight.mounted.subscribe(() => {
2986
+ const sid = ctx.sidebarRight.mounted.getSnapshot();
2987
+ currentMountedSessionId = sid;
2988
+ if (sid) {
2989
+ bindSession(sid);
2990
+ notify();
2991
+ }
2992
+ });
2993
+
2994
+ const initialSid = ctx.sidebarRight.mounted.getSnapshot();
2995
+ currentMountedSessionId = initialSid;
2996
+ if (initialSid) {
2997
+ bindSession(initialSid);
2998
+ notify();
2999
+ }
3000
+
3001
+ return () => {
3002
+ if (unsub) unsub();
3003
+ for (const [, release] of sessionUnsubscribes) {
3004
+ try {
3005
+ release();
3006
+ } catch {
3007
+ // ignore
3008
+ }
3009
+ }
3010
+ sessionUnsubscribes.clear();
3011
+ };
3012
+ }, '@dsh-xhl/dsh-live-inspector: session watcher');
3013
+ }
3014
+
3015
+ console.info('[@dsh-xhl/dsh-live-inspector] Fully guarded & de-duplicated Git Tree active.');
3016
+ }
3017
+ };
3018
+ }
3019
+ });