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
This commit is contained in:
Vladimir Krivosheev
2026-07-09 18:59:26 +00:00
committed by intellij-monorepo-bot
parent 9d244e6208
commit 2c8a446b64
5 changed files with 121 additions and 23 deletions
@@ -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
}
@@ -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
@@ -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
}
@@ -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) {
@@ -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)