diff --git a/platform/lang-impl/testSources/com/intellij/toolWindow/ToolWindowLayoutProfileServiceTest.kt b/platform/lang-impl/testSources/com/intellij/toolWindow/ToolWindowLayoutProfileServiceTest.kt index af446435d8f2..22d809b0e4a4 100644 --- a/platform/lang-impl/testSources/com/intellij/toolWindow/ToolWindowLayoutProfileServiceTest.kt +++ b/platform/lang-impl/testSources/com/intellij/toolWindow/ToolWindowLayoutProfileServiceTest.kt @@ -64,4 +64,65 @@ class ToolWindowLayoutProfileServiceTest { val result = service().getLayout(project = project, profileId = "missing", isNewUi = true) assertThat(result).isNull() } + + @Test + fun returnsProfileMetadataFromMatchingProvider() { + 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" } + } + + override fun getApplyMode(project: Project, profileId: String, isNewUi: Boolean): ToolWindowLayoutApplyMode { + return ToolWindowLayoutApplyMode.FORCE_ONCE + } + + override fun getMigrationVersion(project: Project, profileId: String, isNewUi: Boolean): Int { + return 7 + } + }, + ), + disposable, + ) + + val result = service().getProfile(project = project, profileId = "dedicated", isNewUi = true) + + assertThat(result).isNotNull() + assertThat(result!!.layout).isSameAs(firstLayout) + assertThat(result.applyMode).isEqualTo(ToolWindowLayoutApplyMode.FORCE_ONCE) + assertThat(result.migrationVersion).isEqualTo(7) + } + + @Test + fun clampsNegativeMigrationVersionToZero() { + 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" } + } + + override fun getApplyMode(project: Project, profileId: String, isNewUi: Boolean): ToolWindowLayoutApplyMode { + return ToolWindowLayoutApplyMode.FORCE_ONCE + } + + override fun getMigrationVersion(project: Project, profileId: String, isNewUi: Boolean): Int { + return -1 + } + }, + ), + disposable, + ) + + val result = service().getProfile(project = project, profileId = "dedicated", isNewUi = true) + assertThat(result).isNotNull() + assertThat(result!!.migrationVersion).isEqualTo(0) + } } diff --git a/platform/platform-impl/src/com/intellij/openapi/wm/impl/ToolWindowLayoutProfileMigrationHelper.kt b/platform/platform-impl/src/com/intellij/openapi/wm/impl/ToolWindowLayoutProfileMigrationHelper.kt new file mode 100644 index 000000000000..b86aafb366a8 --- /dev/null +++ b/platform/platform-impl/src/com/intellij/openapi/wm/impl/ToolWindowLayoutProfileMigrationHelper.kt @@ -0,0 +1,64 @@ +// 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.openapi.wm.impl + +import com.intellij.ide.util.PropertiesComponent +import com.intellij.openapi.components.service +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ex.ProjectFrameCapabilitiesService +import com.intellij.toolWindow.ToolWindowLayoutApplyMode +import com.intellij.toolWindow.ToolWindowLayoutProfile +import com.intellij.toolWindow.ToolWindowLayoutProfileService + +private const val TOOL_WINDOW_LAYOUT_MIGRATION_PROPERTY_PREFIX = "toolwindow.layout.profile.migration." + +internal data class ProjectFrameToolWindowLayoutProfile( + val profileId: String, + val profile: ToolWindowLayoutProfile, +) + +internal fun resolveProjectFrameToolWindowLayoutProfile( + project: Project, + isNewUi: Boolean, +): ProjectFrameToolWindowLayoutProfile? { + val uiPolicy = service().getUiPolicy(project) ?: return null + val profileId = uiPolicy.toolWindowLayoutProfileId ?: return null + val profile = service().getProfile(project = project, profileId = profileId, isNewUi = isNewUi) + ?: return null + return ProjectFrameToolWindowLayoutProfile(profileId = profileId, profile = profile) +} + +internal fun applyProjectFrameLayoutPolicy( + projectFrameLayoutProfile: ProjectFrameToolWindowLayoutProfile?, + scheduleSetLayout: (DesktopLayout) -> Unit, +) { + val (profileId, profile) = projectFrameLayoutProfile ?: return + when (profile.applyMode) { + ToolWindowLayoutApplyMode.SEED_ONLY -> return + ToolWindowLayoutApplyMode.FORCE_ONCE -> applyProjectFrameLayoutOnce( + profileId = profileId, + profile = profile, + scheduleSetLayout = scheduleSetLayout, + ) + } +} + +private fun applyProjectFrameLayoutOnce( + profileId: String, + profile: ToolWindowLayoutProfile, + scheduleSetLayout: (DesktopLayout) -> Unit, +) { + val migrationVersion = profile.migrationVersion + if (migrationVersion <= 0) { + return + } + + val propertiesComponent = PropertiesComponent.getInstance() + val migrationKey = TOOL_WINDOW_LAYOUT_MIGRATION_PROPERTY_PREFIX + profileId + val appliedMigrationVersion = propertiesComponent.getInt(migrationKey, 0) + if (appliedMigrationVersion >= migrationVersion) { + return + } + + scheduleSetLayout(profile.layout) + propertiesComponent.setValue(migrationKey, migrationVersion, 0) +} 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 c0219ea4082b..172e5215ba61 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 @@ -1,4 +1,4 @@ -// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. @file:Suppress("ReplaceGetOrSet", "ReplacePutWithAssignment", "OverridingDeprecatedMember", "ReplaceNegatedIsEmptyWithIsNotEmpty", "PrivatePropertyName") @file:OptIn(FlowPreview::class) @@ -87,7 +87,6 @@ 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 @@ -102,7 +101,6 @@ 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 @@ -244,14 +242,18 @@ open class ToolWindowManagerImpl @NonInjectable @TestOnly internal constructor( delay = SystemProperties.getIntProperty("actionSystem.keyGestureDblClickTime", 300), coroutineScope = coroutineScope, ) + val projectFrameLayoutProfile = resolveProjectFrameToolWindowLayoutProfile(project = project, isNewUi = isNewUi) if (state.noStateLoaded) { - loadDefault() + loadDefault(projectFrameLayoutProfile) } @Suppress("LeakingThis") state.scheduledLayout.afterChange(this) { dl -> dl?.let { toolWindowSetInitializer.scheduleSetLayout(it) } } state.scheduledLayout.get()?.let { toolWindowSetInitializer.scheduleSetLayout(it) } + applyProjectFrameLayoutPolicy(projectFrameLayoutProfile) { layout -> + toolWindowSetInitializer.scheduleSetLayout(layout) + } } } @@ -775,17 +777,16 @@ open class ToolWindowManagerImpl @NonInjectable @TestOnly internal constructor( } } - private fun loadDefault() { - val layout = getLayoutForProjectFrameProfile() ?: ToolWindowDefaultLayoutManager.getInstance().getLayoutCopy() + private fun loadDefault( + projectFrameLayoutProfile: ProjectFrameToolWindowLayoutProfile? = resolveProjectFrameToolWindowLayoutProfile( + project = project, + isNewUi = isNewUi, + ), + ) { + val layout = projectFrameLayoutProfile?.profile?.layout ?: ToolWindowDefaultLayoutManager.getInstance().getLayoutCopy() toolWindowSetInitializer.scheduleSetLayout(layout) } - private fun getLayoutForProjectFrameProfile(): DesktopLayout? { - val uiPolicy = service().getUiPolicy(project) ?: return null - val profileId = uiPolicy.toolWindowLayoutProfileId ?: return null - return service().getLayout(project = project, profileId = profileId, isNewUi = isNewUi) - } - @Deprecated("Use {@link ToolWindowManagerListener#TOPIC}", level = DeprecationLevel.ERROR) override fun addToolWindowManagerListener(listener: ToolWindowManagerListener) { dispatcher.addListener(listener) diff --git a/platform/platform-impl/src/com/intellij/toolWindow/ToolWindowLayoutProfileProvider.kt b/platform/platform-impl/src/com/intellij/toolWindow/ToolWindowLayoutProfileProvider.kt index bef667b4af68..728ac7927a6e 100644 --- a/platform/platform-impl/src/com/intellij/toolWindow/ToolWindowLayoutProfileProvider.kt +++ b/platform/platform-impl/src/com/intellij/toolWindow/ToolWindowLayoutProfileProvider.kt @@ -23,12 +23,44 @@ interface ToolWindowLayoutProfileProvider { } fun getLayout(project: Project, profileId: String, isNewUi: Boolean): DesktopLayout? + + fun getApplyMode(project: Project, profileId: String, isNewUi: Boolean): ToolWindowLayoutApplyMode { + return ToolWindowLayoutApplyMode.SEED_ONLY + } + + fun getMigrationVersion(project: Project, profileId: String, isNewUi: Boolean): Int { + return 0 + } } +@Internal +enum class ToolWindowLayoutApplyMode { + /** + * Apply only when no per-project layout was persisted yet. + */ + SEED_ONLY, + + /** + * Apply once for the given profile migration version. + */ + FORCE_ONCE, +} + +@Internal +data class ToolWindowLayoutProfile( + val layout: DesktopLayout, + val applyMode: ToolWindowLayoutApplyMode, + val migrationVersion: Int, +) + @Service(Service.Level.APP) @Internal class ToolWindowLayoutProfileService { fun getLayout(project: Project, profileId: String, isNewUi: Boolean): DesktopLayout? { + return getProfile(project = project, profileId = profileId, isNewUi = isNewUi)?.layout + } + + fun getProfile(project: Project, profileId: String, isNewUi: Boolean): ToolWindowLayoutProfile? { if (project.isDisposed) { return null } @@ -38,20 +70,24 @@ class ToolWindowLayoutProfileService { return null } - var layout: DesktopLayout? = null - var layoutProvider: ToolWindowLayoutProfileProvider? = null + var profile: ToolWindowLayoutProfile? = null + var profileProvider: 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 + if (profile == null) { + profile = ToolWindowLayoutProfile( + layout = providerLayout, + applyMode = provider.getApplyMode(project = project, profileId = profileId, isNewUi = isNewUi), + migrationVersion = provider.getMigrationVersion(project = project, profileId = profileId, isNewUi = isNewUi).coerceAtLeast(0), + ) + profileProvider = provider } else { LOG.error( "Multiple tool window layouts are provided for profile '$profileId'. " + - "Keeping ${layoutProvider?.javaClass?.name}, ignoring ${provider.javaClass.name}." + "Keeping ${profileProvider?.javaClass?.name}, ignoring ${provider.javaClass.name}." ) } } @@ -64,7 +100,7 @@ class ToolWindowLayoutProfileService { } } - return layout + return profile } } diff --git a/plugins/agent-workbench/sessions/src/AgentWorkbenchFrameConstants.kt b/plugins/agent-workbench/sessions/src/AgentWorkbenchFrameConstants.kt index e17f3aa156f4..4eb371c0d9ca 100644 --- a/plugins/agent-workbench/sessions/src/AgentWorkbenchFrameConstants.kt +++ b/plugins/agent-workbench/sessions/src/AgentWorkbenchFrameConstants.kt @@ -3,3 +3,4 @@ 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" +internal const val AGENT_WORKBENCH_LAYOUT_MIGRATION_VERSION: Int = 1 diff --git a/plugins/agent-workbench/sessions/src/AgentWorkbenchToolWindowLayoutProfileProvider.kt b/plugins/agent-workbench/sessions/src/AgentWorkbenchToolWindowLayoutProfileProvider.kt index 189cc5ff2e3e..5d7d20ff63ef 100644 --- a/plugins/agent-workbench/sessions/src/AgentWorkbenchToolWindowLayoutProfileProvider.kt +++ b/plugins/agent-workbench/sessions/src/AgentWorkbenchToolWindowLayoutProfileProvider.kt @@ -10,8 +10,8 @@ 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.ToolWindowLayoutApplyMode import com.intellij.toolWindow.ToolWindowLayoutProfileProvider -import com.intellij.ui.ExperimentalUI internal class AgentWorkbenchToolWindowLayoutProfileProvider : ToolWindowLayoutProfileProvider { override fun getLayout(project: Project, profileId: String, isNewUi: Boolean): DesktopLayout? { @@ -45,4 +45,22 @@ internal class AgentWorkbenchToolWindowLayoutProfileProvider : ToolWindowLayoutP return DesktopLayout(infos, baseLayout.unifiedWeights.copy()) } + + override fun getApplyMode(project: Project, profileId: String, isNewUi: Boolean): ToolWindowLayoutApplyMode { + return if (profileId == AGENT_WORKBENCH_DEDICATED_LAYOUT_PROFILE_ID) { + ToolWindowLayoutApplyMode.FORCE_ONCE + } + else { + ToolWindowLayoutApplyMode.SEED_ONLY + } + } + + override fun getMigrationVersion(project: Project, profileId: String, isNewUi: Boolean): Int { + return if (profileId == AGENT_WORKBENCH_DEDICATED_LAYOUT_PROFILE_ID) { + AGENT_WORKBENCH_LAYOUT_MIGRATION_VERSION + } + else { + 0 + } + } } diff --git a/plugins/agent-workbench/sessions/testSrc/AgentWorkbenchToolWindowLayoutProfileProviderTest.kt b/plugins/agent-workbench/sessions/testSrc/AgentWorkbenchToolWindowLayoutProfileProviderTest.kt new file mode 100644 index 000000000000..1486376f9314 --- /dev/null +++ b/plugins/agent-workbench/sessions/testSrc/AgentWorkbenchToolWindowLayoutProfileProviderTest.kt @@ -0,0 +1,42 @@ +// 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 + +import com.intellij.openapi.project.Project +import com.intellij.toolWindow.ToolWindowLayoutApplyMode +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.lang.reflect.Proxy + +class AgentWorkbenchToolWindowLayoutProfileProviderTest { + private val provider = AgentWorkbenchToolWindowLayoutProfileProvider() + + @Test + fun dedicatedProfileUsesForceOnceMigrationPolicy() { + val project = testProject() + + assertThat(provider.getApplyMode(project, AGENT_WORKBENCH_DEDICATED_LAYOUT_PROFILE_ID, isNewUi = true)) + .isEqualTo(ToolWindowLayoutApplyMode.FORCE_ONCE) + assertThat(provider.getMigrationVersion(project, AGENT_WORKBENCH_DEDICATED_LAYOUT_PROFILE_ID, isNewUi = true)) + .isEqualTo(AGENT_WORKBENCH_LAYOUT_MIGRATION_VERSION) + } + + @Test + fun nonDedicatedProfileKeepsSeedOnlyPolicy() { + val project = testProject() + + assertThat(provider.getApplyMode(project, "other.profile", isNewUi = true)) + .isEqualTo(ToolWindowLayoutApplyMode.SEED_ONLY) + assertThat(provider.getMigrationVersion(project, "other.profile", isNewUi = true)) + .isEqualTo(0) + } +} + +private fun testProject(): Project { + val handler = java.lang.reflect.InvocationHandler { _, method, _ -> + when (method.name) { + "isDisposed" -> false + else -> null + } + } + return Proxy.newProxyInstance(Project::class.java.classLoader, arrayOf(Project::class.java), handler) as Project +}