Skip to content

Commit 95f4d49

Browse files
authored
Improve behavior of AI Changeset diff editor (#14786)
partially fixes #14785 Contributed on behalf of STMicroelectronics Signed-off-by: Thomas Mäder <[email protected]>
1 parent a497457 commit 95f4d49

File tree

9 files changed

+64
-34
lines changed

9 files changed

+64
-34
lines changed

dependency-check-baseline.json

-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
11
{
2-
"npm/npmjs/-/unicode-property-aliases-ecmascript/2.1.0": "Manually approved"
32
}

examples/api-samples/src/browser/chat/change-set-chat-agent-contribution.ts

+27-1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from '@theia/ai-chat';
2525
import { ChangeSetFileElementFactory } from '@theia/ai-chat/lib/browser/change-set-file-element';
2626
import { Agent, PromptTemplate } from '@theia/ai-core';
27+
import { URI } from '@theia/core';
2728
import { inject, injectable, interfaces } from '@theia/core/shared/inversify';
2829
import { FileService } from '@theia/filesystem/lib/browser/file-service';
2930
import { WorkspaceService } from '@theia/workspace/lib/browser';
@@ -94,13 +95,14 @@ export class ChangeSetChatAgent extends AbstractStreamParsingChatAgent implement
9495
chatSessionId
9596
})
9697
);
98+
9799
if (fileToChange && fileToChange.resource) {
98100
changeSet.addElement(
99101
this.fileChangeFactory({
100102
uri: fileToChange.resource,
101103
type: 'modify',
102104
state: 'pending',
103-
targetState: 'Hello World Modify!',
105+
targetState: await this.computeTargetState(fileToChange.resource),
104106
changeSet,
105107
chatSessionId
106108
})
@@ -124,6 +126,30 @@ export class ChangeSetChatAgent extends AbstractStreamParsingChatAgent implement
124126
));
125127
request.response.complete();
126128
}
129+
async computeTargetState(resource: URI): Promise<string> {
130+
const content = await this.fileService.read(resource);
131+
if (content.value.length < 20) {
132+
return 'HelloWorldModify';
133+
}
134+
let readLocation = Math.random() * 0.1 * content.value.length;
135+
let oldLocation = 0;
136+
let output = '';
137+
while (readLocation < content.value.length) {
138+
output += content.value.substring(oldLocation, readLocation);
139+
oldLocation = readLocation;
140+
const type = Math.random();
141+
if (type < 0.33) {
142+
// insert
143+
output += `this is an insert at ${readLocation}`;
144+
} else {
145+
// delete
146+
oldLocation += 20;
147+
}
148+
149+
readLocation += Math.random() * 0.1 * content.value.length;
150+
}
151+
return output;
152+
}
127153

