Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ObjectNode>(enrichmentParams))
val enrichedNode = (node as ObjectNode).apply {
set<JsonNode>("params", insertToCursorPositionParams)
}

handleChat(AmazonQChatServer.insertToCursorPosition, enrichedNode)
}

CHAT_LINK_CLICK -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -567,6 +570,20 @@ class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageC
)
}

override fun applyEdit(params: ApplyWorkspaceEditParams): CompletableFuture<ApplyWorkspaceEditResponse> =
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Copy link
Contributor

@rli rli Jul 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

follow up later with tests for at least this method

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<TextEdit>) {
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<LspEditorUtil>()
}
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +43 to +45
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

take a look at CodeInsightTestFixture

project = mockk(relaxed = true)

mockkStatic(WriteCommandAction::class)
every { WriteCommandAction.runWriteCommandAction(any(), any<Runnable>()) } answers {
secondArg<Runnable>().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<Document>(relaxed = true)
val file = mockk<VirtualFile>()
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<Document>(relaxed = true)
val file = mockk<VirtualFile>()
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<Document>(relaxed = true)
val file = mockk<VirtualFile>()
val editor = mockk<Editor>()
val textEditor = mockk<TextEditor>()

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<LogicalPosition>()
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<Document>(relaxed = true)
val document2 = mockk<Document>(relaxed = true)
val file1 = mockk<VirtualFile>()
val file2 = mockk<VirtualFile>()

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<Document>(relaxed = true)
val file = mockk<VirtualFile>()

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)
}
}
Loading