// ***************************************************************************** // Copyright (C) 2025 EclipseSource GmbH. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { ConfigurableInMemoryResources, ConfigurableMutableReferenceResource } from '@theia/ai-core'; import { CancellationToken, DisposableCollection, Emitter, nls, URI, ILogger } from '@theia/core'; import { ConfirmDialog } from '@theia/core/lib/browser'; import { Replacement } from '@theia/core/lib/common/content-replacer'; import { inject, injectable, postConstruct, named } from '@theia/core/shared/inversify'; import { EditorPreferences } from '@theia/editor/lib/common/editor-preferences'; import { FileSystemPreferences } from '@theia/filesystem/lib/common'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { IReference } from '@theia/monaco-editor-core/esm/vs/base/common/lifecycle'; import { TrimTrailingWhitespaceCommand } from '@theia/monaco-editor-core/esm/vs/editor/common/commands/trimTrailingWhitespaceCommand'; import { Selection } from '@theia/monaco-editor-core/esm/vs/editor/common/core/selection'; import { CommandExecutor } from '@theia/monaco-editor-core/esm/vs/editor/common/cursor/cursor'; import { formatDocumentWithSelectedProvider, FormattingMode } from '@theia/monaco-editor-core/esm/vs/editor/contrib/format/browser/format'; import { StandaloneServices } from '@theia/monaco-editor-core/esm/vs/editor/standalone/browser/standaloneServices'; import { IInstantiationService } from '@theia/monaco-editor-core/esm/vs/platform/instantiation/common/instantiation'; import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service'; import { insertFinalNewline } from '@theia/monaco/lib/browser/monaco-utilities'; import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model'; import { MonacoWorkspace } from '@theia/monaco/lib/browser/monaco-workspace'; import { ChangeSetElement } from '../common'; import { FileReadTracker } from '../common/file-read-tracker'; import { SerializableChangeSetElement } from '../common/chat-model-serialization'; import { createChangeSetFileUri } from './change-set-file-resource'; import { ChangeSetFileService } from './change-set-file-service'; import { Deferred } from '@theia/core/lib/common/promise-util'; import { MonacoCodeActionService } from '@theia/monaco/lib/browser'; export const ChangeSetFileElementFactory = Symbol('ChangeSetFileElementFactory'); export type ChangeSetFileElementFactory = (elementProps: ChangeSetElementArgs) => ChangeSetFileElement; type ChangeSetElementState = ChangeSetElement['state']; export const ChangeSetElementArgs = Symbol('ChangeSetElementArgs'); export interface ChangeSetElementArgs extends Partial { /** The URI of the element, expected to be unique within the same change set. */ uri: URI; /** The id of the chat session containing this change set element. */ chatSessionId: string; /** The id of the request with which this change set element is associated. */ requestId: string; /** * The state of the file after the changes have been applied. * If `undefined`, there is no change. */ targetState?: string; /** * The state before the change has been applied. If it is specified, we don't care * about the state of the original file on disk but just use the specified `originalState`. * If it isn't specified, we'll derived and observe the state from the file system. */ originalState?: string; /** * An array of replacements used to create the new content for the targetState. * This is only available if the agent was able to provide replacements and we were able to apply them. */ replacements?: Replacement[]; }; @injectable() export class ChangeSetFileElement implements ChangeSetElement { static toReadOnlyUri(baseUri: URI, sessionId: string): URI { return baseUri.withScheme('change-set-immutable').withAuthority(sessionId); } @inject(ChangeSetElementArgs) protected readonly elementProps: ChangeSetElementArgs; @inject(ChangeSetFileService) protected readonly changeSetFileService: ChangeSetFileService; @inject(FileService) protected readonly fileService: FileService; @inject(ConfigurableInMemoryResources) protected readonly inMemoryResources: ConfigurableInMemoryResources; @inject(MonacoTextModelService) protected readonly monacoTextModelService: MonacoTextModelService; @inject(EditorPreferences) protected readonly editorPreferences: EditorPreferences; @inject(FileSystemPreferences) protected readonly fileSystemPreferences: FileSystemPreferences; @inject(MonacoCodeActionService) protected readonly codeActionService: MonacoCodeActionService; @inject(ILogger) @named('ai-chat:ChangeSetFileElement') protected readonly logger: ILogger; @inject(MonacoWorkspace) protected readonly monacoWorkspace: MonacoWorkspace; @inject(FileReadTracker) protected readonly fileReadTracker: FileReadTracker; protected readonly toDispose = new DisposableCollection(); protected _state: ChangeSetElementState; private _originalContent: string | undefined; protected _initialized = false; protected _initializationPromise: Promise | undefined; protected _targetStateWithCodeActions: string | undefined; protected codeActionDeferred?: Deferred; protected readonly onDidChangeEmitter = new Emitter(); readonly onDidChange = this.onDidChangeEmitter.event; protected _readOnlyResource?: ConfigurableMutableReferenceResource; protected _changeResource?: ConfigurableMutableReferenceResource; @postConstruct() init(): void { this._initializationPromise = this.initializeAsync(); this.toDispose.push(this.onDidChangeEmitter); } protected async initializeAsync(): Promise { await this.obtainOriginalContent(); this.listenForOriginalFileChanges(); this._initialized = true; } /** * Ensures that the element is fully initialized before proceeding. * This includes loading the original content from the file system. */ async ensureInitialized(): Promise { await this._initializationPromise; } /** * Returns true if the element has been fully initialized. */ get isInitialized(): boolean { return this._initialized; } protected async obtainOriginalContent(): Promise { this._originalContent = this.elementProps.originalState ?? await this.changeSetFileService.read(this.uri); if (this._readOnlyResource) { this.readOnlyResource.update({ contents: this._originalContent ?? '' }); } } protected getInMemoryUri(uri: URI): ConfigurableMutableReferenceResource { try { return this.inMemoryResources.resolve(uri); } catch { return this.inMemoryResources.add(uri, { contents: '' }); } } protected listenForOriginalFileChanges(): void { if (this.elementProps.originalState) { // if we have an original state, we are not interested in the original file on disk but always use `originalState` return; } this.toDispose.push(this.fileService.onDidFilesChange(async event => { if (!event.contains(this.uri)) { return; } if (!this._initialized && this._initializationPromise) { // make sure we are initialized await this._initializationPromise; } // If we are applied, the tricky thing becomes the question what to revert to; otherwise, what to apply. const newContent = await this.changeSetFileService.read(this.uri).catch(() => ''); this.readOnlyResource.update({ contents: newContent }); if (newContent === this._originalContent) { this.state = 'pending'; } else if (newContent === this.targetState) { this.state = 'applied'; } else { this.state = 'stale'; } })); } get uri(): URI { return this.elementProps.uri; } protected get readOnlyResource(): ConfigurableMutableReferenceResource { if (!this._readOnlyResource) { this._readOnlyResource = this.getInMemoryUri(ChangeSetFileElement.toReadOnlyUri(this.uri, this.elementProps.chatSessionId)); this._readOnlyResource.update({ autosaveable: false, readOnly: true, contents: this._originalContent ?? '' }); this.toDispose.push(this._readOnlyResource); // If not yet initialized, update the resource once initialization completes if (!this._initialized) { this._initializationPromise?.then(() => { this._readOnlyResource?.update({ contents: this._originalContent ?? '' }); }); } } return this._readOnlyResource; } get readOnlyUri(): URI { return this.readOnlyResource.uri; } protected get changeResource(): ConfigurableMutableReferenceResource { if (!this._changeResource) { this._changeResource = this.getInMemoryUri(createChangeSetFileUri(this.elementProps.chatSessionId, this.uri)); this._changeResource.update({ autosaveable: false, contents: this.targetState }); this.applyCodeActionsToTargetState(); this.toDispose.push(this._changeResource); } return this._changeResource; } get changedUri(): URI { return this.changeResource.uri; } get name(): string { return this.elementProps.name ?? this.changeSetFileService.getName(this.uri); } get icon(): string | undefined { return this.elementProps.icon ?? this.changeSetFileService.getIcon(this.uri); } get additionalInfo(): string | undefined { return this.changeSetFileService.getAdditionalInfo(this.uri); } get state(): ChangeSetElementState { return this._state ?? this.elementProps.state; } protected set state(value: ChangeSetElementState) { if (this._state !== value) { this._state = value; this.onDidChangeEmitter.fire(); } } get replacements(): Replacement[] | undefined { return this.elementProps.replacements; } get type(): 'add' | 'modify' | 'delete' | undefined { return this.elementProps.type; } get data(): { [key: string]: unknown; } | undefined { return this.elementProps.data; }; get originalContent(): string | undefined { if (!this._initialized && this._initializationPromise) { this.logger.warn('Accessing originalContent before initialization is complete. Consider using async methods.'); } return this._originalContent; } /** * Gets the original content of the file asynchronously. * Ensures initialization is complete before returning the content. */ async getOriginalContent(): Promise { await this.ensureInitialized(); return this._originalContent; } get targetState(): string { return this._targetStateWithCodeActions ?? this.elementProps.targetState ?? ''; } get originalTargetState(): string { return this.elementProps.targetState ?? ''; } async open(): Promise { await this.ensureInitialized(); this.changeSetFileService.open(this); } async openChange(): Promise { await this.ensureInitialized(); this.changeSetFileService.openDiff( this.readOnlyUri, this.changedUri ); } async apply(contents?: string): Promise { await this.ensureInitialized(); if (!await this.confirm('Apply')) { return; } if (this.type === 'delete') { await this.changeSetFileService.delete(this.uri); this.state = 'applied'; } else { await this.applyChangesWithMonaco(contents); } this.changeSetFileService.closeDiff(this.readOnlyUri); // Re-snapshot so the agent's own write is not reported back as external, reading because code actions and formatting on save alter the target state. await this.fileReadTracker.recordRead(this.elementProps.chatSessionId, this.uri); } async writeChanges(contents?: string): Promise { await this.changeSetFileService.writeFrom(this.changedUri, this.uri, contents ?? this.targetState); this.state = 'applied'; } /** * Applies changes using Monaco utilities, including loading the model for the base file URI, * applying edits, and running code actions on save. */ protected async applyChangesWithMonaco(contents?: string): Promise { let modelReference: IReference | undefined; try { modelReference = await this.monacoTextModelService.createModelReference(this.uri); const model = modelReference.object; model.suppressOpenEditorWhenDirty = true; const targetContent = contents ?? this.targetState; const currentContent = model.textEditorModel.getValue(); if (currentContent !== targetContent) { const fullRange = model.textEditorModel.getFullModelRange(); await this.monacoWorkspace.applyBackgroundEdit(model, [{ range: fullRange, text: targetContent, forceMoveMarkers: false }]); } const languageId = model.languageId; const uriStr = this.uri.toString(); await this.codeActionService.applyOnSaveCodeActions(model.textEditorModel, languageId, uriStr, CancellationToken.None); await this.applyFormatting(model, languageId, uriStr); await model.save(); this.state = 'applied'; } catch (error) { this.logger.error('Failed to apply changes with Monaco:', error); await this.writeChanges(contents); } finally { modelReference?.dispose(); } } protected applyCodeActionsToTargetState(): Promise { if (!this.codeActionDeferred) { this.codeActionDeferred = new Deferred(); this.codeActionDeferred.resolve(this.doApplyCodeActionsToTargetState()); } return this.codeActionDeferred.promise; } protected async doApplyCodeActionsToTargetState(): Promise { const targetState = this.originalTargetState; if (!targetState) { this._targetStateWithCodeActions = ''; return this._targetStateWithCodeActions; } let tempResource: ConfigurableMutableReferenceResource | undefined; let tempModel: IReference | undefined; try { // Create a temporary model to apply code actions const tempUri = URI.fromComponents({ scheme: 'untitled', path: this.uri.path.toString(), authority: `changeset-${this.elementProps.chatSessionId}`, query: '', fragment: '' }); tempResource = this.getInMemoryUri(tempUri); tempResource.update({ contents: this.targetState }); tempModel = await this.monacoTextModelService.createModelReference(tempUri); tempModel.object.suppressOpenEditorWhenDirty = true; tempModel.object.textEditorModel.setValue(this.targetState); const languageId = tempModel.object.languageId; const uriStr = this.uri.toString(); await this.codeActionService.applyOnSaveCodeActions(tempModel.object.textEditorModel, languageId, uriStr, CancellationToken.None); // Apply formatting and other editor preferences await this.applyFormatting(tempModel.object, languageId, uriStr); this._targetStateWithCodeActions = tempModel.object.textEditorModel.getValue(); if (this._changeResource?.contents === this.elementProps.targetState) { this._changeResource?.update({ contents: this.targetState }); } } catch (error) { this.logger.warn('Failed to apply code actions to target state:', error); this._targetStateWithCodeActions = targetState; } finally { tempModel?.dispose(); tempResource?.dispose(); } return this.targetState; } /** * Applies formatting preferences like format on save, trim trailing whitespace, and insert final newline. */ protected async applyFormatting(model: MonacoEditorModel, languageId: string, uriStr: string): Promise { try { const formatOnSave = this.editorPreferences.get({ preferenceName: 'editor.formatOnSave', overrideIdentifier: languageId }, undefined, uriStr); if (formatOnSave) { const instantiation = StandaloneServices.get(IInstantiationService); await instantiation.invokeFunction( formatDocumentWithSelectedProvider, model.textEditorModel, FormattingMode.Explicit, { report(): void { } }, CancellationToken.None, true ); } const trimTrailingWhitespace = this.fileSystemPreferences.get({ preferenceName: 'files.trimTrailingWhitespace', overrideIdentifier: languageId }, undefined, uriStr); if (trimTrailingWhitespace) { const ttws = new TrimTrailingWhitespaceCommand(new Selection(1, 1, 1, 1), [], false); CommandExecutor.executeCommands(model.textEditorModel, [], [ttws]); } const shouldInsertFinalNewline = this.fileSystemPreferences.get({ preferenceName: 'files.insertFinalNewline', overrideIdentifier: languageId }, undefined, uriStr); if (shouldInsertFinalNewline) { insertFinalNewline(model); } } catch (error) { this.logger.warn('Failed to apply formatting:', error); } } onShow(): void { this.changeResource.update({ contents: this.targetState, onSave: async content => { // Use Monaco utilities when saving from the change resource await this.applyChangesWithMonaco(content); } }); } async revert(): Promise { await this.ensureInitialized(); if (!await this.confirm('Revert')) { return; } this.state = 'pending'; if (this.type === 'add') { await this.changeSetFileService.delete(this.uri); } else if (this._originalContent) { await this.changeSetFileService.write(this.uri, this._originalContent); } } async confirm(verb: 'Apply' | 'Revert'): Promise { if (this._state !== 'stale') { return true; } await this.openChange(); const answer = await new ConfirmDialog({ title: verb === 'Apply' ? nls.localize('theia/ai/chat/applySuggestion', 'Apply suggestion') : nls.localize('theia/ai/chat/revertSuggestion', 'Revert suggestion'), msg: verb === 'Apply' ? nls.localize('theia/ai/chat/confirmApplySuggestion', 'The file {0} has changed since this suggestion was created. Are you certain you wish to apply the change?', this.uri.path.toString()) : nls.localize('theia/ai/chat/confirmRevertSuggestion', 'The file {0} has changed since this suggestion was created. Are you certain you wish to revert the change?', this.uri.path.toString()) }).open(true); return !!answer; } toSerializable(): SerializableChangeSetElement { return { kind: 'file', uri: this.uri.toString(), name: this.name, icon: this.icon, additionalInfo: this.additionalInfo, state: this.state, type: this.type, data: { ...this.data, targetState: this.elementProps.targetState, originalState: this._originalContent, replacements: this.replacements } }; } dispose(): void { this.toDispose.dispose(); } }