128154
protected override async getSystemMessageDescription(): Promise<SystemMessageDescription | undefined> {
129155
return undefined;

packages/ai-chat/src/browser/change-set-file-element.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ export class ChangeSetFileElement implements ChangeSetElement {
6464
return this.elementProps.uri;
6565
}
6666

67+
get changedUri(): URI {
68+
return createChangeSetFileUri(this.elementProps.chatSessionId, this.uri);
69+
}
70+
6771
get name(): string {
6872
return this.elementProps.name ?? this.changeSetFileService.getName(this.uri);
6973
}
@@ -98,25 +102,25 @@ export class ChangeSetFileElement implements ChangeSetElement {
98102
}
99103

100104
async open(): Promise<void> {
101-
this.changeSetFileService.open(this.uri, this.targetState);
105+
this.changeSetFileService.open(this);
102106
}
103107

104108
async openChange(): Promise<void> {
105109
this.changeSetFileService.openDiff(
106110
this.uri,
107-
createChangeSetFileUri(this.elementProps.chatSessionId, this.uri)
111+
this.changedUri
108112
);
109113
}
110114

111-
async accept(): Promise<void> {
115+
async accept(contents?: string): Promise<void> {
112116
this.state = 'applied';
113117
if (this.type === 'delete') {
114118
await this.changeSetFileService.delete(this.uri);
115119
this.state = 'applied';
116120
return;
117121
}
118122

119-
await this.changeSetFileService.write(this.uri, this.targetState);
123+
await this.changeSetFileService.write(this.uri, contents !== undefined ? contents : this.targetState);
120124
}
121125

122126
async discard(): Promise<void> {

packages/ai-chat/src/browser/change-set-file-resource.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
1515
// *****************************************************************************
1616

17-
import { Resource, ResourceResolver, URI } from '@theia/core';
17+
import { Resource, ResourceResolver, ResourceSaveOptions, URI } from '@theia/core';
1818
import { inject, injectable } from '@theia/core/shared/inversify';
1919
import { ChatService } from '../common';
2020
import { ChangeSetFileElement } from './change-set-file-element';
@@ -23,7 +23,7 @@ export const CHANGE_SET_FILE_RESOURCE_SCHEME = 'changeset-file';
2323
const QUERY = 'uri=';
2424

2525
export function createChangeSetFileUri(chatSessionId: string, elementUri: URI): URI {
26-
return new URI(CHANGE_SET_FILE_RESOURCE_SCHEME + ':/' + chatSessionId).withQuery(QUERY + encodeURIComponent(elementUri.path.toString()));
26+
return new URI(CHANGE_SET_FILE_RESOURCE_SCHEME + '://' + chatSessionId + '/' + elementUri.path).withQuery(QUERY + encodeURIComponent(elementUri.path.toString()));
2727
}
2828

2929
/**
@@ -41,7 +41,7 @@ export class ChangeSetFileResourceResolver implements ResourceResolver {
4141
throw new Error('The given uri is not a change set file uri: ' + uri);
4242
}
4343

44-
const chatSessionId = uri.path.name;
44+
const chatSessionId = uri.authority;
4545
const session = this.chatService.getSession(chatSessionId);
4646
if (!session) {
4747
throw new Error('Chat session not found: ' + chatSessionId);
@@ -60,8 +60,12 @@ export class ChangeSetFileResourceResolver implements ResourceResolver {
6060

6161
return {
6262
uri,
63-
readOnly: true,
63+
readOnly: false,
64+
initiallyDirty: true,
6465
readContents: async () => element.targetState ?? '',
66+
saveContents: async (content: string, options?: ResourceSaveOptions): Promise<void> => {
67+
element.accept(content);
68+
},
6569
dispose: () => { }
6670
};
6771
}

packages/ai-chat/src/browser/change-set-file-service.ts

+7-19
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { EditorManager } from '@theia/editor/lib/browser';
2121
import { FileService } from '@theia/filesystem/lib/browser/file-service';
2222
import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace';
2323
import { WorkspaceService } from '@theia/workspace/lib/browser/workspace-service';
24+
import { ChangeSetFileElement } from './change-set-file-element';
2425

2526
@injectable()
2627
export class ChangeSetFileService {
@@ -82,28 +83,15 @@ export class ChangeSetFileService {
8283
return this.labelProvider.getLongName(uri.parent);
8384
}
8485

85-
async open(uri: URI, targetState: string): Promise<void> {
86-
const exists = await this.fileService.exists(uri);
86+
async open(element: ChangeSetFileElement): Promise<void> {
87+
const exists = await this.fileService.exists(element.uri);
8788
if (exists) {
88-
open(this.openerService, uri);
89+
await open(this.openerService, element.uri);
8990
return;
9091
}
91-
const editor = await this.editorManager.open(uri.withScheme(UNTITLED_SCHEME), {
92+
await this.editorManager.open(element.changedUri, {
9293
mode: 'reveal'
9394
});
94-
editor.editor.executeEdits([{
95-
newText: targetState,
96-
range: {
97-
start: {
98-
character: 1,
99-
line: 1,
100-
},
101-
end: {
102-
character: 1,
103-
line: 1,
104-
},
105-
}
106-
}]);
10795
}
10896

10997
async openDiff(originalUri: URI, suggestedUri: URI): Promise<void> {
@@ -112,7 +100,7 @@ export class ChangeSetFileService {
112100
// Currently we don't have a great way to show the suggestions in a diff editor with accept/reject buttons
113101
// So we just use plain diffs with the suggestions as original and the current state as modified, so users can apply changes in their current state
114102
// But this leads to wrong colors and wrong label (revert change instead of accept change)
115-
const diffUri = DiffUris.encode(suggestedUri, openedUri,
103+
const diffUri = DiffUris.encode(openedUri, suggestedUri,
116104
`AI Changes: ${this.labelProvider.getName(originalUri)}`,
117105
);
118106
open(this.openerService, diffUri);
@@ -139,7 +127,7 @@ export class ChangeSetFileService {
139127
await this.monacoWorkspace.applyBackgroundEdit(document, [{
140128
range: document.textEditorModel.getFullModelRange(),
141129
text
142-
}], true);
130+
}], (editor, wasDirty) => editor === undefined || !wasDirty);
143131
} else {
144132
await this.fileService.write(uri, text);
145133
}

packages/core/src/browser/saveable.ts

+3
Original file line numberDiff line numberDiff line change
@@ -392,4 +392,7 @@ export class ShouldSaveDialog extends AbstractDialog<boolean> {
392392
return this.shouldSave;
393393
}
394394

395+
override async open(disposeOnResolve?: boolean): Promise<boolean | undefined> {
396+
return super.open(disposeOnResolve);
397+
}
395398
}

packages/core/src/common/resource.ts

+4
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export interface Resource extends Disposable {
6060
readonly onDidChangeReadOnly?: Event<boolean | MarkdownString>;
6161

6262
readonly readOnly?: boolean | MarkdownString;
63+
64+
readonly initiallyDirty?: boolean;
6365
/**
6466
* Reads latest content of this resource.
6567
*
@@ -378,11 +380,13 @@ export class UntitledResourceResolver implements ResourceResolver {
378380
export class UntitledResource implements Resource {
379381

380382
protected readonly onDidChangeContentsEmitter = new Emitter<void>();
383+
initiallyDirty: boolean;
381384
get onDidChangeContents(): Event<void> {
382385
return this.onDidChangeContentsEmitter.event;
383386
}
384387

385388
constructor(private resources: Map<string, UntitledResource>, public uri: URI, private content?: string) {
389+
this.initiallyDirty = (content !== undefined && content.length > 0);
386390
this.resources.set(this.uri.toString(), this);
387391
}
388392

packages/monaco/src/browser/monaco-editor-model.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { TextEditorDocument, EncodingMode, FindMatchesOptions, FindMatch, Editor
1919
import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable';
2020
import { Emitter, Event } from '@theia/core/lib/common/event';
2121
import { CancellationTokenSource, CancellationToken } from '@theia/core/lib/common/cancellation';
22-
import { Resource, ResourceError, ResourceVersion, UNTITLED_SCHEME } from '@theia/core/lib/common/resource';
22+
import { Resource, ResourceError, ResourceVersion } from '@theia/core/lib/common/resource';
2323
import { Saveable, SaveOptions } from '@theia/core/lib/browser/saveable';
2424
import { MonacoToProtocolConverter } from './monaco-to-protocol-converter';
2525
import { ProtocolToMonacoConverter } from './protocol-to-monaco-converter';
@@ -202,7 +202,7 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
202202
const languageSelection = StandaloneServices.get(ILanguageService).createByFilepathOrFirstLine(uri, firstLine);
203203
this.model = StandaloneServices.get(IModelService).createModel(value, languageSelection, uri);
204204
this.resourceVersion = this.resource.version;
205-
this.setDirty(this._dirty || (this.resource.uri.scheme === UNTITLED_SCHEME && this.model.getValueLength() > 0));
205+
this.setDirty(this._dirty || (!!this.resource.initiallyDirty));
206206
this.updateSavedVersionId();
207207
this.toDispose.push(this.model);
208208
this.toDispose.push(this.model.onDidChangeContent(event => this.fireDidChangeContent(event)));
@@ -553,7 +553,7 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
553553
}
554554

555555
const changes = [...this.contentChanges];
556-
if (changes.length === 0 && !overwriteEncoding && reason !== TextDocumentSaveReason.Manual) {
556+
if ((changes.length === 0 && !this.resource.initiallyDirty) && !overwriteEncoding && reason !== TextDocumentSaveReason.Manual) {
557557
return;
558558
}
559559

packages/monaco/src/browser/monaco-workspace.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -221,14 +221,16 @@ export class MonacoWorkspace {
221221
* Applies given edits to the given model.
222222
* The model is saved if no editors is opened for it.
223223
*/
224-
applyBackgroundEdit(model: MonacoEditorModel, editOperations: monaco.editor.IIdentifiedSingleEditOperation[], shouldSave = true): Promise<void> {
224+
applyBackgroundEdit(model: MonacoEditorModel, editOperations: monaco.editor.IIdentifiedSingleEditOperation[],
225+
shouldSave?: boolean | ((openEditor: MonacoEditor | undefined, wasDirty: boolean) => boolean)): Promise<void> {
225226
return this.suppressOpenIfDirty(model, async () => {
226227
const editor = MonacoEditor.findByDocument(this.editorManager, model)[0];
228+
const wasDirty = !!editor?.document.dirty;
227229
const cursorState = editor && editor.getControl().getSelections() || [];
228230
model.textEditorModel.pushStackElement();
229231
model.textEditorModel.pushEditOperations(cursorState, editOperations, () => cursorState);
230232
model.textEditorModel.pushStackElement();
231-
if (!editor && shouldSave) {
233+
if ((typeof shouldSave === 'function' && shouldSave(editor, wasDirty)) || (!editor && shouldSave)) {
232234
await model.save();
233235
}
234236
});

0 commit comments

Comments
 (0)