diff --git a/.changes/3.85.json b/.changes/3.85.json new file mode 100644 index 00000000000..cfd626f89be --- /dev/null +++ b/.changes/3.85.json @@ -0,0 +1,11 @@ +{ + "date" : "2025-07-10", + "version" : "3.85", + "entries" : [ { + "type" : "feature", + "description" : "Amazon Q /test, /doc, and /dev capabilities integrated into Agentic coding." + }, { + "type" : "feature", + "description" : "Add image context support" + } ] +} \ No newline at end of file diff --git a/.changes/3.86.json b/.changes/3.86.json new file mode 100644 index 00000000000..4aa38bd6f6a --- /dev/null +++ b/.changes/3.86.json @@ -0,0 +1,8 @@ +{ + "date" : "2025-07-16", + "version" : "3.86", + "entries" : [ { + "type" : "bugfix", + "description" : "- Fixed \"Insert to Cursor\" button to correctly insert code blocks at the current cursor position in the active file" + } ] +} \ No newline at end of file diff --git a/.changes/next-release/bugfix-846e7004-5426-4f6b-970f-2b9437314b98.json b/.changes/next-release/bugfix-846e7004-5426-4f6b-970f-2b9437314b98.json new file mode 100644 index 00000000000..4c4a9e6db41 --- /dev/null +++ b/.changes/next-release/bugfix-846e7004-5426-4f6b-970f-2b9437314b98.json @@ -0,0 +1,4 @@ +{ + "type" : "bugfix", + "description" : "Suppress IDE error when current editor context is not valid for Amazon Q" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e6b3c15c1d..4b55a99cb96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +# _3.86_ (2025-07-16) +- **(Bug Fix)** - Fixed "Insert to Cursor" button to correctly insert code blocks at the current cursor position in the active file + +# _3.85_ (2025-07-10) +- **(Feature)** Amazon Q /test, /doc, and /dev capabilities integrated into Agentic coding. +- **(Feature)** Add image context support + # _3.84_ (2025-07-09) # _3.83_ (2025-07-07) diff --git a/gradle.properties b/gradle.properties index 5ee9f0210c1..271db5bf91b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # Toolkit Version -toolkitVersion=3.85-SNAPSHOT +toolkitVersion=3.87-SNAPSHOT # Publish Settings publishToken= diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQPanel.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQPanel.kt index 028dca4e1d8..082bfc37b21 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQPanel.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/toolwindow/AmazonQPanel.kt @@ -3,6 +3,7 @@ package software.aws.toolkits.jetbrains.services.amazonq.toolwindow +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.intellij.openapi.Disposable import com.intellij.openapi.components.service import com.intellij.openapi.project.Project @@ -20,6 +21,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import software.aws.toolkits.core.utils.error +import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.jetbrains.core.coroutines.EDT import software.aws.toolkits.jetbrains.isDeveloperMode import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext @@ -45,7 +48,12 @@ import software.aws.toolkits.jetbrains.services.amazonqFeatureDev.auth.isFeature import software.aws.toolkits.jetbrains.services.codemodernizer.utils.isCodeTransformAvailable import software.aws.toolkits.jetbrains.utils.isRunningOnRemoteBackend import software.aws.toolkits.resources.message +import java.awt.datatransfer.DataFlavor +import java.awt.dnd.DropTarget +import java.awt.dnd.DropTargetDropEvent +import java.io.File import java.util.concurrent.CompletableFuture +import javax.imageio.ImageIO.read import javax.swing.JButton class AmazonQPanel(val project: Project, private val scope: CoroutineScope) : Disposable { @@ -130,12 +138,76 @@ class AmazonQPanel(val project: Project, private val scope: CoroutineScope) : Di withContext(EDT) { browser.complete( - Browser(this@AmazonQPanel, mynahAsset, project).also { - wrapper.setContent(it.component()) + Browser(this@AmazonQPanel, mynahAsset, project).also { browserInstance -> + wrapper.setContent(browserInstance.component()) + + // Add DropTarget to the browser component + // JCEF does not propagate OS-level dragenter, dragOver and drop into DOM. + // As an alternative, enabling the native drag in JCEF, + // and let the native handling the drop event, and update the UI through JS bridge. + val dropTarget = object : DropTarget() { + override fun drop(dtde: DropTargetDropEvent) { + try { + dtde.acceptDrop(dtde.dropAction) + val transferable = dtde.transferable + if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { + val fileList = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List<*> + + val errorMessages = mutableListOf() + val validImages = mutableListOf() + val allowedTypes = setOf("jpg", "jpeg", "png", "gif", "webp") + val maxFileSize = 3.75 * 1024 * 1024 // 3.75MB in bytes + val maxDimension = 8000 + + for (file in fileList as List) { + val validationResult = validateImageFile(file, allowedTypes, maxFileSize, maxDimension) + if (validationResult != null) { + errorMessages.add(validationResult) + } else { + validImages.add(file) + } + } + + // File count restriction + if (validImages.size > 20) { + errorMessages.add("A maximum of 20 images can be added to a single message.") + validImages.subList(20, validImages.size).clear() + } + + val json = OBJECT_MAPPER.writeValueAsString(validImages) + browserInstance.jcefBrowser.cefBrowser.executeJavaScript( + "window.handleNativeDrop('$json')", + browserInstance.jcefBrowser.cefBrowser.url, + 0 + ) + + val errorJson = OBJECT_MAPPER.writeValueAsString(errorMessages) + browserInstance.jcefBrowser.cefBrowser.executeJavaScript( + "window.handleNativeNotify('$errorJson')", + browserInstance.jcefBrowser.cefBrowser.url, + 0 + ) + dtde.dropComplete(true) + } else { + dtde.dropComplete(false) + } + } catch (e: Exception) { + LOG.error { "Failed to handle file drop operation: ${e.message}" } + dtde.dropComplete(false) + } + } + } + + // Set DropTarget on the browser component and its children + browserInstance.component()?.let { component -> + component.dropTarget = dropTarget + // Also try setting on parent if needed + component.parent?.dropTarget = dropTarget + } initConnections() - connectUi(it) - connectApps(it) + connectUi(browserInstance) + connectApps(browserInstance) loadingPanel.stopLoading() } @@ -219,6 +291,36 @@ class AmazonQPanel(val project: Project, private val scope: CoroutineScope) : Di } } + private fun validateImageFile(file: File, allowedTypes: Set, maxFileSize: Double, maxDimension: Int): String? { + val fileName = file.name + val ext = fileName.substringAfterLast('.', "").lowercase() + + if (ext !in allowedTypes) { + return "$fileName: File must be an image in JPEG, PNG, GIF, or WebP format." + } + + if (file.length() > maxFileSize) { + return "$fileName: Image must be no more than 3.75MB in size." + } + + return try { + val img = read(file) + when { + img == null -> "$fileName: File could not be read as an image." + img.width > maxDimension -> "$fileName: Image must be no more than 8,000px in width." + img.height > maxDimension -> "$fileName: Image must be no more than 8,000px in height." + else -> null + } + } catch (e: Exception) { + "$fileName: File could not be read as an image." + } + } + + companion object { + private val LOG = getLogger() + private val OBJECT_MAPPER = jacksonObjectMapper() + } + override fun dispose() { } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt index 424d6d6da5b..d90d49138fa 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/Browser.kt @@ -89,7 +89,9 @@ class Browser(parent: Disposable, private val mynahAsset: Path, val project: Pro // setup empty state. The message request handlers use this for storing state // that's persistent between page loads. jcefBrowser.setProperty("state", "") - + jcefBrowser.jbCefClient.addDragHandler({ browser, dragData, mask -> + true // Allow drag operations + }, jcefBrowser.cefBrowser) // load the web app jcefBrowser.loadURL( assetRequestHandler.createResource( @@ -130,7 +132,7 @@ class Browser(parent: Disposable, private val mynahAsset: Path, val project: Pro """.trimIndent() diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt index 915a3a23343..6241eefedf3 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt @@ -31,6 +31,7 @@ import org.eclipse.lsp4j.jsonrpc.ResponseErrorException import org.eclipse.lsp4j.jsonrpc.messages.ResponseErrorCode import software.aws.toolkits.core.utils.error import software.aws.toolkits.core.utils.getLogger +import software.aws.toolkits.core.utils.info import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.services.amazonq.apps.AppConnection import software.aws.toolkits.jetbrains.services.amazonq.commands.MessageSerializer @@ -74,9 +75,12 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.Encry import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.EncryptedQuickActionChatParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GET_SERIALIZED_CHAT_REQUEST_METHOD import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GetSerializedChatResponse +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.LIST_AVAILABLE_MODELS import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.LIST_MCP_SERVERS_REQUEST_METHOD import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.LIST_RULES_REQUEST_METHOD import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.MCP_SERVER_CLICK_REQUEST_METHOD +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.OPEN_FILE_DIALOG +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.OPEN_FILE_DIALOG_REQUEST_METHOD import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.OPEN_SETTINGS import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.OPEN_WORKSPACE_SETTINGS_KEY import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.OpenSettingsNotification @@ -301,6 +305,7 @@ class BrowserConnector( } CHAT_READY -> { + LOG.info { "Amazon Q Chat UI loaded and ready for input" } handleChat(AmazonQChatServer.chatReady, node) { params, invoke -> uiReady.complete(true) chatCommunicationManager.setUiReady() @@ -351,7 +356,22 @@ class BrowserConnector( } CHAT_INSERT_TO_CURSOR -> { - handleChat(AmazonQChatServer.insertToCursorPosition, node) + val editor = FileEditorManager.getInstance(project).selectedTextEditor + val textDocumentIdentifier = editor?.let { TextDocumentIdentifier(toUriString(it.virtualFile)) } + val cursorPosition = editor?.let { LspEditorUtil.getCursorPosition(it) } + + val enrichmentParams = mapOf( + "textDocument" to textDocumentIdentifier, + "cursorPosition" to cursorPosition, + ) + + val insertToCursorPositionParams: ObjectNode = (node.params as ObjectNode) + .setAll(serializer.objectMapper.valueToTree(enrichmentParams)) + val enrichedNode = (node as ObjectNode).apply { + set("params", insertToCursorPositionParams) + } + + handleChat(AmazonQChatServer.insertToCursorPosition, enrichedNode) } CHAT_LINK_CLICK -> { @@ -489,6 +509,19 @@ class BrowserConnector( ) } } + + OPEN_FILE_DIALOG -> { + handleChat(AmazonQChatServer.showOpenFileDialog, node) + .whenComplete { response, _ -> + browser.postChat( + FlareUiMessage( + command = OPEN_FILE_DIALOG_REQUEST_METHOD, + params = response + ) + ) + } + } + LIST_RULES_REQUEST_METHOD -> { handleChat(AmazonQChatServer.listRules, node) .whenComplete { response, _ -> @@ -517,6 +550,17 @@ class BrowserConnector( CHAT_PINNED_CONTEXT_REMOVE -> { handleChat(AmazonQChatServer.pinnedContextRemove, node) } + LIST_AVAILABLE_MODELS -> { + handleChat(AmazonQChatServer.listAvailableModels, node) + .whenComplete { response, _ -> + browser.postChat( + FlareUiMessage( + command = LIST_AVAILABLE_MODELS, + params = response + ) + ) + } + } } } diff --git a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/commands/ActionRegistrar.kt b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/commands/ActionRegistrar.kt index e5e92f7544b..fe93c71f505 100644 --- a/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/commands/ActionRegistrar.kt +++ b/plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/cwc/commands/ActionRegistrar.kt @@ -3,7 +3,6 @@ @file:Suppress("BannedImports") package software.aws.toolkits.jetbrains.services.cwc.commands -import com.google.gson.Gson import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import kotlinx.coroutines.flow.MutableSharedFlow @@ -17,7 +16,6 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.SEND_ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.SendToPromptParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.TriggerType import software.aws.toolkits.jetbrains.services.amazonq.messages.AmazonQMessage -import software.aws.toolkits.jetbrains.services.amazonqCodeTest.controller.TestCommandMessage import software.aws.toolkits.jetbrains.services.cwc.editor.context.ActiveFileContextExtractor import software.aws.toolkits.jetbrains.services.cwc.editor.context.ExtractionTriggerType @@ -28,25 +26,21 @@ class ActionRegistrar { val flow = _messages.asSharedFlow() fun reportMessageClick(command: EditorContextCommand, project: Project) { - if (command == EditorContextCommand.GenerateUnitTests) { - AsyncChatUiListener.notifyPartialMessageUpdate(project, Gson().toJson(TestCommandMessage())) - } else { - // new agentic chat route - ApplicationManager.getApplication().executeOnPooledThread { - runBlocking { - val contextExtractor = ActiveFileContextExtractor.create(fqnWebviewAdapter = null, project = project) - val fileContext = contextExtractor.extractContextForTrigger(ExtractionTriggerType.ContextMenu) - val codeSelection = "\n```\n${fileContext.focusAreaContext?.codeSelection?.trimIndent()?.trim()}\n```\n" - var uiMessage: FlareUiMessage? = null - if (command.verb != SEND_TO_PROMPT) { - val params = GenericCommandParams(selection = codeSelection, triggerType = TriggerType.CONTEXT_MENU, genericCommand = command.name) - uiMessage = FlareUiMessage(command = GENERIC_COMMAND, params = params) - } else { - val params = SendToPromptParams(selection = codeSelection, triggerType = TriggerType.CONTEXT_MENU) - uiMessage = FlareUiMessage(command = SEND_TO_PROMPT, params = params) - } - AsyncChatUiListener.notifyPartialMessageUpdate(project, uiMessage) + // new agentic chat route + ApplicationManager.getApplication().executeOnPooledThread { + runBlocking { + val contextExtractor = ActiveFileContextExtractor.create(fqnWebviewAdapter = null, project = project) + val fileContext = contextExtractor.extractContextForTrigger(ExtractionTriggerType.ContextMenu) + val codeSelection = "\n```\n${fileContext.focusAreaContext?.codeSelection?.trimIndent()?.trim()}\n```\n" + var uiMessage: FlareUiMessage? = null + if (command.verb != SEND_TO_PROMPT) { + val params = GenericCommandParams(selection = codeSelection, triggerType = TriggerType.CONTEXT_MENU, genericCommand = command.name) + uiMessage = FlareUiMessage(command = GENERIC_COMMAND, params = params) + } else { + val params = SendToPromptParams(selection = codeSelection, triggerType = TriggerType.CONTEXT_MENU) + uiMessage = FlareUiMessage(command = SEND_TO_PROMPT, params = params) } + AsyncChatUiListener.notifyPartialMessageUpdate(project, uiMessage) } } } diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/generator.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/generator.ts index 1033f29a8e1..35af48f8973 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/generator.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/generator.ts @@ -38,7 +38,6 @@ export class QuickActionGenerator { const quickActionCommands = [ { - groupName: `Q Developer Agent for Software Development`, commands: [ ...(this.isFeatureDevEnabled ? [ @@ -78,11 +77,6 @@ export class QuickActionGenerator { }, ] : []), - ], - }, - { - groupName: `Q Developer Agent for Code Transformation`, - commands:[ ...(this.isCodeTransformEnabled ? [ { @@ -93,22 +87,7 @@ export class QuickActionGenerator { ] : []), ], - }, - { - groupName: 'Quick Actions', - commands: [ - { - command: '/help', - icon: MynahIcons.HELP, - description: 'Learn more about Amazon Q', - }, - { - command: '/clear', - icon: MynahIcons.TRASH, - description: 'Clear this session', - }, - ], - }, + } ].filter((section) => section.commands.length > 0) const commandUnavailability: Record< diff --git a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/handler.ts b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/handler.ts index b700b38ff6d..d8bb2048801 100644 --- a/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/handler.ts +++ b/plugins/amazonq/mynah-ui/src/mynah-ui/ui/quickActions/handler.ts @@ -344,14 +344,7 @@ private handleDocCommand(chatPrompt: ChatPrompt, tabID: string, taskName: string this.connector.startTestGen(testTabId, realPromptText) return } - /** - * right click -> generate test has no tab id - * we have to manually create one if a testgen tab - * wasn't previously created - */ - if (!tabID) { - tabID = this.mynahUI?.updateStore('', {}) - } + const affectedTabId: string | undefined = this.addTab(tabID) // if there is no test tab, open a new one diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQChatServer.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQChatServer.kt index b2c8854cf6d..5df7e06ded1 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQChatServer.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQChatServer.kt @@ -40,11 +40,15 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GetSe import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GetSerializedChatResult import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.InfoLinkClickParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.InsertToCursorPositionParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.LIST_AVAILABLE_MODELS import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.LIST_MCP_SERVERS_REQUEST_METHOD import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.LIST_RULES_REQUEST_METHOD import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.LinkClickParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ListAvailableModelsParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ListAvailableModelsResult import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ListConversationsParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.MCP_SERVER_CLICK_REQUEST_METHOD +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.OPEN_FILE_DIALOG_REQUEST_METHOD import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.PROMPT_INPUT_OPTIONS_CHANGE import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.PromptInputOptionChangeParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.RULE_CLICK_REQUEST_METHOD @@ -248,4 +252,16 @@ object AmazonQChatServer : JsonRpcMethodProvider { TELEMETRY_EVENT, Any::class.java ) + + val showOpenFileDialog = JsonRpcRequest( + OPEN_FILE_DIALOG_REQUEST_METHOD, + Any::class.java, + LSPAny::class.java + ) + + val listAvailableModels = JsonRpcRequest( + LIST_AVAILABLE_MODELS, + ListAvailableModelsParams::class.java, + ListAvailableModelsResult::class.java + ) } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt index 82f7a296327..e52d1e33296 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClient.kt @@ -25,7 +25,9 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GET_S import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GetSerializedChatResult import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.OPEN_FILE_DIFF import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.OpenFileDiffParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.SHOW_OPEN_FILE_DIALOG_REQUEST_METHOD import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.SHOW_SAVE_FILE_DIALOG_REQUEST_METHOD +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowOpenFileDialogParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowSaveFileDialogParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowSaveFileDialogResult import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata @@ -45,6 +47,9 @@ interface AmazonQLanguageClient : LanguageClient { @JsonRequest(SHOW_SAVE_FILE_DIALOG_REQUEST_METHOD) fun showSaveFileDialog(params: ShowSaveFileDialogParams): CompletableFuture + @JsonRequest(SHOW_OPEN_FILE_DIALOG_REQUEST_METHOD) + fun showOpenFileDialog(params: ShowOpenFileDialogParams): CompletableFuture + @JsonRequest(GET_SERIALIZED_CHAT_REQUEST_METHOD) fun getSerializedChat(params: LSPAny): CompletableFuture diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt index 31eaa4d86b4..4b105142484 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt @@ -10,6 +10,8 @@ import com.intellij.ide.BrowserUtil import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationType import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileChooser.FileChooser +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory import com.intellij.openapi.fileChooser.FileChooserFactory import com.intellij.openapi.fileChooser.FileSaverDescriptor import com.intellij.openapi.fileEditor.FileEditorManager @@ -19,6 +21,8 @@ import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFileManager import migration.software.aws.toolkits.jetbrains.settings.AwsSettings +import org.eclipse.lsp4j.ApplyWorkspaceEditParams +import org.eclipse.lsp4j.ApplyWorkspaceEditResponse import org.eclipse.lsp4j.ConfigurationParams import org.eclipse.lsp4j.MessageActionItem import org.eclipse.lsp4j.MessageParams @@ -56,9 +60,11 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.FileP import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GET_SERIALIZED_CHAT_REQUEST_METHOD import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.GetSerializedChatResult import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.OpenFileDiffParams +import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowOpenFileDialogParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowSaveFileDialogParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowSaveFileDialogResult import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata +import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.TelemetryParsingUtil import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService @@ -255,6 +261,76 @@ class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageC ) } + override fun showOpenFileDialog(params: ShowOpenFileDialogParams): CompletableFuture = + CompletableFuture.supplyAsync( + { + // Handle the case where both canSelectFiles and canSelectFolders are false (should never be sent from flare) + if (!params.canSelectFiles && !params.canSelectFolders) { + return@supplyAsync mapOf("uris" to emptyList()) as LSPAny + } + + val descriptor = when { + params.canSelectFolders && params.canSelectFiles -> { + if (params.canSelectMany) { + FileChooserDescriptorFactory.createAllButJarContentsDescriptor() + } else { + FileChooserDescriptorFactory.createSingleFileOrFolderDescriptor() + } + } + params.canSelectFolders -> { + if (params.canSelectMany) { + FileChooserDescriptorFactory.createMultipleFoldersDescriptor() + } else { + FileChooserDescriptorFactory.createSingleFolderDescriptor() + } + } + else -> { + if (params.canSelectMany) { + FileChooserDescriptorFactory.createMultipleFilesNoJarsDescriptor() + } else { + FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor() + } + } + }.apply { + withTitle( + params.title ?: when { + params.canSelectFolders && params.canSelectFiles -> "Select Files or Folders" + params.canSelectFolders -> "Select Folders" + else -> "Select Files" + } + ) + withDescription( + when { + params.canSelectFolders && params.canSelectFiles -> "Choose files or folders to open" + params.canSelectFolders -> "Choose folders to open" + else -> "Choose files to open" + } + ) + + // Apply file filters if provided + if (params.filters.isNotEmpty() && !params.canSelectFolders) { + // Create a combined list of all allowed extensions + val allowedExtensions = params.filters.values.flatten().toSet() + + withFileFilter { virtualFile -> + if (virtualFile.isDirectory) { + true // Always allow directories for navigation + } else { + val extension = virtualFile.extension?.lowercase() + extension != null && allowedExtensions.contains(extension) + } + } + } + } + + val chosenFiles = FileChooser.chooseFiles(descriptor, project, null) + val uris = chosenFiles.map { it.path } + + mapOf("uris" to uris) as LSPAny + }, + ApplicationManager.getApplication()::invokeLater + ) + override fun getSerializedChat(params: LSPAny): CompletableFuture { val requestId = UUID.randomUUID().toString() val result = CompletableFuture() @@ -494,6 +570,20 @@ class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageC ) } + override fun applyEdit(params: ApplyWorkspaceEditParams): CompletableFuture = + CompletableFuture.supplyAsync( + { + try { + LspEditorUtil.applyWorkspaceEdit(project, params.edit) + ApplyWorkspaceEditResponse(true) + } catch (e: Exception) { + LOG.warn(e) { "Failed to apply workspace edit" } + ApplyWorkspaceEditResponse(false) + } + }, + ApplicationManager.getApplication()::invokeLater + ) + private fun refreshVfs(path: String) { val currPath = Paths.get(path) if (currPath.startsWith(localHistoryPath)) return diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt index 9088678aa61..c4a57f2312a 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLspService.kt @@ -73,6 +73,7 @@ import software.aws.toolkits.core.utils.writeText import software.aws.toolkits.jetbrains.core.coroutines.ioDispatcher import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager import software.aws.toolkits.jetbrains.services.amazonq.lsp.auth.DefaultAuthCredentialsService +import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.DefaultModuleDependenciesService import software.aws.toolkits.jetbrains.services.amazonq.lsp.encryption.JwtEncryptionManager import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.AmazonQLspTypeAdapterFactory import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.AwsExtendedInitializeResult @@ -592,9 +593,9 @@ private class AmazonQServerInstance(private val project: Project, private val cs WorkspaceServiceHandler(project, cs, lspInitResult).also { Disposer.register(this, it) } - // DefaultModuleDependenciesService(project, cs).also { - // Disposer.register(this, it) - // } + DefaultModuleDependenciesService(project, cs).also { + Disposer.register(this, it) + } } } } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt index f4f31165d7a..d534345b8c4 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesService.kt @@ -9,6 +9,7 @@ import com.intellij.openapi.project.Project import com.intellij.openapi.roots.ModuleRootEvent import com.intellij.openapi.roots.ModuleRootListener import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.amazonq.lsp.dependencies.ModuleDependencyProvider.Companion.EP_NAME @@ -44,14 +45,34 @@ class DefaultModuleDependenciesService( } private fun syncAllModules() { + val paramsMap = mutableMapOf, DidChangeDependencyPathsParams>() + ModuleManager.getInstance(project).modules.forEach { module -> EP_NAME.forEachExtensionSafe { if (it.isApplicable(module)) { - didChangeDependencyPaths(it.createParams(module)) + val params = it.createParams(module) + val key = params.moduleName to params.runtimeLanguage + + paramsMap.merge(key, params) { existing, new -> + DidChangeDependencyPathsParams( + moduleName = existing.moduleName, + runtimeLanguage = existing.runtimeLanguage, + paths = (existing.paths + new.paths).distinct(), + includePatterns = (existing.includePatterns + new.includePatterns).distinct(), + excludePatterns = (existing.excludePatterns + new.excludePatterns).distinct() + ) + } return@forEachExtensionSafe } } } + + paramsMap.values.chunked(10).forEachIndexed { index, chunk -> + cs.launch { + delay(index * 1000L) + chunk.forEach { didChangeDependencyPaths(it) } + } + } } override fun dispose() { diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/AwsServerCapabilitiesProvider.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/AwsServerCapabilitiesProvider.kt index 4055b8381a5..bae6b41c363 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/AwsServerCapabilitiesProvider.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/AwsServerCapabilitiesProvider.kt @@ -34,7 +34,9 @@ class AwsServerCapabilitiesProvider { ), history = true, export = true, - mcpServers = true + mcpServers = true, + // Seems like this is the only way to bounce reroute back to the LSP? + reroute = true, ) } } @@ -48,6 +50,7 @@ data class ChatOptions( val history: Boolean, val export: Boolean, val mcpServers: Boolean, + val reroute: Boolean, ) data class QuickActions( diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt index a93f7c64297..57f7aef7b45 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/flareChat/ChatCommunicationManager.kt @@ -200,7 +200,7 @@ class ChatCommunicationManager(private val project: Project, private val cs: Cor var errorMessage: String? = null if (exception is ResponseErrorException) { errorMessage = tryOrNull { - Gson().fromJson(exception.responseError.data as JsonObject, ChatMessage::class.java).body + "${exception.responseError.message}: ${Gson().fromJson(exception.responseError.data as JsonObject, ChatMessage::class.java).body}" } ?: exception.responseError.message } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt index 604a80e9215..8168645d829 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/ExtendedClientMetadata.kt @@ -25,6 +25,7 @@ data class DeveloperProfiles( val developerProfiles: Boolean, val mcp: Boolean, val pinnedContextEnabled: Boolean, + val imageContextEnabled: Boolean, val reroute: Boolean, val workspaceFilePath: String?, ) @@ -67,6 +68,7 @@ fun createExtendedClientMetadata(project: Project): ExtendedClientMetadata { developerProfiles = true, mcp = true, pinnedContextEnabled = true, + imageContextEnabled = true, reroute = true, workspaceFilePath = project.workspaceFile?.path, ), diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/FlareChatCommands.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/FlareChatCommands.kt index 4de4c4501c2..9f5b87ae870 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/FlareChatCommands.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/FlareChatCommands.kt @@ -52,12 +52,17 @@ const val PROMPT_INPUT_OPTIONS_CHANGE = "aws/chat/promptInputOptionChange" const val SEND_CHAT_COMMAND_PROMPT = "aws/chat/sendChatPrompt" const val SHOW_SAVE_FILE_DIALOG_REQUEST_METHOD = "aws/showSaveFileDialog" +const val SHOW_OPEN_FILE_DIALOG_REQUEST_METHOD = "aws/showOpenFileDialog" const val STOP_CHAT_RESPONSE = "stopChatResponse" const val SEND_TO_PROMPT = "sendToPrompt" +const val OPEN_FILE_DIALOG = "openFileDialog" const val TELEMETRY_EVENT = "telemetry/event" // https://github.com/aws/language-server-runtimes/blob/112feba70219a98a12f13727d67c540205fa9c9f/types/chat.ts#L32 const val LIST_MCP_SERVERS_REQUEST_METHOD = "aws/chat/listMcpServers" const val MCP_SERVER_CLICK_REQUEST_METHOD = "aws/chat/mcpServerClick" +const val OPEN_FILE_DIALOG_REQUEST_METHOD = "aws/chat/openFileDialog" const val LIST_RULES_REQUEST_METHOD = "aws/chat/listRules" const val RULE_CLICK_REQUEST_METHOD = "aws/chat/ruleClick" + +const val LIST_AVAILABLE_MODELS = "aws/chat/listAvailableModels" diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/ListAvailableModels.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/ListAvailableModels.kt new file mode 100644 index 00000000000..096e0151012 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/ListAvailableModels.kt @@ -0,0 +1,19 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat + +data class Model( + val id: String, + val name: String, +) + +data class ListAvailableModelsParams( + val tabId: String, +) + +data class ListAvailableModelsResult( + val tabId: String, + val models: List, + val selectedModelId: String? = null, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/ShowOpenFileDialogParams.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/ShowOpenFileDialogParams.kt new file mode 100644 index 00000000000..77a9da2e794 --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/model/aws/chat/ShowOpenFileDialogParams.kt @@ -0,0 +1,12 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat + +data class ShowOpenFileDialogParams( + val canSelectFiles: Boolean, + val canSelectFolders: Boolean, + val canSelectMany: Boolean, + val filters: Map>, + val title: String?, +) diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt index d41e7ec0a70..b49f412f987 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandler.kt @@ -33,7 +33,10 @@ import org.eclipse.lsp4j.TextDocumentContentChangeEvent import org.eclipse.lsp4j.TextDocumentIdentifier import org.eclipse.lsp4j.TextDocumentItem import org.eclipse.lsp4j.VersionedTextDocumentIdentifier +import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.tryOrNull +import software.aws.toolkits.core.utils.warn +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLanguageServer import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ACTIVE_EDITOR_CHANGED_NOTIFICATION import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil.getCursorState @@ -98,40 +101,36 @@ class TextDocumentServiceHandler( } } - cs.launch { - AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> - toUriString(file)?.let { uri -> - languageServer.textDocumentService.didOpen( - DidOpenTextDocumentParams().apply { - textDocument = TextDocumentItem().apply { - this.uri = uri - text = file.inputStream.readAllBytes().decodeToString() - languageId = file.fileType.name.lowercase() - version = file.modificationStamp.toInt() - } + trySendIfValid { languageServer -> + toUriString(file)?.let { uri -> + languageServer.textDocumentService.didOpen( + DidOpenTextDocumentParams().apply { + textDocument = TextDocumentItem().apply { + this.uri = uri + text = file.inputStream.readAllBytes().decodeToString() + languageId = file.fileType.name.lowercase() + version = file.modificationStamp.toInt() } - ) - } + } + ) } } } } override fun beforeDocumentSaving(document: Document) { - cs.launch { - AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> - val file = FileDocumentManager.getInstance().getFile(document) ?: return@executeAsyncIfRunning - toUriString(file)?.let { uri -> - languageServer.textDocumentService.didSave( - DidSaveTextDocumentParams().apply { - textDocument = TextDocumentIdentifier().apply { - this.uri = uri - } - // TODO: should respect `textDocumentSync.save.includeText` server capability config - text = document.text + trySendIfValid { languageServer -> + val file = FileDocumentManager.getInstance().getFile(document) ?: return@trySendIfValid + toUriString(file)?.let { uri -> + languageServer.textDocumentService.didSave( + DidSaveTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri } - ) - } + // TODO: should respect `textDocumentSync.save.includeText` server capability config + text = document.text + } + ) } } } @@ -141,23 +140,21 @@ class TextDocumentServiceHandler( val document = FileDocumentManager.getInstance().getCachedDocument(event.file) ?: return@forEach handleFileOpened(event.file) - cs.launch { - AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> - toUriString(event.file)?.let { uri -> - languageServer.textDocumentService.didChange( - DidChangeTextDocumentParams().apply { - textDocument = VersionedTextDocumentIdentifier().apply { - this.uri = uri - version = document.modificationStamp.toInt() - } - contentChanges = listOf( - TextDocumentContentChangeEvent().apply { - text = document.text - } - ) + trySendIfValid { languageServer -> + toUriString(event.file)?.let { uri -> + languageServer.textDocumentService.didChange( + DidChangeTextDocumentParams().apply { + textDocument = VersionedTextDocumentIdentifier().apply { + this.uri = uri + version = document.modificationStamp.toInt() } - ) - } + contentChanges = listOf( + TextDocumentContentChangeEvent().apply { + text = document.text + } + ) + } + ) } } } @@ -179,17 +176,15 @@ class TextDocumentServiceHandler( tryOrNull { FileDocumentManager.getInstance().getDocument(file)?.removeDocumentListener(listener) } file.putUserData(KEY_REAL_TIME_EDIT_LISTENER, null) - cs.launch { - AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> - toUriString(file)?.let { uri -> - languageServer.textDocumentService.didClose( - DidCloseTextDocumentParams().apply { - textDocument = TextDocumentIdentifier().apply { - this.uri = uri - } + trySendIfValid { languageServer -> + toUriString(file)?.let { uri -> + languageServer.textDocumentService.didClose( + DidCloseTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri } - ) - } + } + ) } } } @@ -221,24 +216,22 @@ class TextDocumentServiceHandler( } private fun realTimeEdit(event: DocumentEvent) { - cs.launch { - AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> - val vFile = FileDocumentManager.getInstance().getFile(event.document) ?: return@executeAsyncIfRunning - toUriString(vFile)?.let { uri -> - languageServer.textDocumentService.didChange( - DidChangeTextDocumentParams().apply { - textDocument = VersionedTextDocumentIdentifier().apply { - this.uri = uri - version = event.document.modificationStamp.toInt() - } - contentChanges = listOf( - TextDocumentContentChangeEvent().apply { - text = event.document.text - } - ) + trySendIfValid { languageServer -> + val vFile = FileDocumentManager.getInstance().getFile(event.document) ?: return@trySendIfValid + toUriString(vFile)?.let { uri -> + languageServer.textDocumentService.didChange( + DidChangeTextDocumentParams().apply { + textDocument = VersionedTextDocumentIdentifier().apply { + this.uri = uri + version = event.document.modificationStamp.toInt() } - ) - } + contentChanges = listOf( + TextDocumentContentChangeEvent().apply { + text = event.document.text + } + ) + } + ) } } // Process document changes here @@ -247,7 +240,20 @@ class TextDocumentServiceHandler( override fun dispose() { } + private fun trySendIfValid(runnable: (AmazonQLanguageServer) -> Unit) { + cs.launch { + AmazonQLspService.executeAsyncIfRunning(project) { languageServer -> + try { + runnable(languageServer) + } catch (e: Exception) { + LOG.warn { "Invalid document: $e" } + } + } + } + } + companion object { private val KEY_REAL_TIME_EDIT_LISTENER = Key.create("amazonq.textdocument.realtimeedit.listener") + private val LOG = getLogger() } } diff --git a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtil.kt b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtil.kt index e7ece95f539..d7b80ebd54d 100644 --- a/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtil.kt +++ b/plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtil.kt @@ -4,11 +4,20 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp.util import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.LogicalPosition +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VfsUtilCore import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager import org.eclipse.lsp4j.Position import org.eclipse.lsp4j.Range +import org.eclipse.lsp4j.TextEdit +import org.eclipse.lsp4j.WorkspaceEdit import software.aws.toolkits.core.utils.getLogger import software.aws.toolkits.core.utils.warn import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CursorPosition @@ -63,13 +72,50 @@ object LspEditorUtil { ) } else { return@runReadAction CursorPosition( - Position( - editor.caretModel.primaryCaret.logicalPosition.line, - editor.caretModel.primaryCaret.logicalPosition.column - ) + getCursorPosition(editor) ) } } + fun getCursorPosition(editor: Editor): Position = + runReadAction { + Position( + editor.caretModel.primaryCaret.logicalPosition.line, + editor.caretModel.primaryCaret.logicalPosition.column + ) + } + + fun applyWorkspaceEdit(project: Project, edit: WorkspaceEdit) { + WriteCommandAction.runWriteCommandAction(project) { + edit.documentChanges?.forEach { change -> + if (change.isLeft) { + val textDocumentEdit = change.left + applyEditsToFile(project, textDocumentEdit.textDocument.uri, textDocumentEdit.edits) + } + } + + edit.changes?.forEach { (uri, textEdits) -> + applyEditsToFile(project, uri, textEdits) + } + } + } + + private fun applyEditsToFile(project: Project, uri: String, textEdits: List) { + val file = VirtualFileManager.getInstance().findFileByUrl(uri) ?: return + val document = FileDocumentManager.getInstance().getDocument(file) ?: return + val editor = FileEditorManager.getInstance(project).getSelectedEditor(file)?.let { + if (it is com.intellij.openapi.fileEditor.TextEditor) it.editor else null + } + + textEdits.forEach { textEdit -> + val startOffset = calculateOffset(editor, document, textEdit.range.start) + val endOffset = calculateOffset(editor, document, textEdit.range.end) + document.replaceString(startOffset, endOffset, textEdit.newText) + } + } + + private fun calculateOffset(editor: Editor?, document: Document, position: Position): Int = + editor?.logicalPositionToOffset(LogicalPosition(position.line, position.character)) ?: (document.getLineStartOffset(position.line) + position.character) + private val LOG = getLogger() } diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt index cce9cbe3e0d..f886b797911 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/dependencies/DefaultModuleDependenciesServiceTest.kt @@ -187,6 +187,56 @@ class DefaultModuleDependenciesServiceTest { verify(exactly = 2) { mockLanguageServer.didChangeDependencyPaths(params) } } + @Test + fun `test deduplication of same moduleName and runtimeLanguage`() = runTest { + // Arrange + val module1 = mockk() + val module2 = mockk() + val params1 = DidChangeDependencyPathsParams( + moduleName = "sameModule", + runtimeLanguage = "java", + paths = listOf("/path/to/dep1.jar"), + includePatterns = listOf("*.java"), + excludePatterns = listOf("test/**") + ) + val params2 = DidChangeDependencyPathsParams( + moduleName = "sameModule", + runtimeLanguage = "java", + paths = listOf("/path/to/dep2.jar"), + includePatterns = listOf("*.class"), + excludePatterns = listOf("build/**") + ) + + every { mockModuleManager.modules } returns arrayOf(module1, module2) + every { mockDependencyProvider.isApplicable(any()) } returns true + every { mockDependencyProvider.createParams(module1) } returns params1 + every { mockDependencyProvider.createParams(module2) } returns params2 + + prepDependencyProvider( + listOf( + Pair(module1, params1), + Pair(module2, params2) + ) + ) + + sut = DefaultModuleDependenciesService(project, this) + + advanceUntilIdle() + + // Verify only one call with merged paths + verify(exactly = 1) { + mockLanguageServer.didChangeDependencyPaths( + match { + it.moduleName == "sameModule" && + it.runtimeLanguage == "java" && + it.paths.containsAll(listOf("/path/to/dep1.jar", "/path/to/dep2.jar")) && + it.includePatterns.containsAll(listOf("*.java", "*.class")) && + it.excludePatterns.containsAll(listOf("test/**", "build/**")) + } + ) + } + } + private fun prepDependencyProvider(moduleParamPairs: List>) { every { mockModuleManager.modules } returns moduleParamPairs.map { it.first }.toTypedArray() diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt index a351db5937c..bde4e2e989e 100644 --- a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/textdocument/TextDocumentServiceHandlerTest.kt @@ -188,6 +188,22 @@ class TextDocumentServiceHandlerTest { } } + @Test + fun `fileOpened suppreses document read failure`() = runTest { + sut = TextDocumentServiceHandler(projectRule.project, this) + advanceUntilIdle() + + val file = mockk { + every { inputStream } throws RuntimeException("read failure") + every { getUserData(any()) } returns null + every { putUserData(any(), any()) } returns Unit + every { isValid } returns false + } + sut.fileOpened(mockk(), file) + + verify(exactly = 0) { mockTextDocumentService.didOpen(any()) } + } + @Test fun `didClose runs on fileClosed`() = runTest { sut = TextDocumentServiceHandler(projectRule.project, this) diff --git a/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/ApplyWorkspaceEditTest.kt b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/ApplyWorkspaceEditTest.kt new file mode 100644 index 00000000000..1de4c0ba0ce --- /dev/null +++ b/plugins/amazonq/shared/jetbrains-community/tst/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/ApplyWorkspaceEditTest.kt @@ -0,0 +1,212 @@ +// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package software.aws.toolkits.jetbrains.services.amazonq.lsp.util + +import com.intellij.openapi.command.WriteCommandAction +import com.intellij.openapi.editor.Document +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.LogicalPosition +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.testFramework.ApplicationExtension +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import io.mockk.verify +import org.eclipse.lsp4j.Position +import org.eclipse.lsp4j.Range +import org.eclipse.lsp4j.TextDocumentEdit +import org.eclipse.lsp4j.TextEdit +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier +import org.eclipse.lsp4j.WorkspaceEdit +import org.eclipse.lsp4j.jsonrpc.messages.Either +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ApplicationExtension::class) +class ApplyWorkspaceEditTest { + + private lateinit var project: Project + private lateinit var virtualFileManager: VirtualFileManager + private lateinit var fileDocumentManager: FileDocumentManager + private lateinit var fileEditorManager: FileEditorManager + + @BeforeEach + fun setUp() { + virtualFileManager = mockk(relaxed = true) + fileDocumentManager = mockk(relaxed = true) + fileEditorManager = mockk(relaxed = true) + project = mockk(relaxed = true) + + mockkStatic(WriteCommandAction::class) + every { WriteCommandAction.runWriteCommandAction(any(), any()) } answers { + secondArg().run() + } + + mockkStatic(VirtualFileManager::getInstance) + every { VirtualFileManager.getInstance() } returns virtualFileManager + + mockkStatic(FileDocumentManager::getInstance) + every { FileDocumentManager.getInstance() } returns fileDocumentManager + + mockkStatic(FileEditorManager::getInstance) + every { FileEditorManager.getInstance(any()) } returns fileEditorManager + } + + @Test + fun `test applyWorkspaceEdit with changes`() { + val uri = "file:///test.kt" + val document = mockk(relaxed = true) + val file = mockk() + val textEdit = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText") + + every { virtualFileManager.findFileByUrl(uri) } returns file + every { fileDocumentManager.getDocument(file) } returns document + every { document.getLineStartOffset(0) } returns 0 + + val workspaceEdit = WorkspaceEdit().apply { + changes = mapOf(uri to listOf(textEdit)) + } + + LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit) + + verify { document.replaceString(0, 5, "newText") } + } + + @Test + fun `test applyWorkspaceEdit with documentChanges`() { + val uri = "file:///test.kt" + val document = mockk(relaxed = true) + val file = mockk() + val textEdit = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText") + + every { virtualFileManager.findFileByUrl(uri) } returns file + every { fileDocumentManager.getDocument(file) } returns document + every { document.getLineStartOffset(0) } returns 0 + + val versionedIdentifier = VersionedTextDocumentIdentifier(uri, 1) + val textDocumentEdit = TextDocumentEdit(versionedIdentifier, listOf(textEdit)) + val workspaceEdit = WorkspaceEdit().apply { + documentChanges = listOf(Either.forLeft(textDocumentEdit)) + } + + LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit) + + verify { document.replaceString(0, 5, "newText") } + } + + @Test + fun `test applyWorkspaceEdit with editor`() { + val uri = "file:///test.kt" + val document = mockk(relaxed = true) + val file = mockk() + val editor = mockk() + val textEditor = mockk() + + every { virtualFileManager.findFileByUrl(uri) } returns file + every { fileDocumentManager.getDocument(file) } returns document + every { fileEditorManager.getSelectedEditor(file) } returns textEditor + every { textEditor.editor } returns editor + + val positionSlot = slot() + every { editor.logicalPositionToOffset(capture(positionSlot)) } answers { + if (positionSlot.captured.line == 0 && positionSlot.captured.column == 0) 0 else 5 + } + + val textEdit = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText") + val workspaceEdit = WorkspaceEdit().apply { + changes = mapOf(uri to listOf(textEdit)) + } + + LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit) + + verify { document.replaceString(0, 5, "newText") } + } + + @Test + fun `test applyWorkspaceEdit with both changes and documentChanges`() { + val uri1 = "file:///test1.kt" + val uri2 = "file:///test2.kt" + val document1 = mockk(relaxed = true) + val document2 = mockk(relaxed = true) + val file1 = mockk() + val file2 = mockk() + + val textEdit1 = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText1") + val textEdit2 = TextEdit(Range(Position(1, 0), Position(1, 10)), "newText2") + + every { virtualFileManager.findFileByUrl(uri1) } returns file1 + every { virtualFileManager.findFileByUrl(uri2) } returns file2 + every { fileDocumentManager.getDocument(file1) } returns document1 + every { fileDocumentManager.getDocument(file2) } returns document2 + every { document1.getLineStartOffset(0) } returns 0 + every { document2.getLineStartOffset(1) } returns 100 + + val versionedIdentifier = VersionedTextDocumentIdentifier(uri1, 1) + val textDocumentEdit = TextDocumentEdit(versionedIdentifier, listOf(textEdit1)) + + val workspaceEdit = WorkspaceEdit().apply { + documentChanges = listOf(Either.forLeft(textDocumentEdit)) + changes = mapOf(uri2 to listOf(textEdit2)) + } + + LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit) + + verify { document1.replaceString(0, 5, "newText1") } + verify { document2.replaceString(100, 110, "newText2") } + } + + @Test + fun `test applyWorkspaceEdit with multiple edits to same file`() { + val uri = "file:///test.kt" + val document = mockk(relaxed = true) + val file = mockk() + + val textEdit1 = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText1") + val textEdit2 = TextEdit(Range(Position(1, 0), Position(1, 10)), "newText2") + + every { virtualFileManager.findFileByUrl(uri) } returns file + every { fileDocumentManager.getDocument(file) } returns document + every { document.getLineStartOffset(0) } returns 0 + every { document.getLineStartOffset(1) } returns 100 + + val workspaceEdit = WorkspaceEdit().apply { + changes = mapOf(uri to listOf(textEdit1, textEdit2)) + } + + LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit) + + verify { document.replaceString(0, 5, "newText1") } + verify { document.replaceString(100, 110, "newText2") } + } + + @Test + fun `test applyWorkspaceEdit with empty edits`() { + val workspaceEdit = WorkspaceEdit() + LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit) + + // No verification needed - just ensuring no exceptions + } + + @Test + fun `test applyWorkspaceEdit with invalid file`() { + val uri = "file:///nonexistent.kt" + val textEdit = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText") + + every { virtualFileManager.findFileByUrl(uri) } returns null + + val workspaceEdit = WorkspaceEdit().apply { + changes = mapOf(uri to listOf(textEdit)) + } + + // Execute - should not throw exception + LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit) + } +}