@@ -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)
0 commit comments