diff --git a/packages/amazonq/.changes/next-release/Feature-3ebcda93-ca02-4682-b475-dead26e94900.json b/packages/amazonq/.changes/next-release/Feature-3ebcda93-ca02-4682-b475-dead26e94900.json new file mode 100644 index 00000000000..124bbc0f4bb --- /dev/null +++ b/packages/amazonq/.changes/next-release/Feature-3ebcda93-ca02-4682-b475-dead26e94900.json @@ -0,0 +1,4 @@ +{ + "type": "Feature", + "description": "Add buttons to code blocks to view and accept diffs." +} diff --git a/packages/amazonq/src/extension.ts b/packages/amazonq/src/extension.ts index bc5c8ae34c8..d7c7f3c1448 100644 --- a/packages/amazonq/src/extension.ts +++ b/packages/amazonq/src/extension.ts @@ -20,11 +20,14 @@ import { import { makeEndpointsProvider, registerGenericCommands } from 'aws-core-vscode' import { CommonAuthWebview } from 'aws-core-vscode/login' import { + amazonQDiffScheme, DefaultAWSClientBuilder, DefaultAwsContext, ExtContext, RegionProvider, Settings, + VirtualFileSystem, + VirtualMemoryFile, activateLogger, activateTelemetry, env, @@ -136,6 +139,14 @@ export async function activateAmazonQCommon(context: vscode.ExtensionContext, is // Handle Amazon Q Extension un-installation. setupUninstallHandler(VSCODE_EXTENSION_ID.amazonq, context.extension.packageJSON.version, context) + const vfs = new VirtualFileSystem() + + // Register an empty file that's used when a to open a diff + vfs.registerProvider( + vscode.Uri.from({ scheme: amazonQDiffScheme, path: 'empty' }), + new VirtualMemoryFile(new Uint8Array()) + ) + // Hide the Amazon Q tree in toolkit explorer await setContext('aws.toolkit.amazonq.dismissed', true) diff --git a/packages/amazonq/test/e2e/amazonq/featureDev.test.ts b/packages/amazonq/test/e2e/amazonq/featureDev.test.ts index 861ce35fb2b..9cd87003f36 100644 --- a/packages/amazonq/test/e2e/amazonq/featureDev.test.ts +++ b/packages/amazonq/test/e2e/amazonq/featureDev.test.ts @@ -110,7 +110,7 @@ describe('Amazon Q Feature Dev', function () { beforeEach(() => { registerAuthHook('amazonq-test-account') - framework = new qTestingFramework('featuredev', true) + framework = new qTestingFramework('featuredev', true, []) tab = framework.createTab() }) @@ -135,7 +135,7 @@ describe('Amazon Q Feature Dev', function () { it('Does NOT show /dev when feature dev is NOT enabled', () => { // The beforeEach registers a framework which accepts requests. If we don't dispose before building a new one we have duplicate messages framework.dispose() - framework = new qTestingFramework('featuredev', false) + framework = new qTestingFramework('featuredev', false, []) const tab = framework.createTab() const command = tab.findCommand('/dev') if (command.length > 0) { diff --git a/packages/amazonq/test/e2e/amazonq/framework/framework.ts b/packages/amazonq/test/e2e/amazonq/framework/framework.ts index 8c9e89c49f8..b65e8b184f7 100644 --- a/packages/amazonq/test/e2e/amazonq/framework/framework.ts +++ b/packages/amazonq/test/e2e/amazonq/framework/framework.ts @@ -12,6 +12,7 @@ import * as vscode from 'vscode' import { MynahUI, MynahUIProps } from '@aws/mynah-ui' import { DefaultAmazonQAppInitContext, TabType, createMynahUI } from 'aws-core-vscode/amazonq' import { Messenger, MessengerOptions } from './messenger' +import { FeatureContext } from 'aws-core-vscode/shared' /** * Abstraction over Amazon Q to make e2e testing easier @@ -23,7 +24,7 @@ export class qTestingFramework { lastEventId: string = '' - constructor(featureName: TabType, amazonQEnabled: boolean) { + constructor(featureName: TabType, amazonQEnabled: boolean, featureConfigsSerialized: [string, FeatureContext][]) { /** * Instantiate the UI and override the postMessage to publish using the app message * publishers directly. @@ -42,7 +43,8 @@ export class qTestingFramework { appMessagePublisher.publish(message) }, }, - amazonQEnabled + amazonQEnabled, + featureConfigsSerialized ) this.mynahUI = ui.mynahUI this.mynahUIProps = (this.mynahUI as any).props diff --git a/packages/core/src/amazonq/commons/controllers/contentController.ts b/packages/core/src/amazonq/commons/controllers/contentController.ts index d77ef176ec1..edcb84f9fb0 100644 --- a/packages/core/src/amazonq/commons/controllers/contentController.ts +++ b/packages/core/src/amazonq/commons/controllers/contentController.ts @@ -4,8 +4,28 @@ */ import * as vscode from 'vscode' +import path from 'path' import { Position, TextEditor, window } from 'vscode' import { getLogger } from '../../../shared/logger' +import { amazonQDiffScheme, amazonQTabSuffix } from '../../../shared/constants' +import { disposeOnEditorClose } from '../../../shared/utilities/editorUtilities' +import { + applyChanges, + createTempFileForDiff, + getSelectionFromRange, +} from '../../../shared/utilities/textDocumentUtilities' +import { extractFileAndCodeSelectionFromMessage, fs, getErrorMsg, getIndentedCode, ToolkitError } from '../../../shared' + +class ContentProvider implements vscode.TextDocumentContentProvider { + constructor(private uri: vscode.Uri) {} + + provideTextDocumentContent(_uri: vscode.Uri) { + return fs.readFileText(this.uri.fsPath) + } +} + +const chatDiffCode = 'ChatDiff' +const ChatDiffError = ToolkitError.named(chatDiffCode) export class EditorContentController { /* * @@ -52,4 +72,106 @@ export class EditorContentController { ) } } + + /** + * Accepts and applies a diff to a file, then closes the associated diff view tab. + * + * @param {any} message - The message containing diff information. + * @returns {Promise} A promise that resolves when the diff is applied and the tab is closed. + * + * @description + * This method performs the following steps: + * 1. Extracts file path and selection from the message. + * 2. If valid file path, non-empty code, and selection are present: + * a. Opens the document. + * b. Gets the indented code to update. + * c. Applies the changes to the document. + * d. Attempts to close the diff view tab for the file. + * + * @throws {Error} If there's an issue opening the document or applying changes. + */ + public async acceptDiff(message: any) { + const errorNotification = 'Unable to Apply code changes.' + const { filePath, selection } = extractFileAndCodeSelectionFromMessage(message) + + if (filePath && message?.code?.trim().length > 0 && selection) { + try { + const doc = await vscode.workspace.openTextDocument(filePath) + + const code = getIndentedCode(message, doc, selection) + const range = getSelectionFromRange(doc, selection) + await applyChanges(doc, range, code) + + // Sets the editor selection from the start of the given range, extending it by the number of lines in the code till the end of the last line + const editor = await vscode.window.showTextDocument(doc) + editor.selection = new vscode.Selection( + range.start, + new Position(range.start.line + code.split('\n').length, Number.MAX_SAFE_INTEGER) + ) + + // If vscode.diff is open for the filePath then close it. + vscode.window.tabGroups.all.flatMap(({ tabs }) => + tabs.map((tab) => { + if (tab.label === `${path.basename(filePath)} ${amazonQTabSuffix}`) { + const tabClosed = vscode.window.tabGroups.close(tab) + if (!tabClosed) { + getLogger().error( + '%s: Unable to close the diff view tab for %s', + chatDiffCode, + tab.label + ) + } + } + }) + ) + } catch (error) { + void vscode.window.showInformationMessage(errorNotification) + const wrappedError = ChatDiffError.chain(error, `Failed to Accept Diff`, { code: chatDiffCode }) + getLogger().error('%s: Failed to open diff view %s', chatDiffCode, getErrorMsg(wrappedError, true)) + throw wrappedError + } + } + } + + /** + * Displays a diff view comparing proposed changes with the existing file. + * + * How is diff generated: + * 1. Creates a temporary file as a clone of the original file. + * 2. Applies the proposed changes to the temporary file within the selected range. + * 3. Opens a diff view comparing original file to the temporary file. + * + * This approach ensures that the diff view only shows the changes proposed by Amazon Q, + * isolating them from any other modifications in the original file. + * + * @param message the message from Amazon Q chat + */ + public async viewDiff(message: any, scheme: string = amazonQDiffScheme) { + const errorNotification = 'Unable to Open Diff.' + const { filePath, selection } = extractFileAndCodeSelectionFromMessage(message) + + try { + if (filePath && message?.code?.trim().length > 0 && selection) { + const originalFileUri = vscode.Uri.file(filePath) + const uri = await createTempFileForDiff(originalFileUri, message, selection, scheme) + + // Register content provider and show diff + const contentProvider = new ContentProvider(uri) + const disposable = vscode.workspace.registerTextDocumentContentProvider(scheme, contentProvider) + await vscode.commands.executeCommand( + 'vscode.diff', + originalFileUri, + uri, + `${path.basename(filePath)} ${amazonQTabSuffix}` + ) + + disposeOnEditorClose(uri, disposable) + } + } catch (error) { + void vscode.window.showInformationMessage(errorNotification) + const wrappedError = ChatDiffError.chain(error, `Failed to Open Diff View`, { code: chatDiffCode }) + getLogger().error('%s: Failed to open diff view %s', chatDiffCode, getErrorMsg(wrappedError, true)) + throw wrappedError + } + } } diff --git a/packages/core/src/amazonq/index.ts b/packages/core/src/amazonq/index.ts index ec7dc9a3bf8..2900cd921ef 100644 --- a/packages/core/src/amazonq/index.ts +++ b/packages/core/src/amazonq/index.ts @@ -28,6 +28,7 @@ export { listCodeWhispererCommandsWalkthrough } from '../codewhisperer/ui/status export { focusAmazonQPanel, focusAmazonQPanelKeybinding } from '../codewhispererChat/commands/registerCommands' export { TryChatCodeLensProvider, tryChatCodeLensCommand } from '../codewhispererChat/editor/codelens' export { createAmazonQUri, openDiff, openDeletedDiff, getOriginalFileUri, getFileDiffUris } from './commons/diff' +import { FeatureContext } from '../shared' /** * main from createMynahUI is a purely browser dependency. Due to this @@ -35,10 +36,14 @@ export { createAmazonQUri, openDiff, openDeletedDiff, getOriginalFileUri, getFil * while only running on browser instances (like the e2e tests). If we * just export it regularly we will get "ReferenceError: self is not defined" */ -export function createMynahUI(ideApi: any, amazonQEnabled: boolean) { +export function createMynahUI( + ideApi: any, + amazonQEnabled: boolean, + featureConfigsSerialized: [string, FeatureContext][] +) { if (typeof window !== 'undefined') { const mynahUI = require('./webview/ui/main') - return mynahUI.createMynahUI(ideApi, amazonQEnabled) + return mynahUI.createMynahUI(ideApi, amazonQEnabled, featureConfigsSerialized) } throw new Error('Not implemented for node') } diff --git a/packages/core/src/amazonq/util/functionUtils.ts b/packages/core/src/amazonq/util/functionUtils.ts new file mode 100644 index 00000000000..c658b59aeef --- /dev/null +++ b/packages/core/src/amazonq/util/functionUtils.ts @@ -0,0 +1,24 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Converts an array of key-value pairs into a Map object. + * + * @param {[unknown, unknown][]} arr - An array of tuples, where each tuple represents a key-value pair. + * @returns {Map} A new Map object created from the input array. + * If the conversion fails, an empty Map is returned. + * + * @example + * const array = [['key1', 'value1'], ['key2', 'value2']]; + * const map = tryNewMap(array); + * // map is now a Map object with entries: { 'key1' => 'value1', 'key2' => 'value2' } + */ +export function tryNewMap(arr: [unknown, unknown][]) { + try { + return new Map(arr) + } catch (error) { + return new Map() + } +} diff --git a/packages/core/src/amazonq/webview/generators/webViewContent.ts b/packages/core/src/amazonq/webview/generators/webViewContent.ts index 47cb59a9f2e..494938b5911 100644 --- a/packages/core/src/amazonq/webview/generators/webViewContent.ts +++ b/packages/core/src/amazonq/webview/generators/webViewContent.ts @@ -6,7 +6,7 @@ import path from 'path' import { Uri, Webview } from 'vscode' import { AuthUtil } from '../../../codewhisperer/util/authUtil' -import { globals } from '../../../shared' +import { FeatureConfigProvider, FeatureContext, globals } from '../../../shared' export class WebViewContentGenerator { public async generate(extensionURI: Uri, webView: Webview): Promise { @@ -45,6 +45,15 @@ export class WebViewContentGenerator { Uri.joinPath(globals.context.extensionUri, 'resources', 'css', 'amazonq-webview.css') ) + let featureConfigs = new Map() + try { + await FeatureConfigProvider.instance.fetchFeatureConfigs() + featureConfigs = FeatureConfigProvider.getFeatureConfigs() + } catch (error) { + // eslint-disable-next-line aws-toolkits/no-console-log + console.error('Error fetching feature configs:', error) + } + return ` @@ -52,7 +61,7 @@ export class WebViewContentGenerator { const init = () => { createMynahUI(acquireVsCodeApi(), ${ (await AuthUtil.instance.getChatAuthState()).amazonQ === 'connected' - }); + },${JSON.stringify(Array.from(featureConfigs.entries()))}); } ` diff --git a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts index 0d48a127d81..09963fb77ab 100644 --- a/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/cwChatConnector.ts @@ -18,7 +18,7 @@ interface ChatPayload { export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void - onChatAnswerReceived?: (tabID: string, message: CWCChatItem) => void + onChatAnswerReceived?: (tabID: string, message: CWCChatItem, messageData: any) => void onCWCContextCommandMessage: (message: CWCChatItem, command?: string) => string | undefined onError: (tabID: string, message: string, title: string) => void onWarning: (tabID: string, message: string, title: string) => void @@ -308,7 +308,7 @@ export class Connector { content: messageData.relatedSuggestions, } } - this.onChatAnswerReceived(messageData.tabID, answer) + this.onChatAnswerReceived(messageData.tabID, answer, messageData) // Exit the function if we received an answer from AI if ( @@ -336,7 +336,7 @@ export class Connector { } : undefined, } - this.onChatAnswerReceived(messageData.tabID, answer) + this.onChatAnswerReceived(messageData.tabID, answer, messageData) return } @@ -347,13 +347,17 @@ export class Connector { return } - this.onChatAnswerReceived(messageData.tabID, { - type: ChatItemType.ANSWER, - messageId: messageData.triggerID, - body: messageData.message, - followUp: this.followUpGenerator.generateAuthFollowUps('cwc', messageData.authType), - canBeVoted: false, - }) + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.ANSWER, + messageId: messageData.triggerID, + body: messageData.message, + followUp: this.followUpGenerator.generateAuthFollowUps('cwc', messageData.authType), + canBeVoted: false, + }, + messageData + ) return } diff --git a/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts index 4a31fe337c4..3e013566c91 100644 --- a/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/featureDevChatConnector.ts @@ -19,7 +19,7 @@ export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string) => void - onChatAnswerReceived?: (tabID: string, message: ChatItem) => void + onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void sendFeedback?: (tabId: string, feedbackPayload: FeedbackPayload) => void | undefined onError: (tabID: string, message: string, title: string) => void onWarning: (tabID: string, message: string, title: string) => void @@ -154,7 +154,7 @@ export class Connector { } : undefined, } - this.onChatAnswerReceived(messageData.tabID, answer) + this.onChatAnswerReceived(messageData.tabID, answer, messageData) } } @@ -181,7 +181,7 @@ export class Connector { }, body: '', } - this.onChatAnswerReceived(messageData.tabID, answer) + this.onChatAnswerReceived(messageData.tabID, answer, messageData) } } @@ -190,19 +190,27 @@ export class Connector { return } - this.onChatAnswerReceived(messageData.tabID, { - type: ChatItemType.ANSWER, - body: messageData.message, - followUp: undefined, - canBeVoted: false, - }) - - this.onChatAnswerReceived(messageData.tabID, { - type: ChatItemType.SYSTEM_PROMPT, - body: undefined, - followUp: this.followUpGenerator.generateAuthFollowUps('featuredev', messageData.authType), - canBeVoted: false, - }) + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.ANSWER, + body: messageData.message, + followUp: undefined, + canBeVoted: false, + }, + messageData + ) + + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.SYSTEM_PROMPT, + body: undefined, + followUp: this.followUpGenerator.generateAuthFollowUps('featuredev', messageData.authType), + canBeVoted: false, + }, + messageData + ) return } diff --git a/packages/core/src/amazonq/webview/ui/apps/gumbyChatConnector.ts b/packages/core/src/amazonq/webview/ui/apps/gumbyChatConnector.ts index 533a20da332..b12df0a6ef1 100644 --- a/packages/core/src/amazonq/webview/ui/apps/gumbyChatConnector.ts +++ b/packages/core/src/amazonq/webview/ui/apps/gumbyChatConnector.ts @@ -17,7 +17,7 @@ export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string, messageId: string) => void - onChatAnswerReceived?: (tabID: string, message: ChatItem) => void + onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void onQuickHandlerCommand: (tabID: string, command: string, eventId?: string) => void onError: (tabID: string, message: string, title: string) => void @@ -90,7 +90,7 @@ export class Connector { canBeVoted: false, } - this.onChatAnswerReceived(tabID, answer) + this.onChatAnswerReceived(tabID, answer, messageData) return } @@ -114,7 +114,7 @@ export class Connector { return } - this.onChatAnswerReceived(messageData.tabID, answer) + this.onChatAnswerReceived(messageData.tabID, answer, messageData) } } @@ -143,10 +143,14 @@ export class Connector { return } - this.onChatAnswerReceived(messageData.tabID, { - type: ChatItemType.SYSTEM_PROMPT, - body: messageData.message, - }) + this.onChatAnswerReceived( + messageData.tabID, + { + type: ChatItemType.SYSTEM_PROMPT, + body: messageData.message, + }, + messageData + ) } onCustomFormAction( diff --git a/packages/core/src/amazonq/webview/ui/commands.ts b/packages/core/src/amazonq/webview/ui/commands.ts index 6b803611d63..d502cba861d 100644 --- a/packages/core/src/amazonq/webview/ui/commands.ts +++ b/packages/core/src/amazonq/webview/ui/commands.ts @@ -16,6 +16,8 @@ type MessageCommand = | 'open-diff' | 'code_was_copied_to_clipboard' | 'insert_code_at_cursor_position' + | 'accept_diff' + | 'view_diff' | 'stop-response' | 'trigger-tabID-received' | 'clear' diff --git a/packages/core/src/amazonq/webview/ui/connector.ts b/packages/core/src/amazonq/webview/ui/connector.ts index 05970ce3e94..3d743c04f31 100644 --- a/packages/core/src/amazonq/webview/ui/connector.ts +++ b/packages/core/src/amazonq/webview/ui/connector.ts @@ -3,7 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { ChatItem, FeedbackPayload, Engagement, ChatItemAction } from '@aws/mynah-ui' +import { + ChatItem, + FeedbackPayload, + Engagement, + ChatItemAction, + CodeSelectionType, + ReferenceTrackerInformation, +} from '@aws/mynah-ui' import { Connector as CWChatConnector } from './apps/cwChatConnector' import { Connector as FeatureDevChatConnector } from './apps/featureDevChatConnector' import { Connector as AmazonQCommonsConnector } from './apps/amazonqCommonsConnector' @@ -50,7 +57,7 @@ export interface ConnectorProps { sendMessageToExtension: (message: ExtensionMessage) => void onMessageReceived?: (tabID: string, messageData: any, needToShowAPIDocsTab: boolean) => void onChatAnswerUpdated?: (tabID: string, message: ChatItem) => void - onChatAnswerReceived?: (tabID: string, message: ChatItem) => void + onChatAnswerReceived?: (tabID: string, message: ChatItem, messageData: any) => void onWelcomeFollowUpClicked: (tabID: string, welcomeFollowUpType: WelcomeFollowupType) => void onAsyncEventProgress: (tabID: string, inProgress: boolean, message: string | undefined) => void onQuickHandlerCommand: (tabID: string, command?: string, eventId?: string) => void @@ -187,6 +194,9 @@ export class Connector { } else if (messageData.sender === 'gumbyChat') { await this.gumbyChatConnector.handleMessageReceive(messageData) } + + // Reset lastCommand after message is rendered. + this.tabsStorage.updateTabLastCommand(messageData.tabID, '') } onTabAdd = (tabID: string): void => { @@ -254,6 +264,64 @@ export class Connector { } } + onAcceptDiff = ( + tabId: string, + messageId: string, + actionId: string, + data?: string, + code?: string, + type?: CodeSelectionType, + referenceTrackerInformation?: ReferenceTrackerInformation[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number + ) => { + const tabType = this.tabsStorage.getTab(tabId)?.type + this.sendMessageToExtension({ + tabType, + tabID: tabId, + command: 'accept_diff', + messageId, + actionId, + data, + code, + type, + referenceTrackerInformation, + eventId, + codeBlockIndex, + totalCodeBlocks, + }) + } + + onViewDiff = ( + tabId: string, + messageId: string, + actionId: string, + data?: string, + code?: string, + type?: CodeSelectionType, + referenceTrackerInformation?: ReferenceTrackerInformation[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number + ) => { + const tabType = this.tabsStorage.getTab(tabId)?.type + this.sendMessageToExtension({ + tabType, + tabID: tabId, + command: 'view_diff', + messageId, + actionId, + data, + code, + type, + referenceTrackerInformation, + eventId, + codeBlockIndex, + totalCodeBlocks, + }) + } + onCopyCodeToClipboard = ( tabID: string, messageId: string, diff --git a/packages/core/src/amazonq/webview/ui/main.ts b/packages/core/src/amazonq/webview/ui/main.ts index 09064d5faf8..aa5ad615d44 100644 --- a/packages/core/src/amazonq/webview/ui/main.ts +++ b/packages/core/src/amazonq/webview/ui/main.ts @@ -3,7 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ import { Connector, CWCChatItem } from './connector' -import { ChatItem, ChatItemType, MynahIcons, MynahUI, MynahUIDataModel, NotificationType } from '@aws/mynah-ui' +import { + ChatItem, + ChatItemType, + CodeSelectionType, + MynahIcons, + MynahUI, + MynahUIDataModel, + NotificationType, + ReferenceTrackerInformation, +} from '@aws/mynah-ui' import { ChatPrompt } from '@aws/mynah-ui/dist/static' import { TabsStorage, TabType } from './storages/tabsStorage' import { WelcomeFollowupType } from './apps/amazonqCommonsConnector' @@ -16,8 +25,14 @@ import { TextMessageHandler } from './messages/handler' import { MessageController } from './messages/controller' import { getActions, getDetails } from './diffTree/actions' import { DiffTreeFileInfo } from './diffTree/types' +import { FeatureContext } from '../../../shared' +import { tryNewMap } from '../../util/functionUtils' -export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { +export const createMynahUI = ( + ideApi: any, + amazonQEnabled: boolean, + featureConfigsSerialized: [string, FeatureContext][] +) => { // eslint-disable-next-line prefer-const let mynahUI: MynahUI // eslint-disable-next-line prefer-const @@ -73,6 +88,23 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { // eslint-disable-next-line prefer-const let messageController: MessageController + let featureConfigs: Map = tryNewMap(featureConfigsSerialized) + + function shouldDisplayDiff(messageData: any) { + const isEnabled = featureConfigs.get('ViewDiffInChat')?.variation === 'TREATMENT' + const tab = tabsStorage.getTab(messageData?.tabID || '') + const allowedCommands = [ + 'aws.amazonq.refactorCode', + 'aws.amazonq.fixCode', + 'aws.amazonq.optimizeCode', + 'aws.amazonq.sendToPrompt', + ] + if (isEnabled && tab?.type === 'cwc' && allowedCommands.includes(tab.lastCommand || '')) { + return true + } + return false + } + // eslint-disable-next-line prefer-const connector = new Connector({ tabsStorage, @@ -92,6 +124,9 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { isFeatureDevEnabled, isGumbyEnabled, }) + + featureConfigs = tryNewMap(featureConfigsSerialized) + // Set the new defaults for the quick action commands in all tabs now that isFeatureDevEnabled was enabled/disabled for (const tab of tabsStorage.getTabs()) { mynahUI.updateStore(tab.id, { @@ -117,6 +152,7 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { }, onFileActionClick: (tabID: string, messageId: string, filePath: string, actionName: string): void => {}, onQuickHandlerCommand: (tabID: string, command?: string, eventId?: string) => { + tabsStorage.updateTabLastCommand(tabID, command) if (command === 'aws.awsq.transform') { quickActionHandler.handle({ command: '/transform' }, tabID, eventId) } else if (command === 'aws.awsq.clearchat') { @@ -124,10 +160,13 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { } }, onCWCContextCommandMessage: (message: ChatItem, command?: string): string | undefined => { + const selectedTab = tabsStorage.getSelectedTab() + tabsStorage.updateTabLastCommand(selectedTab?.id || '', command || '') + if (command === 'aws.amazonq.sendToPrompt') { - return messageController.sendSelectedCodeToTab(message) + return messageController.sendSelectedCodeToTab(message, command) } else { - const tabID = messageController.sendMessageToTab(message, 'cwc') + const tabID = messageController.sendMessageToTab(message, 'cwc', command) if (tabID) { ideApi.postMessage({ command: 'start-chat-message-telemetry', @@ -201,7 +240,7 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { } as ChatItem) } }, - onChatAnswerReceived: (tabID: string, item: CWCChatItem) => { + onChatAnswerReceived: (tabID: string, item: CWCChatItem, messageData: any) => { if (item.type === ChatItemType.ANSWER_PART || item.type === ChatItemType.CODE_RESULT) { mynahUI.updateLastChatAnswer(tabID, { ...(item.messageId !== undefined ? { messageId: item.messageId } : {}), @@ -232,7 +271,29 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { item.formItems !== undefined || item.buttons !== undefined ) { - mynahUI.addChatItem(tabID, item) + mynahUI.addChatItem(tabID, { + ...item, + messageId: item.messageId, + codeBlockActions: { + ...(shouldDisplayDiff(messageData) + ? { + 'insert-to-cursor': undefined, + accept_diff: { + id: 'accept_diff', + label: 'Apply Diff', + icon: MynahIcons.OK_CIRCLED, + data: messageData, + }, + view_diff: { + id: 'view_diff', + label: 'View Diff', + icon: MynahIcons.EYE, + data: messageData, + }, + } + : {}), + }, + }) } if ( @@ -359,6 +420,7 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { connector.onUpdateTabType(newTabID) mynahUI.updateStore(newTabID, tabDataGenerator.getTabData(tabType, true)) + featureConfigs = tryNewMap(featureConfigsSerialized) }, onOpenSettingsMessage(tabId: string) { mynahUI.addChatItem(tabId, { @@ -456,6 +518,51 @@ export const createMynahUI = (ideApi: any, amazonQEnabled: boolean) => { messageUserIntentMap.get(messageId) ?? undefined ) }, + onCodeBlockActionClicked: ( + tabId: string, + messageId: string, + actionId: string, + data?: string, + code?: string, + type?: CodeSelectionType, + referenceTrackerInformation?: ReferenceTrackerInformation[], + eventId?: string, + codeBlockIndex?: number, + totalCodeBlocks?: number + ) => { + switch (actionId) { + case 'accept_diff': + connector.onAcceptDiff( + tabId, + messageId, + actionId, + data, + code, + type, + referenceTrackerInformation, + eventId, + codeBlockIndex, + totalCodeBlocks + ) + break + case 'view_diff': + connector.onViewDiff( + tabId, + messageId, + actionId, + data, + code, + type, + referenceTrackerInformation, + eventId, + codeBlockIndex, + totalCodeBlocks + ) + break + default: + break + } + }, onCopyCodeToClipboard: ( tabId, messageId, diff --git a/packages/core/src/amazonq/webview/ui/messages/controller.ts b/packages/core/src/amazonq/webview/ui/messages/controller.ts index a63034c637a..26665a4f7d1 100644 --- a/packages/core/src/amazonq/webview/ui/messages/controller.ts +++ b/packages/core/src/amazonq/webview/ui/messages/controller.ts @@ -33,7 +33,7 @@ export class MessageController { }) } - public sendSelectedCodeToTab(message: ChatItem): string | undefined { + public sendSelectedCodeToTab(message: ChatItem, command: string = ''): string | undefined { const selectedTab = { ...this.tabsStorage.getSelectedTab() } if (selectedTab?.id === undefined || selectedTab?.type === 'featuredev') { // Create a new tab if there's none @@ -53,6 +53,7 @@ export class MessageController { type: 'cwc', status: 'free', isSelected: true, + lastCommand: command, }) selectedTab.id = newTabID } @@ -61,7 +62,7 @@ export class MessageController { return selectedTab.id } - public sendMessageToTab(message: ChatItem, tabType: TabType): string | undefined { + public sendMessageToTab(message: ChatItem, tabType: TabType, command: string = ''): string | undefined { const selectedTab = this.tabsStorage.getSelectedTab() if ( @@ -71,6 +72,7 @@ export class MessageController { ) { this.tabsStorage.updateTabStatus(selectedTab.id, 'busy') this.tabsStorage.updateTabTypeFromUnknown(selectedTab.id, tabType) + this.tabsStorage.updateTabLastCommand(selectedTab.id, command) this.mynahUI.updateStore(selectedTab.id, { loadingChat: true, @@ -96,6 +98,7 @@ export class MessageController { }) return undefined } else { + this.tabsStorage.updateTabLastCommand(newTabID, command) this.mynahUI.addChatItem(newTabID, message) this.mynahUI.addChatItem(newTabID, { type: ChatItemType.ANSWER_STREAM, @@ -114,6 +117,7 @@ export class MessageController { status: 'busy', isSelected: true, openInteractionType: 'contextMenu', + lastCommand: command, }) this.tabsStorage.updateTabTypeFromUnknown(newTabID, 'cwc') diff --git a/packages/core/src/amazonq/webview/ui/messages/handler.ts b/packages/core/src/amazonq/webview/ui/messages/handler.ts index eeec2e65745..e77006cea9e 100644 --- a/packages/core/src/amazonq/webview/ui/messages/handler.ts +++ b/packages/core/src/amazonq/webview/ui/messages/handler.ts @@ -25,6 +25,7 @@ export class TextMessageHandler { } public handle(chatPrompt: ChatPrompt, tabID: string, eventID: string) { + this.tabsStorage.updateTabLastCommand(tabID, chatPrompt.command) this.tabsStorage.updateTabTypeFromUnknown(tabID, 'cwc') this.tabsStorage.resetTabTimer(tabID) this.connector.onUpdateTabType(tabID) diff --git a/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts b/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts index c858169315b..564dd8a7a69 100644 --- a/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts +++ b/packages/core/src/amazonq/webview/ui/storages/tabsStorage.ts @@ -14,6 +14,7 @@ export interface Tab { type: TabType isSelected: boolean openInteractionType?: TabOpenType + lastCommand?: string } export class TabsStorage { @@ -62,6 +63,18 @@ export class TabsStorage { return this.tabs.get(tabID)?.status === 'dead' } + public updateTabLastCommand(tabID: string, command?: string) { + if (command === undefined) { + return + } + const currentTabValue = this.tabs.get(tabID) + if (currentTabValue === undefined || currentTabValue.status === 'dead') { + return + } + currentTabValue.lastCommand = command + this.tabs.set(tabID, currentTabValue) + } + public updateTabStatus(tabID: string, tabStatus: TabStatus) { const currentTabValue = this.tabs.get(tabID) if (currentTabValue === undefined || currentTabValue.status === 'dead') { diff --git a/packages/core/src/codewhispererChat/app.ts b/packages/core/src/codewhispererChat/app.ts index f65352ecc10..6781cde30e5 100644 --- a/packages/core/src/codewhispererChat/app.ts +++ b/packages/core/src/codewhispererChat/app.ts @@ -10,6 +10,7 @@ import { AmazonQAppInitContext } from '../amazonq/apps/initContext' import { MessageListener } from '../amazonq/messages/messageListener' import { MessagePublisher } from '../amazonq/messages/messagePublisher' import { + ViewDiff, ChatItemFeedbackMessage, ChatItemVotedMessage, CopyCodeToClipboard, @@ -24,6 +25,7 @@ import { TabCreatedMessage, TriggerTabIDReceived, UIFocusMessage, + AcceptDiff, } from './controllers/chat/model' import { EditorContextCommand, registerCommands } from './commands/registerCommands' @@ -34,6 +36,8 @@ export function init(appContext: AmazonQAppInitContext) { processTabClosedMessage: new EventEmitter(), processTabChangedMessage: new EventEmitter(), processInsertCodeAtCursorPosition: new EventEmitter(), + processAcceptDiff: new EventEmitter(), + processViewDiff: new EventEmitter(), processCopyCodeToClipboard: new EventEmitter(), processContextMenuCommand: new EventEmitter(), processTriggerTabIDReceived: new EventEmitter(), @@ -62,6 +66,8 @@ export function init(appContext: AmazonQAppInitContext) { processInsertCodeAtCursorPosition: new MessageListener( cwChatControllerEventEmitters.processInsertCodeAtCursorPosition ), + processAcceptDiff: new MessageListener(cwChatControllerEventEmitters.processAcceptDiff), + processViewDiff: new MessageListener(cwChatControllerEventEmitters.processViewDiff), processCopyCodeToClipboard: new MessageListener( cwChatControllerEventEmitters.processCopyCodeToClipboard ), @@ -108,6 +114,8 @@ export function init(appContext: AmazonQAppInitContext) { processInsertCodeAtCursorPosition: new MessagePublisher( cwChatControllerEventEmitters.processInsertCodeAtCursorPosition ), + processAcceptDiff: new MessagePublisher(cwChatControllerEventEmitters.processAcceptDiff), + processViewDiff: new MessagePublisher(cwChatControllerEventEmitters.processViewDiff), processCopyCodeToClipboard: new MessagePublisher( cwChatControllerEventEmitters.processCopyCodeToClipboard ), diff --git a/packages/core/src/codewhispererChat/controllers/chat/controller.ts b/packages/core/src/codewhispererChat/controllers/chat/controller.ts index e4680c02571..39eadd7e689 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/controller.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/controller.ts @@ -24,6 +24,8 @@ import { ResponseBodyLinkClickMessage, ChatPromptCommandType, FooterInfoLinkClick, + ViewDiff, + AcceptDiff, } from './model' import { AppToWebViewMessageDispatcher } from '../../view/connector/connector' import { MessagePublisher } from '../../../amazonq/messages/messagePublisher' @@ -59,6 +61,8 @@ export interface ChatControllerMessagePublishers { readonly processTabClosedMessage: MessagePublisher readonly processTabChangedMessage: MessagePublisher readonly processInsertCodeAtCursorPosition: MessagePublisher + readonly processAcceptDiff: MessagePublisher + readonly processViewDiff: MessagePublisher readonly processCopyCodeToClipboard: MessagePublisher readonly processContextMenuCommand: MessagePublisher readonly processTriggerTabIDReceived: MessagePublisher @@ -77,6 +81,8 @@ export interface ChatControllerMessageListeners { readonly processTabClosedMessage: MessageListener readonly processTabChangedMessage: MessageListener readonly processInsertCodeAtCursorPosition: MessageListener + readonly processAcceptDiff: MessageListener + readonly processViewDiff: MessageListener readonly processCopyCodeToClipboard: MessageListener readonly processContextMenuCommand: MessageListener readonly processTriggerTabIDReceived: MessageListener @@ -159,6 +165,14 @@ export class ChatController { return this.processInsertCodeAtCursorPosition(data) }) + this.chatControllerMessageListeners.processAcceptDiff.onMessage((data) => { + return this.processAcceptDiff(data) + }) + + this.chatControllerMessageListeners.processViewDiff.onMessage((data) => { + return this.processViewDiff(data) + }) + this.chatControllerMessageListeners.processCopyCodeToClipboard.onMessage((data) => { return this.processCopyCodeToClipboard(data) }) @@ -278,6 +292,30 @@ export class ChatController { this.telemetryHelper.recordInteractWithMessage(message) } + private async processAcceptDiff(message: AcceptDiff) { + const context = this.triggerEventsStorage.getTriggerEvent((message.data as any)?.triggerID) || '' + this.editorContentController + .acceptDiff({ ...message, ...context }) + .then(() => { + this.telemetryHelper.recordInteractWithMessage(message) + }) + .catch((error) => { + this.telemetryHelper.recordInteractWithMessage(message, { result: 'Failed' }) + }) + } + + private async processViewDiff(message: ViewDiff) { + const context = this.triggerEventsStorage.getTriggerEvent((message.data as any)?.triggerID) || '' + this.editorContentController + .viewDiff({ ...message, ...context }) + .then(() => { + this.telemetryHelper.recordInteractWithMessage(message) + }) + .catch((error) => { + this.telemetryHelper.recordInteractWithMessage(message, { result: 'Failed' }) + }) + } + private async processCopyCodeToClipboard(message: CopyCodeToClipboard) { this.telemetryHelper.recordInteractWithMessage(message) } diff --git a/packages/core/src/codewhispererChat/controllers/chat/model.ts b/packages/core/src/codewhispererChat/controllers/chat/model.ts index 94e7d0d10cc..d2f7e4ca627 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/model.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/model.ts @@ -61,6 +61,31 @@ export interface CopyCodeToClipboard { totalCodeBlocks: number } +export interface AcceptDiff { + command: string | undefined + tabID: string // rename tabId + messageId: string + actionId: string + data: string + code: string + referenceTrackerInformation?: CodeReference[] + eventId: string + codeBlockIndex?: number + totalCodeBlocks?: number +} +export interface ViewDiff { + command: string | undefined + tabID: string // rename tabId + messageId: string + actionId: string + data: string + code: string + referenceTrackerInformation?: CodeReference[] + eventId: string + codeBlockIndex?: number + totalCodeBlocks?: number +} + export type ChatPromptCommandType = | 'help' | 'clear' diff --git a/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts b/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts index a86d8c0ff68..e5b49a6d59c 100644 --- a/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts +++ b/packages/core/src/codewhispererChat/controllers/chat/telemetryHelper.ts @@ -15,6 +15,8 @@ import { } from '../../../shared/telemetry/telemetry' import { ChatSessionStorage } from '../../storages/chatSession' import { + AcceptDiff, + ViewDiff, ChatItemFeedbackMessage, ChatItemVotedMessage, CopyCodeToClipboard, @@ -171,6 +173,7 @@ export class CWCTelemetryHelper { public recordInteractWithMessage( message: + | AcceptDiff | InsertCodeAtCursorPosition | CopyCodeToClipboard | PromptMessage @@ -178,6 +181,8 @@ export class CWCTelemetryHelper { | SourceLinkClickMessage | ResponseBodyLinkClickMessage | FooterInfoLinkClick + | ViewDiff, + { result }: { result: Result } = { result: 'Succeeded' } ) { const conversationId = this.getConversationId(message.tabID) let event: AmazonqInteractWithMessage | undefined @@ -185,7 +190,7 @@ export class CWCTelemetryHelper { case 'insert_code_at_cursor_position': message = message as InsertCodeAtCursorPosition event = { - result: 'Succeeded', + result, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, cwsprChatMessageId: message.messageId, @@ -203,7 +208,7 @@ export class CWCTelemetryHelper { case 'code_was_copied_to_clipboard': message = message as CopyCodeToClipboard event = { - result: 'Succeeded', + result, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, cwsprChatMessageId: message.messageId, @@ -217,10 +222,42 @@ export class CWCTelemetryHelper { cwsprChatHasProjectContext: this.responseWithProjectContext.get(message.messageId), } break + case 'accept_diff': + message = message as AcceptDiff + event = { + result, + cwsprChatConversationId: conversationId ?? '', + cwsprChatMessageId: message.messageId, + cwsprChatInteractionType: 'acceptDiff', + credentialStartUrl: AuthUtil.instance.startUrl, + cwsprChatAcceptedCharactersLength: message.code.length, + cwsprChatHasReference: + message.referenceTrackerInformation && message.referenceTrackerInformation.length > 0, + cwsprChatCodeBlockIndex: message.codeBlockIndex, + cwsprChatTotalCodeBlocks: message.totalCodeBlocks, + cwsprChatHasProjectContext: this.responseWithProjectContext.get(message.messageId), + } + break + case 'view_diff': + message = message as ViewDiff + event = { + result, + cwsprChatConversationId: conversationId ?? '', + cwsprChatMessageId: message.messageId, + cwsprChatInteractionType: 'viewDiff', + credentialStartUrl: AuthUtil.instance.startUrl, + cwsprChatAcceptedCharactersLength: message.code.length, + cwsprChatHasReference: + message.referenceTrackerInformation && message.referenceTrackerInformation.length > 0, + cwsprChatCodeBlockIndex: message.codeBlockIndex, + cwsprChatTotalCodeBlocks: message.totalCodeBlocks, + cwsprChatHasProjectContext: this.responseWithProjectContext.get(message.messageId), + } + break case 'follow-up-was-clicked': message = message as PromptMessage event = { - result: 'Succeeded', + result, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, cwsprChatMessageId: message.messageId, @@ -231,7 +268,7 @@ export class CWCTelemetryHelper { case 'chat-item-voted': message = message as ChatItemVotedMessage event = { - result: 'Succeeded', + result, cwsprChatMessageId: message.messageId, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, @@ -242,7 +279,7 @@ export class CWCTelemetryHelper { case 'source-link-click': message = message as SourceLinkClickMessage event = { - result: 'Succeeded', + result, cwsprChatMessageId: message.messageId, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, @@ -254,7 +291,7 @@ export class CWCTelemetryHelper { case 'response-body-link-click': message = message as ResponseBodyLinkClickMessage event = { - result: 'Succeeded', + result, cwsprChatMessageId: message.messageId, cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, @@ -266,7 +303,7 @@ export class CWCTelemetryHelper { case 'footer-info-link-click': message = message as FooterInfoLinkClick event = { - result: 'Succeeded', + result, cwsprChatMessageId: 'footer', cwsprChatConversationId: conversationId ?? '', credentialStartUrl: AuthUtil.instance.startUrl, diff --git a/packages/core/src/codewhispererChat/view/messages/messageListener.ts b/packages/core/src/codewhispererChat/view/messages/messageListener.ts index d9214f43365..abe37c019fd 100644 --- a/packages/core/src/codewhispererChat/view/messages/messageListener.ts +++ b/packages/core/src/codewhispererChat/view/messages/messageListener.ts @@ -63,6 +63,12 @@ export class UIMessageListener { }) } break + case 'accept_diff': + this.processAcceptDiff(msg) + break + case 'view_diff': + this.processViewDiff(msg) + break case 'code_was_copied_to_clipboard': this.processCodeWasCopiedToClipboard(msg) break @@ -162,6 +168,22 @@ export class UIMessageListener { }) } + private processAcceptDiff(msg: any) { + this.chatControllerMessagePublishers.processAcceptDiff.publish({ + command: msg.command, + tabID: msg.tabID || msg.tabId, + ...msg, + }) + } + + private processViewDiff(msg: any) { + this.chatControllerMessagePublishers.processViewDiff.publish({ + command: msg.command, + tabID: msg.tabID || msg.tabId, + ...msg, + }) + } + private processCodeWasCopiedToClipboard(msg: any) { this.chatControllerMessagePublishers.processCopyCodeToClipboard.publish({ command: msg.command, diff --git a/packages/core/src/shared/constants.ts b/packages/core/src/shared/constants.ts index 6d6cf842552..98e61d220f0 100644 --- a/packages/core/src/shared/constants.ts +++ b/packages/core/src/shared/constants.ts @@ -129,6 +129,7 @@ export const ecsIamPermissionsUrl = vscode.Uri.parse( */ export const CLOUDWATCH_LOGS_SCHEME = 'aws-cwl' // eslint-disable-line @typescript-eslint/naming-convention export const AWS_SCHEME = 'aws' // eslint-disable-line @typescript-eslint/naming-convention +export const amazonQDiffScheme = 'amazon-q-diff' export const lambdaPackageTypeImage = 'Image' @@ -168,3 +169,5 @@ export const crashMonitoringDirNames = { running: 'running', shutdown: 'shutdown', } as const + +export const amazonQTabSuffix = '(Generated by Amazon Q)' diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index 30b07a55314..6e4845dabee 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -218,7 +218,24 @@ export class ToolkitError extends Error implements ErrorInformation { } /** - * Creates a new {@link ToolkitError} instance that was directly caused by another {@link error}. + * Creates a new {@link ToolkitError} instance that was directly caused by another error. + * + * @param error - The original error that caused this error. + * @param message - A descriptive message for the new error. + * @param info - Additional information about the error. + * @returns {ToolkitError} The new ToolkitError instance. + * + * @recommendation It is recommended to throw the returned ToolkitError instance instead of just returning it. + * This way, the error can be properly propagated and handled in the calling code. + * + * Example: + * ```typescript + * try { + * // Some code that might throw an error + * } catch (error) { + * throw ToolkitError.chain(error, 'An error occurred during operation', { operation: 'someOperation' }); + * } + * ``` */ public static chain(error: unknown, message: string, info?: Omit): ToolkitError { return new this(message, { diff --git a/packages/core/src/shared/index.ts b/packages/core/src/shared/index.ts index e60d18f84ff..3ae076bf53e 100644 --- a/packages/core/src/shared/index.ts +++ b/packages/core/src/shared/index.ts @@ -53,3 +53,5 @@ export * as funcUtil from './utilities/functionUtils' export { fs } from './fs/fs' export * from './handleUninstall' export { CrashMonitoring } from './crashMonitoring' +export { amazonQDiffScheme } from './constants' +export * from './featureConfig' diff --git a/packages/core/src/shared/telemetry/vscodeTelemetry.json b/packages/core/src/shared/telemetry/vscodeTelemetry.json index a1dea2d6b03..1a6f82b977a 100644 --- a/packages/core/src/shared/telemetry/vscodeTelemetry.json +++ b/packages/core/src/shared/telemetry/vscodeTelemetry.json @@ -202,6 +202,7 @@ { "name": "cwsprChatInteractionType", "allowedValues": [ + "acceptDiff", "insertAtCursor", "copySnippet", "copy", @@ -210,7 +211,8 @@ "hoverReference", "upvote", "downvote", - "clickBodyLink" + "clickBodyLink", + "viewDiff" ], "type": "string", "description": "Indicates the specific interaction type with a message in a conversation" diff --git a/packages/core/src/shared/utilities/editorUtilities.ts b/packages/core/src/shared/utilities/editorUtilities.ts index 4fedcd1164d..2528dda1a76 100644 --- a/packages/core/src/shared/utilities/editorUtilities.ts +++ b/packages/core/src/shared/utilities/editorUtilities.ts @@ -60,3 +60,21 @@ export async function getOpenFilesInWindow( return filesOpenedInEditor } } + +/** + * Disposes of resources (content provider) when the temporary diff editor is closed. + * + * @param {vscode.Uri} tempFileUri - The URI of the temporary file used for diff comparison. + * @param {vscode.Disposable} disposable - The disposable resource to be cleaned up (e.g., content provider). + */ +export function disposeOnEditorClose(tempFileUri: vscode.Uri, disposable: vscode.Disposable) { + vscode.window.onDidChangeVisibleTextEditors(() => { + if ( + !vscode.window.visibleTextEditors.some( + (editor) => editor.document.uri.toString() === tempFileUri.toString() + ) + ) { + disposable.dispose() + } + }) +} diff --git a/packages/core/src/shared/utilities/textDocumentUtilities.ts b/packages/core/src/shared/utilities/textDocumentUtilities.ts index e34eed33e0e..4d5805ef639 100644 --- a/packages/core/src/shared/utilities/textDocumentUtilities.ts +++ b/packages/core/src/shared/utilities/textDocumentUtilities.ts @@ -6,6 +6,9 @@ import * as _path from 'path' import * as vscode from 'vscode' import { getTabSizeSetting } from './editorUtilities' +import { tempDirPath } from '../filesystemUtilities' +import { fs, getIndentedCode, ToolkitError } from '../index' +import { getLogger } from '../logger' /** * Finds occurences of text in a document. Currently only used for highlighting cloudwatchlogs data. @@ -76,3 +79,89 @@ export function getTabSize(editor?: vscode.TextEditor): number { return getTabSizeSetting() } } + +/** + * Creates a selection range from the given document and selection. + * If a user selects a partial code, this function generates the range from start line to end line. + * + * @param {vscode.TextDocument} doc - The VSCode document where the selection is applied. + * @param {vscode.Selection} selection - The selection range in the document. + * @returns {vscode.Range} - The VSCode range object representing the start and end of the selection. + */ +export function getSelectionFromRange(doc: vscode.TextDocument, selection: vscode.Selection) { + return new vscode.Range( + new vscode.Position(selection.start.line, 0), + new vscode.Position(selection.end.line, doc.lineAt(selection.end.line).range.end.character) + ) +} + +/** + * Applies the given code to the specified range in the document. + * Saves the document after the edit is successfully applied. + * + * @param {vscode.TextDocument} doc - The VSCode document to which the changes are applied. + * @param {vscode.Range} range - The range in the document where the code is replaced. + * @param {string} code - The code to be applied to the document. + * @returns {Promise} - Resolves when the changes are successfully applied and the document is saved. + */ +export async function applyChanges(doc: vscode.TextDocument, range: vscode.Range, code: string) { + const edit = new vscode.WorkspaceEdit() + edit.replace(doc.uri, range, code) + const successfulEdit = await vscode.workspace.applyEdit(edit) + if (successfulEdit) { + getLogger().debug('Diff: Edits successfully applied to: %s', doc.uri.fsPath) + await doc.save() + } else { + getLogger().error('Diff: Unable to apply changes to: %s', doc.uri.fsPath) + } +} + +/** + * Creates a temporary file for diff comparison by cloning the original file + * and applying the proposed changes within the selected range. + * + * @param {vscode.Uri} originalFileUri - The URI of the original file. + * @param {any} message - The message object containing the proposed code changes. + * @param {vscode.Selection} selection - The selection range in the document where the changes are applied. + * @returns {Promise} - A promise that resolves to the URI of the temporary file. + */ +export async function createTempFileForDiff( + originalFileUri: vscode.Uri, + message: any, + selection: vscode.Selection, + scheme: string +): Promise { + const errorCode = 'createTempFile' + const id = Date.now() + const languageId = (await vscode.workspace.openTextDocument(originalFileUri)).languageId + const tempFile = _path.parse(originalFileUri.path) + const tempFilePath = _path.join(tempDirPath, `${tempFile.name}_proposed-${id}${tempFile.ext}`) + await fs.mkdir(tempDirPath) + const tempFileUri = vscode.Uri.parse(`${scheme}:${tempFilePath}`) + getLogger().debug('Diff: Creating temp file: %s', tempFileUri.fsPath) + + try { + // Write original content to temp file + await fs.writeFile(tempFilePath, await fs.readFileText(originalFileUri.fsPath)) + } catch (error) { + if (!(error instanceof Error)) { + throw error + } + throw ToolkitError.chain(error, 'Failed to write to temp file', { code: errorCode }) + } + + // Apply the proposed changes to the temp file + const doc = await vscode.workspace.openTextDocument(tempFileUri.path) + const languageIdStatus = await vscode.languages.setTextDocumentLanguage(doc, languageId) + if (languageIdStatus) { + getLogger().debug('Diff: languageId for %s is set to: %s', tempFileUri.fsPath, languageId) + } else { + getLogger().error('Diff: Unable to set languageId for %s to: %s', tempFileUri.fsPath, languageId) + } + + const code = getIndentedCode(message, doc, selection) + const range = getSelectionFromRange(doc, selection) + + await applyChanges(doc, range, code) + return tempFileUri +} diff --git a/packages/core/src/shared/utilities/textUtilities.ts b/packages/core/src/shared/utilities/textUtilities.ts index 92e2342d97c..d0af2e6de52 100644 --- a/packages/core/src/shared/utilities/textUtilities.ts +++ b/packages/core/src/shared/utilities/textUtilities.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as vscode from 'vscode' import * as crypto from 'crypto' import * as fs from 'fs' // eslint-disable-line no-restricted-imports import { default as stripAnsi } from 'strip-ansi' @@ -30,11 +31,20 @@ export function truncate(s: string, n: number, suffix?: string): string { } /** - * Indents a string. + * Indents a given string with spaces. * - * @param size Indent width (number of space chars). - * @param clear Clear existing whitespace, if any. - * @param s Text to indent. + * @param {string} s - The input string to be indented. + * @param {number} [size=4] - The number of spaces to use for indentation. Defaults to 4. + * @param {boolean} [clear=false] - If true, the function will clear any existing indentation and apply the new indentation. + * @returns {string} The indented string. + * + * @example + * const indentedString = indent('Hello\nWorld', 2); + * console.log(indentedString); // Output: " Hello\n World" + * + * @example + * const indentedString = indent(' Hello\n World', 4, true); + * console.log(indentedString); // Output: " Hello\n World" */ export function indent(s: string, size: number = 4, clear: boolean = false): string { const n = Math.abs(size) @@ -395,3 +405,34 @@ export function undefinedIfEmpty(str: string | undefined): string | undefined { return undefined } + +/** + * Extracts the file path and selection context from the message. + * + * @param {any} message - The message object containing the file and selection context. + * @returns {Object} - An object with `filePath` and `selection` properties. + */ +export function extractFileAndCodeSelectionFromMessage(message: any) { + const filePath = message?.context?.activeFileContext?.filePath + const selection = message?.context?.focusAreaContext?.selectionInsideExtendedCodeBlock as vscode.Selection + return { filePath, selection } +} + +/** + * Indents the given code based on the current document's indentation at the selection start. + * + * @param {any} message - The message object containing the code. + * @param {vscode.TextDocument} doc - The VSCode document where the code is applied. + * @param {vscode.Selection} selection - The selection range in the document. + * @returns {string} - The processed code to be applied to the document. + */ +export function getIndentedCode(message: any, doc: vscode.TextDocument, selection: vscode.Selection) { + const indentRange = new vscode.Range(new vscode.Position(selection.start.line, 0), selection.active) + let indentation = doc.getText(indentRange) + + if (indentation.trim().length !== 0) { + indentation = ' '.repeat(indentation.length - indentation.trimStart().length) + } + + return indent(message.code, indentation.length) +}