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?