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 17bd13227c70..468e300d531a 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 @@ -16,6 +16,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -28,19 +30,44 @@ import java.awt.Dimension import java.awt.GridBagConstraints import java.awt.GridBagLayout import java.awt.LayoutManager2 +import java.util.concurrent.atomic.AtomicLong import javax.swing.Box import javax.swing.BoxLayout import javax.swing.JComponent import javax.swing.JPanel +import kotlin.coroutines.AbstractCoroutineContextElement +import kotlin.coroutines.CoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource private val LOG = logger() internal const val EDITOR_ROOT_COMPONENT_CONSTRAINT: @NonNls String = "EditorRootComponent" internal const val EMPTY_STATE_COMPONENT_CONSTRAINT: @NonNls String = "EditorEmptyStateComponent" private val EMPTY_STATE_COMPONENT_CREATION_DELAY = 300.milliseconds -private val SLOW_EMPTY_STATE_COMPONENT_PROVIDER_THRESHOLD = 100.milliseconds + +/** + * Budget for the part of a single [EditorEmptyStateComponentProvider.createComponent] call that does not run on the UI thread. + * + * A [EditorEmptyStateComponentProvider.Kind.FALLBACK] provider only builds Swing components, so it has nothing to spend this on. + * A [EditorEmptyStateComponentProvider.Kind.RICH] one is allowed to resolve services and query a backend before it has anything to + * build, so its budget is the point at which that stops being plausible. + */ +private fun slowPreparationThreshold(kind: EditorEmptyStateComponentProvider.Kind): Duration = when (kind) { + EditorEmptyStateComponentProvider.Kind.FALLBACK -> 100.milliseconds + EditorEmptyStateComponentProvider.Kind.RICH -> 1.seconds +} + +/** + * Budget for the UI-thread part of a single [EditorEmptyStateComponentProvider.createComponent] call, whatever the provider's kind. + * + * Preparation overlaps project open, so a UI-thread step this long is a startup freeze rather than a component that took its time — + * which is why it is budgeted apart from [slowPreparationThreshold] instead of disappearing into a generous end-to-end number. + * Only what a provider runs inside [buildEditorEmptyStateComponentOnUiThread] is measured against it. + */ +private val SLOW_UI_BUILD_THRESHOLD = 100.milliseconds internal class EditorEmptyStateComponentController( private val splitters: EditorsSplitters, @@ -52,6 +79,13 @@ internal class EditorEmptyStateComponentController( private var creationJob: Job? = null private var creationGeneration: Int = 0 private var richComponentsEnabled: Boolean = false + + /** + * `false` while something else — startup opening editors of its own — still owns what this area is going to show. + * + * Components are prepared anyway, because preparation is invisible; only the mount waits for the gate, because a mount is not. + */ + private val presentationAllowed = MutableStateFlow(true) private var creationDelay: Duration = EMPTY_STATE_COMPONENT_CREATION_DELAY private var creationGate: (suspend () -> Unit)? = null @@ -85,6 +119,24 @@ internal class EditorEmptyStateComponentController( update() } + /** + * Opens or closes the presentation gate: while it is closed, components are still prepared, but nothing is mounted. + * + * Opening it is knowledge that nothing is going to take this area over any more, so a creation that was started under a closed + * gate mounts without waiting out [EMPTY_STATE_COMPONENT_CREATION_DELAY] — the delay is only a guess that an editor may still be + * arriving, and where there is knowledge there is no need to also guess. + */ + fun setPresentationAllowed(allowed: Boolean) { + if (presentationAllowed.value == allowed) { + return + } + presentationAllowed.value = allowed + if (allowed) { + // a creation parked on the gate mounts on its own; this covers the case where there is nothing parked yet + update() + } + } + fun update() { if (showEmptyState()) { showComponents() @@ -101,6 +153,7 @@ internal class EditorEmptyStateComponentController( fun cancelCreation() { val job = creationJob ?: return + // bump the generation before clearing the job, so the cancelled job's `finally` cannot clear a job started after it creationGeneration++ creationJob = null job.cancel() @@ -128,8 +181,9 @@ internal class EditorEmptyStateComponentController( } } - fun setCreationDelayForTests(delay: Duration) { - creationDelay = delay + /** @param delay `null` restores the production delay. */ + fun setCreationDelayForTests(delay: Duration?) { + creationDelay = delay ?: EMPTY_STATE_COMPONENT_CREATION_DELAY } fun setCreationGateForTests(gate: (suspend () -> Unit)?) { @@ -149,14 +203,13 @@ internal class EditorEmptyStateComponentController( return } + // components are built first and presented afterwards, so a closed gate costs no latency: by the time it opens they are ready + val presentationHeld = !presentationAllowed.value val generation = ++creationGeneration creationJob = coroutineScope.launch(Dispatchers.Default + CoroutineName("create editor empty state components")) { var entries: List = emptyList() var mounted = false try { - if (kind == EditorEmptyStateComponentProvider.Kind.RICH) { - delay(creationDelay) - } creationGate?.invoke() if (!isCreationValidOnEdt(generation, kind)) { return@launch @@ -173,11 +226,21 @@ 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) { + // 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 } + } withContext(Dispatchers.EDT) { - if (!isCreationValid(generation, entries.firstOrNull()?.kind ?: kind)) { - return@withContext - } - if (entries.isEmpty()) { + if (!isCreationValid(generation, entries.first().kind)) { return@withContext } writeIntentReadAction { @@ -284,12 +347,16 @@ internal class EditorEmptyStateComponentController( break } val component = try { + val uiBuildTime = EditorEmptyStateUiBuildTime() val startedAt = TimeSource.Monotonic.markNow() - val result = provider.createComponent(splitters) - val elapsed = startedAt.elapsedNow() - if (elapsed >= SLOW_EMPTY_STATE_COMPONENT_PROVIDER_THRESHOLD) { - LOG.warn("Slow editor empty state component provider $provider from ${pluginDescriptor.pluginId}: $elapsed") - } + val result = withContext(uiBuildTime) { provider.createComponent(splitters) } + reportSlowPreparation( + provider = provider, + pluginDescriptor = pluginDescriptor, + kind = kind, + elapsed = startedAt.elapsedNow(), + uiElapsed = uiBuildTime.elapsed, + ) result } catch (e: CancellationException) { @@ -330,6 +397,42 @@ internal class EditorEmptyStateComponentController( } } +private fun reportSlowPreparation( + provider: EditorEmptyStateComponentProvider, + pluginDescriptor: PluginDescriptor, + kind: EditorEmptyStateComponentProvider.Kind, + elapsed: Duration, + uiElapsed: Duration, +) { + val offUiElapsed = maxOf(Duration.ZERO, elapsed - uiElapsed) + if (uiElapsed < SLOW_UI_BUILD_THRESHOLD && offUiElapsed < slowPreparationThreshold(kind)) { + return + } + LOG.warn( + "Slow editor empty state component preparation by $provider from ${pluginDescriptor.pluginId}: " + + "$elapsed, of which $uiElapsed on the UI thread" + ) +} + +/** + * How long a provider spent building on the UI thread, accumulated by [buildEditorEmptyStateComponentOnUiThread]. + * + * A context element rather than a return value, so that a provider reports it by choosing where to hop rather than by threading a + * measurement back through its own signature. + */ +internal class EditorEmptyStateUiBuildTime : AbstractCoroutineContextElement(Key) { + companion object Key : CoroutineContext.Key + + private val nanos = AtomicLong() + + fun add(duration: Duration) { + nanos.addAndGet(duration.inWholeNanoseconds) + } + + val elapsed: Duration + get() = nanos.get().nanoseconds +} + private data class EditorEmptyStateProviderEntry( val provider: EditorEmptyStateComponentProvider, val pluginDescriptor: PluginDescriptor, 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 94921faee98b..8617e6cb8106 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 @@ -1,9 +1,15 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.openapi.fileEditor.impl +import com.intellij.openapi.application.UI import com.intellij.openapi.extensions.ExtensionPointName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.withContext import org.jetbrains.annotations.ApiStatus import javax.swing.JComponent +import kotlin.time.TimeSource @ApiStatus.Internal interface EditorEmptyStateComponentProvider { @@ -12,7 +18,17 @@ interface EditorEmptyStateComponentProvider { fun isAvailable(splitters: EditorsSplitters): Boolean = true /** - * Called asynchronously by the editor host. Implementations should choose their dispatcher explicitly. + * Called asynchronously by the editor host, off the EDT. Implementations should choose their dispatcher explicitly, and own the hop + * to the EDT they need to build Swing components on — through [buildEditorEmptyStateComponentOnUiThread], so that the host can + * budget that half separately. + * + * The two halves are budgeted apart, because they cost different things: 100 ms of UI-thread time is a startup freeze whatever the + * provider's kind, while time spent off it only delays the component — 100 ms for [Kind.FALLBACK], which has nothing to resolve + * before it builds, and one second for [Kind.RICH], which may resolve services and query a backend first. Exceeding either budget + * is reported as a warning. + * + * The component is not necessarily shown as soon as it is returned — the host may be holding presentation back while startup still + * decides what the editor area will show. */ suspend fun createComponent(splitters: EditorsSplitters): JComponent? @@ -29,4 +45,34 @@ interface EditorEmptyStateComponentProvider { val EP_NAME: ExtensionPointName = ExtensionPointName("com.intellij.editorEmptyStateComponentProvider") } +} + +/** + * Runs the UI-thread half of [EditorEmptyStateComponentProvider.createComponent] — Swing construction — and reports how long it took + * to the host. + * + * An empty state is prepared while the project is still opening, so what a provider does on the UI thread there freezes startup while + * what it does off it only delays a component that nothing is waiting for. The host budgets the two separately, and can tell them + * apart only for a provider that hops through here; a provider that needs the write-intent lock can still take it inside the block. + * + * 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. + */ +@ApiStatus.Internal +suspend fun buildEditorEmptyStateComponentOnUiThread(block: suspend CoroutineScope.() -> T): T { + val uiBuildTime = currentCoroutineContext()[EditorEmptyStateUiBuildTime] + return withContext(Dispatchers.UI) { + if (uiBuildTime == null) { + block() + } + else { + val startedAt = TimeSource.Monotonic.markNow() + try { + block() + } + finally { + uiBuildTime.add(startedAt.elapsedNow()) + } + } + } } \ No newline at end of file diff --git a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyTextComponentProvider.kt b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyTextComponentProvider.kt index 2e98dba7bd35..b20d25f85587 100644 --- a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyTextComponentProvider.kt +++ b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyTextComponentProvider.kt @@ -1,10 +1,7 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.openapi.fileEditor.impl -import com.intellij.openapi.application.EDT import com.intellij.openapi.application.ApplicationManager -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import java.awt.Graphics import javax.swing.JComponent @@ -16,7 +13,8 @@ internal class EditorEmptyTextComponentProvider : EditorEmptyStateComponentProvi } override suspend fun createComponent(splitters: EditorsSplitters): JComponent { - return withContext(Dispatchers.EDT) { + // plain Swing construction, so the UI thread is enough without the write-intent lock + return buildEditorEmptyStateComponentOnUiThread { EditorEmptyTextComponent(splitters) } } 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 d92ee790a6be..dca355d7122b 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 @@ -227,6 +227,23 @@ open class EditorsSplitters internal constructor( private var emptyStatePresentationReady: Boolean = true + /** + * How many times presentation of the empty state has been held back while project open is still opening editors of its own + * (see `IdeProjectFrameAllocator`). The empty state is prepared under a hold, but not shown. + * + * A counter rather than a flag, so that a repeated or unbalanced release cannot strand presentation held. + */ + private var startupPresentationHolds: Int = 0 + + /** + * Set once project open has given up on releasing its own hold (see [abandonStartupEmptyStatePresentationHold]). + * + * From then on a hold is refused rather than counted, because the code that would have released it is gone: restoring takes its hold + * uninterruptibly, so it can still arrive after project open has been cancelled, and a hold nobody releases leaves this area blank + * for as long as the project stays open. + */ + private var startupPresentationAbandoned: Boolean = false + private val splittersLayout: EditorsSplittersLayout get() = layout as EditorsSplittersLayout @@ -262,11 +279,90 @@ open class EditorsSplitters internal constructor( emptyStateComponentController.enableRichComponents() } + /** + * Holds back presentation of the empty state while project open may still open editors of its own — the welcome tab, a README, a + * 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]. + */ + @RequiresEdt + internal fun beginStartupEmptyStatePresentationHold() { + if (startupPresentationAbandoned) { + return + } + startupPresentationHolds++ + applyStartupPresentationHolds() + } + + /** + * 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. + */ + @RequiresEdt + internal fun endStartupEmptyStatePresentationHold() { + if (startupPresentationHolds > 0) { + startupPresentationHolds-- + } + else if (!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() + } + + /** + * Reports that project open will not release the hold it took, because it failed or was cancelled before it got that far. + * + * 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. + */ + @RequiresEdt + internal fun abandonStartupEmptyStatePresentationHold() { + startupPresentationAbandoned = true + startupPresentationHolds = 0 + applyStartupPresentationHolds() + enableRichEmptyStateComponents() + } + + /** + * Reports that startup editor restoring is over, so the empty state may start being prepared. + * + * Whether it is also shown right away is decided separately, by [beginStartupEmptyStatePresentationHold] and its release. + */ + @RequiresEdt + internal fun finishStartupEditorRestore() { + emptyStatePresentationReady = true + if (!coroutineScope.isActive) { + return + } + // `mainSplitters` is a lateinit assigned inside `initJob`, so it must not be touched before that job completes + if (manager.initJob.isCompleted && this === manager.mainSplitters && shouldReopenEditorsOnStartup()) { + enableRichEmptyStateComponents() + } + // restoring disposes whatever was there before it started, so an update is due whether or not this enabled anything + updateEmptyStateComponent() + } + + private fun applyStartupPresentationHolds() { + emptyStateComponentController.setPresentationAllowed(startupPresentationHolds == 0) + } + @TestOnly - internal fun setEmptyStateComponentCreationDelayForTests(delay: Duration) { + internal fun setEmptyStateComponentCreationDelayForTests(delay: Duration?) { emptyStateComponentController.setCreationDelayForTests(delay) } + @TestOnly + internal fun resetStartupEmptyStatePresentationHoldForTests() { + startupPresentationHolds = 0 + startupPresentationAbandoned = false + applyStartupPresentationHolds() + } + @TestOnly internal fun setEmptyStateComponentCreationGateForTests(gate: (suspend () -> Unit)?) { emptyStateComponentController.setCreationGateForTests(gate) @@ -427,7 +523,7 @@ open class EditorsSplitters internal constructor( } @Internal - suspend fun restoreEditors(state: EditorSplitterState, requestFocus: Boolean = true) { + suspend fun restoreEditors(state: EditorSplitterState, requestFocus: Boolean = true, isStartupRestore: Boolean = false) { val delayEmptyStatePresentation = shouldDelayEmptyStatePresentation(state) withContext(Dispatchers.EDT) { if (delayEmptyStatePresentation) { @@ -464,9 +560,14 @@ open class EditorsSplitters internal constructor( } finally { withContext(NonCancellable + Dispatchers.EDT) { - emptyStatePresentationReady = true - if (coroutineScope.isActive) { - updateEmptyStateComponent() + if (isStartupRestore) { + finishStartupEditorRestore() + } + else { + emptyStatePresentationReady = true + if (coroutineScope.isActive) { + updateEmptyStateComponent() + } } } } @@ -498,10 +599,7 @@ open class EditorsSplitters internal constructor( } finally { withContext(NonCancellable + Dispatchers.EDT) { - emptyStatePresentationReady = true - if (coroutineScope.isActive) { - updateEmptyStateComponent() - } + finishStartupEditorRestore() } } } @@ -570,7 +668,7 @@ open class EditorsSplitters internal constructor( val stateToRestore = state.getAndSet(null) try { if (stateToRestore != null) { - restoreEditors(state = stateToRestore, requestFocus = requestFocus) + restoreEditors(state = stateToRestore, requestFocus = requestFocus, isStartupRestore = true) } } finally { 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 8069a0c9a4a1..f833555f0c3f 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 @@ -268,6 +268,8 @@ internal class IdeProjectFrameAllocator( }.invokeOnCompletion { throwable -> if (throwable != null) { onNoEditorsLeft() + // `postOpenEditors` never ran, or never reached its own release + releaseStartupEmptyStatePresentationHold(project) } } } @@ -535,6 +537,16 @@ 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()) { + editorComponent.beginStartupEmptyStatePresentationHold() + if (editorState == null) { + // there is nothing to restore, so preparation may start at once + editorComponent.finishStartupEditorRestore() + } + } if (editorState == null) { WelcomeScreenTabService.getInstance(fileEditorManager.project).openTab() serviceAsync().editorRestoringTillHighlighted() @@ -591,14 +603,40 @@ private suspend fun postOpenEditors( } } finally { - withContext(NonCancellable + Dispatchers.EDT) { + withContext(NonCancellable + Dispatchers.UI + ModalityState.any().asContextElement()) { if (!project.isDisposed) { - fileEditorManager.mainSplitters.enableRichEmptyStateComponents() + // project open is done opening editors: whatever the editor area shows now is what it keeps + fileEditorManager.mainSplitters.endStartupEmptyStatePresentationHold() } } } } +/** + * Ends the startup presentation hold when [postOpenEditors] never got to release the hold [restoreEditors] took. + * + * This is the abandoning release rather than a paired one: restoring takes its hold uninterruptibly, so it may still be taken after + * project open has been cancelled, and a paired release that arrives first would be spent on a hold that does not exist yet. + */ +private fun releaseStartupEmptyStatePresentationHold(project: Project) { + val fileEditorManager = project.serviceIfCreated() as? FileEditorManagerImpl ?: return + // `mainSplitters` is a lateinit assigned inside `initJob`, so it exists only once that job has completed successfully — and where it + // never did, restoring never returned from `init()` either, so no hold was ever taken + fileEditorManager.initJob.invokeOnCompletion { throwable -> + if (throwable != null) { + return@invokeOnCompletion + } + ApplicationManager.getApplication().invokeLater( + { + if (!project.isDisposed) { + fileEditorManager.mainSplitters.abandonStartupEmptyStatePresentationHold() + } + }, + ModalityState.any(), + ) + } +} + private suspend fun focusSelectedEditor(editorComponent: EditorsSplitters) { val composite = editorComponent.currentWindow?.selectedComposite ?: return // TODO: this check for JB Client is made to keep the same behaviour in monolith, 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 ae95cf280610..e42fc11f510a 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 @@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.assertj.core.api.Assertions.assertThat import org.jdom.Element @@ -49,6 +50,8 @@ import javax.swing.JComponent import javax.swing.JPanel import javax.swing.KeyStroke import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.minutes @TestApplication @RunInEdt(writeIntent = true) @@ -89,7 +92,12 @@ internal class EditorEmptyTextPainterTest { @AfterEach fun tearDown() { originalShortcuts.forEach { (actionId, shortcuts) -> resetShortcuts(actionId, shortcuts) } - manager.mainSplitters.setEmptyStateComponentCreationGateForTests(null) + val splitters = manager.mainSplitters + splitters.setEmptyStateComponentCreationGateForTests(null) + // a creation left waiting out an inflated delay must not survive into the next test + splitters.suppressRichEmptyStateComponents() + splitters.setEmptyStateComponentCreationDelayForTests(null) + splitters.resetStartupEmptyStatePresentationHoldForTests() } @Test @@ -185,9 +193,10 @@ internal class EditorEmptyTextPainterTest { assertThat(findEmptyStateComponent(splitters)).isNotNull() scope.cancel() - PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + // the scope's job completes only once its long-running children do, so the disposal it triggers is posted to the EDT + // some time after `cancel()` returns — wait for it instead of pumping once + waitForNoEmptyStateComponent(splitters) - assertThat(findEmptyStateComponent(splitters)).isNull() assertThat(disposedComponents).hasValue(1) } finally { @@ -420,6 +429,12 @@ internal class EditorEmptyTextPainterTest { val openFilesJob = splitters.openFilesAsync(requestFocus = false) PlatformTestUtil.waitWhileBusy { !openFilesJob.isCompleted } waitForEmptyStateComponentCreation(splitters) + + // the restore ends with the area non-empty, so settling the startup empty state must dispose rather than mount — + // no composer flash on a project that does reopen its editors + assertThat(splitters.openFileList).isNotEmpty() + assertThat(providerCalls).hasValue(0) + assertThat(findEmptyStateComponent(splitters)).isNull() } @Test @@ -465,6 +480,152 @@ internal class EditorEmptyTextPainterTest { assertThat(disposedComponents).hasValue(1) } + @Test + fun startupHoldPreparesTheEmptyStateWithoutMountingIt(@TestDisposable disposable: Disposable) { + val splitters = manager.mainSplitters + val providerCalls = AtomicInteger() + registerComponentProvider(disposable, providerCalls = providerCalls) + manager.closeAllFiles() + // building happens before presenting, so a delay this long must never be reached: it would mean the components are only + // built once project open is over, which is the latency this whole split exists to avoid + splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY) + + splitters.beginStartupEmptyStatePresentationHold() + splitters.finishStartupEditorRestore() + + waitForProviderCall(providerCalls, "The empty state was not prepared under the startup hold") + dispatchEventsFor(100.milliseconds) + + // project open may still open an editor of its own, so nothing may be shown yet + assertThat(findEmptyStateComponent(splitters)).isNull() + assertThat(splitters.isEmptyStateComponentCreationPending()).isTrue() + } + + @Test + fun releasingTheStartupHoldMountsWithoutTheCreationDelay(@TestDisposable disposable: Disposable) { + val splitters = manager.mainSplitters + registerComponentProvider(disposable) + manager.closeAllFiles() + splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY) + splitters.beginStartupEmptyStatePresentationHold() + splitters.finishStartupEditorRestore() + dispatchEventsFor(100.milliseconds) + assertThat(findEmptyStateComponent(splitters)).isNull() + + // the release is knowledge that nothing is coming, so there is no flash left for the delay to hide + splitters.endStartupEmptyStatePresentationHold() + + waitForEmptyStateComponent(splitters, "Releasing the startup hold waited out the creation delay") + } + + @Test + fun anEditorOpenedDuringTheStartupHoldPreventsTheEmptyStateEntirely(@TestDisposable disposable: Disposable) { + val splitters = manager.mainSplitters + val disposedComponents = AtomicInteger() + val providerCalls = AtomicInteger() + registerComponentProvider(disposable, disposedComponents, 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") + dispatchEventsFor(100.milliseconds) + + // the welcome tab, a README, a file named on the command line: project open keeps opening editors after restoring finds none + val file = LightVirtualFile("empty-state-startup-hold.txt", "content") + manager.openFile(file, false) + splitters.endStartupEmptyStatePresentationHold() + waitForEmptyStateComponentCreation(splitters) + + assertThat(findEmptyStateComponent(splitters)).isNull() + assertThat(disposedComponents).hasValue(1) + } + + @Test + fun theStartupHoldDoesNotHoldBackTheFallbackEmptyText(@TestDisposable disposable: Disposable) { + resetShortcuts(PROVIDER_ACTION_ID, listOf(doubleCtrlShortcut)) + registerEmptyTextProvider(disposable) + registerFallbackComponentProvider(disposable) + + val splitters = manager.mainSplitters + manager.closeAllFiles() + splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY) + 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) + } + + @Test + fun aStartupHoldTakenAfterProjectOpenGaveUpIsNotHeldAgainstTheEmptyState(@TestDisposable disposable: Disposable) { + val splitters = manager.mainSplitters + registerComponentProvider(disposable) + val gateEntered = CompletableDeferred() + val releaseGate = CompletableDeferred() + splitters.setEmptyStateComponentCreationDelayForTests(Duration.ZERO) + splitters.setEmptyStateComponentCreationGateForTests { + gateEntered.complete(Unit) + releaseGate.await() + } + manager.closeAllFiles() + + // project open was cancelled before restoring took its hold, so its own release runs first + splitters.abandonStartupEmptyStatePresentationHold() + waitForDeferred(gateEntered) + // restoring takes its hold uninterruptibly, so it still arrives — with nobody left to release it + splitters.beginStartupEmptyStatePresentationHold() + releaseGate.complete(Unit) + + waitForEmptyStateComponent(splitters, "A hold taken after project open gave up left the empty state unpresented") + } + + @Test + fun userEmptiedEditorAreaKeepsTheCreationDelay(@TestDisposable disposable: Disposable) { + val splitters = manager.mainSplitters + registerComponentProvider(disposable) + splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY) + splitters.enableRichEmptyStateComponents() + + manager.closeAllFiles() + dispatchEventsFor(100.milliseconds) + + // closing the last tab is a user-visible transition, not startup: nothing says whether an editor is on its way in, so the + // components may be built but the delay still holds them back + assertThat(splitters.isEmptyStateComponentCreationPending()).isTrue() + assertThat(findEmptyStateComponent(splitters)).isNull() + } + + @Test + @Suppress("RAW_SCOPE_CREATION") + fun restoringStateWithoutFileEntriesPreparesTheEmptyStateAtOnce(@TestDisposable disposable: Disposable) { + val splitters = manager.mainSplitters + val providerCalls = AtomicInteger() + registerComponentProvider(disposable, providerCalls = providerCalls) + manager.closeAllFiles() + splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY) + splitters.beginStartupEmptyStatePresentationHold() + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + // a saved state whose leaves hold no files restores nothing, so the empty state may be built while the rest of the project + // open runs — even though restoring does rebuild the editor component and its window + val restoreJob = scope.launch { splitters.createEditors(EditorSplitterState(emptySplitterStateElement())) } + PlatformTestUtil.waitWhileBusy { !restoreJob.isCompleted } + + waitForProviderCall(providerCalls, "Restoring a file-less state did not prepare the empty state") + assertThat(findEmptyStateComponent(splitters)).isNull() + + splitters.endStartupEmptyStatePresentationHold() + waitForEmptyStateComponent(splitters, "Restoring a file-less state waited out the creation delay") + } + finally { + scope.cancel() + PlatformTestUtil.dispatchAllEventsInIdeEventQueue() + } + } + @Test fun openFilesAsyncWithoutSavedStateEnablesRichEmptyStateComponents(@TestDisposable disposable: Disposable) { val providerCalls = AtomicInteger() @@ -576,13 +737,17 @@ internal class EditorEmptyTextPainterTest { disposable: Disposable, disposedComponents: AtomicInteger = AtomicInteger(), includeFallbackProvider: Boolean = false, + providerCalls: AtomicInteger = AtomicInteger(), ) { ExtensionTestUtil.maskExtensions(EditorEmptyStateComponentProvider.EP_NAME, buildList { add(object : EditorEmptyStateComponentProvider { - override suspend fun createComponent(splitters: EditorsSplitters): JComponent = withContext(Dispatchers.EDT) { - JPanel().apply { - name = EMPTY_STATE_COMPONENT_NAME - preferredSize = java.awt.Dimension(320, 40) + override suspend fun createComponent(splitters: EditorsSplitters): JComponent { + providerCalls.incrementAndGet() + return withContext(Dispatchers.EDT) { + JPanel().apply { + name = EMPTY_STATE_COMPONENT_NAME + preferredSize = java.awt.Dimension(320, 40) + } } } @@ -640,6 +805,55 @@ internal class EditorEmptyTextPainterTest { PlatformTestUtil.waitWhileBusy { splitters.isEmptyStateComponentCreationPending() } } + private fun waitForEmptyStateComponent(splitters: EditorsSplitters, message: String) { + try { + // `waitWhileBusy` — not `waitWithEventsDispatching` — because the test body holds the write-intent lock, and the + // mount takes it too: only the former releases it while it dispatches + PlatformTestUtil.waitWhileBusy { findEmptyStateComponent(splitters) == null } + } + catch (e: AssertionError) { + throw AssertionError(message, e) + } + } + + private fun waitForEmptyTextComponent(splitters: EditorsSplitters) { + try { + PlatformTestUtil.waitWhileBusy { findEmptyTextComponent(splitters) == null } + } + catch (e: AssertionError) { + throw AssertionError("The fallback empty text was held back by the startup presentation hold", e) + } + } + + private fun waitForProviderCall(providerCalls: AtomicInteger, message: String) { + try { + PlatformTestUtil.waitWhileBusy { providerCalls.get() == 0 } + } + catch (e: AssertionError) { + throw AssertionError(message, e) + } + } + + private fun waitForNoEmptyStateComponent(splitters: EditorsSplitters) { + try { + PlatformTestUtil.waitWhileBusy { findEmptyStateComponent(splitters) != null } + } + catch (e: AssertionError) { + throw AssertionError("The empty state was not disposed", e) + } + } + + /** Gives a creation that is not waiting out the delay every chance to mount. */ + private fun dispatchEventsFor(duration: Duration) { + val deadline = System.nanoTime() + duration.inWholeNanoseconds + while (System.nanoTime() < deadline) { + Thread.sleep(10) + // same reason as in `waitForEmptyStateComponent`: this dispatches with the write-intent lock released, so a creation + // that is only blocked on that lock cannot pass for one that is waiting out the delay + PlatformTestUtil.dispatchAllInvocationEventsInIdeEventQueue() + } + } + private fun waitForDeferred(deferred: CompletableDeferred) { PlatformTestUtil.waitWhileBusy { !deferred.isCompleted } } @@ -718,5 +932,8 @@ internal class EditorEmptyTextPainterTest { const val PROVIDER_ACTION_ID: String = "EditorEmptyTextPainterTest.ProviderAction" const val PROVIDER_ACTION_TEXT: String = "Provider Action" const val EMPTY_STATE_COMPONENT_NAME: String = "EditorEmptyTextPainterTest.EmptyStateComponent" + + /** Long enough that a test which reaches the delay fails on its own timeout rather than passing slowly. */ + val NEVER_ELAPSING_CREATION_DELAY: Duration = 10.minutes } }