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