diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index e4e3516..5e6b94b 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -18,10 +18,10 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up JDK 17 + - name: Set up JDK 21 uses: actions/setup-java@v4 with: - java-version: '17' + java-version: '21' distribution: 'temurin' - name: Build project with gradle uses: gradle/actions/setup-gradle@v4 diff --git a/.gitignore b/.gitignore index f2e5ce7..2341391 100644 --- a/.gitignore +++ b/.gitignore @@ -180,4 +180,5 @@ gradle-app.setting # example project that is opened by the test IntelliJ instance exampleProject/ -grading-config-example.json \ No newline at end of file +grading-config-example.json +.intellijPlatform/ \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f3c806c --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml index 77c2b59..3f521fc 100644 --- a/.idea/jarRepositories.xml +++ b/.idea/jarRepositories.xml @@ -24,12 +24,37 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 8801e6f..ba0541a 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -7,7 +7,7 @@ - + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 7dc722e..fc10e6e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ // See https://github.com/JetBrains/gradle-intellij-plugin/ plugins { - id 'org.jetbrains.intellij' version '1.17.4' + id("org.jetbrains.intellij.platform") version "2.0.1" id 'java' id 'com.diffplug.spotless' version '6.25.0' } @@ -8,26 +8,42 @@ plugins { group 'edu.kit.kastel.sdq' version '1.0-SNAPSHOT' +java { + sourceCompatibility = "21" + targetCompatibility = "21" +} + repositories { - mavenCentral() mavenLocal() + mavenCentral() + intellijPlatform { + defaultRepositories() + } + maven { + url 'https://s01.oss.sonatype.org/content/repositories/snapshots' + } } dependencies { - implementation 'edu.kit.kastel.sdq:artemis4j:6.7.5' - implementation 'org.eclipse.jgit:org.eclipse.jgit:6.10.0.202406032230-r' - implementation 'org.apache.commons:commons-io:1.3.2' - implementation 'de.firemage.autograder:autograder-cmd:' + AUTOGRADER_VERION - implementation 'de.firemage.autograder:autograder-core:' + AUTOGRADER_VERION - implementation 'de.firemage.autograder:autograder-extra:' + AUTOGRADER_VERION + intellijPlatform { + intellijIdeaCommunity('2024.2.1') + pluginVerifier() + bundledPlugin("com.intellij.java") + bundledPlugin("org.jetbrains.idea.maven") + instrumentationTools() + } + implementation 'edu.kit.kastel.sdq:artemis4j:7.5.0-SNAPSHOT' // Tests testImplementation 'org.junit.jupiter:junit-jupiter-api:' + JUNIT_VERSION testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:' + JUNIT_VERSION + testImplementation 'com.tngtech.archunit:archunit:' + ARCHUNIT_VERSION + testImplementation 'com.tngtech.archunit:archunit-junit5:' + ARCHUNIT_VERSION } -intellij { - version = '2024.1.4' - plugins = ['com.intellij.java'] +intellijPlatform { + pluginConfiguration { + name = "IntelliGrade" + } } patchPluginXml { changeNotes = """""" @@ -38,7 +54,7 @@ test { tasks { runIde { - autoReloadPlugins = true + autoReload = true } buildSearchableOptions { diff --git a/gradle.properties b/gradle.properties index c7a53c6..df4fc35 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ -AUTOGRADER_VERION = 0.5.41 JUNIT_VERSION = 5.11.1 +ARCHUNIT_VERSION = 1.3.0 diff --git a/src/main/java/edu/kit/kastel/actions/edu/kit/kastel/actions/AddAnnotationPopupAction.java b/src/main/java/edu/kit/kastel/actions/edu/kit/kastel/actions/AddAnnotationPopupAction.java deleted file mode 100644 index 7a1ca3a..0000000 --- a/src/main/java/edu/kit/kastel/actions/edu/kit/kastel/actions/AddAnnotationPopupAction.java +++ /dev/null @@ -1,70 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.actions.edu.kit.kastel.actions; - -import java.util.List; - -import com.intellij.openapi.actionSystem.ActionUpdateThread; -import com.intellij.openapi.actionSystem.AnAction; -import com.intellij.openapi.actionSystem.AnActionEvent; -import com.intellij.openapi.actionSystem.CommonDataKeys; -import com.intellij.openapi.editor.Caret; -import com.intellij.openapi.ui.popup.JBPopupFactory; -import edu.kit.kastel.sdq.artemis4j.api.grading.IMistakeType; -import edu.kit.kastel.state.AssessmentModeHandler; -import edu.kit.kastel.utils.AnnotationUtils; -import edu.kit.kastel.wrappers.DisplayableMistakeType; -import org.jetbrains.annotations.NotNull; - -public class AddAnnotationPopupAction extends AnAction { - - @Override - public @NotNull ActionUpdateThread getActionUpdateThread() { - return ActionUpdateThread.BGT; - } - - @Override - public void update(@NotNull AnActionEvent e) { - Caret caret = e.getData(CommonDataKeys.CARET); - - // if no exercise config is loaded, we cannot make annotations - // if there is no caret we can not sensibly display a popup - e.getPresentation() - .setEnabledAndVisible(caret != null - && AssessmentModeHandler.getInstance() - .getCurrentExerciseConfig() - .isPresent()); - } - - @Override - public void actionPerformed(@NotNull AnActionEvent e) { - - Caret caret = e.getData(CommonDataKeys.CARET); - - // if no exercise config is loaded, we cannot make annotations - // if there is no caret we can not sensibly display a popup - if (caret == null - || AssessmentModeHandler.getInstance() - .getCurrentExerciseConfig() - .isEmpty()) { - return; - } - - // collect all mistake types that can be annotated - List mistakeTypes = - AssessmentModeHandler.getInstance().getCurrentExerciseConfig().get().getRatingGroups().stream() - .flatMap(ratingGroup -> ratingGroup.getMistakeTypes().stream()) - .toList(); - - // create a popup with all possible mistakes - JBPopupFactory.getInstance() - .createPopupChooserBuilder( - mistakeTypes.stream().map(DisplayableMistakeType::new).toList()) - .setItemChosenCallback(this::addQuickAnnotation) - .createPopup() - .showInBestPositionFor(caret.getEditor()); - } - - private void addQuickAnnotation(@NotNull DisplayableMistakeType mistakeType) { - AnnotationUtils.addAnnotationByMistakeType(mistakeType.getWrappedValue()); - } -} diff --git a/src/main/java/edu/kit/kastel/exceptions/ImplementationMissing.java b/src/main/java/edu/kit/kastel/exceptions/ImplementationMissing.java deleted file mode 100644 index 471de1b..0000000 --- a/src/main/java/edu/kit/kastel/exceptions/ImplementationMissing.java +++ /dev/null @@ -1,12 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.exceptions; - -/** - * This exception should be thrown if e.g. an event triggers a method call that should never occur - */ -public class ImplementationMissing extends RuntimeException { - - public ImplementationMissing(String message) { - super(message); - } -} diff --git a/src/main/java/edu/kit/kastel/extensions/guis/AnnotationsTableModel.java b/src/main/java/edu/kit/kastel/extensions/guis/AnnotationsTableModel.java deleted file mode 100644 index 1e2fc15..0000000 --- a/src/main/java/edu/kit/kastel/extensions/guis/AnnotationsTableModel.java +++ /dev/null @@ -1,85 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.extensions.guis; - -import java.util.ArrayList; -import java.util.List; - -import javax.swing.table.AbstractTableModel; - -import com.intellij.DynamicBundle; -import edu.kit.kastel.sdq.artemis4j.grading.model.annotation.Annotation; -import edu.kit.kastel.utils.AssessmentUtils; -import edu.kit.kastel.wrappers.AnnotationWithTextSelection; - -/** - * The table model for the annotations table. - */ -public class AnnotationsTableModel extends AbstractTableModel { - - private static final String[] HEADINGS = { - "Mistake type", "Start Line", "End Line", "File", "Message", "Custom Penalty" - }; - - private static final String LOCALE = DynamicBundle.getLocale().getLanguage(); - - private final List annotations = new ArrayList<>(); - - @Override - public int getRowCount() { - return annotations.size(); - } - - @Override - public int getColumnCount() { - return HEADINGS.length; - } - - @Override - public String getColumnName(int column) { - return HEADINGS[column]; - } - - @Override - public Object getValueAt(int i, int i1) { - Annotation queriedAnnotation = annotations.get(i); - - if (queriedAnnotation == null) { - return ""; - } - - return switch (i1) { - case 0 -> queriedAnnotation.getMistakeType().getButtonText(LOCALE); - case 1 -> queriedAnnotation.getStartLine(); - case 2 -> queriedAnnotation.getEndLine(); - case 3 -> queriedAnnotation.getClassFilePath(); - case 4 -> queriedAnnotation.getCustomMessage().orElse(""); - case 5 -> queriedAnnotation.getCustomPenalty().orElse(0.0); - default -> { - System.err.printf("No table data at index %d:%d\n", i, i1); - yield "n.A."; - } - }; - } - - /** - * Add an annotation to the Table model. - * - * @param annotation the Annotation to be added - */ - public void addAnnotation(AnnotationWithTextSelection annotation) { - this.annotations.add(annotation); - } - - /** - * Delete an annotation with a specified index from the Table, its model and the AnnotationManager - * - * @param itemIndex the index of the item to be deleted TODO: not necesarrily the same as - * the selected item if a TableSorter is used. Implement sorting. - */ - public void deleteItem(int itemIndex) { - AnnotationWithTextSelection annotation = this.annotations.get(itemIndex); - annotation.deleteHighlighter(); - AssessmentUtils.deleteAnnotation(annotation); - this.annotations.remove(annotation); - } -} diff --git a/src/main/java/edu/kit/kastel/extensions/guis/AnnotationsViewContent.java b/src/main/java/edu/kit/kastel/extensions/guis/AnnotationsViewContent.java deleted file mode 100644 index 8fb758d..0000000 --- a/src/main/java/edu/kit/kastel/extensions/guis/AnnotationsViewContent.java +++ /dev/null @@ -1,78 +0,0 @@ -/* Licensed under EPL-2.0 2023. */ -package edu.kit.kastel.extensions.guis; - -import java.awt.event.*; - -import javax.swing.*; - -import com.intellij.ui.components.*; -import com.intellij.ui.table.*; -import edu.kit.kastel.utils.AssessmentUtils; -import edu.kit.kastel.wrappers.PlugInEventListener; - -/** - * @author clemens - */ -public class AnnotationsViewContent extends JPanel implements PlugInEventListener { - public AnnotationsViewContent() { - initComponents(); - } - - private void createUIComponents() { - // TODO: add custom component creation code here - } - - private void annotationsTableKeyReleased(KeyEvent e) { - if (e.getKeyCode() == KeyEvent.VK_DELETE) { - int selectedRow = annotationsTable.getSelectedRow(); - if (selectedRow != -1) { - ((AnnotationsTableModel) this.annotationsTable.getModel()).deleteItem(selectedRow); - this.updateUI(); - } - } - } - - private void initComponents() { - // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents @formatter:off - // Generated using JFormDesigner Evaluation license - CHServer root Passwort - scrollPane1 = new JBScrollPane(); - annotationsTable = new JBTable(new AnnotationsTableModel()); - - //======== this ======== - setBorder(new javax.swing.border.CompoundBorder(new javax.swing.border.TitledBorder(new javax.swing.border. - EmptyBorder(0,0,0,0), "JF\u006frm\u0044es\u0069gn\u0065r \u0045va\u006cua\u0074io\u006e",javax.swing.border.TitledBorder.CENTER,javax.swing - .border.TitledBorder.BOTTOM,new java.awt.Font("D\u0069al\u006fg",java.awt.Font.BOLD,12), - java.awt.Color.red), getBorder())); addPropertyChangeListener(new java.beans.PropertyChangeListener() - {@Override public void propertyChange(java.beans.PropertyChangeEvent e){if("\u0062or\u0064er".equals(e.getPropertyName())) - throw new RuntimeException();}}); - setLayout(new BoxLayout(this, BoxLayout.X_AXIS)); - - //======== scrollPane1 ======== - { - - //---- annotationsTable ---- - annotationsTable.addKeyListener(new KeyAdapter() { - @Override - public void keyReleased(KeyEvent e) { - annotationsTableKeyReleased(e); - } - }); - scrollPane1.setViewportView(annotationsTable); - } - add(scrollPane1); - // JFormDesigner - End of component initialization //GEN-END:initComponents @formatter:on - } - - // JFormDesigner - Variables declaration - DO NOT MODIFY //GEN-BEGIN:variables @formatter:off - // Generated using JFormDesigner Evaluation license - CHServer root Passwort - private JBScrollPane scrollPane1; - private JBTable annotationsTable; - // JFormDesigner - End of variables declaration //GEN-END:variables @formatter:on - - @Override - public void trigger() { - AnnotationsTableModel model = ((AnnotationsTableModel) this.annotationsTable.getModel()); - model.addAnnotation(AssessmentUtils.getLatestAnnotation()); - this.annotationsTable.updateUI(); - } -} diff --git a/src/main/java/edu/kit/kastel/extensions/guis/AnnotationsViewContent.jfd b/src/main/java/edu/kit/kastel/extensions/guis/AnnotationsViewContent.jfd deleted file mode 100644 index d0146d6..0000000 --- a/src/main/java/edu/kit/kastel/extensions/guis/AnnotationsViewContent.jfd +++ /dev/null @@ -1,25 +0,0 @@ -JFDML JFormDesigner: "8.2.0.0.331" Java: "17.0.9" encoding: "UTF-8" - -new FormModel { - contentType: "form/swing" - root: new FormRoot { - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class javax.swing.BoxLayout ) { - "axis": 0 - } ) { - name: "this" - add( new FormContainer( "com.intellij.ui.components.JBScrollPane", new FormLayoutManager( class javax.swing.JScrollPane ) ) { - name: "scrollPane1" - add( new FormComponent( "com.intellij.ui.table.JBTable" ) { - name: "annotationsTable" - auxiliary() { - "JavaCodeGenerator.customCreateCode": "new JBTable(new AnnotationsTableModel());" - } - addEvent( new FormEvent( "java.awt.event.KeyListener", "keyReleased", "annotationsTableKeyReleased", true ) ) - } ) - } ) - }, new FormLayoutConstraints( null ) { - "location": new java.awt.Point( 0, 0 ) - "size": new java.awt.Dimension( 955, 300 ) - } ) - } -} diff --git a/src/main/java/edu/kit/kastel/extensions/guis/AssessmentViewContent.java b/src/main/java/edu/kit/kastel/extensions/guis/AssessmentViewContent.java deleted file mode 100644 index 99b42de..0000000 --- a/src/main/java/edu/kit/kastel/extensions/guis/AssessmentViewContent.java +++ /dev/null @@ -1,406 +0,0 @@ -/* Licensed under EPL-2.0 2023. */ -package edu.kit.kastel.extensions.guis; - -import java.awt.*; -import java.util.*; - -import javax.swing.*; -import javax.swing.border.*; - -import com.intellij.openapi.ui.*; -import com.intellij.ui.components.*; -import com.intellij.ui.table.*; -import com.intellij.uiDesigner.core.*; -import edu.kit.kastel.sdq.artemis4j.api.artemis.Course; -import edu.kit.kastel.sdq.artemis4j.api.artemis.Exercise; -import edu.kit.kastel.sdq.artemis4j.api.artemis.exam.Exam; -import edu.kit.kastel.wrappers.Displayable; -import net.miginfocom.swing.*; - -/** - * @author clemens - */ -public class AssessmentViewContent extends JPanel { - public AssessmentViewContent() { - initComponents(); - } - - public ComboBox> getCoursesDropdown() { - return coursesDropdown; - } - - public ComboBox> getExamsDropdown() { - return examsDropdown; - } - - public ComboBox> getExercisesDropdown() { - return exercisesDropdown; - } - - public TextFieldWithBrowseButton getGradingConfigPathInput() { - return gradingConfigPathInput; - } - - public TextFieldWithBrowseButton getAutograderConfigPathInput() { - return autograderConfigPathInput; - } - - public JButton getBtnGradingRound1() { - return btnGradingRound1; - } - - public JPanel getRatingGroupContainer() { - return ratingGroupContainer; - } - - public JButton getBtnSaveAssessment() { - return btnSaveAssessment; - } - - public JButton getSubmitAssesmentBtn() { - return submitAssesmentBtn; - } - - public JBLabel getAssessmentModeLabel() { - return assessmentModeLabel; - } - - public StatisticsContainer getStatisticsContainer() { - return statisticsContainer; - } - - private void createUIComponents() { - // TODO: add custom component creation code here - } - - private void initComponents() { - // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents @formatter:off - // Generated using JFormDesigner Evaluation license - CHServer root Passwort - ResourceBundle bundle = ResourceBundle.getBundle("guiStrings"); - var tabbedPane1 = new JBTabbedPane(); - scrollPane2 = new JBScrollPane(); - var AssessmentPanel = new JPanel(); - var label1 = new JLabel(); - coursesDropdown = new ComboBox<>(); - var label2 = new JLabel(); - examsDropdown = new ComboBox<>(); - var label3 = new JLabel(); - exercisesDropdown = new ComboBox<>(); - var label5 = new JLabel(); - gradingConfigPathInput = new TextFieldWithBrowseButton(); - var label6 = new JLabel(); - autograderConfigPathInput = new TextFieldWithBrowseButton(); - var separator1 = new JSeparator(); - var generalPanel = new JPanel(); - btnGradingRound1 = new JButton(); - btnGradingRound2 = new JButton(); - button5 = new JButton(); - var assessmentPanel = new JPanel(); - btnSaveAssessment = new JButton(); - submitAssesmentBtn = new JButton(); - button3 = new JButton(); - button4 = new JButton(); - var panel5 = new JPanel(); - var label8 = new JBLabel(); - statisticsContainer = new StatisticsContainer(); - label9 = new JBLabel(); - assessmentModeLabel = new JBLabel(); - var panel3 = new JPanel(); - var label7 = new JLabel(); - backlogSelector = new ComboBox(); - panel4 = new JPanel(); - button6 = new JButton(); - button7 = new JButton(); - GradingPanel = new JPanel(); - scrollPane = new JScrollPane(); - scrollPane.getVerticalScrollBar().setUnitIncrement(16); - ratingGroupContainer = new JPanel(); - TestResultsPanel = new JPanel(); - var label4 = new JLabel(); - var scrollPane1 = new JBScrollPane(); - testResultsTable = new JBTable(); - - //======== this ======== - setBorder (new javax. swing. border. CompoundBorder( new javax .swing .border .TitledBorder (new javax. swing. border. - EmptyBorder( 0, 0, 0, 0) , "JF\u006frmD\u0065sig\u006eer \u0045val\u0075ati\u006fn", javax. swing. border. TitledBorder. CENTER, javax. swing - . border. TitledBorder. BOTTOM, new java .awt .Font ("Dia\u006cog" ,java .awt .Font .BOLD ,12 ), - java. awt. Color. red) , getBorder( )) ); addPropertyChangeListener (new java. beans. PropertyChangeListener( ) - { @Override public void propertyChange (java .beans .PropertyChangeEvent e) {if ("\u0062ord\u0065r" .equals (e .getPropertyName () )) - throw new RuntimeException( ); }} ); - setLayout(new BoxLayout(this, BoxLayout.X_AXIS)); - - //======== tabbedPane1 ======== - { - - //======== scrollPane2 ======== - { - - //======== AssessmentPanel ======== - { - AssessmentPanel.setLayout(new MigLayout( - "fillx,insets 0,hidemode 3,align left top,gap 5 5", - // columns - "[80:115,fill]" + - "[151,grow,fill]", - // rows - "[]" + - "[]" + - "[]" + - "[30]" + - "[35:30]" + - "[10:10]" + - "[]" + - "[]" + - "[]" + - "[]" + - "[]")); - - //---- label1 ---- - label1.setText(bundle.getString("AssesmentViewContent.label1.text")); - AssessmentPanel.add(label1, "pad 0,cell 0 0,alignx label,growx 0"); - AssessmentPanel.add(coursesDropdown, "cell 1 0"); - - //---- label2 ---- - label2.setText(bundle.getString("AssesmentViewContent.label2.text")); - AssessmentPanel.add(label2, "cell 0 1,alignx label,growx 0"); - AssessmentPanel.add(examsDropdown, "cell 1 1"); - - //---- label3 ---- - label3.setText(bundle.getString("AssesmentViewContent.label3.text")); - AssessmentPanel.add(label3, "cell 0 2,alignx label,growx 0"); - AssessmentPanel.add(exercisesDropdown, "cell 1 2"); - - //---- label5 ---- - label5.setText("Grading config"); - AssessmentPanel.add(label5, "cell 0 3,alignx label,growx 0"); - - //---- gradingConfigPathInput ---- - gradingConfigPathInput.setEditable(false); - AssessmentPanel.add(gradingConfigPathInput, "cell 1 3"); - - //---- label6 ---- - label6.setText("Autograder config"); - AssessmentPanel.add(label6, "cell 0 4,alignx label,growx 0"); - - //---- autograderConfigPathInput ---- - autograderConfigPathInput.setEditable(false); - AssessmentPanel.add(autograderConfigPathInput, "cell 1 4"); - AssessmentPanel.add(separator1, "cell 0 5 2 1"); - - //======== generalPanel ======== - { - generalPanel.setBorder(new CompoundBorder( - new TitledBorder(new LineBorder(Color.darkGray, 1, true), bundle.getString("AssesmentViewContent.generalPanel.border")), - new EmptyBorder(5, 5, 5, 5))); - generalPanel.setForeground(Color.blue); - generalPanel.setLayout(new BorderLayout()); - - //---- btnGradingRound1 ---- - btnGradingRound1.setText(bundle.getString("AssesmentViewContent.btnGradingRound1.text")); - generalPanel.add(btnGradingRound1, BorderLayout.CENTER); - - //---- btnGradingRound2 ---- - btnGradingRound2.setText(bundle.getString("AssesmentViewContent.btnGradingRound2.text")); - generalPanel.add(btnGradingRound2, BorderLayout.NORTH); - - //---- button5 ---- - button5.setText(bundle.getString("AssesmentViewContent.button5.text")); - generalPanel.add(button5, BorderLayout.SOUTH); - } - AssessmentPanel.add(generalPanel, "cell 0 6 2 1,growx"); - - //======== assessmentPanel ======== - { - assessmentPanel.setBorder(new CompoundBorder( - new TitledBorder(new LineBorder(Color.darkGray, 1, true), bundle.getString("AssesmentViewContent.assessmentPanel.border")), - new EmptyBorder(5, 5, 5, 5))); - assessmentPanel.setLayout(new GridBagLayout()); - ((GridBagLayout)assessmentPanel.getLayout()).columnWidths = new int[] {0, 0, 0}; - ((GridBagLayout)assessmentPanel.getLayout()).rowHeights = new int[] {0, 0, 0}; - ((GridBagLayout)assessmentPanel.getLayout()).columnWeights = new double[] {1.0, 1.0, 1.0E-4}; - ((GridBagLayout)assessmentPanel.getLayout()).rowWeights = new double[] {0.0, 0.0, 1.0E-4}; - - //---- btnSaveAssessment ---- - btnSaveAssessment.setText(bundle.getString("AssesmentViewContent.btnSaveAssessment.text")); - assessmentPanel.add(btnSaveAssessment, new GridBagConstraints(0, 0, 1, 1, 0.0, 0.0, - GridBagConstraints.CENTER, GridBagConstraints.BOTH, - new Insets(0, 0, 5, 5), 0, 0)); - - //---- submitAssesmentBtn ---- - submitAssesmentBtn.setText(bundle.getString("AssesmentViewContent.submitAssesmentBtn.text")); - assessmentPanel.add(submitAssesmentBtn, new GridBagConstraints(1, 0, 1, 1, 0.0, 0.0, - GridBagConstraints.CENTER, GridBagConstraints.BOTH, - new Insets(0, 0, 5, 0), 0, 0)); - - //---- button3 ---- - button3.setText(bundle.getString("AssesmentViewContent.button3.text")); - assessmentPanel.add(button3, new GridBagConstraints(0, 1, 1, 1, 0.0, 0.0, - GridBagConstraints.CENTER, GridBagConstraints.BOTH, - new Insets(0, 0, 0, 5), 0, 0)); - - //---- button4 ---- - button4.setText(bundle.getString("AssesmentViewContent.button4.text")); - assessmentPanel.add(button4, new GridBagConstraints(1, 1, 1, 1, 0.0, 0.0, - GridBagConstraints.CENTER, GridBagConstraints.BOTH, - new Insets(0, 0, 0, 0), 0, 0)); - } - AssessmentPanel.add(assessmentPanel, "cell 0 7 2 1"); - - //======== panel5 ======== - { - panel5.setBorder(new TitledBorder(new LineBorder(Color.darkGray, 1, true), bundle.getString("AssesmentViewContent.panel5.border"))); - panel5.setLayout(new GridLayoutManager(2, 2, new Insets(0, 0, 0, 0), 0, 0)); - - //---- label8 ---- - label8.setText(bundle.getString("AssesmentViewContent.label8.text")); - panel5.add(label8, new GridConstraints(0, 0, 1, 1, - GridConstraints.ANCHOR_CENTER, GridConstraints.FILL_NONE, - GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, - GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, - null, null, null)); - panel5.add(statisticsContainer, new GridConstraints(0, 1, 1, 1, - GridConstraints.ANCHOR_CENTER, GridConstraints.FILL_NONE, - GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, - GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, - null, null, null)); - - //---- label9 ---- - label9.setText(bundle.getString("AssesmentViewContent.label9.text")); - panel5.add(label9, new GridConstraints(1, 0, 1, 1, - GridConstraints.ANCHOR_CENTER, GridConstraints.FILL_NONE, - GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, - GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, - null, null, null)); - - //---- assessmentModeLabel ---- - assessmentModeLabel.setText("\u274c"); - assessmentModeLabel.setIcon(null); - assessmentModeLabel.setHorizontalAlignment(SwingConstants.LEFT); - panel5.add(assessmentModeLabel, new GridConstraints(1, 1, 1, 1, - GridConstraints.ANCHOR_CENTER, GridConstraints.FILL_NONE, - GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, - GridConstraints.SIZEPOLICY_CAN_SHRINK | GridConstraints.SIZEPOLICY_CAN_GROW, - null, null, null)); - } - AssessmentPanel.add(panel5, "pad 0,cell 0 8 2 1,growx"); - - //======== panel3 ======== - { - panel3.setBorder(new TitledBorder(new LineBorder(Color.darkGray), bundle.getString("AssesmentViewContent.panel3.border"))); - panel3.setLayout(new GridBagLayout()); - ((GridBagLayout)panel3.getLayout()).columnWidths = new int[] {85, 0, 0}; - ((GridBagLayout)panel3.getLayout()).rowHeights = new int[] {0, 0, 0}; - ((GridBagLayout)panel3.getLayout()).columnWeights = new double[] {0.0, 1.0, 1.0E-4}; - ((GridBagLayout)panel3.getLayout()).rowWeights = new double[] {0.0, 0.0, 1.0E-4}; - - //---- label7 ---- - label7.setText(bundle.getString("AssesmentViewContent.label7.text")); - panel3.add(label7, new GridBagConstraints(0, 0, 1, 1, 0.0, 0.0, - GridBagConstraints.EAST, GridBagConstraints.VERTICAL, - new Insets(0, 0, 5, 5), 0, 0)); - panel3.add(backlogSelector, new GridBagConstraints(1, 0, 1, 1, 0.0, 0.0, - GridBagConstraints.CENTER, GridBagConstraints.BOTH, - new Insets(0, 0, 5, 0), 0, 0)); - - //======== panel4 ======== - { - panel4.setLayout(new FlowLayout()); - - //---- button6 ---- - button6.setText(bundle.getString("AssesmentViewContent.button6.text")); - panel4.add(button6); - - //---- button7 ---- - button7.setText(bundle.getString("AssesmentViewContent.button7.text")); - panel4.add(button7); - } - panel3.add(panel4, new GridBagConstraints(0, 1, 2, 1, 0.0, 0.0, - GridBagConstraints.CENTER, GridBagConstraints.BOTH, - new Insets(0, 0, 0, 0), 0, 0)); - } - AssessmentPanel.add(panel3, "cell 0 9 2 1"); - } - scrollPane2.setViewportView(AssessmentPanel); - } - tabbedPane1.addTab(bundle.getString("AssesmentViewContent.assessmentPanel.border"), scrollPane2); - - //======== GradingPanel ======== - { - GradingPanel.setLayout(new MigLayout( - "fillx,hidemode 3,align left top", - // columns - "[fill]", - // rows - "[grow]")); - - //======== scrollPane ======== - { - scrollPane.setBorder(BorderFactory.createEmptyBorder()); - - //======== ratingGroupContainer ======== - { - ratingGroupContainer.setLayout(new BoxLayout(ratingGroupContainer, BoxLayout.Y_AXIS)); - } - scrollPane.setViewportView(ratingGroupContainer); - } - GradingPanel.add(scrollPane, "cell 0 0,growy"); - } - tabbedPane1.addTab(bundle.getString("AssesmentViewContent.GradingPanel.tab.title"), GradingPanel); - - //======== TestResultsPanel ======== - { - TestResultsPanel.setLayout(new MigLayout( - "fillx,hidemode 3,align left top", - // columns - "[fill]" + - "[fill]", - // rows - "[36]" + - "[grow]")); - - //---- label4 ---- - label4.setText(bundle.getString("AssesmentViewContent.label4.text")); - label4.setFont(label4.getFont().deriveFont(label4.getFont().getStyle() | Font.BOLD)); - TestResultsPanel.add(label4, "cell 0 0 2 1"); - - //======== scrollPane1 ======== - { - scrollPane1.setViewportView(testResultsTable); - } - TestResultsPanel.add(scrollPane1, "cell 0 1 2 1,growy"); - } - tabbedPane1.addTab(bundle.getString("AssesmentViewContent.TestResultsPanel.tab.title"), TestResultsPanel); - } - add(tabbedPane1); - // JFormDesigner - End of component initialization //GEN-END:initComponents @formatter:on - } - - // JFormDesigner - Variables declaration - DO NOT MODIFY //GEN-BEGIN:variables @formatter:off - // Generated using JFormDesigner Evaluation license - CHServer root Passwort - private JBScrollPane scrollPane2; - private ComboBox> coursesDropdown; - private ComboBox> examsDropdown; - private ComboBox> exercisesDropdown; - private TextFieldWithBrowseButton gradingConfigPathInput; - private TextFieldWithBrowseButton autograderConfigPathInput; - private JButton btnGradingRound1; - private JButton btnGradingRound2; - private JButton button5; - private JButton btnSaveAssessment; - private JButton submitAssesmentBtn; - private JButton button3; - private JButton button4; - private StatisticsContainer statisticsContainer; - private JBLabel label9; - private JBLabel assessmentModeLabel; - private ComboBox backlogSelector; - private JPanel panel4; - private JButton button6; - private JButton button7; - private JPanel GradingPanel; - private JScrollPane scrollPane; - private JPanel ratingGroupContainer; - private JPanel TestResultsPanel; - private JBTable testResultsTable; - // JFormDesigner - End of variables declaration //GEN-END:variables @formatter:on -} diff --git a/src/main/java/edu/kit/kastel/extensions/guis/AssessmentViewContent.jfd b/src/main/java/edu/kit/kastel/extensions/guis/AssessmentViewContent.jfd deleted file mode 100644 index 91fdd57..0000000 --- a/src/main/java/edu/kit/kastel/extensions/guis/AssessmentViewContent.jfd +++ /dev/null @@ -1,361 +0,0 @@ -JFDML JFormDesigner: "8.2.0.0.331" Java: "17.0.9" encoding: "UTF-8" - -new FormModel { - "i18n.bundleName": "guiStrings" - "i18n.keyPrefix": "AssesmentViewContent" - "i18n.autoExternalize": true - contentType: "form/swing" - root: new FormRoot { - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class javax.swing.BoxLayout ) ) { - name: "this" - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - add( new FormContainer( "com.intellij.ui.components.JBTabbedPane", new FormLayoutManager( class javax.swing.JTabbedPane ) ) { - name: "tabbedPane1" - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - add( new FormContainer( "com.intellij.ui.components.JBScrollPane", new FormLayoutManager( class javax.swing.JScrollPane ) ) { - name: "scrollPane2" - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class net.miginfocom.swing.MigLayout ) { - "$layoutConstraints": "fillx,insets 0,hidemode 3,align left top,gap 5 5" - "$columnConstraints": "[80:115,fill][151,grow,fill]" - "$rowConstraints": "[][][][30][35:30][10:10][][][][][]" - } ) { - name: "AssessmentPanel" - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - add( new FormComponent( "javax.swing.JLabel" ) { - name: "label1" - "text": new FormMessage( null, "AssesmentViewContent.label1.text" ) - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "pad 0,cell 0 0,alignx label,growx 0" - } ) - add( new FormComponent( "com.intellij.openapi.ui.ComboBox" ) { - name: "coursesDropdown" - auxiliary() { - "JavaCodeGenerator.typeParameters": "Displayable" - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 1 0" - } ) - add( new FormComponent( "javax.swing.JLabel" ) { - name: "label2" - "text": new FormMessage( null, "AssesmentViewContent.label2.text" ) - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 1,alignx label,growx 0" - } ) - add( new FormComponent( "com.intellij.openapi.ui.ComboBox" ) { - name: "examsDropdown" - auxiliary() { - "JavaCodeGenerator.typeParameters": "Displayable" - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 1 1" - } ) - add( new FormComponent( "javax.swing.JLabel" ) { - name: "label3" - "text": new FormMessage( null, "AssesmentViewContent.label3.text" ) - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 2,alignx label,growx 0" - } ) - add( new FormComponent( "com.intellij.openapi.ui.ComboBox" ) { - name: "exercisesDropdown" - auxiliary() { - "JavaCodeGenerator.typeParameters": "Displayable" - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 1 2" - } ) - add( new FormComponent( "javax.swing.JLabel" ) { - name: "label5" - "text": "Grading config" - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 3,alignx label,growx 0" - } ) - add( new FormComponent( "com.intellij.openapi.ui.TextFieldWithBrowseButton" ) { - name: "gradingConfigPathInput" - "editable": false - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 1 3" - } ) - add( new FormComponent( "javax.swing.JLabel" ) { - name: "label6" - "text": "Autograder config" - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 4,alignx label,growx 0" - } ) - add( new FormComponent( "com.intellij.openapi.ui.TextFieldWithBrowseButton" ) { - name: "autograderConfigPathInput" - "editable": false - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 1 4" - } ) - add( new FormComponent( "javax.swing.JSeparator" ) { - name: "separator1" - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 5 2 1" - } ) - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class java.awt.BorderLayout ) ) { - name: "generalPanel" - "border": new javax.swing.border.CompoundBorder( new javax.swing.border.TitledBorder( new javax.swing.border.LineBorder( sfield java.awt.Color darkGray, 1, true ), "i18nKey=AssesmentViewContent.generalPanel.border" ), &EmptyBorder0 new javax.swing.border.EmptyBorder( 5, 5, 5, 5 ) ) - "foreground": sfield java.awt.Color blue - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - add( new FormComponent( "javax.swing.JButton" ) { - name: "btnGradingRound1" - "text": new FormMessage( null, "AssesmentViewContent.btnGradingRound1.text" ) - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class java.lang.String ) { - "value": "Center" - } ) - add( new FormComponent( "javax.swing.JButton" ) { - name: "btnGradingRound2" - "text": new FormMessage( null, "AssesmentViewContent.btnGradingRound2.text" ) - }, new FormLayoutConstraints( class java.lang.String ) { - "value": "North" - } ) - add( new FormComponent( "javax.swing.JButton" ) { - name: "button5" - "text": new FormMessage( null, "AssesmentViewContent.button5.text" ) - }, new FormLayoutConstraints( class java.lang.String ) { - "value": "South" - } ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 6 2 1,growx" - } ) - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class java.awt.GridBagLayout ) { - "$columnSpecs": "0:1.0, 0:1.0" - "$rowSpecs": "0, 0" - "$hGap": 5 - "$vGap": 5 - "$alignLeft": true - "$alignTop": true - } ) { - name: "assessmentPanel" - "border": new javax.swing.border.CompoundBorder( new javax.swing.border.TitledBorder( new javax.swing.border.LineBorder( sfield java.awt.Color darkGray, 1, true ), "i18nKey=AssesmentViewContent.assessmentPanel.border" ), #EmptyBorder0 ) - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - add( new FormComponent( "javax.swing.JButton" ) { - name: "btnSaveAssessment" - "text": new FormMessage( null, "AssesmentViewContent.btnSaveAssessment.text" ) - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class com.jformdesigner.runtime.GridBagConstraintsEx ) ) - add( new FormComponent( "javax.swing.JButton" ) { - name: "submitAssesmentBtn" - "text": new FormMessage( null, "AssesmentViewContent.submitAssesmentBtn.text" ) - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class com.jformdesigner.runtime.GridBagConstraintsEx ) { - "gridx": 1 - } ) - add( new FormComponent( "javax.swing.JButton" ) { - name: "button3" - "text": new FormMessage( null, "AssesmentViewContent.button3.text" ) - }, new FormLayoutConstraints( class com.jformdesigner.runtime.GridBagConstraintsEx ) { - "gridy": 1 - } ) - add( new FormComponent( "javax.swing.JButton" ) { - name: "button4" - "text": new FormMessage( null, "AssesmentViewContent.button4.text" ) - }, new FormLayoutConstraints( class com.jformdesigner.runtime.GridBagConstraintsEx ) { - "gridx": 1 - "gridy": 1 - } ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 7 2 1" - } ) - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class com.intellij.uiDesigner.core.GridLayoutManager ) { - "$columnCount": 2 - "$rowCount": 2 - "hGap": 0 - "vGap": 0 - } ) { - name: "panel5" - "border": new javax.swing.border.TitledBorder( new javax.swing.border.LineBorder( sfield java.awt.Color darkGray, 1, true ), "i18nKey=AssesmentViewContent.panel5.border" ) - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - add( new FormComponent( "com.intellij.ui.components.JBLabel" ) { - name: "label8" - "text": new FormMessage( null, "AssesmentViewContent.label8.text" ) - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - }, new FormLayoutConstraints( class com.intellij.uiDesigner.core.GridConstraints ) ) - add( new FormComponent( "edu.kit.kastel.extensions.guis.StatisticsContainer" ) { - name: "statisticsContainer" - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class com.intellij.uiDesigner.core.GridConstraints ) { - "column": 1 - } ) - add( new FormComponent( "com.intellij.ui.components.JBLabel" ) { - name: "label9" - "text": new FormMessage( null, "AssesmentViewContent.label9.text" ) - }, new FormLayoutConstraints( class com.intellij.uiDesigner.core.GridConstraints ) { - "row": 1 - } ) - add( new FormComponent( "com.intellij.ui.components.JBLabel" ) { - name: "assessmentModeLabel" - "text": "❌" - "icon": sfield com.jformdesigner.model.FormObject NULL_VALUE - "horizontalAlignment": 2 - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class com.intellij.uiDesigner.core.GridConstraints ) { - "column": 1 - "row": 1 - } ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "pad 0,cell 0 8 2 1,growx" - } ) - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class java.awt.GridBagLayout ) { - "$columnSpecs": "80, 0:1.0" - "$rowSpecs": "0, 0" - "$hGap": 5 - "$vGap": 5 - "$alignLeft": true - "$alignTop": true - } ) { - name: "panel3" - "border": new javax.swing.border.TitledBorder( new javax.swing.border.LineBorder( sfield java.awt.Color darkGray, 1, false ), "i18nKey=AssesmentViewContent.panel3.border" ) - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - add( new FormComponent( "javax.swing.JLabel" ) { - name: "label7" - "text": new FormMessage( null, "AssesmentViewContent.label7.text" ) - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - }, new FormLayoutConstraints( class com.jformdesigner.runtime.GridBagConstraintsEx ) { - "hAlign": 4 - } ) - add( new FormComponent( "com.intellij.openapi.ui.ComboBox" ) { - name: "backlogSelector" - }, new FormLayoutConstraints( class com.jformdesigner.runtime.GridBagConstraintsEx ) { - "gridx": 1 - } ) - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class java.awt.FlowLayout ) ) { - name: "panel4" - add( new FormComponent( "javax.swing.JButton" ) { - name: "button6" - "text": new FormMessage( null, "AssesmentViewContent.button6.text" ) - } ) - add( new FormComponent( "javax.swing.JButton" ) { - name: "button7" - "text": new FormMessage( null, "AssesmentViewContent.button7.text" ) - } ) - }, new FormLayoutConstraints( class com.jformdesigner.runtime.GridBagConstraintsEx ) { - "gridy": 1 - "gridwidth": 2 - } ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 9 2 1" - } ) - } ) - }, new FormLayoutConstraints( null ) { - "title": new FormMessage( null, "AssesmentViewContent.assessmentPanel.border" ) - } ) - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class net.miginfocom.swing.MigLayout ) { - "$layoutConstraints": "fillx,hidemode 3,align left top" - "$columnConstraints": "[fill]" - "$rowConstraints": "[grow]" - } ) { - name: "GradingPanel" - add( new FormContainer( "javax.swing.JScrollPane", new FormLayoutManager( class javax.swing.JScrollPane ) ) { - name: "scrollPane" - "border": new javax.swing.border.EmptyBorder( 0, 0, 0, 0 ) - auxiliary() { - "JavaCodeGenerator.postCreateCode": "${field}.getVerticalScrollBar().setUnitIncrement(16);" - } - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class javax.swing.BoxLayout ) { - "axis": 1 - } ) { - name: "ratingGroupContainer" - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - } ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 0,growy" - } ) - }, new FormLayoutConstraints( null ) { - "title": new FormMessage( null, "AssesmentViewContent.GradingPanel.tab.title" ) - } ) - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class net.miginfocom.swing.MigLayout ) { - "$layoutConstraints": "fillx,hidemode 3,align left top" - "$columnConstraints": "[fill][fill]" - "$rowConstraints": "[36][grow]" - } ) { - name: "TestResultsPanel" - add( new FormComponent( "javax.swing.JLabel" ) { - name: "label4" - "text": new FormMessage( null, "AssesmentViewContent.label4.text" ) - "font": new com.jformdesigner.model.SwingDerivedFont( null, 1, 0, false ) - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 0 2 1" - } ) - add( new FormContainer( "com.intellij.ui.components.JBScrollPane", new FormLayoutManager( class javax.swing.JScrollPane ) ) { - name: "scrollPane1" - auxiliary() { - "JavaCodeGenerator.variableLocal": true - } - add( new FormComponent( "com.intellij.ui.table.JBTable" ) { - name: "testResultsTable" - } ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 1 2 1,growy" - } ) - }, new FormLayoutConstraints( null ) { - "title": new FormMessage( null, "AssesmentViewContent.TestResultsPanel.tab.title" ) - } ) - } ) - }, new FormLayoutConstraints( null ) { - "location": new java.awt.Point( 0, 0 ) - "size": new java.awt.Dimension( 410, 785 ) - } ) - } -} diff --git a/src/main/java/edu/kit/kastel/extensions/guis/SettingsContent.java b/src/main/java/edu/kit/kastel/extensions/guis/SettingsContent.java deleted file mode 100644 index 3225c20..0000000 --- a/src/main/java/edu/kit/kastel/extensions/guis/SettingsContent.java +++ /dev/null @@ -1,172 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.extensions.guis; - -import java.awt.*; -import java.util.*; - -import javax.swing.*; - -import com.intellij.ui.*; -import com.intellij.ui.components.*; -import net.miginfocom.swing.*; - -/** - * @author clemens - */ -public class SettingsContent extends JPanel { - public SettingsContent() { - initComponents(); - } - - public JLabel getLoggedInLabel() { - return loggedInLabel; - } - - public JTextField getArtemisUrlInput() { - return artemisUrlInput; - } - - public JTextField getInputUsername() { - return InputUsername; - } - - public JPasswordField getInputPwd() { - return inputPwd; - } - - public JButton getBtnLogin() { - return btnLogin; - } - - public JSpinner getNumColsSlider() { - return numColsSlider; - } - - public ColorPanel getAnnotationColorPicker() { - return annotationColorPicker; - } - - public JButton getBtnLogout() { - return btnLogout; - } - - private void initComponents() { - // JFormDesigner - Component initialization - DO NOT MODIFY //GEN-BEGIN:initComponents @formatter:off - // Generated using JFormDesigner Evaluation license - Clemens D - ResourceBundle bundle = ResourceBundle.getBundle("guiStrings"); - label3 = new JBLabel(); - loggedInLabel = new JLabel(); - separator1 = new JSeparator(); - label4 = new JBLabel(); - artemisUrlInput = new JTextField(); - label1 = new JBLabel(); - InputUsername = new JTextField(); - label2 = new JBLabel(); - inputPwd = new JPasswordField(); - btnLogin = new JButton(); - separator2 = new JSeparator(); - btnLogout = new JButton(); - label5 = new JBLabel(); - numColsSlider = new JSpinner(); - label6 = new JBLabel(); - annotationColorPicker = new ColorPanel(); - - //======== this ======== - setBorder(new javax.swing.border.CompoundBorder(new javax.swing.border.TitledBorder(new javax.swing.border - .EmptyBorder(0,0,0,0), "JF\u006frmD\u0065sig\u006eer \u0045val\u0075ati\u006fn",javax.swing.border.TitledBorder.CENTER,javax - .swing.border.TitledBorder.BOTTOM,new java.awt.Font("Dia\u006cog",java.awt.Font.BOLD, - 12),java.awt.Color.red), getBorder())); addPropertyChangeListener(new java.beans - .PropertyChangeListener(){@Override public void propertyChange(java.beans.PropertyChangeEvent e){if("\u0062ord\u0065r".equals(e. - getPropertyName()))throw new RuntimeException();}}); - setLayout(new MigLayout( - "fillx,insets 0,hidemode 3,align left top,gap 0 0", - // columns - "[109,fill]" + - "[grow,fill]", - // rows - "[]" + - "[]" + - "[]" + - "[top]" + - "[top]" + - "[top]" + - "[]" + - "[top]" + - "[]" + - "[]")); - - //---- label3 ---- - label3.setText(bundle.getString("DebugMenuContent.label3.text")); - add(label3, "cell 0 0"); - - //---- loggedInLabel ---- - loggedInLabel.setText(bundle.getString("DebugMenuContent.loggedInLabel.text")); - loggedInLabel.setForeground(Color.red); - add(loggedInLabel, "cell 1 0"); - add(separator1, "cell 0 1 2 1"); - - //---- label4 ---- - label4.setText(bundle.getString("DebugMenuContent.label4.text")); - add(label4, "cell 0 2"); - - //---- artemisUrlInput ---- - artemisUrlInput.setText(bundle.getString("DebugMenuContent.artemisUrlInput.text")); - add(artemisUrlInput, "cell 1 2"); - - //---- label1 ---- - label1.setText(bundle.getString("labelUnameField")); - add(label1, "cell 0 3,alignx left,growx 0"); - add(InputUsername, "cell 1 3,growx"); - - //---- label2 ---- - label2.setText(bundle.getString("LabelPwdField")); - add(label2, "cell 0 4,alignx left,growx 0"); - add(inputPwd, "cell 1 4,growx"); - - //---- btnLogin ---- - btnLogin.setText(bundle.getString("DebugMenuContent.btnLogin.text")); - add(btnLogin, "cell 0 5 2 1"); - add(separator2, "cell 0 6 2 1"); - - //---- btnLogout ---- - btnLogout.setText(bundle.getString("DebugMenuContent.btnLogout.text")); - add(btnLogout, "cell 0 7 2 1"); - - //---- label5 ---- - label5.setText(bundle.getString("DebugMenuContent.label5.text")); - add(label5, "cell 0 8"); - - //---- numColsSlider ---- - numColsSlider.setModel(new SpinnerNumberModel(2, 1, null, 1)); - add(numColsSlider, "cell 1 8"); - - //---- label6 ---- - label6.setText(bundle.getString("AnnotationColor")); - add(label6, "cell 0 9"); - - //---- annotationColorPicker ---- - annotationColorPicker.setSelectedColor(new Color(0x9b3636)); - add(annotationColorPicker, "cell 1 9"); - // JFormDesigner - End of component initialization //GEN-END:initComponents @formatter:on - } - - // JFormDesigner - Variables declaration - DO NOT MODIFY //GEN-BEGIN:variables @formatter:off - // Generated using JFormDesigner Evaluation license - Clemens D - private JBLabel label3; - private JLabel loggedInLabel; - private JSeparator separator1; - private JBLabel label4; - private JTextField artemisUrlInput; - private JBLabel label1; - private JTextField InputUsername; - private JBLabel label2; - private JPasswordField inputPwd; - private JButton btnLogin; - private JSeparator separator2; - private JButton btnLogout; - private JBLabel label5; - private JSpinner numColsSlider; - private JBLabel label6; - private ColorPanel annotationColorPicker; - // JFormDesigner - End of variables declaration //GEN-END:variables @formatter:on -} diff --git a/src/main/java/edu/kit/kastel/extensions/guis/SettingsContent.jfd b/src/main/java/edu/kit/kastel/extensions/guis/SettingsContent.jfd deleted file mode 100644 index 8ecac6e..0000000 --- a/src/main/java/edu/kit/kastel/extensions/guis/SettingsContent.jfd +++ /dev/null @@ -1,140 +0,0 @@ -JFDML JFormDesigner: "8.2.3.0.386" Java: "17.0.8" encoding: "UTF-8" - -new FormModel { - "i18n.bundleName": "guiStrings" - "i18n.keyPrefix": "DebugMenuContent" - "i18n.autoExternalize": true - contentType: "form/swing" - root: new FormRoot { - add( new FormContainer( "javax.swing.JPanel", new FormLayoutManager( class net.miginfocom.swing.MigLayout ) { - "$layoutConstraints": "fillx,insets 0,hidemode 3,align left top,gap 0 0" - "$columnConstraints": "[109,fill][grow,fill]" - "$rowConstraints": "[][][][top][top][top][][top][][]" - } ) { - name: "this" - add( new FormComponent( "com.intellij.ui.components.JBLabel" ) { - name: "label3" - "text": new FormMessage( null, "DebugMenuContent.label3.text" ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 0" - } ) - add( new FormComponent( "javax.swing.JLabel" ) { - name: "loggedInLabel" - "text": new FormMessage( null, "DebugMenuContent.loggedInLabel.text" ) - "foreground": sfield java.awt.Color red - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 1 0" - } ) - add( new FormComponent( "javax.swing.JSeparator" ) { - name: "separator1" - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 1 2 1" - } ) - add( new FormComponent( "com.intellij.ui.components.JBLabel" ) { - name: "label4" - "text": new FormMessage( null, "DebugMenuContent.label4.text" ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 2" - } ) - add( new FormComponent( "javax.swing.JTextField" ) { - name: "artemisUrlInput" - "text": new FormMessage( null, "DebugMenuContent.artemisUrlInput.text" ) - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 1 2" - } ) - add( new FormComponent( "com.intellij.ui.components.JBLabel" ) { - name: "label1" - "text": new FormMessage( null, "labelUnameField" ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 3,alignx left,growx 0" - } ) - add( new FormComponent( "javax.swing.JTextField" ) { - name: "InputUsername" - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 1 3,growx" - } ) - add( new FormComponent( "com.intellij.ui.components.JBLabel" ) { - name: "label2" - "text": new FormMessage( null, "LabelPwdField" ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 4,alignx left,growx 0" - } ) - add( new FormComponent( "javax.swing.JPasswordField" ) { - name: "inputPwd" - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 1 4,growx" - } ) - add( new FormComponent( "javax.swing.JButton" ) { - name: "btnLogin" - "text": new FormMessage( null, "DebugMenuContent.btnLogin.text" ) - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 5 2 1" - } ) - add( new FormComponent( "javax.swing.JSeparator" ) { - name: "separator2" - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 6 2 1" - } ) - add( new FormComponent( "javax.swing.JButton" ) { - name: "btnLogout" - "text": new FormMessage( null, "DebugMenuContent.btnLogout.text" ) - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 7 2 1" - } ) - add( new FormComponent( "com.intellij.ui.components.JBLabel" ) { - name: "label5" - "text": new FormMessage( null, "DebugMenuContent.label5.text" ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 8" - } ) - add( new FormComponent( "javax.swing.JSpinner" ) { - name: "numColsSlider" - "model": new javax.swing.SpinnerNumberModel { - minimum: 1 - value: 2 - } - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 1 8" - } ) - add( new FormComponent( "com.intellij.ui.components.JBLabel" ) { - name: "label6" - "text": new FormMessage( null, "AnnotationColor" ) - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 0 9" - } ) - add( new FormComponent( "com.intellij.ui.ColorPanel" ) { - name: "annotationColorPicker" - "selectedColor": new java.awt.Color( 155, 54, 54, 255 ) - auxiliary() { - "JavaCodeGenerator.variableGetter": true - } - }, new FormLayoutConstraints( class net.miginfocom.layout.CC ) { - "value": "cell 1 9" - } ) - }, new FormLayoutConstraints( null ) { - "location": new java.awt.Point( 0, 0 ) - "size": new java.awt.Dimension( 465, 270 ) - } ) - } -} diff --git a/src/main/java/edu/kit/kastel/extensions/guis/StatisticsContainer.java b/src/main/java/edu/kit/kastel/extensions/guis/StatisticsContainer.java deleted file mode 100644 index 6574f48..0000000 --- a/src/main/java/edu/kit/kastel/extensions/guis/StatisticsContainer.java +++ /dev/null @@ -1,32 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.extensions.guis; - -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.ui.components.JBLabel; -import edu.kit.kastel.listeners.ExerciseSelectedListener; -import edu.kit.kastel.sdq.artemis4j.api.ArtemisClientException; -import edu.kit.kastel.sdq.artemis4j.api.artemis.Exercise; -import edu.kit.kastel.sdq.artemis4j.api.artemis.ExerciseStats; -import edu.kit.kastel.utils.ArtemisUtils; - -public class StatisticsContainer extends JBLabel { - - private static final String FETCH_STATS_FORMATTER = "Unable to fetch statistics for exercise %s"; - - /** - * Update the statistics label - */ - public void triggerUpdate(Exercise selected) { - try { - ExerciseStats stats = ArtemisUtils.getArtemisClientInstance() - .getAssessmentArtemisClient() - .getStats(selected); - this.setText(String.format( - "Your submissions: %d | corrected: %d/%d | locked: %d", - stats.submittedByTutor(), stats.totalAssessments(), stats.totalSubmissions(), stats.locked())); - } catch (ArtemisClientException e) { - ArtemisUtils.displayGenericErrorBalloon(String.format(FETCH_STATS_FORMATTER, selected.getShortName())); - Logger.getInstance(ExerciseSelectedListener.class).error(e); - } - } -} diff --git a/src/main/java/edu/kit/kastel/extensions/settings/ArtemisSettings.java b/src/main/java/edu/kit/kastel/extensions/settings/ArtemisSettings.java deleted file mode 100644 index 5f55b48..0000000 --- a/src/main/java/edu/kit/kastel/extensions/settings/ArtemisSettings.java +++ /dev/null @@ -1,182 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.extensions.settings; - -import java.awt.GridLayout; -import java.awt.event.ActionEvent; - -import javax.swing.*; - -import com.intellij.openapi.options.Configurable; -import com.intellij.openapi.options.ConfigurationException; -import com.intellij.openapi.util.NlsContexts; -import com.intellij.ui.ColorPanel; -import com.intellij.ui.JBColor; -import edu.kit.kastel.extensions.guis.SettingsContent; -import edu.kit.kastel.login.CustomLoginManager; -import edu.kit.kastel.sdq.artemis4j.api.ArtemisClientException; -import edu.kit.kastel.sdq.artemis4j.client.RestClientManager; -import org.jetbrains.annotations.Nullable; - -/** - * This class implements the settings Dialog for this PlugIn. - * Everything directly related to the Setting UI should be in here. - */ -public class ArtemisSettings implements Configurable { - - private static final String SETTINGS_DIALOG_NAME = "IntelliGrade Settings"; - private static final String LOGIN_ERROR_DIALOG_TITLE = "Error logging in!"; - - // set up automated GUI and generate necessary bindings - private final JPanel contentPanel = new JPanel(); - private final SettingsContent generatedMenu = new SettingsContent(); - private final JPasswordField pwdInput = generatedMenu.getInputPwd(); - private final JTextField usernameField = generatedMenu.getInputUsername(); - private final JLabel loggedInLabel = generatedMenu.getLoggedInLabel(); - private final JTextField artemisUrlField = generatedMenu.getArtemisUrlInput(); - - private final JSpinner numColsSpinner = generatedMenu.getNumColsSlider(); - - private final ColorPanel annotationColorSelector = generatedMenu.getAnnotationColorPicker(); - - /** - * Returns the visible name of the configurable component. - * Note, that this method must return the display name - * that is equal to the display name declared in XML - * to avoid unexpected errors. - * - * @return the visible name of the configurable component - */ - @Override - public @NlsContexts.ConfigurableName String getDisplayName() { - return SETTINGS_DIALOG_NAME; - } - - /** - * Creates a new Swing form that enables the user to configure the settings. - * Usually this method is called on the EDT, so it should not take a long time. - *

