diff --git a/build.gradle.kts b/build.gradle.kts index 5931698..9aafe03 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -56,11 +56,11 @@ tasks { version.set(properties("pluginVersion").get()) changeNotes.set( """

-

Removed Deprecation

+

Added Review Mode

Improvements

""" ) diff --git a/gradle.properties b/gradle.properties index 63853e4..e12d3ca 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,7 +4,7 @@ pluginGroup=de.tum.www1.artemis.plugin.intellij pluginName=orion pluginRepositoryUrl=https://github.com/ls1intum/Orion # SemVer format -> https://semver.org -pluginVersion=1.2.6 +pluginVersion=1.2.7 # Last 2 digits of the year and the major version digit, 211-211.* equals (20)21.1.* # See https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html pluginSinceBuild=232 diff --git a/src/main/java/de/tum/www1/orion/OrionStartupProjectRefreshActivity.kt b/src/main/java/de/tum/www1/orion/OrionStartupProjectRefreshActivity.kt index 9eaf7c4..2669dfa 100644 --- a/src/main/java/de/tum/www1/orion/OrionStartupProjectRefreshActivity.kt +++ b/src/main/java/de/tum/www1/orion/OrionStartupProjectRefreshActivity.kt @@ -58,6 +58,7 @@ class OrionStartupProjectRefreshActivity : ProjectActivity, DumbAware { return } when (exerciseInfo.currentView) { + ExerciseView.STUDENT -> Unit ExerciseView.TUTOR -> OrionAssessmentUtils.configureEditorsForAssessment(project) else -> Unit } diff --git a/src/main/java/de/tum/www1/orion/connector/ide/exercise/IOrionExerciseConnector.kt b/src/main/java/de/tum/www1/orion/connector/ide/exercise/IOrionExerciseConnector.kt index d5e0c63..1d8de55 100644 --- a/src/main/java/de/tum/www1/orion/connector/ide/exercise/IOrionExerciseConnector.kt +++ b/src/main/java/de/tum/www1/orion/connector/ide/exercise/IOrionExerciseConnector.kt @@ -37,6 +37,13 @@ interface IOrionExerciseConnector { */ fun initializeAssessment(submissionId: Long, feedback: String) + /** + * Initializes the [OrionFeedbackService] + * + * @param feedback a serialized [Programming] + */ + fun initializeFeedback(feedback: String) + /** * Clones the exercise participation repository and saves it under the artemis home directory * diff --git a/src/main/java/de/tum/www1/orion/connector/ide/exercise/OrionExerciseConnector.kt b/src/main/java/de/tum/www1/orion/connector/ide/exercise/OrionExerciseConnector.kt index 7c4f1fb..959d119 100644 --- a/src/main/java/de/tum/www1/orion/connector/ide/exercise/OrionExerciseConnector.kt +++ b/src/main/java/de/tum/www1/orion/connector/ide/exercise/OrionExerciseConnector.kt @@ -9,6 +9,7 @@ import de.tum.www1.orion.dto.Feedback import de.tum.www1.orion.dto.ProgrammingExercise import de.tum.www1.orion.exercise.OrionAssessmentService import de.tum.www1.orion.exercise.OrionExerciseService +import de.tum.www1.orion.exercise.OrionFeedbackService import de.tum.www1.orion.messaging.OrionIntellijStateNotifier import de.tum.www1.orion.ui.browser.IBrowser import de.tum.www1.orion.ui.util.notify @@ -20,7 +21,7 @@ import java.util.* /** * Java handler for when an exercise is first opened */ -@Service +@Service(Service.Level.PROJECT) class OrionExerciseConnector(val project: Project) : OrionConnector(), IOrionExerciseConnector { override fun editExercise(exerciseJson: String) { val exercise = gson().fromJson(exerciseJson, ProgrammingExercise::class.java) @@ -50,6 +51,19 @@ class OrionExerciseConnector(val project: Project) : OrionConnector(), IOrionExe project.service().initializeFeedback(submissionId, feedbackArray) } + override fun initializeFeedback(feedback: String) { + val feedbackArray = gson().fromJson(feedback, Array::class.java) + initializeFeedbackForParticipations(feedbackArray) + } + + /** + * initializes feedback object for a student. it takes the first rated participation + * @param feedback an array of [Feedback] provided by the artemis client. + */ + private fun initializeFeedbackForParticipations(feedback: Array) { + project.service().initializeFeedback(0, feedback) + } + override fun initializeHandlers(browser: IBrowser, queryInjector: JBCefJSQuery) { val reactions = mapOf("editExercise" to { scanner: Scanner -> editExercise(scanner.nextAll()) }, "importParticipation" to { scanner: Scanner -> importParticipation(scanner.nextLine(), scanner.nextAll()) }, @@ -71,7 +85,8 @@ class OrionExerciseConnector(val project: Project) : OrionConnector(), IOrionExe }, "initializeAssessment" to { scanner -> initializeAssessment(scanner.nextLine().toLong(), scanner.nextAll()) - }) + }, + "initializeFeedback" to { scanner -> initializeFeedback(scanner.nextAll()) }) addJavaHandler(browser, reactions) val parameterNames = mapOf( @@ -82,7 +97,8 @@ class OrionExerciseConnector(val project: Project) : OrionConnector(), IOrionExe "submissionId", "correctionRound", //"testRun", "downloadURL" ), - "initializeAssessment" to listOf("submissionId", "feedback") + "initializeAssessment" to listOf("submissionId", "feedback"), + "initializeFeedback" to listOf("feedback") ) addLoadHandler(browser, queryInjector, parameterNames) } diff --git a/src/main/java/de/tum/www1/orion/dto/Dto.kt b/src/main/java/de/tum/www1/orion/dto/Dto.kt index 8f5116c..6632cfc 100644 --- a/src/main/java/de/tum/www1/orion/dto/Dto.kt +++ b/src/main/java/de/tum/www1/orion/dto/Dto.kt @@ -3,6 +3,8 @@ package de.tum.www1.orion.dto import de.tum.www1.orion.enumeration.ProgrammingLanguage import java.net.URL +// All entities are defined like the entities in Artemis + /** * Course with properties as defined in Artemis at entities/course.model.ts */ @@ -21,6 +23,7 @@ data class ProgrammingExercise( val programmingLanguage: ProgrammingLanguage, val auxiliaryRepositories: List?, val exerciseGroup: ExerciseGroup?, + val studentParticipations: Array ) { /** * Returns the course of the exercise, either directly or, if it is not set, from the associated exam @@ -34,14 +37,59 @@ data class ProgrammingExercise( } } -data class ExerciseGroup(val id: Long, val exam: Exam) +/** + * A group of Exercises + * @param id the unique id + * @param exam the [Exam] the exercise group belongs to + */ +data class ExerciseGroup( + val id: Long, + val exam: Exam +) -data class Exam(val id: Long, val title: String, val course: Course) +/** + * Exam class providing some values of the exam + */ +data class Exam( + val id: Long, + val title: String, + val course: Course +) /** * Programming exercise participation as defined in Artemis at entities/participation/programming-exercise-student-participation.model.ts + * @param id the unique id of a programming exercise + * @param repositoryUrl the URL of the repository + * @param buildPlanId the id of the buildplan */ -data class ProgrammingExerciseParticipation(val id: Long, val repositoryUrl: URL, val buildPlanId: String) +data class ProgrammingExerciseParticipation( + val id: Long, + val repositoryUrl: URL, + val buildPlanId: String, + var locked: Boolean +) + +/** + * The Result of s student submission + * @param id the unique id of the result + * @param rated boolean value indicating if the result has a rating + * @param feedbacks an array containing tutor-feedback of the type [Feedback] + */ +data class Result( + val id: Long, + val rated: Boolean, + val feedbacks: Array? +) + +/** + * A Programming excercise participation + * @param id the unique id + * @param results an Array containing [Result]s + */ +data class ProgrammingExerciseStudentParticipation( + val id: Long, + val results: Array? +) /** * Auxiliary repository as defined in Artemis at entities/programming-exercise-auxiliary-repository-model.ts @@ -66,7 +114,7 @@ data class AuxiliaryRepository( data class Feedback( var credits: Double, var detailText: String, - val reference: String, + val reference: String?, val text: String, val type: String, var gradingInstruction: GradingInstruction?, diff --git a/src/main/java/de/tum/www1/orion/exercise/OrionAssessmentService.kt b/src/main/java/de/tum/www1/orion/exercise/OrionAssessmentService.kt index 4d4f183..0ae984e 100644 --- a/src/main/java/de/tum/www1/orion/exercise/OrionAssessmentService.kt +++ b/src/main/java/de/tum/www1/orion/exercise/OrionAssessmentService.kt @@ -1,7 +1,6 @@ package de.tum.www1.orion.exercise import com.intellij.collaboration.ui.codereview.diff.EditorComponentInlaysManager -import com.intellij.openapi.application.WriteAction import com.intellij.openapi.application.invokeAndWaitIfNeeded import com.intellij.openapi.application.runInEdt import com.intellij.openapi.components.Service @@ -26,21 +25,8 @@ import de.tum.www1.orion.util.translate * @property project the service belongs to */ @Service(Service.Level.PROJECT) -class OrionAssessmentService(private val project: Project) { - private var feedbackPerFile: MutableMap> = mutableMapOf() - // set storing a pair of path and line for all new, unsaved feedback comments - // required to ensure only one feedback can be created per line - private val pendingFeedback: MutableSet> = mutableSetOf() - private var isInitialized: Boolean = false - - /** - * Initializes the map with feedback sent from Artemis and notifies all [OrionAssessmentEditor]s - * - * @param submissionId to check validity against - * @param feedback to load - */ - fun initializeFeedback(submissionId: Long, feedback: Array) { - // validate submissionId, reject feedback for a different submission +class OrionAssessmentService(private val project: Project) : OrionInlineCommentService(project = project) { + override fun initializeFeedback(submissionId: Long, feedback: Array) { if (project.service().submissionId != submissionId) { project.notify(translate("orion.warning.assessment.submissionId")) return @@ -57,11 +43,14 @@ class OrionAssessmentService(private val project: Project) { // reference has the format "file:FILE_line:LINE" feedback.forEach { - val textParts = it.reference.split("_") - if (textParts.size == 2 && textParts[0].startsWith("file:") && textParts[1].startsWith("line:")) { - it.path = textParts[0].substring(5) - it.line = textParts[1].substring(5).toInt() + if (it.reference != null) { + val textParts = it.reference.split("_") + if (textParts.size == 2 && textParts[0].startsWith("file:") && textParts[1].startsWith("line:")) { + it.path = textParts[0].substring(5) + it.line = textParts[1].substring(5).toInt() + } } + } // filter invalid entries, group by file @@ -81,21 +70,33 @@ class OrionAssessmentService(private val project: Project) { } } else { // if not first load, close and reopen all files opened by assessment editors to reload the feedback - closeAssessmentEditors(true) + closeEditors(true) } } } + /** - * Retrieves the feedback list for the given file or null if no feedback has been loaded yet + * Adds a new feedback comment to the given file and line, if no feedback comment is present at that position yet * - * @param relativePath of the file to get the feedback for - * @return feedback of the file + * @param path of the file to add a feedback to + * @param line line in that file to add feedback to + * @param inlaysManager passed through to the new comment */ - fun getFeedbackFor(relativePath: String): List? { - if (!isInitialized) return null + fun addFeedbackCommentIfPossible(path: String, line: Int, inlaysManager: EditorComponentInlaysManager) { + + val pair = Pair(path, line) + // if there is already a feedback comment in that file and line, abort + if (pendingFeedback.contains(pair) || getFeedbackFor(path) + ?.any { it.line == line } == true + ) { + return + } + + // add feedback + InlineAssessmentComment(path, line, inlaysManager) + pendingFeedback.add(pair) - return feedbackPerFile[relativePath] ?: emptyList() } /** @@ -117,6 +118,16 @@ class OrionAssessmentService(private val project: Project) { synchronizeWithArtemis() } + /** + * Removes a pending feedback. This allows a different feedback to be added to the given file and line + * + * @param path + * @param line + */ + fun deletePendingFeedback(path: String, line: Int) { + pendingFeedback.remove(Pair(path, line)) + } + /** * Adds the given feedback to the map and informs Artemis * @@ -130,68 +141,13 @@ class OrionAssessmentService(private val project: Project) { } /** - * Close all open [OrionAssessmentEditor]s and reinitialize all variables. - * Should be called upon downloading a new submission - */ - fun reset() { - closeAssessmentEditors(false) - feedbackPerFile = mutableMapOf() - isInitialized = false - } - - /** - * Adds a new feedback comment to the given file and line, if no feedback comment is present at that position yet - * - * @param path of the file to add a feedback to - * @param line line in that file to add feedback to - * @param inlaysManager passed through to the new comment - */ - fun addFeedbackCommentIfPossible(path: String, line: Int, inlaysManager: EditorComponentInlaysManager) { - val pair = Pair(path, line) - // if there is already a feedback comment in that file and line, abort - if (pendingFeedback.contains(pair) || getFeedbackFor(path) - ?.any { it.line == line } == true) { - return - } - - // add feedback - InlineAssessmentComment(path, line, inlaysManager) - pendingFeedback.add(pair) - } - - /** - * Removes a pending feedback. This allows a different feedback to be added to the given file and line - * - * @param path - * @param line + * Synchronizes tutorfeedback with Artemis */ - fun deletePendingFeedback(path: String, line: Int) { - pendingFeedback.remove(Pair(path, line)) - } + fun synchronizeWithArtemis() { - private fun synchronizeWithArtemis() { val submissionId = project.service().submissionId ?: return val feedbackAsJson = gson().toJson(feedbackPerFile.values.flatten()) project.messageBus.syncPublisher(OrionIntellijStateNotifier.INTELLIJ_STATE_TOPIC) .updateAssessment(submissionId, feedbackAsJson) } - - private fun closeAssessmentEditors(reopen: Boolean) { - WriteAction.runAndWait { - FileEditorManager.getInstance(project).let { manager -> - val selectedFile = manager.selectedEditor?.file - manager.allEditors.filterIsInstance().map { it.file } - .forEach { - manager.closeFile(it) - if (reopen && it != selectedFile) { - manager.openFile(it, false) - } - } - // open selected file last to ensure focus; the focusEditor parameter does not seem to work - if (reopen && selectedFile != null) { - manager.openFile(selectedFile, true) - } - } - } - } } diff --git a/src/main/java/de/tum/www1/orion/exercise/OrionExerciseService.kt b/src/main/java/de/tum/www1/orion/exercise/OrionExerciseService.kt index f319877..369ab48 100644 --- a/src/main/java/de/tum/www1/orion/exercise/OrionExerciseService.kt +++ b/src/main/java/de/tum/www1/orion/exercise/OrionExerciseService.kt @@ -31,7 +31,7 @@ import de.tum.www1.orion.util.runWithIndeterminateProgressModal import de.tum.www1.orion.util.translate import de.tum.www1.orion.vcs.OrionGitAdapter import de.tum.www1.orion.vcs.OrionGitAdapter.clone -import org.slf4j.LoggerFactory +import com.intellij.openapi.diagnostic.Logger import java.io.File import java.io.IOException import java.nio.file.Files @@ -107,7 +107,7 @@ class OrionExerciseService(private val project: Project) { ProjectUtil.openOrImport(projectPath, project, false) } } catch (e: IOException) { - LoggerFactory.getLogger(OrionExerciseConnector::class.java).error(e.message, e) + Logger.getInstance(OrionExerciseConnector::class.java).error(e.message, e) project.notify(e.toString()) } } diff --git a/src/main/java/de/tum/www1/orion/exercise/OrionFeedbackService.kt b/src/main/java/de/tum/www1/orion/exercise/OrionFeedbackService.kt new file mode 100644 index 0000000..15a8744 --- /dev/null +++ b/src/main/java/de/tum/www1/orion/exercise/OrionFeedbackService.kt @@ -0,0 +1,59 @@ +package de.tum.www1.orion.exercise + +import com.intellij.openapi.application.invokeAndWaitIfNeeded +import com.intellij.openapi.application.runInEdt +import com.intellij.openapi.components.Service +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import de.tum.www1.orion.dto.Feedback +import de.tum.www1.orion.ui.feedback.OrionFeedbackCommentEditor +import de.tum.www1.orion.ui.util.YesNoChooser + +/** + * A Feedback service that provides a feedback from tutors for students + */ +@Service(Service.Level.PROJECT) +class OrionFeedbackService(private val project: Project) : OrionInlineCommentService(project = project) { + override fun initializeFeedback(submissionId: Long, feedback: Array) { + runInEdt { + if (isInitialized) { + // if feedback already has been loaded, ask if it should be overridden + if (!invokeAndWaitIfNeeded { YesNoChooser(project, "feedbackOverwrite").showAndGet() }) { + // if no, do nothing + return@runInEdt + } + } + + // reference has the format "file:FILE_line:LINE" + feedback.forEach { + if (it.reference != null) { + val textParts = it.reference.split("_") + if (textParts.size == 2 && textParts[0].startsWith("file:") && textParts[1].startsWith("line:")) { + it.path = textParts[0].substring(5) + it.line = textParts[1].substring(5).toInt() + } + } + + } + + // filter invalid entries, group by file + this.feedbackPerFile = feedback.filter { + it.path != null && it.line != null + }.groupByTo(mutableMapOf()) { + it.path!! + } + if (!isInitialized) { + isInitialized = true + // if first load, insert feedback into all currently open editors since their own initialization will have occurred before the feedback was loaded and therefore failed + FileEditorManager.getInstance(project).let { + it.allEditors.forEach { editor -> + (editor as? OrionFeedbackCommentEditor)?.initializeFeedback() + } + } + } else { + // if not first load, close and reopen all files opened by assessment editors to reload the feedback + closeEditors(true) + } + } + } +} diff --git a/src/main/java/de/tum/www1/orion/exercise/OrionInlineCommentService.kt b/src/main/java/de/tum/www1/orion/exercise/OrionInlineCommentService.kt new file mode 100644 index 0000000..b214b5e --- /dev/null +++ b/src/main/java/de/tum/www1/orion/exercise/OrionInlineCommentService.kt @@ -0,0 +1,74 @@ +package de.tum.www1.orion.exercise + +import com.intellij.openapi.application.WriteAction +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.project.Project +import de.tum.www1.orion.dto.Feedback +import de.tum.www1.orion.ui.assessment.OrionAssessmentEditor + +/** + * Super class that provides shared functionality to create Editors including comments + */ +abstract class OrionInlineCommentService(private val project: Project) { + var feedbackPerFile: MutableMap> = mutableMapOf() + + // set storing a pair of path and line for all new, unsaved feedback comments + // required to ensure only one feedback can be created per line + val pendingFeedback: MutableSet> = mutableSetOf() + var isInitialized: Boolean = false + + + /** + * Initializes the map with feedback sent from Artemis and notifies all [OrionAssessmentEditor]s + * + * @param submissionId to check validity against + * @param feedback to load + */ + abstract fun initializeFeedback(submissionId: Long, feedback: Array) + + + /** + * Retrieves the feedback list for the given file or null if no feedback has been loaded yet + * + * @param relativePath of the file to get the feedback for + * @return feedback of the file + */ + fun getFeedbackFor(relativePath: String): List? { + if (!isInitialized) return null + + return feedbackPerFile[relativePath] ?: emptyList() + } + + /** + * Close all open [OrionAssessmentEditor]s and reinitialize all variables. + * Should be called upon downloading a new submission + */ + fun reset() { + closeEditors(false) + feedbackPerFile = mutableMapOf() + isInitialized = false + } + + /** + * Closes the assementEditor + * @param reopen specifies if the editor should be opened again after closing + */ + fun closeEditors(reopen: Boolean) { + WriteAction.runAndWait { + FileEditorManager.getInstance(project).let { manager -> + val selectedFile = manager.selectedEditor?.file + manager.allEditors.filterIsInstance().map { it.file } + .forEach { + manager.closeFile(it) + if (reopen && it != selectedFile) { + manager.openFile(it, false) + } + } + // open selected file last to ensure focus; the focusEditor parameter does not seem to work + if (reopen && selectedFile != null) { + manager.openFile(selectedFile, true) + } + } + } + } +} diff --git a/src/main/java/de/tum/www1/orion/exercise/OrionJavaInstructorProjectCreator.kt b/src/main/java/de/tum/www1/orion/exercise/OrionJavaInstructorProjectCreator.kt index 145174f..eceec83 100644 --- a/src/main/java/de/tum/www1/orion/exercise/OrionJavaInstructorProjectCreator.kt +++ b/src/main/java/de/tum/www1/orion/exercise/OrionJavaInstructorProjectCreator.kt @@ -13,7 +13,7 @@ object OrionJavaInstructorProjectCreator { private const val BASE_TEMPLATE_PATH = "template/instructor_project" /** - * Prepares an exercise opened as instructor by editing the IntelliJ configuration to match the project setup + * Prepares an exercise opened as instructor by editing the IDE configuration to match the project setup * Uses the templates from /resources/template * * @param baseDir location of the project diff --git a/src/main/java/de/tum/www1/orion/ui/assessment/InlineAssessmentComment.kt b/src/main/java/de/tum/www1/orion/ui/assessment/InlineAssessmentComment.kt index 56202cc..d8d0b7e 100644 --- a/src/main/java/de/tum/www1/orion/ui/assessment/InlineAssessmentComment.kt +++ b/src/main/java/de/tum/www1/orion/ui/assessment/InlineAssessmentComment.kt @@ -8,12 +8,11 @@ import com.intellij.openapi.fileTypes.FileTypes import com.intellij.openapi.project.Project import com.intellij.openapi.util.Disposer import com.intellij.ui.EditorTextField -import com.intellij.ui.JBColor import de.tum.www1.orion.dto.Feedback import de.tum.www1.orion.exercise.OrionAssessmentService +import de.tum.www1.orion.ui.util.ColorUtils import de.tum.www1.orion.util.translate import java.awt.BorderLayout -import java.awt.Color import java.awt.Component import javax.swing.* import javax.swing.border.TitledBorder @@ -99,13 +98,19 @@ class InlineAssessmentComment( // create a border of the background color, so we don't have to set the color manually val textPanel = JPanel() - textPanel.border = BorderFactory.createTitledBorder(BorderFactory.createEmptyBorder(4, 1, 4, 2), translate("orion.exercise.assessment.feedback")) + textPanel.border = BorderFactory.createTitledBorder( + BorderFactory.createEmptyBorder(25, 5, 5, 5), + translate("orion.exercise.assessment.feedback") + ) textPanel.layout = BorderLayout() textPanel.add(gradingInstructionLabel, BorderLayout.NORTH) textPanel.add(textField.component, BorderLayout.CENTER) val spinnerPanel = JPanel() - spinnerPanel.border = BorderFactory.createTitledBorder(BorderFactory.createEmptyBorder(4,1,4,2), translate("orion.exercise.assessment.score")) + spinnerPanel.border = BorderFactory.createTitledBorder( + BorderFactory.createEmptyBorder(25, 5, 5, 10), + translate("orion.exercise.assessment.score") + ) spinnerPanel.layout = BorderLayout() spinnerPanel.add(spinner, BorderLayout.CENTER) @@ -218,24 +223,12 @@ class InlineAssessmentComment( private fun updateColor() { val spinnerValue = spinner.value.toString().toDouble() - // colors are the same as in Artemis - val color = when { - spinnerValue > 0 -> JBColor(0xd4edda, 0x00231a) - spinnerValue < 0 -> JBColor(0xf8d7da, 0x370b07) - else -> JBColor(0xfff3cd, 0x362203) - } - val textColor = when { - spinnerValue > 0 -> JBColor(0x186429, 0x8cb294) - spinnerValue < 0 -> JBColor(0x842029, 0xc29094) - else -> JBColor(0x664d03, 0xb3a681) - } - coloredBackgroundComponentList.forEach { - it.background = color + it.background = ColorUtils.getFeedbackColor(spinnerValue) } coloredForegroundComponentList.forEach { - (it as? TitledBorder)?.titleColor = textColor - (it as? JComponent)?.foreground = textColor + (it as? TitledBorder)?.titleColor = ColorUtils.getFeedbackTextColor(spinnerValue) + (it as? JComponent)?.foreground = ColorUtils.getFeedbackTextColor(spinnerValue) } } } diff --git a/src/main/java/de/tum/www1/orion/ui/feedback/FeedbackCommentEditorProvider.kt b/src/main/java/de/tum/www1/orion/ui/feedback/FeedbackCommentEditorProvider.kt new file mode 100644 index 0000000..4464963 --- /dev/null +++ b/src/main/java/de/tum/www1/orion/ui/feedback/FeedbackCommentEditorProvider.kt @@ -0,0 +1,46 @@ +package de.tum.www1.orion.ui.feedback + +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.fileEditor.FileEditor +import com.intellij.openapi.fileEditor.FileEditorPolicy +import com.intellij.openapi.fileEditor.FileEditorProvider +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.vfs.VirtualFile +import de.tum.www1.orion.exercise.OrionFeedbackService +import de.tum.www1.orion.util.translate + + +/** + * Provides an Editor that shows Feedback in IntelliJ + */ +class FeedbackCommentEditorProvider : FileEditorProvider, DumbAware { + override fun accept(project: Project, file: VirtualFile): Boolean { + //Check if there is feedback available + val relativePath = file.path.removePrefix(project.basePath.toString()).removePrefix("/") + return project.service().getFeedbackFor(relativePath) != null + } + + override fun createEditor(project: Project, file: VirtualFile): FileEditor { + val factory = EditorFactory.getInstance() + val viewer = file.let { it -> + FileDocumentManager.getInstance().getDocument(it)?.let { + factory.createEditor(it, project, file.fileType, true) + } + } ?: factory.createViewer(factory.createDocument(translate("orion.error.file.loaded")), project) + // remove base bath from the path + val relativePath = file.path.removePrefix(project.basePath.toString()).removePrefix("/") + val editor = OrionFeedbackCommentEditor(viewer, relativePath, file) + // dispose editor with the project + Disposer.register(project, editor) + return editor + } + + override fun getEditorTypeId(): String = "OrionFeedbackEditor" + + override fun getPolicy(): FileEditorPolicy = FileEditorPolicy.HIDE_DEFAULT_EDITOR + +} diff --git a/src/main/java/de/tum/www1/orion/ui/feedback/InlineFeedbackComment.kt b/src/main/java/de/tum/www1/orion/ui/feedback/InlineFeedbackComment.kt new file mode 100644 index 0000000..fb7bd79 --- /dev/null +++ b/src/main/java/de/tum/www1/orion/ui/feedback/InlineFeedbackComment.kt @@ -0,0 +1,101 @@ +package de.tum.www1.orion.ui.feedback + +import com.intellij.collaboration.ui.codereview.diff.EditorComponentInlaysManager +import com.intellij.openapi.Disposable +import com.intellij.openapi.fileTypes.FileTypes +import com.intellij.openapi.project.Project +import com.intellij.ui.EditorTextField +import de.tum.www1.orion.dto.Feedback +import de.tum.www1.orion.ui.util.ColorUtils +import de.tum.www1.orion.util.translate +import java.awt.BorderLayout +import java.awt.Component +import javax.swing.BorderFactory +import javax.swing.BoxLayout +import javax.swing.JComponent +import javax.swing.JPanel +import javax.swing.border.TitledBorder + +/** + * A ui class that holds a not editable feedback comment + */ +class InlineFeedbackComment( + private var feedback: Feedback, + inlaysManager: EditorComponentInlaysManager +) { + private var disposer: Disposable? + private val project: Project + private val coloredBackgroundComponentList: List + private val coloredForegroundComponentList: List + + val component: JComponent = JPanel() + private val textField: EditorTextField + private val pointsTextField: EditorTextField + private val buttonBar: JPanel = JPanel() + + init { + project = inlaysManager.editor.project!! + + // the text field must be an [EditorTextField], otherwise important keys like enter or delete will not get forwarded by IntelliJ + textField = EditorTextField(feedback.detailText, project, FileTypes.PLAIN_TEXT) + textField.isEnabled = false + textField.setOneLineMode(false) + textField.border = null + + // enter points + pointsTextField = EditorTextField(" " + feedback.credits.toString() + " ", project, FileTypes.PLAIN_TEXT) + pointsTextField.isEnabled = false + + // create a border of the background color, so we don't have to set the color manually + val textPanel = JPanel() + textPanel.border = BorderFactory.createTitledBorder( + BorderFactory.createEmptyBorder(25, 5, 5, 5), + translate("orion.exercise.assessment.feedback") + ) + textPanel.layout = BorderLayout() + textPanel.add(textField.component, BorderLayout.CENTER) + + val pointsPanel = JPanel() + pointsPanel.border = BorderFactory.createTitledBorder( + BorderFactory.createEmptyBorder(25, 5, 5, 10), + translate("orion.exercise.assessment.score") + ) + pointsPanel.layout = BorderLayout() + pointsPanel.add(pointsTextField, BorderLayout.CENTER) + + // create a for points + val rightBar = JPanel() + rightBar.isOpaque = false + rightBar.layout = BoxLayout(rightBar, BoxLayout.LINE_AXIS) + pointsPanel.alignmentX = Component.BOTTOM_ALIGNMENT + rightBar.add(pointsPanel) + + buttonBar.isOpaque = false + + component.layout = BorderLayout() + component.add(textPanel, BorderLayout.CENTER) + component.add(rightBar, BorderLayout.EAST) + component.add(buttonBar, BorderLayout.SOUTH) + + coloredBackgroundComponentList = + listOf( + component, + textPanel, + pointsPanel, + ) + coloredForegroundComponentList = listOf(textPanel.border, pointsPanel.border) + + updateColor() + disposer = inlaysManager.insertAfter(feedback.line!!, component) + } + + private fun updateColor() { + coloredBackgroundComponentList.forEach { + it.background = ColorUtils.getFeedbackColor(feedback.credits) + } + coloredForegroundComponentList.forEach { + (it as? TitledBorder)?.titleColor = ColorUtils.getFeedbackTextColor(feedback.credits) + (it as? JComponent)?.foreground = ColorUtils.getFeedbackTextColor(feedback.credits) + } + } +} \ No newline at end of file diff --git a/src/main/java/de/tum/www1/orion/ui/feedback/OrionFeedbackCommentEditor.kt b/src/main/java/de/tum/www1/orion/ui/feedback/OrionFeedbackCommentEditor.kt new file mode 100644 index 0000000..2ac1e66 --- /dev/null +++ b/src/main/java/de/tum/www1/orion/ui/feedback/OrionFeedbackCommentEditor.kt @@ -0,0 +1,65 @@ +package de.tum.www1.orion.ui.feedback + +import com.intellij.collaboration.ui.codereview.diff.EditorComponentInlaysManager +import com.intellij.diff.util.FileEditorBase +import com.intellij.openapi.components.service +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.impl.EditorImpl +import com.intellij.openapi.vfs.VirtualFile +import de.tum.www1.orion.exercise.OrionFeedbackService +import de.tum.www1.orion.util.OrionAssessmentUtils +import de.tum.www1.orion.util.translate +import javax.swing.JComponent +import javax.swing.JLabel + +/** + * A editor for Feedback comments providing a view for students to review feedback with maintaining the edit ability of their code. + */ +class OrionFeedbackCommentEditor( + private var myEditor: Editor, + private val relativePath: String, + private val file: VirtualFile +) : FileEditorBase() { + private val headerLabel: JLabel = + OrionAssessmentUtils.createHeader(translate("orion.exercise.feedbackModeLoading").uppercase()) + + init { + myEditor.headerComponent = headerLabel + + initializeFeedback() + } + + override fun getComponent(): JComponent = myEditor.component + + override fun getName(): String = translate("orion.exercise.feedback") + + override fun getPreferredFocusedComponent(): JComponent? = null + + // needed to avoid deprecation warning; Should return the file for which the provider was called, note however this editor is showing a different file + override fun getFile(): VirtualFile = file + + /** + * Requests the [OrionFeedbackService] for feedback comments for the opened file. + * If successful, adds the returned feedback comments as well as the gutter icons to create new comments to the editor + * If not, does nothing. Relies on the [OrionFeedbackService] to be called again if feedback becomes available + */ + fun initializeFeedback() { + // request feedback, if not yet initialized, abort + val feedback = myEditor.project?.service()?.getFeedbackFor(relativePath) ?: return + val editorImpl = myEditor as? EditorImpl ?: return + // inlays manager that manages the inline comments + val inlaysManager = EditorComponentInlaysManager(editorImpl) + // add feedback + feedback.forEach { + InlineFeedbackComment(it, inlaysManager) + } + // remove loading text + headerLabel.text = translate("orion.exercise.feedbackMode").uppercase() + } + + override fun dispose() { + super.dispose() + EditorFactory.getInstance().releaseEditor(myEditor) + } +} \ No newline at end of file diff --git a/src/main/java/de/tum/www1/orion/ui/util/ColorUtils.kt b/src/main/java/de/tum/www1/orion/ui/util/ColorUtils.kt new file mode 100644 index 0000000..98e7f1f --- /dev/null +++ b/src/main/java/de/tum/www1/orion/ui/util/ColorUtils.kt @@ -0,0 +1,38 @@ +package de.tum.www1.orion.ui.util + +import com.intellij.ui.JBColor + +/** + * Provides color values for Dark and Light-mode in several places + */ +class ColorUtils { + companion object { + + /** + * Returns the jetbrains color values for dark and light mode for a feedback box + * colors are the same as in Artemis + */ + fun getFeedbackColor(value: Double): JBColor { + + // colors are the same as in Artemis + return when { + // JB Color for Light and Dark themes + value > 0 -> JBColor(0xd4edda, 0x00231a) + value < 0 -> JBColor(0xf8d7da, 0x370b07) + else -> JBColor(0xfff3cd, 0x362203) + } + } + + /** + * Returns the jetbrains color values for dark and light mode for a feedback text + * colors are the same as in Artemis + */ + fun getFeedbackTextColor(value: Double): JBColor { + return when { + value > 0 -> JBColor(0x186429, 0x8cb294) + value < 0 -> JBColor(0x842029, 0xc29094) + else -> JBColor(0x664d03, 0xb3a681) + } + } + } +} \ No newline at end of file diff --git a/src/main/java/de/tum/www1/orion/ui/util/CommitMessageChooser.kt b/src/main/java/de/tum/www1/orion/ui/util/CommitMessageChooser.kt index 9a0862b..cc4def9 100644 --- a/src/main/java/de/tum/www1/orion/ui/util/CommitMessageChooser.kt +++ b/src/main/java/de/tum/www1/orion/ui/util/CommitMessageChooser.kt @@ -3,6 +3,7 @@ package de.tum.www1.orion.ui.util import com.intellij.openapi.components.service import com.intellij.openapi.project.Project import com.intellij.openapi.ui.DialogWrapper +import com.intellij.ui.dsl.builder.bindText import com.intellij.ui.dsl.builder.panel import de.tum.www1.orion.settings.OrionBundle import de.tum.www1.orion.settings.OrionSettingsProvider @@ -61,7 +62,9 @@ class CommitMessageChooser(val project: Project) : label(translate("orion.dialog.commitmessagechooser.title")) } row { - textField().component.text = settings.getSetting(OrionSettingsProvider.KEYS.COMMIT_MESSAGE) + commitMessageField = + textField().bindText({ settings.getSetting(OrionSettingsProvider.KEYS.COMMIT_MESSAGE) }, + {}).component } } diff --git a/src/main/java/de/tum/www1/orion/ui/util/ConfirmPasswordSaveDialog.kt b/src/main/java/de/tum/www1/orion/ui/util/ConfirmPasswordSaveDialog.kt index 352e39c..f0f1877 100644 --- a/src/main/java/de/tum/www1/orion/ui/util/ConfirmPasswordSaveDialog.kt +++ b/src/main/java/de/tum/www1/orion/ui/util/ConfirmPasswordSaveDialog.kt @@ -10,7 +10,7 @@ class ConfirmPasswordSaveDialog(project: Project?) : DialogWrapper(project) { private lateinit var confirmationPanel: JPanel init { - title = "Import Credentials Into IntelliJ" + title = "Import Credentials Into your IDE" init() isOKActionEnabled = true } @@ -19,7 +19,7 @@ class ConfirmPasswordSaveDialog(project: Project?) : DialogWrapper(project) { confirmationPanel = panel { row { label( - "Do you want to save your Artemis credentials in IntelliJ?\n" + + "Do you want to save your Artemis credentials in your IDE?\n" + "This makes importing and submitting exercises a lot easier!" ) } @@ -27,5 +27,4 @@ class ConfirmPasswordSaveDialog(project: Project?) : DialogWrapper(project) { return confirmationPanel } - } diff --git a/src/main/java/de/tum/www1/orion/ui/util/NotificationManager.kt b/src/main/java/de/tum/www1/orion/ui/util/NotificationManager.kt index dc731b8..95150a6 100644 --- a/src/main/java/de/tum/www1/orion/ui/util/NotificationManager.kt +++ b/src/main/java/de/tum/www1/orion/ui/util/NotificationManager.kt @@ -11,7 +11,7 @@ import com.intellij.openapi.project.Project * Inform the user about something using the notification balloon (bottom right corner). It is logged so the user can * open it later */ -@Service +@Service(Service.Level.PROJECT) class NotificationManager(val project: Project) { /** * Send a notification of type Orion Errors diff --git a/src/main/java/de/tum/www1/orion/util/OrionAssessmentUtils.kt b/src/main/java/de/tum/www1/orion/util/OrionAssessmentUtils.kt index 87f997d..ab00371 100644 --- a/src/main/java/de/tum/www1/orion/util/OrionAssessmentUtils.kt +++ b/src/main/java/de/tum/www1/orion/util/OrionAssessmentUtils.kt @@ -109,27 +109,61 @@ object OrionAssessmentUtils { file: VirtualFile ) { val filePath = file.fileSystem.getNioPath(file) ?: return - when { - filePath.startsWith(getTemplateOf(project)) -> manager.getEditors(file).forEach { - (it as? TextEditor)?.editor?.document?.setReadOnly(true) - } - filePath.startsWith(getStudentSubmissionOf(project)) -> { - manager.closeFile(file) - val relativePath = - getRelativePathForStudentSubmission(project, filePath) - val assignmentFile = - VirtualFileManager.getInstance().refreshAndFindFileByNioPath(getAssignmentOf(project).resolve(relativePath)) ?: return Unit.also { - project.notify(translate("orion.error.file.noAssignmentEquivalent")) - } - manager.openFile(assignmentFile, true) - } - filePath.startsWith(getAssignmentOf(project)) -> manager.getEditors(file).forEach { - (it as? TextEditor)?.editor?.headerComponent = - createHeader(translate("orion.exercise.editMode").uppercase()) + if (manager.getEditors(file).size > 1) { + when { + filePath.startsWith(getTemplateOf(project)) -> manager.getEditors(file).forEach { + (it as? TextEditor)?.editor?.document?.setReadOnly(true) + } + + filePath.startsWith(getStudentSubmissionOf(project)) -> { + manager.closeFile(file) + val relativePath = + getRelativePathForStudentSubmission(project, filePath) + val assignmentFile = + VirtualFileManager.getInstance() + .refreshAndFindFileByNioPath(getAssignmentOf(project).resolve(relativePath)) + ?: return Unit.also { + project.notify(translate("orion.error.file.noAssignmentEquivalent")) + } + manager.openFile(assignmentFile, true) + } + + filePath.startsWith(getAssignmentOf(project)) -> manager.getEditors(file).forEach { + (it as? TextEditor)?.editor?.headerComponent = + createHeader(translate("orion.exercise.editMode").uppercase()) + } + } } } + /** + * Configures the review editor if the exercise is ready for review + * @param project to configure the editors for + */ + fun configureEditorsForReview(project: Project) { + project.messageBus.connect() + .subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, object : FileEditorManagerListener { + override fun fileOpened(source: FileEditorManager, file: VirtualFile) { + configureEditorForReview(source, file) + } + }) + runInEdt { + val manager = FileEditorManager.getInstance(project) + manager.openFiles.forEach { configureEditorForReview(manager, it) } + } + } + + private fun configureEditorForReview( + manager: FileEditorManager, + file: VirtualFile + ) { + manager.getEditors(file).forEach { + (it as? TextEditor)?.editor?.headerComponent = + createHeader(translate("orion.exercise.editMode").uppercase()) + } + } + /** * Wraps the given String into a JLabel using a bold font with 1.5 times the default size */ diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 0487af8..feecb4b 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -55,6 +55,7 @@ + diff --git a/src/main/resources/i18n/OrionBundle.properties b/src/main/resources/i18n/OrionBundle.properties index a82c154..07fc656 100644 --- a/src/main/resources/i18n/OrionBundle.properties +++ b/src/main/resources/i18n/OrionBundle.properties @@ -46,6 +46,11 @@ orion.exercise.assessment.sgi.delete=Do you want to remove the link to the asses orion.exercise.assessment.sgi.tooltip=This feedback is associated with an assessment instruction. You can provide additional feedback for this submission element. The student will see the combined feedback during the review. orion.exercise.assessment.feedback=Feedback orion.exercise.assessment.score=Points +# Feedback related +orion.exercise.feedback=Feedback +orion.exercise.noFeedback=No feedback available! +orion.exercise.feedbackMode=Feedback Mode +orion.exercise.feedbackModeLoading=Feedback Mode loading. Open Orion if this does not happen automatically.\ orion.warning.auxiliaryRepositories=Auxiliary Repositories detected. Orion did download the repositories, but configuration of them is not yet supported. orion.warning.assessment.submissionId=Id of the opened submission and the loaded Artemis page do not match, loading of feedback aborted. Make sure to open the correct submission in Orion. diff --git a/src/main/resources/i18n/OrionBundle_de.properties b/src/main/resources/i18n/OrionBundle_de.properties index 57fe1e6..0bdf4d7 100644 --- a/src/main/resources/i18n/OrionBundle_de.properties +++ b/src/main/resources/i18n/OrionBundle_de.properties @@ -45,7 +45,13 @@ orion.exercise.assessment.sgi.link=Bewertungsanweisung: %s orion.exercise.assessment.sgi.delete=Möchtest du den Link zur Bewertungsanweisung entfernen? orion.exercise.assessment.sgi.tooltip=Dieses Feedback ist mit einer Bewertungsanweisung verbunden. Du kannst zusätzliches Feedback für dieses Abgabeelement geben. Der/Die Studierende sieht das kombinierte Feedback während der Überprüfung. orion.exercise.assessment.feedback=Feedback -orion.exercise.assessment.score=Punktzahl +# short form Punkte because Punktzahl is too long +orion.exercise.assessment.score=Punkte +# Feedback related +orion.exercise.feedback=Feedback +orion.exercise.noFeedback=Kein Feedback vorhanden! +orion.exercise.feedbackMode=Feedbackmodus +orion.exercise.feedbackModeLoading=Feedbackmodus lädt. Öffne Orion wenn dies nicht automatisch passiert. orion.warning.auxiliaryRepositories=Auxiliary Repositories erkannt. Orion hat die Repositories heruntergeladen, aber ihre Konfiguration wird noch nicht unterstützt. orion.warning.assessment.submissionId=Id der geöffneten Abgabe stimmt nicht mit der in Artemis geladenen Seite überein. Laden des Feedbacks wurde abgebrochen. Stelle sicher, dass die korrekte Abgabe in Orion geladen ist.