[jdk] Add code vision for external Java configuration tools

IDEA-364749

GitOrigin-RevId: cbd4a97359c41efe147177cac8b4f0e048b671b3
This commit is contained in:
Louis Vignier
2025-08-29 14:42:24 +00:00
committed by intellij-monorepo-bot
parent 604fdbacda
commit 323e1ae105
5 changed files with 195 additions and 62 deletions
@@ -480,6 +480,7 @@
<postStartupActivity implementation="com.intellij.openapi.projectRoots.impl.ExistingJdkConfigurationActivity"/>
<openapi.projectRoots.externalJavaConfigurationProvider implementation="com.intellij.openapi.projectRoots.impl.SdkmanrcConfigurationProvider"/>
<openapi.projectRoots.externalJavaConfigurationProvider implementation="com.intellij.openapi.projectRoots.impl.ToolVersionsConfigurationProvider"/>
<codeInsight.codeVisionProvider implementation="com.intellij.openapi.projectRoots.impl.ExternalJavaConfigurationCodeVision"/>
<postStartupActivity implementation="com.intellij.openapi.projectRoots.impl.ExternalJavaConfigurationActivity"/>
<registryKey key="jdk.configure.existing" defaultValue="false" description="Attempt to add an existing SDK to the SDK table."/>
@@ -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<Class<*>, 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<ExternalJavaConfigurationService>()
ExternalJavaConfigurationProvider.EP_NAME.addExtensionPointListener(object : ExtensionPointListener<ExternalJavaConfigurationProvider<*>> {
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<ExternalJavaConfigurationService>()
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<ExternalJavaConfigurationService>()
val disposable = Disposer.newDisposable(configWatcherService)
configWatcherService.registerListener(disposable, extension)
disposableMap[extension::class.java] = disposable
}
}
@@ -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<Unit> {
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<CodeVisionRelativeOrdering>
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<ExternalJavaConfigurationService>()
@Suppress("UNCHECKED_CAST")
service.updateFromConfig(provider as ExternalJavaConfigurationProvider<Any?>)
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 <T> buildEntry(project: Project,
provider: ExternalJavaConfigurationProvider<T>,
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<ExternalJavaConfigurationService>()
@Suppress("UNCHECKED_CAST")
service.updateFromConfig(provider as ExternalJavaConfigurationProvider<Any?>, true)
}
ClickableTextCodeVisionEntry(text, ID, onClick = onClick, icon = AllIcons.General.Gear)
}
is JavaConfigurationStatus.Missing<*> -> {
@Suppress("UNCHECKED_CAST")
val missing = status as JavaConfigurationStatus.Missing<T>
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
}
}
}
}
}
}
@@ -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<ExternalJavaConfigurationService>()
@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<T>(val releaseData: T) : JavaConfigurationStatus()
}
internal val statuses = HashMap<String, JavaConfigurationStatus>().withDefault { JavaConfigurationStatus.Unknown }
public sealed class JdkCandidate<T> {
public data class Jdk<T>(val releaseData: T, val jdk: Sdk, val project: Boolean) : JdkCandidate<T>()
public data class Path<T>(val releaseData: T, val path: String) : JdkCandidate<T>()
}
internal fun <T> registerListener(disposable: Disposable, configProvider: ExternalJavaConfigurationProvider<T>) {
project.messageBus.connect(disposable).subscribe(VirtualFileManager.VFS_CHANGES, object : BulkFileListener {
override fun after(events: MutableList<out VFileEvent>) {
//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 <T> updateJdkFromConfig(configProvider: ExternalJavaConfigurationProvider<T>) {
public fun <T> updateFromConfig(configProvider: ExternalJavaConfigurationProvider<T>, 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<AddJdkService>().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<AddJdkService>().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 <T> configure(jdk: Sdk, fileName: String, candidate: T) {
private fun <T> 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<CodeVisionHost>().invalidateProvider(
LensInvalidateSignal(null, listOf(ExternalJavaConfigurationCodeVision.ID))
)
}
}
override fun dispose() {}
public fun addExtensionPointListener() {
ExternalJavaConfigurationProvider.EP_NAME.addExtensionPointListener(scope, object : ExtensionPointListener<ExternalJavaConfigurationProvider<*>> {
override fun extensionAdded(extension: ExternalJavaConfigurationProvider<*>, pluginDescriptor: PluginDescriptor) {
updateFromConfig(extension)
}
})
}
}
@@ -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?