Skip to content

Commit d875c54

Browse files
authored
GitHub-21 Fix framework lesson navigation to preserve user-created files (#28)
* GitHub-21 Fix framework lesson navigation to preserve user-created files - Read all files from task directory instead of only template files when saving snapshots - Add logic to preserve user files when navigating between solved tasks in same project - Implement getAllFilesFromTaskDir() to capture user-created files not in template - Use runReadAction for safe document access * Add comprehensive test coverage for user file preservation * fix review * fix review * fix review
1 parent f2032e7 commit d875c54

2 files changed

Lines changed: 357 additions & 20 deletions

File tree

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

Lines changed: 56 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,18 @@ package org.hyperskill.academy.learning.framework.impl
33
import com.intellij.openapi.Disposable
44
import com.intellij.openapi.application.ApplicationManager
55
import com.intellij.openapi.application.invokeAndWaitIfNeeded
6+
import com.intellij.openapi.application.runReadAction
67
import com.intellij.openapi.application.runWriteAction
78
import com.intellij.openapi.diagnostic.Logger
8-
import com.intellij.openapi.editor.Document
99
import com.intellij.openapi.fileEditor.FileDocumentManager
1010
import com.intellij.openapi.fileEditor.FileDocumentManagerListener
1111
import com.intellij.openapi.project.Project
12-
import com.intellij.openapi.ui.Messages
1312
import com.intellij.openapi.util.Disposer
1413
import com.intellij.openapi.util.io.FileUtil
1514
import com.intellij.openapi.vfs.VfsUtil
1615
import com.intellij.openapi.vfs.VirtualFile
1716
import com.intellij.util.SlowOperations
18-
import com.intellij.util.io.storage.AbstractStorage
19-
import org.hyperskill.academy.learning.Err
20-
import org.hyperskill.academy.learning.Ok
21-
import org.hyperskill.academy.learning.StudyTaskManager
22-
import org.hyperskill.academy.learning.courseDir
17+
import org.hyperskill.academy.learning.*
2318
import org.hyperskill.academy.learning.courseFormat.CheckStatus
2419
import org.hyperskill.academy.learning.courseFormat.FrameworkLesson
2520
import org.hyperskill.academy.learning.courseFormat.TaskFile
@@ -34,12 +29,10 @@ import org.hyperskill.academy.learning.framework.FrameworkStorageListener
3429
import org.hyperskill.academy.learning.framework.propagateFilesOnNavigation
3530
import org.hyperskill.academy.learning.framework.storage.Change
3631
import org.hyperskill.academy.learning.framework.storage.FileEntry
37-
import org.hyperskill.academy.learning.framework.ui.PropagationConflictDialog
3832
import org.hyperskill.academy.learning.framework.storage.UserChanges
39-
import org.hyperskill.academy.learning.messages.EduCoreBundle
33+
import org.hyperskill.academy.learning.framework.ui.PropagationConflictDialog
4034
import org.hyperskill.academy.learning.stepik.PyCharmStepOptions
4135
import org.hyperskill.academy.learning.stepik.hyperskill.api.HyperskillConnector
42-
import org.hyperskill.academy.learning.toCourseInfoHolder
4336
import org.hyperskill.academy.learning.ui.getUIName
4437
import org.hyperskill.academy.learning.yaml.YamlFormatSynchronizer
4538
import org.jetbrains.annotations.TestOnly
@@ -243,12 +236,10 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
243236
"The task is not a part of this lesson"
244237
}
245238

246-
// For current task, read from disk
239+
// For current task, read from disk including user-created files
247240
if (lesson.currentTaskIndex + 1 == task.index) {
248241
val taskDir = task.getDir(project.courseDir) ?: return emptyMap()
249-
val initialFiles = task.allFiles
250-
val changes = getUserChangesFromFiles(initialFiles, taskDir)
251-
return HashMap(initialFiles).apply { changes.apply(this) }
242+
return getAllFilesFromTaskDir(taskDir, task)
252243
}
253244

