diff --git a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentController.kt b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentController.kt
index 7d59a4f67720..44f2ad3d0a8e 100644
--- a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentController.kt
+++ b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentController.kt
@@ -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 {
diff --git a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentProvider.kt b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentProvider.kt
index dfe574f0d63e..8a1edc403107 100644
--- a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentProvider.kt
+++ b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorEmptyStateComponentProvider.kt
@@ -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
+ * Esc 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,
diff --git a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorsSplitters.kt b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorsSplitters.kt
index b971facf0fbe..0d8b045c3ad3 100644
--- a/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorsSplitters.kt
+++ b/platform/platform-impl/src/com/intellij/openapi/fileEditor/impl/EditorsSplitters.kt
@@ -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
diff --git a/platform/platform-impl/src/com/intellij/openapi/project/impl/IdeProjectFrameAllocator.kt b/platform/platform-impl/src/com/intellij/openapi/project/impl/IdeProjectFrameAllocator.kt
index 9328b2533078..dd8276b04c74 100644
--- a/platform/platform-impl/src/com/intellij/openapi/project/impl/IdeProjectFrameAllocator.kt
+++ b/platform/platform-impl/src/com/intellij/openapi/project/impl/IdeProjectFrameAllocator.kt
@@ -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().`is`("ide.open.project.view.on.startup")) {
return
}
@@ -799,17 +827,36 @@ private suspend fun openProjectViewIfNeeded(project: Project, toolWindowInitJob:
val toolWindowManager = project.serviceAsync()
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
diff --git a/platform/platform-impl/src/com/intellij/openapi/wm/impl/ToolWindowManagerImpl.kt b/platform/platform-impl/src/com/intellij/openapi/wm/impl/ToolWindowManagerImpl.kt
index c6ed9ea93b9e..21ab47161523 100644
--- a/platform/platform-impl/src/com/intellij/openapi/wm/impl/ToolWindowManagerImpl.kt
+++ b/platform/platform-impl/src/com/intellij/openapi/wm/impl/ToolWindowManagerImpl.kt
@@ -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()
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() 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) {
diff --git a/platform/platform-impl/testSrc/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainterTest.kt b/platform/platform-impl/testSrc/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainterTest.kt
index 454b087ebe5f..b1bad01e9262 100644
--- a/platform/platform-impl/testSrc/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainterTest.kt
+++ b/platform/platform-impl/testSrc/com/intellij/openapi/fileEditor/impl/EditorEmptyTextPainterTest.kt
@@ -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 {
+ val requests = CopyOnWriteArrayList()
+ 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