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/gradle.properties b/gradle.properties index 2a9c5a5b1..216ec0c41 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ # supported values: 252, 253, 261, 262 environmentName=262 -pluginVersion=2026.18 +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/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/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/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/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-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-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-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/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/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 6a2638b07..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) } } } @@ -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/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/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/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/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/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/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 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/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..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.* @@ -62,17 +64,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 +120,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 +145,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 } @@ -192,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 { @@ -231,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/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-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/gradle/GradleAdditionalFilesMigrationTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/gradle/GradleAdditionalFilesMigrationTest.kt new file mode 100644 index 000000000..0370edb1e --- /dev/null +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/gradle/GradleAdditionalFilesMigrationTest.kt @@ -0,0 +1,102 @@ +package org.hyperskill.academy.learning.gradle + +import org.hyperskill.academy.learning.courseFormat.EduFile +import org.hyperskill.academy.learning.courseFormat.InMemoryBinaryContents +import org.hyperskill.academy.learning.courseFormat.InMemoryTextualContents +import org.hyperskill.academy.learning.courseFormat.InMemoryUndeterminedContents +import org.hyperskill.academy.learning.courseFormat.TextualContents +import org.hyperskill.academy.learning.courseFormat.UndeterminedContents +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class GradleAdditionalFilesMigrationTest { + + private val legacyBuildScript = """ + configure(subprojects.findAll { it.name != 'util' }) { + dependencies { + testImplementation project(':util').sourceSets.main.output + } + } + """.trimIndent() + + private val legacySettingsScript = """ + buildscript { + dependencies { + classpath "com.github.hyperskill:hs-gradle-plugin:release-SNAPSHOT" + } + } + + include 'util' + """.trimIndent() + + private fun migrate(vararg files: EduFile): List = 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-core/testSrc/org/hyperskill/academy/learning/gradle/GradleSettingsScriptMigrationTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/gradle/GradleSettingsScriptMigrationTest.kt new file mode 100644 index 000000000..7322d357a --- /dev/null +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/gradle/GradleSettingsScriptMigrationTest.kt @@ -0,0 +1,78 @@ +package org.hyperskill.academy.learning.gradle + +import org.junit.Assert.assertEquals +import org.junit.Test + +class GradleSettingsScriptMigrationTest { + + private fun migrate(content: String): String = GradleScriptMigration.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-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 edf17df03..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}. 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 c5163008d..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,17 +2,93 @@ package org.hyperskill.academy.jvm import com.intellij.openapi.project.Project import com.intellij.openapi.projectRoots.JavaSdkVersion +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 now require JDK version 23 +// Every JVM-based Hyperskill course is built and checked with JDK 23 val hyperskillJdkVersion: JavaSdkVersion = JavaSdkVersion.JDK_23 -val Course.minJvmSdkVersion: ParsedJavaVersion +/** + * 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`, ...). + * + * 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 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 + } + } + +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 +} + +/** 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 0aadf1ba0..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 @@ -17,8 +20,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,9 +32,26 @@ class GradleStartupActivity : ProjectActivity { return } if (EduGradleUtils.isConfiguredWithGradle(project)) { - val buildScriptMigrated = migrateLegacyBuildGradle(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) { + 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 +85,31 @@ class GradleStartupActivity : ProjectActivity { } /** - * Rewrites `project(':util').sourceSets.*.output` references left in build scripts of old projects. + * Applies [GradleScriptMigration] 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. + * 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 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): 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 = GradleScriptMigration.migrate(scriptName, 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 } } @@ -111,14 +128,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}" } } } 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..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 @@ -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.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 && JavaSdkVersionUtil.getJavaSdkVersion(sdk) != hyperskillJdkVersion) { + 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 a3ba69e10..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 @@ -14,6 +14,8 @@ import com.intellij.openapi.roots.ProjectRootManager import com.intellij.openapi.util.io.FileUtil import com.intellij.openapi.vfs.VirtualFileManager 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 @@ -85,9 +87,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 +171,53 @@ 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()) + // 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 } private val Sdk.javaSdkVersion: JavaSdkVersion? get() = JavaSdk.getInstance().getVersion(this) + private const val MIN_SUPPORTED_JDK_FEATURE_VERSION = 8 + 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/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/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) } 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() } }