Skip to content

Commit afaec95

Browse files
fix(amazonq): insert to cursor button in chat applies edit to active file (#5897)
* add missing params to node * implement applyEdit client logic * clean up util functions * changelog * reduce serializer calls * tests for applyWorkspaceEdit * formatting
1 parent b066f7d commit afaec95

File tree

5 files changed

+299
-5
lines changed

5 files changed

+299
-5
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type" : "bugfix",
3+
"description" : "- Fixed \"Insert to Cursor\" button to correctly insert code blocks at the current cursor position in the active file"
4+
}

plugins/amazonq/chat/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/webview/BrowserConnector.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,22 @@ class BrowserConnector(
356356
}
357357

358358
CHAT_INSERT_TO_CURSOR -> {
359-
handleChat(AmazonQChatServer.insertToCursorPosition, node)
359+
val editor = FileEditorManager.getInstance(project).selectedTextEditor
360+
val textDocumentIdentifier = editor?.let { TextDocumentIdentifier(toUriString(it.virtualFile)) }
361+
val cursorPosition = editor?.let { LspEditorUtil.getCursorPosition(it) }
362+
363+
val enrichmentParams = mapOf(
364+
"textDocument" to textDocumentIdentifier,
365+
"cursorPosition" to cursorPosition,
366+
)
367+
368+
val insertToCursorPositionParams: ObjectNode = (node.params as ObjectNode)
369+
.setAll(serializer.objectMapper.valueToTree<ObjectNode>(enrichmentParams))
370+
val enrichedNode = (node as ObjectNode).apply {
371+
set<JsonNode>("params", insertToCursorPositionParams)
372+
}
373+
374+
handleChat(AmazonQChatServer.insertToCursorPosition, enrichedNode)
360375
}
361376

362377
CHAT_LINK_CLICK -> {

plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/AmazonQLanguageClientImpl.kt

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import com.intellij.openapi.vfs.VfsUtil
2121
import com.intellij.openapi.vfs.VfsUtilCore
2222
import com.intellij.openapi.vfs.VirtualFileManager
2323
import migration.software.aws.toolkits.jetbrains.settings.AwsSettings
24+
import org.eclipse.lsp4j.ApplyWorkspaceEditParams
25+
import org.eclipse.lsp4j.ApplyWorkspaceEditResponse
2426
import org.eclipse.lsp4j.ConfigurationParams
2527
import org.eclipse.lsp4j.MessageActionItem
2628
import org.eclipse.lsp4j.MessageParams
@@ -62,6 +64,7 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowO
6264
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowSaveFileDialogParams
6365
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ShowSaveFileDialogResult
6466
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.credentials.ConnectionMetadata
67+
import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.LspEditorUtil
6568
import software.aws.toolkits.jetbrains.services.amazonq.lsp.util.TelemetryParsingUtil
6669
import software.aws.toolkits.jetbrains.services.codewhisperer.customization.CodeWhispererModelConfigurator
6770
import software.aws.toolkits.jetbrains.services.telemetry.TelemetryService
@@ -567,6 +570,20 @@ class AmazonQLanguageClientImpl(private val project: Project) : AmazonQLanguageC
567570
)
568571
}
569572

