Skip to content

Commit 200a319

Browse files
authored
tools: fix undo to a request keeping the initial request (microsoft#254935)
* tools: fix undo to a request keeping the initial request * more timeline improvements - Remove the special `postEdit` stop that was not very undo-able - Improve undo/redo to skip no-op points * update tests
1 parent 660e830 commit 200a319

File tree

5 files changed

+154
-103
lines changed

5 files changed

+154
-103
lines changed

src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSession.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ function getCurrentAndNextStop(requestId: string, stopId: string | undefined, hi
8484
const current = snapshot.stops[stopIndex].entries;
8585
const next = stopIndex < snapshot.stops.length - 1
8686
? snapshot.stops[stopIndex + 1].entries
87-
: snapshot.postEdit || history[snapshotIndex + 1]?.stops[0].entries;
87+
: history[snapshotIndex + 1]?.stops[0].entries;
8888

8989

9090
if (!next) {
@@ -145,6 +145,11 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
145145
hasHistory && this._state.read(reader) === ChatEditingSessionState.Idle);
146146
this.canUndo = this._timeline.canUndo.map((hasHistory, reader) =>
147147
hasHistory && this._state.read(reader) === ChatEditingSessionState.Idle);
148+
149+
this._register(autorun(reader => {
150+
const disabled = this._timeline.requestDisablement.read(reader);
151+
this._chatService.getSession(this.chatSessionId)?.setDisabledRequests(disabled);
152+
}));
148153
}
149154

150155
public async init(): Promise<void> {
@@ -226,16 +231,8 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
226231
}
227232

228233
public getSnapshot(requestId: string, undoStop: string | undefined, snapshotUri: URI): ISnapshotEntry | undefined {
229-
let entries: ResourceMap<ISnapshotEntry> | undefined;
230-
if (undoStop === ChatEditingTimeline.POST_EDIT_STOP_ID) {
231-
// If postEdit, get from timeline state
232-
const timelineState = this._timeline.getStateForPersistence();
233-
const snap = timelineState.history.find(s => s.requestId === requestId);
234-
entries = snap?.postEdit;
235-
} else {
236-
const stopRef = this._timeline.getSnapshotForRestore(requestId, undoStop);
237-
entries = stopRef?.stop.entries;
238-
}
234+
const stopRef = this._timeline.getSnapshotForRestore(requestId, undoStop);
235+
const entries = stopRef?.stop.entries;
239236
return entries && [...entries.values()].find((e) => isEqual(e.snapshotUri, snapshotUri));
240237
}
241238

@@ -268,7 +265,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
268265
this._ensurePendingSnapshot();
269266
await this._restoreSnapshot(stopRef.stop);
270267
stopRef.apply();
271-
this._updateRequestHiddenState();
272268
}
273269
} else {
274270
const pendingSnapshot = this._pendingSnapshot.get();
@@ -470,7 +466,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
470466
this._ensurePendingSnapshot();
471467
await this._restoreSnapshot(undo.stop);
472468
undo.apply();
473-
this._updateRequestHiddenState();
474469
}
475470

476471
async redoInteraction(): Promise<void> {
@@ -485,11 +480,6 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
485480
} else {
486481
this._pendingSnapshot.set(undefined, undefined);
487482
}
488-
this._updateRequestHiddenState();
489-
}
490-
491-
private _updateRequestHiddenState() {
492-
this._chatService.getSession(this.chatSessionId)?.setDisabledRequests(this._timeline.getRequestDisablement());
493483
}
494484

