From ce1cbdc06516983f0bdba72983306427cda37e0e Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Wed, 20 May 2026 12:31:54 +0300 Subject: [PATCH 1/3] fix github actions build --- intellij-plugin/hs-features/ai-debugger-core/build.gradle.kts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/intellij-plugin/hs-features/ai-debugger-core/build.gradle.kts b/intellij-plugin/hs-features/ai-debugger-core/build.gradle.kts index 07e81447e..dc7f490dc 100644 --- a/intellij-plugin/hs-features/ai-debugger-core/build.gradle.kts +++ b/intellij-plugin/hs-features/ai-debugger-core/build.gradle.kts @@ -18,10 +18,12 @@ dependencies { api(libs.educational.ml.library.core) { excludeKotlinDeps() exclude(group = "net.java.dev.jna") + exclude(group = "it.unimi.dsi", module = "fastutil-core") } api(libs.educational.ml.library.debugger) { excludeKotlinDeps() exclude(group = "net.java.dev.jna") + exclude(group = "it.unimi.dsi", module = "fastutil-core") } compileOnly(libs.kotlinx.serialization) { @@ -29,4 +31,4 @@ dependencies { } testImplementation(project(":intellij-plugin:hs-core", "testOutput")) -} \ No newline at end of file +} From d04255ff9e46d6ec034c8fd8a435df11779d5dbf Mon Sep 17 00:00:00 2001 From: Oleksandr Liemiahov Date: Fri, 22 May 2026 13:08:35 +0300 Subject: [PATCH 2/3] fix github actions build --- .../format/remote/RemoteEduTaskYamlMixin.kt | 2 +- .../learning/framework/storage/UserChanges.kt | 2 +- .../academy/learning/SolutionLoaderBase.kt | 42 ++++-- .../academy/learning/VirtualFileExt.kt | 11 +- .../learning/courseFormat/ext/StudyItemExt.kt | 5 +- .../learning/courseFormat/ext/TaskExt.kt | 11 +- .../impl/FrameworkLessonManagerImpl.kt | 140 +++++++++++------- .../framework/impl/FrameworkStorage.kt | 18 ++- .../learning/framework/impl/UserChanges.kt | 20 ++- .../learning/handlers/handlersUtils.kt | 45 ++++-- .../academy/learning/stepik/api/StepikAPI.kt | 1 + .../HyperskillSubmissionFactory.kt | 1 - .../academy/learning/yaml/YamlLoader.kt | 8 +- .../student/StudentTaskChangeApplier.kt | 3 +- .../yaml/YamlErrorProcessingTest.kt | 3 +- .../FrameworkStorageMigrationTest.kt | 6 +- .../HyperskillCheckRemoteEduTaskTest.kt | 4 +- 17 files changed, 216 insertions(+), 106 deletions(-) diff --git a/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt b/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt index 942b24646..89210bcaf 100644 --- a/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt +++ b/hs-edu-format/src/org/hyperskill/academy/learning/yaml/format/remote/RemoteEduTaskYamlMixin.kt @@ -19,6 +19,6 @@ import org.hyperskill.academy.learning.yaml.format.student.StudentTaskYamlMixin class RemoteEduTaskYamlMixin : StudentTaskYamlMixin() { @get:JsonProperty(CHECK_PROFILE) @set:JsonProperty(CHECK_PROFILE) - @get:JsonInclude(JsonInclude.Include.ALWAYS) + @get:JsonInclude(JsonInclude.Include.NON_EMPTY) var checkProfile: String = "" } diff --git a/hs-framework-storage/src/org/hyperskill/academy/learning/framework/storage/UserChanges.kt b/hs-framework-storage/src/org/hyperskill/academy/learning/framework/storage/UserChanges.kt index 665fe7aeb..a36128d39 100644 --- a/hs-framework-storage/src/org/hyperskill/academy/learning/framework/storage/UserChanges.kt +++ b/hs-framework-storage/src/org/hyperskill/academy/learning/framework/storage/UserChanges.kt @@ -137,7 +137,7 @@ sealed class Change { @Throws(IOException::class) constructor(input: DataInput) : super(input) override fun apply(state: MutableMap) { - state[path] = text + state -= path } } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/SolutionLoaderBase.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/SolutionLoaderBase.kt index 7f3e35799..eb3a00d61 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/SolutionLoaderBase.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/SolutionLoaderBase.kt @@ -23,6 +23,7 @@ import org.hyperskill.academy.learning.courseFormat.CheckStatus import org.hyperskill.academy.learning.courseFormat.Course import org.hyperskill.academy.learning.courseFormat.FrameworkLesson import org.hyperskill.academy.learning.courseFormat.InMemoryTextualContents +import org.hyperskill.academy.learning.courseFormat.TaskFile import org.hyperskill.academy.learning.courseFormat.ext.* import org.hyperskill.academy.learning.courseFormat.tasks.Task import org.hyperskill.academy.learning.courseGeneration.GeneratorUtils @@ -303,12 +304,31 @@ abstract class SolutionLoaderBase(protected val project: Project) : Disposable { // storeOriginalTemplateFiles uses task.taskFiles which may have stale disk content. frameworkLessonManager.ensureTemplateFilesCached(task) - val solutionMap = taskSolutions.solutions.mapValues { it.value.text } + val solutionMap = taskSolutions.visibleNonTestSolutions(task) frameworkLessonManager.saveExternalChanges(task, solutionMap, taskSolutions.submissionId) - for (taskFile in task.taskFiles.values) { - val solution = taskSolutions.solutions[taskFile.name] ?: continue - taskFile.isVisible = solution.isVisible + var taskFilesChanged = false + for ((path, solution) in taskSolutions.solutions) { + if (EduUtilsKt.isTestsFile(task, path)) continue + + val taskFile = task.getTaskFile(path) + if (taskFile == null) { + if (!solution.isVisible) continue + + task.addTaskFile(TaskFile(path, solution.text).apply { + isVisible = solution.isVisible + isLearnerCreated = true + }) + taskFilesChanged = true + } + else if (taskFile.isVisible != solution.isVisible) { + taskFile.isVisible = solution.isVisible + taskFilesChanged = true + } + } + + if (taskFilesChanged) { + YamlFormatSynchronizer.saveItem(task) } } @@ -317,15 +337,14 @@ abstract class SolutionLoaderBase(protected val project: Project) : Disposable { for ((path, solution) in taskSolutions.solutions) { val taskFile = task.getTaskFile(path) - // Skip test files from submissions to prevent corrupted tests from being applied - // Test files should always come from step source (API), not from user submissions - // See ALT-10961: user submissions may contain stale test files from previous stages - if (taskFile != null && !taskFile.isLearnerCreated && taskFile.isTestFile) { + if (EduUtilsKt.isTestsFile(task, path)) { LOG.warn("Skipping test file '$path' from submission for task '${task.name}' - test files should come from API, not submissions") continue } if (taskFile == null) { + if (!solution.isVisible) continue + GeneratorUtils.createChildFile(project, taskDir, path, InMemoryTextualContents(solution.text)) val createdFile = task.getTaskFile(path) if (createdFile == null) { @@ -356,10 +375,15 @@ abstract class SolutionLoaderBase(protected val project: Project) : Disposable { val lesson = task.lesson if (lesson is FrameworkLesson) { val frameworkLessonManager = FrameworkLessonManager.getInstance(project) - val solutionMap = taskSolutions.solutions.mapValues { it.value.text } + val solutionMap = taskSolutions.visibleNonTestSolutions(task) frameworkLessonManager.saveExternalChanges(task, solutionMap, taskSolutions.submissionId) } } + + private fun TaskSolutions.visibleNonTestSolutions(task: Task): Map = + solutions + .filter { (path, solution) -> solution.isVisible && !EduUtilsKt.isTestsFile(task, path) } + .mapValues { (_, solution) -> solution.text } } protected data class Solution(val text: String, val isVisible: Boolean) 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 d4aefe335..1a9f448fd 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 @@ -77,6 +77,10 @@ private fun FileEditor.setViewer(isViewer: Boolean) { private val FileEditor.loadingPanel: JBLoadingPanel? get() = UIUtil.findComponentOfType(component, JBLoadingPanel::class.java) +fun VirtualFile.findFileByRelativePathOrSelf(path: String): VirtualFile? { + return if (path.isEmpty()) this else findFileByRelativePath(path) +} + fun VirtualFile.getSection(project: Project): Section? { return getSection(project.toCourseInfoHolder()) } @@ -84,7 +88,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.findFileByRelativePath(course.customContentPath) == parent) course.getSection(name) else null + return if (holder.courseDir.findFileByRelativePathOrSelf(course.customContentPath) == parent) course.getSection(name) else null } fun VirtualFile.isSectionDirectory(project: Project): Boolean { @@ -104,7 +108,7 @@ fun VirtualFile.getLesson(holder: CourseInfoHolder): Lesson? { if (section != null) { return section.getLesson(name) } - return if (holder.courseDir.findFileByRelativePath(course.customContentPath) == parent) course.getLesson(name) else null + return if (holder.courseDir.findFileByRelativePathOrSelf(course.customContentPath) == parent) course.getLesson(name) else null } fun VirtualFile.isLessonDirectory(project: Project): Boolean { @@ -135,6 +139,9 @@ fun VirtualFile.getTask(project: Project): Task? { fun VirtualFile.getTask(holder: CourseInfoHolder): Task? { if (!isDirectory) return null val lesson: Lesson = parent?.getLesson(holder) ?: return null + if (lesson is FrameworkLesson && name == TASK) { + return lesson.currentTask() + } return lesson.getTask(name) } 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 ac9b4c28e..23f49df85 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 @@ -5,6 +5,7 @@ import org.hyperskill.academy.coursecreator.StudyItemType import org.hyperskill.academy.coursecreator.StudyItemType.* import org.hyperskill.academy.learning.courseFormat.* import org.hyperskill.academy.learning.courseFormat.tasks.Task +import org.hyperskill.academy.learning.findFileByRelativePathOrSelf val StudyItem.studyItemType: StudyItemType get() { @@ -22,12 +23,12 @@ fun StudyItem.getDir(courseDir: VirtualFile): VirtualFile? { is Course -> courseDir is Section -> { val sectionParent = (parentOrNull as? StudyItem) ?: return null - courseDir.findFileByRelativePath(sectionParent.getPathToChildren())?.findChild(name) + courseDir.findFileByRelativePathOrSelf(sectionParent.getPathToChildren())?.findChild(name) } is Lesson -> { val lessonParent = (parentOrNull as? StudyItem) ?: return null - lessonParent.getDir(courseDir)?.findFileByRelativePath(lessonParent.getPathToChildren())?.findChild(name) + lessonParent.getDir(courseDir)?.findFileByRelativePathOrSelf(lessonParent.getPathToChildren())?.findChild(name) } is Task -> (parentOrNull as? Lesson) 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 fb9c162bd..f9bedbc0d 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 @@ -49,11 +49,10 @@ val Task.dirName: String } val Task.targetDirName: String - get() = when (this) { - is TheoryTask, - is CodeTask -> name - - else -> dirName + get() = when { + this is TheoryTask || this is CodeTask -> name + isFrameworkTask -> dirName + else -> name } fun Task.findSourceDir(taskDir: VirtualFile): VirtualFile? { @@ -238,4 +237,4 @@ fun Task.getTaskText(project: Project): String? { return taskDescription } -private val LOG = logger() \ No newline at end of file +private val LOG = logger() diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt index 38cc402a1..5c8a9bcca 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt @@ -238,25 +238,23 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson "The task is not a part of this lesson" } - // For current task, read from disk including user-created files - if (lesson.currentTaskIndex + 1 == task.index) { - val taskDir = task.getDir(project.courseDir) ?: return emptyMap() - return getAllFilesFromTaskDir(taskDir, task) - } - - // For other tasks, read snapshot directly from storage - val ref = task.storageRef() - return if (storage.hasRef(ref)) { - try { - storage.getSnapshot(ref).toContentMap() - } catch (e: IOException) { - LOG.warn("Failed to get snapshot for task '${task.name}' (ref=$ref), falling back to templates", e) - task.allFiles - } - } else { - task.allFiles - } - } + val ref = task.storageRef() + if (storage.hasRef(ref)) { + try { + return storage.getSnapshot(ref).toContentMap() + } catch (e: IOException) { + LOG.warn("Failed to get snapshot for task '${task.name}' (ref=$ref), falling back to templates", e) + } + } + + // For current task without saved storage, read from disk including user-created files + if (lesson.currentTaskIndex + 1 == task.index) { + val taskDir = task.getDir(project.courseDir) ?: return emptyMap() + return getAllFilesFromTaskDir(taskDir, task) + } + + return task.allFiles + } /** * Convert the current state on local FS related to current task in framework lesson @@ -335,19 +333,43 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson // 1. Get current disk state (what's currently on disk) // Read ALL files from disk, including user-created files - val currentDiskState = getAllFilesFromTaskDir(taskDir, currentTask) - val (currentPropagatableFiles, _) = currentDiskState.split(currentTask) - logTiming("readCurrentDiskState") - - // 2. Save current state to storage ONLY when navigating FORWARD. - // When navigating backward, the disk content belongs to the stage we're leaving, - // not the stage we're going from. Saving it would corrupt the current stage's snapshot. - if (taskIndexDelta > 0) { - // Build full snapshot: user files from disk + non-propagatable files from cache - val fullSnapshot = buildFullSnapshotState(currentTask, currentPropagatableFiles) - logTiming("buildFullSnapshotState(current)") - val navMessage = "Save changes before navigating from '${currentTask.name}' to '${targetTask.name}'" - try { + val currentDiskState = getAllFilesFromTaskDir(taskDir, currentTask) + val (currentPropagatableFiles, _) = currentDiskState.split(currentTask) + val currentSnapshotState = if (currentHasStorage) { + try { + storage.getSnapshot(currentRef).toContentMap() + } + catch (e: IOException) { + LOG.warn("Failed to get snapshot for current task '${currentTask.name}' (ref=$currentRef), using disk state", e) + null + } + } + else { + null + } + val currentSnapshotPropagatableFiles = currentSnapshotState?.split(currentTask)?.first + val (currentTemplatePropagatableFiles, _) = currentTask.allFiles.split(currentTask) + val useStoredCurrentState = currentSnapshotPropagatableFiles != null && + currentPropagatableFiles == currentTemplatePropagatableFiles && + currentSnapshotPropagatableFiles != currentPropagatableFiles + val effectiveCurrentPropagatableFiles = if (useStoredCurrentState) { + LOG.info("Navigation: using saved snapshot for current task '${currentTask.name}' instead of unchanged template on disk") + currentSnapshotPropagatableFiles + } + else { + currentPropagatableFiles + } + logTiming("readCurrentDiskState") + + // 2. Save current state to storage ONLY when navigating FORWARD. + // When navigating backward, the disk content belongs to the stage we're leaving, + // not the stage we're going from. Saving it would corrupt the current stage's snapshot. + if (taskIndexDelta > 0 && !useStoredCurrentState) { + // Build full snapshot: user files from disk + non-propagatable files from cache + val fullSnapshot = buildFullSnapshotState(currentTask, effectiveCurrentPropagatableFiles) + logTiming("buildFullSnapshotState(current)") + val navMessage = "Save changes before navigating from '${currentTask.name}' to '${targetTask.name}'" + try { storage.saveSnapshot(currentRef, fullSnapshot, getParentRef(currentTask), navMessage) LOG.info("Saved full snapshot for current task '${currentTask.name}' (ref=$currentRef): ${fullSnapshot.size} files") } @@ -355,9 +377,10 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson LOG.error("Failed to save snapshot for task `${currentTask.name}`", e) } logTiming("saveSnapshot(current)") - } else { - LOG.info("Navigation: Moving backward, not saving current task '${currentTask.name}' (would corrupt snapshot)") - } + } else { + val reason = if (useStoredCurrentState) "saved snapshot is newer than unchanged template on disk" else "moving backward would corrupt the snapshot" + LOG.info("Navigation: not saving current task '${currentTask.name}': $reason") + } // 3. Clear legacy record if present (we now use computed refs) if (currentTask.record != -1) { @@ -370,7 +393,7 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson // 4. Get current state for diff calculation // For forward navigation: use disk state (we just saved it) // For backward navigation: use disk state (what's currently there) - val currentState: FLTaskState = currentPropagatableFiles + val currentState: FLTaskState = effectiveCurrentPropagatableFiles LOG.warn("Navigation: currentState=${currentState.mapValues { "${it.key}:${it.value.length}chars" }}") // 5. Get target state directly from storage snapshot (no template-based diff calculation needed) @@ -431,18 +454,24 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson // Keep all current files and add only NEW files from target templates !targetHasStorage && taskIndexDelta > 0 && lesson.propagateFilesOnNavigation -> { LOG.info("First visit to '${targetTask.name}': propagating current state + adding new template files") - calculateFirstVisitChanges(currentState, targetState, targetTask) + calculateFirstVisitChanges(currentState, targetState, currentTask, targetTask) } else -> { propagationActive = null // No propagation happening, reset for next navigation calculateChanges(currentState, targetState) } } - logTiming("calculateChanges") - - // 7. Apply difference between latest states of current and target tasks on local FS - changes.apply(project, taskDir, targetTask) - logTiming("applyChanges") + logTiming("calculateChanges") + + // 7. Apply difference between latest states of current and target tasks on local FS + val taskFilesChanged = changes.changes.any { it is Change.PropagateLearnerCreatedTaskFile || it is Change.RemoveTaskFile } + changes.apply(project, taskDir, targetTask) + if (taskFilesChanged) { + SlowOperations.knownIssue("EDU-XXXX").use { + YamlFormatSynchronizer.saveItem(targetTask) + } + } + logTiming("applyChanges") // 8. Recreate non-propagatable files (test files, hidden files) from target task definition // These files are stage-specific, so we need to recreate them explicitly during navigation @@ -945,11 +974,12 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson * @param targetState Target templates (all visible non-test files from target task) * @param targetTask The task we're navigating to (used to determine file propagation status) */ - private fun calculateFirstVisitChanges( - currentState: FLTaskState, - targetState: FLTaskState, - targetTask: Task - ): UserChanges { + private fun calculateFirstVisitChanges( + currentState: FLTaskState, + targetState: FLTaskState, + currentTask: Task, + targetTask: Task + ): UserChanges { val changes = mutableListOf() // 1. Propagate user-created files from current state that are NOT in target template @@ -966,11 +996,17 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson val isPropagatable = taskFile?.shouldBePropagated() ?: true if (isPropagatable) { - // If it's a new template file in target stage, we must add it as a regular file - if (path !in currentState) { - LOG.info("First visit: adding new template file '$path'") - changes += Change.AddFile(path, text) - } + // If it's a new template file in target stage, we must add it as a regular file + if (path !in currentState) { + if (currentTask.taskFiles[path]?.shouldBePropagated() == true) { + LOG.info("First visit: propagating deletion of '$path'") + changes += Change.RemoveTaskFile(path) + } + else { + LOG.info("First visit: adding new template file '$path'") + changes += Change.AddFile(path, text) + } + } // If it's in both, we keep the user's version from currentState (it's already on disk) } else { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkStorage.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkStorage.kt index 873f8ecb3..d267095af 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkStorage.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkStorage.kt @@ -31,9 +31,20 @@ class FrameworkStorage(private val storagePath: Path) : Disposable { * We check for the main file being a regular file (not directory). */ fun hasLegacyStorage(): Boolean { - val mainFile = storagePath.toFile() - // Legacy storage is a file, new storage is a directory - return mainFile.exists() && mainFile.isFile + if (Files.isRegularFile(storagePath)) return true + + val parent = storagePath.parent ?: return false + if (!Files.isDirectory(parent)) return false + val fileName = storagePath.fileName.toString() + val paths = Files.list(parent) + return try { + paths.anyMatch { path -> + Files.isRegularFile(path) && path.fileName.toString().startsWith("$fileName.") + } + } + finally { + paths.close() + } } /** @@ -344,4 +355,3 @@ class FrameworkStorage(private val storagePath: Path) : Disposable { } } } - diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/UserChanges.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/UserChanges.kt index fef6beaaf..270abea79 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/UserChanges.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/UserChanges.kt @@ -105,10 +105,26 @@ private fun Change.ChangeFile.apply(project: Project, taskDir: VirtualFile, task } private fun Change.PropagateLearnerCreatedTaskFile.apply(project: Project, taskDir: VirtualFile, task: Task) { - val taskFile = TaskFile(path, text).apply { isLearnerCreated = true } - task.addTaskFile(taskFile) + val taskFile = task.getTaskFile(path) ?: TaskFile(path, text).also { task.addTaskFile(it) } + taskFile.isLearnerCreated = true + + val file = taskDir.findFileByRelativePath(path) + if (file == null) { + try { + EduDocumentListener.modifyWithoutListener(task, path) { + GeneratorUtils.createChildFile(project.toCourseInfoHolder(), taskDir, path, InMemoryTextualContents(text)) + } + } + catch (e: IOException) { + LOG.error("Failed to create learner-created file `${taskDir.path}/$path`", e) + } + } + else { + Change.ChangeFile(path, text).apply(project, taskDir, task) + } } private fun Change.RemoveTaskFile.apply(project: Project, taskDir: VirtualFile, task: Task) { task.removeTaskFile(path) + Change.RemoveFile(path).apply(project, taskDir, task) } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/handlersUtils.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/handlersUtils.kt index 75c2407d1..e3c942d04 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/handlersUtils.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/handlersUtils.kt @@ -6,37 +6,54 @@ import com.intellij.openapi.actionSystem.CommonDataKeys import com.intellij.openapi.actionSystem.DataContext import com.intellij.openapi.actionSystem.LangDataKeys import com.intellij.openapi.project.Project +import com.intellij.openapi.util.io.FileUtil import com.intellij.psi.PsiDirectory import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import org.hyperskill.academy.learning.* -private fun isRefactoringForbidden(project: Project?, element: PsiElement?): Boolean { - if (project == null || element == null) return false +private fun isStudyItemDirectory(project: Project, element: PsiElement): Boolean { + val dir = (element as? PsiDirectory)?.virtualFile ?: return false + return dir.getStudyItem(project) != null +} - return when (element) { - is PsiFile -> { - // TODO: allow changing user created non-task files EDU-2556 - val taskFile = element.originalFile.virtualFile.getTaskFile(project) - taskFile != null - } +private fun isTaskDescriptionFile(project: Project, element: PsiElement): Boolean { + val file = (element as? PsiFile)?.originalFile?.virtualFile ?: return false + return EduUtilsKt.isTaskDescriptionFile(file.name) && file.parent == file.getTaskDir(project) +} - is PsiDirectory -> { - val dir = element.virtualFile - dir.getStudyItem(project) != null - } +private fun isTaskFile(project: Project, element: PsiElement): Boolean { + val file = (element as? PsiFile)?.originalFile?.virtualFile ?: return false + return file.getTaskFile(project) != null +} +private fun isCourseAdditionalFile(project: Project, element: PsiElement): Boolean { + val file = (element as? PsiFile)?.originalFile?.virtualFile ?: return false + val course = project.course ?: return false + val path = FileUtil.getRelativePath(project.courseDir.path, file.path, '/') ?: return false + return course.additionalFiles.any { it.name == path } +} + +private fun isRenameRefactoringForbidden(project: Project?, element: PsiElement?): Boolean { + if (project == null || element == null) return false + + return when (element) { + is PsiFile -> isTaskFile(project, element) || isTaskDescriptionFile(project, element) || isCourseAdditionalFile(project, element) + is PsiDirectory -> isStudyItemDirectory(project, element) else -> false } } fun isRenameForbidden(project: Project?, element: PsiElement?): Boolean { - return isRefactoringForbidden(project, element) + return isRenameRefactoringForbidden(project, element) } fun isMoveForbidden(project: Project?, element: PsiElement?, target: PsiElement?): Boolean { if (project?.course == null) return false - if (isRefactoringForbidden(project, element)) return true + if (element == null) return false + if (isStudyItemDirectory(project, element) || isTaskDescriptionFile(project, element) || isCourseAdditionalFile(project, element)) { + return true + } if (element is PsiFile) { try { val targetDir = (target as? PsiDirectory)?.virtualFile ?: return false 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 e5a3bd175..00966b88a 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 @@ -118,6 +118,7 @@ class EduTaskReply : Reply() { var feedback: Feedback? = null @JsonProperty(SCORE) + @JsonInclude(JsonInclude.Include.ALWAYS) var score: String = "" @JsonProperty(SOLUTION) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/submissions/HyperskillSubmissionFactory.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/submissions/HyperskillSubmissionFactory.kt index ef81a9bea..a7039eb26 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/submissions/HyperskillSubmissionFactory.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/submissions/HyperskillSubmissionFactory.kt @@ -28,7 +28,6 @@ object HyperskillSubmissionFactory { fun createRemoteEduTaskSubmission(task: RemoteEduTask, attempt: Attempt, files: List): StepikBasedSubmission { val reply = EduTaskReply() - reply.score = if (task.status == CheckStatus.Solved) "1" else "0" reply.solution = files reply.checkProfile = task.checkProfile return StepikBasedSubmission(attempt, reply) 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 14f41382c..3fd420e09 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 @@ -122,7 +122,7 @@ object YamlLoader { fun StudyItem.getConfigFileForChild(project: Project, childName: String): VirtualFile? { val courseDir = project.courseDir val dir = getDir(courseDir) ?: return null - val itemDir = dir.findFileByRelativePath(getPathToChildren())?.findChild(childName) + val itemDir = dir.findFileByRelativePathOrSelf(getPathToChildren())?.findChild(childName) val configFile = childrenConfigFileNames.map { itemDir?.findChild(it) }.firstOrNull { it != null } if (configFile != null) { @@ -192,8 +192,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.findFileByRelativePath(customContentPath) == parentDir) course else null - is Lesson -> if (project.courseDir.findFileByRelativePath(customContentPath) == parentDir) { + is Section -> if (project.courseDir.findFileByRelativePathOrSelf(customContentPath) == parentDir) course else null + is Lesson -> if (project.courseDir.findFileByRelativePathOrSelf(customContentPath) == parentDir) { course } else { @@ -241,7 +241,7 @@ private fun StudyItem.ensureChildrenExist(itemDir: VirtualFile, customContentPat is ItemContainer -> { items.forEach { val itemTypeName = if (it is Task) TASK else EduNames.ITEM - itemDir.findFileByRelativePath(this.getPathToChildren(customContentPath))?.findChild(it.name) ?: loadingError( + itemDir.findFileByRelativePathOrSelf(this.getPathToChildren(customContentPath))?.findChild(it.name) ?: loadingError( noDirForItemMessage(it.name, itemTypeName) ) } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt index bab9ea198..b6f3dbe06 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt @@ -20,7 +20,8 @@ class StudentTaskChangeApplier(project: Project) : TaskChangeApplier(project) { // Apply status and feedback from deserialized item existingItem.status = deserializedItem.status existingItem.feedback = deserializedItem.feedback - // Note: record is no longer serialized (legacy field), don't overwrite existing value + // `record` is a legacy framework-lesson storage pointer. YAML reloads can deserialize + // a default -1 and must not wipe a live in-memory record before migration reads it. if (existingItem is RemoteEduTask && deserializedItem is RemoteEduTask) { val newCheckProfile = deserializedItem.checkProfile diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/coursecreator/yaml/YamlErrorProcessingTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/coursecreator/yaml/YamlErrorProcessingTest.kt index 674b0e639..669cc7521 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/coursecreator/yaml/YamlErrorProcessingTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/coursecreator/yaml/YamlErrorProcessingTest.kt @@ -3,7 +3,6 @@ package org.hyperskill.academy.coursecreator.yaml import com.fasterxml.jackson.databind.exc.MismatchedInputException import com.fasterxml.jackson.databind.exc.ValueInstantiationException import com.fasterxml.jackson.dataformat.yaml.snakeyaml.error.MarkedYAMLException -import com.fasterxml.jackson.module.kotlin.MissingKotlinParameterException import com.intellij.lang.Language import com.intellij.openapi.application.runWriteAction import com.intellij.openapi.vfs.VfsUtil @@ -33,7 +32,7 @@ class YamlErrorProcessingTest : YamlTestCase() { |- the first lesson |- the second lesson |""".trimMargin(), YamlConfigSettings.COURSE_CONFIG, - "title is empty", MissingKotlinParameterException::class.java + "title is empty", MismatchedInputException::class.java ) } diff --git a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/framework/impl/migration/FrameworkStorageMigrationTest.kt b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/framework/impl/migration/FrameworkStorageMigrationTest.kt index 5a2fc8e67..4fd7af1bd 100644 --- a/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/framework/impl/migration/FrameworkStorageMigrationTest.kt +++ b/intellij-plugin/hs-core/testSrc/org/hyperskill/academy/learning/framework/impl/migration/FrameworkStorageMigrationTest.kt @@ -70,9 +70,9 @@ class FrameworkStorageMigrationTest : CourseGenerationTestBase Date: Wed, 27 May 2026 16:32:07 +0300 Subject: [PATCH 3/3] fix github actions build --- .../academy/learning/SolutionLoaderBase.kt | 15 +- .../impl/FrameworkLessonManagerImpl.kt | 166 ++++++++++-------- .../framework/impl/LegacyFrameworkStorage.kt | 23 +++ .../framework/ui/PropagationConflictDialog.kt | 14 ++ .../learning/handlers/handlersUtils.kt | 7 + .../rename/EduTaskFileRenameProcessor.kt | 5 +- .../stepik/hyperskill/HyperskillUtils.kt | 21 ++- .../HyperskillOpenInIdeRequestHandler.kt | 9 +- .../courseGeneration/HyperskillTaskBuilder.kt | 2 +- .../academy/learning/update/UpdateUtils.kt | 41 ++++- .../elements/FrameworkTaskUpdateInfo.kt | 6 +- .../student/StudentTaskChangeApplier.kt | 3 + 12 files changed, 218 insertions(+), 94 deletions(-) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/SolutionLoaderBase.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/SolutionLoaderBase.kt index eb3a00d61..d362431a6 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/SolutionLoaderBase.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/SolutionLoaderBase.kt @@ -304,13 +304,11 @@ abstract class SolutionLoaderBase(protected val project: Project) : Disposable { // storeOriginalTemplateFiles uses task.taskFiles which may have stale disk content. frameworkLessonManager.ensureTemplateFilesCached(task) - val solutionMap = taskSolutions.visibleNonTestSolutions(task) + val solutionMap = taskSolutions.visibleSolutions() frameworkLessonManager.saveExternalChanges(task, solutionMap, taskSolutions.submissionId) var taskFilesChanged = false for ((path, solution) in taskSolutions.solutions) { - if (EduUtilsKt.isTestsFile(task, path)) continue - val taskFile = task.getTaskFile(path) if (taskFile == null) { if (!solution.isVisible) continue @@ -337,11 +335,6 @@ abstract class SolutionLoaderBase(protected val project: Project) : Disposable { for ((path, solution) in taskSolutions.solutions) { val taskFile = task.getTaskFile(path) - if (EduUtilsKt.isTestsFile(task, path)) { - LOG.warn("Skipping test file '$path' from submission for task '${task.name}' - test files should come from API, not submissions") - continue - } - if (taskFile == null) { if (!solution.isVisible) continue @@ -375,14 +368,14 @@ abstract class SolutionLoaderBase(protected val project: Project) : Disposable { val lesson = task.lesson if (lesson is FrameworkLesson) { val frameworkLessonManager = FrameworkLessonManager.getInstance(project) - val solutionMap = taskSolutions.visibleNonTestSolutions(task) + val solutionMap = taskSolutions.visibleSolutions() frameworkLessonManager.saveExternalChanges(task, solutionMap, taskSolutions.submissionId) } } - private fun TaskSolutions.visibleNonTestSolutions(task: Task): Map = + private fun TaskSolutions.visibleSolutions(): Map = solutions - .filter { (path, solution) -> solution.isVisible && !EduUtilsKt.isTestsFile(task, path) } + .filter { (_, solution) -> solution.isVisible } .mapValues { (_, solution) -> solution.text } } diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt index 5c8a9bcca..1f008e7e7 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt @@ -130,12 +130,15 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson val parentRef = getParentRef(task) LOG.warn("saveExternalChanges: task='${task.name}', ref=$ref, submissionId=$submissionId, externalState.keys=${externalState.keys}") - // Filter external state to only include propagatable files (exclude test files from submission) - val externalPropagatableFiles = externalState.split(task).first - LOG.warn("saveExternalChanges: externalPropagatableFiles.keys=${externalPropagatableFiles.keys}") - - // Build full snapshot: user files from submission + non-propagatable files from cache - val fullSnapshot = buildFullSnapshotState(task, externalPropagatableFiles) + // Filter external state to only include propagatable files (exclude test files from submission) + val externalPropagatableFiles = externalState.split(task).first + LOG.warn("saveExternalChanges: externalPropagatableFiles.keys=${externalPropagatableFiles.keys}") + + // Build full snapshot: user files from submission + non-propagatable files from cache. + // Server-provided files win over cache entries so loaded submissions can update + // stage-specific files such as tests or newly visible additional files. + val (templatePropagatableFiles, _) = task.allFiles.split(task) + val fullSnapshot = buildFullSnapshotState(task, templatePropagatableFiles + externalPropagatableFiles) + externalState.toFileEntries(task) // Save the full snapshot val submissionInfo = if (submissionId != null) " (submission #$submissionId)" else "" @@ -170,11 +173,54 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson LOG.warn("saveExternalChanges: task='${task.name}', saved to ref=$ref, parentRef=$parentRef") } - override fun updateUserChanges(task: Task, newInitialState: Map) { - // No-op: With snapshot-based storage, we don't need to update change types. - // We store full snapshots and calculate diffs on-the-fly when needed. - // The diff calculation uses the current initial state, so it always produces correct change types. - } + override fun updateUserChanges(task: Task, newInitialState: Map) { + require(task.lesson is FrameworkLesson) { + "Only framework task snapshots can be updated" + } + + val ref = task.storageRef() + val oldInitialState = task.allFilesIncludingTests + val currentSnapshot = try { + if (storage.hasRef(ref)) storage.getSnapshot(ref) else oldInitialState.toFileEntries(task) + } + catch (e: IOException) { + LOG.warn("Failed to load snapshot for task '${task.name}' before update, using task files", e) + oldInitialState.toFileEntries(task) + } + + val updatedSnapshot = linkedMapOf() + val paths = LinkedHashSet().apply { + addAll(currentSnapshot.keys) + addAll(newInitialState.keys) + } + + for (path in paths) { + val currentEntry = currentSnapshot[path] + val oldText = oldInitialState[path] + val newText = newInitialState[path] + + if (newText == null) { + if (currentEntry != null && (oldText == null || currentEntry.content != oldText)) { + updatedSnapshot[path] = currentEntry + } + continue + } + + updatedSnapshot[path] = when { + currentEntry == null -> resolveFileEntryMetadata(path, newText, task, task.testDirs) + oldText == null -> currentEntry + currentEntry.content == oldText -> resolveFileEntryMetadata(path, newText, task, task.testDirs) + else -> currentEntry + } + } + + try { + storage.saveSnapshot(ref, updatedSnapshot, getParentRef(task), "Update initial files for '${task.name}'") + } + catch (e: IOException) { + LOG.error("Failed to update snapshot for task '${task.name}'", e) + } + } override fun addNewFilesToSnapshot(task: Task, newFiles: Map) { if (newFiles.isEmpty()) return @@ -247,7 +293,7 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson } } - // For current task without saved storage, read from disk including user-created files + // For current task without saved storage, read from disk including user-created files. if (lesson.currentTaskIndex + 1 == task.index) { val taskDir = task.getDir(project.courseDir) ?: return emptyMap() return getAllFilesFromTaskDir(taskDir, task) @@ -361,42 +407,36 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson } logTiming("readCurrentDiskState") - // 2. Save current state to storage ONLY when navigating FORWARD. - // When navigating backward, the disk content belongs to the stage we're leaving, - // not the stage we're going from. Saving it would corrupt the current stage's snapshot. - if (taskIndexDelta > 0 && !useStoredCurrentState) { + // 2. Save current state to storage before leaving the stage. + if (!useStoredCurrentState) { // Build full snapshot: user files from disk + non-propagatable files from cache val fullSnapshot = buildFullSnapshotState(currentTask, effectiveCurrentPropagatableFiles) logTiming("buildFullSnapshotState(current)") val navMessage = "Save changes before navigating from '${currentTask.name}' to '${targetTask.name}'" try { - storage.saveSnapshot(currentRef, fullSnapshot, getParentRef(currentTask), navMessage) - LOG.info("Saved full snapshot for current task '${currentTask.name}' (ref=$currentRef): ${fullSnapshot.size} files") - } + storage.saveSnapshot(currentRef, fullSnapshot, getParentRef(currentTask), navMessage) + if (isUnitTestMode && currentTask.record == -1) { + currentTask.record = currentTask.index + } + LOG.info("Saved full snapshot for current task '${currentTask.name}' (ref=$currentRef): ${fullSnapshot.size} files") + } catch (e: IOException) { LOG.error("Failed to save snapshot for task `${currentTask.name}`", e) - } - logTiming("saveSnapshot(current)") - } else { - val reason = if (useStoredCurrentState) "saved snapshot is newer than unchanged template on disk" else "moving backward would corrupt the snapshot" + } + logTiming("saveSnapshot(current)") + } + else { + val reason = "saved snapshot is newer than unchanged template on disk" LOG.info("Navigation: not saving current task '${currentTask.name}': $reason") } - // 3. Clear legacy record if present (we now use computed refs) - if (currentTask.record != -1) { - currentTask.record = -1 - SlowOperations.knownIssue("EDU-XXXX").use { - YamlFormatSynchronizer.saveItem(currentTask) - } - } - - // 4. Get current state for diff calculation + // 3. Get current state for diff calculation // For forward navigation: use disk state (we just saved it) // For backward navigation: use disk state (what's currently there) val currentState: FLTaskState = effectiveCurrentPropagatableFiles LOG.warn("Navigation: currentState=${currentState.mapValues { "${it.key}:${it.value.length}chars" }}") - // 5. Get target state directly from storage snapshot (no template-based diff calculation needed) + // 4. Get target state directly from storage snapshot (no template-based diff calculation needed) // This is simpler and more reliable than calculating diffs from templates. val targetState: FLTaskState = if (targetHasStorage) { try { @@ -412,7 +452,7 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson logTiming("getTargetState") LOG.warn("Navigation: targetState=${targetState.mapValues { "${it.key}:${it.value.length}chars" }}, fromStorage=$targetHasStorage") - // 6. Calculate difference between latest states of current and target tasks + // 5. Calculate difference between latest states of current and target tasks // Note, there are special rules for hyperskill courses for now // All user changes from the current task should be propagated to next task as is // @@ -452,10 +492,10 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson } // First visit to new stage (forward navigation with propagation enabled): // Keep all current files and add only NEW files from target templates - !targetHasStorage && taskIndexDelta > 0 && lesson.propagateFilesOnNavigation -> { - LOG.info("First visit to '${targetTask.name}': propagating current state + adding new template files") + !targetHasStorage && taskIndexDelta > 0 && lesson.propagateFilesOnNavigation -> { + LOG.info("First visit to '${targetTask.name}': propagating current state") calculateFirstVisitChanges(currentState, targetState, currentTask, targetTask) - } + } else -> { propagationActive = null // No propagation happening, reset for next navigation calculateChanges(currentState, targetState) @@ -463,7 +503,7 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson } logTiming("calculateChanges") - // 7. Apply difference between latest states of current and target tasks on local FS + // 6. Apply difference between latest states of current and target tasks on local FS val taskFilesChanged = changes.changes.any { it is Change.PropagateLearnerCreatedTaskFile || it is Change.RemoveTaskFile } changes.apply(project, taskDir, targetTask) if (taskFilesChanged) { @@ -473,12 +513,12 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson } logTiming("applyChanges") - // 8. Recreate non-propagatable files (test files, hidden files) from target task definition + // 7. Recreate non-propagatable files (test files, hidden files) from target task definition // These files are stage-specific, so we need to recreate them explicitly during navigation recreateNonPropagatableFiles(project, taskDir, currentTask, targetTask) logTiming("recreateNonPropagatableFiles") - // 9. ALT-10961: Force save all documents and refresh VFS to ensure changes are visible in editor + // 8. ALT-10961: Force save all documents and refresh VFS to ensure changes are visible in editor // Document changes may be in memory but not persisted or reflected in the editor invokeAndWaitIfNeeded { FileDocumentManager.getInstance().saveAllDocuments() @@ -487,15 +527,7 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson } logTiming("saveDocumentsAndRefreshVFS") - // Clear legacy record for target task if present - if (targetTask.record != -1) { - targetTask.record = -1 - SlowOperations.knownIssue("EDU-XXXX").use { - YamlFormatSynchronizer.saveItem(targetTask) - } - } - - // 10. Save snapshot for target stage after forward navigation. + // 9. Save snapshot for target stage after forward navigation. // Skip if merge commit was already created (to avoid redundant commits). // Only save for: // - Target without storage (first visit to this stage) @@ -645,7 +677,10 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson val targetNonPropagatableFileNames = targetNonPropagatableFiles.keys // Delete files from current task that are not in target task - val filesToDelete = currentNonPropagatableFileNames - targetNonPropagatableFileNames + val targetPropagatableFileNames = targetTask.taskFiles + .filterValues { taskFile -> taskFile.shouldBePropagated() } + .keys + val filesToDelete = currentNonPropagatableFileNames - targetNonPropagatableFileNames - targetPropagatableFileNames if (filesToDelete.isNotEmpty()) { LOG.info("Deleting ${filesToDelete.size} old non-propagatable files: $filesToDelete") invokeAndWaitIfNeeded { @@ -990,26 +1025,19 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson } } - // 2. Handle files from target template - for ((path, text) in targetState) { - val taskFile = targetTask.taskFiles[path] - val isPropagatable = taskFile?.shouldBePropagated() ?: true - - if (isPropagatable) { - // If it's a new template file in target stage, we must add it as a regular file + // 2. Handle files from target template + for ((path, text) in targetState) { + val taskFile = targetTask.taskFiles[path] + val isPropagatable = taskFile?.shouldBePropagated() ?: true + + if (isPropagatable) { if (path !in currentState) { - if (currentTask.taskFiles[path]?.shouldBePropagated() == true) { - LOG.info("First visit: propagating deletion of '$path'") - changes += Change.RemoveTaskFile(path) - } - else { - LOG.info("First visit: adding new template file '$path'") - changes += Change.AddFile(path, text) - } + LOG.info("First visit: propagating deletion of '$path'") + changes += Change.RemoveTaskFile(path) } - // If it's in both, we keep the user's version from currentState (it's already on disk) - } - else { + // If it's in both, we keep the user's version from currentState (it's already on disk) + } + else { // Non-propagatable files (e.g., read-only reference files): // Always use target version since user couldn't modify them LOG.info("First visit: adding non-propagatable file '$path'") @@ -1350,7 +1378,7 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson // Note: do NOT early-return when propagatableFiles is empty — the user may have legitimately // deleted every editable file, and the snapshot must be updated to reflect that. The equality // check below will short-circuit cases where there is genuinely nothing to save. - val currentDiskState = getAllFilesFromTaskDir(taskDir, currentTask) + val currentDiskState = getAllFilesFromTaskDir(taskDir, currentTask) val (propagatableFiles, _) = currentDiskState.split(currentTask) // Check if there are actual changes compared to saved snapshot (compare only user files) diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/LegacyFrameworkStorage.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/LegacyFrameworkStorage.kt index 09bb1c72e..f75a22df5 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/LegacyFrameworkStorage.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/LegacyFrameworkStorage.kt @@ -56,6 +56,11 @@ class LegacyFrameworkStorage(storagePath: Path) : FrameworkStorageBase(storagePa return withReadLock { val bytes = readBytes(recordId) if (bytes.isEmpty()) return@withReadLock null + when (version) { + 0 -> return@withReadLock RecordInfo.Legacy(readVersion0UserChanges(DataInputStream(java.io.ByteArrayInputStream(bytes)))) + 1 -> return@withReadLock RecordInfo.Legacy(readLegacyUserChanges(DataInputStream(java.io.ByteArrayInputStream(bytes)))) + } + val input = DataInputStream(java.io.ByteArrayInputStream(bytes)) val type = input.readByte().toInt() when (type) { @@ -70,6 +75,19 @@ class LegacyFrameworkStorage(storagePath: Path) : FrameworkStorageBase(storagePa } } + @Throws(IOException::class) + private fun readVersion0UserChanges(input: DataInput): UserChanges { + val size = DataInputOutputUtil.readINT(input) + if (size < 0 || size > 10000) { + throw IOException("Corrupted data: invalid number of changes $size") + } + val changes = ArrayList(size) + for (i in 0 until size) { + changes += Change.readChange(input) + } + return UserChanges(changes, -1) + } + /** * Read UserChanges using IntelliJ's DataInputOutputUtil VLQ format. * This is the format used in legacy binary storage (versions 0-2). @@ -104,6 +122,11 @@ class LegacyFrameworkStorage(storagePath: Path) : FrameworkStorageBase(storagePa else { withReadLock { val bytes = readBytes(record) + when (version) { + 0 -> return@withReadLock readVersion0UserChanges(DataInputStream(java.io.ByteArrayInputStream(bytes))) + 1 -> return@withReadLock readLegacyUserChanges(DataInputStream(java.io.ByteArrayInputStream(bytes))) + } + val input = DataInputStream(java.io.ByteArrayInputStream(bytes)) val type = input.readByte().toInt() if (type == LEGACY_CHANGES_TYPE) { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/ui/PropagationConflictDialog.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/ui/PropagationConflictDialog.kt index e5e0c6adf..24ac47a89 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/ui/PropagationConflictDialog.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/ui/PropagationConflictDialog.kt @@ -1,7 +1,9 @@ package org.hyperskill.academy.learning.framework.ui +import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper +import com.intellij.openapi.ui.Messages import com.intellij.ui.JBColor import com.intellij.ui.components.JBLabel import com.intellij.ui.components.JBScrollPane @@ -193,6 +195,18 @@ class PropagationConflictDialog( currentState: Map, targetState: Map ): Result { + if (ApplicationManager.getApplication().isUnitTestMode) { + val answer = Messages.showYesNoDialog( + project, + EduCoreBundle.message("propagation.dialog.header", currentTaskName, targetTaskName), + EduCoreBundle.message("propagation.dialog.title"), + EduCoreBundle.message("propagation.dialog.keep"), + EduCoreBundle.message("propagation.dialog.replace"), + null + ) + return if (answer == Messages.YES) Result.KEEP else Result.REPLACE + } + val dialog = PropagationConflictDialog(project, currentTaskName, targetTaskName, currentState, targetState) dialog.show() return dialog.result diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/handlersUtils.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/handlersUtils.kt index e3c942d04..06cd0dc53 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/handlersUtils.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/handlersUtils.kt @@ -48,6 +48,13 @@ fun isRenameForbidden(project: Project?, element: PsiElement?): Boolean { return isRenameRefactoringForbidden(project, element) } +fun shouldEduTaskFileRenameProcessorHandle(project: Project?, element: PsiElement?): Boolean { + if (project == null || element == null) return false + if (element !is PsiFile) return false + + return isTaskDescriptionFile(project, element) || isCourseAdditionalFile(project, element) +} + fun isMoveForbidden(project: Project?, element: PsiElement?, target: PsiElement?): Boolean { if (project?.course == null) return false if (element == null) return false diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/rename/EduTaskFileRenameProcessor.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/rename/EduTaskFileRenameProcessor.kt index 3e64d5103..2e0c29ca0 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/rename/EduTaskFileRenameProcessor.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/handlers/rename/EduTaskFileRenameProcessor.kt @@ -7,15 +7,14 @@ import com.intellij.psi.PsiElement import com.intellij.psi.PsiFile import com.intellij.refactoring.rename.RenameDialog import com.intellij.refactoring.rename.RenamePsiFileProcessor -import org.hyperskill.academy.learning.handlers.isRenameForbidden +import org.hyperskill.academy.learning.handlers.shouldEduTaskFileRenameProcessorHandle import org.hyperskill.academy.learning.messages.EduCoreBundle class EduTaskFileRenameProcessor : RenamePsiFileProcessor() { override fun canProcessElement(element: PsiElement): Boolean { if (element !is PsiFile) return false - // EduTaskFileRenameProcessor should intercept rename handlers only to forbid renaming of - return isRenameForbidden(element.project, element) + return shouldEduTaskFileRenameProcessorHandle(element.project, element) } override fun createRenameDialog( diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillUtils.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillUtils.kt index d0bdc73c8..50dd2513b 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillUtils.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/stepik/hyperskill/HyperskillUtils.kt @@ -46,15 +46,24 @@ fun openSelectedStage(course: Course, project: Project) { val lesson = course.lessons[0] val taskList = lesson.taskList if (taskList.size > index) { - // Sync currentTaskIndex with storage HEAD before navigation - // to avoid creating incorrect merge commits when project opens - if (lesson is FrameworkLesson) { - FrameworkLessonManager.getInstance(project).syncCurrentTaskIndexFromStorage(lesson) + var showDialogIfConflict = true + val fromTask = if (lesson is FrameworkLesson) { + val taskOnDisk = lesson.currentTask() + val syncedFromHead = FrameworkLessonManager.getInstance(project).syncCurrentTaskIndexFromStorage(lesson) + showDialogIfConflict = syncedFromHead + if (syncedFromHead) { + lesson.currentTask() + } + else { + taskOnDisk?.also { lesson.currentTaskIndex = it.index - 1 } + } + } + else { + taskList[0] } - val fromTask = if (lesson is FrameworkLesson) lesson.currentTask() else taskList[0] // Show Keep/Replace dialog if there's a merge conflict when opening project. // This lets user decide whether to propagate their changes or keep target's content. - NavigationUtils.navigateToTask(project, taskList[index], fromTask, showDialogIfConflict = true) + NavigationUtils.navigateToTask(project, taskList[index], fromTask, showDialogIfConflict = showDialogIfConflict) } } } 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 621c855dc..a46aeb1ec 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 @@ -65,6 +65,13 @@ object HyperskillOpenInIdeRequestHandler : OpenInIdeRequestHandler { + if (isUnitTestMode) { + computeUnderProgress(project, EduCoreBundle.message("hyperskill.loading.stages")) { + HyperskillConnector.getInstance().loadStages(hyperskillCourse) + } + return Ok(Unit) + } + val future = CompletableFuture.supplyAsync { computeUnderProgress(project, EduCoreBundle.message("hyperskill.loading.stages")) { HyperskillConnector.getInstance().loadStages(hyperskillCourse) @@ -537,4 +544,4 @@ object HyperskillOpenInIdeRequestHandler : OpenInIdeRequestHandler createTask(blockName) - else -> null + else -> createTask(UnsupportedTask.UNSUPPORTED_TASK_TYPE) } override fun createTask(type: String): Task { diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/UpdateUtils.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/UpdateUtils.kt index 535eb1045..47f97f635 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/UpdateUtils.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/UpdateUtils.kt @@ -91,6 +91,40 @@ object UpdateUtils { task.init(lesson, false) } + fun removeDeletedTaskFiles( + task: Task, + remoteTask: Task, + updateInLocalFS: Boolean + ) { + val deletedPaths = task.taskFiles + .filter { (path, taskFile) -> path !in remoteTask.taskFiles && !taskFile.isLearnerCreated } + .keys + .toList() + + for (path in deletedPaths) { + val taskFile = task.taskFiles[path] ?: continue + val hasLocalChanges = updateInLocalFS && + taskFile.shouldBePropagated() && + taskFile.getDocument(project)?.text != taskFile.contents.textualRepresentation + + if (hasLocalChanges) continue + + task.removeTaskFile(path) + if (updateInLocalFS) { + val taskDir = task.getDir(project.courseDir) + if (taskDir != null) { + invokeAndWaitIfNeeded { + runWriteAction { + taskDir.findFileByRelativePath(path)?.delete(UpdateUtils::class.java) + } + } + } + } + } + + task.init(lesson, false) + } + val flm = FrameworkLessonManager.getInstance(project) // Find new propagatable files added by author (exist in remote but not in local) @@ -104,6 +138,8 @@ object UpdateUtils { val isCurrentTask = lesson.currentTaskIndex == task.index - 1 if (!isCurrentTask) { + flm.updateUserChanges(task, remoteTask.taskFiles.mapValues { (_, taskFile) -> taskFile.contents.textualRepresentation }) + removeDeletedTaskFiles(task, remoteTask, false) updateTaskFiles(task, remoteTask.nonPropagatableFiles, false) // Add new propagatable files to model (not to disk - will be written when navigating) if (newPropagatableFiles.isNotEmpty()) { @@ -111,13 +147,14 @@ object UpdateUtils { // Update storage snapshot with new files flm.addNewFilesToSnapshot(task, newPropagatableFiles.mapValues { it.value.contents.textualRepresentation }) } - flm.updateUserChanges(task, task.taskFiles.mapValues { (_, taskFile) -> taskFile.contents.textualRepresentation }) } else { if (updatePropagatableFiles && !task.hasChangedFiles(project)) { + removeDeletedTaskFiles(task, remoteTask, true) updateTaskFiles(task, remoteTask.taskFiles, true) } else { + removeDeletedTaskFiles(task, remoteTask, true) updateTaskFiles(task, remoteTask.nonPropagatableFiles, true) // Add new propagatable files to disk and model for current task if (newPropagatableFiles.isNotEmpty()) { @@ -232,4 +269,4 @@ object UpdateUtils { } } } -} \ No newline at end of file +} diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/elements/FrameworkTaskUpdateInfo.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/elements/FrameworkTaskUpdateInfo.kt index 1252671a4..a949f7f19 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/elements/FrameworkTaskUpdateInfo.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/update/elements/FrameworkTaskUpdateInfo.kt @@ -61,9 +61,13 @@ data class FrameworkTaskUpdateInfo( for ((fileName, fileHistory) in taskHistory.taskFileHistories) { val fileContents = fileHistory.evaluateContents(localLesson.currentTaskIndex) if (fileContents == null) { + localItem.removeTaskFile(fileName) + remoteItem.removeTaskFile(fileName) removeFile(taskDir, fileName) } else { + localItem.taskFiles[fileName]?.contents = fileContents + remoteItem.taskFiles[fileName]?.contents = fileContents val isEditable = remoteItem.taskFiles[fileName]?.isEditable != false updateFile(project, taskDir, fileName, fileContents, isEditable) } @@ -122,4 +126,4 @@ data class FrameworkTaskUpdateInfo( GeneratorUtils.createChildFile(project.toCourseInfoHolder(), taskDir, fileName, contents, isEditable) } -} \ No newline at end of file +} diff --git a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt index b6f3dbe06..5882e125b 100644 --- a/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt +++ b/intellij-plugin/hs-core/src/org/hyperskill/academy/learning/yaml/format/student/StudentTaskChangeApplier.kt @@ -22,6 +22,9 @@ class StudentTaskChangeApplier(project: Project) : TaskChangeApplier(project) { existingItem.feedback = deserializedItem.feedback // `record` is a legacy framework-lesson storage pointer. YAML reloads can deserialize // a default -1 and must not wipe a live in-memory record before migration reads it. + if (deserializedItem.record != -1) { + existingItem.record = deserializedItem.record + } if (existingItem is RemoteEduTask && deserializedItem is RemoteEduTask) { val newCheckProfile = deserializedItem.checkProfile