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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
* -crlf
*.kt text eol=lf
8 changes: 4 additions & 4 deletions gradle-261.properties
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ customUntilBuild=261.*
# Existent IDE versions can be found in the following repos:
# https://www.jetbrains.com/intellij-repository/releases/
# https://www.jetbrains.com/intellij-repository/snapshots/
ideaVersion=IU-2026.1.1
clionVersion=CL-2026.1.1
pycharmVersion=PC-2026.1
riderVersion=RD-2026.1.0.1
ideaVersion=IU-2026.1.3
clionVersion=CL-2026.1.2
pycharmVersion=PC-2026.1.2
riderVersion=RD-2026.1.2
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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ALWAYS -> NON_EMPTY: an empty checkProfile will now be omitted from serialization. Please confirm the backend / YAML round-trip tolerates a missing check_profile field. Note EduTaskReply.score is changed in the opposite direction (to ALWAYS) in this same PR.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gentle follow-up: did you confirm the backend / YAML round-trip is fine with check_profile omitted when empty (no consumer expects the field to always be present)? Leaving open until confirmed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed safe. A missing check_profile deserializes to the "" default (RemoteEduTask.checkProfile), and StudentTaskChangeApplier guards with isNotEmpty() before applying, so an omitted field is a no-op. The existing test remote edu task serialization test asserts no check_profile line for a profile-less task, which only holds under NON_EMPTY. This is local YAML round-trip only — the backend submission payload uses the separate hyperskillAPI/StepikAPI mixins. EduTaskReply.score → ALWAYS is the opposite direction intentionally because score is required in the submission payload.

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
18 changes: 18 additions & 0 deletions intellij-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType.*
import org.jetbrains.intellij.platform.gradle.extensions.IntelliJPlatformTestingExtension
import org.jetbrains.intellij.platform.gradle.tasks.PrepareSandboxTask
import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask
import org.jetbrains.intellij.platform.gradle.tasks.VerifyPluginTask
import org.jetbrains.intellij.platform.gradle.utils.extensionProvider

