mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
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:
committed by
intellij-monorepo-bot
parent
29dc56c1a3
commit
7bc2ea792b
+118
-15
@@ -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,
|
||||
|
||||
+47
-1
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+108
-10
@@ -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 {
|
||||
|
||||
+40
-2
@@ -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,
|
||||
|
||||
+224
-7
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user