diff --git a/platform/bookmarks/src/com/intellij/ide/bookmark/providers/LineBookmarkProvider.kt b/platform/bookmarks/src/com/intellij/ide/bookmark/providers/LineBookmarkProvider.kt index 24e591997715..d82fb542a25b 100644 --- a/platform/bookmarks/src/com/intellij/ide/bookmark/providers/LineBookmarkProvider.kt +++ b/platform/bookmarks/src/com/intellij/ide/bookmark/providers/LineBookmarkProvider.kt @@ -20,8 +20,8 @@ import com.intellij.openapi.diagnostic.Logger import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.EditorFactory +import com.intellij.openapi.editor.event.BulkAwareDocumentListener import com.intellij.openapi.editor.event.DocumentEvent -import com.intellij.openapi.editor.event.DocumentListener import com.intellij.openapi.editor.impl.event.DocumentEventImpl import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileDocumentManagerListener @@ -187,12 +187,29 @@ class LineBookmarkProvider(private val project: Project, coroutineScope: Corouti private val TreePath.asVirtualFile get() = TreeUtil.getLastUserObject(ProjectViewNode::class.java, this)?.virtualFile - private fun afterDocumentChange(document: Document, event: DocumentEvent? = null) { + private inner class BookmarkDocumentListener : BulkAwareDocumentListener { + override fun beforeDocumentChangeNonBulk(event: DocumentEvent): Unit = captureExpectedText(event.document) + override fun bulkUpdateStarting(document: Document): Unit = captureExpectedText(document) + + override fun documentChangedNonBulk(event: DocumentEvent): Unit = afterDocumentChange(event.document, event) + override fun bulkUpdateFinished(document: Document): Unit = afterDocumentChange(document, null) + + override fun documentChanged(event: DocumentEvent) { + if (event.isWholeTextReplaced && event.document.isInBulkUpdate) { + afterDocumentChange(event.document, event) + } + else { + super.documentChanged(event) + } + } + } + + private fun afterDocumentChange(document: Document, event: DocumentEvent?) { val file = FileDocumentManager.getInstance().getFile(document) ?: return if (file is LightVirtualFile) return val manager = BookmarksManager.getInstance(project) ?: return - if (event != null && (event.isWholeTextReplaced || document.isInBulkUpdate)) { + if (event != null && event.isWholeTextReplaced) { if (updateBookmarksUsingDiffMapping(file, document, event, manager)) { return } @@ -200,11 +217,15 @@ class LineBookmarkProvider(private val project: Project, coroutineScope: Corouti validateBookmarksUsingRangeMarker(file, manager) - if (event != null && !event.isWholeTextReplaced && !document.isInBulkUpdate) { + if (event == null || !event.isWholeTextReplaced) { bookmarkEditQueue.queue(file) + if (hasInvalidBookmarks(file, manager)) requestValidation() } } + private fun hasInvalidBookmarks(file: VirtualFile, manager: BookmarksManager): Boolean = + manager.bookmarks.any { it is InvalidBookmark && it.url == file.url } + private fun validateBookmarksUsingRangeMarker(file: VirtualFile, manager: BookmarksManager) { val map = sortedMapOf(compareBy { it.line }) val set = hashSetOf() @@ -468,7 +489,8 @@ class LineBookmarkProvider(private val project: Project, coroutineScope: Corouti } } - private fun captureExpectedTextBeforeReload(file: VirtualFile, document: Document) { + private fun captureExpectedText(document: Document) { + val file = FileDocumentManager.getInstance().getFile(document) ?: return val manager = BookmarksManager.getInstance(project) ?: return for (bookmark in manager.bookmarks) { if (bookmark is LineBookmarkImpl && bookmark.file == file) { @@ -514,16 +536,7 @@ class LineBookmarkProvider(private val project: Project, coroutineScope: Corouti init { if (!project.isDefault) { val multicaster = EditorFactory.getInstance().eventMulticaster - multicaster.addDocumentListener(object : DocumentListener { - override fun beforeDocumentChange(event: DocumentEvent) { - val file = FileDocumentManager.getInstance().getFile(event.document) ?: return - captureExpectedTextBeforeReload(file, event.document) - } - - override fun documentChanged(event: DocumentEvent) { - this@LineBookmarkProvider.afterDocumentChange(event.document, event) - } - }, project) + multicaster.addDocumentListener(BookmarkDocumentListener(), project) VirtualFileManager.getInstance().addAsyncFileListenerBackgroundable({ events -> this@LineBookmarkProvider.prepareChange(events) }, project) project.messageBus.connect().subscribe(FileDocumentManagerListener.TOPIC, object : FileDocumentManagerListener { override fun beforeDocumentSaving(document: Document) { diff --git a/platform/platform-tests/testSrc/com/intellij/ide/bookmarks/BookmarkManagerTest.java b/platform/platform-tests/testSrc/com/intellij/ide/bookmarks/BookmarkManagerTest.java index 0ca01d9747b0..be786d9001c5 100644 --- a/platform/platform-tests/testSrc/com/intellij/ide/bookmarks/BookmarkManagerTest.java +++ b/platform/platform-tests/testSrc/com/intellij/ide/bookmarks/BookmarkManagerTest.java @@ -29,6 +29,7 @@ import com.intellij.psi.PsiDocumentListener; import com.intellij.psi.PsiDocumentManager; import com.intellij.testFramework.LeakHunter; import com.intellij.testFramework.PlatformTestUtil; +import com.intellij.util.DocumentUtil; import org.intellij.lang.annotations.Language; import org.jetbrains.annotations.NonNls; import org.jetbrains.annotations.NotNull; @@ -168,6 +169,98 @@ public class BookmarkManagerTest extends AbstractEditorTest { assertEquals(2, ((LineBookmark)afterRestore.getFirst()).getLine()); } + public void testWholeTextReplaceInsideBulkUpdateStillReanchorsByLineText() { + @NonNls String before = "line 0\nline 1\nbookmarked\nline 3\n"; + @NonNls String after = "inserted\nline 0\nline 1\nbookmarked\nline 3\n"; + init(before, PlainTextFileType.INSTANCE); + Document document = getEditor().getDocument(); + + addBookmark(2); + + WriteCommandAction.writeCommandAction(getProject()).run( + () -> DocumentUtil.executeInBulk(document, () -> document.setText(after))); + + List result = getManager().getBookmarks(); + assertEquals("the bookmark must survive the replacement, got: " + result, 1, result.size()); + assertInstanceOf(result.getFirst(), LineBookmark.class); + assertEquals(3, ((LineBookmark)result.getFirst()).getLine()); + assertEquals("bookmarked", result.getFirst().getAttributes().get("lineText")); + } + + public void testBookmarkSurvivesBulkEditOnItsLine() { + @NonNls String text = "line 0\nline 1\noriginal\nline 3\n"; + init(text, PlainTextFileType.INSTANCE); + Document document = getEditor().getDocument(); + + addBookmark(2); + Bookmark bookmark = getManager().getBookmarks().getFirst(); + getManager().setType(bookmark, BookmarkType.DIGIT_4); + + int start = document.getLineStartOffset(2); + int end = document.getLineEndOffset(2); + WriteCommandAction.writeCommandAction(getProject()).run( + () -> DocumentUtil.executeInBulk(document, () -> document.replaceString(start, end, "renamed"))); + + List after = getManager().getBookmarks(); + assertEquals("bulk edit must not drop the bookmark, got: " + after, 1, after.size()); + assertInstanceOf(after.getFirst(), LineBookmark.class); + assertEquals(2, ((LineBookmark)after.getFirst()).getLine()); + assertEquals(BookmarkType.DIGIT_4, getManager().getType(after.getFirst())); + } + + public void testBookmarkFollowsLineShiftDoneInsideBulkUpdate() { + @NonNls String text = "line 0\nline 1\nline 2\noriginal\nline 4\n"; + init(text, PlainTextFileType.INSTANCE); + Document document = getEditor().getDocument(); + + addBookmark(3); + + WriteCommandAction.writeCommandAction(getProject()).run(() -> DocumentUtil.executeInBulk(document, () -> { + document.replaceString(document.getLineStartOffset(3), document.getLineEndOffset(3), "renamed"); + document.deleteString(document.getLineStartOffset(1), document.getLineStartOffset(2)); + })); + + List after = getManager().getBookmarks(); + assertEquals("bulk edit must not drop the bookmark, got: " + after, 1, after.size()); + assertInstanceOf(after.getFirst(), LineBookmark.class); + assertEquals(2, ((LineBookmark)after.getFirst()).getLine()); + } + + public void testRestoredLineTextMergesTheMemorialBackIntoTheBookmark() { + @NonNls String text = "line 0\nline 1\noriginal\nline 3\n"; + init(text, PlainTextFileType.INSTANCE); + Document document = getEditor().getDocument(); + + addBookmark(2); + + bulkEditLineTo(document, 2, "renamed"); + PlatformTestUtil.waitWithEventsDispatching( + "the memorial did not appear after the first refactoring", + () -> getManager().getBookmarks().stream().anyMatch(InvalidBookmark.class::isInstance), + 15); + assertEquals("expected the live bookmark and its memorial, got: " + getManager().getBookmarks(), + 2, getManager().getBookmarks().size()); + + bulkEditLineTo(document, 2, "original"); + PlatformTestUtil.waitWithEventsDispatching( + "the memorial was not merged back into the live bookmark", + () -> { + List bookmarks = getManager().getBookmarks(); + return bookmarks.size() == 1 && "original".equals(bookmarks.getFirst().getAttributes().get("lineText")); + }, + 15); + + List after = getManager().getBookmarks(); + assertInstanceOf(after.getFirst(), LineBookmark.class); + assertEquals(2, ((LineBookmark)after.getFirst()).getLine()); + assertEquals("original", after.getFirst().getAttributes().get("lineText")); + } + + private void bulkEditLineTo(Document document, int line, @NonNls String newText) { + WriteCommandAction.writeCommandAction(getProject()).run(() -> DocumentUtil.executeInBulk( + document, () -> document.replaceString(document.getLineStartOffset(line), document.getLineEndOffset(line), newText))); + } + public void testFirstEditLeavesOneMemorialFurtherEditsAreSilent() { @NonNls String text = "line 0\nline 1\noriginal\nline 3\n"; init(text, PlainTextFileType.INSTANCE);