Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ sealed class Change {
@Throws(IOException::class)
constructor(input: DataInput) : super(input)
override fun apply(state: MutableMap<String, String>) {
state[path] = text
state -= path
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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<String, String> =
solutions
.filter { (path, solution) -> solution.isVisible && !EduUtilsKt.isTestsFile(task, path) }
.mapValues { (_, solution) -> solution.text }
}

protected data class Solution(val text: String, val isVisible: Boolean)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,18 @@ 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())
}

fun VirtualFile.getSection(holder: CourseInfoHolder<out Course?>): 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 {
Expand All @@ -104,7 +108,7 @@ fun VirtualFile.getLesson(holder: CourseInfoHolder<out Course?>): 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 {
Expand Down Expand Up @@ -135,6 +139,9 @@ fun VirtualFile.getTask(project: Project): Task? {
fun VirtualFile.getTask(holder: CourseInfoHolder<out Course?>): 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)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -238,4 +237,4 @@ fun Task.getTaskText(project: Project): String? {
return taskDescription
}

private val LOG = logger<Task>()
private val LOG = logger<Task>()
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -335,29 +333,54 @@ 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")
}
catch (e: IOException) {
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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Change>()

// 1. Propagate user-created files from current state that are NOT in target template
Expand All @@ -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 {
Expand Down
Loading
Loading