573+
override fun applyEdit(params: ApplyWorkspaceEditParams): CompletableFuture<ApplyWorkspaceEditResponse> =
574+
CompletableFuture.supplyAsync(
575+
{
576+
try {
577+
LspEditorUtil.applyWorkspaceEdit(project, params.edit)
578+
ApplyWorkspaceEditResponse(true)
579+
} catch (e: Exception) {
580+
LOG.warn(e) { "Failed to apply workspace edit" }
581+
ApplyWorkspaceEditResponse(false)
582+
}
583+
},
584+
ApplicationManager.getApplication()::invokeLater
585+
)
586+
570587
private fun refreshVfs(path: String) {
571588
val currPath = Paths.get(path)
572589
if (currPath.startsWith(localHistoryPath)) return

plugins/amazonq/shared/jetbrains-community/src/software/aws/toolkits/jetbrains/services/amazonq/lsp/util/LspEditorUtil.kt

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,20 @@
44
package software.aws.toolkits.jetbrains.services.amazonq.lsp.util
55

66
import com.intellij.openapi.application.runReadAction
7+
import com.intellij.openapi.command.WriteCommandAction
8+
import com.intellij.openapi.editor.Document
79
import com.intellij.openapi.editor.Editor
10+
import com.intellij.openapi.editor.LogicalPosition
11+
import com.intellij.openapi.fileEditor.FileDocumentManager
12+
import com.intellij.openapi.fileEditor.FileEditorManager
13+
import com.intellij.openapi.project.Project
814
import com.intellij.openapi.vfs.VfsUtilCore
915
import com.intellij.openapi.vfs.VirtualFile
16+
import com.intellij.openapi.vfs.VirtualFileManager
1017
import org.eclipse.lsp4j.Position
1118
import org.eclipse.lsp4j.Range
19+
import org.eclipse.lsp4j.TextEdit
20+
import org.eclipse.lsp4j.WorkspaceEdit
1221
import software.aws.toolkits.core.utils.getLogger
1322
import software.aws.toolkits.core.utils.warn
1423
import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CursorPosition
@@ -63,13 +72,50 @@ object LspEditorUtil {
6372
)
6473
} else {
6574
return@runReadAction CursorPosition(
66-
Position(
67-
editor.caretModel.primaryCaret.logicalPosition.line,
68-
editor.caretModel.primaryCaret.logicalPosition.column
69-
)
75+
getCursorPosition(editor)
7076
)
7177
}
7278
}
7379

