From e4336c6c9c625f230c71898b10d4de098012a152 Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Mon, 24 Aug 2026 17:52:29 +0300 Subject: [PATCH 1/4] projects and java fixes --- gradle.properties | 2 +- gradle/libs.versions.toml | 26 +++--- .../learning/courseFormat/StudyItem.kt | 16 ++++ intellij-plugin/hs-Go/build.gradle.kts | 2 +- .../platform/TestOnlyThreadingCompat.kt | 12 +++ .../platform/TestOnlyThreadingCompat.kt | 12 +++ .../platform/TestOnlyThreadingCompat.kt | 12 +++ .../platform/TestOnlyThreadingCompat.kt | 12 +++ .../internal/hyperskill-settings.gradle.ft | 6 ++ .../academy/learning/EduProjectActivity.kt | 17 +++- .../academy/learning/StudyTaskManager.kt | 11 +++ .../learning/actions/EduActionUtils.kt | 4 +- .../academy/learning/yaml/YamlDeepLoader.kt | 15 +++- .../learning/yaml/YamlFormatSynchronizer.kt | 21 +++++ .../academy/learning/yaml/YamlLoader.kt | 43 +++++++-- .../yaml/format/StudyItemChangeApplier.kt | 16 +++- .../messages/EduJVMBundle.properties | 2 +- .../academy/jvm/JdkEnvironmentSettings.kt | 21 ++++- .../jvm/gradle/GradleStartupActivity.kt | 88 +++++++++++++++---- .../checker/GradleEnvironmentChecker.kt | 4 +- .../jvm/gradle/generation/EduGradleUtils.kt | 76 +++++++++++----- .../GradleSettingsScriptMigrationTest.kt | 78 ++++++++++++++++ .../sql/jvm/gradle/RefreshQueueCompat.kt | 12 +++ .../sql/jvm/gradle/RefreshQueueCompat.kt | 11 +++ .../sql/jvm/gradle/RefreshQueueCompat.kt | 11 +++ .../sql/jvm/gradle/RefreshQueueCompat.kt | 11 +++ .../jvm/gradle/SqlCourseGenerationTestBase.kt | 3 +- 27 files changed, 467 insertions(+), 77 deletions(-) create mode 100644 intellij-plugin/hs-core/branches/252/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt create mode 100644 intellij-plugin/hs-core/branches/253/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt create mode 100644 intellij-plugin/hs-core/branches/261/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt create mode 100644 intellij-plugin/hs-core/branches/262/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt create mode 100644 intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/gradle/GradleSettingsScriptMigrationTest.kt create mode 100644 intellij-plugin/hs-sql/hs-sql-jvm/branches/252/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt create mode 100644 intellij-plugin/hs-sql/hs-sql-jvm/branches/253/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt create mode 100644 intellij-plugin/hs-sql/hs-sql-jvm/branches/261/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt create mode 100644 intellij-plugin/hs-sql/hs-sql-jvm/branches/262/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt diff --git a/gradle.properties b/gradle.properties index 2a9c5a5b1..44e88e53f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ # supported values: 252, 253, 261, 262 environmentName=262 -pluginVersion=2026.18 +pluginVersion=2026.19 # type of IDE (IDEA, CLion, etc.) used to build/test running # for more details see `Different IDEs` section in `PlatformVersions.md` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d2b39c48f..ab6e274e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,39 +1,39 @@ [versions] educational-ml-library = "1.0.65" -jackson = "2.21.2" -kotlin = "2.3.21" -okhttp = "5.3.2" +jackson = "2.22.2" +kotlin = "2.4.10" +okhttp = "5.5.0" retrofit = "3.0.0" [libraries] -annotations = { group = "org.jetbrains", name = "annotations", version = "23.0.0" } -clikt-core = { module = "com.github.ajalt.clikt:clikt-core", version = "5.0.1" } +annotations = { group = "org.jetbrains", name = "annotations", version = "26.1.0" } +clikt-core = { module = "com.github.ajalt.clikt:clikt-core", version = "5.1.0" } converter-jackson = { group = "com.squareup.retrofit2", name = "converter-jackson", version.ref = "retrofit" } educational-ml-library-core = { group = "com.jetbrains.educational.ml", name = "educational-ml-library-core", version.ref = "educational-ml-library" } educational-ml-library-debugger = { group = "com.jetbrains.educational.ml", name = "educational-ml-library-debugger", version.ref = "educational-ml-library" } jackson-dataformat-yaml = { group = "com.fasterxml.jackson.dataformat", name = "jackson-dataformat-yaml", version.ref = "jackson" } jackson-datatype-jsr310 = { group = "com.fasterxml.jackson.datatype", name = "jackson-datatype-jsr310", version.ref = "jackson" } jackson-module-kotlin = { group = "com.fasterxml.jackson.module", name = "jackson-module-kotlin", version.ref = "jackson" } -jsoup = { group = "org.jsoup", name = "jsoup", version = "1.17.2" } -kotlin-css-jvm = { group = "org.jetbrains.kotlin-wrappers", name = "kotlin-css-jvm", version = "2026.4.12" } +jsoup = { group = "org.jsoup", name = "jsoup", version = "1.23.1" } +kotlin-css-jvm = { group = "org.jetbrains.kotlin-wrappers", name = "kotlin-css-jvm", version = "2026.8.4" } kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" } -kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.8.0" } +kotlinx-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version = "1.9.0" } logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } # test dependencies -classgraph = { module = "io.github.classgraph:classgraph", version = "4.8.179" } +classgraph = { module = "io.github.classgraph:classgraph", version = "4.8.193" } junit = { group = "junit", name = "junit", version = "4.13.2" } kotlin-test-junit = { group = "org.jetbrains.kotlin", name = "kotlin-test-junit", version.ref = "kotlin" } -mockk = { group = "io.mockk", name = "mockk", version = "1.13.13" } +mockk = { group = "io.mockk", name = "mockk", version = "1.14.11" } mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } openTest4J = { group = "org.opentest4j", name = "opentest4j", version = "1.3.0" } -hamcrest = { group = "org.hamcrest", name = "hamcrest", version="2.2" } +hamcrest = { group = "org.hamcrest", name = "hamcrest", version="3.0" } [plugins] -intelliJPlatformPlugin = { id = "org.jetbrains.intellij.platform", version = "2.15.0" } +intelliJPlatformPlugin = { id = "org.jetbrains.intellij.platform", version = "2.18.1" } kotlinPlugin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinSerializationPlugin = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } propertiesPlugin = { id = "net.saliman.properties", version = "1.6.0" } -testRetryPlugin = { id = "org.gradle.test-retry", version = "1.6.4" } +testRetryPlugin = { id = "org.gradle.test-retry", version = "1.6.5" } diff --git a/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/StudyItem.kt b/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/StudyItem.kt index 0715f2dde..e8fbcf141 100644 --- a/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/StudyItem.kt +++ b/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/StudyItem.kt @@ -23,6 +23,22 @@ abstract class StudyItem() { var id: Int = 0 // id on remote resource (Stepik, Marketplace) var contentTags: List = listOf() + /** + * `true` when this item's children could not be fully resolved during the last load: a child directory or its config + * file was missing, or a child failed to deserialize. Such an item holds fewer children than the config file on disk + * claims, so writing it back would persist the truncated `content:` list and permanently drop the missing children. + * + * [org.hyperskill.academy.learning.yaml.YamlFormatSynchronizer.saveItem] refuses to write the *structural* config + * while this is set; the remote config keeps being written, as it holds no `content:` list. + * It is recomputed on every load rather than latched, so a later complete load clears it again. + * + * Note this covers children only. A [org.hyperskill.academy.learning.courseFormat.tasks.Task] whose task files + * could not be resolved is deliberately *not* marked here: its `status` lives in the same config file, so blocking + * the save would stop the solved state from being persisted. + */ + @Transient + var isPartiallyLoaded: Boolean = false + @Transient private var _parent: ItemContainer? = null diff --git a/intellij-plugin/hs-Go/build.gradle.kts b/intellij-plugin/hs-Go/build.gradle.kts index a82e507c1..e4c0ba014 100644 --- a/intellij-plugin/hs-Go/build.gradle.kts +++ b/intellij-plugin/hs-Go/build.gradle.kts @@ -7,7 +7,7 @@ dependencies { intellijIde(ideaVersion) bundledModulesSince(ideaVersion, 262, "intellij.platform.smRunner", "intellij.platform.testRunner") - intellijPlugins(goPlugin, intelliLangPlugin) + intellijPlugins(goPlugin) // Workaround to make tests work - the module is not loaded automatically bundledModule("com.intellij.modules.ultimate") testIntellijPlatformFramework(project) diff --git a/intellij-plugin/hs-core/branches/252/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt b/intellij-plugin/hs-core/branches/252/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt new file mode 100644 index 000000000..6d7a28e7e --- /dev/null +++ b/intellij-plugin/hs-core/branches/252/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt @@ -0,0 +1,12 @@ +package org.hyperskill.academy.platform + +/** + * `com.intellij.openapi.application.impl.TestOnlyThreading` only exists since 2025.3. + * On 2025.2 the caller does not hold the write intent lock while dispatching invocation events, so the action runs + * as is -- exactly what this code did before the lock dance was introduced in `Release fixes (#54)`. + * + * BACKCOMPAT: 252 -- drop this branch copy once 2025.2 is no longer supported. + */ +fun runWithoutWriteIntentLock(action: () -> Unit) { + action() +} diff --git a/intellij-plugin/hs-core/branches/253/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt b/intellij-plugin/hs-core/branches/253/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt new file mode 100644 index 000000000..2cc46db05 --- /dev/null +++ b/intellij-plugin/hs-core/branches/253/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt @@ -0,0 +1,12 @@ +package org.hyperskill.academy.platform + +import com.intellij.openapi.application.impl.TestOnlyThreading + +/** + * On 2025.3 `releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack` is a Kotlin function + * taking `() -> T` and returning its result. Since 2026.1 it takes a `java.lang.Runnable` and returns nothing, + * and on 2025.2 the class does not exist at all -- hence one copy of this shim per branch. + */ +fun runWithoutWriteIntentLock(action: () -> Unit) { + TestOnlyThreading.releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack(action) +} diff --git a/intellij-plugin/hs-core/branches/261/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt b/intellij-plugin/hs-core/branches/261/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt new file mode 100644 index 000000000..6245efafd --- /dev/null +++ b/intellij-plugin/hs-core/branches/261/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt @@ -0,0 +1,12 @@ +package org.hyperskill.academy.platform + +import com.intellij.openapi.application.impl.TestOnlyThreading + +/** + * Since 2026.1 `releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack` takes a + * `java.lang.Runnable` and returns nothing. On 2025.3 it takes a Kotlin `() -> T`, and on 2025.2 the class does not + * exist at all -- hence one copy of this shim per branch. + */ +fun runWithoutWriteIntentLock(action: () -> Unit) { + TestOnlyThreading.releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack(Runnable { action() }) +} diff --git a/intellij-plugin/hs-core/branches/262/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt b/intellij-plugin/hs-core/branches/262/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt new file mode 100644 index 000000000..6245efafd --- /dev/null +++ b/intellij-plugin/hs-core/branches/262/src/org/hyperskill/academy/platform/TestOnlyThreadingCompat.kt @@ -0,0 +1,12 @@ +package org.hyperskill.academy.platform + +import com.intellij.openapi.application.impl.TestOnlyThreading + +/** + * Since 2026.1 `releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack` takes a + * `java.lang.Runnable` and returns nothing. On 2025.3 it takes a Kotlin `() -> T`, and on 2025.2 the class does not + * exist at all -- hence one copy of this shim per branch. + */ +fun runWithoutWriteIntentLock(action: () -> Unit) { + TestOnlyThreading.releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack(Runnable { action() }) +} diff --git a/intellij-plugin/hs-core/resources/fileTemplates/internal/hyperskill-settings.gradle.ft b/intellij-plugin/hs-core/resources/fileTemplates/internal/hyperskill-settings.gradle.ft index 74014a0cc..6c73c11f7 100644 --- a/intellij-plugin/hs-core/resources/fileTemplates/internal/hyperskill-settings.gradle.ft +++ b/intellij-plugin/hs-core/resources/fileTemplates/internal/hyperskill-settings.gradle.ft @@ -13,6 +13,12 @@ buildscript { } } +## Lets Gradle download the Java toolchain requested in build.gradle instead of failing with +## "Toolchain download repositories have not been configured" when that JDK is not installed locally. +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + ## Should be the same as `org.hyperskill.academy.learning.courseGeneration.GeneratorUtils.sanitizeName` static String sanitizeName(String name) { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/EduProjectActivity.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/EduProjectActivity.kt index 6a2638b07..a0fa7c8b1 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/EduProjectActivity.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/EduProjectActivity.kt @@ -94,15 +94,24 @@ class EduProjectActivity : ProjectActivity { @VisibleForTesting @RequiresBlockingContext fun migrateYaml(project: Project, course: Course) { - migrateCanCheckLocallyYaml(project, course) + // `saveAll` rewrites every config file from the in-memory model. If that model resolved only partially + // (see `YamlLoader.deserializeContent` and `YamlDeepLoader.removeNonExistingTaskFiles`, both of which silently + // drop unresolvable children), running it on every project open turns a transient loading problem into permanent + // data loss on disk. Save only when a migration actually ran: the YAML-format migration in + // `YamlDeepLoader.loadCourse` already has its own `needMigration`-gated `saveAll`. + if (!migrateCanCheckLocallyYaml(project, course)) return YamlFormatSynchronizer.saveAll(project) } - private fun migrateCanCheckLocallyYaml(project: Project, course: Course) { + /** + * Returns `true` if a migration was performed and the configs have to be written back to disk. + */ + private fun migrateCanCheckLocallyYaml(project: Project, course: Course): Boolean { val propertyComponent = PropertiesComponent.getInstance(project) - if (propertyComponent.getBoolean(YAML_MIGRATED)) return + if (propertyComponent.getBoolean(YAML_MIGRATED)) return false propertyComponent.setValue(YAML_MIGRATED, true) - if (course !is HyperskillCourse) return + if (course !is HyperskillCourse) return false + return true } // In general, it's hack to select proper Project View pane for course projects diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/StudyTaskManager.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/StudyTaskManager.kt index 635f6d6e0..b78e14a0e 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/StudyTaskManager.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/StudyTaskManager.kt @@ -6,6 +6,7 @@ import com.intellij.openapi.application.runReadAction import com.intellij.openapi.components.Service import com.intellij.openapi.components.service import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.util.messages.Topic @@ -34,6 +35,11 @@ class StudyTaskManager(private val project: Project) : DumbAware, Disposable, Ed get() = _course set(course) { _course = course + if (course != null) { + // The course was resolved through another path, e.g. the `course-info.yaml` recovery in `YamlLoader.doLoad` + // or course generation. Clear the failure flag so a later reload is not blocked by a stale failure. + courseLoadedWithError = false + } course?.fireCourseSetEvent() } @@ -66,6 +72,10 @@ class StudyTaskManager(private val project: Project) : DumbAware, Disposable, Ed loadCourse(project) } catch (th: Throwable) { + // Important: ProcessCanceledException must be propagated as-is in the IntelliJ Platform. + // Swallowing it here would latch `courseLoadedWithError` on a merely cancelled read action, leaving the + // project without a course for the rest of the session with no way to recover short of a restart. + if (th is ProcessCanceledException) throw th LOG.error("Error while loading course", th) null } @@ -93,6 +103,7 @@ class StudyTaskManager(private val project: Project) : DumbAware, Disposable, Ed @TestOnly override fun cleanUpState() { course = null + courseLoadedWithError = false } companion object { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/actions/EduActionUtils.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/actions/EduActionUtils.kt index ba7b06039..3e80c4da5 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/actions/EduActionUtils.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/actions/EduActionUtils.kt @@ -4,7 +4,6 @@ import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.PlatformDataKeys -import com.intellij.openapi.application.impl.TestOnlyThreading import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.progress.ProgressIndicator @@ -16,6 +15,7 @@ import org.hyperskill.academy.learning.courseFormat.tasks.Task import org.hyperskill.academy.learning.getContainingTask import org.hyperskill.academy.learning.isUnitTestMode import org.hyperskill.academy.learning.selectedTaskFile +import org.hyperskill.academy.platform.runWithoutWriteIntentLock import org.jetbrains.annotations.NonNls import java.util.concurrent.ExecutionException import java.util.concurrent.Future @@ -64,7 +64,7 @@ object EduActionUtils { } while (true) { try { - TestOnlyThreading.releaseTheAcquiredWriteIntentLockThenExecuteActionAndTakeWriteIntentLockBack { + runWithoutWriteIntentLock { UIUtil.dispatchAllInvocationEvents() } future[10, TimeUnit.MILLISECONDS] diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlDeepLoader.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlDeepLoader.kt index dccdabe91..9db2b15f1 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlDeepLoader.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlDeepLoader.kt @@ -151,8 +151,21 @@ object YamlDeepLoader { // set parent to get dir task.parent = this val taskDir = task.getDir(project.courseDir) + if (taskDir == null) { + // The task directory is not resolvable right now (VFS not refreshed yet, Gradle sync in flight, ...). + // Dropping every task file here would make `YamlFormatSynchronizer.saveAll` persist an empty `files:` list, + // turning a transient loading problem into permanent content loss, so keep the model as it is. + LOG.warn("Task dir for `${task.name}` was not found, keeping its task files as is") + continue + } val invalidTaskFilesNames = task.taskFiles - .filter { (name, _) -> taskDir?.findFileByRelativePath(name) == null }.map { it.key } + .filter { (name, _) -> taskDir.findFileByRelativePath(name) == null }.map { it.key } + if (invalidTaskFilesNames.isNotEmpty()) { + // Contrary to the doc above, these files no longer survive in the config file: any later `saveItem(task)` + // rewrites `files:` from this truncated model. Log what disappeared so a report like GH-59 -- where a Gradle + // sync removed the task modules and source files went missing -- can be diagnosed from the log alone. + LOG.warn("Task files not found under `${taskDir.path}`, removing from task `${task.name}`: $invalidTaskFilesNames") + } invalidTaskFilesNames.forEach { task.removeTaskFile(it) } } } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt index 0c02aa1b2..fd51ea7f8 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlFormatSynchronizer.kt @@ -3,6 +3,7 @@ package org.hyperskill.academy.learning.yaml import com.fasterxml.jackson.databind.ObjectMapper import com.intellij.openapi.application.runReadAction import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory import com.intellij.openapi.fileEditor.FileDocumentManager @@ -14,6 +15,7 @@ import com.intellij.openapi.fileTypes.UnknownFileType import com.intellij.openapi.project.Project import com.intellij.openapi.ui.MessageType import com.intellij.openapi.util.Key +import com.intellij.openapi.util.UserDataHolderEx import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.openapi.vfs.findFile @@ -51,8 +53,11 @@ import javax.swing.JLabel import javax.swing.JPanel object YamlFormatSynchronizer { + private val LOG = Logger.getInstance(YamlFormatSynchronizer::class.java) + val LOAD_FROM_CONFIG = Key("Hyperskill.loadItem") val SAVE_TO_CONFIG = Key("Hyperskill.saveItem") + private val SYNCHRONIZATION_STARTED = Key("Hyperskill.synchronizationStarted") fun saveAll(project: Project) { // If there is no course associated with the project, there is nothing to save. @@ -85,6 +90,17 @@ object YamlFormatSynchronizer { if (!YamlFormatSettings.shouldCreateConfigFiles(project)) { return } + // The item holds fewer children than its config file on disk claims, because some of them could not be resolved + // during the last load. Writing it out now would persist the truncated `content:` list and permanently drop the + // missing children from the course. Skipping the save keeps the on-disk structure intact until a load succeeds. + // + // Only the structural config is guarded. `*-remote-info.yaml` carries no `content:` list, so a partially loaded + // item has to keep persisting its remote state (id, update date, submissions) as usual -- otherwise a single + // unresolvable child would silently freeze the remote state of its whole container for the rest of the session. + if (item.isPartiallyLoaded && configName == item.configFileName) { + LOG.warn("Skipping save of ${item.itemType} `${item.name}`: its children were not fully resolved during load") + return + } item.saveConfig(project, configName, mapper) } @@ -119,6 +135,11 @@ object YamlFormatSynchronizer { return } + // `StudyTaskManager.initializeCourse` may call this more than once for the same project now that a load failure + // is no longer latched forever. Without this guard every extra call would register another global document + // listener and another message bus connection, multiplying the notifications each YAML edit produces. + if (!(project as UserDataHolderEx).replace(SYNCHRONIZATION_STARTED, null, true)) return + val disposable = StudyTaskManager.getInstance(project) EditorFactory.getInstance().eventMulticaster.addDocumentListener(YamlSynchronizationListener(project), disposable) project.messageBus.connect().subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, object : FileEditorManagerListener { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt index 480d9b47b..25beaaee2 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt @@ -62,17 +62,33 @@ object YamlLoader { val existingItem = getStudyItemForConfig(project, configFile) val deserializedItem = deserializeItemProcessingErrors(configFile, project, loadFromVFile, mapper) ?: return + // Recovery path: the course model is missing or broken and the user edited `course-info.yaml`. It is handled + // before `ensureChildrenExist` below so that a course whose child directories are not resolvable yet can still be + // reloaded, instead of failing with `no dir for item` and leaving the project with no model at all. + if (existingItem == null && deserializedItem is Course) { + StudyTaskManager.getInstance(project).course = YamlDeepLoader.loadCourse(project) + return + } + + // The course model itself is unavailable, so this is a plugin-state problem, not a problem with the user's config. + // Reporting `Parent for '' was not found` here blames the user's YAML for something they cannot fix, and the + // code below would call `persistEduFiles`/`addItemAsNew`/`saveItem` against a model that does not exist. + // Bailing out also means any remaining `parent not found` report provably comes from `getParentItem`'s second + // exit, which carries the ALT-11025 diagnostics. + if (existingItem == null && StudyTaskManager.getInstance(project).course == null) { + LOG.warn( + "Skipping YAML reload of `${configFile.path}`: no course is loaded for project `${project.name}`." + + " See the earlier `Error while loading course` entry for the original failure." + ) + return + } + val customContentPath = existingItem?.course.customContentPath deserializedItem.ensureChildrenExist(configFile.parent, customContentPath) if (existingItem == null) { // this code is called if item wasn't loaded because of broken config // and now if config fixed, we'll add item to a parent - if (deserializedItem is Course) { - StudyTaskManager.getInstance(project).course = YamlDeepLoader.loadCourse(project) - return - } - val itemDir = configFile.parent deserializedItem.name = itemDir.name val parentItem = deserializedItem.getParentItem(project, itemDir.parent) @@ -102,10 +118,21 @@ object YamlLoader { mapper: ObjectMapper = basicMapper(), ): List { val content = mutableListOf() + var unresolvedChildren = false for (titledItem in contentList) { - val configFile: VirtualFile = getConfigFileForChild(project, titledItem.name) ?: continue - val deserializeItem = deserializeItemProcessingErrors(configFile, project, mapper = mapper, parentItem = this) as? T ?: continue + val configFile: VirtualFile? = getConfigFileForChild(project, titledItem.name) + if (configFile == null) { + unresolvedChildren = true + continue + } + val deserializeItem = deserializeItemProcessingErrors(configFile, project, mapper = mapper, parentItem = this) as? T + if (deserializeItem == null) { + unresolvedChildren = true + continue + } if (this is Lesson && isHyperskillTopicsLesson() && deserializeItem is UnsupportedTask) { + // A deliberate skip, not an unresolved child: topic lessons legitimately contain tasks this IDE cannot + // support, so this must not mark the lesson partially loaded and block saving it. LOG.warn( "Skipping unsupported task `${titledItem.name}` while loading Hyperskill topic lesson `${name}` from ${configFile.path}" ) @@ -116,6 +143,8 @@ object YamlLoader { content.add(deserializeItem) } + // Recomputed on every load rather than latched, so a later complete load clears it again. + isPartiallyLoaded = unresolvedChildren return content } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/StudyItemChangeApplier.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/StudyItemChangeApplier.kt index ead51e061..9cdc27fcd 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/StudyItemChangeApplier.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/StudyItemChangeApplier.kt @@ -68,6 +68,7 @@ open class ItemContainerChangeApplier(val project: Project) : val existingChildren = existingItem.items val preservedChildren = mutableListOf() val mapper = mapper() + var unresolvedChildren = false for (titledItem in deserializedItem.items) { val child = existingChildren.find { it.name == titledItem.name } if (child != null) { @@ -77,9 +78,17 @@ open class ItemContainerChangeApplier(val project: Project) : else { // this code adding new child item if it was added in config and there's a dir // it is called from `YamlLoader.loadItem` - val configFile = existingItem.getConfigFileForChild(project, titledItem.name) ?: continue + val configFile = existingItem.getConfigFileForChild(project, titledItem.name) + if (configFile == null) { + unresolvedChildren = true + continue + } - val deserializedChild = deserializeItemProcessingErrors(configFile, project, mapper = mapper) ?: continue + val deserializedChild = deserializeItemProcessingErrors(configFile, project, mapper = mapper) + if (deserializedChild == null) { + unresolvedChildren = true + continue + } deserializedChild.name = titledItem.name deserializedChild.index = titledItem.index deserializedChild.parent = existingItem @@ -89,6 +98,9 @@ open class ItemContainerChangeApplier(val project: Project) : } // update items so as removed items are no longer in the course existingItem.items = preservedChildren + // A child named in the config could not be resolved, so `preservedChildren` is shorter than what the config file + // on disk claims. Recomputed on every apply rather than latched, so a later complete load clears it again. + existingItem.isPartiallyLoaded = unresolvedChildren existingItem.init(existingItem.parent, false) } } diff --git a/intellij-plugin/hs-jvm-core/resources/messages/EduJVMBundle.properties b/intellij-plugin/hs-jvm-core/resources/messages/EduJVMBundle.properties index edf17df03..e00137b17 100644 --- a/intellij-plugin/hs-jvm-core/resources/messages/EduJVMBundle.properties +++ b/intellij-plugin/hs-jvm-core/resources/messages/EduJVMBundle.properties @@ -6,7 +6,7 @@ error.jdk.loading.failed=Failed to load JDK list. Please check your IDE settings error.no.main=Unable to execute task `{0}`, main method is missing # Ex.: Gradle project isn't imported. Reload Gradle project. For more information, see the Troubleshooting guide error.gradle.not.imported=Gradle project isn''t imported. Reload Gradle project. For more information, see the Troubleshooting guide -error.hyperskill.incorrect.jdk=Please update your SDK to version {0}. Open Project Settings. +error.hyperskill.incorrect.jdk=Please update your SDK to version {0} or newer. Open Project Settings. failed.determine.java.version=Failed to determine Java version from string: {0}. In the settings section, choose or download another JDK diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkEnvironmentSettings.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkEnvironmentSettings.kt index c5163008d..9d54c90bf 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkEnvironmentSettings.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkEnvironmentSettings.kt @@ -2,16 +2,35 @@ package org.hyperskill.academy.jvm import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.JavaSdkVersion +import com.intellij.openapi.projectRoots.JavaSdkVersionUtil +import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.roots.LanguageLevelProjectExtension +import com.intellij.util.lang.JavaVersion import org.hyperskill.academy.learning.courseFormat.Course import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse const val JVM_LANGUAGE_LEVEL = "jvm_language_level" // TODO(refactor this), this is a temporary solution -// All JVM-based Hyperskill courses now require JDK version 23 +// All JVM-based Hyperskill courses require at least JDK version 23 val hyperskillJdkVersion: JavaSdkVersion = JavaSdkVersion.JDK_23 +/** + * Checks that [this] JDK is new enough for a Hyperskill course, i.e. is [hyperskillJdkVersion] or newer. + * + * Feature versions are compared instead of `JavaSdkVersion` values because the latter enum has no entry for a JDK + * newer than the one the IDE knows about: [JavaSdkVersionUtil.getJavaSdkVersion] returns `null` for such a JDK, + * which would make a perfectly suitable installation look unsupported. + */ +fun Sdk.isAtLeastHyperskillJdkVersion(): Boolean { + val requiredFeatureVersion = JavaVersion.tryParse(hyperskillJdkVersion.description)?.feature + val actualFeatureVersion = versionString?.let { JavaVersion.tryParse(it) }?.feature + if (requiredFeatureVersion != null && actualFeatureVersion != null) { + return actualFeatureVersion >= requiredFeatureVersion + } + return JavaSdkVersionUtil.getJavaSdkVersion(this)?.isAtLeast(hyperskillJdkVersion) == true +} + val Course.minJvmSdkVersion: ParsedJavaVersion get() = when (this) { is HyperskillCourse -> JavaVersionParseSuccess(hyperskillJdkVersion) diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt index 0aadf1ba0..bf7e7e740 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt @@ -29,9 +29,10 @@ class GradleStartupActivity : ProjectActivity { return } if (EduGradleUtils.isConfiguredWithGradle(project)) { - val buildScriptMigrated = migrateLegacyBuildGradle(project) + val buildScriptMigrated = migrateScript(project, GradleConstants.DEFAULT_SCRIPT_NAME, ::migrateLegacyUtilSourceSetReferences) + val settingsMigrated = migrateScript(project, GradleConstants.SETTINGS_FILE_NAME, ::addToolchainResolver) updateGradleSettings(project) - if (buildScriptMigrated) { + if (buildScriptMigrated || settingsMigrated) { // The import triggered by project opening has already read the outdated script, // so it has to be re-run to pick up the migrated one. GradleCourseRefresher.firstAvailable()?.refresh(project, RefreshCause.DEPENDENCIES_UPDATED) @@ -65,34 +66,28 @@ class GradleStartupActivity : ProjectActivity { } /** - * Rewrites `project(':util').sourceSets.*.output` references left in build scripts of old projects. + * Applies [migration] to the Gradle script named [scriptName] in the project root. * - * Inside a `dependencies { }` block such a call is resolved against `DependencyHandler`, - * which since Gradle 9 provides its own `project(String)` returning a `ProjectDependency` instead of a `Project`, - * so `sourceSets` is no longer resolvable there. Qualifying the call with `rootProject` makes it resolve - * against `Project` again, which is valid for every Gradle version, so the migration is not tied to a - * particular IDE or Gradle version. - * - * @return `true` if the build script was actually changed + * @return `true` if the script was actually changed */ - private suspend fun migrateLegacyBuildGradle(project: Project): Boolean { + private suspend fun migrateScript(project: Project, scriptName: String, migration: (String) -> String): Boolean { val projectDir = project.guessProjectDir() ?: return false - val buildFile = projectDir.findChild(GradleConstants.DEFAULT_SCRIPT_NAME) ?: return false - if (buildFile.isDirectory) return false + val scriptFile = projectDir.findChild(scriptName) ?: return false + if (scriptFile.isDirectory) return false return try { - val originalContent = VfsUtilCore.loadText(buildFile) - val migratedContent = migrateLegacyUtilSourceSetReferences(originalContent) + val originalContent = VfsUtilCore.loadText(scriptFile) + val migratedContent = migration(originalContent) if (migratedContent == originalContent) return false writeAction { - VfsUtil.saveText(buildFile, migratedContent) + VfsUtil.saveText(scriptFile, migratedContent) } - LOG.info("Migrated legacy util sourceSets references in ${buildFile.path}") + LOG.info("Migrated ${scriptFile.path}") true } catch (e: IOException) { - LOG.warn("Failed to migrate legacy util sourceSets references in ${buildFile.path}", e) + LOG.warn("Failed to migrate ${scriptFile.path}", e) false } } @@ -117,8 +112,65 @@ class GradleStartupActivity : ProjectActivity { private val LEGACY_UTIL_SOURCE_SET_REFERENCE = Regex("""(? "rootProject.${matchResult.value}" } + + private const val FOOJAY_RESOLVER_ID = "org.gradle.toolchains.foojay-resolver-convention" + private const val FOOJAY_RESOLVER_VERSION = "1.0.0" + + /** Matches the `hs-gradle-plugin` classpath entry that only Hyperskill settings scripts contain */ + private const val HS_GRADLE_PLUGIN = "hs-gradle-plugin" + + private val BUILD_SCRIPT_BLOCK_START = Regex("""(?m)^\s*buildscript\s*\{""") + + /** Leading blank line separates the inserted block from the `buildscript { }` block above it */ + private val TOOLCHAIN_RESOLVER_BLOCK = """ + | + | + |plugins { + | id '$FOOJAY_RESOLVER_ID' version '$FOOJAY_RESOLVER_VERSION' + |} + """.trimMargin() + + /** + * Adds the Foojay toolchain resolver to `settings.gradle` of already generated Hyperskill projects. + * + * The generated build script requests a Java toolchain of `max(, hs.java.version)`. + * Without a resolver Gradle cannot provision that JDK, so the build fails with + * "Toolchain download repositories have not been configured" whenever it is not installed locally. + * + * Only scripts generated from the Hyperskill template are touched, and the resolver is inserted right after + * the leading `buildscript { }` block because `plugins { }` may only be preceded by `buildscript { }` + * and `pluginManagement { }`. + */ + @VisibleForTesting + fun addToolchainResolver(content: String): String { + if (FOOJAY_RESOLVER_ID in content || HS_GRADLE_PLUGIN !in content) return content + val insertionOffset = buildScriptBlockEndOffset(content) ?: return content + return content.substring(0, insertionOffset) + TOOLCHAIN_RESOLVER_BLOCK + content.substring(insertionOffset) + } + + /** Offset right after the closing brace of the leading `buildscript { }` block, or `null` if there is none */ + private fun buildScriptBlockEndOffset(content: String): Int? { + val blockStart = BUILD_SCRIPT_BLOCK_START.find(content) ?: return null + var depth = 0 + for (offset in blockStart.range.last..content.lastIndex) { + when (content[offset]) { + '{' -> depth++ + '}' -> if (--depth == 0) return offset + 1 + } + } + return null + } } } diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/checker/GradleEnvironmentChecker.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/checker/GradleEnvironmentChecker.kt index fbdab4b00..da52812eb 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/checker/GradleEnvironmentChecker.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/checker/GradleEnvironmentChecker.kt @@ -3,12 +3,12 @@ package org.hyperskill.academy.jvm.gradle.checker import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil import com.intellij.openapi.module.ModuleUtil import com.intellij.openapi.project.Project -import com.intellij.openapi.projectRoots.JavaSdkVersionUtil import com.intellij.openapi.roots.ProjectRootManager import com.intellij.openapi.roots.ui.configuration.ProjectSettingsService import org.hyperskill.academy.jvm.gradle.GradleCourseRefresher import org.hyperskill.academy.jvm.gradle.generation.EduGradleUtils import org.hyperskill.academy.jvm.hyperskillJdkVersion +import org.hyperskill.academy.jvm.isAtLeastHyperskillJdkVersion import org.hyperskill.academy.jvm.messages.EduJVMBundle import org.hyperskill.academy.learning.EduNames.ENVIRONMENT_CONFIGURATION_LINK_GRADLE import org.hyperskill.academy.learning.RefreshCause @@ -27,7 +27,7 @@ open class GradleEnvironmentChecker : EnvironmentChecker() { override fun getEnvironmentError(project: Project, task: Task): CheckResult? { val sdk = ProjectRootManager.getInstance(project).projectSdk ?: return noSdkConfiguredResult - if (task.course is HyperskillCourse && JavaSdkVersionUtil.getJavaSdkVersion(sdk) != hyperskillJdkVersion) { + if (task.course is HyperskillCourse && !sdk.isAtLeastHyperskillJdkVersion()) { return getIncorrectHyperskillJDKResult(project) } diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/generation/EduGradleUtils.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/generation/EduGradleUtils.kt index a3ba69e10..72f05ec8e 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/generation/EduGradleUtils.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/generation/EduGradleUtils.kt @@ -13,6 +13,7 @@ import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.roots.ProjectRootManager import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.util.lang.JavaVersion import org.hyperskill.academy.jvm.gradle.GradleWrapperListener import org.hyperskill.academy.jvm.messages.EduJVMBundle import org.hyperskill.academy.learning.CourseInfoHolder @@ -85,9 +86,14 @@ object EduGradleUtils { private fun setUpGradleJvm(project: Project, projectSettings: GradleProjectSettings, sdk: Sdk?) { if (sdk == null) return + // `setGradleSettings` is called on every project opening, so a non-empty `gradleJvm` here is either + // the value we picked before or the one the user chose explicitly. Overwriting it changes the JVM + // the Gradle daemon runs on, and with it `JavaVersion.current()`, which the generated Hyperskill + // build scripts use to compute the requested Java toolchain. + if (!projectSettings.gradleJvm.isNullOrBlank()) return val gradleVersion = getGradleVersion(project) - val maxCompatibleJdk = gradleVersion?.let { getMaxCompatibleJdkVersion(it) } + val maxCompatibleJdk = gradleVersion?.let { getMaxCompatibleJdkFeatureVersion(it) } // If we know Gradle version and max compatible JDK, try to find a compatible JDK if (maxCompatibleJdk != null) { @@ -164,44 +170,70 @@ object EduGradleUtils { } /** - * Returns maximum JDK version compatible with the given Gradle version. + * Returns the maximum JDK feature version compatible with the given Gradle version, + * or `null` for a Gradle version newer than the table below: guessing there would pin the daemon + * to an outdated JDK, so it's better to leave the choice to the platform. + * * Based on https://docs.gradle.org/current/userguide/compatibility.html */ - private fun getMaxCompatibleJdkVersion(gradleVersion: String): JavaSdkVersion? { + private fun getMaxCompatibleJdkFeatureVersion(gradleVersion: String): Int? { val parts = gradleVersion.split(".") val major = parts.getOrNull(0)?.toIntOrNull() ?: return null val minor = parts.getOrNull(1)?.toIntOrNull() ?: 0 return when { - major >= 9 -> JavaSdkVersion.JDK_23 // Gradle 9.x supports JDK 23 - major >= 8 && minor >= 10 -> JavaSdkVersion.JDK_23 - major >= 8 && minor >= 8 -> JavaSdkVersion.JDK_22 - major >= 8 && minor >= 5 -> JavaSdkVersion.JDK_21 - major >= 8 && minor >= 3 -> JavaSdkVersion.JDK_20 - major >= 8 -> JavaSdkVersion.JDK_19 - major >= 7 && minor >= 6 -> JavaSdkVersion.JDK_19 - major >= 7 && minor >= 5 -> JavaSdkVersion.JDK_18 - major >= 7 && minor >= 3 -> JavaSdkVersion.JDK_17 - major >= 7 -> JavaSdkVersion.JDK_16 - else -> JavaSdkVersion.JDK_11 + major > 9 -> null + major == 9 && minor >= 1 -> 25 + major == 9 -> 24 + major == 8 && minor >= 14 -> 24 + major == 8 && minor >= 10 -> 23 + major == 8 && minor >= 8 -> 22 + major == 8 && minor >= 5 -> 21 + major == 8 && minor >= 3 -> 20 + major == 8 -> 19 + major == 7 && minor >= 6 -> 19 + major == 7 && minor >= 5 -> 18 + major == 7 && minor >= 3 -> 17 + major == 7 -> 16 + else -> 11 } } /** - * Finds the highest available JDK that is compatible with the given max version. + * Finds the highest available release JDK that is compatible with the given max feature version. */ - private fun findCompatibleJdk(maxVersion: JavaSdkVersion): Sdk? { - val javaSdk = JavaSdk.getInstance() - return ProjectJdkTable.getInstance().allJdks - .filter { javaSdk.isOfVersionOrHigher(it, JavaSdkVersion.JDK_1_8) } - .mapNotNull { sdk -> javaSdk.getVersion(sdk)?.let { version -> sdk to version } } - .filter { (_, version) -> version <= maxVersion } - .maxByOrNull { (_, version) -> version.ordinal } + private fun findCompatibleJdk(maxFeatureVersion: Int): Sdk? { + return ProjectJdkTable.getInstance().getSdksOfType(JavaSdk.getInstance()) + .mapNotNull { sdk -> sdk.releaseFeatureVersion?.let { sdk to it } } + .filter { (_, featureVersion) -> featureVersion in MIN_SUPPORTED_JDK_FEATURE_VERSION..maxFeatureVersion } + .maxByOrNull { (_, featureVersion) -> featureVersion } ?.first } + /** + * Feature version of a JDK, or `null` if it is a pre-release build. + * + * The Gradle integration refuses to run on EA and project builds (`26-ea`, `23-valhalla`, ...) and falls back + * to an arbitrary installation instead, so such JDKs must never be offered as the Gradle JVM. + * + * Note that [JavaSdk.getVersion] is deliberately not used here: `JavaSdkVersion` has no entry for a JDK newer + * than the one the IDE knows about, so it reports `null` for it, and such a JDK would be silently skipped. + */ + private val Sdk.releaseFeatureVersion: Int? + get() { + val version = versionString ?: return null + if (PRE_RELEASE_JDK_VERSION.containsMatchIn(version)) return null + val javaVersion = JavaVersion.tryParse(version) ?: return null + return if (javaVersion.ea) null else javaVersion.feature + } + private val Sdk.javaSdkVersion: JavaSdkVersion? get() = JavaSdk.getInstance().getVersion(this) + private const val MIN_SUPPORTED_JDK_FEATURE_VERSION = 8 + + /** Matches a feature version followed by a pre-release qualifier: `26-ea`, `25-internal`, `23-valhalla`. */ + private val PRE_RELEASE_JDK_VERSION = Regex("""\d+(\.\d+)*-[A-Za-z]""") + fun updateGradleSettings(project: Project) { val projectBasePath = project.basePath ?: error("Failed to find base path for the project during gradle project setup") val sdk = ProjectRootManager.getInstance(project).projectSdk diff --git a/intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/gradle/GradleSettingsScriptMigrationTest.kt b/intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/gradle/GradleSettingsScriptMigrationTest.kt new file mode 100644 index 000000000..a4ceb5af5 --- /dev/null +++ b/intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/gradle/GradleSettingsScriptMigrationTest.kt @@ -0,0 +1,78 @@ +package org.hyperskill.academy.jvm.gradle + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GradleSettingsScriptMigrationTest { + + private fun migrate(content: String): String = GradleStartupActivity.addToolchainResolver(content) + + private val hyperskillSettings = """ + buildscript { + repositories { + maven { url 'https://jitpack.io' } + } + + dependencies { + classpath "com.github.hyperskill:hs-gradle-plugin:release-SNAPSHOT" + } + } + + include 'util' + """.trimIndent() + + @Test + fun `test resolver is added right after the buildscript block`() { + val expected = """ + buildscript { + repositories { + maven { url 'https://jitpack.io' } + } + + dependencies { + classpath "com.github.hyperskill:hs-gradle-plugin:release-SNAPSHOT" + } + } + + plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' + } + + include 'util' + """.trimIndent() + + assertEquals(expected, migrate(hyperskillSettings)) + } + + @Test + fun `test migration is idempotent`() { + val migrated = migrate(hyperskillSettings) + + assertEquals(migrated, migrate(migrated)) + } + + @Test + fun `test settings script of a non-hyperskill project is left as is`() { + val original = """ + buildscript { + repositories { + mavenCentral() + } + } + + include 'util' + """.trimIndent() + + assertEquals(original, migrate(original)) + } + + @Test + fun `test settings script without a buildscript block is left as is`() { + val original = """ + // hs-gradle-plugin is applied elsewhere + include 'util' + """.trimIndent() + + assertEquals(original, migrate(original)) + } +} diff --git a/intellij-plugin/hs-sql/hs-sql-jvm/branches/252/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt b/intellij-plugin/hs-sql/hs-sql-jvm/branches/252/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt new file mode 100644 index 000000000..3a4c096ba --- /dev/null +++ b/intellij-plugin/hs-sql/hs-sql-jvm/branches/252/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt @@ -0,0 +1,12 @@ +package org.hyperskill.academy.sql.jvm.gradle + +import com.intellij.openapi.vfs.newvfs.RefreshQueueImpl + +/** + * On 2025.2 `RefreshQueueImpl` is a Java class and `isRefreshInProgress()` is a plain static method. Since 2025.3 it + * is a Kotlin class whose companion exposes the very same JVM method as a `@JvmStatic` property, which Kotlin can + * only read as `RefreshQueueImpl.isRefreshInProgress`. No single syntax compiles on both. + * + * BACKCOMPAT: 252 -- drop the branch copies and inline the property read once 2025.2 support is dropped. + */ +internal fun isVfsRefreshInProgress(): Boolean = RefreshQueueImpl.isRefreshInProgress() diff --git a/intellij-plugin/hs-sql/hs-sql-jvm/branches/253/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt b/intellij-plugin/hs-sql/hs-sql-jvm/branches/253/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt new file mode 100644 index 000000000..fe2fafdad --- /dev/null +++ b/intellij-plugin/hs-sql/hs-sql-jvm/branches/253/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt @@ -0,0 +1,11 @@ +package org.hyperskill.academy.sql.jvm.gradle + +import com.intellij.openapi.vfs.newvfs.RefreshQueueImpl + +/** + * Since 2025.3 `RefreshQueueImpl` is a Kotlin class and `isRefreshInProgress` is read as a property. On 2025.2 it is + * a Java class with a plain static `isRefreshInProgress()` method. No single syntax compiles on both. + * + * BACKCOMPAT: 252 -- drop the branch copies and inline the property read once 2025.2 support is dropped. + */ +internal fun isVfsRefreshInProgress(): Boolean = RefreshQueueImpl.isRefreshInProgress diff --git a/intellij-plugin/hs-sql/hs-sql-jvm/branches/261/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt b/intellij-plugin/hs-sql/hs-sql-jvm/branches/261/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt new file mode 100644 index 000000000..fe2fafdad --- /dev/null +++ b/intellij-plugin/hs-sql/hs-sql-jvm/branches/261/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt @@ -0,0 +1,11 @@ +package org.hyperskill.academy.sql.jvm.gradle + +import com.intellij.openapi.vfs.newvfs.RefreshQueueImpl + +/** + * Since 2025.3 `RefreshQueueImpl` is a Kotlin class and `isRefreshInProgress` is read as a property. On 2025.2 it is + * a Java class with a plain static `isRefreshInProgress()` method. No single syntax compiles on both. + * + * BACKCOMPAT: 252 -- drop the branch copies and inline the property read once 2025.2 support is dropped. + */ +internal fun isVfsRefreshInProgress(): Boolean = RefreshQueueImpl.isRefreshInProgress diff --git a/intellij-plugin/hs-sql/hs-sql-jvm/branches/262/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt b/intellij-plugin/hs-sql/hs-sql-jvm/branches/262/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt new file mode 100644 index 000000000..fe2fafdad --- /dev/null +++ b/intellij-plugin/hs-sql/hs-sql-jvm/branches/262/testSrc/org/hyperskill/academy/sql/jvm/gradle/RefreshQueueCompat.kt @@ -0,0 +1,11 @@ +package org.hyperskill.academy.sql.jvm.gradle + +import com.intellij.openapi.vfs.newvfs.RefreshQueueImpl + +/** + * Since 2025.3 `RefreshQueueImpl` is a Kotlin class and `isRefreshInProgress` is read as a property. On 2025.2 it is + * a Java class with a plain static `isRefreshInProgress()` method. No single syntax compiles on both. + * + * BACKCOMPAT: 252 -- drop the branch copies and inline the property read once 2025.2 support is dropped. + */ +internal fun isVfsRefreshInProgress(): Boolean = RefreshQueueImpl.isRefreshInProgress diff --git a/intellij-plugin/hs-sql/hs-sql-jvm/testSrc/org/hyperskill/academy/sql/jvm/gradle/SqlCourseGenerationTestBase.kt b/intellij-plugin/hs-sql/hs-sql-jvm/testSrc/org/hyperskill/academy/sql/jvm/gradle/SqlCourseGenerationTestBase.kt index db2bb0448..ab3216c76 100644 --- a/intellij-plugin/hs-sql/hs-sql-jvm/testSrc/org/hyperskill/academy/sql/jvm/gradle/SqlCourseGenerationTestBase.kt +++ b/intellij-plugin/hs-sql/hs-sql-jvm/testSrc/org/hyperskill/academy/sql/jvm/gradle/SqlCourseGenerationTestBase.kt @@ -17,7 +17,6 @@ import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import com.intellij.openapi.util.BuildNumber -import com.intellij.openapi.vfs.newvfs.RefreshQueueImpl import com.intellij.testFramework.PlatformTestUtil import com.intellij.ui.tree.TreeVisitor import com.intellij.ui.treeStructure.Tree @@ -121,7 +120,7 @@ abstract class SqlCourseGenerationTestBase : JvmCourseGenerationTestBase() { private fun waitFsSynchronizationFinished() { ApplicationManager.getApplication().assertIsDispatchThread() UIUtil.dispatchAllInvocationEvents() - while (RefreshQueueImpl.isRefreshInProgress) { + while (isVfsRefreshInProgress()) { PlatformTestUtil.dispatchAllEventsInIdeEventQueue() } } From 70e925edfee89d3282bd5ed3e554eeb3683ff7aa Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Tue, 25 Aug 2026 12:58:58 +0300 Subject: [PATCH 2/4] projects and java fixes --- ...ellij-plugin-common-conventions.gradle.kts | 8 +- buildSrc/src/main/kotlin/intellijUtils.kt | 7 + gradle-252.properties | 8 +- gradle-253.properties | 8 +- intellij-plugin/hs-Python/build.gradle.kts | 5 + .../learning/gradle/GradleScriptMigration.kt | 122 ++++++++++++++++++ .../hyperskill/api/HyperskillConnector.kt | 6 + .../GradleAdditionalFilesMigrationTest.kt | 102 +++++++++++++++ .../GradleLegacyBuildScriptMigrationTest.kt | 4 +- .../GradleSettingsScriptMigrationTest.kt | 4 +- .../jvm/gradle/GradleStartupActivity.kt | 83 ++---------- 11 files changed, 271 insertions(+), 86 deletions(-) create mode 100644 intellij-plugin/hs-core/src/org/hyperskill/academy/learning/gradle/GradleScriptMigration.kt create mode 100644 intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/gradle/GradleAdditionalFilesMigrationTest.kt rename intellij-plugin/{hs-jvm-core/testSrc/org/hyperskill/academy/jvm => hs-core/testSrc/org/hyperskill/academy/learning}/gradle/GradleLegacyBuildScriptMigrationTest.kt (93%) rename intellij-plugin/{hs-jvm-core/testSrc/org/hyperskill/academy/jvm => hs-core/testSrc/org/hyperskill/academy/learning}/gradle/GradleSettingsScriptMigrationTest.kt (91%) diff --git a/buildSrc/src/main/kotlin/intellij-plugin-common-conventions.gradle.kts b/buildSrc/src/main/kotlin/intellij-plugin-common-conventions.gradle.kts index 1a74391d4..554165b5f 100644 --- a/buildSrc/src/main/kotlin/intellij-plugin-common-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/intellij-plugin-common-conventions.gradle.kts @@ -147,6 +147,12 @@ private fun findModulePackage(project: Project): String? { private fun verifyClasses(project: Project) { val pkg = findModulePackage(project) ?: return val expectedDir = pkg.replace('.', '/') + // Packages the module explicitly opted out for, see `VERIFY_CLASSES_ALLOWED_PACKAGES`. + val allowedDirs = (project.findProperty(VERIFY_CLASSES_ALLOWED_PACKAGES) as? String) + .orEmpty() + .split(',') + .map { it.trim().replace('.', '/') } + .filter { it.isNotEmpty() } var hasErrors = false for (classesDir in project.sourceSets.main.get().output.classesDirs) { @@ -154,7 +160,7 @@ private fun verifyClasses(project: Project) { for (file in classesDir.walk()) { if (file.isFile && file.extension == "class") { val relativePath = basePath.relativize(file.toPath()) - if (!relativePath.startsWith(expectedDir)) { + if (!relativePath.startsWith(expectedDir) && allowedDirs.none { relativePath.startsWith(it) }) { logger.error("Wrong package of `${relativePath.joinToString(".").removeSuffix(".class")}` class. Expected `$pkg`") hasErrors = true } diff --git a/buildSrc/src/main/kotlin/intellijUtils.kt b/buildSrc/src/main/kotlin/intellijUtils.kt index ed8c0aa6a..d9ebfafb3 100644 --- a/buildSrc/src/main/kotlin/intellijUtils.kt +++ b/buildSrc/src/main/kotlin/intellijUtils.kt @@ -8,6 +8,13 @@ import kotlin.reflect.KProperty const val VERIFY_CLASSES_TASK_NAME = "verifyClasses" +/** + * Extra property a module sets to let [VERIFY_CLASSES_TASK_NAME] accept classes outside its own package. + * Comma-separated package prefixes. Only for shims that must sit in a platform package to reach its + * package-private or `internal` API -- there is no other way to call it. + */ +const val VERIFY_CLASSES_ALLOWED_PACKAGES = "verifyClassesAllowedPackages" + private const val IDE_IDEA = "idea" private const val IDE_CLION = "clion" private const val IDE_PYCHARM = "pycharm" diff --git a/gradle-252.properties b/gradle-252.properties index ad938a0cf..cc526d75a 100644 --- a/gradle-252.properties +++ b/gradle-252.properties @@ -4,7 +4,7 @@ customUntilBuild=252.* # Existent IDE versions can be found in the following repos: # https://www.jetbrains.com/intellij-repository/releases/ # https://www.jetbrains.com/intellij-repository/snapshots/ -ideaVersion=IU-2025.2.4 -clionVersion=CL-2025.2.4 -pycharmVersion=PC-2025.2.4 -riderVersion=RD-2025.2.4 +ideaVersion=IU-2025.2.6.3 +clionVersion=CL-2025.2.6.2 +pycharmVersion=PC-2025.2.6.2 +riderVersion=RD-2025.2.6.1 diff --git a/gradle-253.properties b/gradle-253.properties index 7bd02e5ad..b63761200 100644 --- a/gradle-253.properties +++ b/gradle-253.properties @@ -4,7 +4,7 @@ customUntilBuild=253.* # Existent IDE versions can be found in the following repos: # https://www.jetbrains.com/intellij-repository/releases/ # https://www.jetbrains.com/intellij-repository/snapshots/ -ideaVersion=IU-2025.3 -clionVersion=CL-2025.3 -pycharmVersion=PC-2025.3 -riderVersion=RD-2025.3 +ideaVersion=IU-2025.3.6.1 +clionVersion=CL-2025.3.6.1 +pycharmVersion=PC-2025.3.6.1 +riderVersion=RD-2025.3.5 diff --git a/intellij-plugin/hs-Python/build.gradle.kts b/intellij-plugin/hs-Python/build.gradle.kts index f006d8645..b311040e8 100644 --- a/intellij-plugin/hs-Python/build.gradle.kts +++ b/intellij-plugin/hs-Python/build.gradle.kts @@ -2,6 +2,11 @@ plugins { id("intellij-plugin-module-conventions") } +// `branches/261|262/src/com/jetbrains/python/**` holds copies of and shims around Python plugin internals +// (`PyTargetEnvCreationManager`, the `compat.kt` files). They call package-private and `internal` members of the +// Python plugin, which is only possible from that plugin's own package, so `verifyClasses` has to allow them. +ext[VERIFY_CLASSES_ALLOWED_PACKAGES] = "com.jetbrains.python" + private val pythonPlatformModuleDependenciesMarker = "" private val pythonPlatformModuleDependencies = if (environmentName.toInt() >= 262) { listOf( diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/gradle/GradleScriptMigration.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/gradle/GradleScriptMigration.kt new file mode 100644 index 000000000..09cfc0aa8 --- /dev/null +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/gradle/GradleScriptMigration.kt @@ -0,0 +1,122 @@ +package org.hyperskill.academy.learning.gradle + +import org.hyperskill.academy.learning.courseFormat.BinaryContents +import org.hyperskill.academy.learning.courseFormat.EduFile +import org.hyperskill.academy.learning.courseFormat.InMemoryTextualContents +import org.hyperskill.academy.learning.courseFormat.InMemoryUndeterminedContents +import org.hyperskill.academy.learning.courseFormat.TextualContents +import org.jetbrains.annotations.VisibleForTesting + +/** + * Rewrites Gradle scripts of Hyperskill projects that the server still serves in a form the IDE cannot build. + * + * The scripts are course additional files, so the migration has to be applied in two places: + * to the files as they arrive from the API (see `HyperskillConnector.loadAndFillAdditionalCourseInfo`), + * which covers both project generation and course updates, and to the scripts already lying on disk of a project + * generated by an older plugin version (see `GradleStartupActivity`). + * + * Both places must produce the same content: the course updater rewrites an additional file whenever the remote + * content differs from the one on disk, so a migration applied only on disk is reverted on the next update check, + * and, because the difference never goes away, on every check after it. + */ +object GradleScriptMigration { + + /** + * Returns the migrated content of the Gradle script named [fileName], or [content] itself + * if the file needs no migration. + * + * Every migration is idempotent, so it is safe to run on an already migrated script. + */ + fun migrate(fileName: String, content: String): String = when (fileName) { + GradleConstants.BUILD_GRADLE -> migrateLegacyUtilSourceSetReferences(content) + GradleConstants.SETTINGS_GRADLE -> addToolchainResolver(content) + else -> content + } + + /** + * Applies [migrate] to the Gradle scripts among [files] in place, leaving the other additional files untouched. + */ + fun migrateAdditionalFiles(files: List) { + for (file in files) { + val contents = file.contents + if (contents is BinaryContents) continue + + val originalText = contents.textualRepresentation + val migratedText = migrate(file.name, originalText) + if (migratedText == originalText) continue + + file.contents = if (contents is TextualContents) { + InMemoryTextualContents(migratedText) + } + else { + InMemoryUndeterminedContents(migratedText) + } + } + } + + // The negative lookbehind also makes the replacement idempotent: an already migrated + // `rootProject.project(':util')` is preceded by a dot and is not matched again + private val LEGACY_UTIL_SOURCE_SET_REFERENCE = + Regex("""(? "rootProject.${matchResult.value}" } + + private const val FOOJAY_RESOLVER_ID = "org.gradle.toolchains.foojay-resolver-convention" + private const val FOOJAY_RESOLVER_VERSION = "1.0.0" + + /** Matches the `hs-gradle-plugin` classpath entry that only Hyperskill settings scripts contain */ + private const val HS_GRADLE_PLUGIN = "hs-gradle-plugin" + + private val BUILD_SCRIPT_BLOCK_START = Regex("""(?m)^\s*buildscript\s*\{""") + + /** Leading blank line separates the inserted block from the `buildscript { }` block above it */ + private val TOOLCHAIN_RESOLVER_BLOCK = """ + | + | + |plugins { + | id '$FOOJAY_RESOLVER_ID' version '$FOOJAY_RESOLVER_VERSION' + |} + """.trimMargin() + + /** + * Adds the Foojay toolchain resolver to `settings.gradle` of already generated Hyperskill projects. + * + * The generated build script requests a Java toolchain of `max(, hs.java.version)`. + * Without a resolver Gradle cannot provision that JDK, so the build fails with + * "Toolchain download repositories have not been configured" whenever it is not installed locally. + * + * Only scripts generated from the Hyperskill template are touched, and the resolver is inserted right after + * the leading `buildscript { }` block because `plugins { }` may only be preceded by `buildscript { }` + * and `pluginManagement { }`. + */ + @VisibleForTesting + fun addToolchainResolver(content: String): String { + if (FOOJAY_RESOLVER_ID in content || HS_GRADLE_PLUGIN !in content) return content + val insertionOffset = buildScriptBlockEndOffset(content) ?: return content + return content.substring(0, insertionOffset) + TOOLCHAIN_RESOLVER_BLOCK + content.substring(insertionOffset) + } + + /** Offset right after the closing brace of the leading `buildscript { }` block, or `null` if there is none */ + private fun buildScriptBlockEndOffset(content: String): Int? { + val blockStart = BUILD_SCRIPT_BLOCK_START.find(content) ?: return null + var depth = 0 + for (offset in blockStart.range.last..content.lastIndex) { + when (content[offset]) { + '{' -> depth++ + '}' -> if (--depth == 0) return offset + 1 + } + } + return null + } +} diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt index 6a9ef4d12..a5d66abc4 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/api/HyperskillConnector.kt @@ -23,6 +23,7 @@ import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillProject import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillStage import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillTopic import org.hyperskill.academy.learning.courseFormat.tasks.Task +import org.hyperskill.academy.learning.gradle.GradleScriptMigration import org.hyperskill.academy.learning.messages.EduCoreBundle import org.hyperskill.academy.learning.network.executeHandlingExceptions import org.hyperskill.academy.learning.network.executeParsingErrors @@ -273,6 +274,11 @@ abstract class HyperskillConnector : EduOAuthCodeFlowConnector = files.toList().also { + GradleScriptMigration.migrateAdditionalFiles(it) + } + + private fun eduFile(name: String, text: String) = EduFile(name, InMemoryUndeterminedContents(text)) + + @Test + fun `test gradle scripts of a course are migrated`() { + val (buildScript, settingsScript) = migrate( + eduFile(GradleConstants.BUILD_GRADLE, legacyBuildScript), + eduFile(GradleConstants.SETTINGS_GRADLE, legacySettingsScript) + ) + + assertEquals( + GradleScriptMigration.migrateLegacyUtilSourceSetReferences(legacyBuildScript), + buildScript.contents.textualRepresentation + ) + assertEquals( + GradleScriptMigration.addToolchainResolver(legacySettingsScript), + settingsScript.contents.textualRepresentation + ) + } + + /** + * The course updater rewrites an additional file whenever the remote content differs from the one on disk, + * so the content the migration produces has to be stable, otherwise the file is rewritten on every update check. + */ + @Test + fun `test migration is idempotent`() { + val (buildScript, settingsScript) = migrate( + eduFile(GradleConstants.BUILD_GRADLE, legacyBuildScript), + eduFile(GradleConstants.SETTINGS_GRADLE, legacySettingsScript) + ) + val migratedBuildScript = buildScript.contents.textualRepresentation + val migratedSettingsScript = settingsScript.contents.textualRepresentation + + migrate(buildScript, settingsScript) + + assertEquals(migratedBuildScript, buildScript.contents.textualRepresentation) + assertEquals(migratedSettingsScript, settingsScript.contents.textualRepresentation) + } + + @Test + fun `test other additional files are left as is`() { + val text = "testImplementation project(':util').sourceSets.main.output" + val (file) = migrate(eduFile("build.gradle.kts", text)) + + assertEquals(text, file.contents.textualRepresentation) + } + + @Test + fun `test binary additional files are left as is`() { + val bytes = byteArrayOf(0, 1, 2) + val (file) = migrate(EduFile(GradleConstants.BUILD_GRADLE, InMemoryBinaryContents(bytes))) + + assertArrayEquals(bytes, (file.contents as InMemoryBinaryContents).bytes) + } + + @Test + fun `test contents kind is preserved`() { + val (undetermined, textual) = migrate( + EduFile(GradleConstants.BUILD_GRADLE, InMemoryUndeterminedContents(legacyBuildScript)), + EduFile(GradleConstants.BUILD_GRADLE, InMemoryTextualContents(legacyBuildScript)) + ) + + assertTrue(undetermined.contents is UndeterminedContents) + assertTrue(textual.contents is TextualContents) + } +} diff --git a/intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/gradle/GradleLegacyBuildScriptMigrationTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/gradle/GradleLegacyBuildScriptMigrationTest.kt similarity index 93% rename from intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/gradle/GradleLegacyBuildScriptMigrationTest.kt rename to intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/gradle/GradleLegacyBuildScriptMigrationTest.kt index 868abee9e..0460d9dfa 100644 --- a/intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/gradle/GradleLegacyBuildScriptMigrationTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/gradle/GradleLegacyBuildScriptMigrationTest.kt @@ -1,11 +1,11 @@ -package org.hyperskill.academy.jvm.gradle +package org.hyperskill.academy.learning.gradle import org.junit.Assert.assertEquals import org.junit.Test class GradleLegacyBuildScriptMigrationTest { - private fun migrate(content: String): String = GradleStartupActivity.migrateLegacyUtilSourceSetReferences(content) + private fun migrate(content: String): String = GradleScriptMigration.migrateLegacyUtilSourceSetReferences(content) @Test fun `test legacy references are qualified with rootProject`() { diff --git a/intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/gradle/GradleSettingsScriptMigrationTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/gradle/GradleSettingsScriptMigrationTest.kt similarity index 91% rename from intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/gradle/GradleSettingsScriptMigrationTest.kt rename to intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/gradle/GradleSettingsScriptMigrationTest.kt index a4ceb5af5..7322d357a 100644 --- a/intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/gradle/GradleSettingsScriptMigrationTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/gradle/GradleSettingsScriptMigrationTest.kt @@ -1,11 +1,11 @@ -package org.hyperskill.academy.jvm.gradle +package org.hyperskill.academy.learning.gradle import org.junit.Assert.assertEquals import org.junit.Test class GradleSettingsScriptMigrationTest { - private fun migrate(content: String): String = GradleStartupActivity.addToolchainResolver(content) + private fun migrate(content: String): String = GradleScriptMigration.addToolchainResolver(content) private val hyperskillSettings = """ buildscript { diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt index bf7e7e740..11774a31f 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt @@ -17,8 +17,8 @@ import org.hyperskill.academy.learning.EduUtilsKt.isEduProject import org.hyperskill.academy.learning.RefreshCause import org.hyperskill.academy.learning.StudyTaskManager import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse -import org.jetbrains.annotations.VisibleForTesting -import org.jetbrains.plugins.gradle.util.GradleConstants +import org.hyperskill.academy.learning.gradle.GradleConstants +import org.hyperskill.academy.learning.gradle.GradleScriptMigration import java.io.IOException import kotlin.coroutines.resume @@ -29,8 +29,8 @@ class GradleStartupActivity : ProjectActivity { return } if (EduGradleUtils.isConfiguredWithGradle(project)) { - val buildScriptMigrated = migrateScript(project, GradleConstants.DEFAULT_SCRIPT_NAME, ::migrateLegacyUtilSourceSetReferences) - val settingsMigrated = migrateScript(project, GradleConstants.SETTINGS_FILE_NAME, ::addToolchainResolver) + val buildScriptMigrated = migrateScript(project, GradleConstants.BUILD_GRADLE) + val settingsMigrated = migrateScript(project, GradleConstants.SETTINGS_GRADLE) updateGradleSettings(project) if (buildScriptMigrated || settingsMigrated) { // The import triggered by project opening has already read the outdated script, @@ -66,18 +66,21 @@ class GradleStartupActivity : ProjectActivity { } /** - * Applies [migration] to the Gradle script named [scriptName] in the project root. + * Applies [GradleScriptMigration] to the Gradle script named [scriptName] in the project root. + * + * Only projects generated by an older plugin version need this: the scripts coming from the server are already + * migrated by the time they are written to disk. * * @return `true` if the script was actually changed */ - private suspend fun migrateScript(project: Project, scriptName: String, migration: (String) -> String): Boolean { + private suspend fun migrateScript(project: Project, scriptName: String): Boolean { val projectDir = project.guessProjectDir() ?: return false val scriptFile = projectDir.findChild(scriptName) ?: return false if (scriptFile.isDirectory) return false return try { val originalContent = VfsUtilCore.loadText(scriptFile) - val migratedContent = migration(originalContent) + val migratedContent = GradleScriptMigration.migrate(scriptName, originalContent) if (migratedContent == originalContent) return false writeAction { @@ -106,71 +109,5 @@ class GradleStartupActivity : ProjectActivity { private val LOG = Logger.getInstance(GradleStartupActivity::class.java) private const val UTIL_MODULE_NAME = "util" - - // The negative lookbehind also makes the replacement idempotent: an already migrated - // `rootProject.project(':util')` is preceded by a dot and is not matched again - private val LEGACY_UTIL_SOURCE_SET_REFERENCE = - Regex("""(? "rootProject.${matchResult.value}" } - - private const val FOOJAY_RESOLVER_ID = "org.gradle.toolchains.foojay-resolver-convention" - private const val FOOJAY_RESOLVER_VERSION = "1.0.0" - - /** Matches the `hs-gradle-plugin` classpath entry that only Hyperskill settings scripts contain */ - private const val HS_GRADLE_PLUGIN = "hs-gradle-plugin" - - private val BUILD_SCRIPT_BLOCK_START = Regex("""(?m)^\s*buildscript\s*\{""") - - /** Leading blank line separates the inserted block from the `buildscript { }` block above it */ - private val TOOLCHAIN_RESOLVER_BLOCK = """ - | - | - |plugins { - | id '$FOOJAY_RESOLVER_ID' version '$FOOJAY_RESOLVER_VERSION' - |} - """.trimMargin() - - /** - * Adds the Foojay toolchain resolver to `settings.gradle` of already generated Hyperskill projects. - * - * The generated build script requests a Java toolchain of `max(, hs.java.version)`. - * Without a resolver Gradle cannot provision that JDK, so the build fails with - * "Toolchain download repositories have not been configured" whenever it is not installed locally. - * - * Only scripts generated from the Hyperskill template are touched, and the resolver is inserted right after - * the leading `buildscript { }` block because `plugins { }` may only be preceded by `buildscript { }` - * and `pluginManagement { }`. - */ - @VisibleForTesting - fun addToolchainResolver(content: String): String { - if (FOOJAY_RESOLVER_ID in content || HS_GRADLE_PLUGIN !in content) return content - val insertionOffset = buildScriptBlockEndOffset(content) ?: return content - return content.substring(0, insertionOffset) + TOOLCHAIN_RESOLVER_BLOCK + content.substring(insertionOffset) - } - - /** Offset right after the closing brace of the leading `buildscript { }` block, or `null` if there is none */ - private fun buildScriptBlockEndOffset(content: String): Int? { - val blockStart = BUILD_SCRIPT_BLOCK_START.find(content) ?: return null - var depth = 0 - for (offset in blockStart.range.last..content.lastIndex) { - when (content[offset]) { - '{' -> depth++ - '}' -> if (--depth == 0) return offset + 1 - } - } - return null - } } } From f002c86ed31af243e1cb1c0f572deef95eb4296c Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Thu, 10 Sep 2026 13:48:29 +0300 Subject: [PATCH 3/4] projects and java fixes --- gradle.properties | 2 +- .../learning/courseFormat/ItemContainer.kt | 10 + .../academy/learning/network/RetrofitExt.kt | 28 +- .../academy/java/JLanguageSettings.kt | 15 +- .../hyperskill/JHyperskillConfigurator.kt | 9 +- .../hyperskill/JHyperskillLanguageSettings.kt | 2 +- .../messages/EduCoreBundle.properties | 6 + .../projectView/CCSectionNode.kt | 8 +- .../academy/learning/EduProjectActivity.kt | 12 +- .../academy/learning/VirtualFileExt.kt | 18 +- .../learning/courseFormat/ext/StudyItemExt.kt | 16 + .../learning/courseFormat/ext/TaskExt.kt | 7 +- .../courseGeneration/GeneratorUtils.kt | 19 +- .../learning/navigation/NavigationUtils.kt | 1 + .../ui/courseSettings/CourseSettingsPanel.kt | 4 +- .../newproject/ui/errors/ErrorState.kt | 8 + .../ui/errors/SettingsValidationResult.kt | 10 +- .../newproject/ui/errors/errorsUtil.kt | 3 +- .../learning/projectView/CourseViewUtils.kt | 19 +- .../learning/projectView/DirectoryNode.kt | 28 +- .../academy/learning/projectView/EduNode.kt | 75 ++- .../projectView/FrameworkLessonNode.kt | 10 +- .../learning/projectView/LessonNode.kt | 17 +- .../learning/projectView/SectionNode.kt | 14 +- .../academy/learning/stepik/api/StepikAPI.kt | 6 +- .../checker/HyperskillCheckConnector.kt | 81 ++- .../HyperskillOpenInIdeRequestHandler.kt | 6 +- .../update/HyperskillCourseUpdater.kt | 24 +- .../htmlTransformers/steps/CodeHighlighter.kt | 3 +- .../htmlTransformers/steps/HintsWrapper.kt | 3 +- .../taskToolWindow/ui/JCEFToolWindow.kt | 28 +- .../taskToolWindow/ui/tab/DescriptionTab.kt | 4 + .../taskToolWindow/ui/tab/TabManager.kt | 6 +- .../academy/learning/update/LessonUpdater.kt | 2 +- .../academy/learning/update/SectionUpdater.kt | 2 +- .../learning/update/StudyItemUpdater.kt | 14 + .../academy/learning/yaml/YamlLoader.kt | 14 +- .../courseFormat/ItemContainerTest.kt | 52 ++ .../academy/learning/courseView/NodesTest.kt | 102 +++ ...HyperskillRejectedEduTaskSubmissionTest.kt | 181 +++++ .../update/HyperskillSectionUpdateTest.kt | 27 + .../messages/EduJVMBundle.properties | 13 +- .../academy/jvm/JdkAutoInstaller.kt | 177 +++++ .../hyperskill/academy/jvm/JdkDownloadUi.java | 61 ++ .../academy/jvm/JdkEnvironmentSettings.kt | 83 ++- .../academy/jvm/JdkLanguageSettings.kt | 631 +++++++----------- .../academy/jvm/JdkProjectSettings.kt | 115 +++- .../academy/jvm/ParsedJavaVersion.kt | 11 - .../academy/jvm/ProjectJdkRepair.kt | 57 ++ .../jvm/gradle/GradleStartupActivity.kt | 19 + .../checker/GradleEnvironmentChecker.kt | 4 +- .../jvm/gradle/generation/EduGradleUtils.kt | 26 +- .../academy/jvm/JdkSelectionTest.kt | 164 +++++ .../sql/jvm/gradle/SqlJdkLanguageSettings.kt | 84 --- 54 files changed, 1649 insertions(+), 692 deletions(-) create mode 100644 intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseFormat/ItemContainerTest.kt create mode 100644 intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillRejectedEduTaskSubmissionTest.kt create mode 100644 intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkAutoInstaller.kt create mode 100644 intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkDownloadUi.java create mode 100644 intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/ProjectJdkRepair.kt create mode 100644 intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/JdkSelectionTest.kt diff --git a/gradle.properties b/gradle.properties index 44e88e53f..216ec0c41 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ # supported values: 252, 253, 261, 262 environmentName=262 -pluginVersion=2026.19 +pluginVersion=2026.20 # type of IDE (IDEA, CLion, etc.) used to build/test running # for more details see `Different IDEs` section in `PlatformVersions.md` diff --git a/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/ItemContainer.kt b/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/ItemContainer.kt index 7897d53a0..f12cdfc94 100644 --- a/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/ItemContainer.kt +++ b/hs-edu-format/src/org/hyperskill/academy/learning/courseFormat/ItemContainer.kt @@ -20,18 +20,28 @@ abstract class ItemContainer : StudyItem() { return items.firstOrNull { it.name == name } } + /** + * Adding an item also makes this container its [StudyItem.parent]. + * + * Otherwise the item would stay in the container while [StudyItem.parent] throws, and every consumer walking up + * the tree (course view presentation, YAML reload, task description) breaks on an item that looks perfectly fine + * in [items]. [init] still (re)initializes a whole subtree, this only keeps a single insertion consistent. + */ fun addItem(item: StudyItem) { _items.add(item) + item.parent = this } fun addItem(index: Int, item: StudyItem) { _items.add(index, item) + item.parent = this } fun replaceItem(existingItem: StudyItem, newItem: StudyItem) { val index = _items.indexOf(existingItem) if (index < 0) return _items[index] = newItem + newItem.parent = this } fun removeItem(item: StudyItem) { diff --git a/hs-edu-format/src/org/hyperskill/academy/learning/network/RetrofitExt.kt b/hs-edu-format/src/org/hyperskill/academy/learning/network/RetrofitExt.kt index 107f6beb7..d1ec10cb4 100644 --- a/hs-edu-format/src/org/hyperskill/academy/learning/network/RetrofitExt.kt +++ b/hs-edu-format/src/org/hyperskill/academy/learning/network/RetrofitExt.kt @@ -121,18 +121,21 @@ fun Response.executeParsingErrors(omitErrors: Boolean = false): Result Err("${message("error.service.down")}\n\n$error") // 500x - HTTP_FORBIDDEN, HTTP_UNAUTHORIZED -> { - val errorMessage = processForbiddenErrorMessage(error) ?: message("error.access.denied") - Err(errorMessage) - } + // 401 means the access token is not valid anymore, and it's the generic message that makes + // `StepikBasedConnector.withTokenRefreshIfFailed` refresh the tokens and repeat the request + HTTP_UNAUTHORIZED -> Err(processErrorMessage(error, MESSAGE_FIELD) ?: message("error.access.denied")) + + // Unlike 401, 403 is not about an expired token: the server explains why the request is not allowed, + // e.g. that the stage is locked behind a subscription, so its own text is the useful one + HTTP_FORBIDDEN -> Err(processErrorMessage(error, MESSAGE_FIELD, DETAIL_FIELD) ?: message("error.access.denied")) HTTP_UNAVAILABLE_FOR_LEGAL_REASONS -> { // 451 LOG.warning(message("error.agreement.not.accepted")) Err(fullErrorText) } - in HTTP_BAD_REQUEST..HTTP_UNSUPPORTED_TYPE -> - Err(message("error.unexpected.error", error)) // 400x + in HTTP_BAD_REQUEST..HTTP_UNSUPPORTED_TYPE -> // 400x + Err(processErrorMessage(error, MESSAGE_FIELD, DETAIL_FIELD) ?: message("error.unexpected.error", error)) else -> { LOG.warning("Code $code is not handled") Err(message("error.unexpected.error", error)) @@ -140,15 +143,19 @@ fun Response.executeParsingErrors(omitErrors: Boolean = false): Result errorNode.get(field)?.asText()?.takeIf { it.isNotBlank() } } } catch (_: ClassCastException) { null @@ -158,4 +165,7 @@ private fun processForbiddenErrorMessage(jsonText: String): String? { } } +private const val MESSAGE_FIELD = "message" +private const val DETAIL_FIELD = "detail" + const val HTTP_UNAVAILABLE_FOR_LEGAL_REASONS: Int = 451 diff --git a/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/JLanguageSettings.kt b/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/JLanguageSettings.kt index 28c0fa64a..80484370d 100644 --- a/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/JLanguageSettings.kt +++ b/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/JLanguageSettings.kt @@ -1,8 +1,6 @@ package org.hyperskill.academy.java -import com.intellij.openapi.projectRoots.JavaSdk import com.intellij.openapi.projectRoots.JavaSdkVersion -import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel import org.hyperskill.academy.jvm.JavaVersionParseSuccess import org.hyperskill.academy.jvm.JdkLanguageSettings import org.hyperskill.academy.jvm.ParsedJavaVersion @@ -10,18 +8,7 @@ import org.hyperskill.academy.learning.courseFormat.Course open class JLanguageSettings : JdkLanguageSettings() { - // Note: setupProjectSdksModel is intentionally not overridden here. - // Adding SDK via model.addSdk() on EDT is prohibited in IntelliJ 2025.3+. - // Bundled JDK is added in addBundledJdkIfNeeded() which is called from background thread. - - override fun addBundledJdkIfNeeded(model: ProjectSdksModel) { - val (jdkPath, sdk) = findBundledJdk(model) ?: return - if (sdk == null) { - model.addSdk(JavaSdk.getInstance(), jdkPath, null) - } - } - - override fun minJvmSdkVersion(course: Course): ParsedJavaVersion { + override fun requiredJdkVersion(course: Course): ParsedJavaVersion { val javaVersionDescription = course.languageVersion ?: return JavaVersionParseSuccess(DEFAULT_JAVA) return ParsedJavaVersion.fromJavaSdkDescriptionString(javaVersionDescription) } diff --git a/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/hyperskill/JHyperskillConfigurator.kt b/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/hyperskill/JHyperskillConfigurator.kt index cc222cff7..921d97e38 100644 --- a/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/hyperskill/JHyperskillConfigurator.kt +++ b/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/hyperskill/JHyperskillConfigurator.kt @@ -6,10 +6,10 @@ import org.hyperskill.academy.java.JConfigurator import org.hyperskill.academy.java.JCourseBuilder import org.hyperskill.academy.jvm.JdkLanguageSettings import org.hyperskill.academy.jvm.JdkProjectSettings -import org.hyperskill.academy.jvm.ParsedJavaVersion import org.hyperskill.academy.jvm.gradle.GradleCourseBuilderBase import org.hyperskill.academy.jvm.gradle.GradleHyperskillConfigurator import org.hyperskill.academy.jvm.gradle.generation.GradleCourseProjectGenerator +import org.hyperskill.academy.jvm.requiredJdkVersion import org.hyperskill.academy.learning.EduCourseBuilder import org.hyperskill.academy.learning.EduNames import org.hyperskill.academy.learning.courseFormat.Course @@ -45,10 +45,9 @@ class JHyperskillConfigurator : GradleHyperskillConfigurator GradleCourseProjectGenerator(builder, course) { override fun getJdk(settings: JdkProjectSettings): Sdk? { - return super.getJdk(settings) ?: JdkLanguageSettings.findSuitableJdk( - ParsedJavaVersion.fromJavaSdkDescriptionString(course.languageVersion), - settings.model - ) + // `course.languageVersion` is the language level of a Hyperskill Java course ("11"), not the JDK its checker + // needs, so it must not be used here: it would accept a JDK the course cannot be checked with + return super.getJdk(settings) ?: JdkLanguageSettings.findSuitableJdk(course.requiredJdkVersion, settings.model) } } diff --git a/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/hyperskill/JHyperskillLanguageSettings.kt b/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/hyperskill/JHyperskillLanguageSettings.kt index b4c772b00..b5377b633 100644 --- a/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/hyperskill/JHyperskillLanguageSettings.kt +++ b/intellij-plugin/hs-Java/src/org/hyperskill/academy/java/hyperskill/JHyperskillLanguageSettings.kt @@ -7,7 +7,7 @@ import org.hyperskill.academy.jvm.hyperskillJdkVersion import org.hyperskill.academy.learning.courseFormat.Course class JHyperskillLanguageSettings : JLanguageSettings() { - override fun minJvmSdkVersion(course: Course): ParsedJavaVersion { + override fun requiredJdkVersion(course: Course): ParsedJavaVersion { return JavaVersionParseSuccess(hyperskillJdkVersion) } } \ No newline at end of file diff --git a/intellij-plugin/hs-core/resources/messages/EduCoreBundle.properties b/intellij-plugin/hs-core/resources/messages/EduCoreBundle.properties index 460de4bf0..79d76a5c6 100644 --- a/intellij-plugin/hs-core/resources/messages/EduCoreBundle.properties +++ b/intellij-plugin/hs-core/resources/messages/EduCoreBundle.properties @@ -246,6 +246,12 @@ error.failed.to.post.solution=Failed to post solution error.failed.to.post.solution.to=Failed to post solution to {0} error.failed.to.post.solution.with.guide=Failed to post solution to {0}. For more information, \ see the Troubleshooting guide +# Ex.: Can't post a submission for this stage. Upgrade your subscription.
For more information, \ +# see the Troubleshooting guide +error.failed.to.post.solution.reason={0}
For more information, \ + see the Troubleshooting guide +# Ex.: Your solution was not accepted by JetBrains Academy +error.solution.rejected=Your solution was not accepted by {0} hyperskill.error.empty.check.profile=Check profile is empty for task {0}. Please try to {1} or contact support. error.failed.to.refresh.tokens=Failed to refresh tokens error.invalid.rename.message=This rename operation can break the course diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/coursecreator/projectView/CCSectionNode.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/coursecreator/projectView/CCSectionNode.kt index 8d85d25d4..837453f33 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/coursecreator/projectView/CCSectionNode.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/coursecreator/projectView/CCSectionNode.kt @@ -4,9 +4,7 @@ import com.intellij.ide.projectView.ViewSettings import com.intellij.ide.util.treeView.AbstractTreeNode import com.intellij.openapi.project.Project import com.intellij.psi.PsiDirectory -import org.hyperskill.academy.learning.courseFormat.Lesson import org.hyperskill.academy.learning.courseFormat.Section -import org.hyperskill.academy.learning.projectView.LessonNode import org.hyperskill.academy.learning.projectView.SectionNode class CCSectionNode( @@ -14,11 +12,7 @@ class CCSectionNode( viewSettings: ViewSettings, section: Section, psiDirectory: PsiDirectory -) : SectionNode(project, viewSettings, section, psiDirectory) { - - override fun createLessonNode(directory: PsiDirectory, lesson: Lesson): LessonNode { - return CCLessonNode(myProject, directory, settings, lesson) - } +) : CCContentHolderNode, SectionNode(project, viewSettings, section, psiDirectory) { override fun modifyChildNode(childNode: AbstractTreeNode<*>): AbstractTreeNode<*>? { val node = super.modifyChildNode(childNode) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/EduProjectActivity.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/EduProjectActivity.kt index a0fa7c8b1..cf0277213 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/EduProjectActivity.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/EduProjectActivity.kt @@ -4,7 +4,7 @@ import com.intellij.ide.projectView.ProjectView import com.intellij.ide.util.PropertiesComponent import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.EDT -import com.intellij.openapi.application.writeAction +import com.intellij.openapi.application.edtWriteAction import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.diagnostic.logger import com.intellij.openapi.fileEditor.FileEditorManager @@ -82,11 +82,11 @@ class EduProjectActivity : ProjectActivity { SyncChangesStateManager.getInstance(project).updateSyncChangesState(course) - withContext(Dispatchers.EDT) { - writeAction { - course.visitTasks { - setHighlightLevelForFilesInTask(it, project) - } + // `edtWriteAction` and not `writeAction`: since 2026.2 the latter takes the write lock on a background thread, + // and `setHighlightLevelForFilesInTask` needs the EDT (see `VirtualFile.setHighlightLevelInsideWriteAction`). + edtWriteAction { + course.visitTasks { + setHighlightLevelForFilesInTask(it, project) } } } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/VirtualFileExt.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/VirtualFileExt.kt index 1a9f448fd..4c35022e4 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/VirtualFileExt.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/VirtualFileExt.kt @@ -27,6 +27,7 @@ import com.intellij.testFramework.LightVirtualFile import com.intellij.ui.components.JBLoadingPanel import com.intellij.util.SlowOperations import com.intellij.util.concurrency.annotations.RequiresBlockingContext +import com.intellij.util.concurrency.annotations.RequiresEdt import com.intellij.util.concurrency.annotations.RequiresWriteLock import com.intellij.util.io.ReadOnlyAttributeUtil import com.intellij.util.ui.UIUtil @@ -81,6 +82,16 @@ fun VirtualFile.findFileByRelativePathOrSelf(path: String): VirtualFile? { return if (path.isEmpty()) this else findFileByRelativePath(path) } +/** + * Compares by path rather than by instance: the course root is re-resolved on every access through + * `guessProjectDir()`, so relying on both sides being the very same [VirtualFile] object makes the whole course + * structure unresolvable as soon as they are not. + */ +fun VirtualFile?.isSameFileAs(other: VirtualFile?): Boolean { + if (this == null || other == null) return false + return this == other || FileUtil.pathsEqual(path, other.path) +} + fun VirtualFile.getSection(project: Project): Section? { return getSection(project.toCourseInfoHolder()) } @@ -88,7 +99,7 @@ fun VirtualFile.getSection(project: Project): Section? { fun VirtualFile.getSection(holder: CourseInfoHolder): Section? { val course = holder.course ?: return null if (!isDirectory) return null - return if (holder.courseDir.findFileByRelativePathOrSelf(course.customContentPath) == parent) course.getSection(name) else null + return if (holder.courseDir.findFileByRelativePathOrSelf(course.customContentPath).isSameFileAs(parent)) course.getSection(name) else null } fun VirtualFile.isSectionDirectory(project: Project): Boolean { @@ -108,7 +119,7 @@ fun VirtualFile.getLesson(holder: CourseInfoHolder): Lesson? { if (section != null) { return section.getLesson(name) } - return if (holder.courseDir.findFileByRelativePathOrSelf(course.customContentPath) == parent) course.getLesson(name) else null + return if (holder.courseDir.findFileByRelativePathOrSelf(course.customContentPath).isSameFileAs(parent)) course.getLesson(name) else null } fun VirtualFile.isLessonDirectory(project: Project): Boolean { @@ -309,6 +320,7 @@ fun VirtualFile.setHighlightLevel(project: Project, highlightLevel: EduFileError } } +@RequiresEdt fun VirtualFile.setHighlightLevelInsideWriteAction(project: Project, highlightLevel: EduFileErrorHighlightLevel) { checkIsWriteActionAllowed() @@ -326,6 +338,8 @@ fun VirtualFile.setHighlightLevelInsideWriteAction(project: Project, highlightLe // TriggerCompilerHighlightingService will fail if the document for the virtualFile is null. // Read the documentation for FileDocumentManager.getDocument to find out when the document may be null. + // `getDocument` may create the document, which is EDT-only since 2026.2, hence @RequiresEdt on this function: + // callers have to take the write lock on the EDT (`edtWriteAction`), not with a background write action. if (FileDocumentManager.getInstance().getDocument(this) == null) return HighlightLevelUtil.forceRootHighlighting(psiFile, fileHighlightLevel) // this utility method makes additional null checks diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseFormat/ext/StudyItemExt.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseFormat/ext/StudyItemExt.kt index 23f49df85..a216c7393 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseFormat/ext/StudyItemExt.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseFormat/ext/StudyItemExt.kt @@ -38,6 +38,22 @@ fun StudyItem.getDir(courseDir: VirtualFile): VirtualFile? { } } +/** + * The course this item belongs to, or `null` if the item is not attached to one. + * + * Unlike [StudyItem.course] it doesn't fail on items with a missing parent link, which happens when the course + * structure couldn't be fully restored from the config files. + */ +val StudyItem.courseOrNull: Course? + get() { + var item: StudyItem? = this + while (item != null) { + if (item is Course) return item + item = item.parentOrNull + } + return null + } + fun StudyItem.visitTasks(action: (Task) -> Unit) { when (this) { is LessonContainer -> visitTasks(action) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseFormat/ext/TaskExt.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseFormat/ext/TaskExt.kt index f9bedbc0d..75ee96f8a 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseFormat/ext/TaskExt.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseFormat/ext/TaskExt.kt @@ -199,7 +199,7 @@ fun Task.getFormattedTaskText(project: Project): String? { text = StringUtil.replace(text, "%IDE_NAME%", ApplicationNamesInfo.getInstance().fullProductName) val textBuffer = StringBuffer(text) replaceActionIDsWithShortcuts(textBuffer) - if (course is HyperskillCourse) { + if (courseOrNull is HyperskillCourse) { removeHyperskillTags(textBuffer) } return textBuffer.toString() @@ -227,7 +227,10 @@ fun Task.getTaskDirectory(project: Project): VirtualFile? { @RequiresReadLock fun Task.getTaskText(project: Project): String? { - val taskTextFile = getDescriptionFile(project, guessFormat = true) ?: return null + // The description file is looked up through the task directory, so it goes missing whenever the item is not + // properly attached to the course. Falling back to the text kept in the model shows the description instead of + // an empty tool window in that case. + val taskTextFile = getDescriptionFile(project, guessFormat = true) ?: return descriptionText.ifEmpty { null } val taskDescription = taskTextFile.getTextFromTaskTextFile() ?: return descriptionText if (taskTextFile.extension == DescriptionFormat.MD.extension) { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseGeneration/GeneratorUtils.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseGeneration/GeneratorUtils.kt index c80cf747f..4af8312c0 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseGeneration/GeneratorUtils.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/courseGeneration/GeneratorUtils.kt @@ -58,19 +58,30 @@ object GeneratorUtils { unpackAdditionalFiles(holder, ALL_EXCEPT_IDEA_DIRECTORY) } + /** + * @param reuseExistingDir keeps [item] in a directory of the same name if one already exists, instead of creating + * a uniquely named sibling. Pass it when the section is known to be the one that directory belongs to, so that a + * section missing from the in-memory course cannot be re-created next to its own files. + */ @RequiresBlockingContext @Throws(IOException::class) - fun createSection(project: Project, item: Section, baseDir: VirtualFile): VirtualFile { - return createSection(project.toCourseInfoHolder(), item, baseDir) + fun createSection(project: Project, item: Section, baseDir: VirtualFile, reuseExistingDir: Boolean = false): VirtualFile { + return createSection(project.toCourseInfoHolder(), item, baseDir, reuseExistingDir) } @RequiresBlockingContext @Throws(IOException::class) - private fun createSection(holder: CourseInfoHolder, item: Section, baseDir: VirtualFile): VirtualFile { + private fun createSection( + holder: CourseInfoHolder, + item: Section, + baseDir: VirtualFile, + reuseExistingDir: Boolean = false + ): VirtualFile { val parentDir = runInWriteActionAndWait { VfsUtil.createDirectoryIfMissing(baseDir, item.parent.getPathToChildren()) } - val sectionDir = createUniqueDir(parentDir, item) + val existingDir = if (reuseExistingDir) parentDir.findChild(item.name)?.takeIf { it.isDirectory } else null + val sectionDir = existingDir ?: createUniqueDir(parentDir, item) for (lesson in item.lessons) { createLesson(holder, lesson, sectionDir) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/navigation/NavigationUtils.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/navigation/NavigationUtils.kt index 5d5764254..1006ef44a 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/navigation/NavigationUtils.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/navigation/NavigationUtils.kt @@ -277,6 +277,7 @@ object NavigationUtils { } } + @RequiresEdt fun setHighlightLevelForFilesInTask(task: Task, project: Project) { checkIsWriteActionAllowed() for (taskFile in task.taskFiles.values) { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/courseSettings/CourseSettingsPanel.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/courseSettings/CourseSettingsPanel.kt index 56e075543..8a2ce9433 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/courseSettings/CourseSettingsPanel.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/courseSettings/CourseSettingsPanel.kt @@ -178,7 +178,9 @@ class CourseSettingsPanel( fun validateSettings(course: Course?): SettingsValidationResult { val settingsValidationResult = languageSettings?.validate(course, locationString) ?: SettingsValidationResult.OK - if (settingsValidationResult is SettingsValidationResult.Ready && settingsValidationResult.validationMessage != null) { + val hasMessageToShow = settingsValidationResult is SettingsValidationResult.ReadyWithWarning + || (settingsValidationResult is SettingsValidationResult.Ready && settingsValidationResult.validationMessage != null) + if (hasMessageToShow) { setOn(true) } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/ErrorState.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/ErrorState.kt index fcdb07491..cb695b838 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/ErrorState.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/ErrorState.kt @@ -70,6 +70,12 @@ sealed class ErrorState( class LanguageSettingsError(message: ValidationMessage) : ErrorState(LANGUAGE_SETTINGS_ERROR, message, false) + /** + * Non-blocking counterpart of [LanguageSettingsError]: the message is shown, but the course can still be started + * because the plugin fixes the settings itself during project creation. + */ + class LanguageSettingsWarning(message: ValidationMessage) : ErrorState(LANGUAGE_SETTINGS_WARNING, message, true) + object JCEFRequired : ErrorState( NO_JCEF, ValidationMessage( EduCoreBundle.message("validation.no.jcef") @@ -175,6 +181,8 @@ sealed class ErrorState( private enum class ErrorSeverity { OK, + LANGUAGE_SETTINGS_WARNING, + LANGUAGE_SETTINGS_PENDING, LOGIN_RECOMMENDED, diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/SettingsValidationResult.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/SettingsValidationResult.kt index eeeeec12e..4e43dbbc7 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/SettingsValidationResult.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/SettingsValidationResult.kt @@ -5,7 +5,15 @@ sealed class SettingsValidationResult { class Ready(val validationMessage: ValidationMessage?) : SettingsValidationResult() + /** + * Non-blocking counterpart of [Ready]: the message is shown to the user, but the course can still be started. + * + * Use it when the settings are not perfect yet, and the plugin is able to fix them on its own while the project + * is being created, e.g. by downloading the JDK the course requires. + */ + class ReadyWithWarning(val validationMessage: ValidationMessage) : SettingsValidationResult() + companion object { val OK: SettingsValidationResult = Ready(null) } -} \ No newline at end of file +} diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/errorsUtil.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/errorsUtil.kt index 0886c045a..04803617c 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/errorsUtil.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/errorsUtil.kt @@ -10,6 +10,7 @@ fun getErrorState(course: Course?, validateSettings: (Course) -> SettingsValidat languageError = when (validationResult) { is SettingsValidationResult.Pending -> ErrorState.SDKPending + is SettingsValidationResult.ReadyWithWarning -> ErrorState.LanguageSettingsWarning(validationResult.validationMessage) is SettingsValidationResult.Ready -> { val validationMessage = validationResult.validationMessage validationMessage?.let { ErrorState.LanguageSettingsError(it) } ?: ErrorState.None @@ -27,4 +28,4 @@ fun browseHyperlink(message: ValidationMessage?) { if (hyperlink != null) { EduBrowser.getInstance().browse(hyperlink) } -} \ No newline at end of file +} diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/CourseViewUtils.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/CourseViewUtils.kt index 7009a723f..47656c121 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/CourseViewUtils.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/CourseViewUtils.kt @@ -88,7 +88,7 @@ object CourseViewUtils { * This avoids using TaskFile.isTestFile extension which requires task reference to be set. */ private fun isTestFile(task: Task, path: String): Boolean { - val configurator = task.course.configurator ?: return false + val configurator = task.courseOrNull?.configurator ?: return false return configurator.isTestFile(task, path) } @@ -141,7 +141,7 @@ object CourseViewUtils { } private fun getSyncChangesModifier(item: StudyItem): Icon? { - val project = item.course.project ?: return null + val project = item.courseOrNull?.project ?: return null val syncChangesStateManager = SyncChangesStateManager.getInstance(project) val state = when (item) { is Task -> syncChangesStateManager.getSyncChangesState(item) @@ -166,23 +166,12 @@ object CourseViewUtils { } private fun Lesson.isSolved() = taskList.all { - val project = it.project ?: return false + val project = it.courseOrNull?.project ?: return false it.status == CheckStatus.Solved || SubmissionsManager.getInstance(project).containsCorrectSubmission(it.id) } - val Task.icon: Icon - get() { - return when (this) { - is IdeTask -> if (isSolved) IdeTaskSolved else CourseView.IdeTask - is TheoryTask -> if (isSolved) TheoryTaskSolved else CourseView.TheoryTask - else -> if (status == CheckStatus.Unchecked) CourseView.Task - else if (isSolved || containsCorrectSubmissions()) TaskSolved - else TaskFailed - } - } - private fun Task.containsCorrectSubmissions(): Boolean { - val project = course.project ?: return false + val project = courseOrNull?.project ?: return false return SubmissionsManager.getInstance(project).containsCorrectSubmission(id) } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/DirectoryNode.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/DirectoryNode.kt index 13515c2a1..1016f8e49 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/DirectoryNode.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/DirectoryNode.kt @@ -1,8 +1,6 @@ package org.hyperskill.academy.learning.projectView -import com.intellij.ide.projectView.PresentationData import com.intellij.ide.projectView.ViewSettings -import com.intellij.ide.projectView.impl.nodes.ProjectViewDirectoryHelper import com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode import com.intellij.ide.util.treeView.AbstractTreeNode import com.intellij.openapi.project.Project @@ -21,6 +19,18 @@ open class DirectoryNode( task: Task? ) : EduNode(project, value, viewSettings, task) { + /** The task is only kept to decide which children are visible; the node itself stands for the directory. */ + override val presentedItem: Task? + get() = null + + override val presentableName: String + get() { + val name = value.virtualFile.name + val course = StudyTaskManager.getInstance(myProject).course ?: return super.presentableName + // A source or test directory always keeps its own name, whatever the project view would shorten it to + return if (name == course.sourceDir || name in course.testDirs) name else super.presentableName + } + override fun canNavigate(): Boolean = true public override fun modifyChildNode(childNode: AbstractTreeNode<*>): AbstractTreeNode<*>? { @@ -34,18 +44,4 @@ open class DirectoryNode( open fun createChildFileNode(originalNode: AbstractTreeNode<*>, psiFile: PsiFile): AbstractTreeNode<*> { return originalNode } - - override fun updateImpl(data: PresentationData) { - val course = StudyTaskManager.getInstance(myProject).course ?: return - val dir = value - val directoryFile = dir.virtualFile - val name = directoryFile.name - if (name == course.sourceDir || course.testDirs.contains(name)) { - data.presentableText = name - } - else { - val parentValue = parentValue - data.presentableText = ProjectViewDirectoryHelper.getInstance(myProject).getNodeName(settings, parentValue, dir) - } - } } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/EduNode.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/EduNode.kt index 89ac28396..65ba75107 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/EduNode.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/EduNode.kt @@ -5,6 +5,9 @@ import com.intellij.ide.projectView.ViewSettings import com.intellij.ide.projectView.impl.nodes.ProjectViewDirectoryHelper import com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.ide.util.treeView.PresentableNodeDescriptor.ColoredFragment +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.Project import com.intellij.psi.PsiDirectory import com.intellij.ui.JBColor @@ -24,14 +27,70 @@ abstract class EduNode( myName = value.name } + /** + * The text this node wrote in [updateImpl], kept so that [postprocess] can tell whether something replaced it. + * Both run within a single presentation update, so the value is never stale. + */ + private var studyItemText: List = emptyList() + + /** + * The study item this node stands for, or `null` when the node only shows a directory. + * + * A node may hold an item without standing for it: a directory inside a task keeps the task around to decide which + * of its children are visible, but is still shown as the directory it is. + */ + protected open val presentedItem: T? + get() = item + + /** What this node is called in the tree. */ + protected open val presentableName: String + get() = presentedItem?.presentableName ?: directoryName() + + /** The name the project view would give this directory, honouring settings such as compacted middle packages. */ + protected fun directoryName(): String = + ProjectViewDirectoryHelper.getInstance(myProject).getNodeName(settings, parentValue, value) ?: value.name + override fun updateImpl(data: PresentationData) { data.clearText() - val item = item ?: return - val name = item.presentableName - val icon = CourseViewUtils.getIcon(item) - data.addText(name, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, JBColor.BLACK)) - additionalInfo?.let { data.addText(" $additionalInfo", SimpleTextAttributes.GRAYED_ATTRIBUTES) } - data.setIcon(icon) + val item = presentedItem + // The name has to be written before anything that may fail. The platform prefills the presentation with the name + // and the icon of the underlying directory, so leaving this method early shows the directory instead of the study + // item, e.g. `src` instead of a task name when the task node points at the task source directory. + // `JBColor.BLACK` is the theme's foreground in a dark theme and plain black in a light one + data.addText(presentableName, SimpleTextAttributes(SimpleTextAttributes.STYLE_PLAIN, JBColor.BLACK)) + try { + if (item != null) { + data.setIcon(CourseViewUtils.getIcon(item)) + } + additionalInfo?.let { data.addText(" $it", SimpleTextAttributes.GRAYED_ATTRIBUTES) } + } + catch (e: ProcessCanceledException) { + throw e + } + catch (e: Exception) { + LOG.warn("Failed to build presentation for ${item?.itemType} `${item?.name}`", e) + } + studyItemText = data.coloredText.toList() + } + + /** + * Restores the study item's name after the project view decorators, which the platform runs on every node right + * after [updateImpl]. + * + * Every course view node is a [PsiDirectoryNode], so `GradleModuleDirectoryDecorator` (IDEA 2026.2 and later) + * treats it as a plain directory: for a directory that is a Gradle module content root it calls `clearText()` and + * renders ` []`. A task node points at the task source directory, so the task name is replaced + * with `src [main]`. + * + * This is why [updateImpl] is the only place that writes a node's text: a node building its presentation some other + * way silently opts out of this protection. + */ + override fun postprocess(presentation: PresentationData) { + super.postprocess(presentation) + val text = studyItemText + if (text.isEmpty() || presentation.coloredText == text) return + presentation.clearText() + text.forEach { presentation.addText(it) } } open val additionalInfo: String? @@ -51,4 +110,8 @@ abstract class EduNode( } protected open fun modifyChildNode(childNode: AbstractTreeNode<*>): AbstractTreeNode<*>? = childNode + + companion object { + private val LOG: Logger = Logger.getInstance(EduNode::class.java) + } } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/FrameworkLessonNode.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/FrameworkLessonNode.kt index 989aba68b..641235004 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/FrameworkLessonNode.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/FrameworkLessonNode.kt @@ -4,9 +4,9 @@ import com.intellij.ide.projectView.ViewSettings import com.intellij.ide.util.treeView.AbstractTreeNode import com.intellij.openapi.project.Project import com.intellij.psi.PsiDirectory -import com.intellij.psi.PsiFile import org.hyperskill.academy.learning.courseFormat.EduFormatNames.TASK import org.hyperskill.academy.learning.courseFormat.FrameworkLesson +import org.hyperskill.academy.learning.courseFormat.ext.courseOrNull import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse import org.hyperskill.academy.learning.messages.EduCoreBundle import org.hyperskill.academy.learning.navigation.NavigationUtils @@ -23,7 +23,7 @@ class FrameworkLessonNode private constructor( override fun modifyChildNode(childNode: AbstractTreeNode<*>): AbstractTreeNode<*>? { val task = item.currentTask() ?: return null - return CourseViewUtils.modifyTaskChildNode(myProject, childNode, task, ::createChildFileNode) { dir -> + return CourseViewUtils.modifyTaskChildNode(myProject, childNode, task, { node, _ -> node }) { dir -> DirectoryNode(myProject, dir, settings, task) } } @@ -39,7 +39,7 @@ class FrameworkLessonNode private constructor( override val additionalInfo: String? get() { - val course = item.course + val course = item.courseOrNull return if (course is HyperskillCourse && item == course.getProjectLesson()) { val (tasksSolved, tasksTotal) = ProgressUtil.countProgress(item) if (tasksTotal == 0) { @@ -50,10 +50,6 @@ class FrameworkLessonNode private constructor( else super.additionalInfo } - private fun createChildFileNode(originalNode: AbstractTreeNode<*>, psiFile: PsiFile): AbstractTreeNode<*> { - return originalNode - } - companion object { fun createFrameworkLessonNode( diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/LessonNode.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/LessonNode.kt index b9dd41e08..930f31611 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/LessonNode.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/LessonNode.kt @@ -2,6 +2,7 @@ package org.hyperskill.academy.learning.projectView import com.intellij.ide.projectView.ViewSettings import com.intellij.ide.util.treeView.AbstractTreeNode +import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.psi.PsiDirectory import org.hyperskill.academy.learning.courseFormat.Lesson @@ -19,7 +20,17 @@ open class LessonNode( override fun modifyChildNode(childNode: AbstractTreeNode<*>): AbstractTreeNode<*>? { val directory = childNode.value as? PsiDirectory ?: return null - val task = item.getTask(directory.name) ?: return null + val task = item.getTask(directory.name) + if (task == null) { + // The directory is dropped from the tree, so a task shown under its own directory name is the only visible + // sign that this happened. Naming the tasks the lesson does know about tells apart "the model lost the task" + // from "the platform handed us a directory that is not a task directory at all". + LOG.warn( + "No task named `${directory.name}` in lesson `${item.name}` (${directory.virtualFile.path})." + + " Known tasks: [${item.taskList.joinToString { it.name }}]" + ) + return null + } val taskDirectory = findTaskDirectory(myProject, directory, task) ?: return null return createTaskNode(taskDirectory, task) } @@ -29,4 +40,8 @@ open class LessonNode( } override val item: Lesson get() = super.item!! + + companion object { + private val LOG: Logger = Logger.getInstance(LessonNode::class.java) + } } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/SectionNode.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/SectionNode.kt index adb5318e6..feeeabb33 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/SectionNode.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/projectView/SectionNode.kt @@ -4,17 +4,14 @@ import com.intellij.ide.projectView.ViewSettings import com.intellij.ide.util.treeView.AbstractTreeNode import com.intellij.openapi.project.Project import com.intellij.psi.PsiDirectory -import org.hyperskill.academy.learning.courseFormat.FrameworkLesson -import org.hyperskill.academy.learning.courseFormat.Lesson import org.hyperskill.academy.learning.courseFormat.Section -import org.hyperskill.academy.learning.projectView.FrameworkLessonNode.Companion.createFrameworkLessonNode open class SectionNode( project: Project, viewSettings: ViewSettings, section: Section, psiDirectory: PsiDirectory -) : EduNode
(project, psiDirectory, viewSettings, section) { +) : ContentHolderNode, EduNode
(project, psiDirectory, viewSettings, section) { override val item: Section get() = super.item!! @@ -24,14 +21,5 @@ open class SectionNode( return createLessonNode(directory, lesson) } - protected open fun createLessonNode(directory: PsiDirectory, lesson: Lesson): LessonNode? { - return if (lesson is FrameworkLesson) { - createFrameworkLessonNode(myProject, directory, settings, lesson) - } - else { - LessonNode(myProject, directory, settings, lesson) - } - } - override fun getWeight(): Int = item.index } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/api/StepikAPI.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/api/StepikAPI.kt index 00966b88a..37c84ac85 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/api/StepikAPI.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/api/StepikAPI.kt @@ -76,14 +76,16 @@ const val LAST_NAME = "last_name" const val IS_GUEST = "is_guest" +// The lists are not `lateinit`: an error response may well carry no list at all, and then the request has to fail +// with the usual error result instead of an `UninitializedPropertyAccessException` class SubmissionsList : WithPaginationMetaData() { @JsonProperty(SUBMISSIONS) - lateinit var submissions: List + var submissions: List = emptyList() } class AttemptsList : WithPaginationMetaData() { @JsonProperty(ATTEMPTS) - lateinit var attempts: List + var attempts: List = emptyList() } // Auxiliary: diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillCheckConnector.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillCheckConnector.kt index f90f4be30..c816f0c16 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillCheckConnector.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillCheckConnector.kt @@ -1,12 +1,15 @@ package org.hyperskill.academy.learning.stepik.hyperskill.checker +import com.intellij.ide.projectView.ProjectView import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationType.ERROR +import com.intellij.openapi.application.runInEdt import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.project.Project import com.intellij.openapi.util.text.StringUtil import com.intellij.util.text.nullize import org.hyperskill.academy.learning.* +import org.hyperskill.academy.learning.courseFormat.CheckFeedback import org.hyperskill.academy.learning.courseFormat.CheckResult import org.hyperskill.academy.learning.courseFormat.CheckStatus import org.hyperskill.academy.learning.courseFormat.attempts.Attempt @@ -18,6 +21,7 @@ import org.hyperskill.academy.learning.courseFormat.tasks.UnsupportedTask import org.hyperskill.academy.learning.messages.EduCoreBundle import org.hyperskill.academy.learning.messages.EduFormatBundle import org.hyperskill.academy.learning.notification.EduNotificationManager +import org.hyperskill.academy.learning.projectView.ProgressUtil.updateCourseProgress import org.hyperskill.academy.learning.stepik.api.StepikBasedConnector.Companion.getStepikBasedConnector import org.hyperskill.academy.learning.stepik.api.StepikBasedSubmission import org.hyperskill.academy.learning.stepik.hyperskill.HYPERSKILL_DEFAULT_HOST @@ -29,8 +33,11 @@ import org.hyperskill.academy.learning.stepik.hyperskill.submissions.HyperskillS import org.hyperskill.academy.learning.submissions.SolutionFile import org.hyperskill.academy.learning.submissions.SubmissionsManager import org.hyperskill.academy.learning.submissions.getSolutionFiles +import org.hyperskill.academy.learning.taskToolWindow.ui.TaskToolWindowView +import org.hyperskill.academy.learning.yaml.YamlFormatSynchronizer import java.net.URI import java.net.URISyntaxException +import java.util.Date import java.util.concurrent.TimeUnit object HyperskillCheckConnector { @@ -76,26 +83,72 @@ object HyperskillCheckConnector { } fun postEduTaskSolution(task: Task, project: Project, result: CheckResult) { - when (val attemptResponse = HyperskillConnector.getInstance().postAttempt(task)) { - is Err -> showErrorDetails(project, attemptResponse.error) - is Ok -> { - val feedback = if (result.details == null) result.message.xmlUnescaped else "${result.message.xmlUnescaped}\n${result.details}" - postEduSubmission(attemptResponse.value, project, task, feedback) - checkStageToBeCompleted(task) - } + val attempt = HyperskillConnector.getInstance().postAttempt(task).onError { error -> + solutionNotAccepted(project, task, error) + return + } + val feedback = if (result.details == null) result.message.xmlUnescaped else "${result.message.xmlUnescaped}\n${result.details}" + val submission = postEduSubmission(attempt, project, task, feedback).onError { error -> + solutionNotAccepted(project, task, error) + return } + val rejectionMessage = submission.rejectionMessage(task) + if (rejectionMessage != null) { + solutionNotAccepted(project, task, rejectionMessage) + return + } + SubmissionsManager.getInstance(project).addToSubmissionsWithStatus(task.id, task.status, submission) + checkStageToBeCompleted(task) } - private fun postEduSubmission(attempt: Attempt, project: Project, task: Task, feedback: String) { + private fun postEduSubmission( + attempt: Attempt, + project: Project, + task: Task, + feedback: String + ): Result { val files = getSolutionFilesResult(project, task).onError { error -> - showErrorDetails(project, EduCoreBundle.message("error.failed.to.collect.files", task.name)) LOG.error(error) - return + return Err(EduCoreBundle.message("error.failed.to.collect.files", task.name)) } val submission = HyperskillSubmissionFactory.createEduTaskSubmission(task, attempt, files, feedback) - when (val response = HyperskillConnector.getInstance().postSubmission(submission)) { - is Err -> showErrorDetails(project, response.error) - is Ok -> SubmissionsManager.getInstance(project).addToSubmissionsWithStatus(task.id, task.status, response.value) + return HyperskillConnector.getInstance().postSubmission(submission) + } + + /** + * A submission for an edu task carries the score the IDE has determined itself, so the status sent back by the server + * is only meaningful as an objection to it: the server either disagrees with a solved local check or explains why it + * did not accept the solution at all, e.g. because the stage is locked behind a subscription. + */ + private fun StepikBasedSubmission.rejectionMessage(task: Task): String? { + val submissionStatus = status ?: return null + if (submissionStatus == EVALUATION_STATUS || submissionStatus == CheckStatus.Solved.rawStatus) return null + + val explanation = hint.nullize() ?: feedback?.message.nullize() + if (explanation != null) return explanation + return if (task.status == CheckStatus.Solved) EduCoreBundle.message("error.solution.rejected", EduNames.JBA) else null + } + + /** + * The local check result is shown and saved before the solution is posted + * (see `CheckAction.StudyCheckTask.onSuccess`), so a solution the server did not accept has to be rolled back here: + * otherwise the task keeps the solved status it was given by the local tests only. + */ + private fun solutionNotAccepted(project: Project, task: Task, error: String) { + showErrorDetails(project, error) + if (task.status != CheckStatus.Solved) return + + task.status = CheckStatus.Unchecked + task.feedback = CheckFeedback(Date(), CheckResult(CheckStatus.Unchecked, error)) + YamlFormatSynchronizer.saveItem(task) + runInEdt { + if (project.isDisposed) return@runInEdt + val taskToolWindow = TaskToolWindowView.getInstance(project) + if (taskToolWindow.currentTask?.id == task.id) { + taskToolWindow.updateCheckPanel(task) + } + updateCourseProgress(project) + ProjectView.getInstance(project).refresh() } } @@ -220,7 +273,7 @@ object HyperskillCheckConnector { EduNotificationManager.create( ERROR, EduCoreBundle.message("error.failed.to.post.solution"), - EduFormatBundle.message("help.use.guide", EduNames.FAILED_TO_POST_TO_JBA_URL) + EduCoreBundle.message("error.failed.to.post.solution.reason", error, EduNames.FAILED_TO_POST_TO_JBA_URL) ).addAction(NotificationAction.createSimpleExpiring("Open in Browser") { EduBrowser.getInstance().browse(EduNames.FAILED_TO_POST_TO_JBA_URL) }) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/courseGeneration/HyperskillOpenInIdeRequestHandler.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/courseGeneration/HyperskillOpenInIdeRequestHandler.kt index 5779dc529..6137de660 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/courseGeneration/HyperskillOpenInIdeRequestHandler.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/courseGeneration/HyperskillOpenInIdeRequestHandler.kt @@ -481,7 +481,11 @@ object HyperskillOpenInIdeRequestHandler : OpenInIdeRequestHandler ) { - GeneratorUtils.createSection(project, topicsSection, project.courseDir) + // The `Topics` directory may already exist while the section is missing from the in-memory course, e.g. because + // its config could not be loaded. Creating a "unique" directory then renames the section to `Topics (1)`, + // persists that name into course-info.yaml and orphans everything under the real `Topics` -- repeat, and the + // project ends up with `Topics (1)`, `Topics (2)`, ... each holding a part of the learner's work. + GeneratorUtils.createSection(project, topicsSection, project.courseDir, reuseExistingDir = true) tasks.forEach { task -> YamlFormatSynchronizer.saveItemWithRemoteInfo(task) } YamlFormatSynchronizer.saveItem(topicLesson) YamlFormatSynchronizer.saveItem(topicsSection) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/update/HyperskillCourseUpdater.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/update/HyperskillCourseUpdater.kt index 2731c76c6..bc440f849 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/update/HyperskillCourseUpdater.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/update/HyperskillCourseUpdater.kt @@ -61,9 +61,10 @@ class HyperskillCourseUpdater(private val project: Project, val course: Hyperski val lessonFromServer = connector.getLesson(this) ?: return null addLesson(lessonFromServer) if (isFeatureEnabled(EduExperimentalFeatures.NEW_COURSE_UPDATE)) { - runBlockingCancellable { + val topicsMirrored = runBlockingCancellable { addTopicSectionToRemoteCourseIfAbsent(hyperskillCourse) } + if (!topicsMirrored) return null } init(this, false) } @@ -196,27 +197,38 @@ class HyperskillCourseUpdater(private val project: Project, val course: Hyperski } } + /** + * @return `false` if the topics could not be mirrored completely, in which case the reconstructed remote course + * is missing content that exists locally and must not be used to update the project. + */ @VisibleForTesting - suspend fun addTopicSectionToRemoteCourseIfAbsent(remoteCourse: HyperskillCourse) { - if (remoteCourse.getTopicsSection() != null) return + suspend fun addTopicSectionToRemoteCourseIfAbsent(remoteCourse: HyperskillCourse): Boolean { + if (remoteCourse.getTopicsSection() != null) return true val topicSection = course.getTopicsSection() - val localTopics = topicSection?.lessons ?: return + val localTopics = topicSection?.lessons ?: return true val remoteTopicsSection = remoteCourse.createTopicsSection() for (topic in localTopics) { val remoteSteps = withContext(Dispatchers.IO) { HyperskillConnector.getInstance().getProblems(course, topic).associateBy { it.id } } - if (remoteSteps.isEmpty()) continue + // `getProblems` reports any request failure as an empty list, so an empty response for a topic that has tasks + // locally is indistinguishable from a network error. Leaving the topic out of the remote course would make the + // updater treat it as deleted on the server and erase the learner's files, so give up on the whole update. + if (remoteSteps.isEmpty() && topic.taskList.isNotEmpty()) { + LOG.warn("No problems loaded for topic `${topic.name}`, skipping the course update") + return false + } val remoteTopic = remoteTopicsSection.createTopicLesson(topic.presentableName) for (step in topic.taskList) { val remoteTask = remoteSteps[step.id] ?: continue remoteTopic.addTask(remoteTask) } - remoteTopic.init(remoteCourse, false) + remoteTopic.init(remoteTopicsSection, false) } + return true } private fun updateCourse(remoteCourse: HyperskillCourse) { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/htmlTransformers/steps/CodeHighlighter.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/htmlTransformers/steps/CodeHighlighter.kt index 0c7da7425..9076a4a4b 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/htmlTransformers/steps/CodeHighlighter.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/htmlTransformers/steps/CodeHighlighter.kt @@ -1,6 +1,7 @@ package org.hyperskill.academy.learning.taskToolWindow.htmlTransformers.steps import com.intellij.openapi.fileTypes.PlainTextLanguage +import org.hyperskill.academy.learning.courseFormat.ext.courseOrNull import org.hyperskill.academy.learning.courseFormat.ext.languageById import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse import org.hyperskill.academy.learning.taskToolWindow.htmlTransformers.HtmlTransformer @@ -13,7 +14,7 @@ object CodeHighlighter : HtmlTransformer { val task = context.task val project = context.project - val course = task.course + val course = task.courseOrNull ?: return html val language = if (course is HyperskillCourse) PlainTextLanguage.INSTANCE else course.languageById ?: return html return highlightCodeFragments(project, html, language) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/htmlTransformers/steps/HintsWrapper.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/htmlTransformers/steps/HintsWrapper.kt index 9722d97c1..4c0056bd2 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/htmlTransformers/steps/HintsWrapper.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/htmlTransformers/steps/HintsWrapper.kt @@ -1,6 +1,7 @@ package org.hyperskill.academy.learning.taskToolWindow.htmlTransformers.steps import org.hyperskill.academy.learning.JavaUILibrary +import org.hyperskill.academy.learning.courseFormat.ext.courseOrNull import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse import org.hyperskill.academy.learning.taskToolWindow.htmlTransformers.HtmlTransformer import org.hyperskill.academy.learning.taskToolWindow.htmlTransformers.HtmlTransformerContext @@ -15,7 +16,7 @@ import org.jsoup.nodes.TextNode object HintsWrapper : HtmlTransformer { override fun transform(html: Document, context: HtmlTransformerContext): Document { - if (context.task.course is HyperskillCourse) { + if (context.task.courseOrNull is HyperskillCourse) { return html } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/JCEFToolWindow.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/JCEFToolWindow.kt index af7fae558..109c1f868 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/JCEFToolWindow.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/JCEFToolWindow.kt @@ -2,6 +2,8 @@ package org.hyperskill.academy.learning.taskToolWindow.ui import com.intellij.ide.ui.LafManagerListener import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.ui.jcef.JBCefApp @@ -9,6 +11,7 @@ import com.intellij.ui.jcef.JBCefClient import com.intellij.ui.jcef.JCEFHtmlPanel import org.hyperskill.academy.learning.JavaUILibrary import org.hyperskill.academy.learning.courseFormat.tasks.Task +import org.hyperskill.academy.learning.messages.EduCoreBundle import org.hyperskill.academy.learning.taskToolWindow.links.JCefToolWindowLinkHandler import org.jetbrains.annotations.TestOnly import javax.swing.JComponent @@ -34,6 +37,12 @@ class JCEFToolWindow(project: Project) : TaskToolWindow(project) { Disposer.register(this, taskInfoJBCefBrowser) Disposer.register(this, taskSpecificJBCefBrowser) + // Both browsers are created with a `null` URL, which CEF resolves to a non-existent `file:///jbcefbrowser/...` + // page. Until something loads content into them that Chromium error page is what the user sees, so put the + // regular "open any task" placeholder there right away. + taskInfoJBCefBrowser.loadHTML(getTaskDescription(project, null, uiMode)) + taskSpecificJBCefBrowser.loadHTML(EMPTY_HTML) + ApplicationManager.getApplication().messageBus.connect(this) .subscribe( LafManagerListener.TOPIC, @@ -50,9 +59,18 @@ class JCEFToolWindow(project: Project) : TaskToolWindow(project) { get() = JavaUILibrary.JCEF override fun updateTaskInfoPanel(task: Task?) { - taskInfoJBCefBrowser.component.isVisible = false - - val taskDescription = getTaskDescription(project, task, uiMode) + // The panel is deliberately not hidden first: if building the description throws, an invisible panel would + // never come back, and the browser would keep showing the page it was constructed with. + val taskDescription = try { + getTaskDescription(project, task, uiMode) + } + catch (e: ProcessCanceledException) { + throw e + } + catch (e: Exception) { + LOG.warn("Failed to build the description of the task `${task?.name}`", e) + EduCoreBundle.message("task.description.not.found") + } taskInfoJBCefBrowser.loadHTML(taskDescription) taskInfoJBCefBrowser.component.isVisible = true @@ -67,6 +85,10 @@ class JCEFToolWindow(project: Project) : TaskToolWindow(project) { } companion object { + private val LOG = Logger.getInstance(JCEFToolWindow::class.java) + + private const val EMPTY_HTML = "" + // maximum number of created qs queries in termsQueryManager private const val TASK_INFO_PANEL_JS_QUERY_POOL_SIZE = 3 diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/tab/DescriptionTab.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/tab/DescriptionTab.kt index 029a216d8..4c3a267ad 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/tab/DescriptionTab.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/tab/DescriptionTab.kt @@ -51,6 +51,10 @@ class DescriptionTab(project: Project) : TaskToolWindowTab(project) { taskTextToolWindow.updateTaskSpecificPanel(task) } + fun updateTaskDescription(task: Task?) { + taskTextToolWindow.update(task) + } + override fun update(task: Task) { taskTextToolWindow.update(task) } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/tab/TabManager.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/tab/TabManager.kt index a4cbe155b..1c1ed150d 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/tab/TabManager.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/tab/TabManager.kt @@ -115,7 +115,9 @@ class TabManager(private val project: Project) : Disposable { } fun updateTaskDescription(task: Task?) { - task ?: return - descriptionTab.update(task) + // A null task still has to reach the tool window: it renders the "open any task" placeholder there. + // Returning early instead leaves the browser showing whatever it was constructed with, which is a + // `file:///jbcefbrowser/...` URL that does not exist -- the user sees Chromium's ERR_FILE_NOT_FOUND page. + descriptionTab.updateTaskDescription(task) } } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/LessonUpdater.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/LessonUpdater.kt index cbc79df8a..4e3dafbe8 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/LessonUpdater.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/LessonUpdater.kt @@ -59,7 +59,7 @@ abstract class LessonUpdater(project: Project, private val container: LessonCont // lessons to be updated val localLesson = localLessons.firstOrNull() ?: continue - val remoteLesson = remoteLessons.find { it.id == localLesson.id } + val remoteLesson = remoteLessons.findCounterpartOf(localLesson) if (remoteLesson == null) { updates.add(LessonDeletionInfo(localLesson)) localLessons.remove(localLesson) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/SectionUpdater.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/SectionUpdater.kt index ebd187669..79316298f 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/SectionUpdater.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/SectionUpdater.kt @@ -37,7 +37,7 @@ abstract class SectionUpdater(project: Project, private val course: Course) : St // sections to be updated val localSection = localSections.firstOrNull() ?: continue - val remoteSection = remoteSections.find { it.id == localSection.id } + val remoteSection = remoteSections.findCounterpartOf(localSection) if (remoteSection == null) { updates.add(SectionDeletionInfo(localSection)) localSections.remove(localSection) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/StudyItemUpdater.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/StudyItemUpdater.kt index c3e4b19f8..3a4c94bfe 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/StudyItemUpdater.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/StudyItemUpdater.kt @@ -14,6 +14,20 @@ import org.jetbrains.annotations.TestOnly abstract class StudyItemUpdater>(protected val project: Project) : ItemUpdater { protected abstract suspend fun collect(localItems: List, remoteItems: List): List + /** + * Sections and lessons created locally (Hyperskill topics) never receive a server id, so several of them share + * id `0`. Matching purely by id then pairs the first id-less remote item with every id-less local item and turns + * all the remaining ones into deletions, which erase the learner's files. Names disambiguate those. + */ + protected fun Collection.findCounterpartOf(localItem: I): I? { + firstOrNull { it.id != 0 && it.id == localItem.id }?.let { return it } + if (localItem.id != 0) return null + + val idLessItems = filter { it.id == 0 } + // `singleOrNull` keeps a renamed item paired with its counterpart while it is the only candidate + return idLessItems.firstOrNull { it.name == localItem.name } ?: idLessItems.singleOrNull() + } + @TestOnly protected suspend fun update(localItems: List, remoteItems: List) { val updates = collect(localItems, remoteItems) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt index 25beaaee2..445f20a38 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/YamlLoader.kt @@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.google.common.annotations.VisibleForTesting import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.BaseProjectDirectories.Companion.getBaseDirectories import com.intellij.openapi.project.Project +import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VirtualFile import com.intellij.util.messages.Topic import org.hyperskill.academy.learning.* @@ -221,8 +223,8 @@ object YamlLoader { ?: loadingError(EduCoreBundle.message("yaml.editor.invalid.format.parent.not.found", name)) val customContentPath = course.customContentPath val itemContainer = when (this) { - is Section -> if (project.courseDir.findFileByRelativePathOrSelf(customContentPath) == parentDir) course else null - is Lesson -> if (project.courseDir.findFileByRelativePathOrSelf(customContentPath) == parentDir) { + is Section -> if (project.courseDir.findFileByRelativePathOrSelf(customContentPath).isSameFileAs(parentDir)) course else null + is Lesson -> if (project.courseDir.findFileByRelativePathOrSelf(customContentPath).isSameFileAs(parentDir)) { course } else { @@ -260,6 +262,14 @@ object YamlLoader { " sectionDir='${sectionDir?.path}' (name='${sectionDir?.name}')" + " course=${course::class.simpleName} name='${course.name}' customContentPath='$customContentPath'" + " courseDirByCustomPath='${project.courseDir.findFileByRelativePath(customContentPath)?.path}'" + + // `courseDir` is derived from `guessProjectDir()`, which picks an arbitrary element whenever the project has + // more than one base directory. These fields say whether the comparison failed because the course root was + // resolved to the wrong directory, or because the item is genuinely missing from the model. + " courseDir='${project.courseDir.path}' (valid=${project.courseDir.isValid})" + + " basePath='${project.basePath}'" + + " courseDirIsParentDir=${project.courseDir == parentDir}" + + " courseDirPathEqualsParentDir=${FileUtil.pathsEqual(project.courseDir.path, parentDir.path)}" + + " baseDirs=[${project.getBaseDirectories().joinToString { it.path }}]" + " parentDir.getLesson='${parentDir.getLesson(project)?.name}'" + " sectionDir.getSection='${sectionDir?.getSection(project)?.name}'" + " course.topLevelLessons=[${course.lessons.joinToString(", ") { it.name }}]" + diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseFormat/ItemContainerTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseFormat/ItemContainerTest.kt new file mode 100644 index 000000000..a8da31da8 --- /dev/null +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseFormat/ItemContainerTest.kt @@ -0,0 +1,52 @@ +package org.hyperskill.academy.learning.courseFormat + +import org.hyperskill.academy.learning.courseFormat.tasks.EduTask +import org.junit.Assert.assertSame +import org.junit.Test + +class ItemContainerTest { + + @Test + fun `test adding a task makes the lesson its parent`() { + val lesson = Lesson().apply { name = "lesson1" } + val task = EduTask("task1") + + lesson.addTask(task) + + assertSame(lesson, task.parentOrNull) + } + + @Test + fun `test adding a task at an index makes the lesson its parent`() { + val lesson = Lesson().apply { name = "lesson1" } + val first = EduTask("task1") + lesson.addTask(first) + val second = EduTask("task2") + + lesson.addTask(0, second) + + assertSame(lesson, second.parentOrNull) + } + + @Test + fun `test replacing an item makes the container its parent`() { + val lesson = Lesson().apply { name = "lesson1" } + val existing = EduTask("task1") + lesson.addTask(existing) + val replacement = EduTask("task1") + + lesson.replaceItem(existing, replacement) + + assertSame(lesson, replacement.parentOrNull) + } + + @Test + fun `test adding a lesson makes the section its parent`() { + val section = Section().apply { name = "section1" } + val lesson = Lesson().apply { name = "lesson1" } + + section.addLesson(lesson) + + assertSame(section, lesson.parentOrNull) + } +} diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseView/NodesTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseView/NodesTest.kt index 4e07c9f80..869232492 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseView/NodesTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/courseView/NodesTest.kt @@ -1,14 +1,23 @@ // Copyright 2000-2018 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. package org.hyperskill.academy.learning.courseView +import com.intellij.ide.projectView.PresentationData +import com.intellij.ide.projectView.ProjectViewNode +import com.intellij.ide.projectView.ProjectViewNodeDecorator +import com.intellij.ide.projectView.ViewSettings +import com.intellij.ide.projectView.impl.nodes.PsiDirectoryNode +import com.intellij.openapi.extensions.ProjectExtensionPointName import com.intellij.psi.PsiManager +import com.intellij.ui.SimpleTextAttributes import org.hyperskill.academy.learning.courseDir import org.hyperskill.academy.learning.configurators.FakeGradleBasedLanguage import org.hyperskill.academy.learning.courseFormat.CheckStatus import org.hyperskill.academy.learning.courseFormat.CourseMode import org.hyperskill.academy.learning.courseFormat.ext.getDir import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse +import org.hyperskill.academy.learning.courseFormat.tasks.EduTask import org.hyperskill.academy.learning.projectView.CourseViewUtils +import org.hyperskill.academy.learning.projectView.TaskNode import org.junit.Test class NodesTest : CourseViewTestBase() { @@ -294,6 +303,99 @@ class NodesTest : CourseViewTestBase() { ) } + @Test + fun `test task node shows task name when task is detached from its lesson`() { + courseWithFiles(language = FakeGradleBasedLanguage) { + lesson { + eduTask { + taskFile("src/file.txt") + } + } + } + + val taskDir = findTask(0, 0).getDir(project.courseDir)!! + val srcDir = PsiManager.getInstance(project).findDirectory(taskDir.findChild("src")!!)!! + + // A task whose parent link is missing, as happens when the course structure fails to be restored + // from the generated YAML configs (`parent for '' was not found`) + val detachedTask = EduTask("Abstract class") + val node = TaskNode(project, srcDir, ViewSettings.DEFAULT, detachedTask) + node.update() + + assertEquals("TaskNode Abstract class", CourseViewUtils.testPresentation(node)) + } + + /** + * Mimics `GradleModuleDirectoryDecorator` (IDEA 2026.2+): for a `PsiDirectoryNode` whose directory is a Gradle + * module content root it wipes the text and renders ` []`. + */ + private fun registerModuleDirectoryDecorator() { + ProjectExtensionPointName("com.intellij.projectViewNodeDecorator").getPoint(project).registerExtension( + object : ProjectViewNodeDecorator { + override fun decorate(node: ProjectViewNode<*>, data: PresentationData) { + if (node !is PsiDirectoryNode) return + val dirName = data.presentableText ?: return + data.clearText() + data.addText("$dirName ", SimpleTextAttributes.REGULAR_ATTRIBUTES) + data.addText("[main]", SimpleTextAttributes.REGULAR_BOLD_ATTRIBUTES) + } + }, + testRootDisposable + ) + } + + @Test + fun `test task node survives a project view decorator rewriting the presentation`() { + // A task node points at the task source directory, so the decorator turns the task name into `src [main]` + registerModuleDirectoryDecorator() + + courseWithFiles(language = FakeGradleBasedLanguage) { + lesson("Abstract class") { + eduTask("Abstract class") { + taskFile("src/file.txt") + } + } + } + + assertCourseView( + """ + |-Project + | -CourseNode Test Course + | -LessonNode Abstract class + | -TaskNode Abstract class + | file.txt + """.trimMargin("|") + ) + } + + @Test + fun `test directory node survives a project view decorator rewriting the presentation`() { + // A task with files outside the source directory keeps its own directory, and `src` becomes a directory node of + // its own -- which the decorator renames just as happily + registerModuleDirectoryDecorator() + + courseWithFiles(language = FakeGradleBasedLanguage) { + lesson("Abstract class") { + eduTask("Abstract class") { + taskFile("src/file.txt") + taskFile("file1.txt") + } + } + } + + assertCourseView( + """ + |-Project + | -CourseNode Test Course + | -LessonNode Abstract class + | -TaskNode Abstract class + | -DirectoryNode src + | file.txt + | file1.txt + """.trimMargin("|") + ) + } + @Test fun `test hyperskill course with empty framework lesson`() { courseWithFiles(courseProducer = ::HyperskillCourse) { diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillRejectedEduTaskSubmissionTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillRejectedEduTaskSubmissionTest.kt new file mode 100644 index 000000000..e4d75b42b --- /dev/null +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/checker/HyperskillRejectedEduTaskSubmissionTest.kt @@ -0,0 +1,181 @@ +package org.hyperskill.academy.learning.stepik.hyperskill.checker + +import okhttp3.mockwebserver.MockResponse +import org.hyperskill.academy.learning.MockResponseFactory +import org.hyperskill.academy.learning.actions.CheckAction +import org.hyperskill.academy.learning.courseFormat.CheckStatus +import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse +import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillProject +import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillStage +import org.hyperskill.academy.learning.courseFormat.tasks.Task +import org.hyperskill.academy.learning.navigation.NavigationUtils +import org.hyperskill.academy.learning.pathWithoutPrams +import org.hyperskill.academy.learning.submissions.SubmissionsManager +import org.hyperskill.academy.learning.testAction +import org.hyperskill.academy.learning.ui.getUICheckLabel +import org.hyperskill.academy.learning.withNotificationCheck +import org.intellij.lang.annotations.Language +import org.junit.Test +import java.net.HttpURLConnection.HTTP_FORBIDDEN + +/** + * A stage locked behind a subscription is checked locally as any other stage, but the solution for it is not accepted + * by JBA. The rejection has to reach the learner instead of being hidden behind the local test results. + */ +class HyperskillRejectedEduTaskSubmissionTest : HyperskillActionTestBase() { + + override fun createCourse() { + val course = courseWithFiles(courseProducer = ::HyperskillCourse) { + frameworkLesson { + eduTask(stepId = 1) { + checkResultFile(CheckStatus.Solved) + } + } + } as HyperskillCourse + course.stages = listOf(HyperskillStage(1, "", 1)) + course.hyperskillProject = HyperskillProject() + } + + @Test + fun `test rejection reported with an error code is shown and stage is not completed`() { + configureResponses(MockResponseFactory.fromString(forbiddenSubmission, HTTP_FORBIDDEN)) + doCheckAndAssertRejected() + } + + @Test + fun `test rejection reported within a created submission is shown and stage is not completed`() { + configureResponses(MockResponseFactory.fromString(rejectedSubmission)) + doCheckAndAssertRejected() + } + + @Test + fun `test accepted submission keeps the stage solved`() { + configureResponses(MockResponseFactory.fromString(acceptedSubmission)) + val task = projectTask + + withNotificationCheck(project, testRootDisposable, { shown, _ -> + assertFalse("No notification is expected for an accepted solution", shown) + }) { + checkTask(task) + } + + assertEquals(CheckStatus.Solved, task.status) + assertTrue("Stage is expected to be completed", hyperskillCourse.stages.single().isCompleted) + assertEquals(1, SubmissionsManager.getInstance(project).getSubmissionsFromMemory(setOf(task.id)).size) + } + + private fun doCheckAndAssertRejected() { + val task = projectTask + + withNotificationCheck(project, testRootDisposable, { shown, content -> + assertTrue("Notification about the rejected solution is expected", shown) + assertTrue("Notification is expected to contain the reason, but was: `$content`", content.contains(REJECTION_REASON)) + }) { + checkTask(task) + } + + assertEquals("Solution was not accepted, so the task must not stay solved", CheckStatus.Unchecked, task.status) + assertEquals(REJECTION_REASON, task.feedback?.message) + assertFalse("Stage is not expected to be completed", hyperskillCourse.stages.single().isCompleted) + assertTrue( + "Not accepted submission must not be stored", + SubmissionsManager.getInstance(project).getSubmissionsFromMemory(setOf(task.id)).isEmpty() + ) + } + + private fun checkTask(task: Task) { + NavigationUtils.navigateToTask(project, task) + testAction(CheckAction(task.getUICheckLabel())) + } + + private val hyperskillCourse: HyperskillCourse get() = getCourse() as HyperskillCourse + + private val projectTask: Task get() = hyperskillCourse.getProjectLesson()!!.taskList.single() + + private fun configureResponses(submissionResponse: MockResponse) { + mockConnector.withResponseHandler(testRootDisposable) { request, _ -> + // the requests the check makes besides these two are left to the default `not found` response + when (request.pathWithoutPrams) { + "/api/attempts" -> MockResponseFactory.fromString(attempt) + "/api/submissions" -> submissionResponse + else -> null + } + } + } + + @Language("JSON") + private val attempt = """ + { + "meta": { + "page": 1, + "has_next": false, + "has_previous": false + }, + "attempts": [ + { + "dataset": "", + "id": 7565800, + "status": "active", + "step": 1, + "time": "2020-04-29T11:44:20.422Z", + "user": 6242591 + } + ] + } + """ + + @Language("JSON") + private val forbiddenSubmission = """ + { + "detail": "$REJECTION_REASON" + } + """ + + @Language("JSON") + private val rejectedSubmission = """ + { + "meta": { + "page": 1, + "has_next": false, + "has_previous": false + }, + "submissions": [ + { + "attempt": "7565800", + "id": "7565003", + "status": "wrong", + "hint": "$REJECTION_REASON", + "step": 1, + "time": "2020-04-29T11:44:20.422Z", + "user": 6242591 + } + ] + } + """ + + @Language("JSON") + private val acceptedSubmission = """ + { + "meta": { + "page": 1, + "has_next": false, + "has_previous": false + }, + "submissions": [ + { + "attempt": "7565800", + "id": "7565003", + "status": "correct", + "hint": "Congratulations!", + "step": 1, + "time": "2020-04-29T11:44:20.422Z", + "user": 6242591 + } + ] + } + """ + + companion object { + private const val REJECTION_REASON = "Can't post a submission for this stage. Upgrade your subscription." + } +} diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/update/HyperskillSectionUpdateTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/update/HyperskillSectionUpdateTest.kt index 99ac31a7d..f267b54d1 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/update/HyperskillSectionUpdateTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/stepik/hyperskill/update/HyperskillSectionUpdateTest.kt @@ -1,10 +1,12 @@ package org.hyperskill.academy.learning.stepik.hyperskill.update +import kotlinx.coroutines.runBlocking import org.hyperskill.academy.learning.CourseBuilder import org.hyperskill.academy.learning.SectionBuilder import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse import org.hyperskill.academy.learning.fileTree import org.hyperskill.academy.learning.update.UpdateTestBase +import org.hyperskill.academy.learning.update.elements.SectionDeletionInfo import org.junit.Test class HyperskillSectionUpdateTest : UpdateTestBase() { @@ -424,6 +426,31 @@ class HyperskillSectionUpdateTest : UpdateTestBase() { expectedStructure.assertEquals(rootDir) } + @Test + fun `test sections without a server id are paired by name`() { + // Sections created locally, like the Hyperskill "Topics" section, never receive a server id. Pairing them by + // id alone matches every one of them with the first remote id-less section and deletes the rest from disk. + localCourse = createBasicHyperskillCourse { + section("Topics") { + lesson("lesson1", id = 1) { + eduTask("task1", stepId = 1) { taskFile("src/Task.kt") } + } + } + section("Topics (1)") { + lesson("lesson2", id = 2) { + eduTask("task2", stepId = 2) { taskFile("src/Task.kt") } + } + } + } + + val remoteCourse = toRemoteCourse { } + + val updates = runBlocking { getUpdater(localCourse).collect(remoteCourse) } + + val deleted = updates.filterIsInstance().map { it.localItem.name } + assertEquals("Sections were paired with the wrong counterpart", emptyList(), deleted) + } + override fun initiateLocalCourse() { localCourse = createBasicHyperskillCourse { section("section1", id = 1) { diff --git a/intellij-plugin/hs-jvm-core/resources/messages/EduJVMBundle.properties b/intellij-plugin/hs-jvm-core/resources/messages/EduJVMBundle.properties index e00137b17..2483fc947 100644 --- a/intellij-plugin/hs-jvm-core/resources/messages/EduJVMBundle.properties +++ b/intellij-plugin/hs-jvm-core/resources/messages/EduJVMBundle.properties @@ -1,17 +1,16 @@ # Errors error.no.jdk=JDK is not selected. In the settings section, choose or download some JDK -error.no.jdk.need.at.least=JDK is not selected. In the settings section, choose or download some JDK with a version at least {0} -error.no.jdk.available=No JDK found on your system. Please configure JDK in IDE settings -error.jdk.loading.failed=Failed to load JDK list. Please check your IDE settings or configure JDK manually +error.no.required.jdk=This course requires JDK {0}. In the settings section, choose or download it error.no.main=Unable to execute task `{0}`, main method is missing # Ex.: Gradle project isn't imported. Reload Gradle project. For more information, see the Troubleshooting guide error.gradle.not.imported=Gradle project isn''t imported. Reload Gradle project. For more information, see the Troubleshooting guide -error.hyperskill.incorrect.jdk=Please update your SDK to version {0} or newer. Open Project Settings. - -failed.determine.java.version=Failed to determine Java version from string: {0}. In the settings section, choose or download another JDK +error.hyperskill.incorrect.jdk=This course requires JDK {0}. Open Project Settings. error.unsupported.java.version=Unsupported Java version: {0} -error.old.java=Your Java version is {1}, while it should be at least {0}. In the settings section, choose or download a newer version of JDK +jdk.will.be.downloaded=JDK {0} is required for this course and will be downloaded automatically + +progress.downloading.jdk=Downloading JDK {0} progress.resolving.suitable.jdk=Resolving suitable JDK progress.setting.suitable.jdk=Setting suitable JDK progress.warming.suitable.jdk=Warming suitable JDK +action.download.jdk=Download JDK {0} diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkAutoInstaller.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkAutoInstaller.kt new file mode 100644 index 000000000..8bae16fd3 --- /dev/null +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkAutoInstaller.kt @@ -0,0 +1,177 @@ +package org.hyperskill.academy.jvm + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.EDT +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.progress.EmptyProgressIndicator +import com.intellij.openapi.progress.ProcessCanceledException +import com.intellij.openapi.progress.ProgressIndicator +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.JavaSdk +import com.intellij.openapi.projectRoots.JavaSdkVersion +import com.intellij.openapi.projectRoots.ProjectJdkTable +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.projectRoots.impl.SdkConfigurationUtil +import com.intellij.openapi.projectRoots.impl.jdkDownloader.JdkInstaller +import com.intellij.openapi.projectRoots.impl.jdkDownloader.JdkListDownloader +import com.intellij.openapi.util.io.FileUtil +import com.intellij.openapi.util.registry.Registry +import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.util.concurrency.annotations.RequiresEdt +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.hyperskill.academy.jvm.messages.EduJVMBundle +import org.hyperskill.academy.learning.computeUnderProgress +import java.nio.file.Path + +private val LOG = logger() + +/** + * Downloads a JDK of the feature version a course requires, from the same JetBrains JDK feed the + * `Download JDK...` item of the IDE JDK combo box uses. + * + * The whole `com.intellij.openapi.projectRoots.impl.jdkDownloader` package is marked `@ApiStatus.Internal`, and + * there is no public replacement: `SdkDownload.showDownloadUI` always opens a modal picker, and + * `ProjectSdksModel.setupInstallableSdk` needs an `SdkDownloadTask` that only those internal classes can build. + * The three types used here -- `JdkListDownloader`, `JdkInstaller` and `JdkInstallRequest` -- have identical + * signatures on 252, 253, 261 and 262, so one source form compiles on every supported platform. + */ +object JdkAutoInstaller { + + /** Registry key the platform itself uses to switch the JDK downloader off. */ + private const val JDK_DOWNLOADER_REGISTRY_KEY = "jdk.downloader" + + /** + * Whether a missing JDK may be downloaded. Never true in tests: they must not reach out to the network and + * must not install anything on the machine that runs them. + */ + fun isAvailable(): Boolean { + if (ApplicationManager.getApplication().isUnitTestMode) return false + return Registry.`is`(JDK_DOWNLOADER_REGISTRY_KEY, true) + } + + /** + * Downloads [javaSdkVersion] and registers it in [ProjectJdkTable], showing a progress dialog while it runs. + * Returns `null` if the version is not offered by the feed or the download failed; in that case the caller + * keeps whatever JDK it had. + * + * Must be called from the EDT: the download itself runs in the background, but registering the resulting SDK + * needs a write action. + */ + @RequiresEdt + fun installJdk(project: Project?, javaSdkVersion: JavaSdkVersion): Sdk? { + if (!isAvailable()) return null + val featureVersion = javaSdkVersion.featureVersion ?: return null + + val javaHome = try { + computeUnderProgress(project, EduJVMBundle.message("progress.downloading.jdk", javaSdkVersion.description)) { + downloadJdk(project, featureVersion, it) + } + } + catch (_: ProcessCanceledException) { + // The learner cancelled the download. The project is already created at this point, so it must still open: + // they keep the JDK they had and the course checker tells them to update it. + LOG.info("Downloading JDK ${javaSdkVersion.description} was cancelled") + null + } ?: return null + + return try { + registerJdk(javaHome) + } + catch (e: Throwable) { + // Registering goes through `SdkConfigurationUtil`, which scans the JDK home under a modal progress of its own + // and rethrows cancellation. Losing the JDK is bad; taking the rest of project generation down with it is worse. + LOG.warn("Failed to register the JDK downloaded to $javaHome", e) + null + } + } + + /** + * Same as [installJdk], but for callers that are already inside a coroutine and must not block the EDT with a modal + * progress -- opening a project, for instance. Reports through the usual background progress bar instead. + */ + suspend fun installJdkInBackground(project: Project, javaSdkVersion: JavaSdkVersion): Sdk? { + if (!isAvailable()) return null + val featureVersion = javaSdkVersion.featureVersion ?: return null + + val title = EduJVMBundle.message("progress.downloading.jdk", javaSdkVersion.description) + val javaHome = withBackgroundProgress(project, title, false) { + withContext(Dispatchers.IO) { downloadJdk(project, featureVersion, EmptyProgressIndicator()) } + } ?: return null + + return try { + withContext(Dispatchers.EDT) { registerJdk(javaHome) } + } + catch (e: ProcessCanceledException) { + // `SdkConfigurationUtil` deliberately rethrows the cancellation of the JDK scan it runs, and + // `ProcessCanceledException` is a `CancellationException`, which the platform rethrows out of a startup activity + // without logging anything at all. The download itself is done, so this is a failure to register, not a reason + // to abort project opening in silence. + LOG.warn("Registering the JDK downloaded to $javaHome was cancelled") + null + } + catch (e: CancellationException) { + throw e + } + catch (e: Throwable) { + LOG.warn("Failed to register the JDK downloaded to $javaHome", e) + null + } + } + + /** + * Downloads a JDK with the given feature version and returns the path to its java home, or `null` on failure. + * Performs network and disk I/O, so it must not be called on the EDT. + */ + private fun downloadJdk(project: Project?, featureVersion: Int, indicator: ProgressIndicator): Path? { + return try { + val candidates = JdkListDownloader.getInstance() + .downloadModelForJdkInstaller(indicator) + .filter { it.jdkMajorVersion == featureVersion } + // Take the vendor the IDE itself would suggest; the feed marks no default for versions that are not current, + // so fall back to any build offered in the JDK picker + val jdkItem = candidates.firstOrNull { it.isDefaultItem } + ?: candidates.firstOrNull { it.isVisibleOnUI } + ?: candidates.firstOrNull() + if (jdkItem == null) { + LOG.warn("JDK $featureVersion is not offered by the JDK feed, nothing to download") + return null + } + + val installer = JdkInstaller.getInstance() + val request = installer.prepareJdkInstallation(jdkItem, installer.defaultInstallDir(jdkItem)) + LOG.info("Downloading ${jdkItem.fullPresentationText} into ${request.installDir}") + installer.installJdk(request, indicator, project) + request.javaHome + } + catch (e: ProcessCanceledException) { + throw e + } + catch (e: Throwable) { + LOG.warn("Failed to download JDK $featureVersion", e) + null + } + } + + /** + * Adds the JDK installed at [javaHome] to [ProjectJdkTable], reusing an entry that already points there. + */ + @RequiresEdt + private fun registerJdk(javaHome: Path): Sdk? { + // `Path.toString` is system-dependent, the JDK table stores system-independent home paths + val homePath = FileUtil.toSystemIndependentName(javaHome.toString()) + findRegisteredJdk(homePath)?.let { return it } + + // `SdkConfigurationUtil` reports most of its failures by returning `null` after a warning of its own, and it may + // well have added the JDK and only failed to scan its roots, so look it up once more before giving up. + val sdk = SdkConfigurationUtil.createAndAddSDK(homePath, JavaSdk.getInstance()) ?: findRegisteredJdk(homePath) + if (sdk == null) { + LOG.warn("Failed to register the downloaded JDK located at $homePath") + } + return sdk + } + + private fun findRegisteredJdk(homePath: String): Sdk? = + ProjectJdkTable.getInstance().getSdksOfType(JavaSdk.getInstance()).find { FileUtil.pathsEqual(it.homePath, homePath) } +} diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkDownloadUi.java b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkDownloadUi.java new file mode 100644 index 000000000..845bf4704 --- /dev/null +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkDownloadUi.java @@ -0,0 +1,61 @@ +package org.hyperskill.academy.jvm; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.projectRoots.SdkModel; +import com.intellij.openapi.projectRoots.SdkTypeId; +import com.intellij.openapi.projectRoots.impl.jdkDownloader.JdkItem; +import com.intellij.openapi.roots.ui.configuration.projectRoot.SdkDownload; +import com.intellij.openapi.roots.ui.configuration.projectRoot.SdkDownloadTask; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import javax.swing.*; +import java.util.function.Consumer; +import java.util.function.Predicate; + +/** + * Opens the IDE's own {@code Download JDK} dialog with the version a course requires already chosen, so that the + * vendor and the install directory are the only things left to pick. + * + * Written in Java on purpose. The predicate parameter of the seven-argument {@code showDownloadUI} is + * {@code Predicate} on 2025.2 and 2025.3 and {@code Predicate} on 2026.1 and 2026.2. The erasure is + * the same, so one raw {@code Predicate} compiles against every supported platform, while no single Kotlin spelling + * does. + */ +public final class JdkDownloadUi { + + private JdkDownloadUi() { + } + + /** Whether the IDE is able to download a JDK of [sdkType] at all. */ + public static boolean isAvailable(@NotNull SdkTypeId sdkType) { + return findDownload(sdkType) != null; + } + + /** + * Shows the dialog, pinned to {@code featureVersion}, and hands the resulting task to {@code onTaskReady}. + * + * @return {@code false} when the IDE offers no downloader for {@code sdkType} and nothing was shown + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + public static boolean show(@NotNull SdkTypeId sdkType, + @NotNull SdkModel sdkModel, + @NotNull JComponent parentComponent, + @Nullable Project project, + int featureVersion, + @NotNull Consumer onTaskReady) { + SdkDownload download = findDownload(sdkType); + if (download == null) return false; + + Predicate versionFilter = item -> item instanceof JdkItem && ((JdkItem)item).getJdkMajorVersion() == featureVersion; + download.showDownloadUI(sdkType, sdkModel, parentComponent, project, null, versionFilter, onTaskReady); + return true; + } + + private static @Nullable SdkDownload findDownload(@NotNull SdkTypeId sdkType) { + for (SdkDownload candidate : SdkDownload.EP_NAME.getExtensionList()) { + if (candidate.supportsDownload(sdkType)) return candidate; + } + return null; + } +} diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkEnvironmentSettings.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkEnvironmentSettings.kt index 9d54c90bf..5498a3db6 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkEnvironmentSettings.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkEnvironmentSettings.kt @@ -2,36 +2,93 @@ package org.hyperskill.academy.jvm import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.JavaSdkVersion -import com.intellij.openapi.projectRoots.JavaSdkVersionUtil import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.roots.LanguageLevelProjectExtension import com.intellij.util.lang.JavaVersion import org.hyperskill.academy.learning.courseFormat.Course import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse +import java.nio.file.Files +import java.nio.file.InvalidPathException +import java.nio.file.Path const val JVM_LANGUAGE_LEVEL = "jvm_language_level" // TODO(refactor this), this is a temporary solution -// All JVM-based Hyperskill courses require at least JDK version 23 +// Every JVM-based Hyperskill course is built and checked with JDK 23 val hyperskillJdkVersion: JavaSdkVersion = JavaSdkVersion.JDK_23 /** - * Checks that [this] JDK is new enough for a Hyperskill course, i.e. is [hyperskillJdkVersion] or newer. + * Feature version of [this] JDK version, i.e. 23 for [JavaSdkVersion.JDK_23] and 8 for [JavaSdkVersion.JDK_1_8], + * or `null` if it cannot be determined. + */ +val JavaSdkVersion.featureVersion: Int? + get() = JavaVersion.tryParse(description)?.feature + +/** + * Feature version of a JDK reporting [versionString], or `null` when the string cannot be parsed or names a + * pre-release build (`26-ea`, `25-internal`, `23-valhalla`, ...). * - * Feature versions are compared instead of `JavaSdkVersion` values because the latter enum has no entry for a JDK - * newer than the one the IDE knows about: [JavaSdkVersionUtil.getJavaSdkVersion] returns `null` for such a JDK, - * which would make a perfectly suitable installation look unsupported. + * A pre-release build deliberately counts as having no version at all: the Gradle integration refuses to run on one + * and silently falls back to an arbitrary installation instead, so such a JDK must never end up on a course. + * + * Feature numbers are used instead of [JavaSdkVersion] values because that enum has no entry for a JDK newer than the + * one the IDE was built with and reports `null` for it, which used to make a perfectly good installation look + * unusable. */ -fun Sdk.isAtLeastHyperskillJdkVersion(): Boolean { - val requiredFeatureVersion = JavaVersion.tryParse(hyperskillJdkVersion.description)?.feature - val actualFeatureVersion = versionString?.let { JavaVersion.tryParse(it) }?.feature - if (requiredFeatureVersion != null && actualFeatureVersion != null) { - return actualFeatureVersion >= requiredFeatureVersion +fun releaseFeatureVersion(versionString: String?): Int? { + val version = versionString ?: return null + if (PRE_RELEASE_JDK_VERSION.containsMatchIn(version)) return null + val javaVersion = JavaVersion.tryParse(version) ?: return null + return if (javaVersion.ea) null else javaVersion.feature +} + +val Sdk.releaseFeatureVersion: Int? + get() = releaseFeatureVersion(versionString) + +/** Matches a feature version followed by a pre-release qualifier: `26-ea`, `25-internal`, `23-valhalla`. */ +private val PRE_RELEASE_JDK_VERSION = Regex("""\d+(\.\d+)*-[A-Za-z]""") + +/** + * Whether [this] JDK is really installed, i.e. whether its home directory holds a java launcher. + * + * [com.intellij.openapi.projectRoots.ProjectJdkTable] keeps an entry after its JDK has been uninstalled: the IDE only + * paints it red in the JDK combo box. Such an entry still reports the version string it was registered with, so every + * version check accepts it, and the learner lands on a project whose Gradle sync fails with + * `Invalid Gradle JDK configuration found`. + * + * An existing directory is not enough either. `JdkInstaller.prepareJdkInstallation` creates the java home *before* the + * first byte is downloaded, so a download that is still running, was cancelled or failed would otherwise pass for an + * installed JDK -- and the course dialog would start a course on an empty folder. + */ +val Sdk.hasExistingHome: Boolean + get() { + val homePath = homePath ?: return false + return try { + val home = Path.of(homePath) + Files.isRegularFile(home.resolve(UNIX_JAVA_LAUNCHER)) || Files.isRegularFile(home.resolve(WINDOWS_JAVA_LAUNCHER)) + } + catch (_: InvalidPathException) { + false + } } - return JavaSdkVersionUtil.getJavaSdkVersion(this)?.isAtLeast(hyperskillJdkVersion) == true + +private const val UNIX_JAVA_LAUNCHER = "bin/java" +private const val WINDOWS_JAVA_LAUNCHER = "bin/java.exe" + +/** + * Whether [this] JDK is the one every JVM Hyperskill course is built and checked with. + * + * The version is pinned, not a lower bound: the generated Gradle scripts derive the Java toolchain from the JDK the + * daemon runs on, so a newer JDK quietly changes what the learner's code is compiled against, and Hyperskill's own + * tests run on [hyperskillJdkVersion]. + */ +fun Sdk.isHyperskillJdkVersion(): Boolean { + val featureVersion = releaseFeatureVersion ?: return false + return featureVersion == hyperskillJdkVersion.featureVersion } -val Course.minJvmSdkVersion: ParsedJavaVersion +/** The JDK version a course has to be opened with. */ +val Course.requiredJdkVersion: ParsedJavaVersion get() = when (this) { is HyperskillCourse -> JavaVersionParseSuccess(hyperskillJdkVersion) else -> ParsedJavaVersion.fromStringLanguageLevel(environmentSettings[JVM_LANGUAGE_LEVEL]) diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkLanguageSettings.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkLanguageSettings.kt index eed4327cb..b32b80384 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkLanguageSettings.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkLanguageSettings.kt @@ -1,20 +1,29 @@ package org.hyperskill.academy.jvm -import com.intellij.openapi.application.* +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.application.invokeLater import com.intellij.openapi.diagnostic.logger -import com.intellij.openapi.project.ProjectManager -import com.intellij.openapi.projectRoots.* +import com.intellij.openapi.progress.EmptyProgressIndicator +import com.intellij.openapi.projectRoots.JavaSdk +import com.intellij.openapi.projectRoots.JavaSdkType +import com.intellij.openapi.projectRoots.ProjectJdkTable +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.projectRoots.SdkTypeId import com.intellij.openapi.projectRoots.impl.ProjectJdkImpl +import com.intellij.openapi.projectRoots.impl.SdkVersionUtil import com.intellij.openapi.roots.ui.configuration.JdkComboBox -import com.intellij.openapi.roots.ui.configuration.ProjectStructureConfigurable +import com.intellij.openapi.roots.ui.configuration.SdkListItem +import com.intellij.openapi.roots.ui.configuration.SdkListModelBuilder import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel +import com.intellij.openapi.roots.ui.configuration.projectRoot.SdkDownloadTracker import com.intellij.openapi.ui.LabeledComponent import com.intellij.openapi.util.CheckedDisposable -import com.intellij.openapi.util.Condition -import com.intellij.openapi.util.Disposer import com.intellij.openapi.util.UserDataHolder -import com.intellij.openapi.vfs.LocalFileSystem -import kotlinx.coroutines.* +import com.intellij.openapi.util.io.FileUtil +import com.intellij.ui.components.ActionLink +import com.intellij.util.ui.JBUI import org.hyperskill.academy.jvm.messages.EduJVMBundle import org.hyperskill.academy.learning.EduNames.ENVIRONMENT_CONFIGURATION_LINK_JAVA import org.hyperskill.academy.learning.LanguageSettings @@ -22,475 +31,329 @@ import org.hyperskill.academy.learning.courseFormat.Course import org.hyperskill.academy.learning.courseFormat.ext.project import org.hyperskill.academy.learning.newproject.ui.errors.SettingsValidationResult import org.hyperskill.academy.learning.newproject.ui.errors.ValidationMessage +import org.hyperskill.academy.learning.newproject.ui.errors.ValidationMessageType +import org.jetbrains.jps.model.java.JdkVersionDetector import java.awt.BorderLayout -import java.io.File -import java.util.concurrent.atomic.AtomicInteger +import java.nio.file.Files +import java.nio.file.Path import javax.swing.JComponent +import javax.swing.JPanel private val LOG = logger() +/** + * The JDK part of the course creation dialog. + * + * Everything here is synchronous, and deliberately so: the JDKs on offer come from [ProjectJdkTable], an in-memory + * list, so collecting them costs nothing and the combo box works the moment the dialog opens. The platform calls that + * used to force all of this into a background coroutine -- `ProjectSdksModel.reset` and + * `ProjectSdksModel.addSdk(SdkType, home, callback)` -- are not used at all: each runs a modal progress of its own, + * which under the already modal course dialog may never finish, and used to leave the learner looking at + * "No SDK configured" with nothing to click. + * + * The dialog does not have to *guarantee* a JDK either, only to offer one. When nothing suitable is installed, the + * version the course requires is downloaded while the project is generated (see [JdkProjectSettings.setUpProjectJdk]), + * and an existing project gets the same treatment when it is opened (see [ProjectJdkRepair]). + */ open class JdkLanguageSettings : LanguageSettings() { protected var jdk: Sdk? = null - protected val sdkModel: ProjectSdksModel = createSdkModel() /** - * Represents the state of JDK loading process. + * A model of our own, not the one behind `ProjectStructureConfigurable`: that one is an application-wide singleton + * shared with the Project Structure dialog. Nothing is ever committed from this model -- whatever the learner ends + * up with is registered by [JdkProjectSettings.setUpProjectJdk]. */ - private enum class JdkLoadingState { - NOT_STARTED, - LOADING, - LOADED, - FAILED - } - - @Volatile - private var loadingState: JdkLoadingState = JdkLoadingState.NOT_STARTED - - @Volatile - private var loadingError: String? = null - - @Volatile - private var componentsInitialized: Boolean = false - - private val preselectRequestId = AtomicInteger() - - // Keep for backward compatibility with isJdkLoading checks - private val isJdkLoading: Boolean - get() = loadingState == JdkLoadingState.LOADING - - private fun createSdkModel(): ProjectSdksModel { - val project = ProjectManager.getInstance().defaultProject - // Do NOT call reset(project) on EDT — it performs synchronous progress and is prohibited. - // We return the configurable's model as-is and let subclasses optionally pre-populate it - // (e.g., with a bundled JDK) via setupProjectSdksModel. Any heavier refreshes must be done - // in background before UI selection (see preselectJdk and prewarmSdkValidation). - return ProjectStructureConfigurable.getInstance(project).projectJdksModel.apply { - setupProjectSdksModel(this) - } - } + protected val sdkModel: ProjectSdksModel = ProjectSdksModel() - protected open fun setupProjectSdksModel(model: ProjectSdksModel) {} + /** Whether [sdkModel] has been filled at least once, so that [validate] does not rescan on every keystroke. */ + private var jdksLoaded: Boolean = false /** - * Called from background thread to add bundled JDK to the model if needed. - * Override this instead of [setupProjectSdksModel] for operations that require write actions - * (like [ProjectSdksModel.addSdk]) which are prohibited on EDT in IntelliJ 2025.3+. + * Version of the runtime the IDE itself runs on, read from disk once. [validate] runs on every keystroke in the + * location field, and the bundled runtime cannot change while the dialog is open. */ - protected open fun addBundledJdkIfNeeded(model: ProjectSdksModel) {} + private val bundledJdkVersion: JdkVersionDetector.JdkVersionInfo? by lazy(LazyThreadSafetyMode.NONE) { + val homePath = PathManager.getBundledRuntimePath() + // An IDE started by the Gradle IntelliJ plugin has no bundled runtime + if (Files.isDirectory(Path.of(homePath))) SdkVersionUtil.getJdkVersionInfo(homePath) else null + } override fun getLanguageSettingsComponents( course: Course, disposable: CheckedDisposable, context: UserDataHolder? ): List> { - componentsInitialized = true - val sdkTypeFilter = Condition { sdkTypeId -> sdkTypeId is JavaSdkType && !(sdkTypeId as JavaSdkType).isDependent } - val sdkFilter = Condition { sdk -> sdkTypeFilter.value(sdk.sdkType) } - val jdkComboBox = JdkComboBox(course.project, sdkModel, sdkTypeFilter, sdkFilter, sdkTypeFilter, null) - val uiScope = context?.getUserData(COROUTINE_SCOPE_KEY) ?: createFallbackUiScope(disposable) - preselectJdk(course, jdkComboBox, sdkModel, uiScope) - jdk = jdkComboBox.selectedItem?.jdk + val requiredVersion = requiredJdkVersion(course) + reloadJdks(requiredVersion) + + val jdkComboBox = createJdkComboBox(course, requiredVersion) + jdkComboBox.selectedJdk = jdk + updateSelectedJdk(jdkComboBox.selectedJdk) jdkComboBox.addItemListener { updateSelectedJdk(jdkComboBox.selectedItem?.jdk) } - // Subscribe to JDK table changes to update combobox when user adds/removes JDKs in Settings + // The learner may configure a JDK in Settings while the dialog is open, or uninstall the one it preselected val connection = ApplicationManager.getApplication().messageBus.connect(disposable) connection.subscribe(ProjectJdkTable.JDK_TABLE_TOPIC, object : ProjectJdkTable.Listener { override fun jdkAdded(addedJdk: Sdk) { - LOG.info("JDK added event received: ${addedJdk.name}, type=${addedJdk.sdkType}") - if (addedJdk.sdkType is JavaSdkType) { - invalidatePreselectRequests() - invokeLater(ModalityState.any()) { - if (!canUpdateJdkUi(jdkComboBox, course.project)) return@invokeLater - LOG.info("Processing JDK added on EDT, current jdk=$jdk, loadingState=$loadingState") - jdkComboBox.reloadModel() - jdkComboBox.isEnabled = true - if (jdk == null || loadingState == JdkLoadingState.FAILED) { - jdkComboBox.selectedJdk = addedJdk - LOG.info("Selected newly added JDK: ${addedJdk.name}") - } - updateSelectedJdk(jdkComboBox.selectedJdk ?: addedJdk) - LOG.info("Updated loadingState to LOADED, calling notifyListeners") - } - } + if (addedJdk.sdkType !is JavaSdkType) return + invokeLater(ModalityState.any()) { refreshJdks(jdkComboBox, requiredVersion) } } override fun jdkRemoved(removedJdk: Sdk) { - LOG.info("JDK removed event received: ${removedJdk.name}") - if (removedJdk.sdkType is JavaSdkType) { - invalidatePreselectRequests() - invokeLater(ModalityState.any()) { - if (!canUpdateJdkUi(jdkComboBox, course.project)) return@invokeLater - jdkComboBox.reloadModel() - jdkComboBox.isEnabled = true - val selectedJdk = if (jdk == removedJdk) jdkComboBox.selectedJdk else jdk - if (selectedJdk != null) { - updateSelectedJdk(selectedJdk) - } - else if (!hasAnyJdks(sdkModel)) { - loadingState = JdkLoadingState.FAILED - loadingError = EduJVMBundle.message("error.no.jdk.available", ENVIRONMENT_CONFIGURATION_LINK_JAVA) - notifyListeners() - } + if (removedJdk.sdkType !is JavaSdkType) return + invokeLater(ModalityState.any()) { + sdkModel.projectSdks.values.filter { it.name == removedJdk.name }.forEach { sdkModel.removeSdk(it) } + if (jdk?.name == removedJdk.name) { + jdk = null } + refreshJdks(jdkComboBox, requiredVersion) } } }) - return listOf(LabeledComponent.create(jdkComboBox, "JDK", BorderLayout.WEST)) + return listOf(LabeledComponent.create(jdkRow(course, jdkComboBox, requiredVersion, disposable), "JDK", BorderLayout.WEST)) + } + + /** + * A combo box offering nothing but the JDK the course requires. + * + * Three separate filters are needed to get there, which is why the model builder is assembled by hand instead of + * letting [JdkComboBox] do it: registered JDKs go through the SDK filter, the ones the platform detected on disk + * have a list and a filter of their own, and the `Download JDK...` action would open a picker offering every version + * there is. That action is dropped and replaced by [downloadJdkLink], which pins the version. + */ + private fun createJdkComboBox(course: Course, requiredVersion: ParsedJavaVersion): JdkComboBox { + val isJavaSdkType = { sdkType: SdkTypeId -> sdkType is JavaSdkType && !sdkType.isDependent } + val modelBuilder = SdkListModelBuilder( + course.project, + sdkModel, + isJavaSdkType, + isJavaSdkType, + // A JDK the course cannot be built with is not offered at all rather than rejected once it is picked, so there + // is nothing to choose that does not work. One being downloaded has no home directory yet and is kept on + // purpose: that is how the combo box shows the download running. + { sdk -> isSuitableJdk(sdk, requiredVersion) || SdkDownloadTracker.getInstance().isDownloading(sdk) }, + { suggested -> matchesRequiredVersion(releaseFeatureVersion(suggested.version), requiredVersion) }, + { role -> role != SdkListItem.ActionRole.DOWNLOAD }, + ) + return JdkComboBox(course.project, modelBuilder) { newJdk -> + // `Add JDK...` creates the JDK inside the model; it becomes the selection right away + updateSelectedJdk(newJdk) + } } - private fun preselectJdk( + private fun jdkRow( course: Course, jdkComboBox: JdkComboBox, - sdksModel: ProjectSdksModel, - uiScope: CoroutineScope - ) { - if (jdkComboBox.selectedJdk != null) { - loadingState = JdkLoadingState.LOADED - return + requiredVersion: ParsedJavaVersion, + disposable: CheckedDisposable + ): JComponent { + val downloadLink = downloadJdkLink(course, jdkComboBox, requiredVersion, disposable) ?: return jdkComboBox + return JPanel(BorderLayout(JBUI.scale(8), 0)).apply { + isOpaque = false + add(jdkComboBox, BorderLayout.CENTER) + add(downloadLink, BorderLayout.EAST) } - val requestId = preselectRequestId.incrementAndGet() - loadingState = JdkLoadingState.LOADING - loadingError = null - - // Disable combo box while loading to indicate loading state - LOG.info("Starting JDK preselection request #$requestId") - jdkComboBox.isEnabled = false - - uiScope.launch { - LOG.info("Running JDK preselection request #$requestId") - - val result = - withContext(Dispatchers.IO) { - try { - val project = readAction { course.project } - LOG.info("Resetting SDK model for request #$requestId") - if (project != null) { - sdksModel.reset(project) - } - LOG.info("SDK model reset finished for request #$requestId") - - LOG.info("Sync sdks model with jdk table for request #$requestId") - syncSdksModelWithJdkTable(sdksModel) - - LOG.info("Find sdks for request #$requestId") - val suitableJdk = findSuitableJdk(minJvmSdkVersion(course), sdksModel) - ?: findSuitableJdkFromTable(minJvmSdkVersion(course)) - val hasAnyJdks = hasAnyJdks(sdksModel) - - PreselectJdkResult( - suitableJdk = suitableJdk, - loadingState = if (hasAnyJdks) JdkLoadingState.LOADED else JdkLoadingState.FAILED, - loadingError = if (hasAnyJdks) null else EduJVMBundle.message("error.no.jdk.available", ENVIRONMENT_CONFIGURATION_LINK_JAVA) - ) - } - catch (e: CancellationException) { - throw e - } - catch (e: Throwable) { - LOG.warn("Failed to preselect JDK for request #$requestId", e) - PreselectJdkResult.failed(e.message ?: EduJVMBundle.message("error.jdk.loading.failed", ENVIRONMENT_CONFIGURATION_LINK_JAVA)) - } - } - - LOG.info("Add bundled jdk if needed for request #$requestId") - addBundledJdkIfNeeded(sdksModel) + } - withContext(Dispatchers.IO) { - result.suitableJdk?.let { - prewarmSdkValidation(it) + /** + * Replaces the platform's `Download JDK...` action with one that can only download the required version. + * + * Returns `null` when the course pins no version, or when the IDE cannot download JDKs at all -- in both cases there + * is nothing this link could do that the combo box does not already do. + */ + private fun downloadJdkLink( + course: Course, + jdkComboBox: JdkComboBox, + requiredVersion: ParsedJavaVersion, + disposable: CheckedDisposable + ): JComponent? { + if (requiredVersion !is JavaVersionParseSuccess) return null + val requiredFeatureVersion = requiredVersion.javaSdkVersion.featureVersion ?: return null + val javaSdk = JavaSdk.getInstance() + if (!JdkDownloadUi.isAvailable(javaSdk)) return null + + return ActionLink(EduJVMBundle.message("action.download.jdk", requiredVersion.javaSdkVersion.description)) { + JdkDownloadUi.show(javaSdk, sdkModel, jdkComboBox, course.project, requiredFeatureVersion) { task -> + sdkModel.setupInstallableSdk(javaSdk, task) { downloadedJdk -> + jdkComboBox.reloadModel() + jdkComboBox.selectedJdk = downloadedJdk + updateSelectedJdk(downloadedJdk) + // The callback above fires when the download is *scheduled*, not when it is over: until then the JDK home is + // the empty directory the installer created up front and the version string is only the planned one, so + // everything downstream would take an unfinished download for an installed JDK. Re-check when it lands. + SdkDownloadTracker.getInstance().tryRegisterDownloadingListener(downloadedJdk, disposable, EmptyProgressIndicator()) { + invokeLater(ModalityState.any()) { refreshJdks(jdkComboBox, requiredVersion) } + } } } - - LOG.info( - "JDK preselection request #$requestId finished: state=${result.loadingState}, " + - "suitableJdk=${result.suitableJdk?.name}, error=${result.loadingError}" - ) - - LOG.info("Finishing JDK preselection request #$requestId on EDT") - if (!canApplyPreselectResult(requestId, jdkComboBox, course.project)) { - LOG.info( - "comboDisplayable=${jdkComboBox.isDisplayable}, comboShowing=${jdkComboBox.isShowing}, " + - "projectDisposed=${course.project?.isDisposed == true}, latestRequest=${preselectRequestId.get()}" - ) - return@launch - } - finishPreselectJdk(jdkComboBox, result) - - } - } - - private fun updateSelectedJdk(selectedJdk: Sdk?) { - jdk = selectedJdk - if (selectedJdk != null) { - updateLoadingState(true) } - notifyListeners() } - private fun updateLoadingState(hasAnyJdks: Boolean, errorMessage: String? = null) { - loadingState = if (hasAnyJdks) JdkLoadingState.LOADED else JdkLoadingState.FAILED - loadingError = if (hasAnyJdks) null else errorMessage ?: EduJVMBundle.message("error.jdk.loading.failed", ENVIRONMENT_CONFIGURATION_LINK_JAVA) - } - - private fun finishPreselectJdk(jdkComboBox: JdkComboBox, result: PreselectJdkResult) { - LOG.info("JDK preselection finished: state=${result.loadingState}, suitableJdk=${result.suitableJdk?.name}, error=${result.loadingError}") - loadingState = result.loadingState - loadingError = result.loadingError - jdkComboBox.reloadModel() - jdkComboBox.isEnabled = true - val jdkToSelect = jdk ?: jdkComboBox.selectedJdk ?: result.suitableJdk - if (jdkComboBox.selectedJdk != jdkToSelect) { - jdkComboBox.selectedJdk = jdkToSelect + /** + * Fills [sdkModel] with the JDKs the learner can pick from and preselects the one the course needs. + * + * JDKs whose home directory is gone are left out: [ProjectJdkTable] keeps an entry after its JDK has been + * uninstalled, and starting a course on such an entry produces a project the Gradle integration refuses to sync. + */ + private fun reloadJdks(requiredVersion: ParsedJavaVersion) { + // A JDK being downloaded has no launcher in its home yet and is kept on purpose: that is how the combo box shows + // the download running, and dropping it would take the learner's selection away mid-download. + sdkModel.projectSdks.values + .filter { !it.hasExistingHome && !SdkDownloadTracker.getInstance().isDownloading(it) } + .forEach { sdkModel.removeSdk(it) } + val knownHomes = sdkModel.projectSdks.values.mapNotNullTo(mutableSetOf()) { it.systemIndependentHome } + + val candidates = ProjectJdkTable.getInstance().getSdksOfType(JavaSdk.getInstance()).filter { it.hasExistingHome } + + listOfNotNull(bundledJdk(requiredVersion)) + for (candidate in candidates) { + val home = candidate.systemIndependentHome ?: continue + if (knownHomes.add(home)) { + sdkModel.addSdk(candidate) + } } - updateSelectedJdk(jdkComboBox.selectedJdk ?: jdkToSelect) - } - private fun canApplyPreselectResult( - requestId: Int, - jdkComboBox: JdkComboBox, - project: com.intellij.openapi.project.Project? - ): Boolean { - return requestId == preselectRequestId.get() - && canUpdateJdkUi(jdkComboBox, project) - } - - private fun canUpdateJdkUi( - jdkComboBox: JdkComboBox, - project: com.intellij.openapi.project.Project?, - ): Boolean { - if (project?.isDisposed == true) { - return false + if (!isSuitableJdk(jdk, requiredVersion)) { + jdk = findSuitableJdk(requiredVersion, sdkModel) ?: jdk } - return jdkComboBox.isDisplayable || jdkComboBox.parent != null + jdksLoaded = true } - private fun invalidatePreselectRequests() { - preselectRequestId.incrementAndGet() - } - - private fun createFallbackUiScope(disposable: CheckedDisposable): CoroutineScope { - val scope = CoroutineScope(SupervisorJob() + Dispatchers.EDT + ModalityState.any().asContextElement()) - Disposer.register(disposable) { - scope.cancel() + private fun refreshJdks(jdkComboBox: JdkComboBox, requiredVersion: ParsedJavaVersion) { + reloadJdks(requiredVersion) + jdkComboBox.reloadModel() + if (jdkComboBox.selectedJdk != jdk) { + jdkComboBox.selectedJdk = jdk } - return scope + updateSelectedJdk(jdkComboBox.selectedJdk ?: jdk) } - private fun hasAnyJdks(sdksModel: ProjectSdksModel): Boolean { - return sdksModel.sdks.any { it.sdkType is JavaSdkType } - || ProjectJdkTable.getInstance().getSdksOfType(JavaSdk.getInstance()).isNotEmpty() - } + /** + * The runtime the IDE itself runs on, offered as one more JDK. + * + * It counts exactly like any other JDK -- a Java 23 is a Java 23 wherever it came from -- so it is offered only when + * its version fits the course, and that is decided by reading the version off the JDK home. Building a real [Sdk] + * for it is left to [JdkProjectSettings.setUpProjectJdk]: that goes through `SdkType.setupSdkPaths`, which scans the + * whole JDK under a modal progress and has no business running while the course dialog is up. + */ + private fun bundledJdk(requiredVersion: ParsedJavaVersion): Sdk? { + val versionInfo = bundledJdkVersion ?: return null + val requiredFeatureVersion = requiredVersion.requiredFeatureVersion + if (requiredFeatureVersion != null && versionInfo.version.feature < requiredFeatureVersion) return null - private fun syncSdksModelWithJdkTable(sdksModel: ProjectSdksModel) { - val modelJdks = sdksModel.sdks - .filter { it.sdkType is JavaSdkType } - .map { it.name to it.homePath } - .toSet() - - ProjectJdkTable.getInstance().getSdksOfType(JavaSdk.getInstance()).forEach { sdk -> - val sdkKey = sdk.name to sdk.homePath - if (sdkKey !in modelJdks) { - LOG.info("Adding table JDK to SDK model: ${sdk.name}") - sdksModel.addSdk(sdk) - } - } + val homePath = PathManager.getBundledRuntimePath() + return ProjectJdkImpl(versionInfo.suggestedName(), JavaSdk.getInstance(), homePath, versionInfo.version.toString()) } - private data class PreselectJdkResult( - val suitableJdk: Sdk?, - val loadingState: JdkLoadingState, - val loadingError: String? - ) { - companion object { - fun failed(message: String): PreselectJdkResult = - PreselectJdkResult( - suitableJdk = null, - loadingState = JdkLoadingState.FAILED, - loadingError = message - ) - } + private fun updateSelectedJdk(selectedJdk: Sdk?) { + if (jdk == selectedJdk) return + jdk = selectedJdk + LOG.info("Selected JDK: ${selectedJdk?.name} (${selectedJdk?.versionString})") + notifyListeners() } override fun validate(course: Course?, courseLocation: String?): SettingsValidationResult { - LOG.info("validate called: componentsInitialized=$componentsInitialized, loadingState=$loadingState, jdk=$jdk") - - // If UI components haven't been initialized yet or JDK is still loading, return Pending to avoid false errors - if (!componentsInitialized || loadingState == JdkLoadingState.LOADING) { - LOG.info("componentsInitialized is false or loadingState is LOADING, returning Pending: $loadingState, $jdk") - return SettingsValidationResult.Pending - } - - // If JDK loading previously failed but now a JDK is available, reset the error state - if (loadingState == JdkLoadingState.FAILED) { - // First check if user already selected a JDK in the combobox (e.g., downloaded one) - if (jdk != null) { - LOG.info("loadingState is FAILED but jdk is already selected: $jdk, resetting to LOADED") - loadingState = JdkLoadingState.LOADED - loadingError = null - } - else { - // Check ProjectJdkTable as fallback (for JDKs added via Settings) - val jdksInTable = ProjectJdkTable.getInstance().getSdksOfType(JavaSdk.getInstance()) - LOG.info("loadingState is FAILED, jdk is null, checking ProjectJdkTable: found ${jdksInTable.size} JDKs") - if (jdksInTable.isNotEmpty()) { - // JDKs are now available - user added them via Settings - loadingState = JdkLoadingState.LOADED - loadingError = null - jdk = findSuitableJdkFromTable(course?.let { minJvmSdkVersion(it) } ?: JavaVersionNotProvided) - LOG.info("Auto-selected JDK from table: $jdk") - } - else { - val errorMsg = loadingError ?: EduJVMBundle.message("error.jdk.loading.failed", ENVIRONMENT_CONFIGURATION_LINK_JAVA) - LOG.info("Returning FAILED state with error: $errorMsg") - return SettingsValidationResult.Ready(ValidationMessage(errorMsg, ENVIRONMENT_CONFIGURATION_LINK_JAVA)) - } - } - } - - fun ready(messageId: String, vararg additionalSubstitution: String): SettingsValidationResult { - val message = EduJVMBundle.message(messageId, *additionalSubstitution) - - return SettingsValidationResult.Ready(ValidationMessage(message, ENVIRONMENT_CONFIGURATION_LINK_JAVA)) - } - course ?: return super.validate(null, courseLocation) - // compare the version of the selected jdk to the minimum version required by the course - val selectedJavaVersion = ParsedJavaVersion.fromJavaSdkVersionString(jdk?.versionString) - val courseJavaVersion = minJvmSdkVersion(course) - + val courseJavaVersion = requiredJdkVersion(course) if (courseJavaVersion is JavaVersionParseFailed) { return ready("error.unsupported.java.version", courseJavaVersion.versionAsText) } - if (selectedJavaVersion is JavaVersionParseFailed) { - return ready("failed.determine.java.version", selectedJavaVersion.versionAsText) - } - if (selectedJavaVersion == JavaVersionNotProvided) { - return if (courseJavaVersion == JavaVersionNotProvided) { - ready("error.no.jdk") - } - else { - ready("error.no.jdk.need.at.least", (courseJavaVersion as JavaVersionParseSuccess).javaSdkVersion.description) - } + // The dialog validates before it asks for the components, and reading the JDK table costs nothing + if (!jdksLoaded) { + reloadJdks(courseJavaVersion) } - if (courseJavaVersion == JavaVersionNotProvided) { + if (isSuitableJdk(jdk, courseJavaVersion)) { return SettingsValidationResult.OK } - selectedJavaVersion as JavaVersionParseSuccess - courseJavaVersion as JavaVersionParseSuccess + jdkDownloadPlanned(courseJavaVersion)?.let { return it } - return if (selectedJavaVersion isAtLeast courseJavaVersion) { - SettingsValidationResult.OK - } - else { - ready("error.old.java", courseJavaVersion.javaSdkVersion.description, selectedJavaVersion.javaSdkVersion.description) - } + // Downloading is switched off, so the learner has to install the JDK themselves. Which one is the only thing worth + // saying: nothing else is selectable, so there is no "your JDK is too old" case left to report. + val requiredVersion = courseJavaVersion as? JavaVersionParseSuccess ?: return ready("error.no.jdk") + return ready("error.no.required.jdk", requiredVersion.javaSdkVersion.description) + } + + private fun ready(messageId: String, vararg additionalSubstitution: String): SettingsValidationResult { + val message = EduJVMBundle.message(messageId, *additionalSubstitution) + return SettingsValidationResult.Ready(ValidationMessage(message, ENVIRONMENT_CONFIGURATION_LINK_JAVA)) + } + + /** + * The JDK a course requires is downloaded automatically while the project is generated + * (see [JdkProjectSettings.setUpProjectJdk]), so its absence is a warning the learner can start the course with + * rather than an error they have to fix in the IDE settings by hand. + * + * Returns `null` when downloading is not an option, and the caller has to report a real error instead. + */ + private fun jdkDownloadPlanned(courseJavaVersion: ParsedJavaVersion): SettingsValidationResult? { + if (courseJavaVersion !is JavaVersionParseSuccess) return null + if (!JdkAutoInstaller.isAvailable()) return null + + val message = EduJVMBundle.message("jdk.will.be.downloaded", courseJavaVersion.javaSdkVersion.description) + return SettingsValidationResult.ReadyWithWarning(ValidationMessage(message, type = ValidationMessageType.WARNING)) } /** - * This is the minimum JDK version that we allow to use for the course. - * Basically, it is taken from environment settings, but for Java courses it is specified explicitly in [Course.languageVersion] + * The JDK version this course has to be opened with. Taken from the environment settings, except for Hyperskill + * courses, which all pin the same version (see [Course.requiredJdkVersion]). */ - protected open fun minJvmSdkVersion(course: Course): ParsedJavaVersion = course.minJvmSdkVersion + protected open fun requiredJdkVersion(course: Course): ParsedJavaVersion = course.requiredJdkVersion override fun getSettings(): JdkProjectSettings = JdkProjectSettings(sdkModel, jdk) companion object { - fun findBundledJdk(model: ProjectSdksModel): BundledJdkInfo? { - val bundledJdkPath = PathManager.getBundledRuntimePath() - // It's possible IDE doesn't have bundled jdk. - // For example, IDE loaded by gradle-intellij-plugin doesn't have bundled jdk - if (!File(bundledJdkPath).exists()) return null - // Try to find existing bundled jdk added by the plugin on previous course creation or by user - val sdk = model.projectSdks.values.find { it.homePath == bundledJdkPath } - return BundledJdkInfo(bundledJdkPath, sdk) - } fun findSuitableJdk(courseSdkVersion: ParsedJavaVersion, sdkModel: ProjectSdksModel): Sdk? { - val jdks = sdkModel.sdks.filter { it.sdkType == JavaSdk.getInstance() } - - if (courseSdkVersion !is JavaVersionParseSuccess) { - return jdks.firstOrNull() - } - - return jdks.find { - val jdkVersion = ParsedJavaVersion.fromJavaSdkVersionString(it.versionString) - if (jdkVersion is JavaVersionParseSuccess) { - jdkVersion isAtLeast courseSdkVersion - } - else { - false - } - } + return chooseSuitableJdk(sdkModel.sdks.filter { it.sdkType == JavaSdk.getInstance() }, courseSdkVersion) } /** - * Fallback method to find suitable JDK directly from ProjectJdkTable - * when ProjectSdksModel is empty (e.g., when course.project is null in Browse Courses dialog). + * Finds a suitable JDK directly in [ProjectJdkTable], for the callers that have no [ProjectSdksModel] at hand: + * project generation and [ProjectJdkRepair]. */ fun findSuitableJdkFromTable(courseSdkVersion: ParsedJavaVersion): Sdk? { - val jdks = ProjectJdkTable.getInstance().getSdksOfType(JavaSdk.getInstance()) - - if (courseSdkVersion !is JavaVersionParseSuccess) { - return jdks.firstOrNull() - } - - return jdks.find { - val jdkVersion = ParsedJavaVersion.fromJavaSdkVersionString(it.versionString) - if (jdkVersion is JavaVersionParseSuccess) { - jdkVersion isAtLeast courseSdkVersion - } - else { - false - } - } + return chooseSuitableJdk(ProjectJdkTable.getInstance().getSdksOfType(JavaSdk.getInstance()), courseSdkVersion) } - } - data class BundledJdkInfo(val path: String, val existingSdk: Sdk?) + private fun chooseSuitableJdk(jdks: List, courseSdkVersion: ParsedJavaVersion): Sdk? = + jdks.firstOrNull { isSuitableJdk(it, courseSdkVersion) } - /** - * Perform potentially slow checks and VFS access off the EDT so that UI selection/painting - * of the JDK combo box does not trigger SlowOperations violations on the UI thread. - */ - private fun prewarmSdkValidation(sdk: Sdk?) { - if (sdk == null) return - val homePath = sdk.homePath ?: return - // Touch VFS for the SDK home directory in BGT - try { - val lfs = LocalFileSystem.getInstance() - lfs.refreshAndFindFileByPath(homePath) - } - catch (_: Throwable) { - // best-effort pre-warm; ignore failures + /** + * Whether [jdk] is the one a course requiring [courseSdkVersion] has to be opened with. + * + * A JDK whose home directory is gone never counts: [ProjectJdkTable] keeps an entry after its JDK has been + * uninstalled, and it still reports the version string it was registered with. + */ + fun isSuitableJdk(jdk: Sdk?, courseSdkVersion: ParsedJavaVersion): Boolean { + jdk ?: return false + if (!jdk.hasExistingHome) return false + return matchesRequiredVersion(jdk.releaseFeatureVersion, courseSdkVersion) } - // Trigger common queries that are used by renderers off-EDT to populate caches if any - try { - // Access version string (may compute using filesystem) - @Suppress("UNUSED_VARIABLE") - val ignoredVersionString = sdk.versionString - // Access VirtualFile home directory through concrete impl to trigger internal resolution - if (sdk is ProjectJdkImpl) { - @Suppress("UNUSED_VARIABLE") - val ignoredHomeDirectory = sdk.homeDirectory - } - // Validate SDK path using SdkType logic off-EDT (renderers call this on EDT) - val sdkTypeId: SdkTypeId = sdk.sdkType - if (sdkTypeId is SdkType) { - @Suppress("UNUSED_VARIABLE") - val ignoredHasValidPath = sdkTypeId.sdkHasValidPath(sdk) - } - } - catch (_: Throwable) { - // best-effort pre-warm; ignore failures + /** + * Whether a JDK of [featureVersion] is the one [courseSdkVersion] asks for. + * + * The course pins its JDK rather than setting a lower bound: the generated Gradle scripts derive the Java + * toolchain from the JDK the daemon runs on, so a newer one quietly changes what the learner's code is compiled + * against, while Hyperskill's own tests run on the pinned version. A course stating no version takes any release + * JDK, as it always did. + */ + private fun matchesRequiredVersion(featureVersion: Int?, courseSdkVersion: ParsedJavaVersion): Boolean { + val requiredFeatureVersion = courseSdkVersion.requiredFeatureVersion ?: return featureVersion != null + return featureVersion == requiredFeatureVersion } + + private val ParsedJavaVersion.requiredFeatureVersion: Int? + get() = (this as? JavaVersionParseSuccess)?.javaSdkVersion?.featureVersion + + private val Sdk.systemIndependentHome: String? + get() = homePath?.let { FileUtil.toSystemIndependentName(it) } } } diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkProjectSettings.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkProjectSettings.kt index 92a820a31..9c15b3c9e 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkProjectSettings.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/JdkProjectSettings.kt @@ -2,7 +2,6 @@ package org.hyperskill.academy.jvm import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.diagnostic.logger -import com.intellij.openapi.options.ConfigurationException import com.intellij.openapi.project.Project import com.intellij.openapi.project.ProjectManager import com.intellij.openapi.projectRoots.JavaSdk @@ -15,6 +14,7 @@ import com.intellij.openapi.roots.LanguageLevelProjectExtension import com.intellij.openapi.roots.ProjectRootManager import com.intellij.openapi.roots.ui.configuration.ProjectStructureConfigurable import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel +import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.LocalFileSystem import org.hyperskill.academy.learning.* import org.hyperskill.academy.learning.DefaultSettingsUtils.findPath @@ -29,37 +29,104 @@ open class JdkProjectSettings(val model: ProjectSdksModel, val jdk: Sdk?) : EduP course: Course, getJdk: JdkProjectSettings.() -> Sdk? = { jdk } ): Sdk? { - val jdk = getJdk() - - // Only apply the model if the selected JDK is NOT already in ProjectJdkTable. - // When using ProjectSdksModel from ProjectStructureConfigurable (e.g., from JdkLanguageSettings), - // the model already contains existing SDKs. Calling apply() would try to add them again, - // causing SymbolicIdAlreadyExistsException in IntelliJ 2025.3+. - val jdkExistsInTable = jdk != null && ProjectJdkTable.getInstance().findJdk(jdk.name) != null - if (!jdkExistsInTable) { - try { - model.apply() - } - catch (e: ConfigurationException) { - LOG.error(e) - } - catch (e: RuntimeException) { - // SymbolicIdAlreadyExistsException may still occur in edge cases - LOG.warn("Failed to apply SDK model: ${e.message}") - } + // Providing a JDK reaches deep into the platform -- it may scan a JDK home under a modal progress, or download a + // whole JDK -- and anything thrown there used to cost the project its language level as well as its JDK, because + // both are written by the same write action below. + val jdk = try { + ensureSuitableJdk(project, course, getJdk())?.let { registerJdk(it) } + } + catch (e: Throwable) { + LOG.warn("Failed to provide a JDK for ${course.name}", e) + null } - runWriteAction { - ProjectRootManager.getInstance(project).projectSdk = jdk - addAnnotations(ProjectRootManager.getInstance(project).projectSdk?.sdkModificator) - val sdkVersion = course.minJvmSdkVersion + return runWriteAction { + // A project stores the *name* of its JDK and resolves it back through `ProjectJdkTable`, so only a registered + // JDK is worth storing -- and `null` never is: [ProjectJdkRepair] runs concurrently on a freshly generated + // project, and overwriting the JDK it just installed with nothing is how a course ends up with "No SDK". + if (jdk != null) { + ProjectRootManager.getInstance(project).projectSdk = jdk + } + val sdkVersion = course.requiredJdkVersion if (sdkVersion is JavaVersionParseSuccess) { LanguageLevelProjectExtension.getInstance(project).languageLevel = sdkVersion.javaSdkVersion.maxLanguageLevel } + // Annotations are a nicety: `SdkModificator.commitChanges` may fail on an SDK backed by the workspace model, and + // that must not cost the project the JDK and the language level that are already committed above. + try { + addAnnotations(ProjectRootManager.getInstance(project).projectSdk?.sdkModificator) + } + catch (e: Throwable) { + LOG.warn("Failed to attach JDK annotations", e) + } + // Not `jdk`: the JDK the project ended up with is what the caller has to set the Gradle JVM from + ProjectRootManager.getInstance(project).projectSdk + } + } + + /** + * Returns a JDK the course can actually be built and checked with. + * + * [selectedJdk] may be missing or too old: the course dialog lets the learner start anyway when the required JDK + * can be downloaded, and it is not shown at all on some paths. Look for an installed JDK that fits first, and + * download the required one only when there is none, so the learner never lands on a project whose very first + * check fails with "please update your SDK". + */ + private fun ensureSuitableJdk(project: Project, course: Course, selectedJdk: Sdk?): Sdk? { + // Courses that do not state a JVM version take whatever the learner picked, as they always did + val requiredVersion = course.requiredJdkVersion as? JavaVersionParseSuccess ?: return selectedJdk + if (JdkLanguageSettings.isSuitableJdk(selectedJdk, requiredVersion)) return selectedJdk + + val installedJdk = JdkLanguageSettings.findSuitableJdk(requiredVersion, model) + ?: JdkLanguageSettings.findSuitableJdkFromTable(requiredVersion) + if (installedJdk != null) { + LOG.info("Replaced ${selectedJdk?.name} with already installed ${installedJdk.name} required by the course") + return installedJdk + } + + val downloadedJdk = JdkAutoInstaller.installJdk(project, requiredVersion.javaSdkVersion) + if (downloadedJdk == null) { + LOG.warn("Failed to provide JDK ${requiredVersion.javaSdkVersion.description} required by the course, falling back to ${selectedJdk?.name}") + return selectedJdk + } + return downloadedJdk + } + + /** + * Returns [jdk] as an SDK the whole IDE knows about, or `null` when it could not be registered. + * + * The course dialog offers candidates that are not registered anywhere yet -- the IDE's own runtime and a JDK being + * downloaded, for two -- because building a real SDK goes through `SdkType.setupSdkPaths`, which scans the JDK home + * under a modal progress and must not run while the dialog is up. This is where such a candidate becomes a real SDK. + * + * Failure is reported as `null` rather than by handing [jdk] back: a project resolves its JDK by name through + * [ProjectJdkTable], so an SDK that never reached that table reads as "no SDK" everywhere in the IDE, and storing it + * only hides the failure. + * + * `ProjectSdksModel.apply` is deliberately not used for this: the model also holds copies of the SDKs that are + * already in [ProjectJdkTable], and committing those again throws `SymbolicIdAlreadyExistsException` on 2025.3+. + */ + private fun registerJdk(jdk: Sdk): Sdk? { + val homePath = jdk.homePath ?: return null + findRegisteredJdk(homePath)?.let { return it } + + val registered = try { + SdkConfigurationUtil.createAndAddSDK(homePath, JavaSdk.getInstance()) + } + catch (e: Throwable) { + LOG.warn("Failed to register the JDK located at $homePath", e) + null + } + // `SdkConfigurationUtil` reports most of its failures by returning `null` after a warning of its own, and it may + // well have added the JDK and only failed to scan its roots, so look it up once more before giving up. + return registered ?: findRegisteredJdk(homePath).also { + if (it == null) LOG.warn("JDK located at $homePath was not added to the JDK table") } - return jdk } + private fun findRegisteredJdk(homePath: String): Sdk? = + ProjectJdkTable.getInstance().getSdksOfType(JavaSdk.getInstance()).find { FileUtil.pathsEqual(it.homePath, homePath) } + private fun addAnnotations(sdkModificator: SdkModificator?) { sdkModificator?.apply { JavaSdkImpl.attachJdkAnnotations(this) diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/ParsedJavaVersion.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/ParsedJavaVersion.kt index 8a0de2540..c823d3ef7 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/ParsedJavaVersion.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/ParsedJavaVersion.kt @@ -37,17 +37,6 @@ sealed class ParsedJavaVersion { return JavaVersionParseSuccess(javaSdkVersion) } - - fun fromJavaSdkVersionString(versionString: String?): ParsedJavaVersion { - versionString ?: return JavaVersionNotProvided - val parsedVersion = JavaSdkVersion.fromVersionString(versionString) - return if (parsedVersion == null) { - JavaVersionParseFailed(versionString) - } - else { - JavaVersionParseSuccess(parsedVersion) - } - } } } diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/ProjectJdkRepair.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/ProjectJdkRepair.kt new file mode 100644 index 000000000..59da433a3 --- /dev/null +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/ProjectJdkRepair.kt @@ -0,0 +1,57 @@ +package org.hyperskill.academy.jvm + +import com.intellij.openapi.application.edtWriteAction +import com.intellij.openapi.application.readAction +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.project.Project +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.roots.ProjectRootManager +import org.hyperskill.academy.learning.StudyTaskManager + +private val LOG = logger() + +/** + * Restores the JDK of a course project that was generated earlier. + * + * A project stores the *name* of its JDK, not the JDK itself, so uninstalling that JDK leaves the project pointing at + * nothing. The platform then guesses a replacement from the name alone -- an entry named `24` that actually held Java + * 23 makes it offer to download JDK 24 -- and the learner ends up on a JDK the course checker rejects, or on none at + * all. The course knows the version it needs, so the plugin restores that one instead: an already installed JDK when + * there is a suitable one, a downloaded one otherwise. + */ +object ProjectJdkRepair { + + suspend fun ensureProjectJdk(project: Project) { + val course = StudyTaskManager.getInstance(project).course ?: return + val requiredVersion = course.requiredJdkVersion as? JavaVersionParseSuccess ?: return + + val currentJdk = readAction { ProjectRootManager.getInstance(project).projectSdk } + if (JdkLanguageSettings.isSuitableJdk(currentJdk, requiredVersion)) return + + val jdk = suitableJdk(project, currentJdk, requiredVersion) ?: return + edtWriteAction { + // Course generation may have set a JDK while the download above was running: a post-startup activity is not + // sequenced after it, so on a freshly generated project the two run at the same time. + val installedJdk = ProjectRootManager.getInstance(project).projectSdk + if (JdkLanguageSettings.isSuitableJdk(installedJdk, requiredVersion)) { + LOG.info("Project JDK ${installedJdk?.name} was set while ${jdk.name} was being prepared, keeping it") + return@edtWriteAction + } + LOG.info("Replaced project JDK ${installedJdk?.name} with ${jdk.name} required by ${course.name}") + ProjectRootManager.getInstance(project).projectSdk = jdk + } + } + + private suspend fun suitableJdk(project: Project, currentJdk: Sdk?, requiredVersion: JavaVersionParseSuccess): Sdk? { + val installedJdk = JdkLanguageSettings.findSuitableJdkFromTable(requiredVersion) + if (installedJdk != null) return installedJdk + + LOG.info("No installed JDK fits ${requiredVersion.javaSdkVersion.description}, downloading it") + val downloadedJdk = JdkAutoInstaller.installJdkInBackground(project, requiredVersion.javaSdkVersion) + if (downloadedJdk == null) { + // Nothing else to do: the checker will tell the learner to update the SDK by hand + LOG.warn("Failed to provide JDK ${requiredVersion.javaSdkVersion.description}, keeping ${currentJdk?.name}") + } + return downloadedJdk + } +} diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt index 11774a31f..7c848b869 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/GradleStartupActivity.kt @@ -3,13 +3,16 @@ package org.hyperskill.academy.jvm.gradle import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.application.writeAction import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.progress.ProcessCanceledException import com.intellij.openapi.project.DumbService import com.intellij.openapi.project.Project import com.intellij.openapi.project.guessProjectDir import com.intellij.openapi.startup.ProjectActivity import com.intellij.openapi.vfs.VfsUtil import com.intellij.openapi.vfs.VfsUtilCore +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.suspendCancellableCoroutine +import org.hyperskill.academy.jvm.ProjectJdkRepair import org.hyperskill.academy.jvm.gradle.generation.EduGradleUtils import org.hyperskill.academy.jvm.gradle.generation.EduGradleUtils.setupGradleProject import org.hyperskill.academy.jvm.gradle.generation.EduGradleUtils.updateGradleSettings @@ -31,6 +34,22 @@ class GradleStartupActivity : ProjectActivity { if (EduGradleUtils.isConfiguredWithGradle(project)) { val buildScriptMigrated = migrateScript(project, GradleConstants.BUILD_GRADLE) val settingsMigrated = migrateScript(project, GradleConstants.SETTINGS_GRADLE) + // Before `updateGradleSettings`, which derives the Gradle JVM from the project one. + // Nothing here may escape: the platform rethrows a `CancellationException` out of a startup activity without + // logging anything, and `ProcessCanceledException` is one, so a JDK scan cancelled deep inside the platform + // would silently skip everything below and leave the project without Gradle settings and without a trace. + try { + ProjectJdkRepair.ensureProjectJdk(project) + } + catch (e: ProcessCanceledException) { + LOG.warn("Ensuring the project JDK was cancelled") + } + catch (e: CancellationException) { + throw e + } + catch (e: Throwable) { + LOG.warn("Failed to ensure the project JDK", e) + } updateGradleSettings(project) if (buildScriptMigrated || settingsMigrated) { // The import triggered by project opening has already read the outdated script, diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/checker/GradleEnvironmentChecker.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/checker/GradleEnvironmentChecker.kt index da52812eb..434fb8730 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/checker/GradleEnvironmentChecker.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/checker/GradleEnvironmentChecker.kt @@ -8,7 +8,7 @@ import com.intellij.openapi.roots.ui.configuration.ProjectSettingsService import org.hyperskill.academy.jvm.gradle.GradleCourseRefresher import org.hyperskill.academy.jvm.gradle.generation.EduGradleUtils import org.hyperskill.academy.jvm.hyperskillJdkVersion -import org.hyperskill.academy.jvm.isAtLeastHyperskillJdkVersion +import org.hyperskill.academy.jvm.isHyperskillJdkVersion import org.hyperskill.academy.jvm.messages.EduJVMBundle import org.hyperskill.academy.learning.EduNames.ENVIRONMENT_CONFIGURATION_LINK_GRADLE import org.hyperskill.academy.learning.RefreshCause @@ -27,7 +27,7 @@ open class GradleEnvironmentChecker : EnvironmentChecker() { override fun getEnvironmentError(project: Project, task: Task): CheckResult? { val sdk = ProjectRootManager.getInstance(project).projectSdk ?: return noSdkConfiguredResult - if (task.course is HyperskillCourse && !sdk.isAtLeastHyperskillJdkVersion()) { + if (task.course is HyperskillCourse && !sdk.isHyperskillJdkVersion()) { return getIncorrectHyperskillJDKResult(project) } diff --git a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/generation/EduGradleUtils.kt b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/generation/EduGradleUtils.kt index 72f05ec8e..2ec663c96 100644 --- a/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/generation/EduGradleUtils.kt +++ b/intellij-plugin/hs-jvm-core/src/org/hyperskill/academy/jvm/gradle/generation/EduGradleUtils.kt @@ -13,8 +13,9 @@ import com.intellij.openapi.projectRoots.Sdk import com.intellij.openapi.roots.ProjectRootManager import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VirtualFileManager -import com.intellij.util.lang.JavaVersion import org.hyperskill.academy.jvm.gradle.GradleWrapperListener +import org.hyperskill.academy.jvm.hasExistingHome +import org.hyperskill.academy.jvm.releaseFeatureVersion import org.hyperskill.academy.jvm.messages.EduJVMBundle import org.hyperskill.academy.learning.CourseInfoHolder import org.hyperskill.academy.learning.StudyTaskManager @@ -204,36 +205,19 @@ object EduGradleUtils { */ private fun findCompatibleJdk(maxFeatureVersion: Int): Sdk? { return ProjectJdkTable.getInstance().getSdksOfType(JavaSdk.getInstance()) + // An uninstalled JDK is still listed in the table and still reports its version, but running the Gradle daemon + // on it fails with `Invalid Gradle JDK configuration found` + .filter { it.hasExistingHome } .mapNotNull { sdk -> sdk.releaseFeatureVersion?.let { sdk to it } } .filter { (_, featureVersion) -> featureVersion in MIN_SUPPORTED_JDK_FEATURE_VERSION..maxFeatureVersion } .maxByOrNull { (_, featureVersion) -> featureVersion } ?.first } - /** - * Feature version of a JDK, or `null` if it is a pre-release build. - * - * The Gradle integration refuses to run on EA and project builds (`26-ea`, `23-valhalla`, ...) and falls back - * to an arbitrary installation instead, so such JDKs must never be offered as the Gradle JVM. - * - * Note that [JavaSdk.getVersion] is deliberately not used here: `JavaSdkVersion` has no entry for a JDK newer - * than the one the IDE knows about, so it reports `null` for it, and such a JDK would be silently skipped. - */ - private val Sdk.releaseFeatureVersion: Int? - get() { - val version = versionString ?: return null - if (PRE_RELEASE_JDK_VERSION.containsMatchIn(version)) return null - val javaVersion = JavaVersion.tryParse(version) ?: return null - return if (javaVersion.ea) null else javaVersion.feature - } - private val Sdk.javaSdkVersion: JavaSdkVersion? get() = JavaSdk.getInstance().getVersion(this) private const val MIN_SUPPORTED_JDK_FEATURE_VERSION = 8 - /** Matches a feature version followed by a pre-release qualifier: `26-ea`, `25-internal`, `23-valhalla`. */ - private val PRE_RELEASE_JDK_VERSION = Regex("""\d+(\.\d+)*-[A-Za-z]""") - fun updateGradleSettings(project: Project) { val projectBasePath = project.basePath ?: error("Failed to find base path for the project during gradle project setup") val sdk = ProjectRootManager.getInstance(project).projectSdk diff --git a/intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/JdkSelectionTest.kt b/intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/JdkSelectionTest.kt new file mode 100644 index 000000000..e4320add5 --- /dev/null +++ b/intellij-plugin/hs-jvm-core/testSrc/org/hyperskill/academy/jvm/JdkSelectionTest.kt @@ -0,0 +1,164 @@ +package org.hyperskill.academy.jvm + +import com.intellij.openapi.projectRoots.JavaSdk +import com.intellij.openapi.projectRoots.JavaSdkVersion +import com.intellij.openapi.projectRoots.Sdk +import com.intellij.openapi.projectRoots.impl.ProjectJdkImpl +import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.util.io.FileUtil +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import java.nio.file.Path + +/** + * Covers which JDK is offered to a learner for a course that requires a particular Java version. + * + * A course pins its JDK instead of setting a lower bound, so "newer" is as unusable here as "older". + */ +class JdkSelectionTest : BasePlatformTestCase() { + + private lateinit var jdkHomes: Path + + override fun setUp() { + super.setUp() + jdkHomes = FileUtil.createTempDirectory("jdk-selection-test", null, true).toPath() + } + + fun `test the required version is offered`() { + val model = modelOf("17.0.9", "25.0.1", "23.0.2", "21.0.5") + + assertSelected("23.0.2", JdkLanguageSettings.findSuitableJdk(required(JavaSdkVersion.JDK_23), model)) + } + + fun `test a newer jdk is not offered instead of the required one`() { + // The generated Gradle scripts derive the Java toolchain from the JDK the daemon runs on, so a newer JDK compiles + // the learner's code against something the course was not written for + val model = modelOf("24.0.2", "25.0.1", "26.0.1") + + assertNull(JdkLanguageSettings.findSuitableJdk(required(JavaSdkVersion.JDK_23), model)) + } + + fun `test outdated jdks are not offered`() { + val model = modelOf("17.0.9", "21.0.5", "11.0.22") + + assertNull(JdkLanguageSettings.findSuitableJdk(required(JavaSdkVersion.JDK_23), model)) + } + + fun `test pre-release builds of the required version are not offered`() { + // The Gradle integration refuses to run on a pre-release JDK + val model = modelOf("23-ea", "23-valhalla", "23.0.2") + + assertSelected("23.0.2", JdkLanguageSettings.findSuitableJdk(required(JavaSdkVersion.JDK_23), model)) + } + + fun `test nothing is offered when only pre-release builds of the required version are installed`() { + val model = modelOf("23-ea", "23-internal") + + assertNull(JdkLanguageSettings.findSuitableJdk(required(JavaSdkVersion.JDK_23), model)) + } + + fun `test any jdk is offered when the course requires no particular version`() { + // Which one is picked is not defined: `ProjectSdksModel` does not keep its SDKs in any particular order + val model = modelOf("17.0.9", "11.0.22") + + assertNotNull(JdkLanguageSettings.findSuitableJdk(JavaVersionNotProvided, model)) + } + + fun `test uninstalled jdks are not offered`() { + // The learner deleted JDK 23 from disk: its entry survives in the table and still reports version 23.0.2, + // but the project it produces cannot be built + val installed = jdk("23.0.2") + val model = ProjectSdksModel().apply { + addSdk(uninstalledJdk("23.0.2")) + addSdk(installed) + } + + assertEquals(installed.homePath, JdkLanguageSettings.findSuitableJdk(required(JavaSdkVersion.JDK_23), model)?.homePath) + } + + fun `test nothing is offered when the required jdk was uninstalled`() { + val model = ProjectSdksModel().apply { + addSdk(uninstalledJdk("23.0.2")) + addSdk(jdk("21.0.5")) + } + + assertNull(JdkLanguageSettings.findSuitableJdk(required(JavaSdkVersion.JDK_23), model)) + } + + fun `test a jdk that is still being downloaded is not offered`() { + // `JdkInstaller.prepareJdkInstallation` creates the java home before the download starts, so an existing directory + // is not proof of an installed JDK + val model = ProjectSdksModel().apply { addSdk(jdkBeingDownloaded("23.0.2")) } + + assertNull(JdkLanguageSettings.findSuitableJdk(required(JavaSdkVersion.JDK_23), model)) + } + + fun `test uninstalled jdk is not offered when the course requires no particular version`() { + val model = ProjectSdksModel().apply { addSdk(uninstalledJdk("17.0.9")) } + + assertNull(JdkLanguageSettings.findSuitableJdk(JavaVersionNotProvided, model)) + } + + fun `test only the required version is suitable`() { + val required = required(JavaSdkVersion.JDK_23) + + assertTrue(JdkLanguageSettings.isSuitableJdk(jdk("23.0.2"), required)) + + assertFalse(JdkLanguageSettings.isSuitableJdk(jdk("25.0.1"), required)) + assertFalse(JdkLanguageSettings.isSuitableJdk(jdk("21.0.5"), required)) + assertFalse(JdkLanguageSettings.isSuitableJdk(jdk("1.8.0_402"), required)) + // A pre-release build of the required version is not the required version either + assertFalse(JdkLanguageSettings.isSuitableJdk(jdk("23-ea"), required)) + assertFalse(JdkLanguageSettings.isSuitableJdk(null, required)) + } + + fun `test uninstalled jdk is not suitable even when the learner picked it explicitly`() { + // Otherwise the course starts on a JDK that is not there any more instead of downloading the required one + assertFalse(JdkLanguageSettings.isSuitableJdk(uninstalledJdk("23.0.2"), required(JavaSdkVersion.JDK_23))) + assertFalse(JdkLanguageSettings.isSuitableJdk(uninstalledJdk("23.0.2"), JavaVersionNotProvided)) + } + + fun `test version of a jdk newer than the ide knows about is still parsed`() { + // `JavaSdkVersion` has no entry past the JDK the IDE was built with, so the check must not rely on it + val newerThanTheEnum = JavaSdkVersion.entries.last().featureVersion!! + 1 + + assertEquals(newerThanTheEnum, releaseFeatureVersion("$newerThanTheEnum.0.1")) + assertNull(releaseFeatureVersion("$newerThanTheEnum-ea")) + assertNull(releaseFeatureVersion(null)) + } + + private fun assertSelected(expectedVersion: String, actual: Sdk?) { + assertEquals(expectedVersion, actual?.versionString) + } + + private fun required(version: JavaSdkVersion): ParsedJavaVersion = JavaVersionParseSuccess(version) + + /** A JDK that is installed, i.e. whose home directory holds a java launcher. */ + private fun jdk(versionString: String): Sdk = sdk(versionString, javaHome("jdk-$versionString", withLauncher = true)) + + /** A JDK the learner uninstalled: the entry is still there, its home directory is not. */ + private fun uninstalledJdk(versionString: String): Sdk = sdk(versionString, jdkHomes.resolve("uninstalled-$versionString")) + + /** + * A JDK whose home directory exists but is empty, the way `JdkInstaller.prepareJdkInstallation` leaves it before the + * first byte is downloaded. + */ + private fun jdkBeingDownloaded(versionString: String): Sdk = + sdk(versionString, javaHome("downloading-$versionString", withLauncher = false)) + + private fun javaHome(name: String, withLauncher: Boolean): Path { + val home = jdkHomes.resolve(name) + FileUtil.createDirectory(home.toFile()) + if (withLauncher) { + FileUtil.createIfDoesntExist(home.resolve("bin").resolve(if (SystemInfo.isWindows) "java.exe" else "java").toFile()) + } + return home + } + + private fun sdk(versionString: String, home: Path): Sdk = + ProjectJdkImpl("JDK $versionString", JavaSdk.getInstance(), home.toString(), versionString) + + private fun modelOf(vararg versionStrings: String): ProjectSdksModel = ProjectSdksModel().apply { + versionStrings.forEach { addSdk(jdk(it)) } + } +} diff --git a/intellij-plugin/hs-sql/hs-sql-jvm/src/org/hyperskill/academy/sql/jvm/gradle/SqlJdkLanguageSettings.kt b/intellij-plugin/hs-sql/hs-sql-jvm/src/org/hyperskill/academy/sql/jvm/gradle/SqlJdkLanguageSettings.kt index f576f8240..ec92e5dc4 100644 --- a/intellij-plugin/hs-sql/hs-sql-jvm/src/org/hyperskill/academy/sql/jvm/gradle/SqlJdkLanguageSettings.kt +++ b/intellij-plugin/hs-sql/hs-sql-jvm/src/org/hyperskill/academy/sql/jvm/gradle/SqlJdkLanguageSettings.kt @@ -1,95 +1,11 @@ package org.hyperskill.academy.sql.jvm.gradle -import com.intellij.openapi.Disposable -import com.intellij.openapi.observable.util.whenItemSelected -import com.intellij.openapi.projectRoots.JavaSdk -import com.intellij.openapi.roots.ui.configuration.projectRoot.ProjectSdksModel -import com.intellij.openapi.ui.ComboBox -import com.intellij.openapi.ui.LabeledComponent -import com.intellij.openapi.util.CheckedDisposable -import com.intellij.openapi.util.UserDataHolder import org.hyperskill.academy.jvm.JdkLanguageSettings import org.hyperskill.academy.jvm.JdkProjectSettings -import org.hyperskill.academy.learning.courseFormat.Course -import org.hyperskill.academy.sql.core.EduSqlBundle -import java.awt.BorderLayout -import java.awt.Component -import java.util.* -import javax.swing.* class SqlJdkLanguageSettings : JdkLanguageSettings() { private var testLanguage: SqlTestLanguage? = null - // Note: setupProjectSdksModel is intentionally not overridden here. - // Adding SDK via model.addSdk() on EDT is prohibited in IntelliJ 2025.3+. - // Bundled JDK is added in addBundledJdkIfNeeded() which is called from background thread. - - override fun addBundledJdkIfNeeded(model: ProjectSdksModel) { - val (jdkPath, sdk) = findBundledJdk(model) ?: return - if (sdk == null) { - model.addSdk(JavaSdk.getInstance(), jdkPath) { - jdk = it - } - } - else { - jdk = sdk - } - } - - override fun getLanguageSettingsComponents( - course: Course, - disposable: CheckedDisposable, - context: UserDataHolder? - ): List> { - val components = mutableListOf>() - // It doesn't make sense to show a test language component for learners since it doesn't affect course creation anyhow - - // Non-null jdk means that `setupProjectSdksModel` successfully found bundled JDK. - // So there is no reason to show JDK settings at all - if (jdk == null) { - components += super.getLanguageSettingsComponents(course, disposable, context) - } - - return components - } - - private fun createTestLanguageComponent(disposable: Disposable): LabeledComponent { - val comboBox: ComboBox = ComboBox(comboboxModel()) - val defaultTextLanguage = SqlTestLanguage.KOTLIN.takeIf { it.getLanguage() != null } ?: SqlTestLanguage.JAVA - comboBox.selectedItem = defaultTextLanguage - comboBox.renderer = object : DefaultListCellRenderer() { - override fun getListCellRendererComponent( - list: JList<*>, - value: Any?, - index: Int, - isSelected: Boolean, - cellHasFocus: Boolean - ): Component { - val component = super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus) - if (component is JLabel && value is SqlTestLanguage) { - val language = value.getLanguage() - if (language != null) { - component.text = language.displayName - component.icon = value.logo - } - } - return component - } - - } - - comboBox.whenItemSelected(disposable) { - testLanguage = it - } - - return LabeledComponent.create(comboBox, EduSqlBundle.message("hyperskill.sql.test.language"), BorderLayout.WEST) - } - - private fun comboboxModel(): ComboBoxModel { - val languages = SqlTestLanguage.values().filterTo(Vector()) { it.getLanguage() != null } - return DefaultComboBoxModel(languages) - } - override fun getSettings(): JdkProjectSettings = SqlJdkProjectSettings(sdkModel, jdk, testLanguage) } From 46982379727fae75808f031d967ca6890a4018ba Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Fri, 25 Sep 2026 11:39:53 +0300 Subject: [PATCH 4/4] fix permanent EDT deadlock when running actions from Swing listeners ActionManager.tryToExecute(..., now = true) does not just run an action: it runs a full action update session and blocks the EDT in runBlockingForActionExpand waiting for it. For an ActionUpdateThread.BGT action that update needs a read action, which deadlocks against a background VFS refresh that holds the write lock and is itself waiting to be serviced on the EDT. The cycle has no timeout, so the IDE has to be killed. Clicking Check shortly after the course files are saved on disk is enough to lose the race. CheckPanelButtonComponent decides button enablement itself, and all three actions it hosts (CheckAction, NextTaskAction, RetryAction) no-op their update() for the CheckPanel place, so the update session computes nothing we use. Skip it and call ActionUtil.performAction directly, which is what the platform's own AnActionLink does. The two course-selection call sites do rely on the action's own update(), so they keep tryToExecute and instead pass now = false. That routes through tryToExecuteSuspend, which updates via expandActionGroupSuspend without blocking the EDT, still honours presentation.isEnabled, and still performs the action on the EDT. doValidation is chained off the returned ActionCallback via invokeLater with the dialog's ModalityState, since that callback can be resolved on a background thread and doValidation touches Swing. Fixes #61 Co-Authored-By: Claude Opus 5 --- .../ui/errors/ErrorStateHyperlinkListener.kt | 13 +++++++++--- .../academy/learning/newproject/ui/utils.kt | 5 ++++- .../ui/check/CheckPanelButtonComponent.kt | 21 +++++++++++-------- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/ErrorStateHyperlinkListener.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/ErrorStateHyperlinkListener.kt index e5e644b18..c04b165e6 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/ErrorStateHyperlinkListener.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/errors/ErrorStateHyperlinkListener.kt @@ -7,6 +7,7 @@ import com.intellij.openapi.Disposable import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.application.ModalityState import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.extensions.PluginId import com.intellij.openapi.ui.DialogWrapper @@ -87,14 +88,20 @@ class ErrorStateHyperlinkListener(private val parentDisposable: Disposable) : Hy Logger.getInstance(CoursesPanel::class.java).error("$switchUILibraryAction action not found") return } + // `now = false` runs the action update off EDT (suspending) instead of blocking it in + // `runBlockingForActionExpand`, which can deadlock against a background write action. + // See https://github.com/hyperskill/hyperskill-plugin/issues/61 + val modalityState = ModalityState.stateForComponent(coursePanel) ActionManager.getInstance().tryToExecute( action, null, coursePanel, ActionPlaces.UNKNOWN, - true - ) - doValidation(coursePanel) + false + ).doWhenProcessed { + // the callback may be resolved on a background thread, and `doValidation` touches Swing + ApplicationManager.getApplication().invokeLater({ doValidation(coursePanel) }, modalityState) + } } private fun doValidation(coursePanel: CoursePanel) { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/utils.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/utils.kt index 610b57941..e623de4fe 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/utils.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/newproject/ui/utils.kt @@ -99,12 +99,15 @@ fun createHyperlinkWithContextHelp(actionWrapper: ToolbarActionWrapper): JPanel val action = actionWrapper.action val hyperlinkLabel = HyperlinkLabel(actionWrapper.text.get()) hyperlinkLabel.addHyperlinkListener { + // `now = false` runs the action update off EDT (suspending) instead of blocking it in + // `runBlockingForActionExpand`, which can deadlock against a background write action. + // See https://github.com/hyperskill/hyperskill-plugin/issues/61 ActionManager.getInstance().tryToExecute( action, null, hyperlinkLabel, BrowseCoursesDialog.ACTION_PLACE, - true + false ) } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/check/CheckPanelButtonComponent.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/check/CheckPanelButtonComponent.kt index e411838ea..64296af8d 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/check/CheckPanelButtonComponent.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/taskToolWindow/ui/check/CheckPanelButtonComponent.kt @@ -1,7 +1,10 @@ package org.hyperskill.academy.learning.taskToolWindow.ui.check -import com.intellij.openapi.actionSystem.ActionManager +import com.intellij.ide.DataManager +import com.intellij.openapi.actionSystem.ActionUiKind import com.intellij.openapi.actionSystem.AnAction +import com.intellij.openapi.actionSystem.AnActionEvent +import com.intellij.openapi.actionSystem.ex.ActionUtil import com.intellij.util.ui.JBUI import org.apache.commons.lang3.StringUtils import org.hyperskill.academy.learning.actions.ActionWithProgressIcon @@ -74,14 +77,14 @@ class CheckPanelButtonComponent private constructor() : JPanel(BorderLayout()) { this.isDefault = isDefault } if (isEnabled) { - button.addActionListener { e -> - ActionManager.getInstance().tryToExecute( - action, - null, - this, - CheckPanel.ACTION_PLACE, - true - ) + button.addActionListener { + // Use `ActionUtil.performAction` instead of `ActionManager.tryToExecute`: the latter runs a blocking action + // update session on EDT (`runBlockingForActionExpand`), which can deadlock against a background write action. + // The update is useless here anyway - enablement is already decided by `isEnabled` above. + // See https://github.com/hyperskill/hyperskill-plugin/issues/61 + val dataContext = DataManager.getInstance().getDataContext(this) + val event = AnActionEvent.createEvent(action, dataContext, null, CheckPanel.ACTION_PLACE, ActionUiKind.NONE, null) + ActionUtil.performAction(action, event) } } return button