Skip to content

Commit

Permalink
Scaffold showing inline diff in the file (microsoft#230695)
Browse files Browse the repository at this point in the history
  • Loading branch information
alexdima authored Oct 7, 2024
1 parent d1bab6b commit 485a7ed
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 8 deletions.
3 changes: 3 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chat.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ import './chatAttachmentModel.js';
import './contrib/chatInputCompletions.js';
import './contrib/chatInputEditorContrib.js';
import './contrib/chatInputEditorHover.js';
import { EditorContributionInstantiation, registerEditorContribution } from '../../../../editor/browser/editorExtensions.js';
import { ChatEditorController } from './chatEditorController.js';

// Register configuration
const configurationRegistry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration);
Expand Down Expand Up @@ -294,6 +296,7 @@ registerChatContextActions();
registerChatDeveloperActions();

registerEditorFeature(ChatPasteProvidersFeature);
registerEditorContribution(ChatEditorController.ID, ChatEditorController, EditorContributionInstantiation.Eventually);

registerSingleton(IChatService, ChatService, InstantiationType.Delayed);
registerSingleton(IChatWidgetService, ChatWidgetService, InstantiationType.Delayed);
Expand Down
45 changes: 37 additions & 8 deletions src/vs/workbench/contrib/chat/browser/chatEditingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Sequencer } from '../../../../base/common/async.js';
import { CancellationToken, CancellationTokenSource } from '../../../../base/common/cancellation.js';
import { BugIndicatingError } from '../../../../base/common/errors.js';
import { Emitter } from '../../../../base/common/event.js';
import { Disposable, DisposableStore, IDisposable, IReference, MutableDisposable } from '../../../../base/common/lifecycle.js';
import { Disposable, DisposableStore, IDisposable, IReference } from '../../../../base/common/lifecycle.js';
import { ResourceSet } from '../../../../base/common/map.js';
import { derived, IObservable, ITransaction, observableValue, ValueWithChangeEventFromObservable } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
Expand Down Expand Up @@ -42,7 +42,7 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
_serviceBrand: undefined;

private readonly _currentSessionObs = observableValue<ChatEditingSession | null>(this, null);
private readonly _currentSessionDisposeListener = this._register(new MutableDisposable());
private readonly _currentSessionDisposables = this._register(new DisposableStore());

private readonly _currentAutoApplyOperationObs = observableValue<CancellationTokenSource | null>(this, null);
get currentAutoApplyOperation(): CancellationTokenSource | null {
Expand All @@ -53,11 +53,14 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
return this._currentSessionObs.get();
}

private readonly _onDidCreateEditingSession = new Emitter<IChatEditingSession>();
private readonly _onDidCreateEditingSession = this._register(new Emitter<IChatEditingSession>());
get onDidCreateEditingSession() {
return this._onDidCreateEditingSession.event;
}

private readonly _onDidChangeEditingSession = this._register(new Emitter<void>());
public readonly onDidChangeEditingSession = this._onDidChangeEditingSession.event;

constructor(
@IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
Expand Down Expand Up @@ -93,6 +96,20 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
}));
}

getEditingSession(resource: URI): IChatEditingSession | null {
const session = this.currentEditingSession;
if (!session) {
return null;
}
const entries = session.entries.get();
for (const entry of entries) {
if (entry.modifiedURI.toString() === resource.toString()) {
return session;
}
}
return null;
}

