diff --git a/.changes/next-release/bugfix-d5bbb805-aa36-4e2b-9bc2-7377c184c716.json b/.changes/next-release/bugfix-d5bbb805-aa36-4e2b-9bc2-7377c184c716.json new file mode 100644 index 00000000000..6bfec087f3b --- /dev/null +++ b/.changes/next-release/bugfix-d5bbb805-aa36-4e2b-9bc2-7377c184c716.json @@ -0,0 +1,4 @@ +{ + "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/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 f7db0ffc6b7..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 @@ -356,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 -> { 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 fe5f70bec8d..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 @@ -21,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 @@ -62,6 +64,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowO 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 @@ -567,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/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/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) + } +}