plugins {
Expand Down Expand Up @@ -42,6 +43,23 @@ intellijPlatform {
buildSearchableOptions = prop("enableBuildSearchableOptions").toBoolean()

pluginVerification {
// Fail only on genuinely broken structure / hard compatibility breaks.
//
// Excluded on purpose (reported in the report but non-failing):
// - INTERNAL_API_USAGES / EXPERIMENTAL / DEPRECATED / SCHEDULED_FOR_REMOVAL:
// informational; JetBrains Marketplace treats them the same way.
// - MISSING_DEPENDENCIES: this is a multi-IDE plugin. IDE-specific content
// modules (hs-Cpp:CLion-Classic -> com.intellij.cidr.lang,
// hs-CSharp -> com.intellij.modules.rider / intellij.rider /
// com.intellij.resharper.unity) intentionally declare deps that don't exist
// in IDEA Ultimate. Such modules are simply not loaded there; the plugin
// still loads and is reported Compatible.
failureLevel = listOf(
Comment thread
Tsyklop marked this conversation as resolved.
VerifyPluginTask.FailureLevel.COMPATIBILITY_PROBLEMS,
VerifyPluginTask.FailureLevel.NON_EXTENDABLE_API_USAGES,
VerifyPluginTask.FailureLevel.OVERRIDE_ONLY_API_USAGES,
VerifyPluginTask.FailureLevel.INVALID_PLUGIN,
)
ides {
intellijIde(ideaVersion)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import org.hyperskill.academy.coursecreator.framework.SyncChangesStateManager
import org.hyperskill.academy.learning.EduUtilsKt.isEduProject
import org.hyperskill.academy.learning.EduUtilsKt.isNewlyCreated
import org.hyperskill.academy.learning.courseFormat.Course
import org.hyperskill.academy.learning.courseFormat.FrameworkLesson
import org.hyperskill.academy.learning.courseFormat.ext.configurator
import org.hyperskill.academy.learning.courseFormat.hyperskill.HyperskillCourse
import org.hyperskill.academy.learning.framework.FrameworkLessonManager
import org.hyperskill.academy.learning.handlers.UserCreatedFileListener
import org.hyperskill.academy.learning.messages.EduCoreBundle
import org.hyperskill.academy.learning.navigation.NavigationUtils
Expand Down Expand Up @@ -58,6 +60,8 @@ class EduProjectActivity : ProjectActivity {
return@trackActivity
}

cacheFrameworkLessonTemplates(project, course)

withContext(Dispatchers.EDT) {
val fileEditorManager = FileEditorManager.getInstance(project)
if (!fileEditorManager.hasOpenFiles() && !SubmissionSettings.getInstance(project).stateOnClose) {
Expand Down Expand Up @@ -147,6 +151,26 @@ class EduProjectActivity : ProjectActivity {
}
}

/**
* Captures the original propagatable template (visible non-test files) of every framework task
* before the learner can edit. During first-visit navigation this lets us tell a template file
* the learner deleted apart from a genuinely new author template introduced in the target stage.
*
* Hyperskill courses populate this cache from API when opening a stage in the IDE, so we only need
* to cover regular (non-Hyperskill) framework courses here.
*/
private fun cacheFrameworkLessonTemplates(project: Project, course: Course) {
if (course is HyperskillCourse) return
val frameworkLessonManager = FrameworkLessonManager.getInstance(project)
course.visitLessons { lesson ->
if (lesson is FrameworkLesson) {
for (task in lesson.taskList) {
frameworkLessonManager.updateOriginalTemplateFiles(task)
}
}
}
}

companion object {
private val LOG: Logger = logger<EduProjectActivity>()

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,29 +304,44 @@ 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.visibleSolutions(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 (task.isSubmissionTestFile(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)
}
}

private fun applySolutionToCurrentTask(project: Project, task: Task, taskSolutions: TaskSolutions) {
val taskDir = task.getDir(project.courseDir) ?: error("Directory for task `${task.name}` not found")
for ((path, solution) in taskSolutions.solutions) {
val taskFile = task.getTaskFile(path)
if (task.isSubmissionTestFile(path)) continue

// 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) {
LOG.warn("Skipping test file '$path' from submission for task '${task.name}' - test files should come from API, not submissions")
continue
}
val taskFile = task.getTaskFile(path)

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 +372,18 @@ 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.visibleSolutions(task)
frameworkLessonManager.saveExternalChanges(task, solutionMap, taskSolutions.submissionId)
}
}

private fun TaskSolutions.visibleSolutions(task: Task): Map<String, String> =
solutions
.filter { (path, solution) -> solution.isVisible && !task.isSubmissionTestFile(path) }
.mapValues { (_, solution) -> solution.text }

private fun Task.isSubmissionTestFile(path: String): Boolean =
getTaskFile(path)?.isTestFile ?: EduUtilsKt.isTestsFile(this, path)
}

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,9 @@ import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.project.DumbAwareAction
import com.intellij.openapi.project.Project
import org.hyperskill.academy.learning.EduUtilsKt.isEduProject
import org.hyperskill.academy.learning.course
import org.hyperskill.academy.learning.courseFormat.FrameworkLesson
import org.hyperskill.academy.learning.courseFormat.ext.allTasks
import org.hyperskill.academy.learning.courseFormat.tasks.Task
import org.hyperskill.academy.learning.navigation.NavigationUtils
import org.hyperskill.academy.learning.taskToolWindow.ui.TaskToolWindowView
Expand All @@ -23,7 +26,7 @@ abstract class TaskNavigationAction : DumbAwareAction() {
e.presentation.isEnabled = false
val project = e.project ?: return
if (!project.isEduProject()) return
val currentTask = TaskToolWindowView.getInstance(project).currentTask ?: return
val currentTask = project.currentNavigationTask() ?: return
if (getTargetTask(currentTask) != null || getCustomAction(currentTask) != null) {
e.presentation.isEnabled = true
}
Expand All @@ -32,7 +35,7 @@ abstract class TaskNavigationAction : DumbAwareAction() {
override fun getActionUpdateThread() = ActionUpdateThread.BGT

private fun navigateTask(project: Project, place: String) {
val currentTask = TaskToolWindowView.getInstance(project).currentTask ?: return
val currentTask = project.currentNavigationTask() ?: return
val customAction = getCustomAction(currentTask)
if (customAction != null) {
customAction(project, currentTask)
Expand All @@ -43,5 +46,13 @@ abstract class TaskNavigationAction : DumbAwareAction() {
NavigationUtils.navigateToTask(project, targetTask, currentTask)
}

private fun Project.currentNavigationTask(): Task? {
TaskToolWindowView.getInstance(this).currentTask?.let { return it }
return course?.allTasks
?.mapNotNull { it.lesson as? FrameworkLesson }
?.distinct()
?.firstNotNullOfOrNull { it.currentTask() }
}

protected abstract fun getTargetTask(sourceTask: Task): Task?
}
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 @@ -13,7 +13,7 @@ interface FrameworkLessonManager : EduTestAware {
fun preparePrevTask(lesson: FrameworkLesson, taskDir: VirtualFile, showDialogIfConflict: Boolean)

fun saveExternalChanges(task: Task, externalState: Map<String, String>, submissionId: Long? = null)
fun updateUserChanges(task: Task, newInitialState: Map<String, String>)
fun updateUserChanges(task: Task, newInitialState: Map<String, String>, newTaskFiles: Map<String, TaskFile> = emptyMap())

/**
* Adds new files to an existing snapshot without overwriting existing files.
Expand Down
Loading
Loading