Also, this place is designed to allocate resources (subscriptions/listeners etc.)

- * - * @return new Swing form to show, or {@code null} if it cannot be created - * @see #disposeUIResources - */ - @Override - public @Nullable JComponent createComponent() { - contentPanel.setLayout(new GridLayout()); - contentPanel.add(generatedMenu); - // add action listener to login Button - generatedMenu.getBtnLogin().addActionListener(this::loginButtonListener); - // add action listener to log out button - generatedMenu.getBtnLogout().addActionListener(this::logOutButtonListener); - - return contentPanel; - } - - /** - * Listener Method that gets called when the login Button is pressed. - * This method will Log in the User. - * - * @param actionEvent The Event passed by AWT is the Button is pressed - */ - private void loginButtonListener(ActionEvent actionEvent) { - // set label if login was successful - setLabelOnLoginSuccess(); - } - - private void logOutButtonListener(ActionEvent actionEvent) { - // reset username, password and token - ArtemisSettingsState settings = ArtemisSettingsState.getInstance(); - settings.setUsername(""); - settings.setArtemisPassword(""); - settings.setArtemisAuthJWT(""); - - // blank input fields - this.pwdInput.setText(""); - this.usernameField.setText(""); - } - - /** - * Indicates whether the Swing form was modified or not. - * This method is called very often, so it should not take a long time. - * - * @return {@code true} if the settings were modified, {@code false} otherwise - */ - @Override - public boolean isModified() { - ArtemisSettingsState settings = ArtemisSettingsState.getInstance(); - // check if all three parameters are equal - String password = new String(pwdInput.getPassword()); - boolean modified = !password.equals(settings.getArtemisPassword()); - modified |= !usernameField.getText().equals(settings.getUsername()); - modified |= !artemisUrlField.getText().equals(settings.getArtemisInstanceUrl()); - modified |= !numColsSpinner.getValue().equals(settings.getColumnsPerRatingGroup()); - modified |= !annotationColorSelector.getSelectedColor().equals(settings.getAnnotationColor()); - return modified; - } - - /** - * Stores the settings from the Swing form to the configurable component. - * This method is called on EDT upon user's request. - * - * @throws ConfigurationException if values cannot be applied - */ - @Override - public void apply() { - // store all settings persistently - ArtemisSettingsState settings = ArtemisSettingsState.getInstance(); - settings.setArtemisInstanceUrl(artemisUrlField.getText()); - settings.setUsername(usernameField.getText()); - settings.setArtemisPassword(new String(pwdInput.getPassword())); - settings.setColumnsPerRatingGroup( - Integer.parseInt(numColsSpinner.getValue().toString())); - settings.setAnnotationColor(annotationColorSelector.getSelectedColor()); - } - - /** - * Loads the settings from the configurable component to the Swing form. - * This method is called on EDT immediately after the form creation or later upon user's request. - */ - @Override - public void reset() { - ArtemisSettingsState settings = ArtemisSettingsState.getInstance(); - artemisUrlField.setText(settings.getArtemisInstanceUrl()); - usernameField.setText(settings.getUsername()); - pwdInput.setText(settings.getArtemisPassword()); - numColsSpinner.setValue(settings.getColumnsPerRatingGroup()); - annotationColorSelector.setSelectedColor(settings.getAnnotationColor()); - - setLabelOnLoginSuccess(); - } - - /** - * This method will create a new Connection to Artemis and try to log in. - * If login was successful the label in the settings dialog will be set - */ - private void setLabelOnLoginSuccess() { - - // create token based login manager - var tokenLoginManager = new CustomLoginManager( - artemisUrlField.getText(), usernameField.getText(), new String(pwdInput.getPassword())); - - // create new Artemis Instance - var artemisInstance = new RestClientManager(artemisUrlField.getText(), tokenLoginManager); - - // try logging in and display error iff error occurred - try { - tokenLoginManager.login(); - } catch (ArtemisClientException e) { - loggedInLabel.setText("false"); - loggedInLabel.setForeground(JBColor.RED); - JOptionPane.showMessageDialog( - contentPanel, e.getMessage(), LOGIN_ERROR_DIALOG_TITLE, JOptionPane.ERROR_MESSAGE); - } - - if (artemisInstance.isReady()) { - if (usernameField.getText().isBlank() || new String(pwdInput.getPassword()).isBlank()) { - loggedInLabel.setText("true (logged in via Token)"); - } else { - loggedInLabel.setText("true"); - } - loggedInLabel.setForeground(JBColor.GREEN); - } - } -} diff --git a/src/main/java/edu/kit/kastel/extensions/settings/ArtemisSettingsState.java b/src/main/java/edu/kit/kastel/extensions/settings/ArtemisSettingsState.java deleted file mode 100644 index 4992969..0000000 --- a/src/main/java/edu/kit/kastel/extensions/settings/ArtemisSettingsState.java +++ /dev/null @@ -1,162 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.extensions.settings; - -import java.awt.Color; -import java.util.Date; - -import com.intellij.credentialStore.CredentialAttributes; -import com.intellij.credentialStore.CredentialAttributesKt; -import com.intellij.credentialStore.Credentials; -import com.intellij.ide.passwordSafe.PasswordSafe; -import com.intellij.openapi.application.ApplicationManager; -import com.intellij.openapi.components.PersistentStateComponent; -import com.intellij.openapi.components.State; -import com.intellij.openapi.components.Storage; -import com.intellij.ui.JBColor; -import com.intellij.util.xmlb.XmlSerializer; -import com.intellij.util.xmlb.XmlSerializerUtil; -import org.jetbrains.annotations.Contract; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * This class persists all required data for the PlugIn. - * Secrets (such as the Artemis password) are handled by the IntelliJ secrets provider - */ -@State(name = "edu.kit.kastel.extensions.ArtemisSettingsState", storages = @Storage("IntelliGradeSettings.xml")) -public class ArtemisSettingsState implements PersistentStateComponent { - - private static final String PASSWORD_STORE_KEY = "artemisPassword"; - private static final String CREDENTIALS_PATH = "edu.kit.kastel.intelligrade.artemisCredentials"; - - private static final String JWT_STORE_KEY = "artemisAuthJWT"; - - private String username = ""; - private String artemisInstanceUrl = "https://artemis.praktomat.cs.kit.edu"; - private @Nullable String selectedGradingConfigPath; - private int columnsPerRatingGroup = 2; - - private Date jwtExpiry = new Date(Long.MAX_VALUE); - - private Color annotationColor = new JBColor(new Color(155, 54, 54), new Color(155, 54, 54)); - - public static ArtemisSettingsState getInstance() { - return ApplicationManager.getApplication().getService(ArtemisSettingsState.class); - } - - /** - * Gets the Settings state. - * - * @return a component state. - * All properties, public and annotated fields are serialized. - * Only values which differ from the default (i.e. the value of newly instantiated class) - * are serialized. {@code null} value indicates that the returned state won't be stored, - * as a result previously stored state will be used. - * @see XmlSerializer - */ - @Override - public @Nullable ArtemisSettingsState getState() { - return this; - } - - /** - * This method is called when a new component state is loaded. - * The method can and will be called several times if config - * files are externally changed while the IDE is running. - *

State object should be used directly, defensive copying is not required.

