IFT-265 Add lesson about Local History feature

GitOrigin-RevId: 4438df6ef2c2fa728b604969d13d07a4f6e49808
This commit is contained in:
Konstantin Hudyakov
2021-08-31 16:10:13 +00:00
committed by intellij-monorepo-bot
parent 458f9d8ce9
commit 8e91f22b20
5 changed files with 390 additions and 8 deletions
@@ -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")),
@@ -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=<ide/> can help you correct code formatting with just one action. \
to reformat the selected code fragment with {0}.
@@ -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 <C : Container> ContainerFixture<C>.jMenuItem(timeout: Timeout = defaultTimeout,
finderFunction: (JMenuItem) -> Boolean): JMenuItemFixture {
val item = findComponentWithTimeout(timeout) { item: JMenuItem -> finderFunction(item) }
return JMenuItemFixture(robot(), item)
}
// Modified copy-paste
fun <C : Container> ContainerFixture<C>.jListContains(partOfItem: String? = null, timeout: Timeout = defaultTimeout): JListFixture {
return generalListFinder(timeout, partOfItem) { element, p -> element.contains(p) }
@@ -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<Boolean>()
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)
}
}
}
@@ -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")),