diff --git a/platform/ide-core-impl/api-dump-unreviewed.txt b/platform/ide-core-impl/api-dump-unreviewed.txt index e24bb53285db..31b253ef333a 100644 --- a/platform/ide-core-impl/api-dump-unreviewed.txt +++ b/platform/ide-core-impl/api-dump-unreviewed.txt @@ -65,10 +65,7 @@ f:com.intellij.ide.impl.OpenProjectTask - f:component12():Z - f:component13():Z - f:component14():Z -- f:component15():java.lang.String -- f:component17():Z - f:component2():Z -- f:component20():kotlin.jvm.functions.Function2 - f:component3():com.intellij.openapi.project.Project - f:component4():Z - f:component5():Z diff --git a/platform/ide-core-impl/api-dump.txt b/platform/ide-core-impl/api-dump.txt index c7ecf71388e8..dbddf1d91265 100644 --- a/platform/ide-core-impl/api-dump.txt +++ b/platform/ide-core-impl/api-dump.txt @@ -85,17 +85,21 @@ c:com.intellij.ide.impl.DataManagerImpl f:com.intellij.ide.impl.OpenProjectTask - f:asNewProject():com.intellij.ide.impl.OpenProjectTask - sf:build():com.intellij.ide.impl.OpenProjectTask +- f:component15():Z - f:component16():java.lang.String +- f:component17():java.lang.String - f:component18():Z -- f:component19():kotlin.jvm.functions.Function1 +- f:component19():Z +- f:component20():kotlin.jvm.functions.Function1 - f:component21():kotlin.jvm.functions.Function2 -- f:component22():Z -- f:component23():kotlin.jvm.functions.Function1 -- f:component24():java.lang.Object -- f:component25():java.nio.file.Path -- f:component26():Z -- f:copy(Z,Z,com.intellij.openapi.project.Project,Z,Z,com.intellij.openapi.project.Project,java.lang.String,Z,com.intellij.projectImport.ProjectOpenedCallback,I,I,Z,Z,Z,java.lang.String,java.lang.String,Z,Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,kotlin.jvm.functions.Function2,Z,kotlin.jvm.functions.Function1,java.lang.Object,java.nio.file.Path,Z):com.intellij.ide.impl.OpenProjectTask -- bs:copy$default(com.intellij.ide.impl.OpenProjectTask,Z,Z,com.intellij.openapi.project.Project,Z,Z,com.intellij.openapi.project.Project,java.lang.String,Z,com.intellij.projectImport.ProjectOpenedCallback,I,I,Z,Z,Z,java.lang.String,java.lang.String,Z,Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,kotlin.jvm.functions.Function2,Z,kotlin.jvm.functions.Function1,java.lang.Object,java.nio.file.Path,Z,I,java.lang.Object):com.intellij.ide.impl.OpenProjectTask +- f:component22():kotlin.jvm.functions.Function2 +- f:component23():Z +- f:component24():kotlin.jvm.functions.Function1 +- f:component25():java.lang.Object +- f:component26():java.nio.file.Path +- f:component27():Z +- f:copy(Z,Z,com.intellij.openapi.project.Project,Z,Z,com.intellij.openapi.project.Project,java.lang.String,Z,com.intellij.projectImport.ProjectOpenedCallback,I,I,Z,Z,Z,Z,java.lang.String,java.lang.String,Z,Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,kotlin.jvm.functions.Function2,Z,kotlin.jvm.functions.Function1,java.lang.Object,java.nio.file.Path,Z):com.intellij.ide.impl.OpenProjectTask +- bs:copy$default(com.intellij.ide.impl.OpenProjectTask,Z,Z,com.intellij.openapi.project.Project,Z,Z,com.intellij.openapi.project.Project,java.lang.String,Z,com.intellij.projectImport.ProjectOpenedCallback,I,I,Z,Z,Z,Z,java.lang.String,java.lang.String,Z,Z,kotlin.jvm.functions.Function1,kotlin.jvm.functions.Function2,kotlin.jvm.functions.Function2,Z,kotlin.jvm.functions.Function1,java.lang.Object,java.nio.file.Path,Z,I,java.lang.Object):com.intellij.ide.impl.OpenProjectTask - f:getBeforeInit():kotlin.jvm.functions.Function1 - f:getBeforeOpen():kotlin.jvm.functions.Function2 - f:getCallback():com.intellij.projectImport.ProjectOpenedCallback diff --git a/platform/ide-core-impl/src/com/intellij/ide/impl/OpenProjectTask.kt b/platform/ide-core-impl/src/com/intellij/ide/impl/OpenProjectTask.kt index 2b46a14e5bc5..08386aa22d37 100644 --- a/platform/ide-core-impl/src/com/intellij/ide/impl/OpenProjectTask.kt +++ b/platform/ide-core-impl/src/com/intellij/ide/impl/OpenProjectTask.kt @@ -26,6 +26,15 @@ data class OpenProjectTask @Internal constructor( val callback: ProjectOpenedCallback?, val line: Int, val column: Int, + /** + * Whether whoever opens this project is going to open an editor of its own once opening has finished — a file named on the command + * line, for instance. + * + * The editor area holds back what it would otherwise show while project open is still deciding what goes there, and it can only + * hold back what it knows about; this reports work that outlives project open itself. + */ + @Internal + val opensFileAfterProjectOpen: Boolean = false, @Deprecated("Not used") val isRefreshVfsNeeded: Boolean, /** @@ -154,6 +163,10 @@ class OpenProjectTaskBuilder @PublishedApi internal constructor() { var line: Int = -1 var column: Int = -1 + /** See [OpenProjectTask.opensFileAfterProjectOpen]. */ + @Internal + var opensFileAfterProjectOpen: Boolean = false + /** Shim for Java clients */ fun withBeforeOpenCallback(callback: Predicate) { beforeOpen = { callback.test(it) } @@ -209,6 +222,7 @@ class OpenProjectTaskBuilder @PublishedApi internal constructor() { line = line, column = column, + opensFileAfterProjectOpen = opensFileAfterProjectOpen, project = project, projectRootDir = projectRootDir, 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 468e300d531a..7d59a4f67720 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 @@ -2,10 +2,13 @@ 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.EDT import com.intellij.openapi.application.ModalityState -import com.intellij.openapi.application.writeIntentReadAction +import com.intellij.openapi.application.UI +import com.intellij.openapi.application.WriteIntentReadAction +import com.intellij.openapi.application.asContextElement +import com.intellij.openapi.diagnostic.debug import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.extensions.PluginDescriptor import com.intellij.util.ui.JBUI @@ -21,6 +24,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.NonNls import java.awt.BorderLayout @@ -48,6 +52,15 @@ internal const val EDITOR_ROOT_COMPONENT_CONSTRAINT: @NonNls String = "EditorRoo internal const val EMPTY_STATE_COMPONENT_CONSTRAINT: @NonNls String = "EditorEmptyStateComponent" private val EMPTY_STATE_COMPONENT_CREATION_DELAY = 300.milliseconds +/** + * Ceiling on how long a prepared component waits for the presentation gate to open. + * + * Not a latency knob — the gate is opened by project open as soon as it knows, and this is above any plausible project open. It is a + * backstop against a hold nobody releases, which would otherwise leave this area showing nothing at all for as long as the project + * stays open, because the fallback empty text is not selected while a rich provider is available. + */ +private val PRESENTATION_GATE_TIMEOUT = 30.seconds + /** * Budget for the part of a single [EditorEmptyStateComponentProvider.createComponent] call that does not run on the UI thread. * @@ -87,6 +100,7 @@ internal class EditorEmptyStateComponentController( */ private val presentationAllowed = MutableStateFlow(true) private var creationDelay: Duration = EMPTY_STATE_COMPONENT_CREATION_DELAY + private var presentationGateTimeout: Duration = PRESENTATION_GATE_TIMEOUT private var creationGate: (suspend () -> Unit)? = null init { @@ -99,10 +113,6 @@ internal class EditorEmptyStateComponentController( fun isVisible(): Boolean = componentHost != null - fun isLegacyEmptyTextPaintingAllowed(): Boolean { - return componentHost == null && creationJob == null && !hasAvailableRichProvider() - } - fun suppressRichComponents() { if (!richComponentsEnabled && componentHost == null && creationJob == null) { return @@ -162,9 +172,12 @@ internal class EditorEmptyStateComponentController( fun disposeComponents() { cancelCreation() val host = componentHost ?: return - splitters.uninstallEmptyStateOverlay(host) - host.removeAll() - disposeEntries(componentEntries) + // uninstalling fires `removeNotify` on a provider's component, which may release an editor — see [mount] + WriteIntentReadAction.run { + splitters.uninstallEmptyStateOverlay(host) + host.removeAll() + disposeEntries(componentEntries) + } componentHost = null componentEntries = emptyList() splitters.revalidate() @@ -173,7 +186,9 @@ internal class EditorEmptyStateComponentController( private fun disposeComponentsOnEdt() { val application = ApplicationManager.getApplication() - if (application.isDispatchThread) { + // the scope may be cancelled from a strict-UI context, where taking the write-intent lock disposal needs is forbidden rather than + // merely absent — so the direct path is taken only where the lock is already held + if (application.isDispatchThread && application.isWriteIntentLockAcquired) { disposeComponents() } else { @@ -186,6 +201,11 @@ internal class EditorEmptyStateComponentController( creationDelay = delay ?: EMPTY_STATE_COMPONENT_CREATION_DELAY } + /** @param timeout `null` restores the production timeout. */ + fun setPresentationGateTimeoutForTests(timeout: Duration?) { + presentationGateTimeout = timeout ?: PRESENTATION_GATE_TIMEOUT + } + fun setCreationGateForTests(gate: (suspend () -> Unit)?) { creationGate = gate } @@ -207,16 +227,17 @@ internal class EditorEmptyStateComponentController( val presentationHeld = !presentationAllowed.value val generation = ++creationGeneration creationJob = coroutineScope.launch(Dispatchers.Default + CoroutineName("create editor empty state components")) { + val startedAt = TimeSource.Monotonic.markNow() var entries: List = emptyList() var mounted = false try { creationGate?.invoke() - if (!isCreationValidOnEdt(generation, kind)) { + if (!isCreationValidOnUiThread(generation, kind)) { return@launch } entries = createEntries(generation, providers) if (entries.isEmpty() && kind == EditorEmptyStateComponentProvider.Kind.RICH) { - val fallbackProviders = withContext(Dispatchers.EDT) { + val fallbackProviders = withContext(Dispatchers.UI) { if (isCreationValid(generation, EditorEmptyStateComponentProvider.Kind.FALLBACK)) { getAvailableProviders(EditorEmptyStateComponentProvider.Kind.FALLBACK) } @@ -227,31 +248,36 @@ internal class EditorEmptyStateComponentController( entries = createEntries(generation, fallbackProviders) } if (entries.isEmpty()) { - // nothing to present, so there is nothing to wait for either — this creation must not go on holding the legacy empty text back return@launch } - if (!presentationHeld && kind == EditorEmptyStateComponentProvider.Kind.RICH) { - delay(creationDelay) - } - // Only a rich empty state is worth holding back. Plain empty text is what this area showed before any of this existed, and - // holding it back leaves the area blank rather than plain, because legacy painting is suppressed while a creation is pending. - if (entries.first().kind == EditorEmptyStateComponentProvider.Kind.RICH) { + // Only a rich empty state is worth delaying or holding back — the plain empty text is what this area showed before any of this + // existed. Keyed on what was actually built, so a fallback reached through a rich provider that built nothing is not delayed. + val presentedKind = entries.first().kind + if (presentedKind == EditorEmptyStateComponentProvider.Kind.RICH) { + if (!presentationHeld) { + delay(creationDelay) + } // the gate may also have been closed after this creation started, so it is awaited whether it was held at that point or not - presentationAllowed.first { it } + awaitPresentationAllowed() } - withContext(Dispatchers.EDT) { - if (!isCreationValid(generation, entries.first().kind)) { + // `Dispatchers.EDT`, not `Dispatchers.UI`: mounting takes the write-intent lock (see [mount]), and the strict UI dispatcher + // forbids taking it outright. `ModalityState.any()`, like the hold hops in `IdeProjectFrameAllocator`, so a modal dialog + // during startup cannot reorder the mount against the release that allowed it. + withContext(Dispatchers.EDT + ModalityState.any().asContextElement()) { + if (!isCreationValid(generation, presentedKind)) { return@withContext } - writeIntentReadAction { - mount(entries) - } + mount(entries) mounted = true } } finally { - withContext(NonCancellable + Dispatchers.EDT) { + withContext(NonCancellable + Dispatchers.EDT + ModalityState.any().asContextElement()) { if (!mounted) { + if (entries.isNotEmpty()) { + // what the split costs when project open takes this area over anyway: components were built and are now thrown away + LOG.debug { "Discarded ${entries.size} prepared editor empty state component(s) after ${startedAt.elapsedNow()}" } + } disposeEntries(entries) } if (generation == creationGeneration) { @@ -265,6 +291,12 @@ internal class EditorEmptyStateComponentController( } } + private suspend fun awaitPresentationAllowed() { + if (withTimeoutOrNull(presentationGateTimeout) { presentationAllowed.first { it } } == null) { + LOG.warn("Editor empty state presentation was held for $presentationGateTimeout and is presented anyway; a hold was never released") + } + } + private fun getProvidersToCreate(): List { val richProviders = getAvailableProviders(EditorEmptyStateComponentProvider.Kind.RICH) if (richProviders.isNotEmpty()) { @@ -273,19 +305,9 @@ internal class EditorEmptyStateComponentController( return getAvailableProviders(EditorEmptyStateComponentProvider.Kind.FALLBACK) } - private fun hasAvailableRichProvider(): Boolean { - return getAvailableProviders(EditorEmptyStateComponentProvider.Kind.RICH, stopAfterFirst = true).isNotEmpty() - } - - private fun getAvailableProviders( - kind: EditorEmptyStateComponentProvider.Kind, - stopAfterFirst: Boolean = false, - ): List { + private fun getAvailableProviders(kind: EditorEmptyStateComponentProvider.Kind): List { val providers = ArrayList() EditorEmptyStateComponentProvider.EP_NAME.processWithPluginDescriptor { provider, pluginDescriptor -> - if (stopAfterFirst && providers.isNotEmpty()) { - return@processWithPluginDescriptor - } if (getProviderKind(provider, pluginDescriptor) != kind) { return@processWithPluginDescriptor } @@ -322,10 +344,10 @@ internal class EditorEmptyStateComponentController( } } - private suspend fun isCreationValidOnEdt( + private suspend fun isCreationValidOnUiThread( generation: Int, kind: EditorEmptyStateComponentProvider.Kind, - ): Boolean = withContext(Dispatchers.EDT) { + ): Boolean = withContext(Dispatchers.UI) { isCreationValid(generation, kind) } @@ -343,7 +365,7 @@ internal class EditorEmptyStateComponentController( val entries = ArrayList() try { for ((provider, pluginDescriptor, kind) in providers) { - if (!isCreationValidOnEdt(generation, kind)) { + if (!isCreationValidOnUiThread(generation, kind)) { break } val component = try { @@ -373,26 +395,45 @@ internal class EditorEmptyStateComponentController( return entries } catch (e: CancellationException) { - withContext(NonCancellable + Dispatchers.EDT) { + // `Dispatchers.EDT` for the same reason as the mount: disposing takes the write-intent lock + withContext(NonCancellable + Dispatchers.EDT + ModalityState.any().asContextElement()) { disposeEntries(entries) } throw e } } + /** + * Mounts the prepared components. Must be called where the write-intent lock may be taken — the legacy [Dispatchers.EDT], or + * `invokeLater` — because [Dispatchers.UI] forbids taking it rather than merely not carrying it. + */ private fun mount(entries: List) { - val host = EditorEmptyStateComponentHost(fillContent = entries.all { it.kind == EditorEmptyStateComponentProvider.Kind.FALLBACK }) - componentHost = host - componentEntries = entries - host.setComponents(entries.map { it.component }) - splitters.installEmptyStateOverlay(host) - splitters.revalidate() - splitters.repaint() + // Installing the overlay is a plain `Container.add`, but it fires `addNotify` on a provider's component, and a provider may create + // an editor there — AIR's composer hosts an `AirPromptEditorTextField`, whose `addNotify` runs `EditorTextField.initEditor`. The + // lock is taken here, where the need is, so that it is stated rather than inherited from whichever caller arrives. + WriteIntentReadAction.run { + val host = EditorEmptyStateComponentHost(fillContent = entries.all { it.kind == EditorEmptyStateComponentProvider.Kind.FALLBACK }) + componentHost = host + componentEntries = entries + host.setComponents(entries.map { it.component }) + splitters.installEmptyStateOverlay(host) + splitters.revalidate() + splitters.repaint() + } } + /** + * Takes the write-intent lock for the same reason [mount] does: a provider may release an editor while disposing its component. Same + * caller requirement, too — a context where that lock may be taken. + */ private fun disposeEntries(entries: List) { - for ((provider, component) in entries) { - provider.disposeComponent(component) + if (entries.isEmpty()) { + return + } + WriteIntentReadAction.run { + for ((provider, component) in entries) { + provider.disposeComponent(component) + } } } } diff --git a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentProvider.kt b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentProvider.kt index 8617e6cb8106..dfe574f0d63e 100644 --- a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentProvider.kt +++ b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentProvider.kt @@ -57,6 +57,11 @@ interface EditorEmptyStateComponentProvider { * * Only time spent inside [block] is attributed to the UI thread: waiting for the thread to become free is contention, not this * provider's cost. + * + * Two limits of that split are worth knowing, because both understate the UI-thread half or the whole: + * - this measures wall time inside [block], so a suspension point that leaves the UI thread inside it is still charged to the UI budget; + * - only what a provider routes through here is counted, so a provider that hops on its own, or that loses the + * [EditorEmptyStateUiBuildTime] context element on the way, reports no UI time at all. */ @ApiStatus.Internal suspend fun buildEditorEmptyStateComponentOnUiThread(block: suspend CoroutineScope.() -> T): T { @@ -75,4 +80,4 @@ suspend fun buildEditorEmptyStateComponentOnUiThread(block: suspend Coroutin } } } -} \ No newline at end of file +} diff --git a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainter.java b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainter.java index 607ba42cae20..50657efd1023 100644 --- a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainter.java +++ b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainter.java @@ -64,9 +64,6 @@ public class EditorEmptyTextPainter { if (!isEnabled()) { return; } - if (splitters instanceof EditorsSplitters && !((EditorsSplitters)splitters).isEmptyTextPaintingAllowed()) { - return; - } paintEmptyText(splitters, splitters, g); } diff --git a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorsSplitters.kt b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorsSplitters.kt index dca355d7122b..b971facf0fbe 100644 --- a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorsSplitters.kt +++ b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorsSplitters.kt @@ -244,6 +244,14 @@ open class EditorsSplitters internal constructor( */ private var startupPresentationAbandoned: Boolean = false + /** + * Whether a hold was ever taken, so that a release nobody paired can be told apart from a release that had nothing to pair with. + * + * Project open releases its hold unconditionally, but takes it only once restoring has returned a component — an open cancelled + * before that point releases a hold it never took, which is ordinary rather than a defect worth warning about. + */ + private var startupPresentationHoldTaken: Boolean = false + private val splittersLayout: EditorsSplittersLayout get() = layout as EditorsSplittersLayout @@ -265,9 +273,6 @@ open class EditorsSplitters internal constructor( private fun shouldDelayEmptyStatePresentation(state: EditorSplitterState): Boolean = state.hasFileEntries && shouldReopenEditorsOnStartup() - @Internal - fun isEmptyTextPaintingAllowed(): Boolean = shouldShowRichEmptyState() && emptyStateComponentController.isLegacyEmptyTextPaintingAllowed() - @TestOnly internal fun isEmptyStateComponentCreationPending(): Boolean = emptyStateComponentController.isCreationPending() @@ -284,6 +289,10 @@ open class EditorsSplitters internal constructor( * file named on the command line, What's New, a file a project wizard generated. * * The empty state is still prepared under the hold, so the wait costs no latency; see [endStartupEmptyStatePresentationHold]. + * + * Taking or releasing a hold can settle the empty state on the spot, which mounts or disposes a provider's component and therefore + * takes the write-intent lock. So this must be called from a context where that lock may be taken — the legacy `Dispatchers.EDT` or + * `invokeLater`, not the strict `Dispatchers.UI`, which forbids taking it rather than merely not carrying it. */ @RequiresEdt internal fun beginStartupEmptyStatePresentationHold() { @@ -291,6 +300,7 @@ open class EditorsSplitters internal constructor( return } startupPresentationHolds++ + startupPresentationHoldTaken = true applyStartupPresentationHolds() } @@ -298,19 +308,22 @@ open class EditorsSplitters internal constructor( * Reports that project open is done opening editors, so whatever this area shows now is what it keeps. * * That is knowledge rather than a guess, so an empty state prepared under the hold is presented without the creation delay that - * exists only to hide a flash before an editor appears. + * exists only to hide a flash before an editor appears. Rich components are therefore enabled *before* the gate opens: a creation + * that only becomes possible here then starts under a closed gate and mounts at once, instead of waiting out that delay. + * + * Must be called where the write-intent lock may be taken; see [beginStartupEmptyStatePresentationHold]. */ @RequiresEdt internal fun endStartupEmptyStatePresentationHold() { if (startupPresentationHolds > 0) { startupPresentationHolds-- } - else if (!startupPresentationAbandoned) { + else if (startupPresentationHoldTaken && !startupPresentationAbandoned) { // the counter exists so that an unbalanced release cannot strand presentation, not so that one can pass unnoticed LOG.warn("Editor empty state presentation hold released more often than it was taken") } - applyStartupPresentationHolds() enableRichEmptyStateComponents() + applyStartupPresentationHolds() } /** @@ -319,13 +332,15 @@ open class EditorsSplitters internal constructor( * Unlike [endStartupEmptyStatePresentationHold] this does not pair with one hold: it ends the whole startup hold, including a hold * still on its way in from a restore that outlived project open. Whatever the editor area shows at that point is what it keeps, * which is the same conclusion the ordinary release draws — reached by giving up rather than by finishing. + * + * Must be called where the write-intent lock may be taken; see [beginStartupEmptyStatePresentationHold]. */ @RequiresEdt internal fun abandonStartupEmptyStatePresentationHold() { startupPresentationAbandoned = true startupPresentationHolds = 0 - applyStartupPresentationHolds() enableRichEmptyStateComponents() + applyStartupPresentationHolds() } /** @@ -356,10 +371,16 @@ open class EditorsSplitters internal constructor( emptyStateComponentController.setCreationDelayForTests(delay) } + @TestOnly + internal fun setEmptyStateComponentPresentationGateTimeoutForTests(timeout: Duration?) { + emptyStateComponentController.setPresentationGateTimeoutForTests(timeout) + } + @TestOnly internal fun resetStartupEmptyStatePresentationHoldForTests() { startupPresentationHolds = 0 startupPresentationAbandoned = false + startupPresentationHoldTaken = false applyStartupPresentationHolds() } diff --git a/platform/platform-impl/src/com/intellij/openapi/project/impl/IdeProjectFrameAllocator.kt b/platform/platform-impl/src/com/intellij/openapi/project/impl/IdeProjectFrameAllocator.kt index f833555f0c3f..9328b2533078 100644 --- a/platform/platform-impl/src/com/intellij/openapi/project/impl/IdeProjectFrameAllocator.kt +++ b/platform/platform-impl/src/com/intellij/openapi/project/impl/IdeProjectFrameAllocator.kt @@ -201,7 +201,11 @@ internal class IdeProjectFrameAllocator( val project = projectInitObservable.awaitProjectInit() span("restoreEditors") { val fileEditorManager = project.serviceAsync() as FileEditorManagerImpl - restoreEditors(project = project, fileEditorManager = fileEditorManager) + restoreEditors( + project = project, + fileEditorManager = fileEditorManager, + opensFileAfterProjectOpen = options.opensFileAfterProjectOpen, + ) } val start = projectInitObservable.projectInitTimestamp @@ -519,7 +523,11 @@ private fun applyProjectFrameUiPolicy( disconnectIfDone() } -private suspend fun restoreEditors(project: Project, fileEditorManager: FileEditorManagerImpl) { +private suspend fun restoreEditors( + project: Project, + fileEditorManager: FileEditorManagerImpl, + opensFileAfterProjectOpen: Boolean, +) { coroutineScope { // only after FileEditorManager.init - DaemonCodeAnalyzer uses FileEditorManager // DaemonCodeAnalyzer wants DaemonCodeAnalyzerSettings @@ -539,9 +547,15 @@ private suspend fun restoreEditors(project: Project, fileEditorManager: FileEdit val (editorComponent, editorState) = fileEditorManager.init() // the empty state may be built as soon as restoring is over, in parallel with the rest of the project open, but it must not be // shown until project open is done opening editors of its own — the welcome tab and the README below are two of those. - // `ModalityState.any()`, like the release in `postOpenEditors`, so a modal dialog during startup cannot reorder the two. - withContext(NonCancellable + Dispatchers.UI + ModalityState.any().asContextElement()) { + // `ModalityState.any()`, like the release in `postOpenEditors`, so a modal dialog during startup cannot reorder the two, and + // `Dispatchers.EDT` because settling the empty state may mount or dispose components, which needs the write-intent lock. + withContext(NonCancellable + Dispatchers.EDT + ModalityState.any().asContextElement()) { editorComponent.beginStartupEmptyStatePresentationHold() + if (opensFileAfterProjectOpen) { + // a file named on the command line is opened after project open has returned, so project open's own release does not cover it; + // `openFileFromCommandLine` releases this second hold + editorComponent.beginStartupEmptyStatePresentationHold() + } if (editorState == null) { // there is nothing to restore, so preparation may start at once editorComponent.finishStartupEditorRestore() @@ -603,7 +617,9 @@ private suspend fun postOpenEditors( } } finally { - withContext(NonCancellable + Dispatchers.UI + ModalityState.any().asContextElement()) { + // `Dispatchers.EDT` rather than the strict UI dispatcher: releasing may mount or dispose the empty state right here, and both take + // the write-intent lock, which `Dispatchers.UI` forbids taking at all + withContext(NonCancellable + Dispatchers.EDT + ModalityState.any().asContextElement()) { if (!project.isDisposed) { // project open is done opening editors: whatever the editor area shows now is what it keeps fileEditorManager.mainSplitters.endStartupEmptyStatePresentationHold() diff --git a/platform/platform-impl/src/com/intellij/platform/PlatformProjectOpenProcessor.kt b/platform/platform-impl/src/com/intellij/platform/PlatformProjectOpenProcessor.kt index a84f2573f8a1..9ffdb696d210 100644 --- a/platform/platform-impl/src/com/intellij/platform/PlatformProjectOpenProcessor.kt +++ b/platform/platform-impl/src/com/intellij/platform/PlatformProjectOpenProcessor.kt @@ -16,11 +16,14 @@ import com.intellij.openapi.application.EDT import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.components.serviceAsync +import com.intellij.openapi.components.serviceIfCreated import com.intellij.openapi.components.serviceOrNull import com.intellij.openapi.diagnostic.getOrLogException import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.extensions.ExtensionPointName +import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.OpenFileDescriptor +import com.intellij.openapi.fileEditor.impl.FileEditorManagerImpl import com.intellij.openapi.module.Module import com.intellij.openapi.project.Project import com.intellij.openapi.project.ex.ProjectManagerEx @@ -119,6 +122,8 @@ class PlatformProjectOpenProcessor : ProjectOpenProcessor(), CommandLineProjectO projectName = dummyProjectName, runConfigurators = false, runConversionBeforeOpen = false, + // both callers of this go on to `openFileFromCommandLine`, which is what releases the hold this asks for + opensFileAfterProjectOpen = true, beforeOpen = { project -> project.service().state.isLocatedInTempDirectory = true options.beforeOpen?.invoke(project) ?: true @@ -205,7 +210,14 @@ class PlatformProjectOpenProcessor : ProjectOpenProcessor(), CommandLineProjectO val project = ProjectManagerEx.getInstanceEx().openProject( projectStoreBaseDir = baseDir, - options = if (baseDir == file) options else options.copy(projectName = file.fileName.toString()) + // the flag is set on exactly the condition under which `openFileFromCommandLine` is called below, so the hold it asks for is + // always the one that call releases + options = if (baseDir == file) { + options + } + else { + options.copy(projectName = file.fileName.toString(), opensFileAfterProjectOpen = true) + } ) if (project != null && file != baseDir) { openFileFromCommandLine(project, file, options.line, options.column) @@ -273,7 +285,13 @@ class PlatformProjectOpenProcessor : ProjectOpenProcessor(), CommandLineProjectO val project = ProjectManagerEx.getInstanceEx().openProjectAsync( projectIdentityFile = baseDir, - options = if (baseDir == file) options else options.copy(projectName = file.fileName.toString()) + // as in `doOpenProject`: set on exactly the condition under which `openFileFromCommandLine` is called below + options = if (baseDir == file) { + options + } + else { + options.copy(projectName = file.fileName.toString(), opensFileAfterProjectOpen = true) + } ) if (project != null && file != baseDir) { openFileFromCommandLine(project, file, options.line, options.column) @@ -404,25 +422,56 @@ class PlatformProjectOpenProcessor : ProjectOpenProcessor(), CommandLineProjectO get() = "text editor" } +/** + * Opens a file named on the command line, once the project it belongs to is open. + * + * Every caller opens the project with [OpenProjectTask.opensFileAfterProjectOpen] set, so a hold on the editor empty state is waiting + * to be released here: this navigation happens after project open has finished and released its own hold, and without the extra hold + * the empty state would be shown for as long as it takes to get here and then immediately replaced by this file. + */ private fun openFileFromCommandLine(project: Project, file: Path, line: Int, column: Int) { StartupManager.getInstance(project).runAfterOpened { ApplicationManager.getApplication().invokeLater(Runnable { - if (project.isDisposed || !Files.exists(file)) { - return@Runnable - } + try { + if (project.isDisposed || !Files.exists(file)) { + return@Runnable + } - val virtualFile = ProjectUtilCore.getFileAndRefresh(file) ?: return@Runnable - val navigatable = if (line > 0) { - OpenFileDescriptor(project, virtualFile, line - 1, column.coerceAtLeast(0)) + val virtualFile = ProjectUtilCore.getFileAndRefresh(file) ?: return@Runnable + val navigatable = if (line > 0) { + OpenFileDescriptor(project, virtualFile, line - 1, column.coerceAtLeast(0)) + } + else { + PsiNavigationSupport.getInstance().createNavigatable(project, virtualFile, -1) + } + navigatable.navigate(true) } - else { - PsiNavigationSupport.getInstance().createNavigatable(project, virtualFile, -1) + finally { + // in a `finally`, so that a file that turned out not to exist releases the hold as well as one that opened + endStartupEmptyStatePresentationHold(project) } - navigatable.navigate(true) }, ModalityState.nonModal(), project.disposed) } } +/** + * Releases the hold [OpenProjectTask.opensFileAfterProjectOpen] asked for. + * + * Nothing to release if the editor area was never built: `mainSplitters` is a `lateinit` assigned inside `initJob`, and where that job + * did not complete, editor restoring never took a hold either. + */ +@RequiresEdt +private fun endStartupEmptyStatePresentationHold(project: Project) { + if (project.isDisposed) { + return + } + val fileEditorManager = project.serviceIfCreated() as? FileEditorManagerImpl ?: return + if (!fileEditorManager.initJob.isCompleted || fileEditorManager.initJob.isCancelled) { + return + } + fileEditorManager.mainSplitters.endStartupEmptyStatePresentationHold() +} + internal suspend fun attachToProjectAsync( projectToClose: Project, projectDir: Path, 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 40cad38c2b76..454b087ebe5f 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 @@ -11,7 +11,10 @@ import com.intellij.openapi.actionSystem.KeyboardGestureAction import com.intellij.openapi.actionSystem.KeyboardModifierGestureShortcut import com.intellij.openapi.actionSystem.KeyboardShortcut import com.intellij.openapi.actionSystem.Shortcut +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.fileEditor.FileEditorManagerKeys import com.intellij.openapi.keymap.Keymap @@ -20,6 +23,7 @@ import com.intellij.openapi.keymap.KeymapUtil import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.testFramework.ExtensionTestUtil import com.intellij.testFramework.LightVirtualFile +import com.intellij.testFramework.LoggedErrorProcessor import com.intellij.testFramework.PlatformTestUtil import com.intellij.testFramework.junit5.RunInEdt import com.intellij.testFramework.junit5.TestApplication @@ -43,8 +47,8 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.awt.event.InputEvent import java.awt.event.KeyEvent -import java.awt.image.BufferedImage import java.nio.file.Files +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import javax.swing.JComponent import javax.swing.JPanel @@ -97,6 +101,7 @@ internal class EditorEmptyTextPainterTest { // a creation left waiting out an inflated delay must not survive into the next test splitters.suppressRichEmptyStateComponents() splitters.setEmptyStateComponentCreationDelayForTests(null) + splitters.setEmptyStateComponentPresentationGateTimeoutForTests(null) splitters.resetStartupEmptyStatePresentationHoldForTests() } @@ -261,7 +266,7 @@ internal class EditorEmptyTextPainterTest { } @Test - fun componentProviderSuppressesEmptyTextHints(@TestDisposable disposable: Disposable) { + fun richComponentProviderIsPreferredOverTheFallbackEmptyText(@TestDisposable disposable: Disposable) { resetShortcuts(PROVIDER_ACTION_ID, listOf(doubleCtrlShortcut)) resetShortcuts(IdeActions.ACTION_SEARCH_EVERYWHERE, listOf(doubleShiftShortcut)) registerEmptyTextProvider(disposable) @@ -274,11 +279,11 @@ internal class EditorEmptyTextPainterTest { waitForEmptyStateComponentCreation(splitters) assertThat(findEmptyStateComponent(splitters)).isNotNull() - assertThat(RecordingEditorEmptyTextPainter().paintEmptyTextLines(splitters)).isEmpty() + assertThat(findEmptyTextComponent(splitters)).isNull() } @Test - fun componentProviderCreationPendingSuppressesEmptyTextHints(@TestDisposable disposable: Disposable) { + fun aPendingRichCreationLeavesTheFallbackEmptyTextUnmounted(@TestDisposable disposable: Disposable) { resetShortcuts(PROVIDER_ACTION_ID, listOf(doubleCtrlShortcut)) registerEmptyTextProvider(disposable) registerComponentProvider(disposable, includeFallbackProvider = true) @@ -297,8 +302,9 @@ internal class EditorEmptyTextPainterTest { waitForDeferred(gateEntered) try { + // the fallback is not a stand-in for a rich component that is still being prepared: this area shows nothing until it mounts, + // which is what bounds the wait on the presentation gate assertThat(findEmptyTextComponent(splitters)).isNull() - assertThat(RecordingEditorEmptyTextPainter().paintEmptyTextLines(splitters)).isEmpty() } finally { releaseGate.complete(Unit) @@ -320,7 +326,6 @@ internal class EditorEmptyTextPainterTest { assertThat(findEmptyStateComponent(splitters)).isNull() assertThat(findEmptyTextComponent(splitters)).isNotNull() assertThat(emptyStateLayout(splitters).emptyStateOverlay).isSameAs(findEmptyTextComponent(splitters)?.parent) - assertThat(RecordingEditorEmptyTextPainter().paintEmptyTextLines(splitters)).isEmpty() } @Test @@ -337,13 +342,29 @@ internal class EditorEmptyTextPainterTest { assertThat(findEmptyStateComponent(splitters)).isNull() assertThat(findEmptyTextComponent(splitters)).isNotNull() - assertThat(RecordingEditorEmptyTextPainter().paintEmptyTextLines(splitters)).isEmpty() } @Test - fun unavailableComponentProviderDoesNotSuppressEmptyTextHints(@TestDisposable disposable: Disposable) { + fun fallbackEmptyTextReachedThroughARichProviderIsNotDelayed(@TestDisposable disposable: Disposable) { resetShortcuts(PROVIDER_ACTION_ID, listOf(doubleCtrlShortcut)) registerEmptyTextProvider(disposable) + registerNullAndFallbackComponentProviders(disposable) + + val splitters = manager.mainSplitters + manager.closeAllFiles() + splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY) + splitters.enableRichEmptyStateComponents() + + // the delay holds back a rich component; what is presented here is plain empty text, which this area showed before the delay + // existed — reaching it through a rich provider that built nothing does not make it worth hiding + waitForEmptyTextComponent(splitters, "A fallback reached through a rich provider waited out the creation delay") + } + + @Test + fun unavailableComponentProviderMountsNothing(@TestDisposable disposable: Disposable) { + resetShortcuts(PROVIDER_ACTION_ID, listOf(doubleCtrlShortcut)) + registerEmptyTextProvider(disposable) + // the provider fails the test if it is invoked at all, so this covers availability as well as what gets mounted registerUnavailableComponentProvider(disposable) val splitters = manager.mainSplitters @@ -353,8 +374,7 @@ internal class EditorEmptyTextPainterTest { waitForEmptyStateComponentCreation(splitters) assertThat(findEmptyStateComponent(splitters)).isNull() - assertThat(RecordingEditorEmptyTextPainter().paintEmptyTextLines(splitters)) - .containsExactly(PROVIDER_ACTION_TEXT + " " + KeymapUtil.getShortcutText(doubleCtrlShortcut) + "") + assertThat(findEmptyTextComponent(splitters)).isNull() } @Test @@ -424,7 +444,6 @@ internal class EditorEmptyTextPainterTest { assertThat(providerCalls).hasValue(0) assertThat(findEmptyStateComponent(splitters)).isNull() - assertThat(RecordingEditorEmptyTextPainter().paintEmptyTextLines(splitters)).isEmpty() val openFilesJob = splitters.openFilesAsync(requestFocus = false) PlatformTestUtil.waitWhileBusy { !openFilesJob.isCompleted } @@ -518,12 +537,97 @@ internal class EditorEmptyTextPainterTest { waitForEmptyStateComponent(splitters, "Releasing the startup hold waited out the creation delay") } + @Test + fun everyStartupHoldMustBeReleasedBeforeTheEmptyStateIsPresented(@TestDisposable disposable: Disposable) { + val splitters = manager.mainSplitters + val providerCalls = AtomicInteger() + registerComponentProvider(disposable, providerCalls = providerCalls) + manager.closeAllFiles() + splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY) + + // project open holds for its own editor phase, and a file named on the command line is opened after that phase has ended: two + // holds, taken by two owners that know nothing about each other + splitters.beginStartupEmptyStatePresentationHold() + splitters.beginStartupEmptyStatePresentationHold() + splitters.finishStartupEditorRestore() + waitForProviderCall(providerCalls, "The empty state was not prepared under the startup holds") + + splitters.endStartupEmptyStatePresentationHold() + dispatchEventsFor(100.milliseconds) + + assertThat(findEmptyStateComponent(splitters)).isNull() + + splitters.endStartupEmptyStatePresentationHold() + + waitForEmptyStateComponent(splitters, "Releasing the last startup hold waited out the creation delay") + } + + @Test + fun releasingTheStartupHoldFromProjectOpensOwnHopMountsTheEmptyState(@TestDisposable disposable: Disposable) { + val splitters = manager.mainSplitters + val providerCalls = AtomicInteger() + registerComponentProvider(disposable, providerCalls = providerCalls) + manager.closeAllFiles() + splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY) + splitters.beginStartupEmptyStatePresentationHold() + splitters.finishStartupEditorRestore() + waitForProviderCall(providerCalls, "The empty state was not prepared under the startup hold") + + // every other test releases straight from the test body; this one releases through the hop project open actually uses, so a release + // that reaches the mount only because the test body happens to be on the EDT with a lock cannot pass + releaseStartupHoldFromProjectOpensHop(splitters) + + waitForEmptyStateComponent(splitters, "A release from project open's own hop did not mount the empty state") + } + + @Test + fun aHoldNobodyReleasesStopsHoldingBackTheEmptyState(@TestDisposable disposable: Disposable) { + val splitters = manager.mainSplitters + registerComponentProvider(disposable) + manager.closeAllFiles() + splitters.setEmptyStateComponentCreationDelayForTests(Duration.ZERO) + splitters.setEmptyStateComponentPresentationGateTimeoutForTests(200.milliseconds) + + splitters.beginStartupEmptyStatePresentationHold() + splitters.finishStartupEditorRestore() + + // nobody releases this hold, and the fallback empty text is not shown while a rich provider is available: without a ceiling on the + // wait, this area would show nothing at all for as long as the project stays open + waitForEmptyStateComponent(splitters, "A hold nobody released kept the empty state invisible") + } + + @Test + fun aReleaseThatHadNoHoldToPairWithIsNotReportedAsUnbalanced(@TestDisposable disposable: Disposable) { + val splitters = manager.mainSplitters + registerComponentProvider(disposable) + manager.closeAllFiles() + splitters.setEmptyStateComponentCreationDelayForTests(Duration.ZERO) + + val holdWarnings = mutableListOf() + // project open releases its hold unconditionally, but takes it only once restoring has returned a component: an open cancelled + // before that point releases a hold it never took, which is ordinary + LoggedErrorProcessor.executeWith(object : LoggedErrorProcessor() { + override fun processWarn(category: String, message: String, t: Throwable?): Boolean { + if (message.contains("presentation hold")) { + holdWarnings.add(message) + } + return true + } + }) { + splitters.endStartupEmptyStatePresentationHold() + } + + assertThat(holdWarnings).isEmpty() + waitForEmptyStateComponent(splitters, "A release with no hold to pair with left presentation held") + } + @Test fun anEditorOpenedDuringTheStartupHoldPreventsTheEmptyStateEntirely(@TestDisposable disposable: Disposable) { val splitters = manager.mainSplitters val disposedComponents = AtomicInteger() + val disposedWithoutLock = AtomicBoolean() val providerCalls = AtomicInteger() - registerComponentProvider(disposable, disposedComponents, providerCalls = providerCalls) + registerComponentProvider(disposable, disposedComponents, providerCalls = providerCalls, disposedWithoutLock = disposedWithoutLock) manager.closeAllFiles() splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY) splitters.beginStartupEmptyStatePresentationHold() @@ -539,6 +643,9 @@ internal class EditorEmptyTextPainterTest { assertThat(findEmptyStateComponent(splitters)).isNull() assertThat(disposedComponents).hasValue(1) + // discarding runs on a strict-UI hop inside the creation job, so the lock a provider needs to release an editor has to be taken + // there rather than inherited from a dispatcher + assertThat(disposedWithoutLock).isFalse() } @Test @@ -553,9 +660,9 @@ internal class EditorEmptyTextPainterTest { splitters.beginStartupEmptyStatePresentationHold() splitters.finishStartupEditorRestore() - // plain empty text is what this area showed before the hold existed, and holding it back would leave the area blank rather - // than plain, because legacy painting is suppressed while a creation is pending - waitForEmptyTextComponent(splitters) + // plain empty text is what this area showed before the hold existed, and holding it back would leave the area blank rather than + // plain: nothing else paints those hints any more + waitForEmptyTextComponent(splitters, "The fallback empty text was held back by the startup presentation hold") } @Test @@ -597,30 +704,6 @@ internal class EditorEmptyTextPainterTest { assertThat(findEmptyStateComponent(splitters)).isNull() } - @Test - fun doNotReopenFilesKeepsRichEmptyStateDisabledUntilExplicitEnable(@TestDisposable disposable: Disposable) { - val splitters = manager.mainSplitters - registerComponentProvider(disposable) - manager.closeAllFiles() - val project = projectFixture.get() - FileEditorManagerKeys.DO_NOT_REOPEN_FILES.set(project, true) - try { - splitters.setEmptyStateComponentCreationDelayForTests(Duration.ZERO) - splitters.finishStartupEditorRestore() - dispatchEventsFor(100.milliseconds) - - // editors arrive from elsewhere here, so the absence of a local restore settles nothing - assertThat(splitters.isEmptyStateComponentCreationPending()).isFalse() - assertThat(findEmptyStateComponent(splitters)).isNull() - - splitters.enableRichEmptyStateComponents() - waitForEmptyStateComponent(splitters, "The explicit enable did not mount the empty state") - } - finally { - FileEditorManagerKeys.DO_NOT_REOPEN_FILES.set(project, null) - } - } - @Test @Suppress("RAW_SCOPE_CREATION") fun restoringStateWithoutFileEntriesPreparesTheEmptyStateAtOnce(@TestDisposable disposable: Disposable) { @@ -762,6 +845,7 @@ internal class EditorEmptyTextPainterTest { disposedComponents: AtomicInteger = AtomicInteger(), includeFallbackProvider: Boolean = false, providerCalls: AtomicInteger = AtomicInteger(), + disposedWithoutLock: AtomicBoolean = AtomicBoolean(), ) { ExtensionTestUtil.maskExtensions(EditorEmptyStateComponentProvider.EP_NAME, buildList { add(object : EditorEmptyStateComponentProvider { @@ -776,6 +860,11 @@ internal class EditorEmptyTextPainterTest { } override fun disposeComponent(component: JComponent) { + // a real provider may release an editor here, through `removeNotify` — a headless test never realizes the hierarchy, so + // this is where the platform's lock is observable at all + if (!ApplicationManager.getApplication().isWriteIntentLockAcquired) { + disposedWithoutLock.set(true) + } disposedComponents.incrementAndGet() } }) @@ -840,12 +929,40 @@ internal class EditorEmptyTextPainterTest { } } - private fun waitForEmptyTextComponent(splitters: EditorsSplitters) { + private fun waitForEmptyTextComponent(splitters: EditorsSplitters, message: String) { try { PlatformTestUtil.waitWhileBusy { findEmptyTextComponent(splitters) == null } } catch (e: AssertionError) { - throw AssertionError("The fallback empty text was held back by the startup presentation hold", e) + throw AssertionError(message, e) + } + } + + /** + * Releases one startup hold from the hop `IdeProjectFrameAllocator` uses: [Dispatchers.EDT] with [ModalityState.any], off the test + * body's own stack. + * + * `Dispatchers.EDT` and not the strict UI dispatcher because releasing may mount or dispose components, which takes the write-intent + * lock — the strict dispatcher forbids taking it. + */ + @Suppress("RAW_SCOPE_CREATION") + private fun releaseStartupHoldFromProjectOpensHop(splitters: EditorsSplitters) { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + var failure: Throwable? = null + val releaseJob = scope.launch(Dispatchers.EDT + ModalityState.any().asContextElement()) { + try { + splitters.endStartupEmptyStatePresentationHold() + } + catch (e: Throwable) { + failure = e + } + } + PlatformTestUtil.waitWhileBusy { !releaseJob.isCompleted } + failure?.let { throw AssertionError("Releasing the startup hold from project open's own hop failed", it) } + } + finally { + scope.cancel() } } @@ -935,18 +1052,6 @@ internal class EditorEmptyTextPainterTest { return lines.filter { it.startsWith(PROVIDER_ACTION_TEXT) } } - fun paintEmptyTextLines(splitters: JComponent): List { - val image = BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB) - val graphics = image.createGraphics() - try { - doPaintEmptyText(splitters, graphics) - } - finally { - graphics.dispose() - } - return lines - } - override fun appendLine(painter: UIUtil.TextPainter, line: String) { lines.add(line) }