80+
fun getCursorPosition(editor: Editor): Position =
81+
runReadAction {
82+
Position(
83+
editor.caretModel.primaryCaret.logicalPosition.line,
84+
editor.caretModel.primaryCaret.logicalPosition.column
85+
)
86+
}
87+
88+
fun applyWorkspaceEdit(project: Project, edit: WorkspaceEdit) {
89+
WriteCommandAction.runWriteCommandAction(project) {
90+
edit.documentChanges?.forEach { change ->
91+
if (change.isLeft) {
92+
val textDocumentEdit = change.left
93+
applyEditsToFile(project, textDocumentEdit.textDocument.uri, textDocumentEdit.edits)
94+
}
95+
}
96+
97+
edit.changes?.forEach { (uri, textEdits) ->
98+
applyEditsToFile(project, uri, textEdits)
99+
}
100+
}
101+
}
102+
103+
private fun applyEditsToFile(project: Project, uri: String, textEdits: List<TextEdit>) {
104+
val file = VirtualFileManager.getInstance().findFileByUrl(uri) ?: return
105+
val document = FileDocumentManager.getInstance().getDocument(file) ?: return
106+
val editor = FileEditorManager.getInstance(project).getSelectedEditor(file)?.let {
107+
if (it is com.intellij.openapi.fileEditor.TextEditor) it.editor else null
108+
}
109+
110+
textEdits.forEach { textEdit ->
111+
val startOffset = calculateOffset(editor, document, textEdit.range.start)
112+
val endOffset = calculateOffset(editor, document, textEdit.range.end)
113+
document.replaceString(startOffset, endOffset, textEdit.newText)
114+
}
115+
}
116+
117+
private fun calculateOffset(editor: Editor?, document: Document, position: Position): Int =
118+
editor?.logicalPositionToOffset(LogicalPosition(position.line, position.character)) ?: (document.getLineStartOffset(position.line) + position.character)
119+
74120
private val LOG = getLogger<LspEditorUtil>()
75121
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package software.aws.toolkits.jetbrains.services.amazonq.lsp.util
5+
6+
import com.intellij.openapi.command.WriteCommandAction
7+
import com.intellij.openapi.editor.Document
8+
import com.intellij.openapi.editor.Editor
9+
import com.intellij.openapi.editor.LogicalPosition
10+
import com.intellij.openapi.fileEditor.FileDocumentManager
11+
import com.intellij.openapi.fileEditor.FileEditorManager
12+
import com.intellij.openapi.fileEditor.TextEditor
13+
import com.intellij.openapi.project.Project
14+
import com.intellij.openapi.vfs.VirtualFile
15+
import com.intellij.openapi.vfs.VirtualFileManager
16+
import com.intellij.testFramework.ApplicationExtension
17+
import io.mockk.every
18+
import io.mockk.mockk
19+
import io.mockk.mockkStatic
20+
import io.mockk.slot
21+
import io.mockk.verify
22+
import org.eclipse.lsp4j.Position
23+
import org.eclipse.lsp4j.Range
24+
import org.eclipse.lsp4j.TextDocumentEdit
25+
import org.eclipse.lsp4j.TextEdit
26+
import org.eclipse.lsp4j.VersionedTextDocumentIdentifier
27+
import org.eclipse.lsp4j.WorkspaceEdit
28+
import org.eclipse.lsp4j.jsonrpc.messages.Either
29+
import org.junit.jupiter.api.BeforeEach
30+
import org.junit.jupiter.api.Test
31+
import org.junit.jupiter.api.extension.ExtendWith
32+
33+
@ExtendWith(ApplicationExtension::class)
34+
class ApplyWorkspaceEditTest {
35+
36+
private lateinit var project: Project
37+
private lateinit var virtualFileManager: VirtualFileManager
38+
private lateinit var fileDocumentManager: FileDocumentManager
39+
private lateinit var fileEditorManager: FileEditorManager
40+
41+
@BeforeEach
42+
fun setUp() {
43+
virtualFileManager = mockk(relaxed = true)
44+
fileDocumentManager = mockk(relaxed = true)
45+
fileEditorManager = mockk(relaxed = true)
46+
project = mockk(relaxed = true)
47+
48+
mockkStatic(WriteCommandAction::class)
49+
every { WriteCommandAction.runWriteCommandAction(any(), any<Runnable>()) } answers {
50+
secondArg<Runnable>().run()
51+
}
52+
53+
mockkStatic(VirtualFileManager::getInstance)
54+
every { VirtualFileManager.getInstance() } returns virtualFileManager
55+
56+
mockkStatic(FileDocumentManager::getInstance)
57+
every { FileDocumentManager.getInstance() } returns fileDocumentManager
58+
59+
mockkStatic(FileEditorManager::getInstance)
60+
every { FileEditorManager.getInstance(any()) } returns fileEditorManager
61+
}
62+
63+
@Test
64+
fun `test applyWorkspaceEdit with changes`() {
65+
val uri = "file:///test.kt"
66+
val document = mockk<Document>(relaxed = true)
67+
val file = mockk<VirtualFile>()
68+
val textEdit = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText")
69+
70+
every { virtualFileManager.findFileByUrl(uri) } returns file
71+
every { fileDocumentManager.getDocument(file) } returns document
72+
every { document.getLineStartOffset(0) } returns 0
73+
74+
val workspaceEdit = WorkspaceEdit().apply {
75+
changes = mapOf(uri to listOf(textEdit))
76+
}
77+
78+
LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit)
79+
80+
verify { document.replaceString(0, 5, "newText") }
81+
}
82+
83+
@Test
84+
fun `test applyWorkspaceEdit with documentChanges`() {
85+
val uri = "file:///test.kt"
86+
val document = mockk<Document>(relaxed = true)
87+
val file = mockk<VirtualFile>()
88+
val textEdit = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText")
89+
90+
every { virtualFileManager.findFileByUrl(uri) } returns file
91+
every { fileDocumentManager.getDocument(file) } returns document
92+
every { document.getLineStartOffset(0) } returns 0
93+
94+
val versionedIdentifier = VersionedTextDocumentIdentifier(uri, 1)
95+
val textDocumentEdit = TextDocumentEdit(versionedIdentifier, listOf(textEdit))
96+
val workspaceEdit = WorkspaceEdit().apply {
97+
documentChanges = listOf(Either.forLeft(textDocumentEdit))
98+
}
99+
100+
LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit)
101+
102+
verify { document.replaceString(0, 5, "newText") }
103+
}
104+
105+
@Test
106+
fun `test applyWorkspaceEdit with editor`() {
107+
val uri = "file:///test.kt"
108+
val document = mockk<Document>(relaxed = true)
109+
val file = mockk<VirtualFile>()
110+
val editor = mockk<Editor>()
111+
val textEditor = mockk<TextEditor>()
112+
113+
every { virtualFileManager.findFileByUrl(uri) } returns file
114+
every { fileDocumentManager.getDocument(file) } returns document
115+
every { fileEditorManager.getSelectedEditor(file) } returns textEditor
116+
every { textEditor.editor } returns editor
117+
118+
val positionSlot = slot<LogicalPosition>()
119+
every { editor.logicalPositionToOffset(capture(positionSlot)) } answers {
120+
if (positionSlot.captured.line == 0 && positionSlot.captured.column == 0) 0 else 5
121+
}
122+
123+
val textEdit = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText")
124+
val workspaceEdit = WorkspaceEdit().apply {
125+
changes = mapOf(uri to listOf(textEdit))
126+
}
127+
128+
LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit)
129+
130+
verify { document.replaceString(0, 5, "newText") }
131+
}
132+
133+
@Test
134+
fun `test applyWorkspaceEdit with both changes and documentChanges`() {
135+
val uri1 = "file:///test1.kt"
136+
val uri2 = "file:///test2.kt"
137+
val document1 = mockk<Document>(relaxed = true)
138+
val document2 = mockk<Document>(relaxed = true)
139+
val file1 = mockk<VirtualFile>()
140+
val file2 = mockk<VirtualFile>()
141+
142+
val textEdit1 = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText1")
143+
val textEdit2 = TextEdit(Range(Position(1, 0), Position(1, 10)), "newText2")
144+
145+
every { virtualFileManager.findFileByUrl(uri1) } returns file1
146+
every { virtualFileManager.findFileByUrl(uri2) } returns file2
147+
every { fileDocumentManager.getDocument(file1) } returns document1
148+
every { fileDocumentManager.getDocument(file2) } returns document2
149+
every { document1.getLineStartOffset(0) } returns 0
150+
every { document2.getLineStartOffset(1) } returns 100
151+
152+
val versionedIdentifier = VersionedTextDocumentIdentifier(uri1, 1)
153+
val textDocumentEdit = TextDocumentEdit(versionedIdentifier, listOf(textEdit1))
154+
155+
val workspaceEdit = WorkspaceEdit().apply {
156+
documentChanges = listOf(Either.forLeft(textDocumentEdit))
157+
changes = mapOf(uri2 to listOf(textEdit2))
158+
}
159+
160+
LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit)
161+
162+
verify { document1.replaceString(0, 5, "newText1") }
163+
verify { document2.replaceString(100, 110, "newText2") }
164+
}
165+
166+
@Test
167+
fun `test applyWorkspaceEdit with multiple edits to same file`() {
168+
val uri = "file:///test.kt"
169+
val document = mockk<Document>(relaxed = true)
170+
val file = mockk<VirtualFile>()
171+
172+
val textEdit1 = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText1")
173+
val textEdit2 = TextEdit(Range(Position(1, 0), Position(1, 10)), "newText2")
174+
175+
every { virtualFileManager.findFileByUrl(uri) } returns file
176+
every { fileDocumentManager.getDocument(file) } returns document
177+
every { document.getLineStartOffset(0) } returns 0
178+
every { document.getLineStartOffset(1) } returns 100
179+
180+
val workspaceEdit = WorkspaceEdit().apply {
181+
changes = mapOf(uri to listOf(textEdit1, textEdit2))
182+
}
183+
184+
LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit)
185+
186+
verify { document.replaceString(0, 5, "newText1") }
187+
verify { document.replaceString(100, 110, "newText2") }
188+
}
189+
190+
@Test
191+
fun `test applyWorkspaceEdit with empty edits`() {
192+
val workspaceEdit = WorkspaceEdit()
193+
LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit)
194+
195+
// No verification needed - just ensuring no exceptions
196+
}
197+
198+
@Test
199+
fun `test applyWorkspaceEdit with invalid file`() {
200+
val uri = "file:///nonexistent.kt"
201+
val textEdit = TextEdit(Range(Position(0, 0), Position(0, 5)), "newText")
202+
203+
every { virtualFileManager.findFileByUrl(uri) } returns null
204+
205+
val workspaceEdit = WorkspaceEdit().apply {
206+
changes = mapOf(uri to listOf(textEdit))
207+
}
208+
209+
// Execute - should not throw exception
210+
LspEditorUtil.applyWorkspaceEdit(project, workspaceEdit)
211+
}
212+
}

0 commit comments

Comments
 (0)