BAZEL-2972: Fix project icon not loaded for projects with regular file identities

`RecentProjectIconHelper.getDotIdeaPath` assumes the `./.idea/.idea.<name>/.idea` layout for every non-directory project root. This layout seems to be special case for Rider. For other cases standard `ProjectStorePathManager.getInstance().getStoreDescriptor(file).dotIdea` should be used.

This issue affected Bazel plugin projects - icon is not shown correctly

GitOrigin-RevId: cea7b3f48555ea96325d4dbaceb2d30007c1b499
This commit is contained in:
Damian Baczyński
2026-07-30 10:31:46 +00:00
committed by intellij-monorepo-bot
parent 1297be84fa
commit 08fe83d7f5
4 changed files with 101 additions and 20 deletions
@@ -72,7 +72,9 @@ class RecentProjectIconHelper {
val fileName = file.fileName.toString()
val dotIndex = fileName.lastIndexOf('.')
val fileNameWithoutExt = if (dotIndex == -1) fileName else fileName.take(dotIndex)
return file.parent.resolve("$DIRECTORY_STORE_FOLDER/$DIRECTORY_STORE_FOLDER.$fileNameWithoutExt/$DIRECTORY_STORE_FOLDER")
val nestedDotIdea = file.parent.resolve("$DIRECTORY_STORE_FOLDER/$DIRECTORY_STORE_FOLDER.$fileNameWithoutExt/$DIRECTORY_STORE_FOLDER")
if (Files.isDirectory(nestedDotIdea)) return nestedDotIdea
return ProjectStorePathManager.getInstance().getStoreDescriptor(file).dotIdea
}
catch (_: InvalidPathException) {
return null
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -3,22 +3,36 @@
package com.intellij.openapi.project.impl
import com.intellij.configurationStore.FileStorageAnnotation
import com.intellij.configurationStore.ProjectStoreDescriptor
import com.intellij.configurationStore.ProjectStorePathCustomizer
import com.intellij.configurationStore.StateStorageManager
import com.intellij.ide.AppLifecycleListener
import com.intellij.ide.ProjectGroup
import com.intellij.ide.ProjectGroupActionGroup
import com.intellij.ide.RecentProjectListActionProvider
import com.intellij.ide.RecentProjectsManager
import com.intellij.ide.RecentProjectsManagerBase
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.application.EDT
import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.State
import com.intellij.openapi.components.StateStorageOperation
import com.intellij.openapi.components.Storage
import com.intellij.openapi.extensions.ExtensionPointName
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectCloseListener
import com.intellij.openapi.project.ex.ProjectManagerEx
import com.intellij.project.stateStore
import com.intellij.testFramework.ExtensionTestUtil
import com.intellij.testFramework.PlatformTestUtil
import com.intellij.testFramework.TemporaryDirectoryExtension
import com.intellij.testFramework.assertions.Assertions.assertThat
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.createTestOpenProjectOptions
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.junit5.TestDisposable
import com.intellij.ui.DeferredIconImpl
import com.intellij.ui.JBColor
import com.intellij.util.IconUtil
@@ -26,12 +40,16 @@ import com.intellij.util.PathUtil
import com.intellij.util.messages.SimpleMessageBusConnection
import com.intellij.util.ui.AvatarUtils
import com.intellij.util.ui.EmptyIcon
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.RegisterExtension
import java.awt.Color
import java.nio.file.Files
import java.nio.file.Path
import javax.swing.Icon
import kotlin.test.assertEquals
@TestApplication
@@ -138,25 +156,24 @@ class RecentProjectsTest {
val icon = (rpm.getProjectIcon(slnFile.toString(), isProjectValid = true) as DeferredIconImpl<*>).evaluateAsync()
assertThat(icon).isNotInstanceOf(EmptyIcon::class.java)
// For custom icons we add a 2px empty border
val emptyBorderWidth = 2
val iconSize = 20
assertSingleColorIcon(icon, if (JBColor.isBright()) Color.BLUE else Color.RED)
}
// Check that image is loaded from file, and not generated by IDE
val iconImage = IconUtil.toBufferedImage(icon)
for (x in 0 until iconImage.width) {
for (y in 0 until iconImage.height) {
val color = iconImage.getRGB(x, y)
if (x >= emptyBorderWidth && x < (iconSize - emptyBorderWidth) &&
y >= emptyBorderWidth && y < (iconSize - emptyBorderWidth)) {
assertThat(color).isEqualTo(if (JBColor.isBright()) Color.BLUE.rgb else Color.RED.rgb)
}
else {
assertThat(color).isEqualTo(0)
}
}
}
@Test
fun projectIconForRegularFileProjectIdentity(@TestDisposable disposable: Disposable) = timeoutRunBlocking {
JBColor.setDark(false)
ExtensionTestUtil.maskExtensions(
pointName = PROJECT_STORE_PATH_CUSTOMIZER_EP,
newExtensions = listOf(CustomDotIdeaProjectStorePathCustomizer()),
parentDisposable = disposable,
fireEvents = false,
)
val rpm = (RecentProjectsManager.getInstance() as RecentProjectsManagerBase)
val projectDir = Path.of("${PlatformTestUtil.getPlatformTestDataPath()}/recentProjects/regularFileIdentityProject")
val identityFile = projectDir.resolve(".projectidentity")
val icon = (rpm.getProjectIcon(identityFile.toString(), isProjectValid = true) as DeferredIconImpl<*>).evaluateAsync()
assertThat(icon).isNotInstanceOf(EmptyIcon::class.java)
assertSingleColorIcon(icon, Color.BLUE)
}
@Test
@@ -183,6 +200,27 @@ class RecentProjectsTest {
}
}
private suspend fun assertSingleColorIcon(icon: Icon, expectedColor: Color) = withContext(Dispatchers.EDT) {
// For custom icons we add a 2px empty border
val emptyBorderWidth = 2
val iconSize = 20
// Check that image is loaded from file, and not generated by IDE
val iconImage = IconUtil.toBufferedImage(icon)
for (x in 0 until iconImage.width) {
for (y in 0 until iconImage.height) {
val color = iconImage.getRGB(x, y)
if (x >= emptyBorderWidth && x < (iconSize - emptyBorderWidth) &&
y >= emptyBorderWidth && y < (iconSize - emptyBorderWidth)) {
assertThat(color).isEqualTo(expectedColor.rgb)
}
else {
assertThat(color).isEqualTo(0)
}
}
}
}
private fun getProjectOpenTimestamp(@Suppress("SameParameterValue") projectName: String): Long {
val additionalInfo = RecentProjectsManagerBase.getInstanceEx().state.additionalInfo
for (s in additionalInfo.keys) {
@@ -249,4 +287,45 @@ class RecentProjectsTest {
projectManager.forceCloseProjectAsync(project)
}
}
}
}
private val PROJECT_STORE_PATH_CUSTOMIZER_EP = ExtensionPointName.create<ProjectStorePathCustomizer>("com.intellij.projectStorePathCustomizer")
private class CustomDotIdeaProjectStorePathCustomizer : ProjectStorePathCustomizer {
override fun getStoreDirectoryPath(projectRoot: Path): ProjectStoreDescriptor? {
if (projectRoot.fileName?.toString() != ".projectidentity") return null
val projectDir = projectRoot.parent
return CustomDotIdeaProjectStoreDescriptor(
projectIdentityFile = projectRoot,
dotIdea = projectDir.resolve(".idea.custom"),
historicalProjectBasePath = projectDir,
)
}
}
private class CustomDotIdeaProjectStoreDescriptor(
override val projectIdentityFile: Path,
override val dotIdea: Path,
override val historicalProjectBasePath: Path,
) : ProjectStoreDescriptor {
override fun testStoreDirectoryExistsForProjectRoot(): Boolean = Files.isRegularFile(projectIdentityFile)
override fun getJpsBridgeAwareStorageSpec(filePath: String, project: Project): Storage =
FileStorageAnnotation.PROJECT_FILE_STORAGE_ANNOTATION
override fun getModuleStorageSpecs(
component: PersistentStateComponent<*>,
stateSpec: State,
operation: StateStorageOperation,
storageManager: StateStorageManager,
project: Project,
): List<Storage> = listOf(FileStorageAnnotation.MODULE_FILE_STORAGE_ANNOTATION)
override fun <T : Any> getStorageSpecs(
component: PersistentStateComponent<T>,
stateSpec: State,
operation: StateStorageOperation,
storageManager: StateStorageManager,
): List<Storage> = listOf(FileStorageAnnotation.PROJECT_FILE_STORAGE_ANNOTATION)
}