diff --git a/.changes/next-release/bugfix-1d632e06-24fa-40f1-a8ae-33cca0d86ec4.json b/.changes/next-release/bugfix-1d632e06-24fa-40f1-a8ae-33cca0d86ec4.json new file mode 100644 index 00000000000..b33530733ee --- /dev/null +++ b/.changes/next-release/bugfix-1d632e06-24fa-40f1-a8ae-33cca0d86ec4.json @@ -0,0 +1,4 @@ +{ + "type" : "bugfix", + "description" : "Fix UI freezes that may occur when interacting with large files in the editor" +} \ No newline at end of file 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 92e81d376b1..2ff79e68005 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 @@ -5,8 +5,6 @@ package software.aws.toolkits.jetbrains.services.amazonq.toolwindow import com.intellij.idea.AppMode import com.intellij.openapi.Disposable -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.runInEdt import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer @@ -19,12 +17,15 @@ import com.intellij.ui.dsl.builder.AlignY import com.intellij.ui.dsl.builder.panel import com.intellij.ui.jcef.JBCefApp import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import software.aws.toolkits.jetbrains.core.coroutines.EDT import software.aws.toolkits.jetbrains.isDeveloperMode import software.aws.toolkits.jetbrains.services.amazonq.apps.AmazonQAppInitContext import software.aws.toolkits.jetbrains.services.amazonq.apps.AppConnection import software.aws.toolkits.jetbrains.services.amazonq.commands.MessageTypeRegistry +import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.amazonq.lsp.artifacts.ArtifactManager import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.AsyncChatUiListener import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.FlareUiMessage @@ -109,17 +110,21 @@ class AmazonQPanel(val project: Project, private val scope: CoroutineScope) : Di webviewContainer.add(wrapper) wrapper.setContent(loadingPanel) - ApplicationManager.getApplication().executeOnPooledThread { - val webUri = runBlocking { service().fetchArtifact(project).resolve("amazonq-ui.js").toUri() } - loadingPanel.stopLoading() - runInEdt { + scope.launch { + val webUri = service().fetchArtifact(project).resolve("amazonq-ui.js").toUri() + // wait for server to be running + AmazonQLspService.getInstance(project).instanceFlow.first() + + withContext(EDT) { browser.complete( - Browser(this, webUri, project).also { + Browser(this@AmazonQPanel, webUri, project).also { wrapper.setContent(it.component()) initConnections() connectUi(it) connectApps(it) + + loadingPanel.stopLoading() } ) } 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 91975a72284..0bfd683b00b 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 @@ -11,7 +11,6 @@ import com.intellij.openapi.util.Disposer import com.intellij.ui.jcef.JBCefJSQuery import org.cef.CefApp import software.aws.toolkits.jetbrains.services.amazonq.CodeWhispererFeatureConfigService -import software.aws.toolkits.jetbrains.services.amazonq.lsp.AmazonQLspService import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.AwsServerCapabilitiesProvider import software.aws.toolkits.jetbrains.services.amazonq.lsp.flareChat.FlareUiMessage import software.aws.toolkits.jetbrains.services.amazonq.profile.QRegionProfile @@ -46,17 +45,16 @@ class Browser(parent: Disposable, private val webUri: URI, val project: Project) "mynah", AssetResourceHandler.AssetResourceHandlerFactory(), ) - AmazonQLspService.getInstance(project).addLspInitializeMessageListener { - loadWebView( - isCodeTransformAvailable, - isFeatureDevAvailable, - isDocAvailable, - isCodeScanAvailable, - isCodeTestAvailable, - highlightCommand, - activeProfile - ) - } + + loadWebView( + isCodeTransformAvailable, + isFeatureDevAvailable, + isDocAvailable, + isCodeScanAvailable, + isCodeTestAvailable, + highlightCommand, + activeProfile + ) } override fun dispose() { 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 8db334596df..4d7b3a34d4e 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 @@ -12,6 +12,7 @@ import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.options.ShowSettingsUtil import com.intellij.openapi.project.Project import com.intellij.ui.jcef.JBCefJSQuery.Response +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.coroutineScope @@ -65,7 +66,6 @@ import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ChatN import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ChatParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ChatPrompt import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ChatReadyNotification -import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ChatUiMessageParams import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.ConversationClickRequest import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CopyCodeToClipboardNotification import software.aws.toolkits.jetbrains.services.amazonq.lsp.model.aws.chat.CopyCodeToClipboardParams @@ -114,7 +114,6 @@ import software.aws.toolkits.jetbrains.services.amazonq.webview.theme.AmazonQThe import software.aws.toolkits.jetbrains.services.amazonq.webview.theme.ThemeBrowserAdapter import software.aws.toolkits.jetbrains.services.codewhisperer.settings.CodeWhispererConfigurable import software.aws.toolkits.jetbrains.settings.MeetQSettings -import software.aws.toolkits.resources.AwsCoreBundle import software.aws.toolkits.telemetry.MetricResult import software.aws.toolkits.telemetry.Telemetry import java.util.concurrent.CompletableFuture @@ -466,22 +465,6 @@ class BrowserConnector( } cancelInflightRequests(stopResponseRequest.params.tabId) chatCommunicationManager.removePartialChatMessage(stopResponseRequest.params.tabId) - - val paramsJson = Gson().toJson( - // https://github.com/aws/language-servers/blob/1c0d88806087125b6fc561f610cc15e98127c6bf/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts#L403 - ChatUiMessageParams( - title = AwsCoreBundle.message("amazonqChat.stopChatResponse"), - body = "" - ) - ) - - val uiMessage = ChatCommunicationManager.convertToJsonToSendToChat( - command = SEND_CHAT_COMMAND_PROMPT, - tabId = stopResponseRequest.params.tabId, - params = paramsJson.toString(), - isPartialResult = false - ) - browser.postChat(uiMessage) } OPEN_SETTINGS -> { val openSettingsNotification = serializer.deserializeChatMessages(node) @@ -514,6 +497,8 @@ class BrowserConnector( ) browser.postChat(messageToChat) chatCommunicationManager.removeInflightRequestForTab(tabId) + } catch (e: CancellationException) { + LOG.warn { "Cancelled chat generation" } } catch (e: Exception) { LOG.error { "Failed to send chat message $e" } browser.postChat(chatCommunicationManager.getErrorUiMessage(tabId, e, partialResultToken)) 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 c1e0ab471e3..933027a3b36 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 @@ -25,6 +25,10 @@ import com.intellij.util.net.JdkProxyProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.future.asCompletableFuture import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex @@ -75,7 +79,6 @@ import java.net.Proxy import java.net.URI import java.nio.charset.StandardCharsets import java.nio.file.Files -import java.util.Collections import java.util.concurrent.Future import kotlin.time.Duration.Companion.seconds @@ -114,9 +117,8 @@ internal class LSPProcessListener : ProcessListener { @Service(Service.Level.PROJECT) class AmazonQLspService(private val project: Project, private val cs: CoroutineScope) : Disposable { - private val lspInitializedMessageReceivedListener = Collections.synchronizedList(mutableListOf()) - fun addLspInitializeMessageListener(listener: AmazonQInitializeMessageReceivedListener) = lspInitializedMessageReceivedListener.add(listener) - fun notifyInitializeMessageReceived() = lspInitializedMessageReceivedListener.forEach { it() } + private val _flowInstance = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val instanceFlow = _flowInstance.asSharedFlow().map { it.languageServer } private var instance: Deferred val capabilities @@ -140,7 +142,9 @@ class AmazonQLspService(private val project: Project, private val cs: CoroutineS // wait for handshake to complete instance.initializeResult.join() - instance + instance.also { + _flowInstance.emit(it) + } } } catch (e: Exception) { LOG.warn(e) { "Failed to start LSP server" } @@ -324,7 +328,6 @@ private class AmazonQServerInstance(private val project: Project, private val cs if (message is ResponseMessage && message.result is AwsExtendedInitializeResult) { val result = message.result as AwsExtendedInitializeResult AwsServerCapabilitiesProvider.getInstance(project).setAwsServerCapabilities(result.getAwsServerCapabilities()) - AmazonQLspService.getInstance(project).notifyInitializeMessageReceived() } consumer?.consume(message) } 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 1a2c0bb7447..c1e368f9b37 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 @@ -77,16 +77,18 @@ class TextDocumentServiceHandler( } AmazonQLspService.executeIfRunning(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() + pluginAwareExecuteOnPooledThread { + 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() + } } - } - ) + ) + } } } } @@ -95,14 +97,16 @@ class TextDocumentServiceHandler( AmazonQLspService.executeIfRunning(project) { languageServer -> val file = FileDocumentManager.getInstance().getFile(document) ?: return@executeIfRunning toUriString(file)?.let { uri -> - languageServer.textDocumentService.didSave( - DidSaveTextDocumentParams().apply { - textDocument = TextDocumentIdentifier().apply { - this.uri = uri + pluginAwareExecuteOnPooledThread { + languageServer.textDocumentService.didSave( + DidSaveTextDocumentParams().apply { + textDocument = TextDocumentIdentifier().apply { + this.uri = uri + } + text = document.text } - text = document.text - } - ) + ) + } } } } 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 ecb5104adfa..1ceb7d148da 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 @@ -3,31 +3,28 @@ package software.aws.toolkits.jetbrains.services.amazonq.lsp.textdocument -import com.intellij.openapi.Disposable -import com.intellij.openapi.components.serviceIfCreated +import com.intellij.openapi.application.writeAction import com.intellij.openapi.editor.Document import com.intellij.openapi.fileEditor.FileDocumentManager -import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileTypes.FileType -import com.intellij.openapi.project.Project import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent import com.intellij.openapi.vfs.newvfs.events.VFileEvent -import com.intellij.testFramework.ApplicationRule +import com.intellij.openapi.vfs.writeText +import com.intellij.testFramework.DisposableRule import com.intellij.testFramework.LightVirtualFile -import com.intellij.testFramework.runInEdtAndWait -import com.intellij.util.messages.MessageBus -import com.intellij.util.messages.MessageBusConnection +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.intellij.testFramework.replaceService import io.mockk.every -import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject import io.mockk.mockkStatic -import io.mockk.runs import io.mockk.slot import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext import org.assertj.core.api.Assertions.assertThat import org.eclipse.lsp4j.DidChangeTextDocumentParams import org.eclipse.lsp4j.DidCloseTextDocumentParams @@ -38,37 +35,49 @@ import org.eclipse.lsp4j.services.TextDocumentService import org.junit.Before import org.junit.Rule import org.junit.Test +import software.aws.toolkits.jetbrains.core.coroutines.EDT 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.util.LspEditorUtil +import software.aws.toolkits.jetbrains.utils.rules.CodeInsightTestFixtureRule +import software.aws.toolkits.jetbrains.utils.satisfiesKt import java.net.URI import java.nio.file.Path import java.util.concurrent.CompletableFuture +import kotlin.collections.first class TextDocumentServiceHandlerTest { - @Rule - @JvmField - val application = ApplicationRule() - - private lateinit var project: Project - private lateinit var mockFileEditorManager: FileEditorManager private lateinit var mockLanguageServer: AmazonQLanguageServer private lateinit var mockTextDocumentService: TextDocumentService private lateinit var sut: TextDocumentServiceHandler -// private lateinit var mockApplication: Application + + @get:Rule + val projectRule = object : CodeInsightTestFixtureRule() { + override fun createTestFixture(): CodeInsightTestFixture { + val fixtureFactory = IdeaTestFixtureFactory.getFixtureFactory() + val fixtureBuilder = fixtureFactory.createLightFixtureBuilder(testDescription, testName) + val newFixture = fixtureFactory + .createCodeInsightFixture(fixtureBuilder.fixture, fixtureFactory.createTempDirTestFixture()) + newFixture.setUp() + newFixture.testDataPath = testDataPath + + return newFixture + } + } + + @get:Rule + val disposableRule = DisposableRule() @Before fun setup() { - project = mockk() mockTextDocumentService = mockk() mockLanguageServer = mockk() // Mock the LSP service - val mockLspService = mockk() + val mockLspService = mockk(relaxed = true) // Mock the service methods on Project - every { project.getService(AmazonQLspService::class.java) } returns mockLspService - every { project.serviceIfCreated() } returns mockLspService + projectRule.project.replaceService(AmazonQLspService::class.java, mockLspService, disposableRule.disposable) // Mock the LSP service's executeSync method as a suspend function every { @@ -85,19 +94,7 @@ class TextDocumentServiceHandlerTest { every { mockTextDocumentService.didOpen(any()) } returns Unit every { mockTextDocumentService.didClose(any()) } returns Unit - // Mock message bus - val messageBus = mockk() - every { project.messageBus } returns messageBus - val mockConnection = mockk() - every { messageBus.connect(any()) } returns mockConnection - every { mockConnection.subscribe(any(), any()) } just runs - - // Mock FileEditorManager - mockFileEditorManager = mockk() - every { mockFileEditorManager.openFiles } returns emptyArray() - every { project.getService(FileEditorManager::class.java) } returns mockFileEditorManager - - sut = TextDocumentServiceHandler(project, mockk()) + sut = TextDocumentServiceHandler(projectRule.project, mockk()) } @Test @@ -135,41 +132,39 @@ class TextDocumentServiceHandlerTest { @Test fun `didOpen runs on service init`() = runTest { - val uri = URI.create("file:///test/path/file.txt") val content = "test content" - val file = createMockVirtualFile(uri, content) - - every { mockFileEditorManager.openFiles } returns arrayOf(file) + val file = withContext(EDT) { + projectRule.fixture.createFile("name", content).also { projectRule.fixture.openFileInEditor(it) } + } - sut = TextDocumentServiceHandler(project, mockk()) + sut = TextDocumentServiceHandler(projectRule.project, mockk()) - val paramsSlot = slot() + val paramsSlot = mutableListOf() verify { mockTextDocumentService.didOpen(capture(paramsSlot)) } - with(paramsSlot.captured.textDocument) { - assertThat(this.uri).isEqualTo(normalizeFileUri(uri.toString())) - assertThat(text).isEqualTo(content) - assertThat(languageId).isEqualTo("java") - assertThat(version).isEqualTo(1) + assertThat(paramsSlot.first().textDocument).satisfiesKt { + assertThat(it.uri).isEqualTo(file.toNioPath().toUri().toString()) + assertThat(it.text).isEqualTo(content) + assertThat(it.languageId).isEqualTo("plain_text") } } @Test fun `didOpen runs on fileOpened`() = runTest { - val uri = URI.create("file:///test/path/file.txt") val content = "test content" - val file = createMockVirtualFile(uri, content) + val file = withContext(EDT) { + projectRule.fixture.createFile("name", content).also { projectRule.fixture.openFileInEditor(it) } + } sut.fileOpened(mockk(), file) - val paramsSlot = slot() + val paramsSlot = mutableListOf() verify { mockTextDocumentService.didOpen(capture(paramsSlot)) } - with(paramsSlot.captured.textDocument) { - assertThat(this.uri).isEqualTo(normalizeFileUri(uri.toString())) - assertThat(text).isEqualTo(content) - assertThat(languageId).isEqualTo("java") - assertThat(version).isEqualTo(1) + assertThat(paramsSlot.first().textDocument).satisfiesKt { + assertThat(it.uri).isEqualTo(file.toNioPath().toUri().toString()) + assertThat(it.text).isEqualTo(content) + assertThat(it.languageId).isEqualTo("plain_text") } } @@ -188,40 +183,23 @@ class TextDocumentServiceHandlerTest { @Test fun `didChange runs on content change events`() = runTest { - val uri = URI.create("file:///test/path/file.txt") - val document = mockk { - every { text } returns "changed content" - every { modificationStamp } returns 123L - } + val file = withContext(EDT) { + projectRule.fixture.createFile("name", "").also { + projectRule.fixture.openFileInEditor(it) - val file = createMockVirtualFile(uri) - - val changeEvent = mockk { - every { this@mockk.file } returns file - } - - // Mock FileDocumentManager - val fileDocumentManager = mockk { - every { getCachedDocument(file) } returns document - } - - mockkStatic(FileDocumentManager::class) { - every { FileDocumentManager.getInstance() } returns fileDocumentManager - - // Call the handler method - runInEdtAndWait { - sut.after(mutableListOf(changeEvent)) + writeAction { + it.writeText("changed content") + } } } // Verify the correct LSP method was called with matching parameters - val paramsSlot = slot() + val paramsSlot = mutableListOf() verify { mockTextDocumentService.didChange(capture(paramsSlot)) } - with(paramsSlot.captured) { - assertThat(textDocument.uri).isEqualTo(normalizeFileUri(uri.toString())) - assertThat(textDocument.version).isEqualTo(123) - assertThat(contentChanges[0].text).isEqualTo("changed content") + assertThat(paramsSlot.first()).satisfiesKt { + assertThat(it.textDocument.uri).isEqualTo(file.toNioPath().toUri().toString()) + assertThat(it.contentChanges[0].text).isEqualTo("changed content") } } @@ -337,6 +315,11 @@ class TextDocumentServiceHandlerTest { return uri } + if (uri.startsWith("file://C:/")) { + val path = uri.substringAfter("file://C:/") + return "file:///C:/$path" + } + val path = uri.substringAfter("file:///") return "file:///C:/$path" }