495485
private async _acceptStreamingEditsStart(responseModel: IChatResponseModel, undoStop: string | undefined, resource: URI) {

src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingSessionStorage.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,11 @@ export class ChatEditingSessionStorage {
6666
if ('stops' in snapshot) {
6767
return snapshot;
6868
}
69-
return { requestId: snapshot.requestId, stops: [{ stopId: undefined, entries: snapshot.entries }], postEdit: undefined };
69+
return { requestId: snapshot.requestId, stops: [{ stopId: undefined, entries: snapshot.entries }] };
7070
};
7171
const deserializeChatEditingSessionSnapshot = async (startIndex: number, snapshot: IChatEditingSessionSnapshotDTO2): Promise<IChatEditingSessionSnapshot> => {
7272
const stops = await Promise.all(snapshot.stops.map(deserializeChatEditingStopDTO));
73-
return { startIndex, requestId: snapshot.requestId, stops, postEdit: snapshot.postEdit && await deserializeSnapshotEntriesDTO(snapshot.postEdit) };
73+
return { startIndex, requestId: snapshot.requestId, stops };
7474
};
7575
const deserializeSnapshotEntry = async (entry: ISnapshotEntryDTO) => {
7676
return {
@@ -180,7 +180,6 @@ export class ChatEditingSessionStorage {
180180
return {
181181
requestId: snapshot.requestId,
182182
stops: await Promise.all(snapshot.stops.map(serializeChatEditingSessionStop)),
183-
postEdit: snapshot.postEdit ? await Promise.all(Array.from(snapshot.postEdit.values()).map(serializeSnapshotEntry)) : undefined
184183
};
185184
};
186185
const serializeSnapshotEntry = async (entry: ISnapshotEntry): Promise<ISnapshotEntryDTO> => {
@@ -242,9 +241,6 @@ export interface IChatEditingSessionSnapshot {
242241
* Invariant: never empty.
243242
*/
244243
readonly stops: IChatEditingSessionStop[];
245-
246-
/** Stop that represents changes after the last undo stop, kept for diffing purposes. */
247-
readonly postEdit: ResourceMap<ISnapshotEntry> | undefined;
248244
}
249245

250246
export interface IChatEditingSessionStop {
@@ -269,7 +265,6 @@ interface IChatEditingSessionSnapshotDTO {
269265
interface IChatEditingSessionSnapshotDTO2 {
270266
readonly requestId: string | undefined;
271267
readonly stops: IChatEditingSessionStopDTO[];
272-
readonly postEdit: ISnapshotEntryDTO[] | undefined;
273268
}
274269

275270
interface ISnapshotEntryDTO {

src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingTimeline.ts

Lines changed: 79 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66

77

88
import { equals as arraysEqual, binarySearch2 } from '../../../../../base/common/arrays.js';
9+
import { equals as objectsEqual } from '../../../../../base/common/objects.js';
910
import { findLast } from '../../../../../base/common/arraysFind.js';
11+
import { Iterable } from '../../../../../base/common/iterator.js';
1012
import { DisposableStore } from '../../../../../base/common/lifecycle.js';
1113
import { ResourceMap } from '../../../../../base/common/map.js';
1214
import { derived, derivedOpts, IObservable, ITransaction, ObservablePromise, observableValue, transaction } from '../../../../../base/common/observable.js';
@@ -46,6 +48,22 @@ export class ChatEditingTimeline {
4648
public readonly canUndo: IObservable<boolean>;
4749
public readonly canRedo: IObservable<boolean>;
4850

51+
public readonly requestDisablement = derivedOpts<IChatRequestDisablement[]>({ equalsFn: (a, b) => arraysEqual(a, b, objectsEqual) }, reader => {
52+
const history = this._linearHistory.read(reader);
53+
const index = this._linearHistoryIndex.read(reader);
54+
const undoRequests: IChatRequestDisablement[] = [];
55+
for (const entry of history) {
56+
if (!entry.requestId) {
57+
// ignored
58+
} else if (entry.startIndex >= index) {
59+
undoRequests.push({ requestId: entry.requestId });
60+
} else if (entry.startIndex + entry.stops.length > index) {
61+
undoRequests.push({ requestId: entry.requestId, afterUndoStop: entry.stops[(index - 1) - entry.startIndex].stopId });
62+
}
63+
}
64+
return undoRequests;
65+
});
66+
4967
constructor(
5068
@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService,
5169
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@@ -76,7 +94,7 @@ export class ChatEditingTimeline {
7694
* Get the snapshot and history index for restoring, given requestId and stopId.
7795
* If requestId is undefined, returns undefined (pending snapshot is managed by session).
7896
*/
79-
public getSnapshotForRestore(requestId: string | undefined, stopId: string | undefined): { stop: IChatEditingSessionStop; historyIndex: number; apply(): void } | undefined {
97+
public getSnapshotForRestore(requestId: string | undefined, stopId: string | undefined): { stop: IChatEditingSessionStop; apply(): void } | undefined {
8098
if (requestId === undefined) {
8199
return undefined;
82100
}
@@ -85,7 +103,13 @@ export class ChatEditingTimeline {
85103
return undefined;
86104
}
87105

88-
return { stop: stopRef.stop, historyIndex: stopRef.historyIndex, apply: () => this._linearHistoryIndex.set(stopRef.historyIndex + 1, undefined) };
106+
// When rolling back to the first snapshot taken for a request, mark the
107+
// entire request as undone.
108+
const toIndex = stopRef.stop.stopId === undefined ? stopRef.historyIndex : stopRef.historyIndex + 1;
109+
return {
110+
stop: stopRef.stop,
111+
apply: () => this._linearHistoryIndex.set(toIndex, undefined)
112+
};
89113
}
90114

91115
/**
@@ -119,22 +143,21 @@ export class ChatEditingTimeline {
119143
return;
120144
}
121145

122-
const snap = history[snapIndex];
146+
const snap = { ...history[snapIndex] };
123147
let stopIndex = snap.stops.findIndex((s) => s.stopId === undoStop);
124148
if (stopIndex === -1) {
125149
return;
126150
}
127151

152+
let linearHistoryIndexIncr = 0;
128153
if (next) {
129154
if (stopIndex === snap.stops.length - 1) {
130-
const postEdit = new ResourceMap(snap.postEdit || ChatEditingTimeline.createEmptySnapshot(undefined).entries);
131-
if (!snap.postEdit || !entry.equalsSnapshot(postEdit.get(entry.modifiedURI) as ISnapshotEntry | undefined)) {
132-
postEdit.set(entry.modifiedURI, entry.createSnapshot(requestId, ChatEditingTimeline.POST_EDIT_STOP_ID));
133-
const newHistory = history.slice();
134-
newHistory[snapIndex] = { ...snap, postEdit };
135-
this._linearHistory.set(newHistory, tx);
155+
if (snap.stops[stopIndex].stopId === ChatEditingTimeline.POST_EDIT_STOP_ID) {
156+
throw new Error('cannot duplicate post-edit stop');
136157
}
137-
return;
158+
159+
snap.stops = snap.stops.concat(ChatEditingTimeline.createEmptySnapshot(ChatEditingTimeline.POST_EDIT_STOP_ID));
160+
linearHistoryIndexIncr++;
138161
}
139162
stopIndex++;
140163
}
@@ -149,10 +172,15 @@ export class ChatEditingTimeline {
149172

150173
const newStop = snap.stops.slice();
151174
newStop[stopIndex] = { ...stop, entries: newMap };
175+
snap.stops = newStop;
152176

153177
const newHistory = history.slice();
154-
newHistory[snapIndex] = { ...snap, stops: newStop };
178+
newHistory[snapIndex] = snap;
179+
155180
this._linearHistory.set(newHistory, tx);
181+
if (linearHistoryIndexIncr) {
182+
this._linearHistoryIndex.set(this._linearHistoryIndex.get() + linearHistoryIndexIncr, tx);
183+
}
156184
}
157185

158186
/**
@@ -161,23 +189,40 @@ export class ChatEditingTimeline {
161189
* pushed into the history.
162190
*/
163191
public getUndoSnapshot(): { stop: IChatEditingSessionStop; apply(): void } | undefined {
164-
const idx = this._linearHistoryIndex.get() - 2;
165-
const entry = this.getHistoryEntryByLinearIndex(idx);
166-
if (entry) {
167-
return { stop: entry.stop, apply: () => this._linearHistoryIndex.set(idx + 1, undefined) };
168-
}
169-
return undefined;
192+
return this.getUndoRedoSnapshot(-1);
170193
}
171194

172195
/**
173196
* Get the redo snapshot (next in history), or undefined if at end.
174197
*/
175198
public getRedoSnapshot(): { stop: IChatEditingSessionStop; apply(): void } | undefined {
176-
const idx = this._linearHistoryIndex.get();
177-
const entry = this.getHistoryEntryByLinearIndex(idx);
199+
return this.getUndoRedoSnapshot(1);
200+
}
201+
202+
private getUndoRedoSnapshot(direction: number) {
203+
let idx = this._linearHistoryIndex.get() - 1;
204+
const max = getMaxHistoryIndex(this._linearHistory.get());
205+
const startEntry = this.getHistoryEntryByLinearIndex(idx);
206+
let entry = startEntry;
207+
if (!startEntry) {
208+
return undefined;
209+
}
210+
211+
do {
212+
idx += direction;
213+
entry = this.getHistoryEntryByLinearIndex(idx);
214+
} while (
215+
idx + direction < max &&
216+
idx + direction >= 0 &&
217+
entry &&
218+
!(direction === -1 && entry.entry.requestId !== startEntry.entry.requestId) &&
219+
!stopProvidesNewData(startEntry.stop, entry.stop)
220+
);
221+
178222
if (entry) {
179223
return { stop: entry.stop, apply: () => this._linearHistoryIndex.set(idx + 1, undefined) };
180224
}
225+
181226
return undefined;
182227
}
183228

@@ -221,23 +266,27 @@ export class ChatEditingTimeline {
221266
if (entry.startIndex >= linearHistoryPtr) {
222267
break;
223268
} else if (linearHistoryPtr - entry.startIndex < entry.stops.length) {
224-
newLinearHistory.push({ requestId: entry.requestId, stops: entry.stops.slice(0, linearHistoryPtr - entry.startIndex), startIndex: entry.startIndex, postEdit: undefined });
269+
newLinearHistory.push({ requestId: entry.requestId, stops: entry.stops.slice(0, linearHistoryPtr - entry.startIndex), startIndex: entry.startIndex });
225270
} else {
226271
newLinearHistory.push(entry);
227272
}
228273
}
229274

230275
const lastEntry = newLinearHistory.at(-1);
231276
if (requestId && lastEntry?.requestId === requestId) {
232-
if (lastEntry.postEdit && undoStop) {
277+
const hadPostEditStop = lastEntry.stops.at(-1)?.stopId === ChatEditingTimeline.POST_EDIT_STOP_ID && undoStop;
278+
if (hadPostEditStop) {
233279
const rebaseUri = (uri: URI) => uri.with({ query: uri.query.replace(ChatEditingTimeline.POST_EDIT_STOP_ID, undoStop) });
234-
for (const [uri, prev] of lastEntry.postEdit.entries()) {
280+
for (const [uri, prev] of lastEntry.stops.at(-1)!.entries) {
235281
snapshot.entries.set(uri, { ...prev, snapshotUri: rebaseUri(prev.snapshotUri), resource: rebaseUri(prev.resource) });
236282
}
237283
}
238-
newLinearHistory[newLinearHistory.length - 1] = { ...lastEntry, stops: [...lastEntry.stops, snapshot], postEdit: undefined };
284+
newLinearHistory[newLinearHistory.length - 1] = {
285+
...lastEntry,
286+
stops: [...hadPostEditStop ? lastEntry.stops.slice(0, -1) : lastEntry.stops, snapshot]
287+
};
239288
} else {
240-
newLinearHistory.push({ requestId, startIndex: lastEntry ? lastEntry.startIndex + lastEntry.stops.length : 0, stops: [snapshot], postEdit: undefined });
289+
newLinearHistory.push({ requestId, startIndex: lastEntry ? lastEntry.startIndex + lastEntry.stops.length : 0, stops: [snapshot] });
241290
}
242291

243292
transaction((tx) => {
@@ -247,25 +296,6 @@ export class ChatEditingTimeline {
247296
});
248297
}
249298

250-
/**
251-
* Gets chat disablement entries for the current timeline state.
252-
*/
253-
public getRequestDisablement() {
254-
const history = this._linearHistory.get();
255-
const index = this._linearHistoryIndex.get();
256-
const undoRequests: IChatRequestDisablement[] = [];
257-
for (const entry of history) {
258-
if (!entry.requestId) {
259-
// ignored
260-
} else if (entry.startIndex >= index) {
261-
undoRequests.push({ requestId: entry.requestId });
262-
} else if (entry.startIndex + entry.stops.length > index) {
263-
undoRequests.push({ requestId: entry.requestId, afterUndoStop: entry.stops[index - entry.startIndex].stopId });
264-
}
265-
}
266-
return undoRequests;
267-
}
268-
269299
/**
270300
* Gets diff for text entries between stops.
271301
* @param entriesContent Observable that observes either snapshot entry
@@ -390,6 +420,10 @@ export class ChatEditingTimeline {
390420
}
391421
}
392422

423+
function stopProvidesNewData(origin: IChatEditingSessionStop, target: IChatEditingSessionStop) {
424+
return Iterable.some(target.entries, ([uri, e]) => origin.entries.get(uri)?.current !== e.current);
425+
}
426+
393427
function getMaxHistoryIndex(history: readonly IChatEditingSessionSnapshot[]) {
394428
const lastHistory = history.at(-1);
395429
return lastHistory ? lastHistory.startIndex + lastHistory.stops.length : 0;
@@ -415,14 +449,11 @@ function getCurrentAndNextStop(requestId: string, stopId: string | undefined, hi
415449
const nextStop = stopIndex < snapshot.stops.length - 1
416450
? snapshot.stops[stopIndex + 1]
417451
: undefined;
418-
const next = nextStop?.entries || snapshot.postEdit;
419-
420-
421-
if (!next) {
452+
if (!nextStop) {
422453
return undefined;
423454
}
424455

425-
return { current, currentStopId: currentStop.stopId, next, nextStopId: nextStop?.stopId || ChatEditingTimeline.POST_EDIT_STOP_ID };
456+
return { current, currentStopId: currentStop.stopId, next: nextStop.entries, nextStopId: nextStop.stopId };
426457
}
427458

428459
function getFirstAndLastStop(uri: URI, history: readonly IChatEditingSessionSnapshot[]) {
@@ -439,12 +470,6 @@ function getFirstAndLastStop(uri: URI, history: readonly IChatEditingSessionSnap
439470
let lastStopWithUriId: string | undefined;
440471
for (let i = history.length - 1; i >= 0; i--) {
441472
const snapshot = history[i];
442-
if (snapshot.postEdit?.has(uri)) {
443-
lastStopWithUri = snapshot.postEdit;
444-
lastStopWithUriId = ChatEditingTimeline.POST_EDIT_STOP_ID;
445-
break;
446-
}
447-
448473
const stop = findLast(snapshot.stops, s => s.entries.has(uri));
449474
if (stop) {
450475
lastStopWithUri = stop.entries;

src/vs/workbench/contrib/chat/test/browser/chatEditingSessionStorage.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,8 @@ suite('ChatEditingSessionStorage', () => {
6565
recentSnapshot: makeStop(undefined, 'd', 'e'),
6666
linearHistoryIndex: 3,
6767
linearHistory: [
68-
{ startIndex: 0, requestId: r1, stops: [makeStop(r1, 'a', 'b')], postEdit: makeStop(r1, 'b', 'c').entries },
69-
{ startIndex: 1, requestId: r2, stops: [makeStop(r2, 'c', 'd'), makeStop(r2, 'd', 'd')], postEdit: makeStop(r2, 'd', 'd').entries },
68+
{ startIndex: 0, requestId: r1, stops: [makeStop(r1, 'a', 'b')] },
69+
{ startIndex: 1, requestId: r2, stops: [makeStop(r2, 'c', 'd'), makeStop(r2, 'd', 'd')] },
7070
]
7171
};
7272
}

0 commit comments

Comments
 (0)