diff --git a/examples/api-samples/src/browser/chat/change-set-chat-agent-contribution.ts b/examples/api-samples/src/browser/chat/change-set-chat-agent-contribution.ts index 8b0fbd1df8ebb..f5ff6d9493d7c 100644 --- a/examples/api-samples/src/browser/chat/change-set-chat-agent-contribution.ts +++ b/examples/api-samples/src/browser/chat/change-set-chat-agent-contribution.ts @@ -81,7 +81,8 @@ export class ChangeSetChatAgent extends AbstractStreamParsingChatAgent { const chatSessionId = request.session.id; const changeSet = new ChangeSetImpl('My Test Change Set'); - changeSet.addElement( + + changeSet.addElements( this.fileChangeFactory({ uri: fileToAdd, type: 'add', @@ -93,7 +94,7 @@ export class ChangeSetChatAgent extends AbstractStreamParsingChatAgent { ); if (fileToChange && fileToChange.resource) { - changeSet.addElement( + changeSet.addElements( this.fileChangeFactory({ uri: fileToChange.resource, type: 'modify', @@ -105,7 +106,7 @@ export class ChangeSetChatAgent extends AbstractStreamParsingChatAgent { ); } if (fileToDelete && fileToDelete.resource) { - changeSet.addElement( + changeSet.addElements( this.fileChangeFactory({ uri: fileToDelete.resource, type: 'delete', @@ -115,6 +116,7 @@ export class ChangeSetChatAgent extends AbstractStreamParsingChatAgent { }) ); } + request.session.setChangeSet(changeSet); request.response.response.addContent(new MarkdownChatResponseContentImpl( diff --git a/packages/ai-chat-ui/src/browser/chat-input-widget.tsx b/packages/ai-chat-ui/src/browser/chat-input-widget.tsx index 9d9d5dceea61f..5a4b6d91c1f00 100644 --- a/packages/ai-chat-ui/src/browser/chat-input-widget.tsx +++ b/packages/ai-chat-ui/src/browser/chat-input-widget.tsx @@ -417,7 +417,7 @@ const noPropagation = (handler: () => void) => (e: React.MouseEvent) => { const buildChangeSetUI = (changeSet: ChangeSet, labelProvider: LabelProvider, onDeleteChangeSet: () => void, onDeleteChangeSetElement: (index: number) => void): ChangeSetUI => ({ title: changeSet.title, disabled: !hasPendingElementsToAccept(changeSet), - acceptAllPendingElements: () => acceptAllPendingElements(changeSet), + applyAllPendingElements: () => applyAllPendingElements(changeSet), delete: () => onDeleteChangeSet(), elements: changeSet.getElements().map(element => ({ open: element?.open?.bind(element), @@ -426,8 +426,8 @@ const buildChangeSetUI = (changeSet: ChangeSet, labelProvider: LabelProvider, on name: element.name ?? labelProvider.getName(element.uri), additionalInfo: element.additionalInfo ?? labelProvider.getDetails(element.uri), openChange: element?.openChange?.bind(element), - accept: element.state !== 'applied' ? element?.accept?.bind(element) : undefined, - discard: element.state === 'applied' ? element?.discard?.bind(element) : undefined, + apply: element.state !== 'applied' ? element?.apply?.bind(element) : undefined, + revert: element.state === 'applied' || element.state === 'stale' ? element?.revert?.bind(element) : undefined, delete: () => onDeleteChangeSetElement(changeSet.getElements().indexOf(element)) })) }); @@ -439,15 +439,15 @@ interface ChangeSetUIElement { additionalInfo: string; open?: () => void; openChange?: () => void; - accept?: () => void; - discard?: () => void; + apply?: () => void; + revert?: () => void; delete: () => void; } interface ChangeSetUI { title: string; disabled: boolean; - acceptAllPendingElements: () => void; + applyAllPendingElements: () => void; delete: () => void; elements: ChangeSetUIElement[]; } @@ -460,10 +460,10 @@ const ChangeSetBox: React.FunctionComponent<{ changeSet: ChangeSetUI }> = ({ cha changeSet.delete()} /> @@ -488,17 +488,17 @@ const ChangeSetBox: React.FunctionComponent<{ changeSet: ChangeSetUI }> = ({ cha title={nls.localize('theia/ai/chat-ui/openOriginalFile', 'Open Original File')} onClick={noPropagation(() => element.open!())} />)} - {element.discard && ( + {element.revert && ( element.discard!())} + title={nls.localizeByDefault('Revert')} + onClick={noPropagation(() => element.revert!())} />)} - {element.accept && ( + {element.apply && ( element.accept!())} + title={nls.localizeByDefault('Apply')} + onClick={noPropagation(() => element.apply!())} />)} element.delete())} /> @@ -556,16 +556,16 @@ const ChatInputOptions: React.FunctionComponent = ({ left ); -function acceptAllPendingElements(changeSet: ChangeSet): void { - acceptablePendingElements(changeSet).forEach(e => e.accept!()); +function applyAllPendingElements(changeSet: ChangeSet): void { + getPendingElements(changeSet).forEach(e => e.apply!()); } function hasPendingElementsToAccept(changeSet: ChangeSet): boolean | undefined { - return acceptablePendingElements(changeSet).length > 0; + return getPendingElements(changeSet).length > 0; } -function acceptablePendingElements(changeSet: ChangeSet): ChangeSetElement[] { - return changeSet.getElements().filter(e => e.accept && (e.state === undefined || e.state === 'pending')); +function getPendingElements(changeSet: ChangeSet): ChangeSetElement[] { + return changeSet.getElements().filter(e => e.apply && (e.state === undefined || e.state === 'pending')); } function getLatestRequest(chatModel: ChatModel): ChatRequestModel | undefined { diff --git a/packages/ai-chat/src/browser/change-set-file-element.ts b/packages/ai-chat/src/browser/change-set-file-element.ts index 1f3240bb5f6d9..d2502ebe3464d 100644 --- a/packages/ai-chat/src/browser/change-set-file-element.ts +++ b/packages/ai-chat/src/browser/change-set-file-element.ts @@ -14,14 +14,17 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { URI } from '@theia/core'; +import { DisposableCollection, Emitter, URI } from '@theia/core'; import { inject, injectable, postConstruct } from '@theia/core/shared/inversify'; import { ChangeSetElement, ChangeSetImpl } from '../common'; -import { createChangeSetFileUri } from './change-set-file-resource'; +import { ChangeSetFileResourceResolver, createChangeSetFileUri, UpdatableReferenceResource } from './change-set-file-resource'; import { ChangeSetFileService } from './change-set-file-service'; +import { FileService } from '@theia/filesystem/lib/browser/file-service'; +import { ConfirmDialog } from '@theia/core/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 { @@ -41,29 +44,84 @@ export interface ChangeSetElementArgs extends Partial { @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; - protected _state: 'pending' | 'applied' | 'discarded' | undefined; + @inject(FileService) + protected readonly fileService: FileService; + + @inject(ChangeSetFileResourceResolver) + protected readonly resourceResolver: ChangeSetFileResourceResolver; + + protected readonly toDispose = new DisposableCollection(); + protected _state: ChangeSetElementState; protected originalContent: string | undefined; + protected readonly onDidChangeEmitter = new Emitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + protected readOnlyResource: UpdatableReferenceResource; + protected changeResource: UpdatableReferenceResource; + @postConstruct() init(): void { + this.getResources(); this.obtainOriginalContent(); + this.listenForOriginalFileChanges(); + this.toDispose.push(this.onDidChangeEmitter); } protected async obtainOriginalContent(): Promise { this.originalContent = await this.changeSetFileService.read(this.uri); + this.readOnlyResource.update({ contents: this.originalContent ?? '' }); + if (this.state === 'applied') { + + } + } + + protected getResources(): void { + this.readOnlyResource = this.resourceResolver.tryGet(this.readOnlyUri) ?? this.resourceResolver.add(this.readOnlyUri, { autosaveable: false, readOnly: true }); + let changed = this.resourceResolver.tryGet(this.changedUri); + if (changed) { + changed.update({ contents: this.targetState, onSave: content => this.writeChanges(content) }); + } else { + changed = this.resourceResolver.add(this.changedUri, { contents: this.targetState, onSave: content => this.writeChanges(content), autosaveable: false }); + } + this.changeResource = changed; + this.toDispose.pushAll([this.readOnlyResource, this.changeResource]); + } + + protected listenForOriginalFileChanges(): void { + this.toDispose.push(this.fileService.onDidFilesChange(async event => { + if (!event.contains(this.uri)) { return; } + // 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; } + get readOnlyUri(): URI { + return ChangeSetFileElement.toReadOnlyUri(this.uri, this.elementProps.chatSessionId); + } + get changedUri(): URI { return createChangeSetFileUri(this.elementProps.chatSessionId, this.uri); } @@ -80,13 +138,15 @@ export class ChangeSetFileElement implements ChangeSetElement { return this.changeSetFileService.getAdditionalInfo(this.uri); } - get state(): 'pending' | 'applied' | 'discarded' | undefined { + get state(): ChangeSetElementState { return this._state ?? this.elementProps.state; } - protected set state(value: 'pending' | 'applied' | 'discarded' | undefined) { - this._state = value; - this.elementProps.changeSet.notifyChange(); + protected set state(value: ChangeSetElementState) { + if (this._state !== value) { + this._state = value; + this.onDidChangeEmitter.fire(); + } } get type(): 'add' | 'modify' | 'delete' | undefined { @@ -107,31 +167,50 @@ export class ChangeSetFileElement implements ChangeSetElement { async openChange(): Promise { this.changeSetFileService.openDiff( - this.uri, + this.readOnlyUri, this.changedUri ); } - async accept(contents?: string): Promise { - this.state = 'applied'; - if (this.type === 'delete') { - await this.changeSetFileService.delete(this.uri); - this.state = 'applied'; - return; + async apply(contents?: string): Promise { + if (!await this.confirm('Apply')) { return; } + if (!(await this.changeSetFileService.trySave(this.changedUri))) { + if (this.type === 'delete') { + await this.changeSetFileService.delete(this.uri); + this.state = 'applied'; + } else { + await this.writeChanges(contents); + } } + this.changeSetFileService.closeDiff(this.readOnlyUri); + } - await this.changeSetFileService.write(this.uri, contents !== undefined ? contents : this.targetState); + async writeChanges(contents?: string): Promise { + await this.changeSetFileService.writeFrom(this.changedUri, this.uri, contents ?? this.targetState); + this.state = 'applied'; } - async discard(): Promise { - this.state = 'discarded'; + async revert(): Promise { + if (!await this.confirm('Revert')) { return; } + this.state = 'pending'; if (this.type === 'add') { await this.changeSetFileService.delete(this.uri); - return; - } - if (this.originalContent) { + } else if (this.originalContent) { await this.changeSetFileService.write(this.uri, this.originalContent); } } + async confirm(verb: string): Promise { + if (this._state !== 'stale') { return true; } + await this.openChange(); + const thing = await new ConfirmDialog({ + title: `${verb} suggestion.`, + msg: `The file ${this.uri.path.toString()} has changed since this suggestion was created. Are you certain you wish to ${verb.toLowerCase()} the change?` + }).open(true); + return !!thing; + } + + dispose(): void { + this.toDispose.dispose(); + } } diff --git a/packages/ai-chat/src/browser/change-set-file-resource.ts b/packages/ai-chat/src/browser/change-set-file-resource.ts index 7df6fe2721db9..739eec68891ce 100644 --- a/packages/ai-chat/src/browser/change-set-file-resource.ts +++ b/packages/ai-chat/src/browser/change-set-file-resource.ts @@ -14,61 +14,147 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { Resource, ResourceResolver, ResourceSaveOptions, URI } from '@theia/core'; -import { inject, injectable } from '@theia/core/shared/inversify'; -import { ChatService } from '../common'; -import { ChangeSetFileElement } from './change-set-file-element'; +import { MutableResource, Reference, ReferenceMutableResource, Resource, ResourceResolver, URI } from '@theia/core'; +import { injectable } from '@theia/core/shared/inversify'; export const CHANGE_SET_FILE_RESOURCE_SCHEME = 'changeset-file'; -const QUERY = 'uri='; +export type ResourceInitializationOptions = Pick & { contents?: string, onSave?: Resource['saveContents'] }; +export type ResourceUpdateOptions = Pick; export function createChangeSetFileUri(chatSessionId: string, elementUri: URI): URI { - return new URI(CHANGE_SET_FILE_RESOURCE_SCHEME + '://' + chatSessionId + '/' + elementUri.path).withQuery(QUERY + encodeURIComponent(elementUri.path.toString())); + return elementUri.withScheme(CHANGE_SET_FILE_RESOURCE_SCHEME).withAuthority(chatSessionId); } -/** - * A file resource within a chat's change set can be resolved with the following URI: - * changeset-file:/?uri= - */ -@injectable() -export class ChangeSetFileResourceResolver implements ResourceResolver { +export class UpdatableReferenceResource extends ReferenceMutableResource { + static acquire(resource: UpdatableReferenceResource): UpdatableReferenceResource { + DisposableRefCounter.acquire(resource.reference); + return resource; + } + + constructor(protected override reference: DisposableRefCounter) { + super(reference); + } + + update(options: ResourceUpdateOptions): void { + this.reference.object.update(options); + } + + get readOnly(): Resource['readOnly'] { + return this.reference.object.readOnly; + } + + get initiallyDirty(): boolean { + return this.reference.object.initiallyDirty; + } + + get autosaveable(): boolean { + return this.reference.object.autosaveable; + } +} + +export class DisposableMutableResource extends MutableResource { + onSave: Resource['saveContents'] | undefined; + constructor(uri: URI, protected readonly options?: ResourceInitializationOptions) { + super(uri); + this.onSave = options?.onSave; + this.contents = options?.contents ?? ''; + } + + get readOnly(): Resource['readOnly'] { + return this.options?.readOnly || !this.onSave; + } + + get autosaveable(): boolean { + return this.options?.autosaveable !== false; + } + + get initiallyDirty(): boolean { + return !!this.options?.initiallyDirty; + } - @inject(ChatService) - protected readonly chatService: ChatService; + override async saveContents(contents: string): Promise { + if (this.options?.onSave) { + await this.options.onSave(contents); + this.update({ contents }); + } + } - async resolve(uri: URI): Promise { - if (uri.scheme !== CHANGE_SET_FILE_RESOURCE_SCHEME) { - throw new Error('The given uri is not a change set file uri: ' + uri); + update(options: ResourceUpdateOptions): void { + if (options.contents !== undefined && options.contents !== this.contents) { + this.contents = options.contents; + this.fireDidChangeContents(); } + if ('onSave' in options && options.onSave !== this.onSave) { + this.onSave = options.onSave; + } + } - const chatSessionId = uri.authority; - const session = this.chatService.getSession(chatSessionId); - if (!session) { - throw new Error('Chat session not found: ' + chatSessionId); + override dispose(): void { + this.onDidChangeContentsEmitter.dispose(); + } +} + +export class DisposableRefCounter implements Reference { + static acquire(item: DisposableRefCounter): DisposableRefCounter { + item.refs++; + return item; + } + static create(value: V, onDispose: () => void): DisposableRefCounter { + return this.acquire(new this(value, onDispose)); + } + readonly object: V; + protected refs = 0; + protected constructor(value: V, protected readonly onDispose: () => void) { + this.object = value; + } + dispose(): void { + this.refs--; + if (this.refs === 0) { + this.onDispose(); } + } +} + +@injectable() +export class ChangeSetFileResourceResolver implements ResourceResolver { + protected readonly cache = new Map(); - const changeSet = session.model.changeSet; - if (!changeSet) { - throw new Error('Chat session has no change set: ' + chatSessionId); + add(uri: URI, options?: ResourceInitializationOptions): UpdatableReferenceResource { + const key = uri.toString(); + if (this.cache.has(key)) { + throw new Error(`Resource ${key} already exists.`); } + const underlyingResource = new DisposableMutableResource(uri, options); + const ref = DisposableRefCounter.create(underlyingResource, () => { + underlyingResource.dispose(); + this.cache.delete(key); + }); + const refResource = new UpdatableReferenceResource(ref); + this.cache.set(key, refResource); + return refResource; + } - const fileUri = decodeURIComponent(uri.query.toString().replace(QUERY, '')); - const element = changeSet.getElements().find(e => e.uri.path.toString() === fileUri); - if (!(element instanceof ChangeSetFileElement)) { - throw new Error('Change set element not found: ' + fileUri); + tryGet(uri: URI): UpdatableReferenceResource | undefined { + try { + return this.resolve(uri); + } catch { + return undefined; } + } - return { - uri, - readOnly: false, - initiallyDirty: true, - readContents: async () => element.targetState ?? '', - saveContents: async (content: string, options?: ResourceSaveOptions): Promise => { - element.accept(content); - }, - dispose: () => { } - }; + update(uri: URI, contents: string): void { + const key = uri.toString(); + const resource = this.cache.get(key); + if (!resource) { + throw new Error(`No resource for ${key}.`); + } + resource.update({ contents }); } + resolve(uri: URI): UpdatableReferenceResource { + const key = uri.toString(); + const ref = this.cache.get(key); + if (!ref) { throw new Error(`No resource for ${key}.`); } + return UpdatableReferenceResource.acquire(ref); + } } - diff --git a/packages/ai-chat/src/browser/change-set-file-service.ts b/packages/ai-chat/src/browser/change-set-file-service.ts index 2d2181ccb9cfb..4882414cdd3ee 100644 --- a/packages/ai-chat/src/browser/change-set-file-service.ts +++ b/packages/ai-chat/src/browser/change-set-file-service.ts @@ -14,8 +14,8 @@ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** -import { ILogger, UNTITLED_SCHEME, URI } from '@theia/core'; -import { DiffUris, LabelProvider, OpenerService, open } from '@theia/core/lib/browser'; +import { ILogger, URI } from '@theia/core'; +import { ApplicationShell, DiffUris, LabelProvider, NavigatableWidget, OpenerService, open } from '@theia/core/lib/browser'; import { inject, injectable } from '@theia/core/shared/inversify'; import { EditorManager } from '@theia/editor/lib/browser'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; @@ -40,6 +40,9 @@ export class ChangeSetFileService { @inject(EditorManager) protected readonly editorManager: EditorManager; + @inject(ApplicationShell) + protected readonly shell: ApplicationShell; + @inject(MonacoWorkspace) protected readonly monacoWorkspace: MonacoWorkspace; @@ -95,15 +98,14 @@ export class ChangeSetFileService { } async openDiff(originalUri: URI, suggestedUri: URI): Promise { - const exists = await this.fileService.exists(originalUri); - const openedUri = exists ? originalUri : originalUri.withScheme(UNTITLED_SCHEME); - // Currently we don't have a great way to show the suggestions in a diff editor with accept/reject buttons - // 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 - // But this leads to wrong colors and wrong label (revert change instead of accept change) - const diffUri = DiffUris.encode(openedUri, suggestedUri, + const diffUri = this.getDiffUri(originalUri, suggestedUri); + open(this.openerService, diffUri); + } + + protected getDiffUri(originalUri: URI, suggestedUri: URI): URI { + return DiffUris.encode(originalUri, suggestedUri, `AI Changes: ${this.labelProvider.getName(originalUri)}`, ); - open(this.openerService, diffUri); } async delete(uri: URI): Promise { @@ -113,24 +115,44 @@ export class ChangeSetFileService { } } - async write(uri: URI, targetState: string): Promise { - const exists = await this.fileService.exists(uri); - if (!exists) { - await this.fileService.create(uri, targetState); + /** Returns true if there was a document available to save for the specified URI. */ + async trySave(suggestedUri: URI): Promise { + const openModel = this.monacoWorkspace.getTextDocument(suggestedUri.toString()); + if (openModel) { + await openModel.save(); + return true; + } else { + return false; } - await this.doWrite(uri, targetState); } - protected async doWrite(uri: URI, text: string): Promise { + async writeFrom(from: URI, to: URI, fallbackContent: string): Promise { + const authoritativeContent = this.monacoWorkspace.getTextDocument(from.toString())?.getText() ?? fallbackContent; + await this.write(to, authoritativeContent); + } + + async write(uri: URI, text: string): Promise { const document = this.monacoWorkspace.getTextDocument(uri.toString()); if (document) { await this.monacoWorkspace.applyBackgroundEdit(document, [{ range: document.textEditorModel.getFullModelRange(), text - }], (editor, wasDirty) => editor === undefined || !wasDirty); + }], () => true); } else { await this.fileService.write(uri, text); } } + closeDiffsForSession(sessionId: string, except?: URI[]): void { + const openEditors = this.shell.widgets.filter(widget => { + const uri = NavigatableWidget.getUri(widget); + return uri && uri.authority === sessionId && !except?.some(candidate => candidate.path.toString() === uri.path.toString()); + }); + openEditors.forEach(editor => editor.close()); + } + + closeDiff(uri: URI): void { + const openEditors = this.shell.widgets.filter(widget => NavigatableWidget.getUri(widget)?.isEqual(uri)); + openEditors.forEach(editor => editor.close()); + } } diff --git a/packages/ai-chat/src/browser/frontend-chat-service.ts b/packages/ai-chat/src/browser/frontend-chat-service.ts index 1cbc09d9cae40..6994b18b321c8 100644 --- a/packages/ai-chat/src/browser/frontend-chat-service.ts +++ b/packages/ai-chat/src/browser/frontend-chat-service.ts @@ -15,9 +15,10 @@ // ***************************************************************************** import { inject, injectable } from '@theia/core/shared/inversify'; -import { ChatAgent, ChatServiceImpl, ChatSession, ParsedChatRequest } from '../common'; +import { ChangeSet, ChatAgent, ChatAgentLocation, ChatServiceImpl, ChatSession, ParsedChatRequest, SessionOptions } from '../common'; import { PreferenceService } from '@theia/core/lib/browser'; import { DEFAULT_CHAT_AGENT_PREF, PIN_CHAT_AGENT_PREF } from './ai-chat-preferences'; +import { ChangeSetFileService } from './change-set-file-service'; /** * Customizes the ChatServiceImpl to consider preference based default chat agent @@ -26,7 +27,10 @@ import { DEFAULT_CHAT_AGENT_PREF, PIN_CHAT_AGENT_PREF } from './ai-chat-preferen export class FrontendChatServiceImpl extends ChatServiceImpl { @inject(PreferenceService) - protected preferenceService: PreferenceService; + protected readonly preferenceService: PreferenceService; + + @inject(ChangeSetFileService) + protected readonly changeSetFileService: ChangeSetFileService; protected override getAgent(parsedRequest: ParsedChatRequest, session: ChatSession): ChatAgent | undefined { let agent = this.initialAgentSelection(parsedRequest); @@ -60,4 +64,17 @@ export class FrontendChatServiceImpl extends ChatServiceImpl { } return configuredDefaultChatAgent; } + + override createSession(location?: ChatAgentLocation, options?: SessionOptions): ChatSession { + const session = super.createSession(location, options); + session.model.onDidChange(event => { + const changeSet = (event as { changeSet?: ChangeSet }).changeSet; + if (event.kind === 'removeChangeSet') { + this.changeSetFileService.closeDiffsForSession(session.id); + } else if (changeSet) { + this.changeSetFileService.closeDiffsForSession(session.id, changeSet.getElements().map(({ uri }) => uri)); + } + }); + return session; + } } diff --git a/packages/ai-chat/src/common/chat-model.ts b/packages/ai-chat/src/common/chat-model.ts index d014ac38c25f2..8481bf878cbb2 100644 --- a/packages/ai-chat/src/common/chat-model.ts +++ b/packages/ai-chat/src/common/chat-model.ts @@ -35,7 +35,6 @@ export type ChatChangeEvent = | ChatAddResponseEvent | ChatRemoveRequestEvent | ChatSetChangeSetEvent - | ChatSetChangeDeleteEvent | ChatUpdateChangeSetEvent | ChatRemoveChangeSetEvent; @@ -52,10 +51,7 @@ export interface ChatAddResponseEvent { export interface ChatSetChangeSetEvent { kind: 'setChangeSet'; changeSet: ChangeSet; -} - -export interface ChatSetChangeDeleteEvent { - kind: 'deleteChangeSet'; + oldChangeSet?: ChangeSet; } export interface ChatUpdateChangeSetEvent { @@ -70,7 +66,7 @@ export interface ChatRemoveChangeSetEvent { export namespace ChatChangeEvent { export function isChangeSetEvent(event: ChatChangeEvent): event is ChatSetChangeSetEvent | ChatUpdateChangeSetEvent | ChatRemoveChangeSetEvent { - return event.kind === 'setChangeSet' || event.kind === 'deleteChangeSet' || event.kind === 'removeChangeSet' || event.kind === 'updateChangeSet'; + return event.kind === 'setChangeSet' || event.kind === 'removeChangeSet' || event.kind === 'updateChangeSet'; } } @@ -93,25 +89,29 @@ export interface ChatModel { } export interface ChangeSet { + onDidChange: Event; readonly title: string; getElements(): ChangeSetElement[]; + dispose(): void; } export interface ChangeSetElement { readonly uri: URI; + onDidChange?: Event readonly name?: string; readonly icon?: string; readonly additionalInfo?: string; - readonly state?: 'pending' | 'applied' | 'discarded'; + readonly state?: 'pending' | 'applied' | 'stale'; readonly type?: 'add' | 'modify' | 'delete'; readonly data?: { [key: string]: unknown }; open?(): Promise; openChange?(): Promise; - accept?(): Promise; - discard?(): Promise; + apply?(): Promise; + revert?(): Promise; + dispose?(): void; } export interface ChatRequest { @@ -466,13 +466,12 @@ export interface ChatResponseModel { * Implementations **********************/ -export class MutableChatModel implements ChatModel { +export class MutableChatModel implements ChatModel, Disposable { protected readonly _onDidChangeEmitter = new Emitter(); onDidChange: Event = this._onDidChangeEmitter.event; protected _requests: MutableChatRequestModel[]; protected _id: string; - protected _changeSetListener?: Disposable; protected _changeSet?: ChangeSetImpl; constructor(public readonly location = ChatAgentLocation.Panel) { @@ -498,22 +497,21 @@ export class MutableChatModel implements ChatModel { } setChangeSet(changeSet: ChangeSetImpl | undefined): void { - this._changeSet = changeSet; - if (this._changeSet === undefined) { - this._changeSetListener?.dispose(); - this._onDidChangeEmitter.fire({ - kind: 'deleteChangeSet', - }); - return; + if (!changeSet) { + return this.removeChangeSet(); } + const oldChangeSet = this._changeSet; + oldChangeSet?.dispose(); + this._changeSet = changeSet; this._onDidChangeEmitter.fire({ kind: 'setChangeSet', - changeSet: this._changeSet, + changeSet, + oldChangeSet, }); - this._changeSetListener = this._changeSet.onDidChange(() => { + changeSet.onDidChange(() => { this._onDidChangeEmitter.fire({ kind: 'updateChangeSet', - changeSet: this._changeSet!, + changeSet, }); }); } @@ -522,6 +520,7 @@ export class MutableChatModel implements ChatModel { if (this._changeSet) { const oldChangeSet = this._changeSet; this._changeSet = undefined; + oldChangeSet.dispose(); this._onDidChangeEmitter.fire({ kind: 'removeChangeSet', changeSet: oldChangeSet, @@ -542,54 +541,72 @@ export class MutableChatModel implements ChatModel { isEmpty(): boolean { return this._requests.length === 0; } + + dispose(): void { + this.removeChangeSet(); // Signal disposal of last change set. + this._onDidChangeEmitter.dispose(); + } +} + +interface ChangeSetChangeEvent { + added?: URI[], + removed?: URI[], + modified?: URI[], + /** Fired when only the state of a given element changes, not its contents */ + state?: URI[], } export class ChangeSetImpl implements ChangeSet { - protected readonly _onDidChangeEmitter = new Emitter(); - onDidChange: Event = this._onDidChangeEmitter.event; + protected readonly _onDidChangeEmitter = new Emitter(); + onDidChange: Event = this._onDidChangeEmitter.event; protected _elements: ChangeSetElement[] = []; constructor(public readonly title: string, elements: ChangeSetElement[] = []) { - this.addElements(elements); + this.addElements(...elements); } getElements(): ChangeSetElement[] { return this._elements; } - addElement(element: ChangeSetElement): void { - this.addElements([element]); - } - - addElements(elements: ChangeSetElement[]): void { - this._elements.push(...elements); - this.notifyChange(); - } - - replaceElement(element: ChangeSetElement): boolean { - const index = this._elements.findIndex(e => e.uri.toString() === element.uri.toString()); - if (index < 0) { - return false; - } - this._elements[index] = element; - this.notifyChange(); - return true; + /** Will replace any element that is already present, using URI as identity criterion. */ + addElements(...elements: ChangeSetElement[]): void { + const added: URI[] = []; + const modified: URI[] = []; + const toDispose: ChangeSetElement[] = []; + const current = new Map(this.getElements().map((element, index) => [element.uri.toString(), index])); + elements.forEach(element => { + const existingIndex = current.get(element.uri.toString()); + if (existingIndex !== undefined) { + modified.push(element.uri); + toDispose.push(this._elements[existingIndex]); + this._elements[existingIndex] = element; + } else { + added.push(element.uri); + this._elements.push(element); + } + element.onDidChange?.(() => this.notifyChange({ state: [element.uri] })); + }); + toDispose.forEach(element => element.dispose?.()); + this.notifyChange({ added, modified }); } - addOrReplaceElement(element: ChangeSetElement): void { - if (!this.replaceElement(element)) { - this.addElement(element); - } + removeElements(...indices: number[]): void { + // From highest to lowest so that we don't affect lower indices with our splicing. + const sorted = indices.slice().sort((left, right) => left - right); + const deletions = sorted.flatMap(index => this._elements.splice(index, 1)); + deletions.forEach(deleted => deleted.dispose?.()); + this.notifyChange({ removed: deletions.map(element => element.uri) }); } - removeElement(index: number): void { - this._elements.splice(index, 1); - this.notifyChange(); + protected notifyChange(change: ChangeSetChangeEvent): void { + this._onDidChangeEmitter.fire(change); } - notifyChange(): void { - this._onDidChangeEmitter.fire(); + dispose(): void { + this._elements.forEach(element => element.dispose?.()); + this._onDidChangeEmitter.dispose(); } } diff --git a/packages/ai-chat/src/common/chat-service.ts b/packages/ai-chat/src/common/chat-service.ts index 9fb333ac57790..4d1cfdae8db23 100644 --- a/packages/ai-chat/src/common/chat-service.ts +++ b/packages/ai-chat/src/common/chat-service.ts @@ -162,11 +162,15 @@ export class ChatServiceImpl implements ChatService { } deleteSession(sessionId: string): void { + const sessionIndex = this._sessions.findIndex(candidate => candidate.id === sessionId); + if (~sessionIndex) { return; } + const session = this._sessions[sessionIndex]; // If the removed session is the active one, set the newest one as active - if (this.getSession(sessionId)?.isActive) { + if (session.isActive) { this.setActiveSession(this._sessions[this._sessions.length - 1]?.id); } - this._sessions = this._sessions.filter(item => item.id !== sessionId); + session.model.dispose(); + this._sessions.splice(sessionIndex, 1); } setActiveSession(sessionId: string | undefined, options?: SessionOptions): void { @@ -291,6 +295,6 @@ export class ChatServiceImpl implements ChatService { } deleteChangeSetElement(sessionId: string, index: number): void { - this.getSession(sessionId)?.model.changeSet?.removeElement(index); + this.getSession(sessionId)?.model.changeSet?.removeElements(index); } } diff --git a/packages/ai-ide-agents/src/browser/file-changeset-functions.ts b/packages/ai-ide-agents/src/browser/file-changeset-functions.ts index b63ccffe9d383..0935e86473ae3 100644 --- a/packages/ai-ide-agents/src/browser/file-changeset-functions.ts +++ b/packages/ai-ide-agents/src/browser/file-changeset-functions.ts @@ -73,7 +73,7 @@ export class WriteChangeToFileProvider implements ToolProvider { if (!await this.fileService.exists(uri)) { type = 'add'; } - changeSet.addOrReplaceElement( + changeSet.addElements( this.fileChangeFactory({ uri: uri, type: type as 'modify' | 'add' | 'delete', @@ -163,7 +163,7 @@ export class ReplaceContentInFileProvider implements ToolProvider { ctx.session.setChangeSet(changeSet); } - changeSet.addOrReplaceElement( + changeSet.addElements( this.fileChangeFactory({ uri: fileUri, type: 'modify', diff --git a/packages/core/src/browser/saveable-service.ts b/packages/core/src/browser/saveable-service.ts index 15fbf85fe3c1d..f67d941e1adcf 100644 --- a/packages/core/src/browser/saveable-service.ts +++ b/packages/core/src/browser/saveable-service.ts @@ -162,7 +162,7 @@ export class SaveableService implements FrontendApplicationContribution { // Never auto-save untitled documents return false; } else { - return saveable.dirty; + return saveable.autosaveable !== false && saveable.dirty; } } diff --git a/packages/core/src/browser/saveable.ts b/packages/core/src/browser/saveable.ts index 5f2238c60869e..17e809254bc9d 100644 --- a/packages/core/src/browser/saveable.ts +++ b/packages/core/src/browser/saveable.ts @@ -28,6 +28,8 @@ export type AutoSaveMode = 'off' | 'afterDelay' | 'onFocusChange' | 'onWindowCha export interface Saveable { readonly dirty: boolean; + /** If false, the saveable will not participate in autosaving. */ + readonly autosaveable?: boolean; /** * This event is fired when the content of the `dirty` variable changes. */ diff --git a/packages/core/src/common/resource.ts b/packages/core/src/common/resource.ts index 016add0db5edc..79fe26ad2ca84 100644 --- a/packages/core/src/common/resource.ts +++ b/packages/core/src/common/resource.ts @@ -62,6 +62,8 @@ export interface Resource extends Disposable { readonly readOnly?: boolean | MarkdownString; readonly initiallyDirty?: boolean; + /** If false, the application should not attempt to auto-save this resource. */ + readonly autosaveable?: boolean; /** * Reads latest content of this resource. * @@ -223,7 +225,7 @@ export class DefaultResourceProvider { } export class MutableResource implements Resource { - private contents: string = ''; + protected contents: string = ''; constructor(readonly uri: URI) { } @@ -288,7 +290,7 @@ export class InMemoryResources implements ResourceResolver { const resourceUri = uri.toString(); const resource = this.resources.get(resourceUri); if (!resource) { - throw new Error(`Cannot update non-existed in-memory resource '${resourceUri}'`); + throw new Error(`Cannot update non-existent in-memory resource '${resourceUri}'`); } resource.saveContents(contents); return resource; @@ -390,7 +392,8 @@ export class UntitledResourceResolver implements ResourceResolver { export class UntitledResource implements Resource { protected readonly onDidChangeContentsEmitter = new Emitter(); - initiallyDirty: boolean; + readonly initiallyDirty: boolean; + readonly autosaveable = false; get onDidChangeContents(): Event { return this.onDidChangeContentsEmitter.event; } diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts index 0cb127d4d54be..346aaaf82cbdf 100644 --- a/packages/monaco/src/browser/monaco-editor-model.ts +++ b/packages/monaco/src/browser/monaco-editor-model.ts @@ -259,6 +259,10 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo return this.resource.uri.toString(); } + get autosaveable(): boolean | undefined { + return this.resource.autosaveable; + } + protected _languageId: string | undefined; get languageId(): string { return this._languageId !== undefined ? this._languageId : this.model.getLanguageId();