IJAI-468 split preparing the empty-state composer from presenting it

The settled-empty signal was reported when startup stopped *restoring* editors, which
is before project open stops *opening* them: the welcome tab, the README (on by
default), What's New, a wizard-generated file, and a file named on the command line all
follow it, and every one of them correlates with "startup restored nothing" -- the very
condition that triggered the immediate mount. No signal available at that point can
tell them apart, because the editor area being empty now says nothing about an editor
being on its way.

So preparation and presentation are separated instead of enumerated. Preparation -- the
expensive half -- starts as soon as startup restores nothing and overlaps the rest of
project open; the mount waits on a refcounted hold the frame allocator takes across the
whole startup open, which no opener has to know about. Releasing that hold is knowledge
that nothing more is coming rather than a guess that something might be, so the mount
needs no creation delay at all: time-to-visible becomes max(build, project open). The
delay stays where an editor can still arrive outside project open -- an area the user
emptied, a backend push on JetBrains Client, non-main containers. Four flags and the
immediate parameter chain are deleted with the old signal.

Only a rich empty state waits on the hold. 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 stays suppressed while a creation is pending -- so a product
without a rich provider, or IDEA Ultimate with the inline composer off, would have shown
nothing at all until project open finished opening editors of its own.

The abnormal release ends the whole startup hold rather than pairing off one of them.
Restoring takes its hold uninterruptibly, so it can still arrive after project open was
cancelled, and a paired release that ran first was clamped away and left the later hold
standing for the life of the project -- trivially so under a modal dialog, where the
release's ModalityState.any() jumps the queue the hold was taken on. Both hops share
any() modality now, taking the hold costs one EDT trip rather than two and no
write-intent lock, and a release that finds no hold is logged instead of clamped away.

Slow preparation is measured as two numbers, because the halves cost different things.
What a provider runs inside buildEditorEmptyStateComponentOnUiThread is budgeted at
100 ms whatever its kind: preparation overlaps project open, so a UI-thread step that
long is a startup freeze rather than a component taking its time. The remainder keeps a
per-kind budget -- 1 s for a rich provider, which may resolve services and query a
backend before it has anything to build, 100 ms for a pure-Swing fallback, which has
nothing to spend it on. The single end-to-end number this replaces fired routinely on
AIR's off-EDT prologue at 100 ms, and a rich budget generous enough to silence that
would have hidden the one case worth a warning.

Three further defects the same review found, fixed alongside:
- the composer's generation-settings prewarm looked its configured default up among
  user presets only, so a built-in default -- including the product default -- prewarmed
  nothing; built-ins now resolve from the catalog snapshot, after descriptor route
  replacement, and still without consulting availability, whose pre-snapshot seed is
  optimistic by contract;
- the reserved settings lane froze one measurement at construction and took the max, so
  a later font change left the band permanently wrong in one direction or the other; it
  is measured from a chip the lane already holds, on every layout;
- overlapping refreshes of one route shared one set entry, so the first completion
  dropped the guard while the second observer was still repainting; the observers are
  counted now, and a count is released whether its refresh finishes or the surface
  holding it is torn down under it, so a cancelled composer cannot leave a route counted
  as observed with nothing observing it.

The AIR inline composer resolves its services, catalog, preferences and draft off the
EDT, hops to the UI thread only for Swing construction, prewarms the stored default
preset's generation settings, and reserves its settings lane at the measured height of
one chip so late chips fill in without growing or re-centring the card.

(cherry picked from commit 2d7890e30bcc3304b64f9930c94fd34f64605e15)

GitOrigin-RevId: 9d8df5e36865122174fe1a9b4bec7fdb8cf3b096
This commit is contained in:
Vladimir Krivosheev
2026-08-01 01:02:24 +00:00
committed by intellij-monorepo-bot
parent 29dc56c1a3
commit 7bc2ea792b
6 changed files with 539 additions and 39 deletions
@@ -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<EditorEmptyStateComponentController>()
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<EditorEmptyStateComponentEntry> = 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<EditorEmptyStateUiBuildTime>
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,
@@ -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<EditorEmptyStateComponentProvider> =
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 <T> 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())
}
}
}
}
@@ -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)
}
}
@@ -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 {
@@ -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<StartUpPerformanceService>().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<FileEditorManager>() 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,
@@ -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<Unit>()
val releaseGate = CompletableDeferred<Unit>()
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<Unit>) {
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
}
}