IJAI-363 focus the prompt on the empty editor screen

(cherry picked from commit e7d49b9c5ca4a3e11c510cb4a23721ea257f6e40)

Platform-only pick: the original commit also touched plugins/air
(AgentPromptInlineEmptyStateProvider.kt, its test, and
global-prompt-entry.spec.md); those changes were stripped from this pick.

GitOrigin-RevId: bcc87a6acdb72088818a17630605081b8b208ff9
This commit is contained in:
Ilia.Shulgin
2026-07-31 16:21:48 +00:00
committed by intellij-monorepo-bot
parent 988bbba960
commit 50a6765b75
6 changed files with 586 additions and 8 deletions
@@ -11,7 +11,11 @@ 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.openapi.wm.IdeFocusManager
import com.intellij.toolWindow.InternalDecoratorImpl
import com.intellij.ui.ComponentUtil
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
@@ -33,6 +37,7 @@ import java.awt.Container
import java.awt.Dimension
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import java.awt.KeyboardFocusManager
import java.awt.LayoutManager2
import java.util.concurrent.atomic.AtomicLong
import javax.swing.Box
@@ -103,6 +108,16 @@ internal class EditorEmptyStateComponentController(
private var presentationGateTimeout: Duration = PRESENTATION_GATE_TIMEOUT
private var creationGate: (suspend () -> Unit)? = null
/**
* A pending request to focus the empty state, made where an editor would have been focused — project open finding no editors to
* restore, or the user closing the area's last tab.
*
* One-shot, and outlives creation rather than the area: the component of an area the user just emptied is mounted a creation delay
* later, so the request is remembered until there is something to focus, and dropped as soon as the area stops being empty.
*/
private var focusRequest: EmptyStateFocusRequest? = null
private var focusRequesterForTests: ((JComponent) -> Unit)? = null
init {
coroutineScope.coroutineContext.job.invokeOnCompletion {
disposeComponentsOnEdt()
@@ -113,6 +128,56 @@ internal class EditorEmptyStateComponentController(
fun isVisible(): Boolean = componentHost != null
/**
* Whether the empty state of this area is a focus target: a mounted component whose provider claims focus, or — before anything is
* mounted — an available provider of the kind that would be presented.
*
* Answered before the component exists as well as after, because project open asks it while the empty state is still being prepared.
* Deliberately not gated on whether rich components are enabled yet: project open asks this while they are still off and enables them
* on its way out, so gating would answer for the moment of the question rather than for the moment of the mount.
*/
fun claimsFocus(): Boolean {
val entries = componentEntries
if (entries.isNotEmpty()) {
return entries.any { claimsFocus(it.provider, it.pluginDescriptor) }
}
return getProvidersToCreate().any { claimsFocus(it.provider, it.pluginDescriptor) }
}
/** The component to focus inside the mounted empty state, or `null` when nothing is mounted or nothing claims focus. */
fun preferredFocusedComponent(): JComponent? {
return componentEntries.firstNotNullOfOrNull { entry ->
if (claimsFocus(entry.provider, entry.pluginDescriptor)) preferredFocusedComponent(entry) else null
}
}
/**
* Whether a request made through [requestFocusWhenPresented] has not settled yet, so that whoever else would focus something on an
* empty editor area can stand down for it — the tool window manager focusing a tool window by default when the last editor closes.
*/
fun isFocusRequestPending(): Boolean = focusRequest != null
/**
* Focuses the empty state where an editor would have been focused, once there is something to focus.
*
* The component of an area the user just emptied is mounted a creation delay later, and the one prepared during project open is
* mounted when the presentation gate opens, so this is a request rather than a focus call. It is honoured at most once, and only
* while the area stays empty.
*
* @param onFocusUnclaimed run on the EDT when this claim leaves the focus it asked for unclaimed — nothing was built for an area that
* is still empty, or what was built named no component to focus. Whoever stood down for the claim focuses its own target from here;
* it is not run when someone else holds the focus instead, an editor that took the area over or a tool window the user is working in.
*/
fun requestFocusWhenPresented(onFocusUnclaimed: (() -> Unit)?) {
val request = EmptyStateFocusRequest(onFocusUnclaimed)
if (componentHost != null) {
focusRequest = null
honourFocusRequest(request)
return
}
focusRequest = request
}
fun suppressRichComponents() {
if (!richComponentsEnabled && componentHost == null && creationJob == null) {
return
@@ -147,6 +212,85 @@ internal class EditorEmptyStateComponentController(
}
}
private fun claimsFocus(provider: EditorEmptyStateComponentProvider, pluginDescriptor: PluginDescriptor): Boolean {
return try {
provider.claimsFocus(splitters)
}
catch (e: Throwable) {
LOG.error(PluginException("Cannot check editor empty state focus claim using $provider", e, pluginDescriptor.pluginId))
false
}
}
private fun preferredFocusedComponent(entry: EditorEmptyStateComponentEntry): JComponent? {
return try {
entry.provider.getPreferredFocusedComponent(entry.component)
}
catch (e: Throwable) {
LOG.error(PluginException(
"Cannot get editor empty state preferred focused component using ${entry.provider}", e, entry.pluginDescriptor.pluginId,
))
null
}
}
/** Settles a request: the empty state takes the focus it claimed, or hands the claim back to whoever stood down for it. */
private fun honourFocusRequest(request: EmptyStateFocusRequest) {
if (focusClaimedComponent() == FocusOutcome.UNCLAIMED) {
request.onFocusUnclaimed?.invoke()
}
}
/**
* Drops a pending request that will not be honoured because nothing was built for an area that is still empty, and hands the claim
* back. Nothing was mounted, so there is no component and no other focus owner: whoever stood down focuses its own target instead.
*/
private fun abandonFocusRequest() {
val request = focusRequest ?: return
focusRequest = null
LOG.debug { "Editor empty state claimed focus but nothing was presented to take it" }
request.onFocusUnclaimed?.invoke()
}
/**
* Focuses the mounted empty state, unless the user is working somewhere this must not take focus from.
*
* Requested through [IdeFocusManager] rather than by [JComponent.requestFocus], for the same reason [focusEditorOnComposite] does:
* this runs while the frame may still be settling its own focus.
*/
private fun focusClaimedComponent(): FocusOutcome {
val target = preferredFocusedComponent()
if (target == null) {
// a claim was made for this area before anything was built, and what was built claims nothing or names nothing — see [claimsFocus]
LOG.debug { "Editor empty state claimed focus but named no component to focus" }
return FocusOutcome.UNCLAIMED
}
if (!canTakeFocus()) {
return FocusOutcome.HELD_ELSEWHERE
}
val requester = focusRequesterForTests
if (requester != null) {
requester(target)
return FocusOutcome.TAKEN
}
val project = splitters.manager.project
IdeFocusManager.getInstance(project).requestFocusInProject(target, project)
return FocusOutcome.TAKEN
}
/**
* Whether the focus this empty state claims is focus it may actually take.
*
* A tool window the user is working in keeps its focus — closing the area's last tab from there is not a request to leave it — and so
* does anything outside this area's own window, a dialog or another frame among them. What is left is focus the empty state inherits:
* an editor that has just gone, the frame that has not given focus to anything yet, or nothing at all.
*/
private fun canTakeFocus(): Boolean {
val focusOwner = KeyboardFocusManager.getCurrentKeyboardFocusManager().focusOwner ?: return true
return UIUtil.getParentOfType(InternalDecoratorImpl::class.java, focusOwner) == null &&
ComponentUtil.getWindow(focusOwner) === ComponentUtil.getWindow(splitters)
}
fun update() {
if (showEmptyState()) {
showComponents()
@@ -171,6 +315,9 @@ internal class EditorEmptyStateComponentController(
fun disposeComponents() {
cancelCreation()
// The empty state a pending request was made for is gone; a later one comes with its own request. Dropped silently: this area stops
// being empty when an editor takes it over, and that editor is focused by whoever opened it.
focusRequest = null
val host = componentHost ?: return
// uninstalling fires `removeNotify` on a provider's component, which may release an editor — see [mount]
WriteIntentReadAction.run {
@@ -210,6 +357,11 @@ internal class EditorEmptyStateComponentController(
creationGate = gate
}
/** @param requester `null` restores the real [IdeFocusManager] request, which a headless test cannot observe. */
fun setFocusRequesterForTests(requester: ((JComponent) -> Unit)?) {
focusRequesterForTests = requester
}
private fun showComponents() {
if (componentHost != null || creationJob != null) {
return
@@ -285,6 +437,12 @@ internal class EditorEmptyStateComponentController(
if (!mounted && richComponentsEnabled && showEmptyState()) {
splitters.repaint()
}
// A creation of this generation that mounted nothing is the last word on what this area shows, so a claim on its focus is
// over too — unless the area stopped being empty, where the editor that took it over owns that focus instead, or something
// was mounted after all by the creation this one gave way to.
if (!mounted && componentHost == null && showEmptyState()) {
abandonFocusRequest()
}
}
}
}
@@ -389,7 +547,7 @@ internal class EditorEmptyStateComponentController(
null
}
if (component != null) {
entries.add(EditorEmptyStateComponentEntry(provider, component, kind))
entries.add(EditorEmptyStateComponentEntry(provider, component, kind, pluginDescriptor))
}
}
return entries
@@ -420,6 +578,12 @@ internal class EditorEmptyStateComponentController(
splitters.revalidate()
splitters.repaint()
}
// outside the lock: the component is in the hierarchy and showing by now, which is what focusing it needs
val request = focusRequest
if (request != null) {
focusRequest = null
honourFocusRequest(request)
}
}
/**
@@ -474,6 +638,21 @@ internal class EditorEmptyStateUiBuildTime : AbstractCoroutineContextElement(Key
get() = nanos.get().nanoseconds
}
/** A request to focus the empty state once it is presented, and what to do when the focus it claimed ends up unclaimed. */
private class EmptyStateFocusRequest(@JvmField val onFocusUnclaimed: (() -> Unit)?)
/**
* What became of the focus an empty state claimed.
*
* [HELD_ELSEWHERE] is told apart from [UNCLAIMED] because only the latter leaves the frame without a focus owner: a claim that was
* refused because the user is working somewhere else needs no one to step in, while a claim that found nothing to focus does.
*/
private enum class FocusOutcome {
TAKEN,
HELD_ELSEWHERE,
UNCLAIMED,
}
private data class EditorEmptyStateProviderEntry(
val provider: EditorEmptyStateComponentProvider,
val pluginDescriptor: PluginDescriptor,
@@ -484,6 +663,7 @@ private data class EditorEmptyStateComponentEntry(
val provider: EditorEmptyStateComponentProvider,
val component: JComponent,
val kind: EditorEmptyStateComponentProvider.Kind,
val pluginDescriptor: PluginDescriptor,
)
internal class EditorsSplittersLayout : LayoutManager2 {
@@ -35,6 +35,29 @@ interface EditorEmptyStateComponentProvider {
fun disposeComponent(component: JComponent) {
}
/**
* Whether this provider's empty state is the focus target of an editor area that shows it — the counterpart, for an area with no
* editor in it, of the composite the platform focuses when it opens one.
*
* Asked synchronously and before the component exists, because project open decides what to focus while the empty state is still
* being prepared. A provider that claims focus is focused where an editor would have been: after project open finds no editors to
* restore, and after the user closes the area's last tab. It is also offered as the area's default focus component, so
* <kbd>Esc</kbd> and Focus Editor reach it. `false` — the default — keeps an empty state out of the focus path entirely.
*
* Claiming focus is not the same as taking it: focus is never moved out of a tool window the user is working in.
*/
fun claimsFocus(splitters: EditorsSplitters): Boolean = false
/**
* The component to focus inside [component], which this provider created — the editable field of a composer rather than its host
* panel, say. Consulted only for a provider that returns `true` from [claimsFocus]; `null` means there is nothing to focus yet.
*
* `null` is also the default, rather than [component] itself: a host panel is usually not focusable, so returning it would answer a
* claim with a focus request that quietly does nothing, where `null` tells the platform that the claim it made on this area's focus
* cannot be kept and lets whoever stood down for it focus instead.
*/
fun getPreferredFocusedComponent(component: JComponent): JComponent? = null
enum class Kind {
RICH,
FALLBACK,
@@ -60,6 +60,7 @@ import com.intellij.openapi.vfs.VirtualFileWithoutContent
import com.intellij.openapi.wm.FocusWatcher
import com.intellij.openapi.wm.IdeFocusManager
import com.intellij.openapi.wm.IdeFrame
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.openapi.wm.ex.IdeFocusTraversalPolicy
import com.intellij.openapi.wm.ex.IdeFrameEx
import com.intellij.openapi.wm.ex.WelcomeScreenTabService
@@ -70,6 +71,7 @@ import com.intellij.openapi.wm.impl.FrameTitleBuilder
import com.intellij.openapi.wm.impl.IdeBackgroundUtil
import com.intellij.openapi.wm.impl.IdeFrameImpl
import com.intellij.openapi.wm.impl.ProjectFrameHelper
import com.intellij.openapi.wm.impl.ToolWindowManagerImpl
import com.intellij.platform.diagnostic.telemetry.impl.span
import com.intellij.platform.fileEditor.FileEntry
import com.intellij.platform.fileEditor.parseFileEntry
@@ -178,7 +180,10 @@ open class EditorsSplitters internal constructor(
const val SPLITTER_KEY: @NonNls String = "EditorsSplitters"
fun findDefaultComponentInSplitters(project: Project?): JComponent? {
return getSplittersToFocus(project)?.currentCompositeFlow?.value?.preferredFocusedComponent
val splitters = getSplittersToFocus(project) ?: return null
// an editor area with no editor in it still has a default component when its empty state claims focus — that is what Focus Editor
// and a tool window returning focus reach there
return splitters.currentCompositeFlow.value?.preferredFocusedComponent ?: splitters.emptyStatePreferredFocusedComponent()
}
@JvmStatic
@@ -284,6 +289,45 @@ open class EditorsSplitters internal constructor(
emptyStateComponentController.enableRichComponents()
}
/**
* Whether this area's empty state takes the focus an editor would have taken, so that whoever else focuses something on an empty
* editor area — project open activating the Project tool window — can leave it to the empty state instead.
*/
@Internal
@RequiresEdt
fun emptyStateClaimsFocus(): Boolean = emptyStateComponentController.claimsFocus()
/** The default focus component of an area with no editor in it; `null` unless a mounted empty state claims focus. */
@Internal
@RequiresEdt
fun emptyStatePreferredFocusedComponent(): JComponent? = emptyStateComponentController.preferredFocusedComponent()
/**
* Asks this area's empty state to take focus once it is presented — where the platform would have focused an editor: project open
* that restored none, and an area whose last tab the user just closed.
*
* @param onFocusUnclaimed run on the EDT when the claim leaves that focus unclaimed, so that whoever stood down for it can focus its
* own target after all; see [EditorEmptyStateComponentController.requestFocusWhenPresented].
*/
@Internal
@RequiresEdt
fun requestEmptyStateFocusWhenPresented(onFocusUnclaimed: (() -> Unit)? = null) {
emptyStateComponentController.requestFocusWhenPresented(onFocusUnclaimed)
}
/**
* Whether this area's empty state has been asked to take focus and has not settled that claim yet, so that whoever else would focus
* something on an empty editor area can leave it to the empty state — see [requestEmptyStateFocusWhenPresented].
*/
@Internal
@RequiresEdt
fun isEmptyStateFocusRequestPending(): Boolean = emptyStateComponentController.isFocusRequestPending()
@TestOnly
internal fun setEmptyStateComponentFocusRequesterForTests(requester: ((JComponent) -> Unit)?) {
emptyStateComponentController.setFocusRequesterForTests(requester)
}
/**
* Holds back presentation of the empty state while project open may still open editors of its own — the welcome tab, a README, a
* file named on the command line, What's New, a file a project wizard generated.
@@ -847,6 +891,12 @@ open class EditorsSplitters internal constructor(
internal open fun afterFileClosed(file: VirtualFile) {
cancelEmptyStateComponentCreation()
if (showEmptyText()) {
// The closed editor's focus is inherited by what replaces it, and on an area with nothing left that is its empty state; the
// request is dropped again if this turns out to be a close that another editor takes over (a preview replacement, say).
// The tool window manager stands down for this claim, so a claim that finds nothing to focus hands that focus back to it.
requestEmptyStateFocusWhenPresented(onFocusUnclaimed = { focusToolWindowByDefaultOnEmptiedArea(manager) })
}
updateEmptyStateComponent()
}
@@ -1750,6 +1800,23 @@ private fun getSplitCount(component: JComponent): Int {
return 1
}
/**
* Focuses what [ToolWindowManagerImpl] focuses when the last editor is closed: the most recently active visible tool window.
*
* It stands down while a claim on the focus of the emptied area is pending, because that focus is the empty state's a creation delay
* later. This is the other half of standing down — the claim found nothing to focus, so the focus goes where it would have gone.
*/
@RequiresEdt
private fun focusToolWindowByDefaultOnEmptiedArea(manager: FileEditorManagerImpl) {
val project = manager.project
// `hasOpenFiles` is the condition the tool window manager stood down under, so this hands back exactly the focus it gave up: an area
// this one shares an editor with — a docked editor window — is one it never stood down for
if (project.isDisposed || manager.hasOpenFiles()) {
return
}
(ToolWindowManager.getInstance(project) as? ToolWindowManagerImpl)?.focusToolWindowByDefault()
}
private fun getSplittersToFocus(suggestedProject: Project?): EditorsSplitters? {
var project = suggestedProject
var activeWindow = WindowManagerEx.getInstanceEx().mostRecentFocusedWindow
@@ -54,6 +54,7 @@ import com.intellij.openapi.util.SystemInfoRt
import com.intellij.openapi.util.registry.RegistryManager
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowId
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.openapi.wm.WindowManager
import com.intellij.openapi.wm.ex.ProjectFrameCapabilitiesService
@@ -83,6 +84,7 @@ import com.intellij.toolWindow.computeToolWindowBeans
import com.intellij.ui.ScreenUtil
import com.intellij.util.PlatformUtils
import com.intellij.util.TimeoutUtil
import com.intellij.util.concurrency.annotations.RequiresEdt
import com.intellij.util.messages.SimpleMessageBusConnection
import com.intellij.util.ui.accessibility.ScreenReader
import kotlinx.coroutines.CancellationException
@@ -609,8 +611,29 @@ private suspend fun postOpenEditors(
// check after `initDockableContentFactory` - editor in a docked window
if (!fileEditorManager.hasOpenFiles()) {
stopOpenFilesActivity(project)
// An editor area whose empty state claims focus is where the caret belongs on a project that restored no editors, the same way it
// would belong in a restored editor. The request is made here, where "project open restored nothing" is known, and honoured when
// the empty state is presented — the hold released below. An editor opened after this point (the README) drops it again.
// Not on a remote dev host, which does not focus anything of its own on project open either — see `openProjectViewIfNeeded`.
val emptyStateClaimKept = AtomicBoolean(true)
val emptyStateTakesFocus = !AppMode.isRemoteDevHost() &&
withContext(Dispatchers.EDT + ModalityState.any().asContextElement()) {
val splitters = fileEditorManager.mainSplitters
splitters.emptyStateClaimsFocus().also { takesFocus ->
if (takesFocus) {
// The Project view below is opened without focus for this claim, which is a promise made before
// there is a component to keep it with. Where it cannot be kept — a provider that builds nothing,
// an empty state that is never presented — the Project view is focused after all, either here or
// at the point it is opened, whichever comes second.
splitters.requestEmptyStateFocusWhenPresented(onFocusUnclaimed = {
emptyStateClaimKept.set(false)
focusProjectViewIfOpened(project)
})
}
}
}
if (!isNotificationSilentMode(project)) {
openProjectViewIfNeeded(project, toolWindowInitJob)
openProjectViewIfNeeded(project, toolWindowInitJob, focusProjectView = { !emptyStateTakesFocus || !emptyStateClaimKept.get() })
findAndOpenReadmeIfNeeded(project)
}
FUSProjectHotStartUpMeasurer.reportNoMoreEditorsOnStartup(System.nanoTime())
@@ -788,7 +811,12 @@ private fun installMaximizeListener(frame: IdeFrameImpl) {
})
}
private suspend fun openProjectViewIfNeeded(project: Project, toolWindowInitJob: Job) {
/**
* @param focusProjectView `false` when something else on this project's editor area takes focus instead — an empty state that claims
* it — so the Project view is opened without focus rather than focused and then focused away from. Asked at the moment the Project view
* is activated rather than in advance, because a claim on the editor area's focus can be given up before that moment.
*/
private suspend fun openProjectViewIfNeeded(project: Project, toolWindowInitJob: Job, focusProjectView: () -> Boolean) {
if (!serviceAsync<RegistryManager>().`is`("ide.open.project.view.on.startup")) {
return
}
@@ -799,17 +827,36 @@ private suspend fun openProjectViewIfNeeded(project: Project, toolWindowInitJob:
val toolWindowManager = project.serviceAsync<ToolWindowManager>()
withContext(Dispatchers.ui(CoroutineSupport.UiDispatcherKind.STRICT)) {
if (toolWindowManager.activeToolWindowId == null) {
val toolWindow = toolWindowManager.getToolWindow("Project")
val toolWindow = toolWindowManager.getToolWindow(ToolWindowId.PROJECT_VIEW)
if (toolWindow != null) {
// maybe readAction
withContext(Dispatchers.EDT) {
toolWindow.activate(null, !AppMode.isRemoteDevHost())
toolWindow.activate(null, focusProjectView() && !AppMode.isRemoteDevHost())
}
}
}
}
}
/**
* Focuses the Project view that [openProjectViewIfNeeded] opened without focus, because the editor area's empty state claimed that focus
* and then found nothing to take it with.
*
* Does nothing when the Project view is not showing: it was never opened — notification silent mode, the registry key off — and there is
* nothing here to focus. Where that is only because it has not been opened *yet*, the claim is already known to be given up by the time
* it is, and it is opened focused instead.
*/
@RequiresEdt
private fun focusProjectViewIfOpened(project: Project) {
if (project.isDisposed) {
return
}
val toolWindow = ToolWindowManager.getInstance(project).getToolWindow(ToolWindowId.PROJECT_VIEW) ?: return
if (toolWindow.isVisible) {
toolWindow.activate(null, true)
}
}
private suspend fun findAndOpenReadmeIfNeeded(project: Project) {
if (!AdvancedSettings.getBoolean("ide.open.readme.md.on.startup")) {
return
@@ -26,12 +26,14 @@ import com.intellij.openapi.application.asContextElement
import com.intellij.openapi.application.writeIntentReadAction
import com.intellij.openapi.components.ComponentManagerEx
import com.intellij.openapi.components.service
import com.intellij.openapi.components.serviceIfCreated
import com.intellij.openapi.diagnostic.debug
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.extensions.PluginDescriptor
import com.intellij.openapi.fileEditor.FileEditorManager
import com.intellij.openapi.fileEditor.FileEditorManagerListener
import com.intellij.openapi.fileEditor.impl.EditorsSplitters
import com.intellij.openapi.fileEditor.impl.FileEditorManagerImpl
import com.intellij.openapi.options.advanced.AdvancedSettings
import com.intellij.openapi.progress.ProcessCanceledException
import com.intellij.openapi.project.Project
@@ -118,6 +120,20 @@ import javax.swing.JRootPane
private val LOG = logger<ToolWindowManagerImpl>()
private val performShowInSeparateTask = System.getProperty("idea.toolwindow.show.separate.task", "false").toBoolean()
/**
* Whether the editor area has claimed the focus of the editor that was just closed, because its empty state is that area's focus target.
*
* The claimed component is mounted a creation delay later, so focusing a tool window by default here would take focus the empty state is
* about to be given — and take it visibly, only to lose it again. A claim that ends up with nothing to focus calls
* [ToolWindowManagerImpl.focusToolWindowByDefault] itself, so standing down cannot leave the frame without a focus owner.
*/
@RequiresEdt
private fun emptyEditorAreaClaimsClosedEditorFocus(project: Project): Boolean {
val fileEditorManager = project.serviceIfCreated<FileEditorManager>() as? FileEditorManagerImpl ?: return false
// the init job is awaited because `splitters` reaches `mainSplitters`, which exists only once that job has completed
return fileEditorManager.initJob.isCompleted && fileEditorManager.splitters.isEmptyStateFocusRequestPending()
}
private typealias Mutation = ((WindowInfoImpl) -> Unit)
@ApiStatus.Internal
@@ -437,7 +453,7 @@ open class ToolWindowManagerImpl @NonInjectable @TestOnly internal constructor(
coroutineScope.launch(Dispatchers.EDT) {
@Suppress("DEPRECATION")
focusManager.doWhenFocusSettlesDown(ExpirableRunnable.forProject(project) {
if (!FileEditorManager.getInstance(project).hasOpenFiles()) {
if (!FileEditorManager.getInstance(project).hasOpenFiles() && !emptyEditorAreaClaimsClosedEditorFocus(project)) {
focusToolWindowByDefault()
}
})
@@ -1889,7 +1905,14 @@ open class ToolWindowManagerImpl @NonInjectable @TestOnly internal constructor(
fireStateChanged(ToolWindowManagerEventType.MovedOrResized, toolWindow)
}
private fun focusToolWindowByDefault() {
/**
* Focuses the most recently active visible tool window, where the editor area has nothing to hand focus to.
*
* Also called from the editor area itself, for a claim on the focus of an emptied area that could not be kept — see
* [EditorsSplitters.requestEmptyStateFocusWhenPresented].
*/
@RequiresEdt
internal fun focusToolWindowByDefault() {
var toFocus: ToolWindowEntry? = null
for (each in activeStack.stack) {
if (each.readOnlyWindowInfo.isVisible) {
@@ -48,6 +48,7 @@ import org.junit.jupiter.api.Test
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import java.nio.file.Files
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import javax.swing.JComponent
@@ -98,6 +99,7 @@ internal class EditorEmptyTextPainterTest {
originalShortcuts.forEach { (actionId, shortcuts) -> resetShortcuts(actionId, shortcuts) }
val splitters = manager.mainSplitters
splitters.setEmptyStateComponentCreationGateForTests(null)
splitters.setEmptyStateComponentFocusRequesterForTests(null)
// a creation left waiting out an inflated delay must not survive into the next test
splitters.suppressRichEmptyStateComponents()
splitters.setEmptyStateComponentCreationDelayForTests(null)
@@ -823,6 +825,192 @@ internal class EditorEmptyTextPainterTest {
assertThat(disposedComponents).hasValue(1)
}
@Test
fun aClaimingEmptyStateIsFocusedWhenItIsPresented(@TestDisposable disposable: Disposable) {
val splitters = manager.mainSplitters
registerFocusClaimingComponentProvider(disposable)
manager.closeAllFiles()
val focusRequests = recordFocusRequests(splitters)
splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY)
splitters.beginStartupEmptyStatePresentationHold()
// the request is made where project open makes it: before anything is built, and honoured only once the empty state is presented
splitters.requestEmptyStateFocusWhenPresented()
splitters.finishStartupEditorRestore()
dispatchEventsFor(100.milliseconds)
assertThat(focusRequests).isEmpty()
releaseStartupHoldFromProjectOpensHop(splitters)
waitForEmptyStateComponent(splitters, "The claimed empty state was not presented")
assertThat(focusRequests).containsExactly(findFocusTargetComponent(splitters))
}
@Test
fun anEmptyStateThatDoesNotClaimFocusIsNotFocused(@TestDisposable disposable: Disposable) {
val splitters = manager.mainSplitters
registerComponentProvider(disposable)
manager.closeAllFiles()
val focusRequests = recordFocusRequests(splitters)
assertThat(splitters.emptyStateClaimsFocus()).isFalse()
splitters.requestEmptyStateFocusWhenPresented()
enableRichEmptyStateComponentsWithoutDelay(splitters)
waitForEmptyStateComponent(splitters, "The empty state was not presented")
assertThat(focusRequests).isEmpty()
assertThat(splitters.emptyStatePreferredFocusedComponent()).isNull()
}
@Test
fun aClaimingEmptyStateClaimsFocusBeforeItIsBuilt(@TestDisposable disposable: Disposable) {
val splitters = manager.mainSplitters
registerFocusClaimingComponentProvider(disposable)
manager.closeAllFiles()
// project open asks this while the empty state is still being prepared, so the answer must not depend on a mounted component
assertThat(splitters.emptyStateClaimsFocus()).isTrue()
assertThat(splitters.emptyStatePreferredFocusedComponent()).isNull()
enableRichEmptyStateComponentsWithoutDelay(splitters)
waitForEmptyStateComponent(splitters, "The claimed empty state was not presented")
assertThat(splitters.emptyStateClaimsFocus()).isTrue()
assertThat(splitters.emptyStatePreferredFocusedComponent()).isSameAs(findFocusTargetComponent(splitters))
}
@Test
fun aFocusRequestIsDroppedWhenAnEditorTakesTheAreaOver(@TestDisposable disposable: Disposable) {
val splitters = manager.mainSplitters
registerFocusClaimingComponentProvider(disposable)
manager.closeAllFiles()
val focusRequests = recordFocusRequests(splitters)
splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY)
splitters.requestEmptyStateFocusWhenPresented()
splitters.enableRichEmptyStateComponents()
val file = LightVirtualFile("empty-state-focus-drop.txt", "content")
manager.openFile(file, false)
waitForEmptyStateComponentCreation(splitters)
// the editor that took the area over owns the focus the request was made for
assertThat(findEmptyStateComponent(splitters)).isNull()
assertThat(focusRequests).isEmpty()
manager.closeFile(file)
enableRichEmptyStateComponentsWithoutDelay(splitters)
waitForEmptyStateComponent(splitters, "The claimed empty state was not presented after the editor was closed")
// and closing that editor is a request of its own rather than the dropped one coming back
assertThat(focusRequests).containsExactly(findFocusTargetComponent(splitters))
}
@Test
fun closingTheLastEditorFocusesAClaimingEmptyState(@TestDisposable disposable: Disposable) {
val splitters = manager.mainSplitters
registerFocusClaimingComponentProvider(disposable)
manager.closeAllFiles()
enableRichEmptyStateComponentsWithoutDelay(splitters)
waitForEmptyStateComponent(splitters, "The claimed empty state was not presented")
val file = LightVirtualFile("empty-state-last-tab.txt", "content")
manager.openFile(file, false)
waitForNoEmptyStateComponent(splitters)
val focusRequests = recordFocusRequests(splitters)
manager.closeFile(file)
waitForEmptyStateComponent(splitters, "The claimed empty state did not come back after the last editor was closed")
// the focus of the editor the user just closed is inherited by the empty state that replaces it
assertThat(focusRequests).containsExactly(findFocusTargetComponent(splitters))
}
@Test
fun aClaimIsPendingUntilTheEmptyStateTakesTheFocus(@TestDisposable disposable: Disposable) {
val splitters = manager.mainSplitters
registerFocusClaimingComponentProvider(disposable)
manager.closeAllFiles()
val focusRequests = recordFocusRequests(splitters)
val handedBack = AtomicInteger()
splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY)
splitters.beginStartupEmptyStatePresentationHold()
splitters.requestEmptyStateFocusWhenPresented(onFocusUnclaimed = { handedBack.incrementAndGet() })
splitters.finishStartupEditorRestore()
dispatchEventsFor(100.milliseconds)
// what the tool window manager stands down for while the claimed component is still being prepared
assertThat(splitters.isEmptyStateFocusRequestPending()).isTrue()
releaseStartupHoldFromProjectOpensHop(splitters)
waitForEmptyStateComponent(splitters, "The claimed empty state was not presented")
assertThat(focusRequests).containsExactly(findFocusTargetComponent(splitters))
assertThat(splitters.isEmptyStateFocusRequestPending()).isFalse()
assertThat(handedBack).hasValue(0)
}
@Test
fun aClaimThatIsNeverPresentedHandsTheFocusBack(@TestDisposable disposable: Disposable) {
val splitters = manager.mainSplitters
registerFocusClaimingComponentProviderThatBuildsNothing(disposable)
manager.closeAllFiles()
val focusRequests = recordFocusRequests(splitters)
val handedBack = AtomicInteger()
// the claim is made on an available provider, before it is known that the provider will build nothing
assertThat(splitters.emptyStateClaimsFocus()).isTrue()
splitters.requestEmptyStateFocusWhenPresented(onFocusUnclaimed = { handedBack.incrementAndGet() })
enableRichEmptyStateComponentsWithoutDelay(splitters)
waitForEmptyStateComponentCreation(splitters)
// nothing was presented for the area to focus, so whoever stood down for the claim gets it back
assertThat(focusRequests).isEmpty()
assertThat(handedBack).hasValue(1)
assertThat(splitters.isEmptyStateFocusRequestPending()).isFalse()
}
@Test
fun aClaimingEmptyStateThatNamesNoComponentHandsTheFocusBack(@TestDisposable disposable: Disposable) {
val splitters = manager.mainSplitters
registerFocusClaimingComponentProviderWithoutFocusTarget(disposable)
manager.closeAllFiles()
val focusRequests = recordFocusRequests(splitters)
val handedBack = AtomicInteger()
splitters.requestEmptyStateFocusWhenPresented(onFocusUnclaimed = { handedBack.incrementAndGet() })
enableRichEmptyStateComponentsWithoutDelay(splitters)
waitForEmptyStateComponent(splitters, "The claimed empty state was not presented")
assertThat(focusRequests).isEmpty()
assertThat(handedBack).hasValue(1)
}
@Test
fun anEditorTakingTheAreaOverDoesNotHandTheFocusBack(@TestDisposable disposable: Disposable) {
val splitters = manager.mainSplitters
registerFocusClaimingComponentProvider(disposable)
manager.closeAllFiles()
val focusRequests = recordFocusRequests(splitters)
val handedBack = AtomicInteger()
splitters.setEmptyStateComponentCreationDelayForTests(NEVER_ELAPSING_CREATION_DELAY)
splitters.requestEmptyStateFocusWhenPresented(onFocusUnclaimed = { handedBack.incrementAndGet() })
splitters.enableRichEmptyStateComponents()
// the editor that took the area over owns the focus the claim was made for — the README on a project's first open
val file = LightVirtualFile("empty-state-focus-handback.txt", "content")
manager.openFile(file, false)
waitForEmptyStateComponentCreation(splitters)
dispatchEventsFor(100.milliseconds)
assertThat(focusRequests).isEmpty()
assertThat(handedBack).hasValue(0)
assertThat(splitters.isEmptyStateFocusRequestPending()).isFalse()
}
private fun registerDefaultEmptyTextProvider(disposable: Disposable) {
ExtensionTestUtil.maskExtensions(EditorEmptyTextProvider.EP_NAME, listOf(DefaultEditorEmptyTextProvider()), disposable)
}
@@ -874,6 +1062,44 @@ internal class EditorEmptyTextPainterTest {
}, disposable)
}
/** A provider whose empty state is the focus target of the area it is shown in, and whose focus target is inside its component. */
private fun registerFocusClaimingComponentProvider(disposable: Disposable) {
ExtensionTestUtil.maskExtensions(EditorEmptyStateComponentProvider.EP_NAME, listOf(object : EditorEmptyStateComponentProvider {
override suspend fun createComponent(splitters: EditorsSplitters): JComponent = withContext(Dispatchers.EDT) {
JPanel().apply {
name = EMPTY_STATE_COMPONENT_NAME
add(JPanel().apply { name = FOCUS_TARGET_COMPONENT_NAME })
}
}
override fun claimsFocus(splitters: EditorsSplitters): Boolean = true
override fun getPreferredFocusedComponent(component: JComponent): JComponent? {
return UIUtil.uiTraverser(component).find { it is JComponent && it.name == FOCUS_TARGET_COMPONENT_NAME } as? JComponent
}
}), disposable)
}
/** A provider that claims the area's focus and then builds nothing, so the claim it made cannot be kept. */
private fun registerFocusClaimingComponentProviderThatBuildsNothing(disposable: Disposable) {
ExtensionTestUtil.maskExtensions(EditorEmptyStateComponentProvider.EP_NAME, listOf(object : EditorEmptyStateComponentProvider {
override suspend fun createComponent(splitters: EditorsSplitters): JComponent? = null
override fun claimsFocus(splitters: EditorsSplitters): Boolean = true
}), disposable)
}
/** A provider that claims the area's focus and presents a component that names nothing to focus inside it. */
private fun registerFocusClaimingComponentProviderWithoutFocusTarget(disposable: Disposable) {
ExtensionTestUtil.maskExtensions(EditorEmptyStateComponentProvider.EP_NAME, listOf(object : EditorEmptyStateComponentProvider {
override suspend fun createComponent(splitters: EditorsSplitters): JComponent = withContext(Dispatchers.EDT) {
JPanel().apply { name = EMPTY_STATE_COMPONENT_NAME }
}
override fun claimsFocus(splitters: EditorsSplitters): Boolean = true
}), disposable)
}
private fun registerNullAndFallbackComponentProviders(disposable: Disposable) {
ExtensionTestUtil.maskExtensions(EditorEmptyStateComponentProvider.EP_NAME, buildList {
add(object : EditorEmptyStateComponentProvider {
@@ -901,6 +1127,17 @@ internal class EditorEmptyTextPainterTest {
return UIUtil.uiTraverser(splitters).find { it is JComponent && it.name == EMPTY_STATE_COMPONENT_NAME } as? JComponent
}
private fun findFocusTargetComponent(splitters: EditorsSplitters): JComponent {
return checkNotNull(UIUtil.uiTraverser(splitters).find { it is JComponent && it.name == FOCUS_TARGET_COMPONENT_NAME } as? JComponent)
}
/** Records what the empty state asks to focus, which is all a headless test can observe of a focus request. */
private fun recordFocusRequests(splitters: EditorsSplitters): List<JComponent> {
val requests = CopyOnWriteArrayList<JComponent>()
splitters.setEmptyStateComponentFocusRequesterForTests { requests.add(it) }
return requests
}
private fun findEmptyTextComponent(splitters: EditorsSplitters): JComponent? {
return UIUtil.uiTraverser(splitters).find { it is JComponent && it.name == EDITOR_EMPTY_TEXT_COMPONENT_NAME } as? JComponent
}
@@ -1061,6 +1298,7 @@ internal class EditorEmptyTextPainterTest {
const val PROVIDER_ACTION_ID: String = "EditorEmptyTextPainterTest.ProviderAction"
const val PROVIDER_ACTION_TEXT: String = "Provider Action"
const val EMPTY_STATE_COMPONENT_NAME: String = "EditorEmptyTextPainterTest.EmptyStateComponent"
const val FOCUS_TARGET_COMPONENT_NAME: String = "EditorEmptyTextPainterTest.FocusTarget"
/** Long enough that a test which reaches the delay fails on its own timeout rather than passing slowly. */
val NEVER_ELAPSING_CREATION_DELAY: Duration = 10.minutes