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();