From 8e91f22b208543bd04f48d233d7e7d9442951ed9 Mon Sep 17 00:00:00 2001 From: Konstantin Hudyakov Date: Tue, 17 Aug 2021 20:18:10 +0300 Subject: [PATCH] IFT-265 Add lesson about Local History feature GitOrigin-RevId: 4438df6ef2c2fa728b604969d13d07a4f6e49808 --- .../intellij/java/ift/JavaLearningCourse.kt | 2 + .../res/messages/LessonsBundle.properties | 14 + .../src/training/dsl/TaskTestContext.kt | 16 +- .../general/assistance/LocalHistoryLesson.kt | 364 ++++++++++++++++++ .../python/ift/PythonLearningCourse.kt | 2 + 5 files changed, 390 insertions(+), 8 deletions(-) create mode 100644 plugins/ide-features-trainer/src/training/learn/lesson/general/assistance/LocalHistoryLesson.kt diff --git a/java/java-features-trainer/src/com/intellij/java/ift/JavaLearningCourse.kt b/java/java-features-trainer/src/com/intellij/java/ift/JavaLearningCourse.kt index 9b14a5da8237..dc573b17151f 100644 --- a/java/java-features-trainer/src/com/intellij/java/ift/JavaLearningCourse.kt +++ b/java/java-features-trainer/src/com/intellij/java/ift/JavaLearningCourse.kt @@ -21,6 +21,7 @@ import training.learn.course.LearningModule import training.learn.course.LessonType import training.learn.lesson.general.* import training.learn.lesson.general.assistance.CodeFormatLesson +import training.learn.lesson.general.assistance.LocalHistoryLesson import training.learn.lesson.general.assistance.ParameterInfoLesson import training.learn.lesson.general.assistance.QuickPopupsLesson import training.learn.lesson.general.navigation.FindInFilesLesson @@ -87,6 +88,7 @@ class JavaLearningCourse : LearningCourseBase(JavaLanguage.INSTANCE.id) { moduleType = LessonType.SINGLE_EDITOR) { fun ls(sampleName: String) = loadSample("CodeAssistance/$sampleName") listOf( + LocalHistoryLesson(), CodeFormatLesson(ls("CodeFormat.java.sample"), true), ParameterInfoLesson(ls("ParameterInfo.java.sample")), QuickPopupsLesson(ls("QuickPopups.java.sample")), diff --git a/plugins/ide-features-trainer/res/messages/LessonsBundle.properties b/plugins/ide-features-trainer/res/messages/LessonsBundle.properties index 48a0ec380bd3..bf181711df98 100644 --- a/plugins/ide-features-trainer/res/messages/LessonsBundle.properties +++ b/plugins/ide-features-trainer/res/messages/LessonsBundle.properties @@ -175,6 +175,20 @@ refactoring.menu.finish.refactoring=To complete refactoring, you need to choose code.assistance.module.name=Code assistance code.assistance.module.description=Learn how to format your code, obtain parameter info, and preview quick popups. +local.history.lesson.name=Restore removed code +local.history.remove.code=In this lesson, you will learn how to restore code using {0}. Press {1} to remove the selected code fragment. +local.history.file.modification.progress=Modifying file +local.history.editor.hint=File modification is disabled for the current step. +local.history.dialog.message=The IDE simulates the process of editing a file. Please wait until it completes. +local.history.imagine.restore=Let''s imagine that you need to restore the code that you deleted in the beginning. The {0} action cannot help you because there were a lot of other changes since that, and you might lose them. +local.history.invoke.context.menu=With {0} you can restore the deleted code with a couple of clicks. Right-click the editor to invoke the context menu. +local.history.show.history=Choose {0} \u2192 {1}. +local.history.select.revision=The left pane of the {0} dialog displays a list of saved revisions of the current file. The project state is automatically saved to {1} when you edit code, run tests, and perform other actions. For example, select the highlighted revision. +local.history.restore.code=The right-hand pane shows a diff viewer that displays the differences between each revision and the current one. You can see the code in this revision that has been deleted at the beginning of the lesson. Click the chevron button {0} to restore the code fragment. +local.history.restore.code.balloon=Click to restore the code fragment +local.history.close.window=Close the dialog to return to the changed code. Press {0}. +local.history.congratulations=Great! You have restored your code fragment! + code.format.lesson.name=Code format code.format.reformat.selection= can help you correct code formatting with just one action. \ to reformat the selected code fragment with {0}. diff --git a/plugins/ide-features-trainer/src/training/dsl/TaskTestContext.kt b/plugins/ide-features-trainer/src/training/dsl/TaskTestContext.kt index b493edeadc41..0b02a95435cd 100644 --- a/plugins/ide-features-trainer/src/training/dsl/TaskTestContext.kt +++ b/plugins/ide-features-trainer/src/training/dsl/TaskTestContext.kt @@ -15,10 +15,7 @@ import org.fest.swing.driver.BasicJListCellReader import org.fest.swing.driver.ComponentDriver import org.fest.swing.exception.ComponentLookupException import org.fest.swing.exception.WaitTimedOutError -import org.fest.swing.fixture.AbstractComponentFixture -import org.fest.swing.fixture.ContainerFixture -import org.fest.swing.fixture.JButtonFixture -import org.fest.swing.fixture.JListFixture +import org.fest.swing.fixture.* import org.fest.swing.timing.Condition import org.fest.swing.timing.Pause import org.fest.swing.timing.Timeout @@ -29,10 +26,7 @@ import java.awt.Component import java.awt.Container import java.util.* import java.util.concurrent.TimeUnit -import javax.swing.JButton -import javax.swing.JDialog -import javax.swing.JLabel -import javax.swing.JList +import javax.swing.* @LearningDsl class TaskTestContext(rt: TaskRuntimeContext) : TaskRuntimeContext(rt) { @@ -101,6 +95,12 @@ class TaskTestContext(rt: TaskRuntimeContext) : TaskRuntimeContext(rt) { return ActionButtonFixture(robot(), actionButton) } + fun ContainerFixture.jMenuItem(timeout: Timeout = defaultTimeout, + finderFunction: (JMenuItem) -> Boolean): JMenuItemFixture { + val item = findComponentWithTimeout(timeout) { item: JMenuItem -> finderFunction(item) } + return JMenuItemFixture(robot(), item) + } + // Modified copy-paste fun ContainerFixture.jListContains(partOfItem: String? = null, timeout: Timeout = defaultTimeout): JListFixture { return generalListFinder(timeout, partOfItem) { element, p -> element.contains(p) } diff --git a/plugins/ide-features-trainer/src/training/learn/lesson/general/assistance/LocalHistoryLesson.kt b/plugins/ide-features-trainer/src/training/learn/lesson/general/assistance/LocalHistoryLesson.kt new file mode 100644 index 000000000000..58d80cb4b78f --- /dev/null +++ b/plugins/ide-features-trainer/src/training/learn/lesson/general/assistance/LocalHistoryLesson.kt @@ -0,0 +1,364 @@ +// Copyright 2000-2021 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file. +package training.learn.lesson.general.assistance + +import com.intellij.CommonBundle +import com.intellij.icons.AllIcons +import com.intellij.idea.ActionsBundle +import com.intellij.openapi.actionSystem.impl.ActionMenu +import com.intellij.openapi.actionSystem.impl.ActionMenuItem +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.invokeAndWaitIfNeeded +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorModificationUtil +import com.intellij.openapi.editor.LogicalPosition +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.ex.EditorGutterComponentEx +import com.intellij.openapi.editor.impl.EditorComponentImpl +import com.intellij.openapi.fileEditor.FileDocumentManager +import com.intellij.openapi.progress.runBackgroundableTask +import com.intellij.openapi.project.Project +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.ui.popup.Balloon +import com.intellij.ui.components.JBLoadingPanel +import com.intellij.ui.components.JBLoadingPanelListener +import com.intellij.ui.components.JBScrollPane +import com.intellij.ui.table.JBTable +import com.intellij.util.DocumentUtil +import com.intellij.util.concurrency.annotations.RequiresBackgroundThread +import com.intellij.util.ui.UIUtil +import org.fest.swing.core.MouseButton +import org.fest.swing.data.TableCell +import org.fest.swing.fixture.JTableFixture +import org.jetbrains.annotations.Nls +import training.FeaturesTrainerIcons +import training.dsl.* +import training.dsl.LessonUtil.restoreIfModifiedOrMoved +import training.learn.LearnBundle +import training.learn.LessonsBundle +import training.learn.course.KLesson +import training.learn.course.LessonProperties +import training.learn.course.LessonType +import training.learn.lesson.LessonManager +import training.ui.LearningUiHighlightingManager +import training.ui.LearningUiHighlightingManager.HighlightingOptions +import training.ui.LearningUiUtil +import java.awt.Component +import java.awt.Point +import java.awt.Rectangle +import java.util.concurrent.CompletableFuture +import javax.swing.JFrame + +class LocalHistoryLesson : KLesson("CodeAssistance.LocalHistory", LessonsBundle.message("local.history.lesson.name")) { + override val languageId = "yaml" + override val lessonType = LessonType.SCRATCH + override val properties = LessonProperties(availableSince = "212.5284") + + private val lineToDelete = 14 + private val revisionInd = 2 + + private val sample = parseLessonSample(""" + cat: + name: Pelmen + gender: male + breed: sphinx + fur_type: hairless + fur_pattern: solid + fur_colors: [ white ] + tail_length: long + eyes_colors: [ green ] + + favourite_things: + - three plaids + - pile of clothes + - castle of boxes + - toys scattered all over the place + + behavior: + - play: + condition: boring + actions: + - bring one of the favourite toys to the human + - run all over the house + - eat: + condition: want to eat + actions: + - shout to the whole house + - sharpen claws by the sofa + - wake up a human in the middle of the night""".trimIndent()) + + private val textToDelete = """ + | - play: + | condition: boring + | actions: + | - bring one of the favourite toys to the human + | - run all over the house + """.trimMargin() + + private val textAfterDelete = """ + | behavior: + | + | - eat: + """.trimMargin() + + private val textToAppend = """ + | - sleep: + | condition: want to sleep + | action: + | - bury himself in a human's blanket + | - bury himself in a favourite plaid + """.trimMargin() + + override val lessonContent: LessonContext.() -> Unit = { + prepareSample(sample) + + caret(textToDelete, select = true) + + prepareRuntimeTask(ModalityState.NON_MODAL) { + FileDocumentManager.getInstance().saveDocument(editor.document) + } + + val localHistoryActionText = ActionsBundle.groupText("LocalHistory").dropMnemonic() + task { + text(LessonsBundle.message("local.history.remove.code", strong(localHistoryActionText), action("EditorDelete"))) + stateCheck { + editor.document.charsSequence.contains(textAfterDelete) + } + restoreIfModifiedOrMoved() + test { invokeActionViaShortcut("DELETE") } + } + + setEditorHint(LessonsBundle.message("local.history.editor.hint")) + + waitBeforeContinue(500) + + prepareRuntimeTask { + if (!TaskTestContext.inTestMode) { + val userDecision = Messages.showOkCancelDialog( + LessonsBundle.message("local.history.dialog.message"), + LessonsBundle.message("recent.files.dialog.title"), + CommonBundle.message("button.ok"), + LearnBundle.message("learn.stop.lesson"), + FeaturesTrainerIcons.Img.PluginIcon + ) + if (userDecision != Messages.OK) { + LessonManager.instance.stopLesson() + } + } + } + + modifyFile() + + lateinit var invokeMenuTaskId: TaskContext.TaskId + task { + invokeMenuTaskId = taskId + text(LessonsBundle.message("local.history.imagine.restore", strong(ActionsBundle.message("action.\$Undo.text")))) + text(LessonsBundle.message("local.history.invoke.context.menu", strong(localHistoryActionText))) + triggerByUiComponentAndHighlight { ui: ActionMenu -> + ui.text?.contains(localHistoryActionText) == true + } + test { + ideFrame { robot().rightClick(editor.component) } + } + } + + task("LocalHistory.ShowHistory") { + val showHistoryActionText = ActionsBundle.actionText(it).dropMnemonic() + text(LessonsBundle.message("local.history.show.history", strong(localHistoryActionText), strong(showHistoryActionText))) + triggerByUiComponentAndHighlight(clearPreviousHighlights = false) { ui: ActionMenuItem -> + ui.text == showHistoryActionText + } + trigger(it) + restoreByUi() + test { + ideFrame { + jMenuItem { item -> item.text?.contains(localHistoryActionText) == true }.click() + jMenuItem { item -> item.text == showHistoryActionText }.click() + } + } + } + + var revisionsTable: JBTable? = null + task { + triggerByPartOfComponent { ui: JBTable -> + if (checkInsideLocalHistoryFrame(ui)) { + revisionsTable = ui + ui.getCellRect(revisionInd, 0, false) + } + else null + } + } + + lateinit var selectRevisionTaskId: TaskContext.TaskId + task { + selectRevisionTaskId = taskId + text(LessonsBundle.message("local.history.select.revision", strong(localHistoryActionText), strong(localHistoryActionText))) + val step = CompletableFuture() + addStep(step) + triggerByUiComponentAndHighlight(false, false, clearPreviousHighlights = false) l@{ ui: JBLoadingPanel -> + if (!checkInsideLocalHistoryFrame(ui)) return@l false + ui.addListener(object : JBLoadingPanelListener { + override fun onLoadingStart() { + // do nothing + } + + override fun onLoadingFinish() { + val revisions = revisionsTable ?: return + if (revisions.selectionModel.selectedIndices.let { it.size == 1 && it[0] == revisionInd }) { + ui.removeListener(this) + step.complete(true) + } + } + }) + true + } + restoreByUi(invokeMenuTaskId, delayMillis = defaultRestoreDelay) + test { + ideFrame { + Thread.sleep(1000) + val table = revisionsTable ?: error("revisionsTable is not initialized") + JTableFixture(robot(), table).click(TableCell.row(revisionInd).column(0), MouseButton.LEFT_BUTTON) + } + } + } + + task { + triggerByPartOfComponent { ui: EditorGutterComponentEx -> findDiffGutterRect(ui) } + } + + task { + text(LessonsBundle.message("local.history.restore.code", icon(AllIcons.Diff.ArrowRight))) + text(LessonsBundle.message("local.history.restore.code.balloon"), + LearningBalloonConfig(Balloon.Position.below, 0, cornerToPointerDistance = 50)) + stateCheck { + editor.document.charsSequence.contains(textToDelete) + } + restoreByUi(invokeMenuTaskId) + restoreState(selectRevisionTaskId) l@{ + val revisions = revisionsTable ?: return@l false + revisions.selectionModel.selectedIndices.let { it.size != 1 || it[0] != revisionInd } + } + test { + ideFrame { + val gutterComponent = previous.ui as? EditorGutterComponentEx ?: error("Failed to find gutter component") + val gutterRect = findDiffGutterRect(gutterComponent) ?: error("Failed to find required gutter") + robot().click(gutterComponent, Point(gutterRect.x + gutterRect.width / 2, gutterRect.y + gutterRect.height / 2)) + } + } + } + + task { + before { LearningUiHighlightingManager.clearHighlights() } + text(LessonsBundle.message("local.history.close.window", action("EditorEscape"))) + stateCheck { + previous.ui?.isShowing != true + } + test { + Thread.sleep(500) + invokeActionViaShortcut("ESCAPE") + } + } + + setEditorHint(null) + + text(LessonsBundle.message("local.history.congratulations")) + } + + override fun onLessonEnd(project: Project, lessonPassed: Boolean) { + if (!lessonPassed) return + val editorComponent = LearningUiUtil.findComponentOrNull(EditorComponentImpl::class.java) ?: error("Failed to find editor component") + val lines = textToDelete.lines() + val rightColumn = lines.maxOf { it.length } + LearningUiHighlightingManager.highlightPartOfComponent(editorComponent, HighlightingOptions(highlightInside = false)) { + val editor = editorComponent.editor + val textToFind = lines[0].trim() + val offset = editor.document.charsSequence.indexOf(textToFind) + if (offset == -1) error("Failed to find '$textToFind' in the editor") + val leftPosition = editor.offsetToLogicalPosition(offset) + val leftPoint = editor.logicalPositionToXY(leftPosition) + val rightPoint = editor.logicalPositionToXY(LogicalPosition(leftPosition.line, rightColumn)) + Rectangle(leftPoint.x - 3, leftPoint.y, rightPoint.x - leftPoint.x + 6, editor.lineHeight * lines.size) + } + } + + private fun findDiffGutterRect(ui: EditorGutterComponentEx): Rectangle? { + val editor = findEditorForGutter(ui) ?: return null + val offset = editor.document.charsSequence.indexOf(textToDelete) + return if (offset != -1) { + val lineIndex = editor.document.getLineNumber(offset) + invokeAndWaitIfNeeded { + val y = editor.visualLineToY(lineIndex) + Rectangle(ui.width - ui.whitespaceSeparatorOffset, y, ui.width - 26, editor.lineHeight) + } + } + else null + } + + private fun findEditorForGutter(component: EditorGutterComponentEx): Editor? { + val scrollPane = UIUtil.getParentOfType(JBScrollPane::class.java, component) ?: return null + return UIUtil.findComponentOfType(scrollPane, EditorComponentImpl::class.java)?.editor + } + + private fun TaskRuntimeContext.checkInsideLocalHistoryFrame(component: Component): Boolean { + val frame = UIUtil.getParentOfType(JFrame::class.java, component) + return frame?.title == virtualFile.path + } + + // If message is null it will remove the existing hint and allow file modification + private fun LessonContext.setEditorHint(@Nls message: String?) { + prepareRuntimeTask { + EditorModificationUtil.setReadOnlyHint(editor, message) + (editor as EditorEx).isViewer = message != null + } + } + + private fun LessonContext.modifyFile() { + task { + addFutureStep { + val editor = this.editor + runBackgroundableTask(LessonsBundle.message("local.history.file.modification.progress"), project, cancellable = false) { + val document = editor.document + invokeAndWaitIfNeeded { FileDocumentManager.getInstance().saveDocument(document) } + removeLineWithAnimation(editor) + invokeAndWaitIfNeeded { FileDocumentManager.getInstance().saveDocument(document) } + Thread.sleep(50) + insertStringWithAnimation(editor, textToAppend, editor.document.textLength) + taskInvokeLater { + editor.caretModel.moveToOffset(document.textLength) + FileDocumentManager.getInstance().saveDocument(document) + completeStep() + } + } + } + } + } + + @RequiresBackgroundThread + private fun removeLineWithAnimation(editor: Editor) { + val document = editor.document + val startOffset = document.getLineStartOffset(lineToDelete) + val endOffset = document.getLineEndOffset(lineToDelete) + for (ind in endOffset downTo startOffset) { + invokeAndWaitIfNeeded { + DocumentUtil.writeInRunUndoTransparentAction { + editor.caretModel.moveToOffset(ind) + document.deleteString(ind - 1, ind) + } + } + Thread.sleep(10) + } + } + + @RequiresBackgroundThread + private fun insertStringWithAnimation(editor: Editor, text: String, offset: Int) { + val document = editor.document + for (ind in text.indices) { + invokeAndWaitIfNeeded { + DocumentUtil.writeInRunUndoTransparentAction { + document.insertString(offset + ind, text[ind].toString()) + editor.caretModel.moveToOffset(offset + ind) + } + } + Thread.sleep(10) + } + } +} \ No newline at end of file diff --git a/python/python-features-trainer/src/com/jetbrains/python/ift/PythonLearningCourse.kt b/python/python-features-trainer/src/com/jetbrains/python/ift/PythonLearningCourse.kt index a4cef3a091c4..513ca3be250c 100644 --- a/python/python-features-trainer/src/com/jetbrains/python/ift/PythonLearningCourse.kt +++ b/python/python-features-trainer/src/com/jetbrains/python/ift/PythonLearningCourse.kt @@ -27,6 +27,7 @@ import training.learn.course.LearningModule import training.learn.course.LessonType import training.learn.lesson.general.* import training.learn.lesson.general.assistance.CodeFormatLesson +import training.learn.lesson.general.assistance.LocalHistoryLesson import training.learn.lesson.general.assistance.ParameterInfoLesson import training.learn.lesson.general.assistance.QuickPopupsLesson import training.learn.lesson.general.navigation.FindInFilesLesson @@ -98,6 +99,7 @@ class PythonLearningCourse : LearningCourseBase(PythonLanguage.INSTANCE.id) { moduleType = LessonType.SINGLE_EDITOR) { fun ls(sampleName: String) = loadSample("CodeAssistance/$sampleName") listOf( + LocalHistoryLesson(), CodeFormatLesson(ls("CodeFormat.py.sample"), true), ParameterInfoLesson(ls("ParameterInfo.py.sample")), QuickPopupsLesson(ls("QuickPopups.py.sample")),