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