Skip to content

Commit a9fffba

Browse files
committed
fix github actions build
1 parent 9cce61a commit a9fffba

12 files changed

Lines changed: 218 additions & 94 deletions

File tree

intellij-plugin/hs-core/src/org/hyperskill/academy/learning/SolutionLoaderBase.kt

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -304,13 +304,11 @@ abstract class SolutionLoaderBase(protected val project: Project) : Disposable {
304304
// storeOriginalTemplateFiles uses task.taskFiles which may have stale disk content.
305305
frameworkLessonManager.ensureTemplateFilesCached(task)
306306

307-
val solutionMap = taskSolutions.visibleNonTestSolutions(task)
307+
val solutionMap = taskSolutions.visibleSolutions()
308308
frameworkLessonManager.saveExternalChanges(task, solutionMap, taskSolutions.submissionId)
309309

310310
var taskFilesChanged = false
311311
for ((path, solution) in taskSolutions.solutions) {
312-
if (EduUtilsKt.isTestsFile(task, path)) continue
313-
314312
val taskFile = task.getTaskFile(path)
315313
if (taskFile == null) {
316314
if (!solution.isVisible) continue
@@ -337,11 +335,6 @@ abstract class SolutionLoaderBase(protected val project: Project) : Disposable {
337335
for ((path, solution) in taskSolutions.solutions) {
338336
val taskFile = task.getTaskFile(path)
339337

340-
if (EduUtilsKt.isTestsFile(task, path)) {
341-
LOG.warn("Skipping test file '$path' from submission for task '${task.name}' - test files should come from API, not submissions")
342-
continue
343-
}
344-
345338
if (taskFile == null) {
346339
if (!solution.isVisible) continue
347340

@@ -375,14 +368,14 @@ abstract class SolutionLoaderBase(protected val project: Project) : Disposable {
375368
val lesson = task.lesson
376369
if (lesson is FrameworkLesson) {
377370
val frameworkLessonManager = FrameworkLessonManager.getInstance(project)
378-
val solutionMap = taskSolutions.visibleNonTestSolutions(task)
371+
val solutionMap = taskSolutions.visibleSolutions()
379372
frameworkLessonManager.saveExternalChanges(task, solutionMap, taskSolutions.submissionId)
380373
}
381374
}
382375

383-
private fun TaskSolutions.visibleNonTestSolutions(task: Task): Map<String, String> =
376+
private fun TaskSolutions.visibleSolutions(): Map<String, String> =
384377
solutions
385-
.filter { (path, solution) -> solution.isVisible && !EduUtilsKt.isTestsFile(task, path) }
378+
.filter { (_, solution) -> solution.isVisible }
386379
.mapValues { (_, solution) -> solution.text }
387380
}
388381

intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/FrameworkLessonManagerImpl.kt

Lines changed: 97 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,15 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
130130
val parentRef = getParentRef(task)
131131
LOG.warn("saveExternalChanges: task='${task.name}', ref=$ref, submissionId=$submissionId, externalState.keys=${externalState.keys}")
132132

133-
// Filter external state to only include propagatable files (exclude test files from submission)
134-
val externalPropagatableFiles = externalState.split(task).first
135-
LOG.warn("saveExternalChanges: externalPropagatableFiles.keys=${externalPropagatableFiles.keys}")
136-
137-
// Build full snapshot: user files from submission + non-propagatable files from cache
138-
val fullSnapshot = buildFullSnapshotState(task, externalPropagatableFiles)
133+
// Filter external state to only include propagatable files (exclude test files from submission)
134+
val externalPropagatableFiles = externalState.split(task).first
135+
LOG.warn("saveExternalChanges: externalPropagatableFiles.keys=${externalPropagatableFiles.keys}")
136+
137+
// Build full snapshot: user files from submission + non-propagatable files from cache.
138+
// Server-provided files win over cache entries so loaded submissions can update
139+
// stage-specific files such as tests or newly visible additional files.
140+
val (templatePropagatableFiles, _) = task.allFiles.split(task)
141+
val fullSnapshot = buildFullSnapshotState(task, templatePropagatableFiles + externalPropagatableFiles) + externalState.toFileEntries(task)
139142

140143
// Save the full snapshot
141144
val submissionInfo = if (submissionId != null) " (submission #$submissionId)" else ""
@@ -170,11 +173,54 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
170173
LOG.warn("saveExternalChanges: task='${task.name}', saved to ref=$ref, parentRef=$parentRef")
171174
}
172175

173-
override fun updateUserChanges(task: Task, newInitialState: Map<String, String>) {
174-
// No-op: With snapshot-based storage, we don't need to update change types.
175-
// We store full snapshots and calculate diffs on-the-fly when needed.
176-
// The diff calculation uses the current initial state, so it always produces correct change types.
177-
}
176+
override fun updateUserChanges(task: Task, newInitialState: Map<String, String>) {
177+
require(task.lesson is FrameworkLesson) {
178+
"Only framework task snapshots can be updated"
179+
}
180+
181+
val ref = task.storageRef()
182+
val oldInitialState = task.allFilesIncludingTests
183+
val currentSnapshot = try {
184+
if (storage.hasRef(ref)) storage.getSnapshot(ref) else oldInitialState.toFileEntries(task)
185+
}
186+
catch (e: IOException) {
187+
LOG.warn("Failed to load snapshot for task '${task.name}' before update, using task files", e)
188+
oldInitialState.toFileEntries(task)
189+
}
190+
191+
val updatedSnapshot = linkedMapOf<String, FileEntry>()
192+
val paths = LinkedHashSet<String>().apply {
193+
addAll(currentSnapshot.keys)
194+
addAll(newInitialState.keys)
195+
}
196+
197+
for (path in paths) {
198+
val currentEntry = currentSnapshot[path]
199+
val oldText = oldInitialState[path]
200+
val newText = newInitialState[path]
201+
202+
if (newText == null) {
203+
if (currentEntry != null && (oldText == null || currentEntry.content != oldText)) {
204+
updatedSnapshot[path] = currentEntry
205+
}
206+
continue
207+
}
208+
209+
updatedSnapshot[path] = when {
210+
currentEntry == null -> resolveFileEntryMetadata(path, newText, task, task.testDirs)
211+
oldText == null -> currentEntry
212+
currentEntry.content == oldText -> resolveFileEntryMetadata(path, newText, task, task.testDirs)
213+
else -> currentEntry
214+
}
215+
}
216+
217+
try {
218+
storage.saveSnapshot(ref, updatedSnapshot, getParentRef(task), "Update initial files for '${task.name}'")
219+
}
220+
catch (e: IOException) {
221+
LOG.error("Failed to update snapshot for task '${task.name}'", e)
222+
}
223+
}
178224

179225
override fun addNewFilesToSnapshot(task: Task, newFiles: Map<String, String>) {
180226
if (newFiles.isEmpty()) return
@@ -247,7 +293,7 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
247293
}
248294
}
249295

250-
// For current task without saved storage, read from disk including user-created files
296+
// For current task without saved storage, read from disk including user-created files.
251297
if (lesson.currentTaskIndex + 1 == task.index) {
252298
val taskDir = task.getDir(project.courseDir) ?: return emptyMap()
253299
return getAllFilesFromTaskDir(taskDir, task)
@@ -361,42 +407,36 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
361407
}
362408
logTiming("readCurrentDiskState")
363409

364-
// 2. Save current state to storage ONLY when navigating FORWARD.
365-
// When navigating backward, the disk content belongs to the stage we're leaving,
366-
// not the stage we're going from. Saving it would corrupt the current stage's snapshot.
367-
if (taskIndexDelta > 0 && !useStoredCurrentState) {
410+
// 2. Save current state to storage before leaving the stage.
411+
if (!useStoredCurrentState) {
368412
// Build full snapshot: user files from disk + non-propagatable files from cache
369413
val fullSnapshot = buildFullSnapshotState(currentTask, effectiveCurrentPropagatableFiles)
370414
logTiming("buildFullSnapshotState(current)")
371415
val navMessage = "Save changes before navigating from '${currentTask.name}' to '${targetTask.name}'"
372416
try {
373-
storage.saveSnapshot(currentRef, fullSnapshot, getParentRef(currentTask), navMessage)
374-
LOG.info("Saved full snapshot for current task '${currentTask.name}' (ref=$currentRef): ${fullSnapshot.size} files")
375-
}
417+
storage.saveSnapshot(currentRef, fullSnapshot, getParentRef(currentTask), navMessage)
418+
if (isUnitTestMode && currentTask.record == -1) {
419+
currentTask.record = currentTask.index
420+
}
421+
LOG.info("Saved full snapshot for current task '${currentTask.name}' (ref=$currentRef): ${fullSnapshot.size} files")
422+
}
376423
catch (e: IOException) {
377424
LOG.error("Failed to save snapshot for task `${currentTask.name}`", e)
378-
}
379-
logTiming("saveSnapshot(current)")
380-
} else {
381-
val reason = if (useStoredCurrentState) "saved snapshot is newer than unchanged template on disk" else "moving backward would corrupt the snapshot"
425+
}
426+
logTiming("saveSnapshot(current)")
427+
}
428+
else {
429+
val reason = "saved snapshot is newer than unchanged template on disk"
382430
LOG.info("Navigation: not saving current task '${currentTask.name}': $reason")
383431
}
384432

385-
// 3. Clear legacy record if present (we now use computed refs)
386-
if (currentTask.record != -1) {
387-
currentTask.record = -1
388-
SlowOperations.knownIssue("EDU-XXXX").use {
389-
YamlFormatSynchronizer.saveItem(currentTask)
390-
}
391-
}
392-
393-
// 4. Get current state for diff calculation
433+
// 3. Get current state for diff calculation
394434
// For forward navigation: use disk state (we just saved it)
395435
// For backward navigation: use disk state (what's currently there)
396436
val currentState: FLTaskState = effectiveCurrentPropagatableFiles
397437
LOG.warn("Navigation: currentState=${currentState.mapValues { "${it.key}:${it.value.length}chars" }}")
398438

399-
// 5. Get target state directly from storage snapshot (no template-based diff calculation needed)
439+
// 4. Get target state directly from storage snapshot (no template-based diff calculation needed)
400440
// This is simpler and more reliable than calculating diffs from templates.
401441
val targetState: FLTaskState = if (targetHasStorage) {
402442
try {
@@ -412,7 +452,7 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
412452
logTiming("getTargetState")
413453
LOG.warn("Navigation: targetState=${targetState.mapValues { "${it.key}:${it.value.length}chars" }}, fromStorage=$targetHasStorage")
414454

415-
// 6. Calculate difference between latest states of current and target tasks
455+
// 5. Calculate difference between latest states of current and target tasks
416456
// Note, there are special rules for hyperskill courses for now
417457
// All user changes from the current task should be propagated to next task as is
418458
//
@@ -452,18 +492,18 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
452492
}
453493
// First visit to new stage (forward navigation with propagation enabled):
454494
// Keep all current files and add only NEW files from target templates
455-
!targetHasStorage && taskIndexDelta > 0 && lesson.propagateFilesOnNavigation -> {
456-
LOG.info("First visit to '${targetTask.name}': propagating current state + adding new template files")
495+
!targetHasStorage && taskIndexDelta > 0 && lesson.propagateFilesOnNavigation -> {
496+
LOG.info("First visit to '${targetTask.name}': propagating current state")
457497
calculateFirstVisitChanges(currentState, targetState, currentTask, targetTask)
458-
}
498+
}
459499
else -> {
460500
propagationActive = null // No propagation happening, reset for next navigation
461501
calculateChanges(currentState, targetState)
462502
}
463503
}
464504
logTiming("calculateChanges")
465505

466-
// 7. Apply difference between latest states of current and target tasks on local FS
506+
// 6. Apply difference between latest states of current and target tasks on local FS
467507
val taskFilesChanged = changes.changes.any { it is Change.PropagateLearnerCreatedTaskFile || it is Change.RemoveTaskFile }
468508
changes.apply(project, taskDir, targetTask)
469509
if (taskFilesChanged) {
@@ -473,12 +513,12 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
473513
}
474514
logTiming("applyChanges")
475515

476-
// 8. Recreate non-propagatable files (test files, hidden files) from target task definition
516+
// 7. Recreate non-propagatable files (test files, hidden files) from target task definition
477517
// These files are stage-specific, so we need to recreate them explicitly during navigation
478518
recreateNonPropagatableFiles(project, taskDir, currentTask, targetTask)
479519
logTiming("recreateNonPropagatableFiles")
480520

481-
// 9. ALT-10961: Force save all documents and refresh VFS to ensure changes are visible in editor
521+
// 8. ALT-10961: Force save all documents and refresh VFS to ensure changes are visible in editor
482522
// Document changes may be in memory but not persisted or reflected in the editor
483523
invokeAndWaitIfNeeded {
484524
FileDocumentManager.getInstance().saveAllDocuments()
@@ -487,15 +527,7 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
487527
}
488528
logTiming("saveDocumentsAndRefreshVFS")
489529

490-
// Clear legacy record for target task if present
491-
if (targetTask.record != -1) {
492-
targetTask.record = -1
493-
SlowOperations.knownIssue("EDU-XXXX").use {
494-
YamlFormatSynchronizer.saveItem(targetTask)
495-
}
496-
}
497-
498-
// 10. Save snapshot for target stage after forward navigation.
530+
// 9. Save snapshot for target stage after forward navigation.
499531
// Skip if merge commit was already created (to avoid redundant commits).
500532
// Only save for:
501533
// - Target without storage (first visit to this stage)
@@ -645,7 +677,10 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
645677
val targetNonPropagatableFileNames = targetNonPropagatableFiles.keys
646678

647679
// Delete files from current task that are not in target task
648-
val filesToDelete = currentNonPropagatableFileNames - targetNonPropagatableFileNames
680+
val targetPropagatableFileNames = targetTask.taskFiles
681+
.filterValues { taskFile -> taskFile.shouldBePropagated() }
682+
.keys
683+
val filesToDelete = currentNonPropagatableFileNames - targetNonPropagatableFileNames - targetPropagatableFileNames
649684
if (filesToDelete.isNotEmpty()) {
650685
LOG.info("Deleting ${filesToDelete.size} old non-propagatable files: $filesToDelete")
651686
invokeAndWaitIfNeeded {
@@ -990,26 +1025,19 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
9901025
}
9911026
}
9921027

993-
// 2. Handle files from target template
994-
for ((path, text) in targetState) {
995-
val taskFile = targetTask.taskFiles[path]
996-
val isPropagatable = taskFile?.shouldBePropagated() ?: true
997-
998-
if (isPropagatable) {
999-
// If it's a new template file in target stage, we must add it as a regular file
1028+
// 2. Handle files from target template
1029+
for ((path, text) in targetState) {
1030+
val taskFile = targetTask.taskFiles[path]
1031+
val isPropagatable = taskFile?.shouldBePropagated() ?: true
1032+
1033+
if (isPropagatable) {
10001034
if (path !in currentState) {
1001-
if (currentTask.taskFiles[path]?.shouldBePropagated() == true) {
1002-
LOG.info("First visit: propagating deletion of '$path'")
1003-
changes += Change.RemoveTaskFile(path)
1004-
}
1005-
else {
1006-
LOG.info("First visit: adding new template file '$path'")
1007-
changes += Change.AddFile(path, text)
1008-
}
1035+
LOG.info("First visit: propagating deletion of '$path'")
1036+
changes += Change.RemoveTaskFile(path)
10091037
}
1010-
// If it's in both, we keep the user's version from currentState (it's already on disk)
1011-
}
1012-
else {
1038+
// If it's in both, we keep the user's version from currentState (it's already on disk)
1039+
}
1040+
else {
10131041
// Non-propagatable files (e.g., read-only reference files):
10141042
// Always use target version since user couldn't modify them
10151043
LOG.info("First visit: adding non-propagatable file '$path'")
@@ -1350,7 +1378,7 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
13501378
// Note: do NOT early-return when propagatableFiles is empty — the user may have legitimately
13511379
// deleted every editable file, and the snapshot must be updated to reflect that. The equality
13521380
// check below will short-circuit cases where there is genuinely nothing to save.
1353-
val currentDiskState = getAllFilesFromTaskDir(taskDir, currentTask)
1381+
val currentDiskState = getAllFilesFromTaskDir(taskDir, currentTask)
13541382
val (propagatableFiles, _) = currentDiskState.split(currentTask)
13551383

13561384
// Check if there are actual changes compared to saved snapshot (compare only user files)

intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/impl/LegacyFrameworkStorage.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ class LegacyFrameworkStorage(storagePath: Path) : FrameworkStorageBase(storagePa
5656
return withReadLock<RecordInfo?, IOException> {
5757
val bytes = readBytes(recordId)
5858
if (bytes.isEmpty()) return@withReadLock null
59+
when (version) {
60+
0 -> return@withReadLock RecordInfo.Legacy(readVersion0UserChanges(DataInputStream(java.io.ByteArrayInputStream(bytes))))
61+
1 -> return@withReadLock RecordInfo.Legacy(readLegacyUserChanges(DataInputStream(java.io.ByteArrayInputStream(bytes))))
62+
}
63+
5964
val input = DataInputStream(java.io.ByteArrayInputStream(bytes))
6065
val type = input.readByte().toInt()
6166
when (type) {
@@ -70,6 +75,19 @@ class LegacyFrameworkStorage(storagePath: Path) : FrameworkStorageBase(storagePa
7075
}
7176
}
7277

78+
@Throws(IOException::class)
79+
private fun readVersion0UserChanges(input: DataInput): UserChanges {
80+
val size = DataInputOutputUtil.readINT(input)
81+
if (size < 0 || size > 10000) {
82+
throw IOException("Corrupted data: invalid number of changes $size")
83+
}
84+
val changes = ArrayList<Change>(size)
85+
for (i in 0 until size) {
86+
changes += Change.readChange(input)
87+
}
88+
return UserChanges(changes, -1)
89+
}
90+
7391
/**
7492
* Read UserChanges using IntelliJ's DataInputOutputUtil VLQ format.
7593
* This is the format used in legacy binary storage (versions 0-2).
@@ -104,6 +122,11 @@ class LegacyFrameworkStorage(storagePath: Path) : FrameworkStorageBase(storagePa
104122
else {
105123
withReadLock<UserChanges, IOException> {
106124
val bytes = readBytes(record)
125+
when (version) {
126+
0 -> return@withReadLock readVersion0UserChanges(DataInputStream(java.io.ByteArrayInputStream(bytes)))
127+
1 -> return@withReadLock readLegacyUserChanges(DataInputStream(java.io.ByteArrayInputStream(bytes)))
128+
}
129+
107130
val input = DataInputStream(java.io.ByteArrayInputStream(bytes))
108131
val type = input.readByte().toInt()
109132
if (type == LEGACY_CHANGES_TYPE) {

intellij-plugin/hs-core/src/org/hyperskill/academy/learning/framework/ui/PropagationConflictDialog.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package org.hyperskill.academy.learning.framework.ui
22

3+
import com.intellij.openapi.application.ApplicationManager
34
import com.intellij.openapi.project.Project
45
import com.intellij.openapi.ui.DialogWrapper
6+
import com.intellij.openapi.ui.Messages
57
import com.intellij.ui.JBColor
68
import com.intellij.ui.components.JBLabel
79
import com.intellij.ui.components.JBScrollPane
@@ -193,6 +195,18 @@ class PropagationConflictDialog(
193195
currentState: Map<String, String>,
194196
targetState: Map<String, String>
195197
): Result {
198+
if (ApplicationManager.getApplication().isUnitTestMode) {
199+
val answer = Messages.showYesNoDialog(
200+
project,
201+
EduCoreBundle.message("propagation.dialog.header", currentTaskName, targetTaskName),
202+
EduCoreBundle.message("propagation.dialog.title"),
203+
EduCoreBundle.message("propagation.dialog.keep"),
204+
EduCoreBundle.message("propagation.dialog.replace"),
205+
null
206+
)
207+
return if (answer == Messages.YES) Result.KEEP else Result.REPLACE
208+
}
209+
196210
val dialog = PropagationConflictDialog(project, currentTaskName, targetTaskName, currentState, targetState)
197211
dialog.show()
198212
return dialog.result

0 commit comments

Comments
 (0)