async addFileToWorkingSet(resource: URI): Promise<void> {
const session = this._currentSessionObs.get();
if (session) {
Expand Down Expand Up @@ -120,8 +137,10 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
throw new BugIndicatingError('Cannot have more than one active editing session');
}

this._currentSessionDisposables.clear();

// listen for completed responses, run the code mapper and apply the edits to this edit session
this._register(this.installAutoApplyObserver(chatSessionId));
this._currentSessionDisposables.add(this.installAutoApplyObserver(chatSessionId));

const input = MultiDiffEditorInput.fromResourceMultiDiffEditorInput({
multiDiffSource: ChatEditingMultiDiffSourceResolver.getMultiDiffSourceUri(),
Expand All @@ -131,13 +150,18 @@ export class ChatEditingService extends Disposable implements IChatEditingServic
const editorPane = options?.silent ? undefined : await this._editorGroupsService.activeGroup.openEditor(input, { pinned: true, activation: EditorActivation.ACTIVATE }) as MultiDiffEditor | undefined;

const session = this._instantiationService.createInstance(ChatEditingSession, chatSessionId, editorPane);
this._currentSessionDisposeListener.value = session.onDidDispose(() => {
this._currentSessionDisposeListener.clear();
this._currentSessionDisposables.add(session.onDidDispose(() => {
this._currentSessionDisposables.clear();
this._currentSessionObs.set(null, undefined);
});
this._onDidChangeEditingSession.fire();
}));
this._currentSessionDisposables.add(session.onDidChange(() => {
this._onDidChangeEditingSession.fire();
}));

this._currentSessionObs.set(session, undefined);
this._onDidCreateEditingSession.fire(session);
this._onDidChangeEditingSession.fire();
return session;
}

Expand Down Expand Up @@ -568,7 +592,7 @@ class ChatEditingSession extends Disposable implements IChatEditingSession {
private async _acceptTextEdits(resource: URI, textEdits: TextEdit[]): Promise<void> {
const entry = await this._getOrCreateModifiedFileEntry(resource);
entry.applyEdits(textEdits);
await this._editorService.openEditor({ original: { resource: entry.originalURI }, modified: { resource: entry.modifiedURI }, options: { inactive: true } });
await this._editorService.openEditor({ resource: entry.modifiedURI, options: { inactive: true } });
}

private async _resolve(): Promise<void> {
Expand Down Expand Up @@ -626,6 +650,10 @@ class ModifiedFileEntry extends Disposable implements IModifiedFileEntry {
return this.docSnapshot.uri;
}

get originalModel(): ITextModel {
return this.docSnapshot;
}

get modifiedURI(): URI {
return this.doc.uri;
}
Expand Down Expand Up @@ -657,6 +685,7 @@ class ModifiedFileEntry extends Disposable implements IModifiedFileEntry {

// Create a reference to this model to avoid it being disposed from under our nose
(async () => {
// TODO: dispose manually if the outer object was disposed in the meantime
this._register(await textModelService.createModelReference(docSnapshot.uri));
})();

Expand Down
160 changes: 160 additions & 0 deletions src/vs/workbench/contrib/chat/browser/chatEditorController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js';
import { Constants } from '../../../../base/common/uint.js';
import { ICodeEditor, IViewZone } from '../../../../editor/browser/editorBrowser.js';
import { LineSource, renderLines, RenderOptions } from '../../../../editor/browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js';
import { diffAddDecoration, diffDeleteDecoration, diffWholeLineAddDecoration } from '../../../../editor/browser/widget/diffEditor/registrations.contribution.js';
import { IDocumentDiff } from '../../../../editor/common/diff/documentDiffProvider.js';
import { IEditorContribution } from '../../../../editor/common/editorCommon.js';
import { IModelDeltaDecoration, ITextModel } from '../../../../editor/common/model.js';
import { IEditorWorkerService } from '../../../../editor/common/services/editorWorker.js';
import { InlineDecoration, InlineDecorationType } from '../../../../editor/common/viewModel.js';
import { IChatEditingService, IChatEditingSession, IModifiedFileEntry } from '../common/chatEditingService.js';

export class ChatEditorController extends Disposable implements IEditorContribution {

public static readonly ID = 'editor.contrib.chatEditorController';
private readonly _sessionStore = this._register(new DisposableStore());
private readonly _decorations = this._editor.createDecorationsCollection();
private _viewZones: string[] = [];

constructor(
private readonly _editor: ICodeEditor,
@IChatEditingService private readonly _chatEditingService: IChatEditingService,
@IEditorWorkerService private readonly _editorWorkerService: IEditorWorkerService
) {
super();
this._register(this._editor.onDidChangeModel(() => this._update()));
this._register(this._chatEditingService.onDidChangeEditingSession(() => this._updateSessionDecorations()));
this._register(toDisposable(() => this._clearRendering()));
}

private _update(): void {
this._sessionStore.clear();
if (!this._editor.hasModel()) {
return;
}
const model = this._editor.getModel();
this._sessionStore.add(model.onDidChangeContent(() => this._updateSessionDecorations()));
this._updateSessionDecorations();
}

private _updateSessionDecorations(): void {
if (!this._editor.hasModel()) {
this._decorations.clear();
return;
}
const model = this._editor.getModel();
const editingSession = this._chatEditingService.getEditingSession(model.uri);
const entry = this._getEntry(editingSession, model);
if (!entry) {
this._decorations.clear();
return;
}

this._editorWorkerService.computeDiff(
entry.originalURI,
model.uri,
{
ignoreTrimWhitespace: false,
maxComputationTimeMs: Constants.MAX_SAFE_SMALL_INTEGER,
computeMoves: false
},
'advanced'
).then(diff => {
if (!this._editor.hasModel()) {
this._clearRendering();
return;
}

const model = this._editor.getModel();
const editingSession = this._chatEditingService.getEditingSession(model.uri);
const entry = this._getEntry(editingSession, model);
if (!entry) {
this._clearRendering();
return;
}

this._updateWithDiff(model, entry, diff);
});
}

private _getEntry(editingSession: IChatEditingSession | null, model: ITextModel): IModifiedFileEntry | null {
if (!editingSession) {
return null;
}
return editingSession.entries.get().find(e => e.modifiedURI.toString() === model.uri.toString()) || null;
}

private _clearRendering() {
this._editor.changeViewZones((viewZoneChangeAccessor) => {
for (const id of this._viewZones) {
viewZoneChangeAccessor.removeZone(id);
}
});
this._viewZones = [];
this._decorations.clear();
}

private _updateWithDiff(model: ITextModel, entry: IModifiedFileEntry, diff: IDocumentDiff | null): void {
if (!diff) {
this._clearRendering();
return;
}

const originalModel = entry.originalModel;

// original view zone

this._editor.changeViewZones((viewZoneChangeAccessor) => {
for (const id of this._viewZones) {
viewZoneChangeAccessor.removeZone(id);
}
this._viewZones = [];
const modifiedDecorations: IModelDeltaDecoration[] = [];
const mightContainNonBasicASCII = originalModel.mightContainNonBasicASCII();
const mightContainRTL = originalModel.mightContainRTL();
const renderOptions = RenderOptions.fromEditor(this._editor);

for (const diffEntry of diff.changes) {
const originalRange = diffEntry.original;
originalModel.tokenization.forceTokenization(originalRange.endLineNumberExclusive - 1);
const source = new LineSource(
originalRange.mapToLineArray(l => originalModel.tokenization.getLineTokens(l)),
[],
mightContainNonBasicASCII,
mightContainRTL,
);
const decorations: InlineDecoration[] = [];
for (const i of diffEntry.innerChanges || []) {
decorations.push(new InlineDecoration(
i.originalRange.delta(-(diffEntry.original.startLineNumber - 1)),
diffDeleteDecoration.className!,
InlineDecorationType.Regular
));
modifiedDecorations.push({ range: i.modifiedRange, options: diffAddDecoration });
}
if (!diffEntry.modified.isEmpty) {
modifiedDecorations.push({ range: diffEntry.modified.toInclusiveRange()!, options: diffWholeLineAddDecoration });
}
const domNode = document.createElement('div');
domNode.className = 'chat-editing-original-zone line-delete';
const result = renderLines(source, renderOptions, decorations, domNode);
const viewZoneData: IViewZone = {
afterLineNumber: diffEntry.modified.startLineNumber - 1,
heightInLines: result.heightInLines,
domNode,
ordinal: 50000 + 2 // more than https://github.com/microsoft/vscode/blob/bf52a5cfb2c75a7327c9adeaefbddc06d529dcad/src/vs/workbench/contrib/inlineChat/browser/inlineChatZoneWidget.ts#L42
};

this._viewZones.push(viewZoneChangeAccessor.addZone(viewZoneData));
}

this._decorations.set(modifiedDecorations);
});
}
}
7 changes: 7 additions & 0 deletions src/vs/workbench/contrib/chat/common/chatEditingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Event } from '../../../../base/common/event.js';
import { IObservable, ITransaction } from '../../../../base/common/observable.js';
import { URI } from '../../../../base/common/uri.js';
import { TextEdit } from '../../../../editor/common/languages.js';
import { ITextModel } from '../../../../editor/common/model.js';
import { localize } from '../../../../nls.js';
import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
Expand All @@ -19,13 +20,18 @@ export interface IChatEditingService {
_serviceBrand: undefined;

readonly onDidCreateEditingSession: Event<IChatEditingSession>;
/**
* emitted when a session is created, changed or disposed
*/
readonly onDidChangeEditingSession: Event<void>;

readonly currentEditingSession: IChatEditingSession | null;
readonly currentAutoApplyOperation: CancellationTokenSource | null;

startOrContinueEditingSession(chatSessionId: string, options?: { silent: boolean }): Promise<IChatEditingSession>;
addFileToWorkingSet(resource: URI): Promise<void>;
triggerEditComputation(responseModel: IChatResponseModel): Promise<void>;
getEditingSession(resource: URI): IChatEditingSession | null;
}

export interface IChatEditingSession {
Expand Down Expand Up @@ -55,6 +61,7 @@ export const enum WorkingSetEntryState {

export interface IModifiedFileEntry {
readonly originalURI: URI;
readonly originalModel: ITextModel;
readonly modifiedURI: URI;
readonly state: IObservable<WorkingSetEntryState>;
accept(transaction: ITransaction | undefined): Promise<void>;
Expand Down

0 comments on commit 485a7ed

Please sign in to comment.