Path: blob/main/src/vs/workbench/contrib/chat/browser/chatEditorInput.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../../base/common/cancellation.js';6import { Codicon } from '../../../../base/common/codicons.js';7import { Emitter } from '../../../../base/common/event.js';8import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js';9import { Schemas } from '../../../../base/common/network.js';10import { isEqual } from '../../../../base/common/resources.js';11import { ThemeIcon } from '../../../../base/common/themables.js';12import { URI } from '../../../../base/common/uri.js';13import * as nls from '../../../../nls.js';14import { ConfirmResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js';15import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';16import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js';17import { EditorInputCapabilities, IEditorIdentifier, IEditorSerializer, IUntypedEditorInput } from '../../../common/editor.js';18import { EditorInput, IEditorCloseHandler } from '../../../common/editor/editorInput.js';19import { IChatEditingSession, ModifiedFileEntryState } from '../common/chatEditingService.js';20import { IChatModel } from '../common/chatModel.js';21import { IChatService } from '../common/chatService.js';22import { ChatAgentLocation } from '../common/constants.js';23import { IClearEditingSessionConfirmationOptions } from './actions/chatActions.js';24import type { IChatEditorOptions } from './chatEditor.js';2526const ChatEditorIcon = registerIcon('chat-editor-label-icon', Codicon.chatSparkle, nls.localize('chatEditorLabelIcon', 'Icon of the chat editor label.'));2728export class ChatEditorInput extends EditorInput implements IEditorCloseHandler {29static readonly countsInUse = new Set<number>();3031static readonly TypeID: string = 'workbench.input.chatSession';32static readonly EditorID: string = 'workbench.editor.chatSession';3334private readonly inputCount: number;35public sessionId: string | undefined;36private hasCustomTitle: boolean = false;3738private model: IChatModel | undefined;3940static getNewEditorUri(): URI {41const handle = Math.floor(Math.random() * 1e9);42return ChatEditorUri.generate(handle);43}4445static getNextCount(): number {46let count = 0;47while (ChatEditorInput.countsInUse.has(count)) {48count++;49}5051return count;52}5354constructor(55readonly resource: URI,56readonly options: IChatEditorOptions,57@IChatService private readonly chatService: IChatService,58@IDialogService private readonly dialogService: IDialogService,59) {60super();6162if (resource.scheme === Schemas.vscodeChatEditor) {63const parsed = ChatEditorUri.parse(resource);64if (!parsed || typeof parsed !== 'number') {65throw new Error('Invalid chat URI');66}67} else if (resource.scheme !== Schemas.vscodeChatSession) {68throw new Error('Invalid chat URI');69}7071this.sessionId = (options.target && 'sessionId' in options.target) ?72options.target.sessionId :73undefined;7475// Check if we already have a custom title for this session76const hasExistingCustomTitle = this.sessionId && (77this.chatService.getSession(this.sessionId)?.title ||78this.chatService.getPersistedSessionTitle(this.sessionId)?.trim()79);8081this.hasCustomTitle = Boolean(hasExistingCustomTitle);8283// Only allocate a count if we don't already have a custom title84if (!this.hasCustomTitle) {85this.inputCount = ChatEditorInput.getNextCount();86ChatEditorInput.countsInUse.add(this.inputCount);87this._register(toDisposable(() => {88// Only remove if we haven't already removed it due to custom title89if (!this.hasCustomTitle) {90ChatEditorInput.countsInUse.delete(this.inputCount);91}92}));93} else {94this.inputCount = 0; // Not used when we have a custom title95}96}9798override closeHandler = this;99100showConfirm(): boolean {101return this.model?.editingSession ? shouldShowClearEditingSessionConfirmation(this.model.editingSession) : false;102}103104async confirm(editors: ReadonlyArray<IEditorIdentifier>): Promise<ConfirmResult> {105if (!this.model?.editingSession) {106return ConfirmResult.SAVE;107}108109const titleOverride = nls.localize('chatEditorConfirmTitle', "Close Chat Editor");110const messageOverride = nls.localize('chat.startEditing.confirmation.pending.message.default', "Closing the chat editor will end your current edit session.");111const result = await showClearEditingSessionConfirmation(this.model.editingSession, this.dialogService, { titleOverride, messageOverride });112return result ? ConfirmResult.SAVE : ConfirmResult.CANCEL;113}114115override get editorId(): string | undefined {116return ChatEditorInput.EditorID;117}118119override get capabilities(): EditorInputCapabilities {120return super.capabilities | EditorInputCapabilities.Singleton | EditorInputCapabilities.CanDropIntoEditor;121}122123override matches(otherInput: EditorInput | IUntypedEditorInput): boolean {124if (!(otherInput instanceof ChatEditorInput)) {125return false;126}127128if (this.resource.scheme === Schemas.vscodeChatSession) {129return isEqual(this.resource, otherInput.resource);130}131132if (this.resource.scheme === Schemas.vscodeChatEditor && otherInput.resource.scheme === Schemas.vscodeChatEditor) {133return this.sessionId === otherInput.sessionId;134}135136return false;137}138139override get typeId(): string {140return ChatEditorInput.TypeID;141}142143override getName(): string {144// If we have a resolved model, use its title145if (this.model?.title) {146return this.model.title;147}148149// If we have a sessionId but no resolved model, try to get the title from persisted sessions150if (this.sessionId) {151// First try the active session registry152const existingSession = this.chatService.getSession(this.sessionId);153if (existingSession?.title) {154return existingSession.title;155}156157// If not in active registry, try persisted session data158const persistedTitle = this.chatService.getPersistedSessionTitle(this.sessionId);159if (persistedTitle && persistedTitle.trim()) { // Only use non-empty persisted titles160return persistedTitle;161}162}163164// Fall back to default naming pattern165const defaultName = nls.localize('chatEditorName', "Chat") + (this.inputCount > 0 ? ` ${this.inputCount + 1}` : '');166return defaultName;167}168169override getIcon(): ThemeIcon {170return ChatEditorIcon;171}172173override async resolve(): Promise<ChatEditorModel | null> {174if (this.resource.scheme === Schemas.vscodeChatSession) {175this.model = await this.chatService.loadSessionForResource(this.resource, ChatAgentLocation.Editor, CancellationToken.None);176} else if (typeof this.sessionId === 'string') {177this.model = await this.chatService.getOrRestoreSession(this.sessionId)178?? this.chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None);179} else if (!this.options.target) {180this.model = this.chatService.startSession(ChatAgentLocation.Panel, CancellationToken.None);181} else if ('data' in this.options.target) {182this.model = this.chatService.loadSessionFromContent(this.options.target.data);183}184185if (!this.model || this.isDisposed()) {186return null;187}188189this.sessionId = this.model.sessionId;190this._register(this.model.onDidChange((e) => {191// When a custom title is set, we no longer need the numeric count192if (e && e.kind === 'setCustomTitle' && !this.hasCustomTitle) {193this.hasCustomTitle = true;194ChatEditorInput.countsInUse.delete(this.inputCount);195}196this._onDidChangeLabel.fire();197}));198199return this._register(new ChatEditorModel(this.model));200}201202override dispose(): void {203super.dispose();204if (this.sessionId) {205this.chatService.clearSession(this.sessionId);206}207}208}209210export class ChatEditorModel extends Disposable {211private _onWillDispose = this._register(new Emitter<void>());212readonly onWillDispose = this._onWillDispose.event;213214private _isDisposed = false;215private _isResolved = false;216217constructor(218readonly model: IChatModel219) { super(); }220221async resolve(): Promise<void> {222this._isResolved = true;223}224225isResolved(): boolean {226return this._isResolved;227}228229isDisposed(): boolean {230return this._isDisposed;231}232233override dispose(): void {234super.dispose();235this._isDisposed = true;236}237}238239240export namespace ChatEditorUri {241242export const scheme = Schemas.vscodeChatEditor;243244export function generate(handle: number): URI {245return URI.from({ scheme, path: `chat-${handle}` });246}247248export function parse(resource: URI): number | undefined {249if (resource.scheme !== scheme) {250return undefined;251}252253const match = resource.path.match(/chat-(\d+)/);254const handleStr = match?.[1];255if (typeof handleStr !== 'string') {256return undefined;257}258259const handle = parseInt(handleStr);260if (isNaN(handle)) {261return undefined;262}263264return handle;265}266}267268interface ISerializedChatEditorInput {269options: IChatEditorOptions;270sessionId: string;271resource: URI;272}273274export class ChatEditorInputSerializer implements IEditorSerializer {275canSerialize(input: EditorInput): input is ChatEditorInput & { readonly sessionId: string } {276return input instanceof ChatEditorInput && typeof input.sessionId === 'string';277}278279serialize(input: EditorInput): string | undefined {280if (!this.canSerialize(input)) {281return undefined;282}283284const obj: ISerializedChatEditorInput = {285options: input.options,286sessionId: input.sessionId,287resource: input.resource288};289return JSON.stringify(obj);290}291292deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined {293try {294const parsed: ISerializedChatEditorInput = JSON.parse(serializedEditor);295const resource = URI.revive(parsed.resource);296return instantiationService.createInstance(ChatEditorInput, resource, { ...parsed.options, target: { sessionId: parsed.sessionId } });297} catch (err) {298return undefined;299}300}301}302303export async function showClearEditingSessionConfirmation(editingSession: IChatEditingSession, dialogService: IDialogService, options?: IClearEditingSessionConfirmationOptions): Promise<boolean> {304const defaultPhrase = nls.localize('chat.startEditing.confirmation.pending.message.default1', "Starting a new chat will end your current edit session.");305const defaultTitle = nls.localize('chat.startEditing.confirmation.title', "Start new chat?");306const phrase = options?.messageOverride ?? defaultPhrase;307const title = options?.titleOverride ?? defaultTitle;308309const currentEdits = editingSession.entries.get();310const undecidedEdits = currentEdits.filter((edit) => edit.state.get() === ModifiedFileEntryState.Modified);311312const { result } = await dialogService.prompt({313title,314message: phrase + ' ' + nls.localize('chat.startEditing.confirmation.pending.message.2', "Do you want to keep pending edits to {0} files?", undecidedEdits.length),315type: 'info',316cancelButton: true,317buttons: [318{319label: nls.localize('chat.startEditing.confirmation.acceptEdits', "Keep & Continue"),320run: async () => {321await editingSession.accept();322return true;323}324},325{326label: nls.localize('chat.startEditing.confirmation.discardEdits', "Undo & Continue"),327run: async () => {328await editingSession.reject();329return true;330}331}332],333});334335return Boolean(result);336}337338export function shouldShowClearEditingSessionConfirmation(editingSession: IChatEditingSession): boolean {339const currentEdits = editingSession.entries.get();340const currentEditCount = currentEdits.length;341342if (currentEditCount) {343const undecidedEdits = currentEdits.filter((edit) => edit.state.get() === ModifiedFileEntryState.Modified);344return !!undecidedEdits.length;345}346347return false;348}349350351