IJAI-363 focus the prompt on the empty editor screen

The editor area's empty state had no place in the focus path: nothing focused
a mounted component, and the area's default focus component was null without a
composite, so a project that restored no editors left the caret nowhere and the
inline composer had to be clicked before it could be typed in.

Give that empty state the focus contract an editor has. A provider declares
that its empty state is the area's focus target and names the component to
focus; the platform focuses it where it would have focused an editor - project
open that restored none, and an area whose last tab was just closed - and
offers it as the area's default focus component, so Focus Editor and a tool
window returning focus reach it. Focus is claimed rather than taken: a tool
window the user is working in keeps it, as does anything outside the area's own
window, and an editor that takes the area over cancels a pending request.
Because a claiming empty state takes that focus, project open opens the Project
tool window without focusing it.

AIR's inline composer claims it and names its editable prompt text, which is
what puts the caret in the input; its own focus plumbing goes away, since only
the platform knows whether a presentation is one the user's focus was headed
for.

The Project view test opens the tool window's options menu right after closing
the area's last editor, which used to leave focus in that tool window: the
platform focused the most recently active one. Now that the editor area's empty
state claims that focus, the tool window is no longer active there, and the New
UI keeps a header toolbar out of the component tree while its tool window is
neither hovered nor active - so the options button was not merely faded, it was
gone, and the driver timed out looking for it.

Focus the tree first, the way the test's own second options step already did: a
click in blank row space hovers and activates the tool window without moving the
selection the surrounding steps depend on. Both steps share one helper now.

The spec's user-experience note still said the empty state must not take focus,
which is the contract this test encoded and the opposite of what the same spec
now states as a requirement; it says what ships instead.

(cherry picked from commit 45cb31cec092ea0dbf0e87a779c706f387af8f84)

GitOrigin-RevId: 5ec589cbeb739d6c2cc90aa7ba1e77812a5b4270
This commit is contained in:
Vladimir Krivosheev
2026-08-01 01:02:27 +00:00
committed by intellij-monorepo-bot
parent 1e8a99b307
commit adb4e6c9d4
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