@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/README.md +163 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +3019 -0
- package/lib/index.js +274 -0
- package/package.json +48 -0
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
|
+
});
|