- * - * @param state loaded component state - * @see XmlSerializerUtil#copyBean(Object, Object) - */ - @Override - public void loadState(@NotNull ArtemisSettingsState state) { - XmlSerializerUtil.copyBean(state, this); - } - - @Contract("_ -> new") - private @NotNull CredentialAttributes createCredentialAttributes(String key) { - return new CredentialAttributes(CredentialAttributesKt.generateServiceName(CREDENTIALS_PATH, key)); - } - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - - /** - * Get the password for the Artemis instance from the IDEs CredentialStore. - * - * @return the Password stored under the key {@value PASSWORD_STORE_KEY} - */ - public String getArtemisPassword() { - CredentialAttributes credentialAttributes = createCredentialAttributes(PASSWORD_STORE_KEY); - return PasswordSafe.getInstance().getPassword(credentialAttributes); - } - - /** - * Store the provided Password securely into the IDEs - * Credential Store under the key {@value PASSWORD_STORE_KEY}. - * - * @param artemisPassword the password to be stored - */ - public void setArtemisPassword(String artemisPassword) { - CredentialAttributes credentialAttributes = createCredentialAttributes(PASSWORD_STORE_KEY); - Credentials credentials = new Credentials(username, artemisPassword); - PasswordSafe.getInstance().set(credentialAttributes, credentials); - } - - public synchronized void setArtemisAuthJWT(String jwt) { - CredentialAttributes credentialAttributes = createCredentialAttributes(JWT_STORE_KEY); - PasswordSafe.getInstance().setPassword(credentialAttributes, jwt); - } - - public synchronized String getArtemisAuthJWT() { - CredentialAttributes credentialAttributes = createCredentialAttributes(JWT_STORE_KEY); - return PasswordSafe.getInstance().getPassword(credentialAttributes); - } - - public String getArtemisInstanceUrl() { - return artemisInstanceUrl; - } - - public void setArtemisInstanceUrl(String artemisInstanceUrl) { - // invalidate JWT if URL changed - this.setArtemisAuthJWT(""); - this.artemisInstanceUrl = artemisInstanceUrl; - } - - public @Nullable String getSelectedGradingConfigPath() { - return selectedGradingConfigPath; - } - - public int getColumnsPerRatingGroup() { - return columnsPerRatingGroup; - } - - public void setColumnsPerRatingGroup(int columnsPerRatingGroup) { - this.columnsPerRatingGroup = columnsPerRatingGroup; - } - - public void setSelectedGradingConfigPath(@Nullable String selectedGradingConfigPath) { - this.selectedGradingConfigPath = selectedGradingConfigPath; - } - - public Color getAnnotationColor() { - return annotationColor; - } - - public void setAnnotationColor(Color annotationColor) { - this.annotationColor = annotationColor; - } - - public Date getJwtExpiry() { - return jwtExpiry; - } - - public void setJwtExpiry(Date jwtExpiry) { - this.jwtExpiry = jwtExpiry; - } -} diff --git a/src/main/java/edu/kit/kastel/extensions/tool_windows/AnnotationsToolWindowFactory.java b/src/main/java/edu/kit/kastel/extensions/tool_windows/AnnotationsToolWindowFactory.java deleted file mode 100644 index 4f14b5d..0000000 --- a/src/main/java/edu/kit/kastel/extensions/tool_windows/AnnotationsToolWindowFactory.java +++ /dev/null @@ -1,46 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.extensions.tool_windows; - -import java.awt.GridLayout; - -import javax.swing.JPanel; - -import com.intellij.openapi.project.Project; -import com.intellij.openapi.wm.ToolWindow; -import com.intellij.openapi.wm.ToolWindowFactory; -import com.intellij.ui.content.Content; -import com.intellij.ui.content.ContentFactory; -import edu.kit.kastel.extensions.guis.AnnotationsViewContent; -import edu.kit.kastel.utils.ArtemisUtils; -import edu.kit.kastel.utils.AssessmentUtils; -import org.jetbrains.annotations.NotNull; - -/** - * This class generates the tool Window for annotations in the bottom. - */ -public class AnnotationsToolWindowFactory implements ToolWindowFactory { - - // set up automated GUI and generate necessary bindings - private final JPanel contentPanel = new JPanel(); - - private final AnnotationsViewContent generatedMenu = new AnnotationsViewContent(); - - @Override - public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { - contentPanel.setLayout(new GridLayout()); - - // give up if logging in to Artemis failed - if (!ArtemisUtils.getArtemisClientInstance().isReady()) { - return; - } - - // add content to menu panel - contentPanel.add(generatedMenu); - Content content = ContentFactory.getInstance().createContent(this.contentPanel, null, false); - - toolWindow.getContentManager().addContent(content); - - // register the GUI to be updated when an annotation is added - AssessmentUtils.registerAssessmentListener(generatedMenu); - } -} diff --git a/src/main/java/edu/kit/kastel/extensions/tool_windows/MainToolWindowFactory.java b/src/main/java/edu/kit/kastel/extensions/tool_windows/MainToolWindowFactory.java deleted file mode 100644 index 37ee78d..0000000 --- a/src/main/java/edu/kit/kastel/extensions/tool_windows/MainToolWindowFactory.java +++ /dev/null @@ -1,160 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.extensions.tool_windows; - -import java.awt.GridLayout; -import java.util.List; -import java.util.Objects; - -import javax.swing.JButton; -import javax.swing.JPanel; - -import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.ui.ComboBox; -import com.intellij.openapi.ui.TextBrowseFolderListener; -import com.intellij.openapi.ui.TextFieldWithBrowseButton; -import com.intellij.openapi.wm.ToolWindow; -import com.intellij.openapi.wm.ToolWindowFactory; -import com.intellij.ui.content.Content; -import com.intellij.ui.content.ContentFactory; -import edu.kit.kastel.extensions.guis.AssessmentViewContent; -import edu.kit.kastel.extensions.settings.ArtemisSettingsState; -import edu.kit.kastel.listeners.ExerciseSelectedListener; -import edu.kit.kastel.listeners.GradingConfigSelectedListener; -import edu.kit.kastel.listeners.OnSaveAssessmentBtnClick; -import edu.kit.kastel.listeners.OnSubmitAssessmentBtnClick; -import edu.kit.kastel.listeners.StartAssesment1Listener; -import edu.kit.kastel.sdq.artemis4j.api.ArtemisClientException; -import edu.kit.kastel.sdq.artemis4j.api.artemis.Course; -import edu.kit.kastel.sdq.artemis4j.api.artemis.Exercise; -import edu.kit.kastel.sdq.artemis4j.api.artemis.exam.Exam; -import edu.kit.kastel.state.AssessmentModeHandler; -import edu.kit.kastel.utils.ArtemisUtils; -import edu.kit.kastel.wrappers.Displayable; -import edu.kit.kastel.wrappers.DisplayableCourse; -import edu.kit.kastel.wrappers.DisplayableExam; -import edu.kit.kastel.wrappers.DisplayableExercise; -import org.jetbrains.annotations.NotNull; - -/** - * This class handles all logic for the main grading UI. - * It does not handle any other logic, that should be factored out. - */ -public class MainToolWindowFactory implements ToolWindowFactory { - - private static final String EXAMS_FETCH_ERROR_FORMAT = "Unable to fetch Exams for course %s."; - private static final String EXERCISES_FETCH_ERROR_FORMAT = "Unable to fetch exercises for course %s."; - - // set up automated GUI and generate necessary bindings - private final JPanel contentPanel = new JPanel(); - private final AssessmentViewContent generatedMenu = new AssessmentViewContent(); - private final TextFieldWithBrowseButton gradingConfigInput = generatedMenu.getGradingConfigPathInput(); - private final TextFieldWithBrowseButton autograderConfigInput = generatedMenu.getAutograderConfigPathInput(); - private final ComboBox> coursesComboBox = generatedMenu.getCoursesDropdown(); - private final ComboBox> examsComboBox = generatedMenu.getExamsDropdown(); - private final ComboBox> exerciseComboBox = generatedMenu.getExercisesDropdown(); - - private final JButton startAssessment1Btn = generatedMenu.getBtnGradingRound1(); - - private final JButton saveAssessmentBtn = generatedMenu.getBtnSaveAssessment(); - - private final JButton submitAssessmentBtn = generatedMenu.getSubmitAssesmentBtn(); - - @Override - public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { - contentPanel.setLayout(new GridLayout()); - - // give up if logging in to Artemis failed - if (!ArtemisUtils.getArtemisClientInstance().isReady()) { - return; - } - - // add content to menu panel - contentPanel.add(generatedMenu); - Content content = ContentFactory.getInstance().createContent(this.contentPanel, null, false); - - addListeners(); - try { - populateDropdowns(); - } catch (ArtemisClientException exc) { - ArtemisUtils.displayLoginErrorBalloon("Error retrieving courses!", null); - } - - // connect label to AssessmentModeHandler - AssessmentModeHandler.getInstance().connectIndicatorLabel(generatedMenu.getAssessmentModeLabel()); - - toolWindow.getContentManager().addContent(content); - } - - private void addListeners() { - gradingConfigInput.addBrowseFolderListener( - new TextBrowseFolderListener(FileChooserDescriptorFactory.createSingleFileDescriptor("json"))); - autograderConfigInput.addBrowseFolderListener( - new TextBrowseFolderListener(FileChooserDescriptorFactory.createSingleFileDescriptor("json"))); - - // why the heck would you add a listener for text change like this???? - gradingConfigInput - .getTextField() - .getDocument() - .addDocumentListener(new GradingConfigSelectedListener(gradingConfigInput)); - - // set config path saved in settings - ArtemisSettingsState settings = ArtemisSettingsState.getInstance(); - gradingConfigInput.setText(settings.getSelectedGradingConfigPath()); - - // parse config on exercise select - exerciseComboBox.addItemListener(new ExerciseSelectedListener(generatedMenu)); - - // add listener for Button that starts first grading round - startAssessment1Btn.addActionListener(new StartAssesment1Listener(generatedMenu)); - - // button that saves assessment - saveAssessmentBtn.addActionListener(new OnSaveAssessmentBtnClick()); - - // button that submits assessment - submitAssessmentBtn.addActionListener(new OnSubmitAssessmentBtnClick(generatedMenu.getStatisticsContainer())); - } - - private void populateDropdowns() throws ArtemisClientException { - coursesComboBox.removeAllItems(); - // add all courses to the courses dropdown - List courses = - ArtemisUtils.getArtemisClientInstance().getCourseArtemisClient().getCourses(); - courses.forEach(course -> coursesComboBox.addItem(new DisplayableCourse(course))); - - // populate exam and exercise dropdown if a course is selected - coursesComboBox.addItemListener(itemEvent -> { - Course selectedCourse = ((DisplayableCourse) itemEvent.getItem()).getWrappedValue(); - populateExamDropdown(selectedCourse); - populateExercisesDropdown(selectedCourse); - }); - - // populate the dropdowns once because on load event listener is not triggered - Course initial = - ((DisplayableCourse) Objects.requireNonNull(coursesComboBox.getSelectedItem())).getWrappedValue(); - populateExamDropdown(initial); - populateExercisesDropdown(initial); - } - - private void populateExamDropdown(@NotNull Course course) { - examsComboBox.removeAllItems(); - // we usually do not want to select the exam. Whe thus create a null Item - examsComboBox.addItem(new DisplayableExam(null)); - try { - // try to add all exams to the dropdown or fail - course.getExams().forEach(exam -> examsComboBox.addItem(new DisplayableExam(exam))); - } catch (ArtemisClientException e) { - ArtemisUtils.displayGenericErrorBalloon(String.format(EXAMS_FETCH_ERROR_FORMAT, course)); - } - } - - private void populateExercisesDropdown(@NotNull Course course) { - exerciseComboBox.removeAllItems(); - - try { - course.getExercises().forEach(exercise -> exerciseComboBox.addItem(new DisplayableExercise(exercise))); - } catch (ArtemisClientException e) { - ArtemisUtils.displayGenericErrorBalloon(String.format(EXERCISES_FETCH_ERROR_FORMAT, course)); - } - } -} diff --git a/src/main/java/edu/kit/kastel/listeners/ExerciseSelectedListener.java b/src/main/java/edu/kit/kastel/listeners/ExerciseSelectedListener.java deleted file mode 100644 index 4fb97b8..0000000 --- a/src/main/java/edu/kit/kastel/listeners/ExerciseSelectedListener.java +++ /dev/null @@ -1,142 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.listeners; - -import java.awt.GridLayout; -import java.awt.event.ItemEvent; -import java.awt.event.ItemListener; -import java.io.IOException; -import java.util.Comparator; -import java.util.Objects; - -import javax.swing.BorderFactory; -import javax.swing.JButton; -import javax.swing.JPanel; - -import com.intellij.DynamicBundle; -import com.intellij.ui.JBColor; -import edu.kit.kastel.extensions.guis.AssessmentViewContent; -import edu.kit.kastel.extensions.settings.ArtemisSettingsState; -import edu.kit.kastel.sdq.artemis4j.api.artemis.Exercise; -import edu.kit.kastel.sdq.artemis4j.grading.config.ExerciseConfig; -import edu.kit.kastel.sdq.artemis4j.grading.config.JsonFileConfig; -import edu.kit.kastel.sdq.artemis4j.grading.model.MistakeType; -import edu.kit.kastel.state.AssessmentModeHandler; -import edu.kit.kastel.utils.ArtemisUtils; -import edu.kit.kastel.wrappers.DisplayableExercise; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * This listener handles selection of a new exercise. - */ -public class ExerciseSelectedListener implements ItemListener { - private static @Nullable JsonFileConfig fileConfig; - - private static final String CONFIG_FILE_LOAD_ERROR_FORMATTER = "Error loading config at %s!"; - private static final String NO_EXERCISE_SELECTED_ERROR = "Please select an exercise to begin grading."; - - private static final String EXERCISE_INVALID_FORMATTER = "You are trying to grade \"%s\" with a config for \"%s\"!"; - - private static final String LOCALE = DynamicBundle.getLocale().getLanguage(); - - /** - * Hold a reference to the UI, so we can dynamically modify components. - */ - private final AssessmentViewContent gui; - - public ExerciseSelectedListener(AssessmentViewContent gui) { - this.gui = gui; - } - - @Override - public void itemStateChanged(@NotNull ItemEvent itemEvent) { - // to avoid errors when Item is only deselected - if (itemEvent.getStateChange() == ItemEvent.DESELECTED) { - return; - } - - ExerciseConfig configForExercise; - Exercise selected = ((DisplayableExercise) Objects.requireNonNull(itemEvent.getItem())).getWrappedValue(); - - // we cannot load a config if no exercise is selected - if (ExerciseSelectedListener.fileConfig == null) { - ArtemisUtils.displayGenericErrorBalloon(NO_EXERCISE_SELECTED_ERROR); - return; - } - - // try loading the config for an exercise - try { - configForExercise = ExerciseSelectedListener.fileConfig.getExerciseConfig(selected); - } catch (IOException e) { - ArtemisUtils.displayGenericErrorBalloon(String.format( - ExerciseSelectedListener.CONFIG_FILE_LOAD_ERROR_FORMATTER, - ArtemisSettingsState.getInstance().getSelectedGradingConfigPath())); - // stop any further parse attempts if loading file failed - return; - } - - // if the exercise that is to be graded is invalid for this Config - if (!configForExercise.getAllowedExercises().contains(selected.getExerciseId())) { - // display error message - ArtemisUtils.displayGenericErrorBalloon(String.format( - EXERCISE_INVALID_FORMATTER, selected.getShortName(), configForExercise.getShortName())); - // grey out assessment buttons - gui.getBtnGradingRound1().setEnabled(false); - return; - } - // enable assessment button because it may still be greyed out - gui.getBtnGradingRound1().setEnabled(true); - addRatingGroups(configForExercise); - AssessmentModeHandler.getInstance().setCurrentExerciseConfig(configForExercise); - - // update statistics - gui.getStatisticsContainer().triggerUpdate(selected); - } - - private void addRatingGroups(@NotNull ExerciseConfig configForExercise) { - - // clear content before adding new - gui.getRatingGroupContainer().removeAll(); - - // add all rating groups - configForExercise.getRatingGroups().stream() - // only add assessment group if it is non-empty - .filter(ratingGroup -> !ratingGroup.getMistakeTypes().isEmpty()) - .forEach(ratingGroup -> { - - // calculate grid size - int colsPerRatingGroup = ArtemisSettingsState.getInstance().getColumnsPerRatingGroup(); - int numRows = ratingGroup.getMistakeTypes().size() / colsPerRatingGroup; - - // create a panel of appropriate size for each rating group - JPanel ratingCroupContainer = new JPanel(new GridLayout(numRows + 1, colsPerRatingGroup)); - - ratingCroupContainer.setBorder(BorderFactory.createTitledBorder( - BorderFactory.createLineBorder(JBColor.LIGHT_GRAY), - String.format( - "%s [%.2f of %.2f]", - ratingGroup.getDisplayName(LOCALE), - ratingGroup.getRange().second(), - ratingGroup.getRange().first()))); - - // add buttons to rating group - ratingGroup.getMistakeTypes().stream() - // sort buttons alphabetically - // TODO: for some reason this is broken - .sorted(Comparator.comparing(mistake -> mistake.getButtonText(LOCALE))) - .forEach(mistakeType -> { - // create button, add listener and add it to the container - JButton assessmentButton = new JButton(mistakeType.getButtonText(LOCALE)); - assessmentButton.addActionListener( - new OnAssesmentButtonClickListener((MistakeType) mistakeType)); - ratingCroupContainer.add(assessmentButton); - }); - - gui.getRatingGroupContainer().add(ratingCroupContainer); - }); - } - - public static synchronized void updateJsonConfig(JsonFileConfig jsonFileConfig) { - ExerciseSelectedListener.fileConfig = jsonFileConfig; - } -} diff --git a/src/main/java/edu/kit/kastel/listeners/OnAssesmentButtonClickListener.java b/src/main/java/edu/kit/kastel/listeners/OnAssesmentButtonClickListener.java deleted file mode 100644 index 8389c5c..0000000 --- a/src/main/java/edu/kit/kastel/listeners/OnAssesmentButtonClickListener.java +++ /dev/null @@ -1,27 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.listeners; - -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; - -import edu.kit.kastel.sdq.artemis4j.grading.model.MistakeType; -import edu.kit.kastel.utils.AnnotationUtils; -import org.jetbrains.annotations.NotNull; - -/** - * This class represents a generic listener that is called if an assessment button - * is clicked. It should create and save the new annotation. - */ -public class OnAssesmentButtonClickListener implements ActionListener { - - private final MistakeType mistakeType; - - public OnAssesmentButtonClickListener(MistakeType mistakeType) { - this.mistakeType = mistakeType; - } - - @Override - public void actionPerformed(@NotNull ActionEvent actionEvent) { - AnnotationUtils.addAnnotationByMistakeType(this.mistakeType); - } -} diff --git a/src/main/java/edu/kit/kastel/listeners/OnSaveAssessmentBtnClick.java b/src/main/java/edu/kit/kastel/listeners/OnSaveAssessmentBtnClick.java deleted file mode 100644 index a311f6a..0000000 --- a/src/main/java/edu/kit/kastel/listeners/OnSaveAssessmentBtnClick.java +++ /dev/null @@ -1,24 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.listeners; - -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; - -import edu.kit.kastel.state.AssessmentModeHandler; -import edu.kit.kastel.utils.ArtemisUtils; - -/** - * Listener to be called if the Assessment is to be saved. - */ -public class OnSaveAssessmentBtnClick implements ActionListener { - - private static final String NO_ASSESSMENT_OPEN_ERR = "You are currently not grading an assessment."; - - @Override - public void actionPerformed(ActionEvent actionEvent) { - if (!AssessmentModeHandler.getInstance().isInAssesmentMode()) { - ArtemisUtils.displayGenericErrorBalloon(NO_ASSESSMENT_OPEN_ERR); - } - // TODO: implement - } -} diff --git a/src/main/java/edu/kit/kastel/listeners/OnSubmitAssessmentBtnClick.java b/src/main/java/edu/kit/kastel/listeners/OnSubmitAssessmentBtnClick.java deleted file mode 100644 index ff1994f..0000000 --- a/src/main/java/edu/kit/kastel/listeners/OnSubmitAssessmentBtnClick.java +++ /dev/null @@ -1,78 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.listeners; - -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; -import java.io.IOException; -import java.util.Optional; - -import com.intellij.openapi.diagnostic.Logger; -import edu.kit.kastel.extensions.guis.StatisticsContainer; -import edu.kit.kastel.sdq.artemis4j.api.ArtemisClientException; -import edu.kit.kastel.sdq.artemis4j.grading.artemis.AnnotationMapper; -import edu.kit.kastel.sdq.artemis4j.grading.config.ExerciseConfig; -import edu.kit.kastel.state.AssessmentModeHandler; -import edu.kit.kastel.utils.ArtemisUtils; -import edu.kit.kastel.utils.AssessmentUtils; - -public class OnSubmitAssessmentBtnClick implements ActionListener { - - private static final String ARTEMIS_ERROR_STRING = "An error occurred submitting the assessment to Artemis."; - - private static final String IO_ERROR_STRING = "Error creating assessment result"; - - private static final String ERROR_NOT_ASSESSING = - "Error obtaining exercise config. Are you currently assessing a submission?"; - - private final StatisticsContainer statisticsContainer; - - public OnSubmitAssessmentBtnClick(StatisticsContainer statisticsContainer) { - this.statisticsContainer = statisticsContainer; - } - - @Override - public void actionPerformed(ActionEvent actionEvent) { - // submit iff a lock is present - AssessmentModeHandler.getInstance().getAssessmentLock().ifPresent(lockResult -> { - - // trigger a statistics update - this.statisticsContainer.triggerUpdate(lockResult.getExercise()); - - Optional config = AssessmentUtils.getConfigAsExerciseCfg(); - - // only assess if the exercise config can be obtained - config.ifPresentOrElse( - exerciseConfig -> { - try { - // create assessment results - AnnotationMapper annotationMapper = new AnnotationMapper( - lockResult.getExercise(), - lockResult.getSubmission(), - AssessmentUtils.getAllAnnotations(), - exerciseConfig.getIRatingGroups(), - ArtemisUtils.getArtemisClientInstance() - .getAuthenticationClient() - .getUser(), - lockResult.getSubmissionLock()); - // save the assessment - ArtemisUtils.getArtemisClientInstance() - .getAssessmentArtemisClient() - .saveAssessment( - lockResult.getLockedSubmissionId(), - true, - annotationMapper.createAssessmentResult()); - } catch (ArtemisClientException ace) { - Logger.getInstance(OnSubmitAssessmentBtnClick.class).error(ace); - ArtemisUtils.displayGenericErrorBalloon(ARTEMIS_ERROR_STRING); - } catch (IOException ioe) { - Logger.getInstance(OnSubmitAssessmentBtnClick.class).error(ioe); - ArtemisUtils.displayGenericErrorBalloon(IO_ERROR_STRING); - } - - // disable the assessment mode - AssessmentModeHandler.getInstance().disableAssessmentMode(); - }, - () -> ArtemisUtils.displayGenericErrorBalloon(ERROR_NOT_ASSESSING)); - }); - } -} diff --git a/src/main/java/edu/kit/kastel/listeners/StartAssesment1Listener.java b/src/main/java/edu/kit/kastel/listeners/StartAssesment1Listener.java deleted file mode 100644 index a0332e4..0000000 --- a/src/main/java/edu/kit/kastel/listeners/StartAssesment1Listener.java +++ /dev/null @@ -1,255 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.listeners; - -import java.awt.event.ActionEvent; -import java.awt.event.ActionListener; -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Comparator; -import java.util.Optional; -import java.util.stream.Stream; - -import com.intellij.ide.impl.ProjectUtil; -import com.intellij.notification.NotificationGroupManager; -import com.intellij.notification.NotificationType; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.ui.MessageDialogBuilder; -import edu.kit.kastel.extensions.guis.AssessmentViewContent; -import edu.kit.kastel.extensions.settings.ArtemisSettingsState; -import edu.kit.kastel.sdq.artemis4j.api.ArtemisClientException; -import edu.kit.kastel.sdq.artemis4j.api.artemis.Exercise; -import edu.kit.kastel.sdq.artemis4j.api.artemis.assessment.LockResult; -import edu.kit.kastel.sdq.artemis4j.api.artemis.assessment.Submission; -import edu.kit.kastel.sdq.artemis4j.api.client.IAssessmentArtemisClient; -import edu.kit.kastel.state.AssessmentModeHandler; -import edu.kit.kastel.utils.ArtemisUtils; -import edu.kit.kastel.utils.AssessmentUtils; -import edu.kit.kastel.wrappers.Displayable; -import edu.kit.kastel.wrappers.ExtendedLockResult; -import org.eclipse.jgit.api.CloneCommand; -import org.eclipse.jgit.api.Git; -import org.eclipse.jgit.api.errors.GitAPIException; -import org.eclipse.jgit.api.errors.InvalidRemoteException; -import org.eclipse.jgit.api.errors.TransportException; -import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider; -import org.jetbrains.annotations.NotNull; -import org.slf4j.LoggerFactory; - -/** - * Listener that gets called when the first grading round is started. - */ -public class StartAssesment1Listener implements ActionListener { - - private static final String NO_CONFIG_SELECTED_MSG = "Please select the appropriate grading config"; - - private static final String SELECT_EXERCISE_MSG = "Please select an exercise to start grading"; - - private static final String ERROR_NEXT_ASSESSMENT_FORMATTER = "Error requestung a new submission lock: %s " - + "(this most likely means there are no more submissions to be graded)"; - - private static final String GIT_ERROR_FORMATTER = "Error cloning submission \"%s\". Are you authenticated?"; - - private static final String ERROR_DELETE_REPO_DIR = "Error deleting existing submission directory."; - - private static final String LOOSE_ASSESSMENT_MSG = "You already have an assessment loaded. Loading a new assessment" - + " will cause you to loose all unsaved gradings! Load new assessment anyway?"; - - private static final String LOCKED_BUT_NO_LOCK_RESULT_ERROR = - "The submission has been locked but a LockResult could not be obtained. Please submit a GitHub Issue"; - private final AssessmentViewContent gui; - - private final IAssessmentArtemisClient assessmentClient = - ArtemisUtils.getArtemisClientInstance().getAssessmentArtemisClient(); - - public StartAssesment1Listener(AssessmentViewContent gui) { - this.gui = gui; - } - - @Override - public void actionPerformed(ActionEvent actionEvent) { - - // check if any config is selected. If wrong config is selected Button will be unclickable - if (gui.getGradingConfigPathInput().getText().isBlank()) { - ArtemisUtils.displayGenericErrorBalloon(NO_CONFIG_SELECTED_MSG); - return; - } - - if (gui.getExercisesDropdown().getSelectedItem() == null) { - ArtemisUtils.displayGenericErrorBalloon(SELECT_EXERCISE_MSG); - return; - } - - // check if an assessment is already loaded - if (AssessmentModeHandler.getInstance().isInAssesmentMode() - && !MessageDialogBuilder.yesNo("Unsaved assessment", LOOSE_ASSESSMENT_MSG) - .guessWindowAndAsk()) { - return; - } - - // get the assessment and try to obtain a lock - Exercise selectedExercise = - ((Displayable) gui.getExercisesDropdown().getSelectedItem()).getWrappedValue(); - - Optional lockedSubmissionIdWrapper; - try { - - // TODO: Calculate if assessments are still to be graded or not - - lockedSubmissionIdWrapper = assessmentClient.startNextAssessment(selectedExercise, 0); - - } catch (ArtemisClientException e) { - ArtemisUtils.displayGenericErrorBalloon(String.format(ERROR_NEXT_ASSESSMENT_FORMATTER, e.getMessage())); - return; - } - - if (lockedSubmissionIdWrapper.isEmpty()) { - ArtemisUtils.displayGenericErrorBalloon( - // TODO: correct error message - String.format(ERROR_NEXT_ASSESSMENT_FORMATTER, "")); - return; - } - - // process the submission iff a lock was obtained - Integer lockedAssessmentId = lockedSubmissionIdWrapper.get(); - - // attempt to truly obtain the lock for the submission - Optional lockWrapper = Optional.empty(); - try { - lockWrapper = Optional.ofNullable(assessmentClient.startAssessment(ArtemisUtils.getArtemisClientInstance() - .getSubmissionArtemisClient() - .getSubmissionById(selectedExercise, lockedAssessmentId))); - } catch (ArtemisClientException e) { - ArtemisUtils.displayGenericErrorBalloon(LOCKED_BUT_NO_LOCK_RESULT_ERROR); - Logger.getInstance(StartAssesment1Listener.class).error(e); - } - - Optional repoUrlWrapper = getRepoUrlFromAssessmentLock(lockedAssessmentId, selectedExercise); - - // obtain student name or use submission number - String repoIdentifier; - try { - repoIdentifier = ArtemisUtils.getArtemisClientInstance() - .getSubmissionArtemisClient() - .getSubmissionById(selectedExercise, lockedAssessmentId) - .getParticipantIdentifier(); - } catch (ArtemisClientException e) { - repoIdentifier = Integer.toString(lockedAssessmentId); - } - - String repositoryName = String.format("%s_%s", selectedExercise.getShortName(), repoIdentifier); - - repoUrlWrapper.ifPresent(repoUrl -> this.cloneSubmissionToTempdir(repoUrl, repositoryName)); - - // set assessment mode - lockWrapper.ifPresent(submissionLock -> AssessmentModeHandler.getInstance() - .enableAssessmentMode(new ExtendedLockResult(lockedAssessmentId, selectedExercise, submissionLock))); - - // run Autograder - runAutograder(); - - // update statistics - gui.getStatisticsContainer().triggerUpdate(selectedExercise); - } - - /** - * Use an assessmentLock to get the repository URL of a submission. - * - * @param lockedSubmissionId The id of the submission that has been locked - * @param selectedExercise The exercise this submission belongs to - * @return Optional#empty if an error occurred, The wrapped string otherwise - */ - private Optional getRepoUrlFromAssessmentLock( - @NotNull Integer lockedSubmissionId, @NotNull Exercise selectedExercise) { - - Optional repoUrl = Optional.empty(); - try { - // try to get the repository URL of the submission - Submission toBeGraded = ArtemisUtils.getArtemisClientInstance() - .getSubmissionArtemisClient() - .getSubmissionById(selectedExercise, lockedSubmissionId); - repoUrl = Optional.of(toBeGraded.getRepositoryUrl()); - } catch (ArtemisClientException e) { - // display Error Balloon on failure - ArtemisUtils.displayGenericErrorBalloon(String.format(ERROR_NEXT_ASSESSMENT_FORMATTER, e.getMessage())); - } - - return repoUrl; - } - - private void cloneSubmissionToTempdir(@NotNull String repoUrl, @NotNull String repoName) { - // construct repo path - File submissionRepoDir = new File(String.valueOf(Path.of(System.getProperty("java.io.tmpdir"), repoName))); - - // delete repository folder if it exists - if (submissionRepoDir.exists()) { - try (Stream files = Files.walk(submissionRepoDir.toPath())) { - files.sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete); - } catch (IOException e) { - ArtemisUtils.displayGenericErrorBalloon(ERROR_DELETE_REPO_DIR); - } - } - - // try to clone the repository - CloneCommand cloner = Git.cloneRepository() - .setURI(repoUrl) - .setDirectory(submissionRepoDir) - .setCredentialsProvider(new UsernamePasswordCredentialsProvider( - ArtemisSettingsState.getInstance().getUsername(), - ArtemisSettingsState.getInstance().getArtemisPassword())); - // generate notification because cloning is slow - NotificationGroupManager.getInstance() - .getNotificationGroup("IntelliGrade Notifications") - .createNotification("Cloning repository...\n This might take a while.", NotificationType.INFORMATION) - .setTitle("Please wait") - .notify(ProjectUtil.getActiveProject()); - - try (Git ignored = cloner.call()) { - // empty because of try with resource - } catch (InvalidRemoteException | TransportException e) { - ArtemisUtils.displayGenericErrorBalloon(String.format(GIT_ERROR_FORMATTER, e.getMessage())); - return; - } catch (GitAPIException e) { - LoggerFactory.getLogger(StartAssesment1Listener.class).error(e.toString()); - return; - } - - // document selected exercise - int selectedIdx = this.gui.getExercisesDropdown().getSelectedIndex(); - - // open project - ProjectUtil.openOrImport(submissionRepoDir.toPath(), ProjectUtil.getActiveProject(), false); - - // reset listeners because they get registered when a new project is opened - AssessmentUtils.resetAssessmentListeners(); - - // generate notification because cloning is slow - NotificationGroupManager.getInstance() - .getNotificationGroup("IntelliGrade Notifications") - .createNotification( - String.format("Submission %s cloned successfully.", repoName), NotificationType.INFORMATION) - .setTitle("Finished") - .notify(ProjectUtil.getActiveProject()); - - // set correct exercise in panel (new IDE instance resets this) - this.gui.getExercisesDropdown().setSelectedIndex(selectedIdx); - } - - private void runAutograder() { - /* - //TODO: boilerplate implementation - try (TempLocation tempProjectLocation = TempLocation.random()) { - Application autograderInstance = new Application(tempProjectLocation); - Future resultCode = Executors.newSingleThreadExecutor().submit(autograderInstance); - resultCode.get(); - } catch (IOException e) { - //If creation of temp dir fails - } catch (ExecutionException e) { - //if autograder Throws Exception - } catch (InterruptedException e) { - //If autograder is interrupted - } - */ - } -} diff --git a/src/main/java/edu/kit/kastel/login/CefUtils.java b/src/main/java/edu/kit/kastel/login/CefUtils.java deleted file mode 100644 index c108592..0000000 --- a/src/main/java/edu/kit/kastel/login/CefUtils.java +++ /dev/null @@ -1,68 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.login; - -import java.awt.GridLayout; -import java.awt.Toolkit; -import java.util.concurrent.Future; - -import javax.swing.JFrame; -import javax.swing.JPanel; -import javax.swing.WindowConstants; - -import com.intellij.ui.jcef.JBCefApp; -import com.intellij.ui.jcef.JBCefBrowser; -import com.intellij.ui.jcef.JBCefClient; -import com.intellij.ui.jcef.JBCefCookie; -import edu.kit.kastel.extensions.settings.ArtemisSettingsState; -import org.cef.handler.CefFocusHandler; -import org.jetbrains.annotations.NotNull; - -public final class CefUtils { - - private static final double BROWSER_WINDOW_SCALE_FACTOR = 0.75f; - private static final String BROWSER_LOGIN_TITLE = "Artemis log in"; - - private CefUtils() { - throw new IllegalAccessError("Utility Class"); - } - - /** - * Create and display a Window containing a JBCef Window to request login. - * - * @return A future on the JWT Cookie to log in - */ - public static @NotNull Future jcefBrowserLogin() throws InterruptedException { - - // create browser and browser Client - JBCefClient browserClient = JBCefApp.getInstance().createClient(); - JBCefBrowser browser = JBCefBrowser.createBuilder() - .setClient(browserClient) - .setUrl(ArtemisSettingsState.getInstance().getArtemisInstanceUrl()) - .build(); - - // add a handler to the Browser to be run if a page is loaded - CefWindowLoadHandler loadHandler = new CefWindowLoadHandler(browser); - browserClient.addLoadHandler(loadHandler, browser.getCefBrowser()); - - // set focus handler because it gets invoked sometimes and causes NullPE otherwise - CefFocusHandler focusHandler = new CefWindowFocusHandler(); - browserClient.addFocusHandler(focusHandler, browser.getCefBrowser()); - - // create window, display it and navigate to log in URL - createWindow(browser); - return loadHandler.getCookieFuture(); - } - - private static void createWindow(@NotNull JBCefBrowser browserToAdd) { - // build and display browser window - JFrame browserContainerWindow = new JFrame(BROWSER_LOGIN_TITLE); - browserContainerWindow.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); - browserContainerWindow.setSize( - (int) Math.ceil(Toolkit.getDefaultToolkit().getScreenSize().getWidth() * BROWSER_WINDOW_SCALE_FACTOR), - (int) Math.ceil(Toolkit.getDefaultToolkit().getScreenSize().getHeight() * BROWSER_WINDOW_SCALE_FACTOR)); - JPanel browserContainer = new JPanel(new GridLayout(1, 1)); - browserContainerWindow.add(browserContainer); - browserContainer.add(browserToAdd.getComponent()); - browserContainerWindow.setVisible(true); - } -} diff --git a/src/main/java/edu/kit/kastel/login/CefWindowLoadHandler.java b/src/main/java/edu/kit/kastel/login/CefWindowLoadHandler.java deleted file mode 100644 index e634a5b..0000000 --- a/src/main/java/edu/kit/kastel/login/CefWindowLoadHandler.java +++ /dev/null @@ -1,62 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.login; - -import java.util.Optional; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; - -import com.intellij.ui.jcef.JBCefBrowser; -import com.intellij.ui.jcef.JBCefCookie; -import org.cef.browser.CefBrowser; -import org.cef.browser.CefFrame; -import org.cef.handler.CefLoadHandlerAdapter; -import org.jetbrains.annotations.NotNull; - -/** - * Class to handle callbacks when a site in the JBCef Browser is loaded. Use to start retrieving cookies. - */ -public class CefWindowLoadHandler extends CefLoadHandlerAdapter { - - private final JBCefBrowser containingBrowser; - - private Optional> cookieFuture; - - CefWindowLoadHandler(JBCefBrowser browser) { - this.containingBrowser = browser; - this.cookieFuture = Optional.empty(); - } - - @Override - public void onLoadEnd(@NotNull CefBrowser browser, CefFrame frame, int httpStatusCode) { - - // build and start a new Thread to retrieve the Cookies - JwtRetriever cookieRetriever = new JwtRetriever(containingBrowser); - // lock the Monitor - synchronized (this) { - // create future to retrieve Cookie - // NOTE: using Callable instead of Runnable - this.cookieFuture = Optional.of(Executors.newSingleThreadExecutor().submit(cookieRetriever)); - // wake up potentially waiting Threads that want to get the Cookie - notifyAll(); - } - } - - /** - * Get the Future on the Cookie. Wait for it to become available if necessary. - * - * @return a Future on the JWT Cookie to be retrieved later - * @throws InterruptedException if the call is interrupted while attempting to obtain the Cookie - */ - public Future getCookieFuture() throws InterruptedException { - // lock monitor - synchronized (this) { - // wait for the cookie to become available. - // necessary because this method might be called before onLoadEnd - while (this.cookieFuture.isEmpty()) { - wait(); - } - // return content of Optional - return cookieFuture.get(); - } - } -} diff --git a/src/main/java/edu/kit/kastel/login/CustomLoginManager.java b/src/main/java/edu/kit/kastel/login/CustomLoginManager.java deleted file mode 100644 index 7b78c36..0000000 --- a/src/main/java/edu/kit/kastel/login/CustomLoginManager.java +++ /dev/null @@ -1,89 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.login; - -import java.time.Instant; -import java.util.Date; -import java.util.concurrent.ExecutionException; - -import com.intellij.notification.Notification; -import com.intellij.notification.NotificationAction; -import com.intellij.openapi.actionSystem.AnActionEvent; -import com.intellij.openapi.options.ShowSettingsUtil; -import com.intellij.ui.jcef.JBCefApp; -import com.intellij.ui.jcef.JBCefCookie; -import edu.kit.kastel.extensions.settings.ArtemisSettingsState; -import edu.kit.kastel.sdq.artemis4j.api.ArtemisClientException; -import edu.kit.kastel.utils.ArtemisUtils; -import org.jetbrains.annotations.NotNull; - -public class CustomLoginManager extends edu.kit.kastel.sdq.artemis4j.client.LoginManager { - - private static final String JFCE_UNAVAILABLE_ERRORMESSAGE = - "Embedded browser unavailable. Please log in using username and password."; - - private static final ArtemisSettingsState settingsStore = ArtemisSettingsState.getInstance(); - - public CustomLoginManager(String hostname, String username, String password) { - super(hostname, username, password); - } - - @Override - public void login() throws ArtemisClientException { - - // before we log in we check if the jwt needs to be invalidated - if (Date.from(Instant.now()).after(settingsStore.getJwtExpiry())) { - settingsStore.setArtemisAuthJWT(""); - settingsStore.setJwtExpiry(new Date(Long.MAX_VALUE)); - } - - if (this.hostname.isBlank()) { - throw new ArtemisClientException("Login without hostname is impossible"); - } else if (this.username == null - || this.password == null - || this.username.isBlank() - || this.password.isBlank()) { - // check log in data and open login Browser iff needed - // only do browser login if no token is set and either username or password is not set as well - if (settingsStore.getArtemisAuthJWT() == null - || settingsStore.getArtemisAuthJWT().isBlank()) { - if (JBCefApp.isSupported()) { - ifCefAvailable(); - } else { - // if JCEF is unavailable suggest usage of conventional login - displayJCEFUnavailableError(); - } - } - // get Token from Settings, which should be set now - this.token = settingsStore.getArtemisAuthJWT(); - } else { - this.token = this.loginViaUsernameAndPassword(); - } - this.assessor = this.fetchAssessor(); - } - - private void displayJCEFUnavailableError() { - ArtemisUtils.displayLoginErrorBalloon(JFCE_UNAVAILABLE_ERRORMESSAGE, new NotificationAction("Log in...") { - @Override - public void actionPerformed(@NotNull AnActionEvent e, @NotNull Notification notification) { - ShowSettingsUtil.getInstance().showSettingsDialog(null, "IntelliGrade Settings"); - } - }); - } - - private void ifCefAvailable() throws ArtemisClientException { - try { - // open the JCEF Browser in a new Panel to allow user to log in - // wait for operation to complete - JBCefCookie jwtCookie = CefUtils.jcefBrowserLogin().get(); - - // set relevant information - settingsStore.setArtemisAuthJWT(jwtCookie.getValue()); - settingsStore.setJwtExpiry(jwtCookie.getExpires()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new ArtemisClientException("Interrupted while attempting to get login token"); - } catch (ExecutionException e) { - throw new ArtemisClientException(String.format("Error retrieving token! %n%s", e.getMessage())); - } - } -} diff --git a/src/main/java/edu/kit/kastel/login/JwtRetriever.java b/src/main/java/edu/kit/kastel/login/JwtRetriever.java deleted file mode 100644 index d28b802..0000000 --- a/src/main/java/edu/kit/kastel/login/JwtRetriever.java +++ /dev/null @@ -1,44 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.login; - -import java.util.List; -import java.util.concurrent.Callable; -import java.util.concurrent.Future; - -import com.intellij.ui.jcef.JBCefBrowser; -import com.intellij.ui.jcef.JBCefCookie; -import com.intellij.ui.jcef.JBCefCookieManager; -import edu.kit.kastel.extensions.settings.ArtemisSettingsState; - -public class JwtRetriever implements Callable { - - private static final String JWT_COOKIE_KEY = "jwt"; - - private final JBCefBrowser browser; - - private JBCefCookie cookieRetrieved; - - public JwtRetriever(JBCefBrowser browser) { - this.browser = browser; - } - - @Override - public JBCefCookie call() throws Exception { - ArtemisSettingsState settingsStore = ArtemisSettingsState.getInstance(); - - JBCefCookieManager cookieManager = browser.getJBCefCookieManager(); - Future> cookies = cookieManager.getCookies(settingsStore.getArtemisInstanceUrl(), true); - // get all cookies, search for the jwt and update it in the settings if necessary - cookies.get().stream() - .filter(cookie -> cookie.getName().equals(JWT_COOKIE_KEY)) - .forEach(cookie -> { - String jwt = cookie.getValue(); - this.cookieRetrieved = cookie; - if (!jwt.equals(settingsStore.getArtemisAuthJWT())) { - this.browser.getJBCefClient().dispose(); - this.browser.dispose(); - } - }); - return cookieRetrieved; - } -} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/actions/AddAnnotationPopupAction.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/actions/AddAnnotationPopupAction.java new file mode 100644 index 0000000..bd07024 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/actions/AddAnnotationPopupAction.java @@ -0,0 +1,124 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.actions; + +import java.awt.event.ActionEvent; +import java.awt.event.InputEvent; +import java.awt.event.KeyEvent; +import java.util.Locale; + +import javax.swing.AbstractAction; + +import com.intellij.DynamicBundle; +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnAction; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.CommonDataKeys; +import com.intellij.openapi.actionSystem.DataContext; +import com.intellij.openapi.actionSystem.DefaultActionGroup; +import com.intellij.openapi.editor.Caret; +import com.intellij.openapi.ui.popup.JBPopupFactory; +import com.intellij.ui.AnActionButton; +import com.intellij.ui.popup.list.ListPopupImpl; +import edu.kit.kastel.sdq.artemis4j.grading.penalty.MistakeType; +import edu.kit.kastel.sdq.intelligrade.state.PluginState; +import org.jetbrains.annotations.NotNull; + +public class AddAnnotationPopupAction extends AnAction { + private static final Locale LOCALE = DynamicBundle.getLocale(); + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.BGT; + } + + @Override + public void update(@NotNull AnActionEvent e) { + Caret caret = e.getData(CommonDataKeys.CARET); + + // if no exercise config is loaded, we cannot make annotations + // if there is no caret we can not sensibly display a popup + e.getPresentation() + .setEnabledAndVisible(caret != null && PluginState.getInstance().isAssessing()); + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + Caret caret = e.getData(CommonDataKeys.CARET); + + // if no exercise config is loaded, we cannot make annotations + // if there is no caret we can not sensibly display a popup + if (caret == null || !PluginState.getInstance().isAssessing()) { + return; + } + + // collect all mistake types that can be annotated + var mistakeTypes = PluginState.getInstance() + .getActiveAssessment() + .orElseThrow() + .getGradingConfig() + .getMistakeTypes(); + + var actions = new DefaultActionGroup(); + for (var mistakeType : mistakeTypes) { + actions.add(new MistakeTypeButton(mistakeType)); + } + + // create a popup with all possible mistakes + var popup = JBPopupFactory.getInstance() + .createActionGroupPopup( + "Add Annotation", + actions, + DataContext.EMPTY_CONTEXT, + JBPopupFactory.ActionSelectionAid.SPEEDSEARCH, + false); + + // Code borrowed from ListPopupImpl#createContent (line 323) to allow ctrl+enter for selection + var listPopup = ((ListPopupImpl) popup); + listPopup.registerAction( + "handleSelectionCtrl", KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK, new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + popup.handleSelect( + true, + new KeyEvent( + listPopup.getList(), + KeyEvent.KEY_PRESSED, + e.getWhen(), + e.getModifiers(), + KeyEvent.VK_ENTER, + KeyEvent.CHAR_UNDEFINED)); + } + }); + + popup.showInBestPositionFor(caret.getEditor()); + } + + private static class MistakeTypeButton extends AnActionButton { + private final MistakeType mistakeType; + + public MistakeTypeButton(MistakeType mistakeType) { + super(mistakeType.getButtonText().translateTo(LOCALE)); + this.mistakeType = mistakeType; + } + + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + boolean withCustomMessage = + e.getInputEvent() != null && e.getInputEvent().isControlDown(); + PluginState.getInstance() + .getActiveAssessment() + .orElseThrow() + .addAnnotationAtCaret(mistakeType, withCustomMessage); + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.EDT; + } + + @Override + public boolean isDumbAware() { + return true; + } + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/autograder/AutograderTask.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/autograder/AutograderTask.java new file mode 100644 index 0000000..9756d2e --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/autograder/AutograderTask.java @@ -0,0 +1,103 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.autograder; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.Locale; +import java.util.function.Consumer; + +import com.intellij.icons.AllIcons; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.Task; +import com.intellij.openapi.ui.Messages; +import de.firemage.autograder.api.loader.AutograderLoader; +import edu.kit.kastel.sdq.artemis4j.grading.Assessment; +import edu.kit.kastel.sdq.artemis4j.grading.ClonedProgrammingSubmission; +import edu.kit.kastel.sdq.artemis4j.grading.autograder.AutograderFailedException; +import edu.kit.kastel.sdq.artemis4j.grading.autograder.AutograderRunner; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettingsState; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.AutograderOption; +import edu.kit.kastel.sdq.intelligrade.utils.ArtemisUtils; +import edu.kit.kastel.sdq.intelligrade.utils.IntellijUtil; +import org.jetbrains.annotations.NotNull; + +public class AutograderTask extends Task.Backgroundable { + private static final Logger LOG = Logger.getInstance(AutograderTask.class); + + private final Assessment assessment; + private final ClonedProgrammingSubmission clonedSubmission; + private final Runnable onSuccessCallback; + + public static void execute( + Assessment assessment, ClonedProgrammingSubmission clonedSubmission, Runnable onSuccessCallback) { + new AutograderTask(assessment, clonedSubmission, onSuccessCallback) + .setCancelText("Stop Autograder") + .queue(); + } + + private AutograderTask(Assessment assessment, ClonedProgrammingSubmission clonedSubmission, Runnable onSuccess) { + super(IntellijUtil.getActiveProject(), "Autograder", true); + + this.assessment = assessment; + this.clonedSubmission = clonedSubmission; + this.onSuccessCallback = onSuccess; + } + + public void run(@NotNull ProgressIndicator indicator) { + indicator.setIndeterminate(true); + + var settings = ArtemisSettingsState.getInstance(); + + // Load Autograder from file + if (settings.getAutograderOption() == AutograderOption.FROM_FILE + && !loadAutograderFromFile(settings, indicator)) { + return; + } + + try { + Consumer statusConsumer = status -> indicator.setText("Autograder: " + status); + + var stats = AutograderRunner.runAutograder(assessment, clonedSubmission, Locale.GERMANY, 2, statusConsumer); + + String message = "Autograder made %d annotation(s). Please double-check all of them for false-positives!" + .formatted(stats.annotationsMade()); + ApplicationManager.getApplication() + .invokeLater( + () -> Messages.showMessageDialog(message, "Autograder Completed", AllIcons.Status.Success)); + + ApplicationManager.getApplication().invokeLater(this.onSuccessCallback); + } catch (AutograderFailedException e) { + LOG.warn(e); + ArtemisUtils.displayGenericErrorBalloon("Autograder Failed", e.getMessage()); + } + } + + private boolean loadAutograderFromFile(ArtemisSettingsState settings, ProgressIndicator indicator) { + if (!AutograderLoader.isAutograderLoaded()) { + var path = settings.getAutograderPath(); + if (path == null || path.isBlank()) { + ArtemisUtils.displayGenericErrorBalloon( + "No Autograder Path", + "Please set the path to the Autograder JAR, or choose to download it from GitHub."); + return false; + } + + indicator.setText("Loading Autograder"); + try { + AutograderLoader.loadFromFile(Path.of(settings.getAutograderPath())); + } catch (IOException e) { + LOG.warn(e); + ArtemisUtils.displayGenericErrorBalloon("Could not load Autograder", e.getMessage()); + return false; + } + } else { + ArtemisUtils.displayGenericWarningBalloon( + "Autograder Already Loaded", + "Not reloading it from the specified file. Restart the IDE to reload it."); + } + + return true; + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/AnnotationsListPanel.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/AnnotationsListPanel.java new file mode 100644 index 0000000..ed753bf --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/AnnotationsListPanel.java @@ -0,0 +1,182 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.guis; + +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +import javax.swing.SwingUtilities; + +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.DefaultActionGroup; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.OpenFileDescriptor; +import com.intellij.openapi.ui.SimpleToolWindowPanel; +import com.intellij.openapi.wm.ToolWindowManager; +import com.intellij.ui.AnActionButton; +import com.intellij.ui.PopupHandler; +import com.intellij.ui.ScrollPaneFactory; +import com.intellij.ui.table.JBTable; +import edu.kit.kastel.sdq.artemis4j.grading.Annotation; +import edu.kit.kastel.sdq.intelligrade.state.PluginState; +import edu.kit.kastel.sdq.intelligrade.utils.IntellijUtil; +import org.jetbrains.annotations.NotNull; + +public class AnnotationsListPanel extends SimpleToolWindowPanel { + private final List displayAnnotations = new ArrayList<>(); + private final AnnotationsTableModel model; + private final JBTable table; + + public static AnnotationsListPanel getPanel() { + var toolWindow = + ToolWindowManager.getInstance(IntellijUtil.getActiveProject()).getToolWindow("Annotations"); + return (AnnotationsListPanel) + toolWindow.getContentManager().getContent(0).getComponent(); + } + + public AnnotationsListPanel() { + super(true, true); + + model = new AnnotationsTableModel(); + + table = new JBTable(model); + table.setAutoCreateRowSorter(true); + + setContent(ScrollPaneFactory.createScrollPane(table)); + + // Delete annotation on delete key press + table.addKeyListener(new KeyAdapter() { + @Override + public void keyReleased(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_DELETE) { + // First collect all annotations to delete, then delete them + // If delete them one by one, the row indices change and the wrong annotations are deleted + var annotationsToDelete = Arrays.stream(table.getSelectedRows()) + .map(table::convertRowIndexToModel) + .filter(row -> row >= 0) + .mapToObj(model::get) + .toList(); + + var assessment = + PluginState.getInstance().getActiveAssessment().orElseThrow(); + for (var annotation : annotationsToDelete) { + assessment.deleteAnnotation(annotation); + } + } + } + }); + + // Double-clicks on the table + table.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + int row = table.rowAtPoint(e.getPoint()); + int column = table.columnAtPoint(e.getPoint()); + + if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2 && row >= 0) { + var annotation = model.get(table.convertRowIndexToModel(row)); + + if (table.convertColumnIndexToModel(column) == AnnotationsTableModel.CUSTOM_MESSAGE_COLUMN) { + // Edit the custom message + PluginState.getInstance() + .getActiveAssessment() + .orElseThrow() + .changeCustomMessage(annotation); + } else { + // Jump to the line in the editor + var file = IntellijUtil.getAnnotationFile(annotation); + var document = FileDocumentManager.getInstance().getDocument(file); + int offset = document.getLineStartOffset(annotation.getStartLine()); + FileEditorManager.getInstance(IntellijUtil.getActiveProject()) + .openTextEditor( + new OpenFileDescriptor(IntellijUtil.getActiveProject(), file, offset), true); + } + } + } + }); + + // Add the right-click menu + addPopupMenu(); + + PluginState.getInstance() + .registerAssessmentStartedListener( + assessment -> assessment.registerAnnotationsUpdatedListener(annotations -> { + this.displayAnnotations.clear(); + this.displayAnnotations.addAll(annotations); + this.displayAnnotations.sort(Comparator.comparing(Annotation::getFilePath) + .thenComparing(Annotation::getStartLine) + .thenComparing(Annotation::getEndLine)); + + AnnotationsTableModel tableModel = ((AnnotationsTableModel) table.getModel()); + tableModel.setAnnotations(displayAnnotations); + table.revalidate(); + table.updateUI(); + })); + + PluginState.getInstance().registerAssessmentClosedListener(() -> { + this.displayAnnotations.clear(); + AnnotationsTableModel tableModel = ((AnnotationsTableModel) table.getModel()); + tableModel.clearAnnotations(); + table.updateUI(); + }); + } + + public void selectAnnotation(Annotation annotation) { + var row = displayAnnotations.indexOf(annotation); + if (row >= 0) { + int viewRow = table.convertRowIndexToView(row); + table.setRowSelectionInterval(viewRow, viewRow); + } + } + + private void addPopupMenu() { + var group = new DefaultActionGroup(); + + var editButton = new AnActionButton("Edit Custom Message/Score") { + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + var row = table.convertRowIndexToModel(table.getSelectedRow()); + if (row >= 0) { + PluginState.getInstance() + .getActiveAssessment() + .orElseThrow() + .changeCustomMessage(model.get(row)); + } + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.EDT; + } + }; + group.addAction(editButton); + + var deleteButton = new AnActionButton("Delete") { + @Override + public void actionPerformed(@NotNull AnActionEvent e) { + var row = table.convertRowIndexToModel(table.getSelectedRow()); + if (row >= 0) { + PluginState.getInstance() + .getActiveAssessment() + .orElseThrow() + .deleteAnnotation(model.get(row)); + } + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.EDT; + } + }; + group.addAction(deleteButton); + + PopupHandler.installPopupMenu(table, group, "popup@AnnotationsListPanel"); + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/AnnotationsTableModel.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/AnnotationsTableModel.java new file mode 100644 index 0000000..eb605d8 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/AnnotationsTableModel.java @@ -0,0 +1,98 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.guis; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +import javax.swing.table.AbstractTableModel; + +import com.intellij.DynamicBundle; +import edu.kit.kastel.sdq.artemis4j.grading.Annotation; + +/** + * The table model for the annotations table. + */ +public class AnnotationsTableModel extends AbstractTableModel { + + public static final int MISTAKE_TYPE_COLUMN = 0; + public static final int LINES_COLUMN = 1; + public static final int FILE_COLUMN = 2; + public static final int SOURCE_COLUMN = 3; + public static final int CUSTOM_MESSAGE_COLUMN = 4; + public static final int CUSTOM_PENALTY_COLUMN = 5; + + private static final String[] HEADINGS = { + "Mistake type", "Line(s)", "File", "Source", "Custom Message", "Custom Penalty" + }; + + private static final Locale LOCALE = DynamicBundle.getLocale(); + + private final List annotations = new ArrayList<>(); + + @Override + public int getRowCount() { + return annotations.size(); + } + + @Override + public int getColumnCount() { + return HEADINGS.length; + } + + @Override + public String getColumnName(int column) { + return HEADINGS[column]; + } + + @Override + public Object getValueAt(int row, int column) { + Annotation annotation = annotations.get(row); + + if (annotation == null) { + return ""; + } + + return switch (column) { + case MISTAKE_TYPE_COLUMN -> annotation + .getMistakeType() + .getButtonText() + .translateTo(LOCALE); + case LINES_COLUMN -> formatLines(annotation); + case FILE_COLUMN -> annotation.getFilePath(); + case SOURCE_COLUMN -> annotation.getSource(); + case CUSTOM_MESSAGE_COLUMN -> annotation.getCustomMessage().orElse(""); + case CUSTOM_PENALTY_COLUMN -> annotation + .getCustomScore() + .map(String::valueOf) + .orElse(""); + default -> throw new IllegalStateException("No table data at index %d:%d".formatted(row, column)); + }; + } + + public void setAnnotations(List annotations) { + this.annotations.clear(); + this.annotations.addAll(annotations); + fireTableDataChanged(); + } + + public void clearAnnotations() { + this.annotations.clear(); + fireTableDataChanged(); + } + + public Annotation get(int index) { + return annotations.get(index); + } + + private String formatLines(Annotation annotation) { + int startLine = annotation.getStartLine() + 1; + int endLine = annotation.getEndLine() + 1; + + if (startLine == endLine) { + return String.valueOf(startLine); + } else { + return String.format("%d - %d", startLine, endLine); + } + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/AssessmentPanel.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/AssessmentPanel.java new file mode 100644 index 0000000..792feb4 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/AssessmentPanel.java @@ -0,0 +1,261 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.guis; + +import java.awt.*; +import java.awt.event.ActionEvent; +import java.awt.font.TextAttribute; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import javax.swing.*; +import javax.swing.border.TitledBorder; +import javax.swing.plaf.LayerUI; + +import com.intellij.DynamicBundle; +import com.intellij.openapi.ui.SimpleToolWindowPanel; +import com.intellij.ui.JBColor; +import com.intellij.ui.ScrollPaneFactory; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBPanel; +import com.intellij.util.ui.JBFont; +import edu.kit.kastel.sdq.artemis4j.grading.Assessment; +import edu.kit.kastel.sdq.artemis4j.grading.penalty.*; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettingsState; +import edu.kit.kastel.sdq.intelligrade.state.ActiveAssessment; +import edu.kit.kastel.sdq.intelligrade.state.PluginState; +import net.miginfocom.swing.MigLayout; + +public class AssessmentPanel extends SimpleToolWindowPanel { + private static final Locale LOCALE = DynamicBundle.getLocale(); + + private final JPanel content; + private final JBLabel pointsLabel; + private final Map ratingGroupBorders = new IdentityHashMap<>(); + private final List assessmentButtons = new ArrayList<>(); + + public AssessmentPanel() { + super(true, true); + + content = new JBPanel<>(new MigLayout("wrap 1", "[grow]")); + setContent(ScrollPaneFactory.createScrollPane(content)); + + pointsLabel = new JBLabel(); + + this.showNoActiveAssessment(); + PluginState.getInstance().registerAssessmentStartedListener(assessment -> { + content.removeAll(); + + content.add(pointsLabel, "alignx center"); + + var infoLabel = new JBLabel("Hold Ctrl to add a custom message"); + infoLabel.setForeground(JBColor.GRAY); + content.add(infoLabel, "alignx center"); + + assessment.registerAnnotationsUpdatedListener(annotations -> { + var a = assessment.getAssessment(); + var testPoints = a.calculateTotalPointsOfTests(); + var annotationPoints = a.calculateTotalPointsOfAnnotations(); + var totalPoints = a.calculateTotalPoints(); + var maxPoints = a.getMaxPoints(); + pointsLabel.setText(getAssessmentPointsTitle(testPoints, annotationPoints, totalPoints, maxPoints)); + }); + + this.createMistakeButtons(assessment); + }); + PluginState.getInstance().registerAssessmentClosedListener(this::showNoActiveAssessment); + } + + private void createMistakeButtons(ActiveAssessment assessment) { + int buttonsPerRow = ArtemisSettingsState.getInstance().getColumnsPerRatingGroup(); + for (var ratingGroup : assessment.getGradingConfig().getRatingGroups()) { + if (ratingGroup.getMistakeTypes().isEmpty()) { + continue; + } + + var panel = new JBPanel<>(new MigLayout("fill, gap 0, wrap " + buttonsPerRow)); + + var border = BorderFactory.createTitledBorder( + BorderFactory.createLineBorder(JBColor.border()), + String.format(getRatingGroupTitle(assessment.getAssessment(), ratingGroup))); + border.setTitleFont(JBFont.h3().asBold()); + panel.setBorder(border); + this.ratingGroupBorders.put(ratingGroup, border); + + for (var mistakeType : ratingGroup.getMistakeTypes()) { + var button = new JButton(mistakeType.getButtonText().translateTo(LOCALE)); + button.setToolTipText(mistakeType.getMessage().translateTo(LOCALE)); + + var iconRenderer = new MistakeTypeIconRenderer(); + var layer = new LayerUI<>() { + @Override + public void paint(Graphics g, JComponent c) { + super.paint(g, c); + iconRenderer.paint((Graphics2D) g, c); + } + }; + JPanel buttonPanel = new JPanel(new MigLayout("fill")); + buttonPanel.add(button, "growx"); + panel.add(new JLayer<>(buttonPanel, layer), "growx, sizegroup main"); + + button.addActionListener(a -> assessment.addAnnotationAtCaret( + mistakeType, (a.getModifiers() & ActionEvent.CTRL_MASK) == ActionEvent.CTRL_MASK)); + this.assessmentButtons.add(new AssessmentButton(mistakeType, button, iconRenderer)); + } + + this.content.add(panel, "growx"); + } + + assessment.registerAnnotationsUpdatedListener(annotations -> { + var a = assessment.getAssessment(); + updateRatingGroupTitles(a); + updateButtonIcons(a); + }); + + this.updateUI(); + } + + private void updateRatingGroupTitles(Assessment assessment) { + for (var entry : this.ratingGroupBorders.entrySet()) { + var ratingGroup = entry.getKey(); + var border = entry.getValue(); + border.setTitle(getRatingGroupTitle(assessment, ratingGroup)); + } + } + + private void updateButtonIcons(Assessment assessment) { + for (AssessmentButton assessmentButton : this.assessmentButtons) { + var settings = ArtemisSettingsState.getInstance(); + var mistakeType = assessmentButton.mistakeType(); + + String iconText; + Color color; + Font font = JBFont.regular(); + + if (mistakeType.getReporting().shouldScore()) { + int count = assessment.getAnnotations(mistakeType).size(); + var rule = mistakeType.getRule(); + + switch (rule) { + case ThresholdPenaltyRule thresholdRule -> { + iconText = count + "/" + thresholdRule.getThreshold(); + if (count >= thresholdRule.getThreshold()) { + color = settings.getFinishedAssessmentButtonColor(); + font = font.deriveFont(Map.of(TextAttribute.STRIKETHROUGH, TextAttribute.STRIKETHROUGH_ON)); + } else { + color = settings.getActiveAssessmentButtonColor(); + } + } + case CustomPenaltyRule customPenaltyRule -> { + iconText = "C"; + color = settings.getActiveAssessmentButtonColor(); + } + case StackingPenaltyRule stackingPenaltyRule -> { + iconText = String.valueOf(count); + color = settings.getActiveAssessmentButtonColor(); + } + } + } else { + iconText = "R"; + color = settings.getReportingAssessmentButtonColor(); + } + + assessmentButton.iconRenderer().update(iconText, color); + assessmentButton.button().setForeground(color); + assessmentButton.button().setFont(font); + } + } + + private void showNoActiveAssessment() { + this.ratingGroupBorders.clear(); + this.assessmentButtons.clear(); + + content.removeAll(); + content.add(new JBLabel("No active assessment"), "growx"); + this.updateUI(); + } + + private String getRatingGroupTitle(Assessment assessment, RatingGroup ratingGroup) { + var points = assessment.calculatePointsForRatingGroup(ratingGroup); + return "%s (%.1f of [%.1f, %.1f])" + .formatted( + ratingGroup.getDisplayName().translateTo(LOCALE), + points.score(), + ratingGroup.getMinPenalty(), + ratingGroup.getMaxPenalty()); + } + + private static String getAssessmentPointsTitle( + double testPoints, double annotationPoints, double totalPoints, double maxPoints) { + if (annotationPoints > 0.0) { + return """ +

