IJPL-233558 force reapply project frame layout

GitOrigin-RevId: f5ecff5241f05e3c7291d73a534c4f8cee7516e5
This commit is contained in:
Vladimir Krivosheev
2026-02-12 15:38:42 +00:00
committed by intellij-monorepo-bot
parent 64f85ceb62
commit afd381f6a2
7 changed files with 243 additions and 20 deletions
@@ -64,4 +64,65 @@ class ToolWindowLayoutProfileServiceTest {
val result = service<ToolWindowLayoutProfileService>().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<ToolWindowLayoutProfileService>().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<ToolWindowLayoutProfileService>().getProfile(project = project, profileId = "dedicated", isNewUi = true)
assertThat(result).isNotNull()
assertThat(result!!.migrationVersion).isEqualTo(0)
}
}
@@ -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<ProjectFrameCapabilitiesService>().getUiPolicy(project) ?: return null
val profileId = uiPolicy.toolWindowLayoutProfileId ?: return null
val profile = service<ToolWindowLayoutProfileService>().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)
}
@@ -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<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)
override fun addToolWindowManagerListener(listener: ToolWindowManagerListener) {
dispatcher.addListener(listener)
@@ -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
}
}
@@ -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
@@ -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
}
}
}
@@ -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
}