254245
// For other tasks, read snapshot directly from storage
@@ -341,9 +332,8 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
341332
LOG.info("Navigation refs: current=$currentRef (hasStorage=$currentHasStorage), target=$targetRef (hasStorage=$targetHasStorage)")
342333

343334
// 1. Get current disk state (what's currently on disk)
344-
// Use template file keys to know which files to read
345-
val templateFiles = originalTemplateFilesCache[currentTask.id] ?: currentTask.allFiles
346-
val currentDiskState = getTaskStateFromFiles(templateFiles.keys, taskDir)
335+
// Read ALL files from disk, including user-created files
336+
val currentDiskState = getAllFilesFromTaskDir(taskDir, currentTask)
347337
val (currentPropagatableFiles, _) = currentDiskState.split(currentTask)
348338
logTiming("readCurrentDiskState")
349339

@@ -480,9 +470,8 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
480470
// - Target without storage (first visit to this stage)
481471
// - Navigation without merge (ancestor check passed, no Keep/Replace dialog)
482472
if (taskIndexDelta > 0 && !mergeCommitCreated) {
483-
// Read user files from disk, then build full snapshot with non-propagatable files
484-
val userFileKeys = targetTask.allFiles.keys
485-
val finalDiskState = getTaskStateFromFiles(userFileKeys, taskDir)
473+
// Read ALL files from disk, including user-created files
474+
val finalDiskState = getAllFilesFromTaskDir(taskDir, targetTask)
486475
val (finalPropagatableFiles, _) = finalDiskState.split(targetTask)
487476
val fullSnapshot = buildFullSnapshotState(targetTask, finalPropagatableFiles)
488477
logTiming("buildFullSnapshotState(target)")
@@ -1367,6 +1356,53 @@ class FrameworkLessonManagerImpl(private val project: Project) : FrameworkLesson
13671356
private val Task.allFilesIncludingTests: FLTaskState
13681357
get() = taskFiles.mapValues { it.value.contents.textualRepresentation }
13691358

1359+
/**
1360+
* Reads ALL files from task directory, including user-created files.
1361+
* This is needed to capture user-created files that are not in the template.
1362+
*
1363+
* @param taskDir The task directory to read files from
1364+
* @param task The task (used to filter out test directories)
1365+
* @return Map of file paths to content
1366+
*/
1367+
private fun getAllFilesFromTaskDir(taskDir: VirtualFile, task: Task): FLTaskState {
1368+
val result = HashMap<String, String>()
1369+
val documentManager = FileDocumentManager.getInstance()
1370+
val testDirs = task.testDirs
1371+
1372+
// Recursively collect all files from task directory
1373+
fun collectFiles(dir: VirtualFile, pathPrefix: String = "") {
1374+
for (child in dir.children) {
1375+
val relativePath = if (pathPrefix.isEmpty()) child.name else "$pathPrefix/${child.name}"
1376+
1377+
if (child.isDirectory) {
1378+
// Skip test directories - they will be handled separately
1379+
val isTestDir = testDirs.any { testDir ->
1380+
relativePath == testDir || relativePath.startsWith("$testDir/")
1381+
}
1382+
if (!isTestDir) {
1383+
collectFiles(child, relativePath)
1384+
}
1385+
}
1386+
else {
1387+
// Read file content
1388+
val text = if (child.isToEncodeContent) {
1389+
child.loadEncodedContent(isToEncodeContent = true)
1390+
}
1391+
else {
1392+
runReadAction { documentManager.getDocument(child)?.text }
1393+
}
1394+
1395+
if (text != null) {
1396+
result[relativePath] = text
1397+
}
1398+
}
1399+
}
1400+
}
1401+
1402+
collectFiles(taskDir)
1403+
return result
1404+
}
1405+
13701406
/**
13711407
* Builds complete task state for snapshot: user files from disk + non-propagatable files from cache.
13721408
* Non-propagatable files (test files, hidden files) are taken from cache (not disk)

0 commit comments

Comments
 (0)