Skip to content

Commit fce85e9

Browse files
authored
uses /edit editor UX for "Apply In Editor" (#237944)
* WIP * uses /edit editor UX for "Apply In Editor" fixes microsoft/vscode-copilot#8577
1 parent 1db1071 commit fce85e9

11 files changed

+218
-86
lines changed

src/vs/base/common/iterator.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* Licensed under the MIT License. See License.txt in the project root for license information.
44
*--------------------------------------------------------------------------------------------*/
55

6+
import { isIterable } from './types.js';
7+
68
export namespace Iterable {
79

810
export function is<T = any>(thing: any): thing is Iterable<T> {
@@ -90,9 +92,13 @@ export namespace Iterable {
9092
}
9193
}
9294

93-
export function* concat<T>(...iterables: Iterable<T>[]): Iterable<T> {
94-
for (const iterable of iterables) {
95-
yield* iterable;
95+
export function* concat<T>(...iterables: (Iterable<T> | T)[]): Iterable<T> {
96+
for (const item of iterables) {
97+
if (isIterable(item)) {
98+
yield* item;
99+
} else {
100+
yield item;
101+
}
96102
}
97103
}
98104

src/vs/workbench/contrib/chat/browser/actions/chatCodeblockActions.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ export function registerChatCodeCompareBlockActions() {
558558
const inlineChatController = InlineChatController.get(editorToApply);
559559
if (inlineChatController) {
560560
editorToApply.revealLineInCenterIfOutsideViewport(firstEdit.range.startLineNumber);
561-
inlineChatController.reviewEdits(firstEdit.range, textEdits, CancellationToken.None);
561+
inlineChatController.reviewEdits(textEdits, CancellationToken.None);
562562
response.setEditApplied(item, 1);
563563
return true;
564564
}

src/vs/workbench/contrib/chat/browser/actions/codeBlockOperations.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ export class ApplyCodeBlockOperation {
266266
const inlineChatController = InlineChatController.get(codeEditor);
267267
if (inlineChatController) {
268268
let isOpen = true;
269-
const promise = inlineChatController.reviewEdits(codeEditor.getSelection(), edits, tokenSource.token);
269+
const promise = inlineChatController.reviewEdits(edits, tokenSource.token);
270270
promise.finally(() => {
271271
isOpen = false;
272272
tokenSource.dispose();

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

+13
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie
134134

135135
private readonly _diffTrimWhitespace: IObservable<boolean>;
136136

137+
private _refCounter: number = 1;
138+
137139
constructor(
138140
resourceRef: IReference<IResolvedTextEditorModel>,
139141
private readonly _multiDiffEntryDelegate: { collapse: (transaction: ITransaction | undefined) => void },
@@ -199,6 +201,17 @@ export class ChatEditingModifiedFileEntry extends Disposable implements IModifie
199201
}));
200202
}
201203

204+
override dispose(): void {
205+
if (--this._refCounter === 0) {
206+
super.dispose();
207+
}
208+
}
209+
210+
acquire() {
211+
this._refCounter++;
212+
return this;
213+
}
214+
202215
private _clearCurrentEditLineDecoration() {
203216
this._editDecorations = this.doc.deltaDecorations(this._editDecorations, []);
204217
}

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

+59-16
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../base/
99
import { Codicon } from '../../../../../base/common/codicons.js';
1010
import { BugIndicatingError } from '../../../../../base/common/errors.js';
1111
import { Emitter, Event } from '../../../../../base/common/event.js';
12+
import { Iterable } from '../../../../../base/common/iterator.js';
1213
import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
14+
import { LinkedList } from '../../../../../base/common/linkedList.js';
1315
import { ResourceMap } from '../../../../../base/common/map.js';
14-
import { derived, IObservable, observableValue, runOnChange, ValueWithChangeEventFromObservable } from '../../../../../base/common/observable.js';
16+
import { derived, IObservable, observableValue, observableValueOpts, runOnChange, ValueWithChangeEventFromObservable } from '../../../../../base/common/observable.js';
1517
import { compare } from '../../../../../base/common/strings.js';
1618
import { ThemeIcon } from '../../../../../base/common/themables.js';
1719
import { isString } from '../../../../../base/common/types.js';
@@ -37,6 +39,7 @@ import { ChatContextKeys } from '../../common/chatContextKeys.js';
3739
import { applyingChatEditsContextKey, applyingChatEditsFailedContextKey, CHAT_EDITING_MULTI_DIFF_SOURCE_RESOLVER_SCHEME, chatEditingAgentSupportsReadonlyReferencesContextKey, chatEditingMaxFileAssignmentName, chatEditingResourceContextKey, ChatEditingSessionState, decidedChatEditingResourceContextKey, defaultChatEditingMaxFileLimit, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, IChatEditingSessionStream, IChatRelatedFile, IChatRelatedFilesProvider, IModifiedFileEntry, inChatEditingSessionContextKey, WorkingSetEntryState } from '../../common/chatEditingService.js';
3840
import { IChatResponseModel, IChatTextEditGroup } from '../../common/chatModel.js';
3941
import { IChatService } from '../../common/chatService.js';
42+
import { ChatEditingModifiedFileEntry } from './chatEditingModifiedFileEntry.js';
4043
import { ChatEditingSession } from './chatEditingSession.js';
4144
import { ChatEditingSnapshotTextModelContentProvider, ChatEditingTextModelContentProvider } from './chatEditingTextModelContentProviders.js';
4245

@@ -50,6 +53,17 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
5053
private readonly _currentSessionObs = observableValue<ChatEditingSession | null>(this, null);
5154
private readonly _currentSessionDisposables = this._register(new DisposableStore());
5255

56+
private readonly _adhocSessionsObs = observableValueOpts<LinkedList<ChatEditingSession>>({ equalsFn: (a, b) => false }, new LinkedList());
57+
58+
readonly editingSessionsObs: IObservable<readonly IChatEditingSession[]> = derived(r => {
59+
const result = Array.from(this._adhocSessionsObs.read(r));
60+
const globalSession = this._currentSessionObs.read(r);
61+
if (globalSession) {
62+
result.push(globalSession);
63+
}
64+
return result;
65+
});
66+
5367
private readonly _currentAutoApplyOperationObs = observableValue<CancellationTokenSource | null>(this, null);
5468
get currentAutoApplyOperation(): CancellationTokenSource | null {
5569
return this._currentAutoApplyOperationObs.get();
@@ -63,9 +77,6 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
6377
return this._currentSessionObs;
6478
}
6579

66-
private readonly _onDidChangeEditingSession = this._register(new Emitter<void>());
67-
public readonly onDidChangeEditingSession = this._onDidChangeEditingSession.event;
68-
6980
private _editingSessionFileLimitPromise: Promise<number>;
7081
private _editingSessionFileLimit: number | undefined;
7182
get editingSessionFileLimit() {
@@ -111,13 +122,14 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
111122
return decidedEntries.map(entry => entry.entryId);
112123
}));
113124
this._register(bindContextKey(hasUndecidedChatEditingResourceContextKey, contextKeyService, (reader) => {
114-
const currentSession = this._currentSessionObs.read(reader);
115-
if (!currentSession) {
116-
return;
125+
126+
for (const session of this.editingSessionsObs.read(reader)) {
127+
const entries = session.entries.read(reader);
128+
const decidedEntries = entries.filter(entry => entry.state.read(reader) === WorkingSetEntryState.Modified);
129+
return decidedEntries.length > 0;
117130
}
118-
const entries = currentSession.entries.read(reader);
119-
const decidedEntries = entries.filter(entry => entry.state.read(reader) === WorkingSetEntryState.Modified);
120-
return decidedEntries.length > 0;
131+
132+
return false;
121133
}));
122134
this._register(bindContextKey(hasAppliedChatEditsContextKey, contextKeyService, (reader) => {
123135
const currentSession = this._currentSessionObs.read(reader);
@@ -211,14 +223,26 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
211223
}
212224

213225

226+
private _lookupEntry(uri: URI): ChatEditingModifiedFileEntry | undefined {
227+
228+
for (const item of Iterable.concat(this.editingSessionsObs.get())) {
229+
const candidate = item.getEntry(uri);
230+
if (candidate instanceof ChatEditingModifiedFileEntry) {
231+
// make sure to ref-count this object
232+
return candidate.acquire();
233+
}
234+
}
235+
return undefined;
236+
}
237+
214238
private async _createEditingSession(chatSessionId: string): Promise<IChatEditingSession> {
215239
if (this._currentSessionObs.get()) {
216240
throw new BugIndicatingError('Cannot have more than one active editing session');
217241
}
218242

219243
this._currentSessionDisposables.clear();
220244

221-
const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, this._editingSessionFileLimitPromise);
245+
const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, this._editingSessionFileLimitPromise, this._lookupEntry.bind(this));
222246
await session.init();
223247

224248
// listen for completed responses, run the code mapper and apply the edits to this edit session
@@ -227,14 +251,33 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
227251
this._currentSessionDisposables.add(session.onDidDispose(() => {
228252
this._currentSessionDisposables.clear();
229253
this._currentSessionObs.set(null, undefined);
230-
this._onDidChangeEditingSession.fire();
231-
}));
232-
this._currentSessionDisposables.add(session.onDidChange(() => {
233-
this._onDidChangeEditingSession.fire();
234254
}));
235255

236256
this._currentSessionObs.set(session, undefined);
237-
this._onDidChangeEditingSession.fire();
257+
return session;
258+
}
259+
260+
async createAdhocEditingSession(chatSessionId: string): Promise<IChatEditingSession & IDisposable> {
261+
const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, this._editingSessionFileLimitPromise, this._lookupEntry.bind(this));
262+
await session.init();
263+
264+
const list = this._adhocSessionsObs.get();
265+
const removeSession = list.unshift(session);
266+
267+
const store = new DisposableStore();
268+
this._store.add(store);
269+
270+
store.add(this.installAutoApplyObserver(session));
271+
272+
store.add(session.onDidDispose(e => {
273+
removeSession();
274+
this._adhocSessionsObs.set(list, undefined);
275+
this._store.deleteAndLeak(store);
276+
store.dispose();
277+
}));
278+
279+
this._adhocSessionsObs.set(list, undefined);
280+
238281
return session;
239282
}
240283

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

+25-8
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
163163
constructor(
164164
public readonly chatSessionId: string,
165165
private editingSessionFileLimitPromise: Promise<number>,
166+
private _lookupExternalEntry: (uri: URI) => ChatEditingModifiedFileEntry | undefined,
166167
@IInstantiationService private readonly _instantiationService: IInstantiationService,
167168
@IModelService private readonly _modelService: IModelService,
168169
@ILanguageService private readonly _languageService: ILanguageService,
@@ -670,23 +671,39 @@ export class ChatEditingSession extends Disposable implements IChatEditingSessio
670671
}
671672
return existingEntry;
672673
}
673-
const initialContent = this._initialFileContents.get(resource);
674-
// This gets manually disposed in .dispose() or in .restoreSnapshot()
675-
const entry = await this._createModifiedFileEntry(resource, responseModel, false, initialContent);
676-
if (!initialContent) {
677-
this._initialFileContents.set(resource, entry.initialContent);
674+
675+
let entry: ChatEditingModifiedFileEntry;
676+
const existingExternalEntry = this._lookupExternalEntry(resource);
677+
if (existingExternalEntry) {
678+
entry = existingExternalEntry;
679+
} else {
680+
const initialContent = this._initialFileContents.get(resource);
681+
// This gets manually disposed in .dispose() or in .restoreSnapshot()
682+
entry = await this._createModifiedFileEntry(resource, responseModel, false, initialContent);
683+
if (!initialContent) {
684+
this._initialFileContents.set(resource, entry.initialContent);
685+
}
678686
}
687+
679688
// If an entry is deleted e.g. reverting a created file,
680689
// remove it from the entries and don't show it in the working set anymore
681690
// so that it can be recreated e.g. through retry
682-
this._register(entry.onDidDelete(() => {
691+
const listener = entry.onDidDelete(() => {
683692
const newEntries = this._entriesObs.get().filter(e => !isEqual(e.modifiedURI, entry.modifiedURI));
684693
this._entriesObs.set(newEntries, undefined);
685694
this._workingSet.delete(entry.modifiedURI);
686695
this._editorService.closeEditors(this._editorService.findEditors(entry.modifiedURI));
687-
entry.dispose();
696+
697+
if (!existingExternalEntry) {
698+
// don't dispose entries that are not yours!
699+
entry.dispose();
700+
}
701+
702+
this._store.delete(listener);
688703
this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet);
689-
}));
704+
});
705+
this._store.add(listener);
706+
690707
const entriesArr = [...this._entriesObs.get(), entry];
691708
this._entriesObs.set(entriesArr, undefined);
692709
this._onDidChange.fire(ChatEditingSessionChangeType.WorkingSet);

src/vs/workbench/contrib/chat/browser/chatEditorActions.ts

+9-6
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,15 @@ abstract class NavigateAction extends Action2 {
6161
if (!isCodeEditor(editor) || !editor.hasModel()) {
6262
return;
6363
}
64-
65-
const session = chatEditingService.currentEditingSession;
66-
if (!session) {
64+
const ctrl = ChatEditorController.get(editor);
65+
if (!ctrl) {
6766
return;
6867
}
6968

70-
const ctrl = ChatEditorController.get(editor);
71-
if (!ctrl) {
69+
const session = chatEditingService.editingSessionsObs.get()
70+
.find(candidate => candidate.getEntry(editor.getModel().uri));
71+
72+
if (!session) {
7273
return;
7374
}
7475

@@ -167,7 +168,9 @@ abstract class AcceptDiscardAction extends Action2 {
167168
return;
168169
}
169170

170-
const session = chatEditingService.currentEditingSession;
171+
const session = chatEditingService.editingSessionsObs.get()
172+
.find(candidate => candidate.getEntry(uri));
173+
171174
if (!session) {
172175
return;
173176
}

src/vs/workbench/contrib/chat/browser/chatEditorController.ts

+35-20
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,8 @@ export class ChatEditorController extends Disposable implements IEditorContribut
7373

7474
constructor(
7575
private readonly _editor: ICodeEditor,
76-
@IInstantiationService private readonly _instantiationService: IInstantiationService,
7776
@IChatEditingService private readonly _chatEditingService: IChatEditingService,
77+
@IInstantiationService private readonly _instantiationService: IInstantiationService,
7878
@IChatAgentService private readonly _chatAgentService: IChatAgentService,
7979
@IEditorService private readonly _editorService: IEditorService,
8080
@IContextKeyService contextKeyService: IContextKeyService,
@@ -92,27 +92,31 @@ export class ChatEditorController extends Disposable implements IEditorContribut
9292

9393

9494
this._store.add(autorun(r => {
95-
const session = this._chatEditingService.currentEditingSessionObs.read(r);
96-
this._ctxRequestInProgress.set(session?.state.read(r) === ChatEditingSessionState.StreamingEdits);
95+
let isStreamingEdits = false;
96+
for (const session of _chatEditingService.editingSessionsObs.read(r)) {
97+
isStreamingEdits ||= session.state.read(r) === ChatEditingSessionState.StreamingEdits;
98+
}
99+
this._ctxRequestInProgress.set(isStreamingEdits);
97100
}));
98101

99-
100102
const entryForEditor = derived(r => {
101103
const model = modelObs.read(r);
102-
const session = this._chatEditingService.currentEditingSessionObs.read(r);
103-
if (!session) {
104-
return undefined;
104+
if (!model) {
105+
return;
105106
}
106107

107-
const entries = session.entries.read(r);
108-
const idx = model?.uri
109-
? entries.findIndex(e => isEqual(e.modifiedURI, model.uri))
110-
: -1;
108+
for (const session of _chatEditingService.editingSessionsObs.read(r)) {
109+
const entries = session.entries.read(r);
110+
const idx = model?.uri
111+
? entries.findIndex(e => isEqual(e.modifiedURI, model.uri))
112+
: -1;
111113

112-
if (idx < 0) {
113-
return undefined;
114+
if (idx >= 0) {
115+
return { session, entry: entries[idx], entries, idx };
116+
}
114117
}
115-
return { session, entry: entries[idx], entries, idx };
118+
119+
return undefined;
116120
});
117121

118122

@@ -185,13 +189,17 @@ export class ChatEditorController extends Disposable implements IEditorContribut
185189
// ---- readonly while streaming
186190

187191
const shouldBeReadOnly = derived(this, r => {
188-
const value = this._chatEditingService.currentEditingSessionObs.read(r);
189-
if (!value || value.state.read(r) !== ChatEditingSessionState.StreamingEdits) {
190-
return false;
191-
}
192192

193193
const model = modelObs.read(r);
194-
return model ? value.readEntry(model.uri, r) : undefined;
194+
if (!model) {
195+
return undefined;
196+
}
197+
for (const session of _chatEditingService.editingSessionsObs.read(r)) {
198+
if (session.readEntry(model.uri, r) && session.state.read(r) === ChatEditingSessionState.StreamingEdits) {
199+
return true;
200+
}
201+
}
202+
return false;
195203
});
196204

197205

@@ -570,7 +578,14 @@ export class ChatEditorController extends Disposable implements IEditorContribut
570578
return;
571579
}
572580

573-
const entry = this._chatEditingService.currentEditingSessionObs.get()?.getEntry(this._editor.getModel().uri);
581+
let entry: IModifiedFileEntry | undefined;
582+
for (const session of this._chatEditingService.editingSessionsObs.get()) {
583+
entry = session.getEntry(this._editor.getModel().uri);
584+
if (entry) {
585+
break;
586+
}
587+
}
588+
574589
if (!entry) {
575590
return;
576591
}

0 commit comments

Comments
 (0)