diff --git a/java/java-impl/resources/META-INF/JavaPlugin.xml b/java/java-impl/resources/META-INF/JavaPlugin.xml
index 97b044542650..98c92f4e3502 100644
--- a/java/java-impl/resources/META-INF/JavaPlugin.xml
+++ b/java/java-impl/resources/META-INF/JavaPlugin.xml
@@ -480,6 +480,7 @@
+
diff --git a/java/java-impl/src/com/intellij/openapi/projectRoots/impl/ExternalJavaConfigurationActivity.kt b/java/java-impl/src/com/intellij/openapi/projectRoots/impl/ExternalJavaConfigurationActivity.kt
index fef68b369428..0d8cf18d14ce 100644
--- a/java/java-impl/src/com/intellij/openapi/projectRoots/impl/ExternalJavaConfigurationActivity.kt
+++ b/java/java-impl/src/com/intellij/openapi/projectRoots/impl/ExternalJavaConfigurationActivity.kt
@@ -1,15 +1,11 @@
-// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.projectRoots.impl
-import com.intellij.openapi.Disposable
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.service
import com.intellij.openapi.extensions.ExtensionNotApplicableException
-import com.intellij.openapi.extensions.ExtensionPointListener
-import com.intellij.openapi.extensions.PluginDescriptor
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
-import com.intellij.openapi.util.Disposer
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.seconds
@@ -18,8 +14,6 @@ import kotlin.time.Duration.Companion.seconds
*/
public class ExternalJavaConfigurationActivity : ProjectActivity {
- private val disposableMap = mutableMapOf, Disposable>()
-
init {
if (ApplicationManager.getApplication().isUnitTestMode) {
throw ExtensionNotApplicableException.create()
@@ -30,33 +24,11 @@ public class ExternalJavaConfigurationActivity : ProjectActivity {
// Delay a little bit not to overload project opening
delay(5.seconds)
- val configWatcherService = project.service()
-
- ExternalJavaConfigurationProvider.EP_NAME.addExtensionPointListener(object : ExtensionPointListener> {
- override fun extensionAdded(extension: ExternalJavaConfigurationProvider<*>, pluginDescriptor: PluginDescriptor) {
- setupExtension(project, extension)
- }
-
- override fun extensionRemoved(extension: ExternalJavaConfigurationProvider<*>, pluginDescriptor: PluginDescriptor) {
- val clazz = extension::class.java
- disposableMap[clazz]?.let { Disposer.dispose(it) }
- disposableMap.remove(extension::class.java)
- }
- }, configWatcherService)
+ val service = project.service()
+ service.addExtensionPointListener()
for (configProvider in ExternalJavaConfigurationProvider.EP_NAME.extensionList) {
- val file = configProvider.getConfigurationFile(project)
- setupExtension(project, configProvider)
- if (file.exists()) configWatcherService.updateJdkFromConfig(configProvider)
+ service.updateFromConfig(configProvider, true)
}
}
-
- private fun setupExtension(project: Project, extension: ExternalJavaConfigurationProvider<*>) {
- val key = extension::class.java
- if (disposableMap.containsKey(key)) return
- val configWatcherService = project.service()
- val disposable = Disposer.newDisposable(configWatcherService)
- configWatcherService.registerListener(disposable, extension)
- disposableMap[extension::class.java] = disposable
- }
}
\ No newline at end of file
diff --git a/java/java-impl/src/com/intellij/openapi/projectRoots/impl/ExternalJavaConfigurationCodeVision.kt b/java/java-impl/src/com/intellij/openapi/projectRoots/impl/ExternalJavaConfigurationCodeVision.kt
new file mode 100644
index 000000000000..be0de6956daa
--- /dev/null
+++ b/java/java-impl/src/com/intellij/openapi/projectRoots/impl/ExternalJavaConfigurationCodeVision.kt
@@ -0,0 +1,125 @@
+// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
+package com.intellij.openapi.projectRoots.impl
+
+import com.intellij.codeInsight.codeVision.*
+import com.intellij.codeInsight.codeVision.ui.model.ClickableTextCodeVisionEntry
+import com.intellij.codeInsight.codeVision.ui.model.TextCodeVisionEntry
+import com.intellij.codeInsight.hints.InlayHintsUtils
+import com.intellij.icons.AllIcons
+import com.intellij.java.JavaBundle
+import com.intellij.openapi.components.service
+import com.intellij.openapi.editor.Editor
+import com.intellij.openapi.fileEditor.FileDocumentManager
+import com.intellij.openapi.project.Project
+import com.intellij.openapi.projectRoots.impl.ExternalJavaConfigurationService.JavaConfigurationStatus
+import com.intellij.openapi.roots.ProjectRootManager
+import com.intellij.openapi.roots.ui.configuration.ProjectSettingsService
+import com.intellij.openapi.util.TextRange
+import com.intellij.psi.PsiDocumentManager
+import com.intellij.psi.PsiFile
+import java.awt.event.MouseEvent
+
+/**
+ * Code Vision for external Java configuration files (e.g., .sdkmanrc, .tool-versions).
+ *
+ * It shows the [JavaConfigurationStatus] and makes it possible to set the project JDK
+ * or download missing JDKs through an external configuration tool.
+ */
+public class ExternalJavaConfigurationCodeVision : CodeVisionProvider {
+ public companion object {
+ internal const val ID: String = "java.external.configuration"
+ }
+
+ override fun isAvailableFor(project: Project): Boolean = true
+
+ override fun precomputeOnUiThread(editor: Editor) {}
+
+ override fun preparePreview(editor: Editor, file: PsiFile) {
+
+ }
+
+ override val name: String
+ get() = JavaBundle.message("external.java.configuration.inlay.provider.name")
+ override val relativeOrderings: List
+ get() = emptyList()
+ override val defaultAnchor: CodeVisionAnchorKind
+ get() = CodeVisionAnchorKind.Default
+ override val id: String
+ get() = ID
+
+ override fun computeCodeVision(editor: Editor, uiData: Unit): CodeVisionState {
+ val project = editor.project ?: return CodeVisionState.READY_EMPTY
+
+ return InlayHintsUtils.computeCodeVisionUnderReadAction {
+ val document = editor.document
+ val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(document) ?: return@computeCodeVisionUnderReadAction CodeVisionState.READY_EMPTY
+
+ val provider = ExternalJavaConfigurationProvider.EP_NAME.extensionList.find { it.isConfigurationFile(psiFile.name) }
+ ?: return@computeCodeVisionUnderReadAction CodeVisionState.READY_EMPTY
+
+ val text = FileDocumentManager.getInstance().getFile(document)?.let { FileDocumentManager.getInstance().getDocument(it)?.text }
+ ?: psiFile.text
+
+ val range = provider.getReleaseDataOffset(text) ?: return@computeCodeVisionUnderReadAction CodeVisionState.READY_EMPTY
+
+ val service = project.service()
+ @Suppress("UNCHECKED_CAST")
+ service.updateFromConfig(provider as ExternalJavaConfigurationProvider)
+ val status = service.statuses[psiFile.virtualFile.path] ?: JavaConfigurationStatus.Unknown
+
+ val entry = buildEntry(project, provider, status)
+ if (entry == null) return@computeCodeVisionUnderReadAction CodeVisionState.READY_EMPTY
+
+ CodeVisionState.Ready(listOf(TextRange(range.startOffset, range.endOffset) to entry))
+ }
+ }
+
+ private fun buildEntry(project: Project,
+ provider: ExternalJavaConfigurationProvider,
+ status: JavaConfigurationStatus): CodeVisionEntry? {
+ return when (status) {
+ is JavaConfigurationStatus.Unknown -> {
+ val text = JavaBundle.message("external.java.configuration.inlay.unknown")
+ TextCodeVisionEntry(text, ID, icon = AllIcons.Actions.Refresh).apply { showInMorePopup = false }
+ }
+
+ is JavaConfigurationStatus.AlreadyConfigured -> {
+ val jdkName = ProjectRootManager.getInstance(project).projectSdk?.name ?: return null
+ val text = JavaBundle.message("external.java.configuration.inlay.already.configured", jdkName)
+ val onClick: (MouseEvent?, Editor) -> Unit = { _, _ ->
+ ProjectSettingsService.getInstance(project).openProjectSettings()
+ }
+ ClickableTextCodeVisionEntry(text, ID, onClick = onClick, icon = AllIcons.General.GreenCheckmark)
+ }
+
+ is JavaConfigurationStatus.Found -> {
+ val text = JavaBundle.message("external.java.configuration.inlay.found")
+ val onClick: (MouseEvent?, Editor) -> Unit = { _, _ ->
+ val service = project.service()
+ @Suppress("UNCHECKED_CAST")
+ service.updateFromConfig(provider as ExternalJavaConfigurationProvider, true)
+ }
+ ClickableTextCodeVisionEntry(text, ID, onClick = onClick, icon = AllIcons.General.Gear)
+ }
+
+ is JavaConfigurationStatus.Missing<*> -> {
+ @Suppress("UNCHECKED_CAST")
+ val missing = status as JavaConfigurationStatus.Missing
+ val command = provider.getDownloadCommandFor(missing.releaseData)
+ if (command != null) {
+ val text = JavaBundle.message("external.java.configuration.inlay.download", command)
+ val onClick: (MouseEvent?, Editor) -> Unit = { _, _ ->
+ // TODO: Open terminal session with the [command]
+ }
+ ClickableTextCodeVisionEntry(text, ID, onClick = onClick, icon = AllIcons.Actions.Download)
+ }
+ else {
+ val text = JavaBundle.message("external.java.configuration.inlay.missing")
+ TextCodeVisionEntry(text, ID, icon = AllIcons.General.Warning).apply {
+ showInMorePopup = false
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/java/java-impl/src/com/intellij/openapi/projectRoots/impl/ExternalJavaConfigurationService.kt b/java/java-impl/src/com/intellij/openapi/projectRoots/impl/ExternalJavaConfigurationService.kt
index 89a65bcc72d8..9b9ba930d6b9 100644
--- a/java/java-impl/src/com/intellij/openapi/projectRoots/impl/ExternalJavaConfigurationService.kt
+++ b/java/java-impl/src/com/intellij/openapi/projectRoots/impl/ExternalJavaConfigurationService.kt
@@ -1,6 +1,8 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.openapi.projectRoots.impl
+import com.intellij.codeInsight.codeVision.CodeVisionHost
+import com.intellij.codeInsight.codeVision.CodeVisionHost.LensInvalidateSignal
import com.intellij.execution.wsl.WslPath
import com.intellij.java.JavaBundle
import com.intellij.notification.NotificationGroupManager
@@ -9,9 +11,12 @@ import com.intellij.openapi.Disposable
import com.intellij.openapi.application.EDT
import com.intellij.openapi.application.edtWriteAction
import com.intellij.openapi.application.readAction
+import com.intellij.openapi.application.runInEdt
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.logger
+import com.intellij.openapi.extensions.ExtensionPointListener
+import com.intellij.openapi.extensions.PluginDescriptor
import com.intellij.openapi.fileEditor.FileDocumentManager
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.guessProjectDir
@@ -21,10 +26,6 @@ import com.intellij.openapi.projectRoots.Sdk
import com.intellij.openapi.roots.ProjectRootManager
import com.intellij.openapi.util.SystemInfo
import com.intellij.openapi.vfs.VirtualFileManager
-import com.intellij.openapi.vfs.newvfs.BulkFileListener
-import com.intellij.openapi.vfs.newvfs.events.VFileContentChangeEvent
-import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent
-import com.intellij.openapi.vfs.newvfs.events.VFileEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -40,43 +41,50 @@ private val LOG = logger()
@Service(Service.Level.PROJECT)
public class ExternalJavaConfigurationService(public val project: Project, private val scope: CoroutineScope) : Disposable {
+ internal sealed class JavaConfigurationStatus {
+ object Unknown : JavaConfigurationStatus()
+ object AlreadyConfigured : JavaConfigurationStatus()
+ object Found : JavaConfigurationStatus()
+ data class Missing(val releaseData: T) : JavaConfigurationStatus()
+ }
+
+ internal val statuses = HashMap().withDefault { JavaConfigurationStatus.Unknown }
+
public sealed class JdkCandidate {
public data class Jdk(val releaseData: T, val jdk: Sdk, val project: Boolean) : JdkCandidate()
public data class Path(val releaseData: T, val path: String) : JdkCandidate()
}
- internal fun registerListener(disposable: Disposable, configProvider: ExternalJavaConfigurationProvider) {
- project.messageBus.connect(disposable).subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener {
- override fun after(events: MutableList) {
- //TODO: getConfigurationFile(project) create a File with a fixed name, only to extract this name back here
- // much better would be to provide .getConfigurationFileName() method, OR just cache the file created
- // inside getConfigurationFile()
- val configFileName = configProvider.getConfigurationFile(project).name
- for (event in events) {
- if (!event.path.endsWith(configFileName)) continue
- if (event !is VFileContentChangeEvent && event !is VFileCreateEvent) continue
-
- updateJdkFromConfig(configProvider)
- }
- }
- })
- }
-
/**
- * Updates the project JDK according to the configuration file of [configProvider].
+ * Searches for a matching JDK candidate if a Java configuration is defined in the config file.
+ * If [configureJdk] is true, the project JDK will be updated if a candidate is found.
*/
- public fun updateJdkFromConfig(configProvider: ExternalJavaConfigurationProvider) {
+ public fun updateFromConfig(configProvider: ExternalJavaConfigurationProvider, configureJdk: Boolean = false) {
scope.launch {
val releaseData: T = getReleaseData(configProvider) ?: return@launch
val file = configProvider.getConfigurationFile(project)
- val suggestion = findCandidate(releaseData, configProvider)
- when (suggestion) {
- is JdkCandidate.Jdk -> if (!suggestion.project) configure(suggestion.jdk, file.name, releaseData)
- is JdkCandidate.Path -> service().createJdkFromPath(suggestion.path) {
- configure(it, file.name, releaseData)
+ when (val candidate = findCandidate(releaseData, configProvider)) {
+ is JdkCandidate.Jdk -> {
+ if (candidate.project) {
+ setStatus(file.path, JavaConfigurationStatus.AlreadyConfigured)
+ }
+ else {
+ setStatus(file.path, JavaConfigurationStatus.Found)
+ if (configureJdk) configure(candidate.jdk, file.name, file.path, releaseData)
+ }
+ }
+ is JdkCandidate.Path -> {
+ setStatus(file.path, JavaConfigurationStatus.Found)
+ if (configureJdk) {
+ service().createJdkFromPath(candidate.path) {
+ configure(it, file.name, file.path, releaseData)
+ }
+ }
+ }
+ else -> {
+ setStatus(file.path, JavaConfigurationStatus.Missing(releaseData))
}
- else -> null
}
}
}
@@ -131,12 +139,15 @@ public class ExternalJavaConfigurationService(public val project: Project, priva
return null
}
- private fun configure(jdk: Sdk, fileName: String, candidate: T) {
+ private fun configure(jdk: Sdk, fileName: String, filePath: String, candidate: T) {
scope.launch(Dispatchers.EDT) {
val rootManager = ProjectRootManager.getInstance(project)
edtWriteAction { rootManager.projectSdk = jdk }
LOG.info("[$fileName] $candidate - JDK registered: ${jdk.versionString}")
+ // Update status and refresh inlays after configuration
+ setStatus(filePath, JavaConfigurationStatus.AlreadyConfigured)
+
NotificationGroupManager.getInstance()
.getNotificationGroup("Setup JDK")
.createNotification(
@@ -148,5 +159,23 @@ public class ExternalJavaConfigurationService(public val project: Project, priva
}
}
+ private fun setStatus(filePath: String, newStatus: JavaConfigurationStatus) {
+ if (statuses[filePath] == newStatus) return
+ statuses[filePath] = newStatus
+ runInEdt {
+ project.service().invalidateProvider(
+ LensInvalidateSignal(null, listOf(ExternalJavaConfigurationCodeVision.ID))
+ )
+ }
+ }
+
override fun dispose() {}
+
+ public fun addExtensionPointListener() {
+ ExternalJavaConfigurationProvider.EP_NAME.addExtensionPointListener(scope, object : ExtensionPointListener> {
+ override fun extensionAdded(extension: ExternalJavaConfigurationProvider<*>, pluginDescriptor: PluginDescriptor) {
+ updateFromConfig(extension)
+ }
+ })
+ }
}
\ No newline at end of file
diff --git a/java/openapi/resources/messages/JavaBundle.properties b/java/openapi/resources/messages/JavaBundle.properties
index 7c19b3eaff25..b9c3ba34d1b2 100644
--- a/java/openapi/resources/messages/JavaBundle.properties
+++ b/java/openapi/resources/messages/JavaBundle.properties
@@ -1777,6 +1777,12 @@ dialog.message.template.not.applicable=Template is not applicable
dialog.message.class.not.found=Template class ''{0}'' not found
notification.content.was.set.up=JDK ''{0}'' was set up for the project
notification.content.change.jdk=Change JDK
+external.java.configuration.inlay.provider.name=External JDK Configuration
+external.java.configuration.inlay.unknown=Syncing\u2026
+external.java.configuration.inlay.already.configured=Project JDK ({0})
+external.java.configuration.inlay.found=Set as project JDK
+external.java.configuration.inlay.download=Run ''{0}''
+external.java.configuration.inlay.missing=JDK not found
button.to.another.directory=To &Directory
button.to.another.source.root=To &Source Root
where.do.you.want.to.move.directory.prompt={0}\n\nWould you like to move the directory to another source root or another directory?