IJPL-233558 IJ-MR-184958 IJ-MR-184993 IJ-MR-179029 IJ-MR-184126 IJ-MR-181153 IJ-MR-146078 IJ-MR-175479 IJ-MR-186058 feat(platform): hide project toolwindow via layout profile

GitOrigin-RevId: f332445e972bb1ed26d8a3f7e4e5905379986059
This commit is contained in:
Vladimir Krivosheev
2026-02-12 15:38:41 +00:00
committed by intellij-monorepo-bot
parent 59668c448d
commit a7bc6397b3
10 changed files with 240 additions and 15 deletions
@@ -10,8 +10,7 @@ import com.intellij.testFramework.junit5.RunInEdt
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.TestDisposable
import com.intellij.testFramework.junit5.fixture.projectFixture
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
@@ -26,7 +25,12 @@ class ProjectFrameCapabilitiesServiceTest {
@Test
fun uiPolicyUsesAggregatedCapabilitiesAndCapabilitiesAreCached() {
val uiPolicyRef = AtomicReference(ProjectFrameUiPolicy(projectPaneToActivateId = "pane-1"))
val uiPolicyRef = AtomicReference(
ProjectFrameUiPolicy(
projectPaneToActivateId = "pane-1",
toolWindowLayoutProfileId = "layout-profile-1",
)
)
val capabilitiesComputationCount = AtomicInteger()
val capabilitiesRef = AtomicReference(setOf(ProjectFrameCapability.WELCOME_EXPERIENCE))
ExtensionTestUtil.maskExtensions(
@@ -56,16 +60,23 @@ class ProjectFrameCapabilitiesServiceTest {
)
val service = ProjectFrameCapabilitiesService(service<CoreUiCoroutineScopeHolder>().coroutineScope)
assertEquals("pane-1", service.getUiPolicy(project)?.projectPaneToActivateId)
assertThat(service.getUiPolicy(project)?.projectPaneToActivateId).isEqualTo("pane-1")
assertThat(service.getUiPolicy(project)?.toolWindowLayoutProfileId).isEqualTo("layout-profile-1")
uiPolicyRef.set(ProjectFrameUiPolicy(projectPaneToActivateId = "pane-2"))
assertEquals("pane-2", service.getUiPolicy(project)?.projectPaneToActivateId)
uiPolicyRef.set(
ProjectFrameUiPolicy(
projectPaneToActivateId = "pane-2",
toolWindowLayoutProfileId = "layout-profile-2",
)
)
assertThat(service.getUiPolicy(project)?.projectPaneToActivateId).isEqualTo("pane-2")
assertThat(service.getUiPolicy(project)?.toolWindowLayoutProfileId).isEqualTo("layout-profile-2")
assertEquals(setOf(ProjectFrameCapability.WELCOME_EXPERIENCE), service.getAll(project))
assertTrue(service.has(project, ProjectFrameCapability.WELCOME_EXPERIENCE))
assertThat(service.getAll(project)).isEqualTo(setOf(ProjectFrameCapability.WELCOME_EXPERIENCE))
assertThat(service.has(project, ProjectFrameCapability.WELCOME_EXPERIENCE)).isTrue()
capabilitiesRef.set(emptySet())
assertEquals(setOf(ProjectFrameCapability.WELCOME_EXPERIENCE), service.getAll(project))
assertEquals(1, capabilitiesComputationCount.get())
assertThat(service.getAll(project)).isEqualTo(setOf(ProjectFrameCapability.WELCOME_EXPERIENCE))
assertThat(capabilitiesComputationCount.get()).isEqualTo(1)
}
}
@@ -0,0 +1,67 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.toolWindow
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.impl.DesktopLayout
import com.intellij.testFramework.ExtensionTestUtil
import com.intellij.testFramework.junit5.RunInEdt
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.TestDisposable
import com.intellij.testFramework.junit5.fixture.projectFixture
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
@TestApplication
@RunInEdt
class ToolWindowLayoutProfileServiceTest {
private val project by projectFixture()
@TestDisposable
private lateinit var disposable: Disposable
@Test
fun returnsLayoutFromFirstMatchingProvider() {
val firstLayout = DesktopLayout()
ExtensionTestUtil.maskExtensions(
ToolWindowLayoutProfileProvider.EP_NAME,
listOf(
object : ToolWindowLayoutProfileProvider {
override fun getLayout(project: Project, profileId: String, isNewUi: Boolean): DesktopLayout? {
return firstLayout.takeIf { profileId == "dedicated" }
}
},
object : ToolWindowLayoutProfileProvider {
override fun getLayout(project: Project, profileId: String, isNewUi: Boolean): DesktopLayout? {
return null
}
},
),
disposable,
)
val result = service<ToolWindowLayoutProfileService>().getLayout(project = project, profileId = "dedicated", isNewUi = true)
assertThat(result).isSameAs(firstLayout)
}
@Test
fun returnsNullWhenNoProviderMatchesProfileId() {
ExtensionTestUtil.maskExtensions(
ToolWindowLayoutProfileProvider.EP_NAME,
listOf(
object : ToolWindowLayoutProfileProvider {
override fun getLayout(project: Project, profileId: String, isNewUi: Boolean): DesktopLayout? {
return null
}
},
),
disposable,
)
val result = service<ToolWindowLayoutProfileService>().getLayout(project = project, profileId = "missing", isNewUi = true)
assertThat(result).isNull()
}
}
@@ -87,6 +87,7 @@ import com.intellij.openapi.wm.ToolWindowType
import com.intellij.openapi.wm.WINDOW_INFO_DEFAULT_TOOL_WINDOW_PANE_ID
import com.intellij.openapi.wm.WindowInfo
import com.intellij.openapi.wm.WindowManager
import com.intellij.openapi.wm.ex.ProjectFrameCapabilitiesService
import com.intellij.openapi.wm.ex.ToolWindowEx
import com.intellij.openapi.wm.ex.ToolWindowManagerEx
import com.intellij.openapi.wm.ex.ToolWindowManagerListener
@@ -101,6 +102,7 @@ import com.intellij.toolWindow.ToolWindowButtonManager
import com.intellij.toolWindow.ToolWindowDefaultLayoutManager
import com.intellij.toolWindow.ToolWindowEntry
import com.intellij.toolWindow.ToolWindowEventSource
import com.intellij.toolWindow.ToolWindowLayoutProfileService
import com.intellij.toolWindow.ToolWindowPane
import com.intellij.toolWindow.ToolWindowPaneNewButtonManager
import com.intellij.toolWindow.ToolWindowProperty
@@ -424,7 +426,7 @@ open class ToolWindowManagerImpl @NonInjectable @TestOnly internal constructor(
val updateHeadersRequests = MutableSharedFlow<Unit>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
coroutineScope.launch {
updateHeadersRequests
.debounce(50)
.debounce(50.milliseconds)
.collectLatest {
for (project in getOpenedProjects()) {
val toolWindowManager = project.serviceAsync<ToolWindowManager>() as ToolWindowManagerImpl
@@ -774,7 +776,14 @@ open class ToolWindowManagerImpl @NonInjectable @TestOnly internal constructor(
}
private fun loadDefault() {
toolWindowSetInitializer.scheduleSetLayout(ToolWindowDefaultLayoutManager.getInstance().getLayoutCopy())
val layout = getLayoutForProjectFrameProfile() ?: ToolWindowDefaultLayoutManager.getInstance().getLayoutCopy()
toolWindowSetInitializer.scheduleSetLayout(layout)
}
private fun getLayoutForProjectFrameProfile(): DesktopLayout? {
val uiPolicy = service<ProjectFrameCapabilitiesService>().getUiPolicy(project) ?: return null
val profileId = uiPolicy.toolWindowLayoutProfileId ?: return null
return service<ToolWindowLayoutProfileService>().getLayout(project = project, profileId = profileId, isNewUi = isNewUi)
}
@Deprecated("Use {@link ToolWindowManagerListener#TOPIC}", level = DeprecationLevel.ERROR)
@@ -0,0 +1,71 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.toolWindow
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.logger
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.impl.DesktopLayout
import kotlinx.coroutines.CancellationException
import org.jetbrains.annotations.ApiStatus.Internal
import org.jetbrains.annotations.VisibleForTesting
/**
* Resolves a toolwindow layout for a specific project frame profile.
*
* The layout is used to seed a frame when no project-specific layout was persisted yet.
*/
@Internal
interface ToolWindowLayoutProfileProvider {
companion object {
@VisibleForTesting
val EP_NAME: ExtensionPointName<ToolWindowLayoutProfileProvider> = ExtensionPointName("com.intellij.toolWindowLayoutProfileProvider")
}
fun getLayout(project: Project, profileId: String, isNewUi: Boolean): DesktopLayout?
}
@Service(Service.Level.APP)
@Internal
class ToolWindowLayoutProfileService {
fun getLayout(project: Project, profileId: String, isNewUi: Boolean): DesktopLayout? {
if (project.isDisposed) {
return null
}
val providers = ToolWindowLayoutProfileProvider.EP_NAME.extensionList
if (providers.isEmpty()) {
return null
}
var layout: DesktopLayout? = null
var layoutProvider: ToolWindowLayoutProfileProvider? = null
for (provider in providers) {
try {
val providerLayout = provider.getLayout(project = project, profileId = profileId, isNewUi = isNewUi)
if (providerLayout != null) {
if (layout == null) {
layout = providerLayout
layoutProvider = provider
}
else {
LOG.error(
"Multiple tool window layouts are provided for profile '$profileId'. " +
"Keeping ${layoutProvider?.javaClass?.name}, ignoring ${provider.javaClass.name}."
)
}
}
}
catch (e: CancellationException) {
throw e
}
catch (e: Throwable) {
LOG.error("Tool window layout profile provider '${provider.javaClass.name}' failed", e)
}
}
return layout
}
}
private val LOG = logger<ToolWindowLayoutProfileService>()
@@ -216,6 +216,7 @@
</extensionPoint>
<extensionPoint name="defaultToolWindowLayout" interface="com.intellij.toolWindow.DefaultToolWindowLayoutExtension" dynamic="true"/>
<extensionPoint name="toolWindowLayoutProfileProvider" interface="com.intellij.toolWindow.ToolWindowLayoutProfileProvider" dynamic="true"/>
<extensionPoint name="toolWindow.splitContentProvider" beanClass="com.intellij.toolWindow.ToolWindowSplitContentProviderBean" dynamic="true">
<with attribute="implementationClass" implements="com.intellij.toolWindow.ToolWindowSplitContentProvider"/>
</extensionPoint>
@@ -54,9 +54,18 @@ data class ProjectFrameUiPolicy(
/** Toolwindow ids to hide after startup activation. */
val toolWindowIdsToHideOnStartup: Set<String> = emptySet(),
/**
* Toolwindow layout profile id used to seed project frame layout on first open (when no
* project-specific toolwindow layout has been persisted yet).
*/
val toolWindowLayoutProfileId: String? = null,
) {
fun isEmpty(): Boolean {
return projectPaneToActivateId == null && startupToolWindowIdToActivate == null && toolWindowIdsToHideOnStartup.isEmpty()
return projectPaneToActivateId == null &&
startupToolWindowIdToActivate == null &&
toolWindowIdsToHideOnStartup.isEmpty() &&
toolWindowLayoutProfileId == null
}
}
@@ -23,6 +23,7 @@
bundle="messages.AgentSessionsBundle"
groupKey="advanced.settings.agent.workbench"/>
<projectFrameCapabilitiesProvider implementation="com.intellij.agent.workbench.sessions.AgentWorkbenchProjectFrameCapabilitiesProvider"/>
<toolWindowLayoutProfileProvider implementation="com.intellij.agent.workbench.sessions.AgentWorkbenchToolWindowLayoutProfileProvider"/>
</extensions>
<actions resource-bundle="messages.AgentSessionsBundle">
@@ -0,0 +1,5 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.agent.workbench.sessions
internal const val AGENT_SESSIONS_TOOL_WINDOW_ID: String = "agent.workbench.sessions"
internal const val AGENT_WORKBENCH_DEDICATED_LAYOUT_PROFILE_ID: String = "agent.workbench.dedicated"
@@ -6,6 +6,7 @@ import com.intellij.openapi.wm.ToolWindowId
import com.intellij.openapi.wm.ex.ProjectFrameCapabilitiesProvider
import com.intellij.openapi.wm.ex.ProjectFrameCapability
import com.intellij.openapi.wm.ex.ProjectFrameUiPolicy
import java.util.EnumSet
internal class AgentWorkbenchProjectFrameCapabilitiesProvider : ProjectFrameCapabilitiesProvider {
override fun getCapabilities(project: Project): Set<ProjectFrameCapability> {
@@ -33,8 +34,10 @@ internal class AgentWorkbenchProjectFrameCapabilitiesProvider : ProjectFrameCapa
}
}
private val AGENT_WORKBENCH_FRAME_CAPABILITIES = setOf(ProjectFrameCapability.SUPPRESS_VCS_UI)
private val AGENT_WORKBENCH_FRAME_CAPABILITIES = EnumSet.of(ProjectFrameCapability.SUPPRESS_VCS_UI)
private val AGENT_WORKBENCH_FRAME_UI_POLICY = ProjectFrameUiPolicy(
startupToolWindowIdToActivate = "agent.workbench.sessions",
startupToolWindowIdToActivate = AGENT_SESSIONS_TOOL_WINDOW_ID,
toolWindowIdsToHideOnStartup = setOf(ToolWindowId.PROJECT_VIEW),
toolWindowLayoutProfileId = AGENT_WORKBENCH_DEDICATED_LAYOUT_PROFILE_ID,
)
@@ -0,0 +1,48 @@
// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
@file:Suppress("ReplacePutWithAssignment", "ReplaceGetOrSet")
package com.intellij.agent.workbench.sessions
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindowAnchor
import com.intellij.openapi.wm.ToolWindowId
import com.intellij.openapi.wm.WINDOW_INFO_DEFAULT_TOOL_WINDOW_PANE_ID
import com.intellij.openapi.wm.impl.DesktopLayout
import com.intellij.openapi.wm.impl.WindowInfoImpl
import com.intellij.toolWindow.ToolWindowDefaultLayoutManager
import com.intellij.toolWindow.ToolWindowLayoutProfileProvider
import com.intellij.ui.ExperimentalUI
internal class AgentWorkbenchToolWindowLayoutProfileProvider : ToolWindowLayoutProfileProvider {
override fun getLayout(project: Project, profileId: String, isNewUi: Boolean): DesktopLayout? {
if (project.isDisposed) {
return null
}
if (profileId != AGENT_WORKBENCH_DEDICATED_LAYOUT_PROFILE_ID) {
return null
}
val baseLayout = ToolWindowDefaultLayoutManager.getInstance().getLayoutCopy()
val infos = baseLayout.getInfos()
.asSequence()
.filter { (id, _) -> id != ToolWindowId.PROJECT_VIEW }
.associateTo(LinkedHashMap()) { (id, info) -> id to info.copy() }
val sessionsInfo = infos.get(AGENT_SESSIONS_TOOL_WINDOW_ID) ?: WindowInfoImpl()
val paneId = WINDOW_INFO_DEFAULT_TOOL_WINDOW_PANE_ID
val nextOrderOnLeft = infos.values.asSequence()
.filter { it.toolWindowPaneId == paneId && it.anchor == ToolWindowAnchor.LEFT && it.order >= 0 }
.maxOfOrNull { it.order + 1 } ?: 0
sessionsInfo.id = AGENT_SESSIONS_TOOL_WINDOW_ID
sessionsInfo.toolWindowPaneId = paneId
sessionsInfo.anchor = ToolWindowAnchor.LEFT
sessionsInfo.order = nextOrderOnLeft
sessionsInfo.isVisible = true
sessionsInfo.isShowStripeButton = true
sessionsInfo.weight = 0.25f
infos.put(AGENT_SESSIONS_TOOL_WINDOW_ID, sessionsInfo)
return DesktopLayout(infos, baseLayout.unifiedWeights.copy())
}
}