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:
Ilia.Shulgin
2026-07-31 16:21:48 +00:00
committed by intellij-monorepo-bot
parent 30f2729529
commit af9339a6de
10 changed files with 390 additions and 141 deletions
@@ -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
+12 -8
View File
@@ -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,
@@ -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)
}
}
}
}
@@ -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
}
}
}
}
}
@@ -64,9 +64,6 @@ public class EditorEmptyTextPainter {
if (!isEnabled()) {
return;
}
if (splitters instanceof EditorsSplitters && !((EditorsSplitters)splitters).isEmptyTextPaintingAllowed()) {
return;
}
paintEmptyText(splitters, splitters, g);
}
@@ -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()
}
@@ -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,
@@ -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)
}