From 2c8a446b649ab51e9fd8d371a9c02e41add603c6 Mon Sep 17 00:00:00 2001 From: Vladimir Krivosheev Date: Mon, 29 Jun 2026 16:30:53 +0200 Subject: [PATCH] IJPL-248623 dispose AWB empty-state prompt on splitters shutdown The inline Agent Workbench prompt can be mounted through the editor empty-state controller without another disposable owner. When the owning splitters scope completes, keep the rich empty-state entries tied to that lifecycle and dispose them on EDT instead of leaving the Swing tree reachable. Make prompt palette teardown exhaustive so one cleanup failure cannot skip EditorTextField disposal, and clear the inline component reference before disposing nested content. Cover both the splitters-scope owner path and the inline prompt editor disposal path with regression tests. GitOrigin-RevId: b135ec8c04f2f210768a1ca475c6286e3df69721 --- .../EditorEmptyStateComponentController.kt | 19 +++++++ .../impl/EditorEmptyTextPainterTest.kt | 29 +++++++++++ .../prompt/ui/src/AgentPromptPalettePopup.kt | 39 +++++++-------- ...WorkbenchInlinePromptEmptyStateProvider.kt | 7 ++- ...benchInlinePromptEmptyStateProviderTest.kt | 50 +++++++++++++++++++ 5 files changed, 121 insertions(+), 23 deletions(-) diff --git a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentController.kt b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentController.kt index c96c671ae364..17bd13227c70 100644 --- a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentController.kt +++ b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentController.kt @@ -3,6 +3,8 @@ package com.intellij.openapi.fileEditor.impl import com.intellij.diagnostic.PluginException import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.application.writeIntentReadAction import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.extensions.PluginDescriptor @@ -14,6 +16,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay +import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.annotations.ApiStatus @@ -52,6 +55,12 @@ internal class EditorEmptyStateComponentController( private var creationDelay: Duration = EMPTY_STATE_COMPONENT_CREATION_DELAY private var creationGate: (suspend () -> Unit)? = null + init { + coroutineScope.coroutineContext.job.invokeOnCompletion { + disposeComponentsOnEdt() + } + } + fun isCreationPending(): Boolean = creationJob != null fun isVisible(): Boolean = componentHost != null @@ -109,6 +118,16 @@ internal class EditorEmptyStateComponentController( splitters.repaint() } + private fun disposeComponentsOnEdt() { + val application = ApplicationManager.getApplication() + if (application.isDispatchThread) { + disposeComponents() + } + else { + application.invokeLater({ disposeComponents() }, ModalityState.any()) + } + } + fun setCreationDelayForTests(delay: Duration) { creationDelay = delay } diff --git a/platform/platform-impl/testSrc/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainterTest.kt b/platform/platform-impl/testSrc/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainterTest.kt index 4d06cdfb135f..ae95cf280610 100644 --- a/platform/platform-impl/testSrc/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainterTest.kt +++ b/platform/platform-impl/testSrc/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainterTest.kt @@ -29,8 +29,11 @@ import com.intellij.testFramework.junit5.fixture.projectFixture import com.intellij.testFramework.junit5.fixture.tempPathFixture import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel import kotlinx.coroutines.withContext import org.assertj.core.api.Assertions.assertThat import org.jdom.Element @@ -167,6 +170,32 @@ internal class EditorEmptyTextPainterTest { assertThat(findEmptyStateComponent(splitters)).isNotNull() } + @Test + @Suppress("RAW_SCOPE_CREATION") + fun splittersScopeDisposesVisibleEmptyStateComponent(@TestDisposable disposable: Disposable) { + val disposedComponents = AtomicInteger() + registerComponentProvider(disposable, disposedComponents) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val splitters = EditorsSplitters(manager = manager, coroutineScope = scope) + try { + enableRichEmptyStateComponentsWithoutDelay(splitters) + splitters.updateEmptyStateComponent() + waitForEmptyStateComponentCreation(splitters) + + assertThat(findEmptyStateComponent(splitters)).isNotNull() + + scope.cancel() + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + + assertThat(findEmptyStateComponent(splitters)).isNull() + assertThat(disposedComponents).hasValue(1) + } + finally { + scope.cancel() + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + } + @Test fun componentProviderIsMountedAsEmptyStatePanel(@TestDisposable disposable: Disposable) { val splitters = manager.mainSplitters diff --git a/plugins/agent-workbench/prompt/ui/src/AgentPromptPalettePopup.kt b/plugins/agent-workbench/prompt/ui/src/AgentPromptPalettePopup.kt index 1adfea210bdf..be93c40f07a1 100644 --- a/plugins/agent-workbench/prompt/ui/src/AgentPromptPalettePopup.kt +++ b/plugins/agent-workbench/prompt/ui/src/AgentPromptPalettePopup.kt @@ -25,6 +25,7 @@ import com.intellij.openapi.util.Disposer import com.intellij.openapi.wm.IdeFocusManager import com.intellij.openapi.wm.IdeFrame import com.intellij.openapi.wm.ex.IdeFocusTraversalPolicy +import com.intellij.util.runSuppressing import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -193,25 +194,15 @@ internal class AgentPromptPaletteContent( return } disposed = true - imageDropSupportScope.cancel(reason) - try { - sessionController.onHostClosed() - } - finally { - try { - view.headerToolbar.targetComponent = null - view.footerPinToolbar.targetComponent = null - rootPanel.parent?.remove(rootPanel) - } - finally { - try { - Disposer.dispose(swingDisposable) - } - finally { - sessionScope.cancel(reason) - } - } - } + runSuppressing( + { imageDropSupportScope.cancel(reason) }, + { sessionController.onHostClosed() }, + { view.headerToolbar.targetComponent = null }, + { view.footerPinToolbar.targetComponent = null }, + { rootPanel.parent?.remove(rootPanel) }, + { Disposer.dispose(swingDisposable) }, + { sessionScope.cancel(reason) }, + ) } } @@ -330,10 +321,14 @@ internal fun createAgentPromptPaletteContent( } catch (error: Throwable) { try { - imageDropSupportScope.cancel("Agent prompt palette content creation failed", error) + runSuppressing( + { imageDropSupportScope.cancel("Agent prompt palette content creation failed", error) }, + { Disposer.dispose(swingDisposable) }, + { sessionScope.cancel("Agent prompt palette content creation failed", error) }, + ) } - finally { - Disposer.dispose(swingDisposable) + catch (cleanupError: Throwable) { + error.addSuppressed(cleanupError) } throw error } diff --git a/plugins/agent-workbench/prompt/ui/src/emptyState/AgentWorkbenchInlinePromptEmptyStateProvider.kt b/plugins/agent-workbench/prompt/ui/src/emptyState/AgentWorkbenchInlinePromptEmptyStateProvider.kt index 62164d3500c2..4e08390e3c6b 100644 --- a/plugins/agent-workbench/prompt/ui/src/emptyState/AgentWorkbenchInlinePromptEmptyStateProvider.kt +++ b/plugins/agent-workbench/prompt/ui/src/emptyState/AgentWorkbenchInlinePromptEmptyStateProvider.kt @@ -183,8 +183,13 @@ class AgentWorkbenchInlinePromptEmptyStateComponent internal constructor( return } disposed = true - content?.dispose(configuration.disposeReason) + disposeContent(configuration.disposeReason) + } + + private fun disposeContent(reason: String) { + val promptContent = content ?: return content = null + promptContent.dispose(reason) } override fun paintComponent(g: Graphics) { diff --git a/plugins/agent-workbench/prompt/ui/testSrc/emptyState/AgentWorkbenchInlinePromptEmptyStateProviderTest.kt b/plugins/agent-workbench/prompt/ui/testSrc/emptyState/AgentWorkbenchInlinePromptEmptyStateProviderTest.kt index 74eff8b1c109..1f3126f37583 100644 --- a/plugins/agent-workbench/prompt/ui/testSrc/emptyState/AgentWorkbenchInlinePromptEmptyStateProviderTest.kt +++ b/plugins/agent-workbench/prompt/ui/testSrc/emptyState/AgentWorkbenchInlinePromptEmptyStateProviderTest.kt @@ -31,6 +31,7 @@ import com.intellij.openapi.fileEditor.impl.EditorEmptyStateComponentHost import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.util.Disposer import com.intellij.testFramework.ExtensionTestUtil +import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.junit5.TestApplication import com.intellij.testFramework.junit5.fixture.fileEditorManagerFixture import com.intellij.testFramework.junit5.fixture.projectFixture @@ -106,6 +107,45 @@ class AgentWorkbenchInlinePromptEmptyStateProviderTest { } } + @Test + fun disposingInitializedPromptDisposesPromptEditorAndClearsContent() { + runInEdtAndWait { + val component = AgentWorkbenchInlinePromptEmptyStateComponent(ProjectManager.getInstance().defaultProject) + component.ensureContentInitialized() + val promptArea = collectComponents(component, AgentPromptTextField::class.java).single() + val editor = checkNotNull(promptArea.getEditor(true)) + + disposeComponent(component) + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() + + assertThat(editor.isDisposed).isTrue() + assertThat(collectComponents(component, AgentPromptTextField::class.java)).isEmpty() + assertThat(component.preferredFocusedComponent).isSameAs(component) + } + } + + @Test + fun disposingInlineNewThreadPromptDisposesPromptEditorAndClearsContent() { + runInEdtAndWait { + val project = ProjectManager.getInstance().defaultProject + val component = createAgentWorkbenchInlineNewThreadPromptComponent( + project = project, + invocationData = testInvocationData(project), + launcherProvider = { TestPromptLauncher }, + initialLaunchProfileId = null, + ) + val promptArea = collectComponents(component, AgentPromptTextField::class.java).single() + val editor = checkNotNull(promptArea.getEditor(true)) + + disposeComponent(component) + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() + + assertThat(editor.isDisposed).isTrue() + assertThat(collectComponents(component, AgentPromptTextField::class.java)).isEmpty() + assertThat(component.preferredFocusedComponent).isSameAs(component) + } + } + @Test fun providerReturnsNullWhenFeatureDisabled() { val previous = System.getProperty(INLINE_EMPTY_STATE_PROMPT_PROPERTY) @@ -304,6 +344,16 @@ class AgentWorkbenchInlinePromptEmptyStateProviderTest { return component as AgentWorkbenchInlinePromptEmptyStateComponent } + private fun testInvocationData(project: com.intellij.openapi.project.Project): AgentPromptInvocationData { + return AgentPromptInvocationData( + project = project, + actionId = "AgentWorkbenchPrompt.OpenGlobalPalette", + actionText = "Ask Agent", + actionPlace = "EditorEmptyState", + invokedAtMs = 0L, + ) + } + private fun disposeComponent(component: AgentWorkbenchInlinePromptEmptyStateComponent) { if (ApplicationManager.getApplication().isDispatchThread) { Disposer.dispose(component)