IJPL-252323 keep line bookmarks across in-editor bulk edits

Bulk edits like in-place rename were mistaken for a file reload, which threw
away the bookmark on the edited line.


(cherry picked from commit 4025718fad7215393d3001044650ff3195b56000)

IJ-CR-221471

GitOrigin-RevId: 0f7578e563e39adeec795201328d670049a58835
This commit is contained in:
Ekaterina Berezhko
2026-09-02 15:11:29 +00:00
committed by intellij-monorepo-bot
parent f58a1b0361
commit ebb53806fb
2 changed files with 121 additions and 15 deletions
@@ -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<LineBookmarkImpl, Int>(compareBy { it.line })
val set = hashSetOf<Int>()
@@ -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) {
@@ -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<Bookmark> 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<Bookmark> 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<Bookmark> 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<Bookmark> bookmarks = getManager().getBookmarks();
return bookmarks.size() == 1 && "original".equals(bookmarks.getFirst().getAttributes().get("lineText"));
},
15);
List<Bookmark> 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);