%.1f %.1f = %.1f of %.1f

""" + .formatted( + colorToCSS(JBColor.GREEN), + testPoints, + colorToCSS(JBColor.GREEN), + Math.abs(annotationPoints), + totalPoints, + maxPoints); + } else { + return """ +

%.1f - %.1f = %.1f of %.1f

""" + .formatted( + colorToCSS(JBColor.GREEN), + testPoints, + colorToCSS(JBColor.RED), + Math.abs(annotationPoints), + totalPoints, + maxPoints); + } + } + + private static String colorToCSS(JBColor color) { + return "rgb(%d, %d, %d)".formatted(color.getRed(), color.getGreen(), color.getBlue()); + } + + private static class MistakeTypeIconRenderer { + private final JBFont font; + + private String text; + private Color bgColor; + + private int textWidth; + private int baselineHeight; + private int textHeight; + + public MistakeTypeIconRenderer() { + this.font = JBFont.regular(); + this.update("", JBColor.foreground()); + } + + public void paint(Graphics2D g, JComponent component) { + g.setFont(this.font); + + if (textWidth < 0) { + textWidth = g.getFontMetrics().stringWidth(text); + baselineHeight = g.getFontMetrics().getMaxAscent(); + textHeight = + g.getFontMetrics().getMaxAscent() + g.getFontMetrics().getMaxDescent(); + } + + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + g.setColor(bgColor); + g.fillRoundRect(component.getWidth() - textWidth - 5, 0, textWidth + 2, textHeight, 2, 2); + + g.setColor(JBColor.background()); + g.drawString(text, component.getWidth() - textWidth - 4, baselineHeight); + } + + private void update(String text, Color bgColor) { + this.text = text; + this.bgColor = bgColor; + this.textWidth = -1; + } + } + + private record AssessmentButton(MistakeType mistakeType, JButton button, MistakeTypeIconRenderer iconRenderer) {} +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/ExercisePanel.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/ExercisePanel.java new file mode 100644 index 0000000..1f2a5c6 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/ExercisePanel.java @@ -0,0 +1,471 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.guis; + +import java.awt.event.ItemEvent; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; + +import javax.swing.JButton; +import javax.swing.JPanel; +import javax.swing.event.DocumentEvent; + +import com.intellij.icons.AllIcons; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory; +import com.intellij.openapi.ui.ComboBox; +import com.intellij.openapi.ui.MessageDialogBuilder; +import com.intellij.openapi.ui.MessageType; +import com.intellij.openapi.ui.SimpleToolWindowPanel; +import com.intellij.openapi.ui.TextBrowseFolderListener; +import com.intellij.openapi.ui.TextFieldWithBrowseButton; +import com.intellij.openapi.wm.ToolWindowManager; +import com.intellij.ui.DocumentAdapter; +import com.intellij.ui.JBColor; +import com.intellij.ui.ScrollPaneFactory; +import com.intellij.ui.TitledSeparator; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBPanel; +import com.intellij.ui.components.JBTextField; +import com.intellij.ui.components.TextComponentEmptyText; +import edu.kit.kastel.sdq.artemis4j.ArtemisNetworkException; +import edu.kit.kastel.sdq.artemis4j.client.AssessmentStatsDTO; +import edu.kit.kastel.sdq.artemis4j.client.AssessmentType; +import edu.kit.kastel.sdq.artemis4j.grading.ArtemisConnection; +import edu.kit.kastel.sdq.artemis4j.grading.Course; +import edu.kit.kastel.sdq.artemis4j.grading.Exam; +import edu.kit.kastel.sdq.artemis4j.grading.ProgrammingExercise; +import edu.kit.kastel.sdq.artemis4j.grading.ProgrammingSubmission; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettingsState; +import edu.kit.kastel.sdq.intelligrade.state.ActiveAssessment; +import edu.kit.kastel.sdq.intelligrade.state.PluginState; +import edu.kit.kastel.sdq.intelligrade.utils.ArtemisUtils; +import edu.kit.kastel.sdq.intelligrade.utils.IntellijUtil; +import net.miginfocom.swing.MigLayout; +import org.jetbrains.annotations.NotNull; + +public class ExercisePanel extends SimpleToolWindowPanel { + private static final Logger LOG = Logger.getInstance(ExercisePanel.class); + + private final JBLabel connectedLabel; + + private final ComboBox courseSelector; + private final ComboBox examSelector; + private final ComboBox exerciseSelector; + + private JPanel generalPanel; + private JButton startGradingRound1Button; + private JButton startGradingRound2Button; + private TextFieldWithBrowseButton gradingConfigPathInput; + + private JPanel statisticsPanel; + private JBLabel totalStatisticsLabel; + private JBLabel userStatisticsLabel; + + private JPanel assessmentPanel; + private JButton submitAssessmentButton; + private JButton cancelAssessmentButton; + private JButton saveAssessmentButton; + private JButton closeAssessmentButton; + private JButton reRunAutograder; + + private JPanel backlogPanel; + private JPanel backlogList; + + public ExercisePanel() { + super(true, true); + + connectedLabel = new JBLabel(); + JPanel content = new JBPanel<>(new MigLayout("wrap 2", "[][grow]")); + content.add(connectedLabel, "span 2, alignx center"); + + content.add(new JBLabel("Course:")); + courseSelector = new ComboBox<>(); + content.add(courseSelector, "growx"); + + content.add(new JBLabel("Exam:")); + examSelector = new ComboBox<>(); + content.add(examSelector, "growx"); + + content.add(new JBLabel("Exercise:")); + exerciseSelector = new ComboBox<>(); + content.add(exerciseSelector, "growx"); + + createStatisticsPanel(); + content.add(statisticsPanel, "span 2, growx"); + + createGeneralPanel(); + content.add(new TitledSeparator("General"), "spanx 2, growx"); + content.add(generalPanel, "span 2, growx"); + + content.add(new TitledSeparator("Assessment"), "spanx 2, growx"); + createAssessmentPanel(); + content.add(assessmentPanel, "span 2, growx"); + + content.add(new TitledSeparator("Backlog"), "spanx 2, growx"); + createBacklogPanel(); + content.add(backlogPanel, "span 2, growx"); + + setContent(ScrollPaneFactory.createScrollPane(content)); + + exerciseSelector.addItemListener(this::handleExerciseSelected); + + examSelector.addItemListener(this::handleExamSelected); + + courseSelector.addItemListener(this::handleCourseSelected); + + PluginState.getInstance().registerConnectedListener(this::handleConnectionChange); + + PluginState.getInstance().registerAssessmentStartedListener(this::handleAssessmentStarted); + + PluginState.getInstance().registerAssessmentClosedListener(this::handleAssessmentClosed); + } + + private void createGeneralPanel() { + generalPanel = new JBPanel<>(new MigLayout("wrap 1", "[grow]")); + + startGradingRound1Button = new JButton("Start Grading Round 1"); + startGradingRound1Button.setForeground(JBColor.GREEN); + startGradingRound1Button.addActionListener( + a -> PluginState.getInstance().startNextAssessment(0)); + generalPanel.add(startGradingRound1Button, "growx"); + + startGradingRound2Button = new JButton("Start Grading Round 2"); + startGradingRound2Button.setForeground(JBColor.GREEN); + startGradingRound2Button.addActionListener( + a -> PluginState.getInstance().startNextAssessment(1)); + generalPanel.add(startGradingRound2Button, "growx"); + + gradingConfigPathInput = new TextFieldWithBrowseButton(); + gradingConfigPathInput.addBrowseFolderListener( + new TextBrowseFolderListener(FileChooserDescriptorFactory.createSingleFileDescriptor("json"))); + gradingConfigPathInput.setText(ArtemisSettingsState.getInstance().getSelectedGradingConfigPath()); + gradingConfigPathInput.getTextField().getDocument().addDocumentListener(new DocumentAdapter() { + @Override + protected void textChanged(@NotNull DocumentEvent documentEvent) { + ArtemisSettingsState.getInstance().setSelectedGradingConfigPath(gradingConfigPathInput.getText()); + } + }); + generalPanel.add(gradingConfigPathInput, "growx"); + + var innerTextField = (JBTextField) gradingConfigPathInput.getTextField(); + innerTextField.getEmptyText().setText("Path to grading config"); + innerTextField.putClientProperty(TextComponentEmptyText.STATUS_VISIBLE_FUNCTION, (Predicate) + f -> f.getText().isEmpty()); + } + + private void createStatisticsPanel() { + statisticsPanel = new JBPanel<>(new MigLayout("wrap 2", "[][grow]")); + + statisticsPanel.add(new JBLabel("Submissions:")); + totalStatisticsLabel = new JBLabel(); + statisticsPanel.add(totalStatisticsLabel); + + statisticsPanel.add(new JBLabel("Your Assessments:")); + userStatisticsLabel = new JBLabel(); + statisticsPanel.add(userStatisticsLabel); + } + + private void createAssessmentPanel() { + assessmentPanel = new JBPanel<>(new MigLayout("wrap 2", "[grow][grow]")); + assessmentPanel.setEnabled(false); + + submitAssessmentButton = new JButton("Submit Assessment"); + submitAssessmentButton.setForeground(JBColor.GREEN); + submitAssessmentButton.setEnabled(false); + submitAssessmentButton.addActionListener(a -> PluginState.getInstance().submitAssessment()); + assessmentPanel.add(submitAssessmentButton, "growx"); + + cancelAssessmentButton = new JButton("Cancel Assessment"); + cancelAssessmentButton.setEnabled(false); + cancelAssessmentButton.addActionListener(a -> { + var confirmed = MessageDialogBuilder.okCancel( + "Cancel Assessment?", "Your assessment will be discarded, and the lock will be freed.") + .guessWindowAndAsk(); + + if (confirmed) { + PluginState.getInstance().cancelAssessment(); + } + }); + assessmentPanel.add(cancelAssessmentButton, "growx"); + + saveAssessmentButton = new JButton("Save Assessment"); + + saveAssessmentButton.setEnabled(false); + saveAssessmentButton.addActionListener(a -> PluginState.getInstance().saveAssessment()); + assessmentPanel.add(saveAssessmentButton, "growx"); + + closeAssessmentButton = new JButton("Close Assessment"); + closeAssessmentButton.setEnabled(false); + closeAssessmentButton.addActionListener(a -> { + var confirmed = MessageDialogBuilder.okCancel( + "Close Assessment?", "Your will loose any unsaved progress, but you will keep the lock.") + .guessWindowAndAsk(); + + if (confirmed) { + PluginState.getInstance().closeAssessment(); + } + }); + assessmentPanel.add(closeAssessmentButton, "growx"); + + reRunAutograder = new JButton("Re-run Autograder"); + reRunAutograder.setEnabled(false); + reRunAutograder.addActionListener(a -> { + var confirmed = MessageDialogBuilder.okCancel( + "Re-Run Autograder?", "This may create duplicate annotations!") + .guessWindowAndAsk(); + + if (confirmed) { + PluginState.getInstance().getActiveAssessment().orElseThrow().runAutograder(); + } + }); + assessmentPanel.add(reRunAutograder, "spanx 2, growx"); + } + + private void createBacklogPanel() { + backlogPanel = new JBPanel<>(new MigLayout("wrap 2", "[grow] []")); + + backlogList = new JBPanel<>(new MigLayout("wrap 4, gapx 30", "[][][][grow]")); + backlogPanel.add(ScrollPaneFactory.createScrollPane(backlogList, true), "spanx 2, growx"); + + var refreshButton = new JButton(AllIcons.Actions.Refresh); + refreshButton.addActionListener(a -> updateBacklogAndStats()); + backlogPanel.add(refreshButton, "skip 1, alignx right"); + } + + private void handleExerciseSelected(ItemEvent e) { + // Exercise selected: Update plugin state, enable/disable grading buttons, update backlog + if (e.getStateChange() != ItemEvent.DESELECTED) { + var exercise = (ProgrammingExercise) e.getItem(); + startGradingRound2Button.setEnabled( + !PluginState.getInstance().isAssessing() && exercise.hasSecondCorrectionRound()); + + PluginState.getInstance().setActiveExercise(exercise); + + updateBacklogAndStats(); + } + } + + private void handleExamSelected(ItemEvent e) { + // If an exam was selected, update the exercise selector with the exercises of the exam + // If no exam was selected, update the exercise selector with the exercises of the course + if (e.getStateChange() == ItemEvent.DESELECTED) { + return; + } + + try { + exerciseSelector.removeAllItems(); + var item = (OptionalExam) e.getItem(); + if (item.exam() != null) { + for (var group : item.exam().getExerciseGroups()) { + for (var exercise : group.getProgrammingExercises()) { + exerciseSelector.addItem(exercise); + } + } + } else { + for (ProgrammingExercise programmingExercise : + courseSelector.getItem().getProgrammingExercises()) { + exerciseSelector.addItem(programmingExercise); + } + } + } catch (ArtemisNetworkException ex) { + LOG.warn(ex); + ArtemisUtils.displayNetworkErrorBalloon("Failed to fetch exercise info", ex); + } + + updateUI(); + } + + private void handleCourseSelected(ItemEvent e) { + // Course was selected: Update the exam selector with the exams of the course + // This triggers an item event in the exam selector, which updates the exercise selector + if (e.getStateChange() != ItemEvent.DESELECTED) { + try { + var course = (Course) e.getItem(); + examSelector.removeAllItems(); + examSelector.addItem(new OptionalExam(null)); + for (Exam exam : course.getExams()) { + examSelector.addItem(new OptionalExam(exam)); + } + updateUI(); + } catch (ArtemisNetworkException ex) { + LOG.warn(ex); + ArtemisUtils.displayNetworkErrorBalloon("Failed to fetch exam info", ex); + } + } + } + + private void handleConnectionChange(Optional connection) { + courseSelector.removeAllItems(); + + if (connection.isPresent()) { + // When a connection is established, update the course selector with the courses of the connection + try { + connectedLabel.setText("✔ Connected to " + + connection.get().getClient().getInstance().getDomain() + " as " + + connection.get().getAssessor().getLogin()); + connectedLabel.setForeground(JBColor.GREEN); + for (Course course : connection.get().getCourses()) { + courseSelector.addItem(course); + } + } catch (ArtemisNetworkException ex) { + LOG.warn(ex); + ArtemisUtils.displayNetworkErrorBalloon("Failed to fetch course info", ex); + } + } else { + connectedLabel.setText("❌ Not connected"); + connectedLabel.setForeground(JBColor.RED); + } + + updateUI(); + } + + private void handleAssessmentStarted(ActiveAssessment assessment) { + startGradingRound1Button.setEnabled(false); + startGradingRound2Button.setEnabled(false); + + assessmentPanel.setEnabled(true); + submitAssessmentButton.setEnabled(true); + cancelAssessmentButton.setEnabled( + !assessment.getAssessment().getSubmission().isSubmitted()); + saveAssessmentButton.setEnabled(true); + closeAssessmentButton.setEnabled(true); + reRunAutograder.setEnabled(true); + + updateBacklogAndStats(); + } + + private void handleAssessmentClosed() { + startGradingRound1Button.setEnabled(true); + startGradingRound2Button.setEnabled(exerciseSelector.getItem().hasSecondCorrectionRound()); + + assessmentPanel.setEnabled(false); + submitAssessmentButton.setEnabled(false); + cancelAssessmentButton.setEnabled(false); + saveAssessmentButton.setEnabled(false); + closeAssessmentButton.setEnabled(false); + reRunAutograder.setEnabled(false); + + updateBacklogAndStats(); + } + + private void updateBacklogAndStats() { + // Fetch data in the background, but do all UI updates on the EDT! + ApplicationManager.getApplication().executeOnPooledThread(() -> { + var exercise = PluginState.getInstance().getActiveExercise().orElseThrow(); + + List submissions; + AssessmentStatsDTO stats; + try { + submissions = exercise.fetchSubmissions(); + stats = exercise.fetchAssessmentStats(); + } catch (ArtemisNetworkException ex) { + LOG.warn(ex); + ArtemisUtils.displayNetworkErrorBalloon("Failed to fetch backlog or statistics", ex); + ApplicationManager.getApplication().invokeLater(() -> { + backlogList.removeAll(); + this.updateUI(); + }); + return; + } + + ApplicationManager.getApplication().invokeLater(() -> { + updateStatistics(exercise, stats, submissions); + updateBacklog(submissions); + updateUI(); + + // Tell the user that we've done something + ToolWindowManager.getInstance(IntellijUtil.getActiveProject()) + .notifyByBalloon("Artemis", MessageType.INFO, "Backlog updated"); + }); + }); + } + + private void updateStatistics( + ProgrammingExercise exercise, AssessmentStatsDTO stats, List submissions) { + String totalText; + if (exercise.hasSecondCorrectionRound()) { + totalText = "%d / %d / %d (%d locked)" + .formatted( + stats.numberOfAssessmentsOfCorrectionRounds() + .getFirst() + .inTime(), + stats.numberOfAssessmentsOfCorrectionRounds().get(1).inTime(), + stats.numberOfSubmissions().inTime(), + stats.totalNumberOfAssessmentLocks()); + } else { + totalText = "%d / %d (%d locked)" + .formatted( + stats.numberOfAssessmentsOfCorrectionRounds() + .getFirst() + .inTime(), + stats.numberOfSubmissions().inTime(), + stats.totalNumberOfAssessmentLocks()); + } + totalStatisticsLabel.setText(totalText); + + int submittedSubmissions = (int) + submissions.stream().filter(ProgrammingSubmission::isSubmitted).count(); + int lockedSubmissions = (int) + submissions.stream().filter(ExercisePanel::isSubmissionStarted).count(); + String userText = "%d (%d locked)".formatted(submittedSubmissions, lockedSubmissions); + userStatisticsLabel.setText(userText); + } + + private void updateBacklog(List submissions) { + backlogList.removeAll(); + + List sortedSubmissions = new ArrayList<>(submissions); + sortedSubmissions.sort(Comparator.comparing(ProgrammingSubmission::getSubmissionDate)); + for (ProgrammingSubmission submission : sortedSubmissions) { + String dateText = submission + .getSubmissionDate() + .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT, FormatStyle.SHORT)); + backlogList.add(new JBLabel(dateText), "alignx right"); + + // Correction Round + backlogList.add(new JBLabel("Round " + (submission.getCorrectionRound() + 1))); + + // Score in percent + var latestResult = submission.getLatestResult(); + String resultText = ""; + if (submission.isSubmitted()) { + resultText = latestResult + .map(resultDTO -> "%.0f%%".formatted(resultDTO.score())) + .orElse("???"); + } + backlogList.add(new JBLabel(resultText), "alignx right"); + + // Action Button + JButton reopenButton; + if (submission.isSubmitted()) { + reopenButton = new JButton("Reopen Assessment"); + } else if (isSubmissionStarted(submission)) { + reopenButton = new JButton("Continue Assessment"); + reopenButton.setForeground(JBColor.ORANGE); + } else { + reopenButton = new JButton("Start Assessment"); + } + reopenButton.addActionListener(a -> PluginState.getInstance().reopenAssessment(submission)); + backlogList.add(reopenButton, "growx"); + } + } + + private static boolean isSubmissionStarted(ProgrammingSubmission submission) { + return !submission.isSubmitted() + && submission.getLatestResult().isPresent() + && submission.getLatestResult().get().assessmentType() != AssessmentType.AUTOMATIC; + } + + private record OptionalExam(Exam exam) { + @Override + public String toString() { + return exam == null ? "" : exam.toString(); + } + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/TestCasePanel.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/TestCasePanel.java new file mode 100644 index 0000000..58d90b1 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/guis/TestCasePanel.java @@ -0,0 +1,55 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.guis; + +import javax.swing.JPanel; + +import com.intellij.icons.AllIcons; +import com.intellij.openapi.ui.SimpleToolWindowPanel; +import com.intellij.ui.ScrollPaneFactory; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBPanel; +import edu.kit.kastel.sdq.intelligrade.state.PluginState; +import net.miginfocom.swing.MigLayout; + +public class TestCasePanel extends SimpleToolWindowPanel { + private final JPanel content; + + public TestCasePanel() { + super(true, true); + + this.content = new JBPanel<>(new MigLayout("wrap 3, gapx 10px, gapy 5px", "[][][]")); + + setContent(ScrollPaneFactory.createScrollPane(content)); + + PluginState.getInstance().registerAssessmentStartedListener(assessment -> { + this.content.removeAll(); + + var testResults = assessment.getAssessment().getTestResults(); + for (var result : testResults) { + String tooltip = result.getDetailText().orElse("No details available"); + + var icon = result.getPoints() != 0.0 + ? AllIcons.RunConfigurations.TestPassed + : AllIcons.RunConfigurations.TestFailed; + var iconLabel = new JBLabel(icon); + iconLabel.setToolTipText(tooltip); + this.content.add(iconLabel); + + var testName = new JBLabel(result.getTestName()); + testName.setToolTipText(tooltip); + this.content.add(testName); + + String points = result.getPoints() != 0.0 ? String.format("%.3fP", result.getPoints()) : ""; + this.content.add(new JBLabel(points)); + } + + updateUI(); + }); + + PluginState.getInstance().registerAssessmentClosedListener(() -> { + this.content.removeAll(); + this.content.add(new JBLabel("No active assessment"), "spanx 3, alignx center"); + updateUI(); + }); + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/ArtemisCredentialsProvider.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/ArtemisCredentialsProvider.java new file mode 100644 index 0000000..a464f0b --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/ArtemisCredentialsProvider.java @@ -0,0 +1,75 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.settings; + +import com.intellij.credentialStore.CredentialAttributes; +import com.intellij.credentialStore.CredentialAttributesKt; +import com.intellij.ide.passwordSafe.PasswordSafe; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.Service; + +@Service +public final class ArtemisCredentialsProvider { + private static final String CREDENTIALS_PATH = "edu.kit.kastel.intelligrade.artemisCredentials"; + private static final String PASSWORD_STORE_KEY = "artemisPassword"; + private static final String JWT_STORE_KEY = "artemisAuthJWT"; + + private boolean initialized = false; + private String artemisPassword; + private String jwt; + + public static ArtemisCredentialsProvider getInstance() { + return ApplicationManager.getApplication().getService(ArtemisCredentialsProvider.class); + } + + public void initialize() { + // Prefetches the credentials, since the password store is very slow + ApplicationManager.getApplication().executeOnPooledThread(() -> { + var safe = PasswordSafe.getInstance(); + artemisPassword = safe.getPassword(createCredentialAttributes(PASSWORD_STORE_KEY)); + jwt = safe.getPassword(createCredentialAttributes(JWT_STORE_KEY)); + synchronized (this) { + initialized = true; + this.notifyAll(); + } + }); + } + + public String getArtemisPassword() { + waitForInitialization(); + return artemisPassword; + } + + public void setArtemisPassword(String artemisPassword) { + this.artemisPassword = artemisPassword; + ApplicationManager.getApplication().executeOnPooledThread(() -> PasswordSafe.getInstance() + .setPassword(createCredentialAttributes(PASSWORD_STORE_KEY), artemisPassword)); + } + + public String getJwt() { + waitForInitialization(); + return jwt; + } + + public void setJwt(String jwt) { + this.jwt = jwt; + ApplicationManager.getApplication().executeOnPooledThread(() -> PasswordSafe.getInstance() + .setPassword(createCredentialAttributes(JWT_STORE_KEY), jwt)); + } + + private CredentialAttributes createCredentialAttributes(String key) { + return new CredentialAttributes(CredentialAttributesKt.generateServiceName(CREDENTIALS_PATH, key)); + } + + private void waitForInitialization() { + synchronized (this) { + while (!initialized) { + try { + this.wait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + } + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/ArtemisSettings.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/ArtemisSettings.java new file mode 100644 index 0000000..dea187e --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/ArtemisSettings.java @@ -0,0 +1,307 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.settings; + +import java.util.Objects; + +import javax.swing.*; + +import com.intellij.openapi.fileChooser.FileChooserDescriptor; +import com.intellij.openapi.options.Configurable; +import com.intellij.openapi.ui.TextBrowseFolderListener; +import com.intellij.openapi.ui.TextFieldWithBrowseButton; +import com.intellij.openapi.util.NlsContexts; +import com.intellij.ui.ColorPanel; +import com.intellij.ui.JBIntSpinner; +import com.intellij.ui.TitledSeparator; +import com.intellij.ui.components.JBCheckBox; +import com.intellij.ui.components.JBLabel; +import com.intellij.ui.components.JBPanel; +import com.intellij.ui.components.JBPasswordField; +import com.intellij.ui.components.JBRadioButton; +import com.intellij.ui.components.JBTextField; +import edu.kit.kastel.sdq.intelligrade.state.PluginState; +import net.miginfocom.swing.MigLayout; +import org.jetbrains.annotations.Nullable; + +/** + * This class implements the settings Dialog for this PlugIn. + * Everything directly related to the Setting UI should be in here. + */ +public class ArtemisSettings implements Configurable { + private JBTextField artemisURLField; + + private JBLabel usernameLabel; + private JBRadioButton useTokenLoginButton; + private JBLabel passwordLabel; + private JBRadioButton usePasswordLoginButton; + private JBTextField usernameField; + private JBPasswordField passwordField; + private JBRadioButton useVcsSSH; + private JBRadioButton useVcsToken; + + private JBRadioButton autograderDownloadButton; + private JBRadioButton autograderPathButton; + private TextFieldWithBrowseButton autograderPathField; + private JBRadioButton autograderSkipButton; + + private JBCheckBox autoOpenMainClassCheckBox; + private JBIntSpinner columnsPerRatingGroupSpinner; + private ColorPanel highlighterColorChooser; + private ColorPanel activeAssessmentButtonColorChooser; + private ColorPanel finishedAssessmentButtonColorChooser; + private ColorPanel reportingAssessmentButtonColorChooser; + + /** + * Returns the visible name of the configurable component. + * Note, that this method must return the display name + * that is equal to the display name declared in XML + * to avoid unexpected errors. + * + * @return the visible name of the configurable component + */ + @Override + public @NlsContexts.ConfigurableName String getDisplayName() { + return "Artemis (IntelliGrade)"; + } + + /** + * Creates a new Swing form that enables the user to configure the settings. + * Usually this method is called on the EDT, so it should not take a long time. + *

Also, this place is designed to allocate resources (subscriptions/listeners etc.)

+ * + * @return new Swing form to show, or {@code null} if it cannot be created + * @see #disposeUIResources + */ + @Override + public @Nullable JComponent createComponent() { + var contentPanel = new JBPanel<>(new MigLayout("wrap 2", "[] [grow]")); + + contentPanel.add(new JBLabel("Artemis URL:")); + artemisURLField = new JBTextField(); + contentPanel.add(artemisURLField, "growx"); + + var loginButton = new JButton("(Re-)Connect"); + loginButton.addActionListener(a -> { + ArtemisCredentialsProvider.getInstance().setJwt(null); + ArtemisSettingsState.getInstance().setJwtExpiry(null); + this.apply(); + PluginState.getInstance().connect(); + }); + contentPanel.add(loginButton, "span 2, growx"); + + // Login options + contentPanel.add(new TitledSeparator("Login Options"), "span 2, growx"); + var loginButtonGroup = new ButtonGroup(); + + useTokenLoginButton = new JBRadioButton("Token Login (Preferred)"); + useTokenLoginButton.addActionListener(a -> updateLoginType()); + loginButtonGroup.add(useTokenLoginButton); + contentPanel.add(useTokenLoginButton, "span 2, growx"); + + usePasswordLoginButton = new JBRadioButton("Password Login"); + usePasswordLoginButton.addActionListener(a -> updateLoginType()); + loginButtonGroup.add(usePasswordLoginButton); + contentPanel.add(usePasswordLoginButton, "span 2, growx"); + + usernameLabel = new JBLabel("Username:"); + contentPanel.add(usernameLabel, "pad 0 40 0 0, growx"); + usernameField = new JBTextField(); + contentPanel.add(usernameField, "growx"); + + passwordLabel = new JBLabel("Password:"); + contentPanel.add(passwordLabel, "pad 0 40 0 0, growx"); + passwordField = new JBPasswordField(); + contentPanel.add(passwordField, "growx"); + + // VCS Access + contentPanel.add(new TitledSeparator("VCS Access"), "span 2, grow x"); + ButtonGroup vcsAccessButtonGroup = new ButtonGroup(); + + useVcsToken = new JBRadioButton("VCS Token"); + contentPanel.add(useVcsToken); + vcsAccessButtonGroup.add(useVcsToken); + + useVcsSSH = new JBRadioButton("SSH"); + contentPanel.add(useVcsSSH); + vcsAccessButtonGroup.add(useVcsSSH); + + // Autograder options + contentPanel.add(new TitledSeparator("Autograder"), "span 2, growx"); + ButtonGroup autograderButtonGroup = new ButtonGroup(); + + autograderDownloadButton = new JBRadioButton("Download latest Autograder release from GitHub"); + autograderDownloadButton.addActionListener(a -> updateAutograderOptions()); + autograderButtonGroup.add(autograderDownloadButton); + contentPanel.add(autograderDownloadButton, "span 2, growx"); + + autograderPathButton = new JBRadioButton("Use local Autograder JAR"); + autograderPathButton.addActionListener(a -> updateAutograderOptions()); + autograderButtonGroup.add(autograderPathButton); + contentPanel.add(autograderPathButton, "span 2, growx"); + autograderPathField = new TextFieldWithBrowseButton(); + var fileDescriptor = + new TextBrowseFolderListener(new FileChooserDescriptor(true, false, true, true, false, false) + .withFileFilter(file -> "jar".equalsIgnoreCase(file.getExtension()))); + autograderPathField.addBrowseFolderListener(fileDescriptor); + contentPanel.add(autograderPathField, "pad 0 40 0 0, span 2, growx"); + + autograderSkipButton = new JBRadioButton("Skip Autograder"); + autograderSkipButton.addActionListener(a -> updateAutograderOptions()); + autograderButtonGroup.add(autograderSkipButton); + contentPanel.add(autograderSkipButton, "span 2, growx"); + + // UI / General options + contentPanel.add(new TitledSeparator("General"), "span 2, growx"); + autoOpenMainClassCheckBox = new JBCheckBox("Auto-open main class"); + contentPanel.add(autoOpenMainClassCheckBox, "span 2, growx"); + + contentPanel.add(new JBLabel("Columns per rating group:")); + columnsPerRatingGroupSpinner = new JBIntSpinner(3, 1, 50); + contentPanel.add(columnsPerRatingGroupSpinner, "growx"); + + contentPanel.add(new JBLabel("Highlighter color:")); + highlighterColorChooser = new ColorPanel(); + contentPanel.add(highlighterColorChooser, "growx"); + + contentPanel.add(new JBLabel("Scoring grading button:")); + activeAssessmentButtonColorChooser = new ColorPanel(); + contentPanel.add(activeAssessmentButtonColorChooser, "growx"); + + contentPanel.add(new JBLabel("Scoring grading button (limit reached):")); + finishedAssessmentButtonColorChooser = new ColorPanel(); + contentPanel.add(finishedAssessmentButtonColorChooser, "growx"); + + contentPanel.add(new JBLabel("Reporting grading button:")); + reportingAssessmentButtonColorChooser = new ColorPanel(); + contentPanel.add(reportingAssessmentButtonColorChooser, "growx"); + + return contentPanel; + } + + /** + * Indicates whether the Swing form was modified or not. + * This method is called very often, so it should not take a long time. + * + * @return {@code true} if the settings were modified, {@code false} otherwise + */ + @Override + public boolean isModified() { + var settings = ArtemisSettingsState.getInstance(); + var credentials = ArtemisCredentialsProvider.getInstance(); + + boolean modified = !new String(passwordField.getPassword()).equals(credentials.getArtemisPassword()); + modified |= !usernameField.getText().equals(settings.getUsername()); + modified |= !artemisURLField.getText().equals(settings.getArtemisInstanceUrl()); + modified |= !columnsPerRatingGroupSpinner.getValue().equals(settings.getColumnsPerRatingGroup()); + modified |= !Objects.equals(highlighterColorChooser.getSelectedColor(), settings.getAnnotationColor()); + modified |= useTokenLoginButton.isSelected() != settings.isUseTokenLogin(); + modified |= getSelectedAutograderOption() != settings.getAutograderOption(); + modified |= autoOpenMainClassCheckBox.isSelected() != settings.isAutoOpenMainClass(); + modified |= getSelectedVcsOption() != settings.getVcsAccessOption(); + modified |= !Objects.equals( + activeAssessmentButtonColorChooser.getSelectedColor(), settings.getActiveAssessmentButtonColor()); + modified |= !Objects.equals( + finishedAssessmentButtonColorChooser.getSelectedColor(), settings.getFinishedAssessmentButtonColor()); + modified |= !Objects.equals( + reportingAssessmentButtonColorChooser.getSelectedColor(), settings.getReportingAssessmentButtonColor()); + return modified; + } + + /** + * Stores the settings from the Swing form to the configurable component. + * This method is called on EDT upon user's request. + */ + @Override + public void apply() { + var settings = ArtemisSettingsState.getInstance(); + var credentials = ArtemisCredentialsProvider.getInstance(); + + settings.setArtemisInstanceUrl(artemisURLField.getText()); + + settings.setUseTokenLogin(useTokenLoginButton.isSelected()); + settings.setUsername(usernameField.getText()); + credentials.setArtemisPassword(new String(passwordField.getPassword())); + + settings.setVcsAccessOption(getSelectedVcsOption()); + + settings.setAutograderOption(getSelectedAutograderOption()); + settings.setAutograderPath(autograderPathField.getText()); + + settings.setAutoOpenMainClass(autoOpenMainClassCheckBox.isSelected()); + settings.setColumnsPerRatingGroup( + Integer.parseInt(columnsPerRatingGroupSpinner.getValue().toString())); + settings.setAnnotationColor(highlighterColorChooser.getSelectedColor()); + settings.setActiveAssessmentButtonColor(activeAssessmentButtonColorChooser.getSelectedColor()); + settings.setFinishedAssessmentButtonColor(finishedAssessmentButtonColorChooser.getSelectedColor()); + settings.setReportingAssessmentButtonColor(reportingAssessmentButtonColorChooser.getSelectedColor()); + } + + /** + * Loads the settings from the configurable component to the Swing form. + * This method is called on EDT immediately after the form creation or later upon user's request. + */ + @Override + public void reset() { + var settings = ArtemisSettingsState.getInstance(); + var credentials = ArtemisCredentialsProvider.getInstance(); + + artemisURLField.setText(settings.getArtemisInstanceUrl()); + + useTokenLoginButton.setSelected(settings.isUseTokenLogin()); + usePasswordLoginButton.setSelected(!settings.isUseTokenLogin()); + usernameField.setText(settings.getUsername()); + passwordField.setText(credentials.getArtemisPassword()); + + switch (settings.getVcsAccessOption()) { + case SSH -> useVcsSSH.setSelected(true); + case TOKEN -> useVcsToken.setSelected(true); + } + + switch (settings.getAutograderOption()) { + case FROM_GITHUB -> autograderDownloadButton.setSelected(true); + case FROM_FILE -> autograderPathButton.setSelected(true); + case SKIP -> autograderSkipButton.setSelected(true); + } + autograderPathField.setText(settings.getAutograderPath()); + + autoOpenMainClassCheckBox.setSelected(settings.isAutoOpenMainClass()); + columnsPerRatingGroupSpinner.setValue(settings.getColumnsPerRatingGroup()); + highlighterColorChooser.setSelectedColor(settings.getAnnotationColor()); + activeAssessmentButtonColorChooser.setSelectedColor(settings.getActiveAssessmentButtonColor()); + finishedAssessmentButtonColorChooser.setSelectedColor(settings.getFinishedAssessmentButtonColor()); + reportingAssessmentButtonColorChooser.setSelectedColor(settings.getReportingAssessmentButtonColor()); + + updateLoginType(); + updateAutograderOptions(); + } + + private void updateLoginType() { + var useToken = useTokenLoginButton.isSelected(); + usernameLabel.setEnabled(!useToken); + passwordLabel.setEnabled(!useToken); + usernameField.setEnabled(!useToken); + passwordField.setEnabled(!useToken); + } + + private void updateAutograderOptions() { + autograderPathField.setEnabled(autograderPathButton.isSelected()); + } + + private AutograderOption getSelectedAutograderOption() { + if (autograderDownloadButton.isSelected()) { + return AutograderOption.FROM_GITHUB; + } else if (autograderPathButton.isSelected()) { + return AutograderOption.FROM_FILE; + } else { + return AutograderOption.SKIP; + } + } + + private VCSAccessOption getSelectedVcsOption() { + if (useVcsSSH.isSelected()) { + return VCSAccessOption.SSH; + } else { + return VCSAccessOption.TOKEN; + } + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/ArtemisSettingsState.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/ArtemisSettingsState.java new file mode 100644 index 0000000..35526d3 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/ArtemisSettingsState.java @@ -0,0 +1,192 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.settings; + +import java.awt.*; +import java.util.Date; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.PersistentStateComponent; +import com.intellij.openapi.components.State; +import com.intellij.openapi.components.Storage; +import com.intellij.ui.JBColor; +import com.intellij.util.xmlb.XmlSerializer; +import com.intellij.util.xmlb.XmlSerializerUtil; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * This class persists all required data for the PlugIn. + * Secrets (such as the Artemis password) are handled by the IntelliJ secrets provider + */ +@State(name = "edu.kit.kastel.extensions.ArtemisSettingsState", storages = @Storage("IntelliGradeSettings.xml")) +public class ArtemisSettingsState implements PersistentStateComponent { + private final InternalState state = new InternalState(); + + // Settings need to be public for IntelliJ to serialize them + @SuppressWarnings("java:S1104") + public static class InternalState { + public boolean useTokenLogin = true; + public VCSAccessOption vcsAccessOption = VCSAccessOption.TOKEN; + public String username = ""; + public String artemisInstanceUrl = ""; + public AutograderOption autograderOption = AutograderOption.FROM_GITHUB; + public String autograderPath = null; + public boolean autoOpenMainClass = true; + public String selectedGradingConfigPath; + public int columnsPerRatingGroup = 3; + + public Date jwtExpiry = new Date(Long.MAX_VALUE); + + public int annotationColor = new JBColor(new Color(155, 54, 54), new Color(155, 54, 54)).getRGB(); + public int activeAssessmentButtonColor = JBColor.YELLOW.getRGB(); + public int finishedAssessmentButtonColor = JBColor.MAGENTA.getRGB(); + public int reportingAssessmentButtonColor = JBColor.GREEN.getRGB(); + } + + public static ArtemisSettingsState getInstance() { + return ApplicationManager.getApplication().getService(ArtemisSettingsState.class); + } + + /** + * Gets the Settings state. + * + * @return state.a component state. + * All properties, public and annotated fields are serialized. + * Only values which differ from the default (i.e. the value of newly instantiated class) + * are serialized. {@code null} value indicates that the returned state won't be stored, + * as a result previously stored state will be used. + * @see XmlSerializer + */ + @Override + public @Nullable InternalState getState() { + return state; + } + + /** + * This method is called when a new component state is loaded. + * The method can and will be called several times if config + * files are externally changed while the IDE is running. + *

State object should be used directly, defensive copying is not required.

+ * + * @param state loaded component state + * @see XmlSerializerUtil#copyBean(Object, Object) + */ + @Override + public void loadState(@NotNull InternalState state) { + XmlSerializerUtil.copyBean(state, this.state); + } + + public boolean isUseTokenLogin() { + return state.useTokenLogin; + } + + public void setUseTokenLogin(boolean useTokenLogin) { + state.useTokenLogin = useTokenLogin; + } + + public String getUsername() { + return state.username; + } + + public void setUsername(String username) { + state.username = username; + } + + public String getArtemisInstanceUrl() { + return state.artemisInstanceUrl; + } + + public void setArtemisInstanceUrl(String artemisInstanceUrl) { + // invalidate JWT if URL changed + ArtemisCredentialsProvider.getInstance().setJwt(""); + state.artemisInstanceUrl = artemisInstanceUrl; + } + + public @Nullable String getSelectedGradingConfigPath() { + return state.selectedGradingConfigPath; + } + + public void setSelectedGradingConfigPath(@Nullable String selectedGradingConfigPath) { + state.selectedGradingConfigPath = selectedGradingConfigPath; + } + + public int getColumnsPerRatingGroup() { + return state.columnsPerRatingGroup; + } + + public void setColumnsPerRatingGroup(int columnsPerRatingGroup) { + state.columnsPerRatingGroup = columnsPerRatingGroup; + } + + public Color getAnnotationColor() { + return new Color(state.annotationColor); + } + + public void setAnnotationColor(Color annotationColor) { + state.annotationColor = annotationColor.getRGB(); + } + + public Date getJwtExpiry() { + return state.jwtExpiry; + } + + public void setJwtExpiry(Date jwtExpiry) { + state.jwtExpiry = jwtExpiry; + } + + public AutograderOption getAutograderOption() { + return state.autograderOption; + } + + public void setAutograderOption(AutograderOption autograderOption) { + state.autograderOption = autograderOption; + } + + public String getAutograderPath() { + return state.autograderPath; + } + + public void setAutograderPath(String autograderPath) { + state.autograderPath = autograderPath; + } + + public boolean isAutoOpenMainClass() { + return state.autoOpenMainClass; + } + + public void setAutoOpenMainClass(boolean autoOpenMainClass) { + state.autoOpenMainClass = autoOpenMainClass; + } + + public VCSAccessOption getVcsAccessOption() { + return state.vcsAccessOption; + } + + public void setVcsAccessOption(VCSAccessOption vcsAccessOption) { + state.vcsAccessOption = vcsAccessOption; + } + + public Color getActiveAssessmentButtonColor() { + return new Color(state.activeAssessmentButtonColor); + } + + public void setActiveAssessmentButtonColor(Color activeAssessmentButtonColor) { + state.activeAssessmentButtonColor = activeAssessmentButtonColor.getRGB(); + } + + public Color getFinishedAssessmentButtonColor() { + return new Color(state.finishedAssessmentButtonColor); + } + + public void setFinishedAssessmentButtonColor(Color finishedAssessmentButtonColor) { + state.finishedAssessmentButtonColor = finishedAssessmentButtonColor.getRGB(); + } + + public Color getReportingAssessmentButtonColor() { + return new Color(state.reportingAssessmentButtonColor); + } + + public void setReportingAssessmentButtonColor(Color reportingAssessmentButtonColor) { + state.reportingAssessmentButtonColor = reportingAssessmentButtonColor.getRGB(); + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/AutograderOption.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/AutograderOption.java new file mode 100644 index 0000000..2b3c04a --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/AutograderOption.java @@ -0,0 +1,8 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.settings; + +public enum AutograderOption { + SKIP, + FROM_GITHUB, + FROM_FILE +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/VCSAccessOption.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/VCSAccessOption.java new file mode 100644 index 0000000..ad6f6f4 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/settings/VCSAccessOption.java @@ -0,0 +1,7 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.settings; + +public enum VCSAccessOption { + SSH, + TOKEN +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/tool_windows/AnnotationsToolWindowFactory.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/tool_windows/AnnotationsToolWindowFactory.java new file mode 100644 index 0000000..01a5a21 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/tool_windows/AnnotationsToolWindowFactory.java @@ -0,0 +1,22 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.tool_windows; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.ContentFactory; +import edu.kit.kastel.sdq.intelligrade.extensions.guis.AnnotationsListPanel; +import org.jetbrains.annotations.NotNull; + +/** + * This class generates the tool Window for annotations in the bottom. + */ +public class AnnotationsToolWindowFactory implements ToolWindowFactory { + + @Override + public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { + var content = ContentFactory.getInstance().createContent(new AnnotationsListPanel(), null, false); + toolWindow.show(); + toolWindow.getContentManager().addContent(content); + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/tool_windows/MainToolWindowFactory.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/tool_windows/MainToolWindowFactory.java new file mode 100644 index 0000000..cbaecb3 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/extensions/tool_windows/MainToolWindowFactory.java @@ -0,0 +1,31 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.extensions.tool_windows; + +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.wm.ToolWindow; +import com.intellij.openapi.wm.ToolWindowFactory; +import com.intellij.ui.content.ContentFactory; +import edu.kit.kastel.sdq.intelligrade.extensions.guis.AssessmentPanel; +import edu.kit.kastel.sdq.intelligrade.extensions.guis.ExercisePanel; +import edu.kit.kastel.sdq.intelligrade.extensions.guis.TestCasePanel; +import org.jetbrains.annotations.NotNull; + +/** + * This class handles all logic for the main grading UI. + * It does not handle any other logic, that should be factored out. + */ +public class MainToolWindowFactory implements ToolWindowFactory, DumbAware { + @Override + public void createToolWindowContent(@NotNull Project project, @NotNull ToolWindow toolWindow) { + toolWindow + .getContentManager() + .addContent(ContentFactory.getInstance().createContent(new ExercisePanel(), "Exercise", false)); + toolWindow + .getContentManager() + .addContent(ContentFactory.getInstance().createContent(new AssessmentPanel(), "Grading", false)); + toolWindow + .getContentManager() + .addContent(ContentFactory.getInstance().createContent(new TestCasePanel(), "Test Results", false)); + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/highlighter/HighlighterManager.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/highlighter/HighlighterManager.java new file mode 100644 index 0000000..ffe85f5 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/highlighter/HighlighterManager.java @@ -0,0 +1,291 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.highlighter; + +import java.awt.Font; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import javax.swing.Icon; + +import com.intellij.DynamicBundle; +import com.intellij.openapi.actionSystem.ActionGroup; +import com.intellij.openapi.actionSystem.ActionUpdateThread; +import com.intellij.openapi.actionSystem.AnActionEvent; +import com.intellij.openapi.actionSystem.DefaultActionGroup; +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.editor.event.EditorMouseEvent; +import com.intellij.openapi.editor.markup.EffectType; +import com.intellij.openapi.editor.markup.GutterIconRenderer; +import com.intellij.openapi.editor.markup.HighlighterLayer; +import com.intellij.openapi.editor.markup.HighlighterTargetArea; +import com.intellij.openapi.editor.markup.RangeHighlighter; +import com.intellij.openapi.editor.markup.TextAttributes; +import com.intellij.openapi.fileEditor.FileDocumentManager; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.FileEditorManagerListener; +import com.intellij.openapi.fileEditor.TextEditor; +import com.intellij.openapi.ui.popup.JBPopup; +import com.intellij.openapi.util.text.StringUtil; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.ui.AnActionButton; +import com.intellij.ui.JBColor; +import edu.kit.kastel.sdq.artemis4j.grading.Annotation; +import edu.kit.kastel.sdq.intelligrade.extensions.guis.AnnotationsListPanel; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettingsState; +import edu.kit.kastel.sdq.intelligrade.icons.ArtemisIcons; +import edu.kit.kastel.sdq.intelligrade.state.PluginState; +import edu.kit.kastel.sdq.intelligrade.utils.IntellijUtil; +import org.jetbrains.annotations.NotNull; + +public class HighlighterManager { + private static final Map> highlightersPerEditor = new IdentityHashMap<>(); + + // private static int lastPopupLine; + // private static Editor lastPopupEditor; + private static JBPopup lastPopup; + + public static void initialize() { + var messageBus = IntellijUtil.getActiveProject().getMessageBus(); + messageBus.connect().subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, new FileEditorManagerListener() { + @Override + public void fileOpened(@NotNull FileEditorManager source, @NotNull VirtualFile file) { + var editor = source.getSelectedTextEditor(); + + if (PluginState.getInstance().isAssessing()) { + editor.getDocument().setReadOnly(true); + updateHighlightersForEditor(editor); + } + } + + @Override + public void fileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile file) { + var editor = source.getSelectedTextEditor(); + if (editor == null) { + return; + } + + clearHighlightersForEditor(editor); + } + }); + + PluginState.getInstance() + .registerAssessmentStartedListener(assessment -> assessment.registerAnnotationsUpdatedListener( + annotations -> updateHighlightersForAllEditors())); + + // When an assessment is closed, clear everything + PluginState.getInstance().registerAssessmentClosedListener(() -> { + highlightersPerEditor.clear(); + cancelLastPopup(); + }); + } + + public static void onMouseMovedInEditor(EditorMouseEvent e) { + // TODO Later implement feature + // var highlighters = highlightersPerEditor.get(e.getEditor()); + // if (highlighters == null) { + // return; + // } + // + // int line = e.getLogicalPosition().getLine(); + // + // // If the cursor is still in the same line, nothing has to change + // if (line == lastPopupLine && e.getEditor() == lastPopupEditor) { + // return; + // } + // + // var annotations = highlighters.stream().filter(h -> h.annotation().getStartLine() <= line && + // h.annotation().getEndLine() >= line) + // .map(HighlighterWithAnnotation::annotation) + // .toList(); + // + // if (!annotations.isEmpty()) { + // lastPopupLine = line; + // lastPopupEditor = e.getEditor(); + // + // // First finish the current event, then show the popup + // // Otherwise, the event may be cancelled, and e.g. the caret not moved + // ApplicationManager.getApplication().invokeLater(() -> { + // lastPopup = JBPopupFactory.getInstance() + // .createPopupChooserBuilder(annotations) + // .setRenderer((list, annotation, index, isSelected, cellHasFocus) -> + // new + // JBLabel(annotation.getMistakeType().getButtonText().translateTo(DynamicBundle.getLocale()))) + // .setModalContext(false) + // .setResizable(true) + // .setRequestFocus(false) + // .setCancelOnClickOutside(false) + // .createPopup(); + // + // var point = e.getMouseEvent().getPoint(); + // // point.translate(30, 10); + // lastPopup.show(new RelativePoint(e.getMouseEvent().getComponent(), point)); + // }, x -> lastPopupLine != line || lastPopupEditor != e.getEditor() || lastPopup != null); + // } else { + // cancelLastPopup(); + // } + } + + private static void createHighlighter(Editor editor, int startLine, List annotations) { + var document = FileDocumentManager.getInstance().getDocument(editor.getVirtualFile()); + + int startOffset = document.getLineStartOffset(startLine); + int endOffset = document.getLineEndOffset( + annotations.stream().mapToInt(Annotation::getEndLine).max().orElse(startLine)); + + var annotationColor = ArtemisSettingsState.getInstance().getAnnotationColor(); + var attributes = new TextAttributes( + null, new JBColor(annotationColor, annotationColor), null, EffectType.BOLD_LINE_UNDERSCORE, Font.PLAIN); + + var highlighter = editor.getMarkupModel() + .addRangeHighlighter( + startOffset, + endOffset, + HighlighterLayer.SELECTION - 1, + attributes, + HighlighterTargetArea.LINES_IN_RANGE); + + String gutterTooltip = annotations.stream() + .map(a -> { + String text = "" + + a.getMistakeType().getButtonText().translateTo(DynamicBundle.getLocale()) + ""; + if (a.getCustomMessage().isPresent()) { + text += " " + a.getCustomMessage().get(); + } + + if (a.getCustomScore().isPresent()) { + text += " (" + a.getCustomScore().get() + ")"; + } + + return text; + }) + .collect(Collectors.joining("

")); + + var popupActions = getGutterPopupActions(annotations); + + highlighter.setGutterIconRenderer(new GutterIconRenderer() { + @Override + public boolean equals(Object o) { + // TODO implement some actually useful equals method + return false; + } + + @Override + public int hashCode() { + return 0; + } + + @Override + public @NotNull Icon getIcon() { + return switch (annotations.size()) { + case 1 -> ArtemisIcons.AnnotationsGutter1; + case 2 -> ArtemisIcons.AnnotationsGutter2; + case 3 -> ArtemisIcons.AnnotationsGutter3; + default -> ArtemisIcons.AnnotationsGutter4; + }; + } + + @Override + public String getTooltipText() { + return gutterTooltip; + } + + @Override + public ActionGroup getPopupMenuActions() { + return popupActions; + } + + @Override + public boolean isDumbAware() { + return true; + } + }); + + highlightersPerEditor.computeIfAbsent(editor, e -> new ArrayList<>()); + highlightersPerEditor.get(editor).add(new HighlighterWithAnnotations(highlighter, annotations)); + } + + private static void cancelLastPopup() { + // if (lastPopup != null) { + // if (!lastPopup.isDisposed()) { + // lastPopup.cancel(); + // } + // lastPopup = null; + // lastPopupLine = -1; + // lastPopupEditor = null; + // } + } + + private static void updateHighlightersForAllEditors() { + if (!PluginState.getInstance().isAssessing()) { + return; + } + + var editors = + FileEditorManager.getInstance(IntellijUtil.getActiveProject()).getAllEditors(); + for (var editor : editors) { + updateHighlightersForEditor(((TextEditor) editor).getEditor()); + } + + cancelLastPopup(); + } + + private static void updateHighlightersForEditor(Editor editor) { + // E.g. decompiled classes are not in the local file system + // Since they are never part of an assessment, ignore them + if (!editor.getVirtualFile().isInLocalFileSystem()) { + return; + } + + clearHighlightersForEditor(editor); + + var filePath = editor.getVirtualFile().toNioPath(); + var state = PluginState.getInstance(); + var assessment = state.getActiveAssessment().orElseThrow().getAssessment(); + var annotationsByLine = assessment.getAnnotations().stream() + .filter(a -> IntellijUtil.getAnnotationPath(a).equals(filePath)) + .collect(Collectors.groupingBy(Annotation::getStartLine)); + for (var entry : annotationsByLine.entrySet()) { + createHighlighter(editor, entry.getKey(), entry.getValue()); + } + } + + private static void clearHighlightersForEditor(Editor editor) { + highlightersPerEditor.remove(editor); + editor.getMarkupModel().removeAllHighlighters(); + } + + private static ActionGroup getGutterPopupActions(List annotations) { + var group = new DefaultActionGroup(); + for (Annotation annotation : annotations) { + String text = annotation.getMistakeType().getButtonText().translateTo(DynamicBundle.getLocale()); + if (annotation.getCustomMessage().isPresent()) { + String displayPath = StringUtil.shortenPathWithEllipsis( + annotation.getCustomMessage().get(), 80); + text += ": " + displayPath; + } + + group.addAction(new AnActionButton(text) { + @Override + public void actionPerformed(@NotNull AnActionEvent anActionEvent) { + AnnotationsListPanel.getPanel().selectAnnotation(annotation); + } + + @Override + public boolean isDumbAware() { + return true; + } + + @Override + public @NotNull ActionUpdateThread getActionUpdateThread() { + return ActionUpdateThread.EDT; + } + }); + } + return group; + } + + private record HighlighterWithAnnotations(RangeHighlighter highlighter, List annotation) {} +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/highlighter/WrapperPanel.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/highlighter/WrapperPanel.java new file mode 100644 index 0000000..1cff72b --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/highlighter/WrapperPanel.java @@ -0,0 +1,36 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.highlighter; + +import java.awt.*; + +import javax.swing.*; + +import com.intellij.ui.WidthBasedLayout; + +final class WrapperPanel extends JPanel implements WidthBasedLayout { + + WrapperPanel(JComponent content) { + super(new BorderLayout()); + setBorder(null); + setContent(content); + } + + void setContent(JComponent content) { + removeAll(); + add(content, BorderLayout.CENTER); + } + + private JComponent getComponent() { + return (JComponent) getComponent(0); + } + + @Override + public int getPreferredWidth() { + return WidthBasedLayout.getPreferredWidth(getComponent()); + } + + @Override + public int getPreferredHeight(int width) { + return WidthBasedLayout.getPreferredHeight(getComponent(), width); + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/icons/ArtemisIcons.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/icons/ArtemisIcons.java new file mode 100644 index 0000000..55d21d1 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/icons/ArtemisIcons.java @@ -0,0 +1,18 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.icons; + +import javax.swing.Icon; + +import com.intellij.openapi.util.IconLoader; + +// This class follows IntelliJ's code style conventions +public final class ArtemisIcons { + private ArtemisIcons() { + throw new IllegalStateException("Utility class"); + } + + public static final Icon AnnotationsGutter1 = IconLoader.getIcon("/icons/annotation1.svg", ArtemisIcons.class); + public static final Icon AnnotationsGutter2 = IconLoader.getIcon("/icons/annotation2.svg", ArtemisIcons.class); + public static final Icon AnnotationsGutter3 = IconLoader.getIcon("/icons/annotation3.svg", ArtemisIcons.class); + public static final Icon AnnotationsGutter4 = IconLoader.getIcon("/icons/annotation4.svg", ArtemisIcons.class); +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/FileOpener.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/FileOpener.java new file mode 100644 index 0000000..c20f23b --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/FileOpener.java @@ -0,0 +1,142 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.listeners; + +import com.intellij.ide.projectView.ProjectView; +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.components.Service; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.fileEditor.OpenFileDescriptor; +import com.intellij.openapi.project.DumbService; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.psi.PsiArrayType; +import com.intellij.psi.PsiManager; +import com.intellij.psi.PsiModifier; +import com.intellij.psi.PsiType; +import com.intellij.psi.PsiTypes; +import com.intellij.psi.search.GlobalSearchScope; +import com.intellij.psi.search.GlobalSearchScopes; +import com.intellij.psi.search.PsiShortNamesCache; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettingsState; +import edu.kit.kastel.sdq.intelligrade.state.ActiveAssessment; +import edu.kit.kastel.sdq.intelligrade.state.PluginState; +import edu.kit.kastel.sdq.intelligrade.utils.IntellijUtil; + +@Service +public final class FileOpener implements DumbService.DumbModeListener { + private static final Logger LOG = Logger.getInstance(FileOpener.class); + + private volatile boolean openClassesNextTime = false; + + public static FileOpener getInstance() { + return ApplicationManager.getApplication().getService(FileOpener.class); + } + + public FileOpener() { + PluginState.getInstance().registerAssessmentStartedListener(a -> { + var settings = ArtemisSettingsState.getInstance(); + synchronized (this) { + openClassesNextTime = settings.isAutoOpenMainClass(); + } + }); + + PluginState.getInstance().registerAssessmentClosedListener(() -> { + // Relevant if building indices is not finished before the assessment is closed + synchronized (this) { + openClassesNextTime = false; + } + }); + } + + @Override + public void exitDumbMode() { + if (!openClassesNextTime || !PluginState.getInstance().isAssessing()) { + return; + } + + // Open the main class + // Do this in the background because it may cause a synchronous vfs refresh + ApplicationManager.getApplication().executeOnPooledThread(() -> { + synchronized (this) { + if (!openClassesNextTime || !PluginState.getInstance().isAssessing()) { + return; + } + + openClassesNextTime = false; + } + + var project = IntellijUtil.getActiveProject(); + + // Only look in assignment/, we aren't interested in test classes + var directory = VfsUtil.findFile( + IntellijUtil.getProjectRootDirectory().resolve(ActiveAssessment.ASSIGNMENT_SUB_PATH), true); + + if (directory == null) { + LOG.warn("Can't resolve assignment directory"); + return; + } + + // Even though we exited dumb mode, the index operations below may throw IndexNotReadyExceptions, + // so defensively wrap this in a smart mode action + DumbService.getInstance(project).runReadActionInSmartMode(() -> findAnOpenMainMethod(project, directory)); + }); + } + + private static void findAnOpenMainMethod(Project project, VirtualFile directory) { + var scope = GlobalSearchScopes.directoryScope(project, directory, true); + var mainMethods = PsiShortNamesCache.getInstance(project).getMethodsByName("main", scope); + + PsiType stringType = + PsiType.getJavaLangString(PsiManager.getInstance(project), GlobalSearchScope.allScope(project)); + for (var method : mainMethods) { + // Is public & static + var modifiers = method.getModifierList(); + if (!modifiers.hasExplicitModifier(PsiModifier.PUBLIC) + || !modifiers.hasExplicitModifier(PsiModifier.STATIC)) { + continue; + } + + // Returns void + if (!PsiTypes.voidType().equals(method.getReturnType())) { + continue; + } + + // Single parameter of type String[] or String... + var parameters = method.getParameterList(); + if (parameters.getParametersCount() != 1) { + continue; + } + + var parameter = parameters.getParameters()[0]; + var type = parameter.getType(); + + // This should also cover varargs, since PsiEllipsisType is a subtype of PsiArrayType + if (type instanceof PsiArrayType arrayType) { + if (!stringType.equals(arrayType.getComponentType())) { + continue; + } + } else { + continue; + } + + // All checks passed, this is a main method! + var file = method.getContainingFile().getVirtualFile(); + int offset = method.getTextOffset(); + + ApplicationManager.getApplication().invokeLater(() -> { + // Open the file in an editor, and place the caret at the main method's declaration + FileEditorManager.getInstance(project) + .openTextEditor(new OpenFileDescriptor(project, file, offset), true); + + // Expand the project view and select the file + ProjectView.getInstance(IntellijUtil.getActiveProject()).select(null, file, true); + }); + + return; + } + + LOG.info("No main class found"); + } +} diff --git a/src/main/java/edu/kit/kastel/listeners/GradingConfigSelectedListener.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/GradingConfigSelectedListener.java similarity index 68% rename from src/main/java/edu/kit/kastel/listeners/GradingConfigSelectedListener.java rename to src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/GradingConfigSelectedListener.java index 281020b..4106205 100644 --- a/src/main/java/edu/kit/kastel/listeners/GradingConfigSelectedListener.java +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/GradingConfigSelectedListener.java @@ -1,16 +1,11 @@ /* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.listeners; - -import java.io.File; +package edu.kit.kastel.sdq.intelligrade.listeners; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import com.intellij.openapi.ui.TextFieldWithBrowseButton; -import edu.kit.kastel.exceptions.ImplementationMissing; -import edu.kit.kastel.extensions.settings.ArtemisSettingsState; -import edu.kit.kastel.sdq.artemis4j.grading.config.JsonFileConfig; -import edu.kit.kastel.utils.AssessmentUtils; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettingsState; /** * This class handles everything related to the grading config and related UI events. @@ -30,11 +25,6 @@ public void insertUpdate(DocumentEvent documentEvent) { // store saved grading config path ArtemisSettingsState settings = ArtemisSettingsState.getInstance(); settings.setSelectedGradingConfigPath(gradingConfigPath); - - // parse JSON Data and make it accessible to the listeners - JsonFileConfig gradingConfig = new JsonFileConfig(new File(gradingConfigPath)); - ExerciseSelectedListener.updateJsonConfig(gradingConfig); - AssessmentUtils.initExerciseConfig(gradingConfig); } @Override @@ -44,7 +34,7 @@ public void removeUpdate(DocumentEvent documentEvent) { @Override public void changedUpdate(DocumentEvent documentEvent) { - throw new ImplementationMissing( + throw new IllegalStateException( "Wrong event `GradingConfigSelectedListener::changedUpdate` " + "called. This requires bug fixing!"); } } diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/OnMouseInEditorMoved.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/OnMouseInEditorMoved.java new file mode 100644 index 0000000..ead23c1 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/OnMouseInEditorMoved.java @@ -0,0 +1,14 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.listeners; + +import com.intellij.openapi.editor.event.EditorMouseEvent; +import com.intellij.openapi.editor.event.EditorMouseMotionListener; +import edu.kit.kastel.sdq.intelligrade.highlighter.HighlighterManager; +import org.jetbrains.annotations.NotNull; + +public class OnMouseInEditorMoved implements EditorMouseMotionListener { + @Override + public void mouseMoved(@NotNull EditorMouseEvent e) { + HighlighterManager.onMouseMovedInEditor(e); + } +} diff --git a/src/main/java/edu/kit/kastel/listeners/OnPlugInLoad.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/OnPlugInLoad.java similarity index 61% rename from src/main/java/edu/kit/kastel/listeners/OnPlugInLoad.java rename to src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/OnPlugInLoad.java index 9eeed19..1d153d8 100644 --- a/src/main/java/edu/kit/kastel/listeners/OnPlugInLoad.java +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/OnPlugInLoad.java @@ -1,10 +1,11 @@ /* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.listeners; +package edu.kit.kastel.sdq.intelligrade.listeners; import java.util.List; import com.intellij.ide.AppLifecycleListener; -import edu.kit.kastel.utils.ArtemisUtils; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisCredentialsProvider; +import edu.kit.kastel.sdq.intelligrade.state.PluginState; import org.jetbrains.annotations.NotNull; /** @@ -16,6 +17,7 @@ public class OnPlugInLoad implements AppLifecycleListener { @Override public void appFrameCreated(@NotNull List commandLineArgs) { AppLifecycleListener.super.appFrameCreated(commandLineArgs); - ArtemisUtils.getArtemisClientInstance(); + ArtemisCredentialsProvider.getInstance().initialize(); + PluginState.getInstance().connect(); } } diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/OnStartupCompleted.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/OnStartupCompleted.java new file mode 100644 index 0000000..f11758c --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/listeners/OnStartupCompleted.java @@ -0,0 +1,31 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.listeners; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.project.DumbAware; +import com.intellij.openapi.project.DumbService; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.startup.ProjectActivity; +import com.intellij.openapi.wm.ToolWindowManager; +import edu.kit.kastel.sdq.intelligrade.highlighter.HighlighterManager; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class OnStartupCompleted implements ProjectActivity, DumbAware { + @Nullable + @Override + public Object execute(@NotNull Project project, @NotNull Continuation continuation) { + HighlighterManager.initialize(); + + project.getMessageBus().connect().subscribe(DumbService.DUMB_MODE, FileOpener.getInstance()); + + // Open the Artemis tool window + ApplicationManager.getApplication().invokeLater(() -> ToolWindowManager.getInstance(project) + .getToolWindow("Artemis") + .show()); + + return null; + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/login/CefDialog.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/login/CefDialog.java new file mode 100644 index 0000000..cacb05b --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/login/CefDialog.java @@ -0,0 +1,39 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.login; + +import java.awt.GridLayout; + +import javax.swing.Action; +import javax.swing.JComponent; +import javax.swing.JPanel; + +import com.intellij.openapi.project.Project; +import com.intellij.openapi.ui.DialogWrapper; +import com.intellij.ui.jcef.JBCefBrowser; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public class CefDialog extends DialogWrapper { + private final JBCefBrowser browser; + + public CefDialog(JBCefBrowser browser) { + super((Project) null); + this.browser = browser; + + this.setTitle("Artemis Login"); + this.setModal(false); + this.init(); + } + + @Override + protected @Nullable JComponent createCenterPanel() { + JPanel browserContainer = new JPanel(new GridLayout(1, 1)); + browserContainer.add(this.browser.getComponent()); + return browserContainer; + } + + @Override + protected Action @NotNull [] createActions() { + return new Action[0]; + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/login/CefUtils.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/login/CefUtils.java new file mode 100644 index 0000000..a720bf0 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/login/CefUtils.java @@ -0,0 +1,83 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.login; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +import javax.swing.SwingUtilities; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.util.Disposer; +import com.intellij.ui.jcef.JBCefApp; +import com.intellij.ui.jcef.JBCefBrowser; +import com.intellij.ui.jcef.JBCefClient; +import com.intellij.ui.jcef.JBCefCookie; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettingsState; +import org.cef.CefApp; +import org.cef.handler.CefFocusHandler; + +public final class CefUtils { + private static JBCefClient browserClient = JBCefApp.getInstance().createClient(); + + static { + // TODO choose a different disposer, see + // https://plugins.jetbrains.com/docs/intellij/disposers.html?from=IncorrectParentDisposable#choosing-a-disposable-parent + Disposer.register(ApplicationManager.getApplication(), browserClient); + } + + private CefUtils() { + throw new IllegalAccessError("Utility Class"); + } + + /** + * Create and display a Window containing a JBCef Window to request login. Call this on the EDT! + * + * @return A future on the JWT Cookie to log in. Don't await it on the EDT. + */ + public static CompletableFuture jcefBrowserLogin() { + + if (browserClient == null) { + browserClient = JBCefApp.getInstance().createClient(); + } + + // offscreen rendering is problematic on Linux + JBCefBrowser browser = JBCefBrowser.createBuilder() + .setClient(browserClient) + .setOffScreenRendering(false) + .setUrl(ArtemisSettingsState.getInstance().getArtemisInstanceUrl()) + .build(); + + // TODO the following code deletes the jwt cookie, which is needed for a "fresh" login + // TODO add this somewhere where it is useful + // CefApp.getInstance().onInitialization(state -> { + // browser.getJBCefCookieManager().deleteCookies(null, null); + // }); + + // set focus handler because it gets invoked sometimes and causes NullPE otherwise + CefFocusHandler focusHandler = new CefWindowFocusHandler(); + browserClient.addFocusHandler(focusHandler, browser.getCefBrowser()); + + var jwtFuture = new CompletableFuture(); + + SwingUtilities.invokeLater(() -> { + // create window, display it and navigate to log in URL + var window = new CefDialog(browser); + window.show(); + + JwtRetriever jwtRetriever = new JwtRetriever(browser, window); + browserClient.addLoadHandler(jwtRetriever, browser.getCefBrowser()); + + // Wait for CEF initialization + CefApp.getInstance() + .onInitialization(state -> jwtFuture.completeAsync(() -> { + try { + return jwtRetriever.getJwtCookie(); + } catch (Exception ex) { + throw new CompletionException(ex); + } + })); + }); + + return jwtFuture; + } +} diff --git a/src/main/java/edu/kit/kastel/login/CefWindowFocusHandler.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/login/CefWindowFocusHandler.java similarity index 68% rename from src/main/java/edu/kit/kastel/login/CefWindowFocusHandler.java rename to src/main/java/edu/kit/kastel/sdq/intelligrade/login/CefWindowFocusHandler.java index 43d580e..40f35d2 100644 --- a/src/main/java/edu/kit/kastel/login/CefWindowFocusHandler.java +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/login/CefWindowFocusHandler.java @@ -1,5 +1,5 @@ /* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.login; +package edu.kit.kastel.sdq.intelligrade.login; import org.cef.browser.CefBrowser; import org.cef.handler.CefFocusHandler; @@ -10,7 +10,9 @@ */ public class CefWindowFocusHandler implements CefFocusHandler { @Override - public void onTakeFocus(CefBrowser cefBrowser, boolean b) {} + public void onTakeFocus(CefBrowser cefBrowser, boolean b) { + // Nothing to do + } @Override public boolean onSetFocus(CefBrowser cefBrowser, FocusSource focusSource) { @@ -18,5 +20,7 @@ public boolean onSetFocus(CefBrowser cefBrowser, FocusSource focusSource) { } @Override - public void onGotFocus(CefBrowser cefBrowser) {} + public void onGotFocus(CefBrowser cefBrowser) { + // Nothing to do + } } diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/login/JwtRetriever.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/login/JwtRetriever.java new file mode 100644 index 0000000..fe25953 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/login/JwtRetriever.java @@ -0,0 +1,83 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.login; + +import javax.swing.SwingUtilities; + +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.ui.jcef.JBCefBrowser; +import com.intellij.ui.jcef.JBCefCookie; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisCredentialsProvider; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettingsState; +import org.cef.browser.CefBrowser; +import org.cef.browser.CefFrame; +import org.cef.handler.CefLoadHandlerAdapter; +import org.cef.network.CefCookieManager; + +public class JwtRetriever extends CefLoadHandlerAdapter { + private static final Logger LOG = Logger.getInstance(JwtRetriever.class); + + private static final String JWT_COOKIE_KEY = "jwt"; + + private final JBCefBrowser browser; + private final CefDialog window; + + private volatile JBCefCookie jwtCookie; + + public JwtRetriever(JBCefBrowser browser, CefDialog window) { + this.browser = browser; + this.window = window; + } + + @Override + public void onLoadEnd(CefBrowser browser, CefFrame frame, int httpStatusCode) { + synchronized (this) { + this.notifyAll(); + } + } + + public JBCefCookie getJwtCookie() throws Exception { + var settings = ArtemisSettingsState.getInstance(); + var credentials = ArtemisCredentialsProvider.getInstance(); + + String url = settings.getArtemisInstanceUrl(); + String jwt = credentials.getJwt(); + + synchronized (this) { + while (true) { + // We may have been woken up because the cookie is available + if (jwtCookie != null) { + // Can't use ApplicationManager.getApplication().invokeLater here, + // as this is only called on application exit on Linux in this specific case (not sure why) + SwingUtilities.invokeLater(() -> { + window.performOKAction(); + // window.dispatchEvent(new WindowEvent(window, WindowEvent.WINDOW_CLOSING)); + this.browser.getCefBrowser().close(true); + }); + return jwtCookie; + } + + // Otherwise, visit all cookies and look for the JWT cookie + try { + CefCookieManager.getGlobalManager().visitUrlCookies(url, true, (cookie, count, total, delete) -> { + if (cookie.name.equals(JWT_COOKIE_KEY)) { + if (!cookie.value.equals(jwt)) { + synchronized (JwtRetriever.this) { + jwtCookie = new JBCefCookie(cookie); + this.notifyAll(); + } + } + return false; + } + return true; + }); + } catch (RuntimeException e) { + // This can happen if the cookie manager is not yet initialized + // In this case, we just wait and try again + LOG.warn(e); + } + + this.wait(500); + } + } + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/state/ActiveAssessment.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/state/ActiveAssessment.java new file mode 100644 index 0000000..777e787 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/state/ActiveAssessment.java @@ -0,0 +1,235 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.state; + +import java.awt.EventQueue; +import java.awt.event.InputEvent; +import java.awt.event.KeyAdapter; +import java.awt.event.KeyEvent; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JSpinner; +import javax.swing.SpinnerNumberModel; + +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.ui.popup.JBPopupFactory; +import com.intellij.ui.JBColor; +import com.intellij.ui.ScrollPaneFactory; +import com.intellij.ui.components.JBPanel; +import com.intellij.ui.components.JBTextArea; +import com.intellij.util.ui.JBFont; +import edu.kit.kastel.sdq.artemis4j.grading.Annotation; +import edu.kit.kastel.sdq.artemis4j.grading.Assessment; +import edu.kit.kastel.sdq.artemis4j.grading.ClonedProgrammingSubmission; +import edu.kit.kastel.sdq.artemis4j.grading.penalty.GradingConfig; +import edu.kit.kastel.sdq.artemis4j.grading.penalty.MistakeType; +import edu.kit.kastel.sdq.intelligrade.autograder.AutograderTask; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettingsState; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.AutograderOption; +import edu.kit.kastel.sdq.intelligrade.utils.ArtemisUtils; +import edu.kit.kastel.sdq.intelligrade.utils.CodeSelection; +import edu.kit.kastel.sdq.intelligrade.utils.IntellijUtil; +import net.miginfocom.swing.MigLayout; + +public class ActiveAssessment { + private static final Logger LOG = Logger.getInstance(ActiveAssessment.class); + + public static final Path ASSIGNMENT_SUB_PATH = Path.of("assignment"); + + private final List>> annotationsUpdatedListener = new ArrayList<>(); + + private final Assessment assessment; + private final ClonedProgrammingSubmission clonedSubmission; + + public ActiveAssessment(Assessment assessment, ClonedProgrammingSubmission clonedSubmission) { + this.assessment = assessment; + this.clonedSubmission = clonedSubmission; + } + + public void registerAnnotationsUpdatedListener(Consumer> listener) { + annotationsUpdatedListener.add(listener); + listener.accept(assessment.getAnnotations()); + } + + public GradingConfig getGradingConfig() { + return assessment.getConfig(); + } + + public void addAnnotationAtCaret(MistakeType mistakeType, boolean withCustomMessage) { + if (assessment == null) { + throw new IllegalStateException("No active assessment"); + } + + var selection = CodeSelection.fromCaret(); + if (selection.isEmpty()) { + ArtemisUtils.displayGenericErrorBalloon( + "No code selected", "Cannot create annotation without code selection"); + return; + } + + var editor = IntellijUtil.getActiveEditor(); + int startLine = editor.getDocument().getLineNumber(selection.get().startOffset()); + int endLine = editor.getDocument().getLineNumber(selection.get().endOffset()); + String path = Path.of(IntellijUtil.getActiveProject().getBasePath()) + .resolve(ASSIGNMENT_SUB_PATH) + .relativize(selection.get().path()) + .toString(); + + if (mistakeType.isCustomAnnotation()) { + addCustomAnnotation(mistakeType, startLine, endLine, path); + } else { + if (withCustomMessage) { + addPredefinedAnnotationWithCustomMessage(mistakeType, startLine, endLine, path); + } else { + assessment.addPredefinedAnnotation(mistakeType, path, startLine, endLine, null); + this.notifyListeners(); + } + } + } + + public void deleteAnnotation(Annotation annotation) { + this.assessment.removeAnnotation(annotation); + this.notifyListeners(); + } + + public void runAutograder() { + var settings = ArtemisSettingsState.getInstance(); + if (settings.getAutograderOption() == AutograderOption.SKIP) { + return; + } + + AutograderTask.execute(assessment, clonedSubmission, this::notifyListeners); + } + + public Assessment getAssessment() { + return this.assessment; + } + + public void changeCustomMessage(Annotation annotation) { + if (annotation.getMistakeType().isCustomAnnotation()) { + showCustomAnnotationDialog( + annotation.getMistakeType(), + annotation.getCustomMessage().orElseThrow(), + annotation.getCustomScore().orElseThrow(), + messageWithPoints -> { + annotation.setCustomMessage(messageWithPoints.message()); + annotation.setCustomScore(messageWithPoints.points()); + this.notifyListeners(); + }); + } else { + showCustomMessageDialog(annotation.getCustomMessage().orElse(""), customMessage -> { + if (customMessage.isBlank()) { + annotation.setCustomMessage(null); + } else { + annotation.setCustomMessage(customMessage); + } + this.notifyListeners(); + }); + } + } + + private void addPredefinedAnnotationWithCustomMessage( + MistakeType mistakeType, int startLine, int endLine, String path) { + showCustomMessageDialog("", customMessage -> { + this.assessment.addPredefinedAnnotation(mistakeType, path, startLine, endLine, customMessage); + this.notifyListeners(); + }); + } + + private void addCustomAnnotation(MistakeType mistakeType, int startLine, int endLine, String path) { + showCustomAnnotationDialog(mistakeType, "", 0.0, messageWithPoints -> { + this.assessment.addCustomAnnotation( + mistakeType, path, startLine, endLine, messageWithPoints.message(), messageWithPoints.points()); + this.notifyListeners(); + }); + } + + private void notifyListeners() { + for (Consumer> listener : this.annotationsUpdatedListener) { + listener.accept(this.assessment.getAnnotations()); + } + } + + private void showCustomMessageDialog(String initialMessage, Consumer onOk) { + var panel = new JBPanel<>(new MigLayout("wrap 1", "[250lp]")); + + var customMessage = new JBTextArea(initialMessage); + customMessage.setFont(JBFont.regular()); + customMessage.setBorder(BorderFactory.createLineBorder(JBColor.border())); + panel.add(ScrollPaneFactory.createScrollPane(customMessage), "grow, height 100lp"); + + var popup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(panel, customMessage) + .setTitle("Custom Message") + .setFocusable(true) + .setRequestFocus(true) + .setMovable(true) + .setResizable(true) + .setNormalWindowLevel(true) + .setOkHandler(() -> onOk.accept(customMessage.getText().trim())) + .createPopup(); + + customMessage.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER) { + if (e.isControlDown()) { + customMessage.insert("\n", customMessage.getCaretPosition()); + } else { + popup.closeOk((InputEvent) EventQueue.getCurrentEvent()); + } + } + } + }); + + popup.showCenteredInCurrentWindow(IntellijUtil.getActiveProject()); + } + + private void showCustomAnnotationDialog( + MistakeType mistakeType, String initialMessage, double initialPoints, Consumer onOk) { + var panel = new JBPanel<>(new MigLayout("wrap 2", "[200lp] []")); + + var customMessage = new JBTextArea(initialMessage); + customMessage.setFont(JBFont.regular()); + customMessage.setBorder(BorderFactory.createLineBorder(JBColor.border())); + panel.add(ScrollPaneFactory.createScrollPane(customMessage), "span 2, grow, height 100lp"); + + double maxValue = this.assessment.getConfig().isPositiveFeedbackAllowed() ? Double.MAX_VALUE : 0.0; + double minValue = mistakeType.getRatingGroup().getMinPenalty(); + var customScore = new JSpinner(new SpinnerNumberModel(initialPoints, minValue, maxValue, 0.5)); + panel.add(customScore, "spanx 2, growx"); + + var okButton = new JButton("Ok"); + panel.add(okButton, "skip 1, tag ok"); + + var popup = JBPopupFactory.getInstance() + .createComponentPopupBuilder(panel, customMessage) + .setTitle("Custom Comment") + .setFocusable(true) + .setRequestFocus(true) + .setMovable(true) + .setResizable(true) + .setNormalWindowLevel(true) + .setOkHandler(() -> onOk.accept( + new MessageWithPoints(customMessage.getText().trim(), (double) customScore.getValue()))) + .createPopup(); + + okButton.addActionListener(a -> popup.closeOk((InputEvent) EventQueue.getCurrentEvent())); + customMessage.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_ENTER && !e.isControlDown()) { + popup.closeOk((InputEvent) EventQueue.getCurrentEvent()); + } + } + }); + + popup.showCenteredInCurrentWindow(IntellijUtil.getActiveProject()); + } + + private record MessageWithPoints(String message, double points) {} +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/state/PluginState.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/state/PluginState.java new file mode 100644 index 0000000..aad1fac --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/state/PluginState.java @@ -0,0 +1,514 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.state; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +import com.intellij.openapi.application.ApplicationManager; +import com.intellij.openapi.diagnostic.Logger; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.progress.ProgressIndicator; +import com.intellij.openapi.progress.Task; +import com.intellij.ui.jcef.JBCefApp; +import edu.kit.kastel.sdq.artemis4j.ArtemisClientException; +import edu.kit.kastel.sdq.artemis4j.ArtemisNetworkException; +import edu.kit.kastel.sdq.artemis4j.client.ArtemisInstance; +import edu.kit.kastel.sdq.artemis4j.grading.ArtemisConnection; +import edu.kit.kastel.sdq.artemis4j.grading.Assessment; +import edu.kit.kastel.sdq.artemis4j.grading.MoreRecentSubmissionException; +import edu.kit.kastel.sdq.artemis4j.grading.ProgrammingExercise; +import edu.kit.kastel.sdq.artemis4j.grading.ProgrammingSubmission; +import edu.kit.kastel.sdq.artemis4j.grading.metajson.AnnotationMappingException; +import edu.kit.kastel.sdq.artemis4j.grading.penalty.GradingConfig; +import edu.kit.kastel.sdq.artemis4j.grading.penalty.InvalidGradingConfigException; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisCredentialsProvider; +import edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettingsState; +import edu.kit.kastel.sdq.intelligrade.login.CefUtils; +import edu.kit.kastel.sdq.intelligrade.utils.ArtemisUtils; +import edu.kit.kastel.sdq.intelligrade.utils.IntellijUtil; +import org.apache.commons.io.FileUtils; +import org.jetbrains.annotations.NotNull; + +public class PluginState { + private static final Logger LOG = Logger.getInstance(PluginState.class); + + private static PluginState pluginState; + + private final List>> connectedListeners = new ArrayList<>(); + private final List> assessmentStartedListeners = new ArrayList<>(); + private final List assessmentClosedListeners = new ArrayList<>(); + + private ArtemisConnection connection; + private ProgrammingExercise activeExercise; + + private ActiveAssessment activeAssessment; + + public static PluginState getInstance() { + if (pluginState == null) { + pluginState = new PluginState(); + } + return pluginState; + } + + public void connect() { + this.resetState(); + + var settings = ArtemisSettingsState.getInstance(); + var credentials = ArtemisCredentialsProvider.getInstance(); + + String url = settings.getArtemisInstanceUrl(); + if (!ArtemisUtils.doesUrlExist(url)) { + ArtemisUtils.displayGenericErrorBalloon( + "Artemis URL not reachable", + "The Artemis URL is not valid, or you do not have a working internet connection."); + this.notifyConnectedListeners(); + return; + } + + var instance = new ArtemisInstance(settings.getArtemisInstanceUrl()); + + CompletableFuture connectionFuture; + if (settings.isUseTokenLogin()) { + connectionFuture = retrieveJWT().thenApplyAsync(token -> ArtemisConnection.fromToken(instance, token)); + } else { + connectionFuture = CompletableFuture.supplyAsync(() -> { + try { + return ArtemisConnection.connectWithUsernamePassword( + instance, settings.getUsername(), credentials.getArtemisPassword()); + } catch (ArtemisClientException e) { + throw new CompletionException(e); + } + }); + } + + connectionFuture + .thenAcceptAsync(newConnection -> { + this.connection = newConnection; + try { + this.verifyLogin(); + this.notifyConnectedListeners(); + } catch (ArtemisClientException e) { + throw new CompletionException(e); + } + }) + .exceptionallyAsync(e -> { + LOG.warn(e); + ArtemisUtils.displayGenericErrorBalloon("Artemis Login failed", e.getMessage()); + this.connection = null; + this.notifyConnectedListeners(); + return null; + }); + } + + public void registerConnectedListener(Consumer> listener) { + this.connectedListeners.add(listener); + listener.accept(Optional.ofNullable(this.connection)); + } + + public boolean isAssessing() { + return activeAssessment != null; + } + + public void startNextAssessment(int correctionRound) { + if (activeAssessment != null) { + ArtemisUtils.displayFinishAssessmentFirstBalloon(); + return; + } + + if (activeExercise == null) { + ArtemisUtils.displayGenericErrorBalloon("Could not start assessment", "No course selected"); + return; + } + + var gradingConfig = createGradingConfig(); + if (gradingConfig.isEmpty()) { + return; + } + + new StartAssessmentTask(correctionRound, gradingConfig.get()).queue(); + } + + public void saveAssessment() { + if (activeAssessment == null) { + ArtemisUtils.displayNoAssessmentBalloon(); + return; + } + + try { + activeAssessment.getAssessment().save(); + ArtemisUtils.displayGenericInfoBalloon("Assessment saved", "The assessment has been saved."); + } catch (ArtemisNetworkException e) { + LOG.warn(e); + ArtemisUtils.displayNetworkErrorBalloon("Could not save assessment", e); + } catch (AnnotationMappingException e) { + LOG.warn(e); + ArtemisUtils.displayGenericErrorBalloon( + "Could not save assessment", + "Failed to serialize the assessment. This is a serious bug; please contact the Übungsleitung!"); + } + } + + public void submitAssessment() { + if (activeAssessment == null) { + ArtemisUtils.displayNoAssessmentBalloon(); + return; + } + + try { + activeAssessment.getAssessment().submit(); + this.cleanupAssessment(); + } catch (ArtemisNetworkException e) { + LOG.warn(e); + ArtemisUtils.displayNetworkErrorBalloon("Could not submit assessment", e); + } catch (AnnotationMappingException e) { + LOG.warn(e); + ArtemisUtils.displayGenericErrorBalloon( + "Could not submit assessment", + "Failed to serialize the assessment. This is a serious bug; please contact the Übungsleitung!"); + } + } + + public void cancelAssessment() { + if (activeAssessment == null) { + ArtemisUtils.displayNoAssessmentBalloon(); + return; + } + + try { + activeAssessment.getAssessment().cancel(); + this.cleanupAssessment(); + } catch (ArtemisNetworkException e) { + LOG.warn(e); + ArtemisUtils.displayNetworkErrorBalloon("Could not submit assessment", e); + } + } + + public void closeAssessment() { + if (activeAssessment == null) { + ArtemisUtils.displayNoAssessmentBalloon(); + return; + } + + this.cleanupAssessment(); + } + + public void reopenAssessment(ProgrammingSubmission submission) { + if (activeAssessment != null) { + ArtemisUtils.displayFinishAssessmentFirstBalloon(); + return; + } + + if (activeExercise == null) { + ArtemisUtils.displayGenericErrorBalloon("Could not reopen assessment", "No exercise selected"); + return; + } + + var gradingConfig = createGradingConfig(); + if (gradingConfig.isEmpty()) { + return; + } + + new ReopenAssessmentTask(submission, gradingConfig.get()).queue(); + } + + public Optional getActiveExercise() { + return Optional.ofNullable(activeExercise); + } + + public void setActiveExercise(ProgrammingExercise exercise) { + this.activeExercise = exercise; + } + + public Optional getActiveAssessment() { + return Optional.ofNullable(activeAssessment); + } + + public void registerAssessmentStartedListener(Consumer listener) { + this.assessmentStartedListeners.add(listener); + if (this.isAssessing()) { + listener.accept(activeAssessment); + } + } + + public void registerAssessmentClosedListener(Runnable listener) { + this.assessmentClosedListeners.add(listener); + } + + private void resetState() { + connection = null; + activeExercise = null; + activeAssessment = null; + } + + private CompletableFuture retrieveJWT() { + var settings = ArtemisSettingsState.getInstance(); + var credentials = ArtemisCredentialsProvider.getInstance(); + + return CompletableFuture.supplyAsync(() -> { + String previousJwt = credentials.getJwt(); + if (previousJwt != null && !previousJwt.isBlank()) { + return previousJwt; + } + + if (!JBCefApp.isSupported()) { + throw new CompletionException(new IllegalStateException("JCEF unavailable")); + } + + try { + var cookie = CefUtils.jcefBrowserLogin().get(); + credentials.setJwt(cookie.getValue()); + settings.setJwtExpiry(cookie.getExpires()); + return cookie.getValue(); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new CompletionException(ex); + } catch (ExecutionException e) { + throw new CompletionException(e); + } + }); + } + + private void verifyLogin() throws ArtemisClientException { + // This triggers a request and forces a connection error if the token is invalid + this.connection.getAssessor(); + } + + private void notifyConnectedListeners() { + ApplicationManager.getApplication().invokeLater(() -> { + for (Consumer> l : this.connectedListeners) { + l.accept(Optional.ofNullable(this.connection)); + } + }); + } + + private boolean initializeAssessment(Assessment assessment) { + try { + // Cleanup first, in case there are files left from a previous assessment + this.cleanupProjectDirectory(); + + // Clone the new submission + var clonedSubmission = + switch (ArtemisSettingsState.getInstance().getVcsAccessOption()) { + case SSH -> { + // We need to switch the classloader here (same as + // https://plugins.jetbrains.com/docs/intellij/plugin-class-loaders.html#using-serviceloader) + // Somewhere deep in the auth libs, an instanceof check is performed, which returns false in + // some cases where the same class was loaded with two different class loaders (the plugin + // and the platform ones) + Thread currentThread = Thread.currentThread(); + ClassLoader originalClassLoader = currentThread.getContextClassLoader(); + ClassLoader pluginClassLoader = this.getClass().getClassLoader(); + try { + currentThread.setContextClassLoader(pluginClassLoader); + yield assessment + .getSubmission() + .cloneViaSSHInto(IntellijUtil.getProjectRootDirectory()); + } finally { + currentThread.setContextClassLoader(originalClassLoader); + } + } + case TOKEN -> assessment + .getSubmission() + .cloneViaVCSTokenInto(IntellijUtil.getProjectRootDirectory(), null); + }; + + // Refresh all files, so that they are up-to-date for the maven update + IntellijUtil.forceFilesSync(() -> { + // Force IntelliJ to update the Maven project + IntellijUtil.getMavenManager().forceUpdateAllProjectsOrFindAllAvailablePomFiles(); + }); + + this.activeAssessment = new ActiveAssessment(assessment, clonedSubmission); + for (Consumer listener : this.assessmentStartedListeners) { + listener.accept(activeAssessment); + } + + return true; + } catch (ArtemisClientException e) { + LOG.warn(e); + ArtemisUtils.displayGenericErrorBalloon("Error cloning submission", e.getMessage()); + + // Cancel the assessment to prevent spurious locks + try { + assessment.cancel(); + } catch (ArtemisNetworkException ex) { + LOG.warn(ex); + ArtemisUtils.displayGenericErrorBalloon("Failed to free the assessment lock", ex.getMessage()); + } + + return false; + } + } + + private void cleanupAssessment() { + this.activeAssessment = null; + + // Do not close the ClonedProgrammingSubmission, since this would try to delete the workspace file + // Instead, we delete the project directory manually + this.cleanupProjectDirectory(); + + // Tell IntelliJ's VCS manager that the Git repo is gone + // This prevents an annoying popup that warns about a missing Git root + IntellijUtil.forceFilesSync(() -> { + IntellijUtil.getVcsManager().setDirectoryMappings(List.of()); + IntellijUtil.getVcsManager().fireDirectoryMappingsChanged(); + }); + + for (Runnable assessmentClosedListener : this.assessmentClosedListeners) { + assessmentClosedListener.run(); + } + } + + private Optional createGradingConfig() { + var gradingConfigPath = ArtemisSettingsState.getInstance().getSelectedGradingConfigPath(); + if (gradingConfigPath == null) { + ArtemisUtils.displayGenericErrorBalloon("No grading config", "Please select a grading config"); + return Optional.empty(); + } + + try { + return Optional.of( + GradingConfig.readFromString(Files.readString(Path.of(gradingConfigPath)), activeExercise)); + } catch (IOException | InvalidGradingConfigException e) { + LOG.warn(e); + ArtemisUtils.displayGenericErrorBalloon("Invalid grading config", e.getMessage()); + return Optional.empty(); + } + } + + private void cleanupProjectDirectory() { + // Close all open editors + var editorManager = FileEditorManager.getInstance(IntellijUtil.getActiveProject()); + ApplicationManager.getApplication().invokeAndWait(() -> { + for (var editor : editorManager.getAllEditors()) { + editorManager.closeFile(editor.getFile()); + } + }); + + var rootPath = IntellijUtil.getProjectRootDirectory(); + // Delete all directory contents, but not the directory itself + try { + Files.walkFileTree(rootPath, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + FileUtils.forceDelete(file.toFile()); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException { + if (!dir.equals(rootPath)) { + FileUtils.forceDelete(dir.toFile()); + } + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + LOG.warn(e); + ArtemisUtils.displayGenericErrorBalloon("Error cleaning up project directory", e.getMessage()); + } + } + + private class StartAssessmentTask extends Task.Modal { + private final int correctionRound; + private final GradingConfig gradingConfig; + + public StartAssessmentTask(int correctionRound, GradingConfig gradingConfig) { + super(IntellijUtil.getActiveProject(), "Starting Assessment", false); + this.correctionRound = correctionRound; + this.gradingConfig = gradingConfig; + } + + @Override + public void run(@NotNull ProgressIndicator progressIndicator) { + try { + progressIndicator.setText("Locking..."); + var nextAssessment = activeExercise.tryLockNextSubmission(correctionRound, gradingConfig); + if (nextAssessment.isPresent()) { + progressIndicator.setText("Cloning..."); + if (!initializeAssessment(nextAssessment.get())) { + return; + } + + // Now everything is done - the submission is properly locked, and the repository is cloned + if (activeAssessment.getAssessment().getAnnotations().isEmpty()) { + activeAssessment.runAutograder(); + } else { + ArtemisUtils.displayGenericInfoBalloon( + "Skipping Autograder", + "The submission already has annotations. Skipping the Autograder."); + } + + ArtemisUtils.displayGenericInfoBalloon( + "Assessment started", + "You can now grade the submission. Please make sure that are familiar with all " + + "grading guidelines."); + } else { + ArtemisUtils.displayGenericInfoBalloon( + "Could not start assessment", + "There are no more submissions to assess. Thanks for your work :)"); + } + } catch (ArtemisNetworkException e) { + LOG.warn(e); + ArtemisUtils.displayNetworkErrorBalloon("Could not lock assessment", e); + } catch (AnnotationMappingException e) { + LOG.warn(e); + ArtemisUtils.displayGenericErrorBalloon( + "Could not parse assessment", + "Could not parse previous assessment. This is a serious bug; please contact the " + + "Übungsleitung!"); + } + } + } + + private class ReopenAssessmentTask extends Task.Modal { + private final ProgrammingSubmission submission; + private final GradingConfig gradingConfig; + + public ReopenAssessmentTask(ProgrammingSubmission submission, GradingConfig gradingConfig) { + super(IntellijUtil.getActiveProject(), "Reopening Assessment", false); + this.submission = submission; + this.gradingConfig = gradingConfig; + } + + @Override + public void run(@NotNull ProgressIndicator progressIndicator) { + try { + progressIndicator.setText("Locking..."); + var assessment = submission.tryLock(gradingConfig); + if (assessment.isPresent()) { + progressIndicator.setText("Cloning..."); + initializeAssessment(assessment.get()); + } else { + ArtemisUtils.displayGenericErrorBalloon( + "Failed to reopen assessment", "Most likely, your lock has been taken by someone else."); + } + + } catch (ArtemisNetworkException e) { + LOG.warn(e); + ArtemisUtils.displayNetworkErrorBalloon("Could not lock assessment", e); + } catch (AnnotationMappingException e) { + LOG.warn(e); + ArtemisUtils.displayGenericErrorBalloon( + "Could not parse assessment", + "Could not parse previous assessment. This is a serious bug; please contact the " + + "Übungsleitung!"); + } catch (MoreRecentSubmissionException e) { + LOG.warn(e); + ArtemisUtils.displayGenericErrorBalloon( + "Could not reopen assessment", "The student has submitted a newer version of his code."); + } + } + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/utils/ArtemisUtils.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/utils/ArtemisUtils.java new file mode 100644 index 0000000..7c20e20 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/utils/ArtemisUtils.java @@ -0,0 +1,64 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.utils; + +import java.net.HttpURLConnection; +import java.net.URI; + +import com.intellij.notification.NotificationGroupManager; +import com.intellij.notification.NotificationType; +import edu.kit.kastel.sdq.artemis4j.ArtemisNetworkException; + +/** + * Utility Class to handle Artemis related common tasks such as + * creating a new client and logging in or creating Error messages. + */ +public final class ArtemisUtils { + private ArtemisUtils() {} + + public static boolean doesUrlExist(String url) { + try { + var connection = (HttpURLConnection) new URI(url).toURL().openConnection(); + return connection.getResponseCode() == HttpURLConnection.HTTP_OK; + } catch (Exception ex) { + return false; + } + } + + public static void displayGenericErrorBalloon(String title, String content) { + NotificationGroupManager.getInstance() + .getNotificationGroup("IntelliGrade Notifications") + .createNotification(content, NotificationType.ERROR) + .setTitle(title) + .notify(null); + } + + public static void displayGenericWarningBalloon(String title, String content) { + NotificationGroupManager.getInstance() + .getNotificationGroup("IntelliGrade Notifications") + .createNotification(content, NotificationType.WARNING) + .setTitle(title) + .notify(null); + } + + public static void displayGenericInfoBalloon(String title, String content) { + NotificationGroupManager.getInstance() + .getNotificationGroup("IntelliGrade Notifications") + .createNotification(content, NotificationType.INFORMATION) + .setTitle(title) + .notify(null); + } + + public static void displayNetworkErrorBalloon(String content, ArtemisNetworkException cause) { + displayGenericErrorBalloon("Network Error", content + " (" + cause.getMessage() + ")"); + } + + public static void displayNoAssessmentBalloon() { + displayGenericWarningBalloon("No active assessment", "Please start an assessment first."); + } + + public static void displayFinishAssessmentFirstBalloon() { + displayGenericWarningBalloon( + "Finish assessment first", + "Please finish the current assessment first. If you do not want to, please cancel it."); + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/utils/CodeSelection.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/utils/CodeSelection.java new file mode 100644 index 0000000..5919cee --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/utils/CodeSelection.java @@ -0,0 +1,31 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.utils; + +import java.nio.file.Path; +import java.util.Optional; + +public record CodeSelection(int startOffset, int endOffset, Path path) { + + public static Optional fromCaret() { + var editor = IntellijUtil.getActiveEditor(); + if (editor == null) { + // no editor open or no selection made + return Optional.empty(); + } + + var caret = editor.getCaretModel().getPrimaryCaret(); + + int startOffset; + int endOffset; + if (caret.hasSelection()) { + startOffset = caret.getSelectionRange().getStartOffset(); + endOffset = caret.getSelectionRange().getEndOffset(); + } else { + startOffset = caret.getOffset(); + endOffset = caret.getOffset(); + } + + var path = editor.getVirtualFile().toNioPath(); + return Optional.of(new CodeSelection(startOffset, endOffset, path)); + } +} diff --git a/src/main/java/edu/kit/kastel/sdq/intelligrade/utils/IntellijUtil.java b/src/main/java/edu/kit/kastel/sdq/intelligrade/utils/IntellijUtil.java new file mode 100644 index 0000000..aaa1115 --- /dev/null +++ b/src/main/java/edu/kit/kastel/sdq/intelligrade/utils/IntellijUtil.java @@ -0,0 +1,66 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel.sdq.intelligrade.utils; + +import java.nio.file.Path; +import java.util.Objects; + +import com.intellij.openapi.editor.Editor; +import com.intellij.openapi.fileEditor.FileEditorManager; +import com.intellij.openapi.project.Project; +import com.intellij.openapi.project.ProjectManager; +import com.intellij.openapi.vcs.impl.ProjectLevelVcsManagerImpl; +import com.intellij.openapi.vfs.VfsUtil; +import com.intellij.openapi.vfs.VirtualFile; +import com.intellij.openapi.vfs.newvfs.RefreshQueue; +import edu.kit.kastel.sdq.artemis4j.grading.Annotation; +import edu.kit.kastel.sdq.intelligrade.state.ActiveAssessment; +import org.jetbrains.idea.maven.project.MavenProjectsManager; + +public final class IntellijUtil { + private IntellijUtil() { + throw new IllegalStateException("Utility class"); + } + + public static Project getActiveProject() { + return ProjectManager.getInstance().getOpenProjects()[0]; + } + + public static Editor getActiveEditor() { + return FileEditorManager.getInstance(getActiveProject()).getSelectedTextEditor(); + } + + public static Path getProjectRootDirectory() { + return Path.of(getActiveProject().getBasePath()); + } + + public static MavenProjectsManager getMavenManager() { + return MavenProjectsManager.getInstance(getActiveProject()); + } + + public static void forceFilesSync(Runnable afterSyncAction) { + var rootVirtualFile = Objects.requireNonNull( + VfsUtil.findFileByIoFile(getProjectRootDirectory().toFile(), true)); + var session = RefreshQueue.getInstance().createSession(true, true, afterSyncAction); + session.addFile(rootVirtualFile); + session.launch(); + } + + public static ProjectLevelVcsManagerImpl getVcsManager() { + return ProjectLevelVcsManagerImpl.getInstanceImpl(IntellijUtil.getActiveProject()); + } + + public static Path getAnnotationPath(Annotation annotation) { + return IntellijUtil.getProjectRootDirectory() + .resolve(ActiveAssessment.ASSIGNMENT_SUB_PATH) + .resolve(annotation.getFilePath()); + } + + public static VirtualFile getAnnotationFile(Annotation annotation) { + var path = getAnnotationPath(annotation); + var file = VfsUtil.findFile(path, true); + if (file == null) { + throw new IllegalStateException("File not found: " + path); + } + return file; + } +} diff --git a/src/main/java/edu/kit/kastel/state/AssessmentModeHandler.java b/src/main/java/edu/kit/kastel/state/AssessmentModeHandler.java deleted file mode 100644 index bd22836..0000000 --- a/src/main/java/edu/kit/kastel/state/AssessmentModeHandler.java +++ /dev/null @@ -1,75 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.state; - -import java.awt.Color; -import java.util.Optional; - -import com.intellij.ui.JBColor; -import com.intellij.ui.components.JBLabel; -import edu.kit.kastel.sdq.artemis4j.grading.config.ExerciseConfig; -import edu.kit.kastel.utils.AssessmentUtils; -import edu.kit.kastel.wrappers.ExtendedLockResult; - -/** - * Class to handle assessment mode state. This class is a Singleton. - */ -public class AssessmentModeHandler { - - private static AssessmentModeHandler assessmentModeHandler; - private static final String ASSESSMENT_MODE_ENABLED = "✅"; - private static final String ASSESSMENT_MODE_DISABLED = "❌"; - - private boolean assessmentMode = false; - private Optional currentExerciseConfig = Optional.empty(); - private Optional assessmentLock; - - private Optional indicatorLabel = Optional.empty(); - - private AssessmentModeHandler() {} - - public static AssessmentModeHandler getInstance() { - if (assessmentModeHandler == null) { - assessmentModeHandler = new AssessmentModeHandler(); - } - return assessmentModeHandler; - } - - public void enableAssessmentMode(ExtendedLockResult assLock) { - this.assessmentLock = Optional.of(assLock); - this.assessmentMode = true; - AssessmentUtils.resetAnnotations(); - this.indicatorLabel.ifPresent(jbLabel -> { - jbLabel.setText(ASSESSMENT_MODE_ENABLED); - jbLabel.setBackground(new JBColor(new Color(54, 155, 54), new Color(54, 155, 54))); - }); - } - - public void disableAssessmentMode() { - this.assessmentMode = false; - this.assessmentLock = Optional.empty(); - this.indicatorLabel.ifPresent(jbLabel -> { - jbLabel.setText(ASSESSMENT_MODE_DISABLED); - jbLabel.setBackground(new JBColor(new Color(155, 54, 54), new Color(155, 54, 54))); - }); - } - - public boolean isInAssesmentMode() { - return this.assessmentMode; - } - - public Optional getAssessmentLock() { - return assessmentLock; - } - - public void connectIndicatorLabel(JBLabel label) { - this.indicatorLabel = Optional.of(label); - } - - public Optional getCurrentExerciseConfig() { - return currentExerciseConfig; - } - - public void setCurrentExerciseConfig(ExerciseConfig currentExerciseConfig) { - this.currentExerciseConfig = Optional.of(currentExerciseConfig); - } -} diff --git a/src/main/java/edu/kit/kastel/utils/AnnotationUtils.java b/src/main/java/edu/kit/kastel/utils/AnnotationUtils.java deleted file mode 100644 index 46bfbc0..0000000 --- a/src/main/java/edu/kit/kastel/utils/AnnotationUtils.java +++ /dev/null @@ -1,119 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.utils; - -import java.awt.*; -import java.nio.file.Path; -import java.nio.file.Paths; - -import com.intellij.DynamicBundle; -import com.intellij.openapi.diagnostic.Logger; -import com.intellij.openapi.editor.Editor; -import com.intellij.openapi.editor.markup.EffectType; -import com.intellij.openapi.editor.markup.HighlighterLayer; -import com.intellij.openapi.editor.markup.HighlighterTargetArea; -import com.intellij.openapi.editor.markup.RangeHighlighter; -import com.intellij.openapi.editor.markup.TextAttributes; -import com.intellij.openapi.fileEditor.FileEditorManager; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.project.ProjectManager; -import com.intellij.openapi.util.TextRange; -import com.intellij.psi.PsiDocumentManager; -import com.intellij.psi.PsiElement; -import com.intellij.ui.JBColor; -import edu.kit.kastel.extensions.settings.ArtemisSettingsState; -import edu.kit.kastel.listeners.OnSubmitAssessmentBtnClick; -import edu.kit.kastel.sdq.artemis4j.api.grading.IAnnotation; -import edu.kit.kastel.sdq.artemis4j.api.grading.IMistakeType; -import edu.kit.kastel.sdq.artemis4j.grading.model.annotation.AnnotationException; -import edu.kit.kastel.state.AssessmentModeHandler; -import edu.kit.kastel.wrappers.AnnotationWithTextSelection; -import org.apache.commons.io.FilenameUtils; - -public final class AnnotationUtils { - - private static final String ANNOT_ADD_ERR = "Error adding annotation."; - private static final String NO_ASSESSMENT_MSG = "Please start an assessment first"; - - private AnnotationUtils() { - throw new IllegalAccessError("Utility class"); - } - - public static void addAnnotationByMistakeType(IMistakeType mistakeType) { - if (!AssessmentModeHandler.getInstance().isInAssesmentMode()) { - ArtemisUtils.displayGenericErrorBalloon(NO_ASSESSMENT_MSG); - return; - } - - Project currentProject = ProjectManager.getInstance().getOpenProjects()[0]; - - Editor editor = FileEditorManager.getInstance(currentProject).getSelectedTextEditor(); - - if (editor == null || !editor.getSelectionModel().hasSelection()) { - // no editor open or no selection made - return; - } - - // get editor Selection - TextRange selectedText = editor.getCaretModel().getPrimaryCaret().getSelectionRange(); - - // only annotate if a selection has been made - // get the currently selected element and the containing file - PsiElement selectedElement = PsiDocumentManager.getInstance(currentProject) - .getPsiFile(editor.getDocument()) - .findElementAt(editor.getCaretModel().getOffset()) - .getContext(); - - Path subtracted = Paths.get(selectedElement.getProject().getBasePath()) - .relativize(selectedElement.getContainingFile().getVirtualFile().toNioPath()); - - // Add highlight in Editor - Color annotationColor = ArtemisSettingsState.getInstance().getAnnotationColor(); - - TextAttributes annotationMarkup = new TextAttributes( - null, new JBColor(annotationColor, annotationColor), null, EffectType.BOLD_LINE_UNDERSCORE, Font.PLAIN); - - RangeHighlighter highlighter = editor.getMarkupModel() - .addRangeHighlighter( - selectedText.getStartOffset(), - selectedText.getEndOffset(), - HighlighterLayer.SELECTION - 1, - annotationMarkup, - HighlighterTargetArea.EXACT_RANGE); - - // add tooltip (on the right bar) - highlighter.setErrorStripeMarkColor(JBColor.CYAN); - highlighter.setThinErrorStripeMark(true); - highlighter.setErrorStripeTooltip( - mistakeType.getButtonText(DynamicBundle.getLocale().getLanguage())); - - // create and add the annotation - var annotation = new AnnotationWithTextSelection( - IAnnotation.createID(), - mistakeType, - // lines are 0 indexed - editor.getCaretModel() - .getPrimaryCaret() - .getSelectionStartPosition() - .getLine() - + 1, - editor.getCaretModel() - .getPrimaryCaret() - .getSelectionEndPosition() - .getLine() - + 1, - FilenameUtils.removeExtension(subtracted.toString()), - "", - 0.0, - highlighter); - - try { - AssessmentUtils.addAnnotation(annotation); - } catch (AnnotationException e) { - ArtemisUtils.displayGenericErrorBalloon(ANNOT_ADD_ERR); - Logger.getInstance(OnSubmitAssessmentBtnClick.class).error(e); - - // if an adding the annotation occurs, we remove the highlighter - editor.getMarkupModel().removeHighlighter(highlighter); - } - } -} diff --git a/src/main/java/edu/kit/kastel/utils/ArtemisUtils.java b/src/main/java/edu/kit/kastel/utils/ArtemisUtils.java deleted file mode 100644 index 36c4459..0000000 --- a/src/main/java/edu/kit/kastel/utils/ArtemisUtils.java +++ /dev/null @@ -1,100 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.utils; - -import com.intellij.notification.Notification; -import com.intellij.notification.NotificationAction; -import com.intellij.notification.NotificationGroupManager; -import com.intellij.notification.NotificationType; -import com.intellij.openapi.actionSystem.AnAction; -import com.intellij.openapi.actionSystem.AnActionEvent; -import com.intellij.openapi.options.ShowSettingsUtil; -import edu.kit.kastel.extensions.settings.ArtemisSettingsState; -import edu.kit.kastel.login.CustomLoginManager; -import edu.kit.kastel.sdq.artemis4j.api.ArtemisClientException; -import edu.kit.kastel.sdq.artemis4j.client.RestClientManager; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * Utility Class to handle Artemis related common tasks such as - * creating a new client and logging in or creating Error messages. - */ -public final class ArtemisUtils { - private static final String LOGIN_ERROR_DIALOG_TITLE = "IntelliGrade Login error"; - public static final String GENERIC_ARTEMIS_ERROR_TITLE = "Artemis Error"; - - private static RestClientManager artemisClient; - - private ArtemisUtils() { - throw new IllegalAccessError("Utility Class Constructor"); - } - - /** - * get an instance of the Artemis Client. Create one if necessary (singleton). - * - * @return the instance persisted or created - */ - public static @NotNull RestClientManager getArtemisClientInstance() { - if (artemisClient == null) { - // retrieve settings - ArtemisSettingsState settings = ArtemisSettingsState.getInstance(); - - var tokenLoginManager = new CustomLoginManager( - settings.getArtemisInstanceUrl(), settings.getUsername(), settings.getArtemisPassword()); - - // create new Artemis Instance - var artemisInstance = new RestClientManager(settings.getArtemisInstanceUrl(), tokenLoginManager); - - // try logging in - try { - tokenLoginManager.login(); - } catch (ArtemisClientException clientException) { - ArtemisUtils.displayLoginErrorBalloon( - String.format( - "%s. This will make the grading PlugIn unusable!%n", clientException.getMessage()), - new NotificationAction("Configure...") { - @Override - public void actionPerformed(@NotNull AnActionEvent e, @NotNull Notification notification) { - ShowSettingsUtil.getInstance().showSettingsDialog(null, "IntelliGrade Settings"); - } - }); - } - artemisClient = artemisInstance; - } - return artemisClient; - } - - /** - * Display an error ballon that indicates a login error. - * - * @param msg The message to be displayed. Should describe the login error. - * @param fix A possible fix for the error. Should be non-null. A null value - * is only allowed if a fix is provided in the message. - */ - public static void displayLoginErrorBalloon(String msg, @Nullable AnAction fix) { - // create Balloon notification indicating error & fix - Notification balloon = NotificationGroupManager.getInstance() - .getNotificationGroup("IntelliGrade Notifications") - .createNotification(msg, NotificationType.ERROR) - .setTitle(LOGIN_ERROR_DIALOG_TITLE); - // add fix if available - if (fix != null) { - balloon.addAction(fix); - } - - balloon.notify(null); - } - - /** - * Display an error balloon that indicates a generic error message. - * - * @param balloonContent The message to be displayed in the error balloon - */ - public static void displayGenericErrorBalloon(String balloonContent) { - NotificationGroupManager.getInstance() - .getNotificationGroup("IntelliGrade Notifications") - .createNotification(balloonContent, NotificationType.ERROR) - .setTitle(ArtemisUtils.GENERIC_ARTEMIS_ERROR_TITLE) - .notify(null); - } -} diff --git a/src/main/java/edu/kit/kastel/utils/AssessmentUtils.java b/src/main/java/edu/kit/kastel/utils/AssessmentUtils.java deleted file mode 100644 index 9294d73..0000000 --- a/src/main/java/edu/kit/kastel/utils/AssessmentUtils.java +++ /dev/null @@ -1,113 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.utils; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; - -import edu.kit.kastel.sdq.artemis4j.api.grading.IAnnotation; -import edu.kit.kastel.sdq.artemis4j.grading.config.ExerciseConfig; -import edu.kit.kastel.sdq.artemis4j.grading.config.GradingConfig; -import edu.kit.kastel.sdq.artemis4j.grading.model.annotation.Annotation; -import edu.kit.kastel.sdq.artemis4j.grading.model.annotation.AnnotationException; -import edu.kit.kastel.sdq.artemis4j.grading.model.annotation.AnnotationManagement; -import edu.kit.kastel.state.AssessmentModeHandler; -import edu.kit.kastel.wrappers.AnnotationWithTextSelection; -import edu.kit.kastel.wrappers.PlugInEventListener; -import org.jetbrains.annotations.NotNull; - -/** - * Holds all information and meta on assessments that are required globally. - * Some Information are: - * - all assessment annotations - * - whether assessment mode is currently enabled - */ -public final class AssessmentUtils { - private static List assesmentListeners = new ArrayList<>(); - - private static AnnotationManagement annotationManager = new AnnotationManagement(); - - private static AnnotationWithTextSelection latestAnnotation; - - private static GradingConfig config; - - private static final String ERROR_GETTING_EXERCISE_CONFIG = "IO Error while obtaining an exercise config"; - - private AssessmentUtils() { - throw new IllegalAccessError("Utility Class constructor"); - } - - public static void initExerciseConfig(GradingConfig config) { - AssessmentUtils.config = config; - } - - /** - * Get the exercise config from the saved grading config or Optional#empty if no lock is currently held - * - * @return the exercise config behind the current grading config or Empty if no lock is currently held - */ - public static Optional getConfigAsExerciseCfg() { - AtomicReference> returnValue = new AtomicReference<>(Optional.empty()); - // we can only obtain a config if a lock is currently held - AssessmentModeHandler.getInstance().getAssessmentLock().ifPresent(extendedLockResult -> { - try { - returnValue.set( - Optional.of(AssessmentUtils.config.getExerciseConfig(extendedLockResult.getExercise()))); - } catch (IOException e) { - ArtemisUtils.displayGenericErrorBalloon(ERROR_GETTING_EXERCISE_CONFIG); - } - }); - - return returnValue.get(); - } - - /** - * add an annotation and update all listening components. - * - * @param annotation the annotation to be added - * @throws AnnotationException if adding the annotation fails - */ - public static void addAnnotation(@NotNull AnnotationWithTextSelection annotation) throws AnnotationException { - - AssessmentUtils.annotationManager.addAnnotation( - annotation.getUUID(), - annotation.getMistakeType(), - annotation.getStartLine(), - annotation.getEndLine(), - annotation.getClassFilePath(), - annotation.getCustomMessage().orElse(""), - annotation.getCustomPenalty().orElse(0.0)); - - // add latest annotation so UI can use it - latestAnnotation = annotation; - - // trigger each event so that all assessment views are updated - assesmentListeners.forEach(PlugInEventListener::trigger); - } - - public static void deleteAnnotation(@NotNull Annotation annotation) { - AssessmentUtils.annotationManager.removeAnnotation(annotation.getUUID()); - } - - public static List getAllAnnotations() { - return new ArrayList<>(AssessmentUtils.annotationManager.getAnnotations()); - } - - public static void resetAnnotations() { - AssessmentUtils.annotationManager = new AnnotationManagement(); - } - - public static void registerAssessmentListener(PlugInEventListener assessmentListener) { - AssessmentUtils.assesmentListeners.add(assessmentListener); - } - - public static void resetAssessmentListeners() { - assesmentListeners = new ArrayList<>(); - } - - public static AnnotationWithTextSelection getLatestAnnotation() { - return latestAnnotation; - } -} diff --git a/src/main/java/edu/kit/kastel/wrappers/AnnotationWithTextSelection.java b/src/main/java/edu/kit/kastel/wrappers/AnnotationWithTextSelection.java deleted file mode 100644 index 176772d..0000000 --- a/src/main/java/edu/kit/kastel/wrappers/AnnotationWithTextSelection.java +++ /dev/null @@ -1,38 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.wrappers; - -import com.intellij.openapi.editor.Editor; -import com.intellij.openapi.editor.markup.RangeHighlighter; -import com.intellij.openapi.fileEditor.FileEditorManager; -import com.intellij.openapi.project.Project; -import com.intellij.openapi.project.ProjectManager; -import edu.kit.kastel.sdq.artemis4j.api.grading.IMistakeType; -import edu.kit.kastel.sdq.artemis4j.grading.model.annotation.Annotation; - -public class AnnotationWithTextSelection extends Annotation { - - RangeHighlighter mistakeHighlighter; - - public AnnotationWithTextSelection( - String uuid, - IMistakeType mistakeType, - int startLine, - int endLine, - String fullyClassifiedClassName, - String customMessage, - Double customPenalty, - RangeHighlighter pMistakeHighlighter) { - super(uuid, mistakeType, startLine, endLine, fullyClassifiedClassName, customMessage, customPenalty); - this.mistakeHighlighter = pMistakeHighlighter; - } - - /** - * Deletes the mistake Highlighter associated with this Annotation - */ - public void deleteHighlighter() { - Project currentProject = ProjectManager.getInstance().getOpenProjects()[0]; - Editor editor = FileEditorManager.getInstance(currentProject).getSelectedTextEditor(); - - editor.getMarkupModel().removeHighlighter(mistakeHighlighter); - } -} diff --git a/src/main/java/edu/kit/kastel/wrappers/Displayable.java b/src/main/java/edu/kit/kastel/wrappers/Displayable.java deleted file mode 100644 index df1cf65..0000000 --- a/src/main/java/edu/kit/kastel/wrappers/Displayable.java +++ /dev/null @@ -1,28 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.wrappers; - -/** - * A generic wrapper class to enable overriding the toString method. - * Required because some UI classes are parameterized and automatically call toString. - * - * @param The Type to be wrapped - */ -public abstract class Displayable { - private final T item; - - protected Displayable(T item) { - this.item = item; - } - - /** - * Get the wrapped value. - * - * @return A reference to the wrapped value. Does not copy. - */ - public T getWrappedValue() { - return item; - } - - @Override - public abstract String toString(); -} diff --git a/src/main/java/edu/kit/kastel/wrappers/DisplayableCourse.java b/src/main/java/edu/kit/kastel/wrappers/DisplayableCourse.java deleted file mode 100644 index 688f5f8..0000000 --- a/src/main/java/edu/kit/kastel/wrappers/DisplayableCourse.java +++ /dev/null @@ -1,21 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.wrappers; - -import edu.kit.kastel.sdq.artemis4j.api.artemis.Course; - -/** - * A course that can be displayed in the UI by calling the toString method. - */ -public class DisplayableCourse extends Displayable { - - public DisplayableCourse(Course item) { - super(item); - } - - @Override - public String toString() { - return String.format( - "%s (%s)", - this.getWrappedValue().getTitle(), this.getWrappedValue().getShortName()); - } -} diff --git a/src/main/java/edu/kit/kastel/wrappers/DisplayableExam.java b/src/main/java/edu/kit/kastel/wrappers/DisplayableExam.java deleted file mode 100644 index 2c91228..0000000 --- a/src/main/java/edu/kit/kastel/wrappers/DisplayableExam.java +++ /dev/null @@ -1,28 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.wrappers; - -import edu.kit.kastel.sdq.artemis4j.api.artemis.exam.Exam; -import org.jetbrains.annotations.Nullable; - -/** - * An exam that can be displayed in the UI by calling the toString method. - * The wrapped value is Nullable. If exam is null its String representation - * will be {@value EMPTY_EXAM_REPRESENTATION}. - */ -public class DisplayableExam extends Displayable { - - private static final String EMPTY_EXAM_REPRESENTATION = "No exam selected"; - - public DisplayableExam(@Nullable Exam exam) { - super(exam); - } - - @Override - public String toString() { - if (this.getWrappedValue() != null) { - return this.getWrappedValue().getTitle(); - } else { - return EMPTY_EXAM_REPRESENTATION; - } - } -} diff --git a/src/main/java/edu/kit/kastel/wrappers/DisplayableExercise.java b/src/main/java/edu/kit/kastel/wrappers/DisplayableExercise.java deleted file mode 100644 index 38ec3d1..0000000 --- a/src/main/java/edu/kit/kastel/wrappers/DisplayableExercise.java +++ /dev/null @@ -1,21 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.wrappers; - -import edu.kit.kastel.sdq.artemis4j.api.artemis.Exercise; - -/** - * An exercise that can be displayed in the UI by calling the toString method. - */ -public class DisplayableExercise extends Displayable { - - public DisplayableExercise(Exercise exercise) { - super(exercise); - } - - @Override - public String toString() { - return String.format( - "%s (%s)", - this.getWrappedValue().getTitle(), this.getWrappedValue().getShortName()); - } -} diff --git a/src/main/java/edu/kit/kastel/wrappers/DisplayableMistakeType.java b/src/main/java/edu/kit/kastel/wrappers/DisplayableMistakeType.java deleted file mode 100644 index 64ea310..0000000 --- a/src/main/java/edu/kit/kastel/wrappers/DisplayableMistakeType.java +++ /dev/null @@ -1,19 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.wrappers; - -import com.intellij.DynamicBundle; -import edu.kit.kastel.sdq.artemis4j.api.grading.IMistakeType; - -public class DisplayableMistakeType extends Displayable { - - private static final String LOCALE = DynamicBundle.getLocale().getLanguage(); - - public DisplayableMistakeType(IMistakeType item) { - super(item); - } - - @Override - public String toString() { - return super.getWrappedValue().getButtonText(LOCALE); - } -} diff --git a/src/main/java/edu/kit/kastel/wrappers/ExtendedLockResult.java b/src/main/java/edu/kit/kastel/wrappers/ExtendedLockResult.java deleted file mode 100644 index c964714..0000000 --- a/src/main/java/edu/kit/kastel/wrappers/ExtendedLockResult.java +++ /dev/null @@ -1,40 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.wrappers; - -import edu.kit.kastel.sdq.artemis4j.api.ArtemisClientException; -import edu.kit.kastel.sdq.artemis4j.api.artemis.Exercise; -import edu.kit.kastel.sdq.artemis4j.api.artemis.assessment.LockResult; -import edu.kit.kastel.sdq.artemis4j.api.artemis.assessment.Submission; -import edu.kit.kastel.utils.ArtemisUtils; - -public class ExtendedLockResult { - - private final Integer submissionId; - private final Exercise exercise; - - private final LockResult submissionLock; - - public ExtendedLockResult(Integer submissionId, Exercise exercise, LockResult submissionLock) { - this.submissionId = submissionId; - this.exercise = exercise; - this.submissionLock = submissionLock; - } - - public Integer getLockedSubmissionId() { - return submissionId; - } - - public Exercise getExercise() { - return exercise; - } - - public Submission getSubmission() throws ArtemisClientException { - return ArtemisUtils.getArtemisClientInstance() - .getSubmissionArtemisClient() - .getSubmissionById(exercise, submissionId); - } - - public LockResult getSubmissionLock() { - return submissionLock; - } -} diff --git a/src/main/java/edu/kit/kastel/wrappers/PlugInEventListener.java b/src/main/java/edu/kit/kastel/wrappers/PlugInEventListener.java deleted file mode 100644 index b1f821f..0000000 --- a/src/main/java/edu/kit/kastel/wrappers/PlugInEventListener.java +++ /dev/null @@ -1,10 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.wrappers; - -/** - * represents anything that can react to a plugIn internal event. - */ -public interface PlugInEventListener extends java.util.EventListener { - - void trigger(); -} diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 885e2b3..826de5f 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -12,30 +12,33 @@ on how to target different products --> com.intellij.modules.platform com.intellij.modules.java + org.jetbrains.idea.maven + instance="edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettings" + id="edu.kit.kastel.sdq.intelligrade.extensions.settings.ArtemisSettings" + displayName="Artemis (IntelliGrade)"/> - - + + + + - diff --git a/src/main/resources/guiStrings.properties b/src/main/resources/guiStrings.properties deleted file mode 100644 index e1053c9..0000000 --- a/src/main/resources/guiStrings.properties +++ /dev/null @@ -1,37 +0,0 @@ -# -# Created by JFormDesigner on Thu Jul 13 01:06:42 CEST 2023 -# - -AnnotationColor=Color for annotations -AssesmentViewContent.btnGradingRound2.text=Start Grading Round 2 -AssesmentViewContent.btnGradingRound1.text=Start Grading Round 1 -AssesmentViewContent.btnSaveAssessment.text=Save Assessment -AssesmentViewContent.submitAssesmentBtn.text=Submit Assessment -AssesmentViewContent.button3.text=Reload Assessment -AssesmentViewContent.button4.text=Close Assesment -AssesmentViewContent.button5.text=Re-run Autograder -AssesmentViewContent.button6.text=Refresh submissions -AssesmentViewContent.button7.text=Reload Assessment -AssesmentViewContent.label1.text=Course -AssesmentViewContent.label2.text=Exam -AssesmentViewContent.label3.text=Exercise -AssesmentViewContent.label4.text=Test Results from Artemis -AssesmentViewContent.label7.text=Submission -AssesmentViewContent.label8.text=Statistics\: -AssesmentViewContent.label9.text=Currently assessing\: -AssesmentViewContent.GradingPanel.tab.title=Grading -AssesmentViewContent.TestResultsPanel.tab.title=Test Results -AssesmentViewContent.panel3.border=Backlog -AssesmentViewContent.panel5.border=Metrics -AssesmentViewContent.generalPanel.border=General -AssesmentViewContent.assessmentPanel.border=Assessment -DebugMenuContent.btnLogin.text=test Connection -DebugMenuContent.btnLogout.text=log out -DebugMenuContent.artemisUrlInput.text=https\://artemis-test.ipd.kit.edu/ -DebugMenuContent.label4.text=Artemis URL -DebugMenuContent.loggedInLabel.text=false -DebugMenuContent.label3.text=Logged in\: -DebugMenuContent.label5.text=Columns per rating group -DebugMenuContent.label6.text=text -LabelPwdField=Password -labelUnameField=Username diff --git a/src/main/resources/guiStrings_de.properties b/src/main/resources/guiStrings_de.properties deleted file mode 100644 index 971e1da..0000000 --- a/src/main/resources/guiStrings_de.properties +++ /dev/null @@ -1,25 +0,0 @@ -# -# Created by JFormDesigner on Thu Jul 13 01:07:12 CEST 2023 -# - -AnnotationColor=Farbe der Anmerkungen -AssesmentViewContent.GradingPanel.tab.title=Bewertung -AssesmentViewContent.btnGradingRound1.text=Neue Erstbewertung -AssesmentViewContent.btnGradingRound2.text=Neue Zweitbewertung -AssesmentViewContent.btnSaveAssessment.text=Bewertung speichern -AssesmentViewContent.submitAssesmentBtn.text=Bewertung abgeben -AssesmentViewContent.button3.text=Bewertung neu laden -AssesmentViewContent.button4.text=Bewertung schlieen -AssesmentViewContent.button5.text=Autograder neu ausfhren -AssesmentViewContent.label1.text=Kurs -AssesmentViewContent.label2.text=Klausur -AssesmentViewContent.label3.text=Aufgabe -AssesmentViewContent.label4.text=Testergebnisse -AssesmentViewContent.TestResultsPanel.tab.title=Testergebnisse -AssesmentViewContent.assessmentPanel.border=Bewertung -DebugMenuContent.btnLogin.text=Verbindung testen -DebugMenuContent.btnLogout.text=ausloggen -DebugMenuContent.label3.text=Eingeloggt? -DebugMenuContent.loggedInLabel.text=Nein -LabelPwdField=Passwort -labelUnameField=Nutzername diff --git a/src/main/resources/guiStrings_en.properties b/src/main/resources/guiStrings_en.properties deleted file mode 100644 index 91d8f8b..0000000 --- a/src/main/resources/guiStrings_en.properties +++ /dev/null @@ -1,5 +0,0 @@ -# -# Created by JFormDesigner on Thu Jul 13 01:07:12 CEST 2023 -# - -AnnotationColor=Color for annotations diff --git a/src/main/resources/icons/annotation1.svg b/src/main/resources/icons/annotation1.svg new file mode 100644 index 0000000..79fe4d7 --- /dev/null +++ b/src/main/resources/icons/annotation1.svg @@ -0,0 +1,78 @@ + + + + + + + + + + + diff --git a/src/main/resources/icons/annotation2.svg b/src/main/resources/icons/annotation2.svg new file mode 100644 index 0000000..c885d9a --- /dev/null +++ b/src/main/resources/icons/annotation2.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + 2 + diff --git a/src/main/resources/icons/annotation3.svg b/src/main/resources/icons/annotation3.svg new file mode 100644 index 0000000..5ce0db1 --- /dev/null +++ b/src/main/resources/icons/annotation3.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + 3 + diff --git a/src/main/resources/icons/annotation4.svg b/src/main/resources/icons/annotation4.svg new file mode 100644 index 0000000..3943dff --- /dev/null +++ b/src/main/resources/icons/annotation4.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + 4 + + + diff --git a/src/test/java/edu/kit/kastel/ArchitectureTest.java b/src/test/java/edu/kit/kastel/ArchitectureTest.java new file mode 100644 index 0000000..29f8604 --- /dev/null +++ b/src/test/java/edu/kit/kastel/ArchitectureTest.java @@ -0,0 +1,27 @@ +/* Licensed under EPL-2.0 2024. */ +package edu.kit.kastel; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; + +@AnalyzeClasses(packages = "edu.kit.kastel.sdq.intelligrade") +class ArchitectureTest { + @ArchTest + static final ArchRule noForEachInCollectionsOrStream = noClasses() + .should() + .callMethod(Stream.class, "forEach", Consumer.class) + .orShould() + .callMethod(Stream.class, "forEachOrdered", Consumer.class) + .orShould() + .callMethod(List.class, "forEach", Consumer.class) + .orShould() + .callMethod(List.class, "forEachOrdered", Consumer.class) + .because("Lambdas should be functional. ForEach is typically used for side-effects."); +} diff --git a/src/test/java/edu/kit/kastel/extensions/settings/ArtemisSettingsStateTest.java b/src/test/java/edu/kit/kastel/extensions/settings/ArtemisSettingsStateTest.java deleted file mode 100644 index cf845ff..0000000 --- a/src/test/java/edu/kit/kastel/extensions/settings/ArtemisSettingsStateTest.java +++ /dev/null @@ -1,17 +0,0 @@ -/* Licensed under EPL-2.0 2024. */ -package edu.kit.kastel.extensions.settings; - -import com.intellij.testFramework.fixtures.BasePlatformTestCase; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -@RunWith(JUnit4.class) -class ArtemisSettingsStateTest extends BasePlatformTestCase { - - @Test - void alwaysTrueTest() { - Assertions.assertTrue(true); - } -}