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