mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-09-27 10:03:11 +07:00
[xml] IJPL-174820 Use coroutines to create TagNameSynchronizers.
GitOrigin-RevId: 3ff1b1f47e69a7475319e5970f449a26cd48b863
This commit is contained in:
committed by
intellij-monorepo-bot
parent
0ce99ce18a
commit
8a80130854
@@ -5,23 +5,21 @@ import com.intellij.application.options.editor.WebEditorOptions;
|
||||
import com.intellij.codeInsight.lookup.LookupManager;
|
||||
import com.intellij.codeInsight.lookup.impl.LookupImpl;
|
||||
import com.intellij.codeInspection.htmlInspections.RenameTagBeginOrEndIntentionAction;
|
||||
import com.intellij.ide.plugins.DynamicPluginListener;
|
||||
import com.intellij.ide.plugins.IdeaPluginDescriptor;
|
||||
import com.intellij.lang.Language;
|
||||
import com.intellij.lang.html.HTMLLanguage;
|
||||
import com.intellij.lang.injection.InjectedLanguageManager;
|
||||
import com.intellij.lang.xhtml.XHTMLLanguage;
|
||||
import com.intellij.lang.xml.XMLLanguage;
|
||||
import com.intellij.openapi.Disposable;
|
||||
import com.intellij.openapi.application.ApplicationManager;
|
||||
import com.intellij.openapi.application.ReadAction;
|
||||
import com.intellij.openapi.command.CommandEvent;
|
||||
import com.intellij.openapi.command.CommandListener;
|
||||
import com.intellij.openapi.command.undo.UndoManager;
|
||||
import com.intellij.openapi.diagnostic.Attachment;
|
||||
import com.intellij.openapi.diagnostic.Logger;
|
||||
import com.intellij.openapi.editor.*;
|
||||
import com.intellij.openapi.editor.event.*;
|
||||
import com.intellij.openapi.editor.Caret;
|
||||
import com.intellij.openapi.editor.CaretAction;
|
||||
import com.intellij.openapi.editor.Document;
|
||||
import com.intellij.openapi.editor.RangeMarker;
|
||||
import com.intellij.openapi.editor.event.CaretEvent;
|
||||
import com.intellij.openapi.editor.event.CaretListener;
|
||||
import com.intellij.openapi.editor.event.DocumentEvent;
|
||||
import com.intellij.openapi.editor.event.DocumentListener;
|
||||
import com.intellij.openapi.editor.impl.EditorImpl;
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager;
|
||||
import com.intellij.openapi.project.Project;
|
||||
@@ -35,14 +33,11 @@ import com.intellij.psi.impl.source.tree.LeafPsiElement;
|
||||
import com.intellij.psi.impl.source.tree.TreeUtil;
|
||||
import com.intellij.psi.impl.source.tree.injected.InjectedLanguageUtilBase;
|
||||
import com.intellij.psi.templateLanguages.OuterLanguageElement;
|
||||
import com.intellij.psi.templateLanguages.TemplateLanguage;
|
||||
import com.intellij.psi.templateLanguages.TemplateLanguageUtil;
|
||||
import com.intellij.psi.util.PsiTreeUtil;
|
||||
import com.intellij.psi.xml.XmlTag;
|
||||
import com.intellij.psi.xml.XmlToken;
|
||||
import com.intellij.psi.xml.XmlTokenType;
|
||||
import com.intellij.util.concurrency.AppExecutorUtil;
|
||||
import com.intellij.util.containers.ContainerUtil;
|
||||
import com.intellij.xml.XmlExtension;
|
||||
import com.intellij.xml.util.XmlUtil;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
@@ -50,495 +45,396 @@ import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static com.intellij.util.ObjectUtils.doIfNotNull;
|
||||
import static com.intellij.util.ObjectUtils.tryCast;
|
||||
|
||||
public final class XmlTagNameSynchronizer implements EditorFactoryListener {
|
||||
private static final Key<Boolean> SKIP_COMMAND = Key.create("tag.name.synchronizer.skip.command");
|
||||
final class XmlTagNameSynchronizer implements DocumentListener, CaretListener, Disposable {
|
||||
@SuppressWarnings("LoggerInitializedWithForeignClass")
|
||||
private static final Logger LOG = Logger.getInstance(XmlTagNameSynchronizer.class);
|
||||
private static final Set<Language> SUPPORTED_LANGUAGES = Set.of(HTMLLanguage.INSTANCE,
|
||||
XMLLanguage.INSTANCE,
|
||||
XHTMLLanguage.INSTANCE);
|
||||
private static final Key<XmlTagNameSynchronizer> SYNCHRONIZER_KEY = com.intellij.codeInsight.editorActions.XmlTagNameSynchronizer.Companion.getSYNCHRONIZER_KEY$intellij_xml_impl();
|
||||
private static final Key<Couple<RangeMarker>> MARKERS_KEY = Key.create("tag.name.synchronizer.markers");
|
||||
private static final TreeSet<Segment> allMarkers = new TreeSet<>(Comparator.comparingInt(Segment::getStartOffset));
|
||||
private final PsiDocumentManagerBase myDocumentManager;
|
||||
private final Language myLanguage;
|
||||
private final EditorImpl myEditor;
|
||||
private final Project myProject;
|
||||
private boolean myApplying;
|
||||
|
||||
private static final Key<TagNameSynchronizer> SYNCHRONIZER_KEY = Key.create("tag_name_synchronizer");
|
||||
XmlTagNameSynchronizer(EditorImpl editor, Project project, Language language) {
|
||||
myEditor = editor;
|
||||
myLanguage = language;
|
||||
myDocumentManager = (PsiDocumentManagerBase)PsiDocumentManager.getInstance(project);
|
||||
myProject = project;
|
||||
}
|
||||
|
||||
private XmlTagNameSynchronizer() { }
|
||||
@Override
|
||||
public void dispose() {
|
||||
myEditor.putUserData(SYNCHRONIZER_KEY, null);
|
||||
allMarkers.clear();
|
||||
}
|
||||
|
||||
private static void createSynchronizerFor(Editor editor) {
|
||||
Project project = editor.getProject();
|
||||
if (project == null || !(editor instanceof EditorImpl)) {
|
||||
void listenForDocumentChanges() {
|
||||
Disposer.register(myEditor.getDisposable(), this);
|
||||
myEditor.getDocument().addDocumentListener(this, this);
|
||||
myEditor.getCaretModel().addCaretListener(this, this);
|
||||
var previousSynchronizer = myEditor.getUserData(SYNCHRONIZER_KEY);
|
||||
if (previousSynchronizer != null) {
|
||||
LOG.error("Already listening for document changes in " + myEditor);
|
||||
Disposer.dispose(previousSynchronizer);
|
||||
}
|
||||
myEditor.putUserData(SYNCHRONIZER_KEY, this);
|
||||
for (Caret caret : myEditor.getCaretModel().getAllCarets()) {
|
||||
Couple<RangeMarker> markers = getMarkers(caret);
|
||||
if (markers != null) {
|
||||
allMarkers.add(markers.first);
|
||||
allMarkers.add(markers.second);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void caretRemoved(@NotNull CaretEvent event) {
|
||||
Caret caret = event.getCaret();
|
||||
clearMarkers(caret);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeDocumentChange(@NotNull DocumentEvent event) {
|
||||
if (!WebEditorOptions.getInstance().isSyncTagEditing()) return;
|
||||
|
||||
final Document document = event.getDocument();
|
||||
Project project = Objects.requireNonNull(myEditor.getProject());
|
||||
if (myApplying || project.isDefault() || UndoManager.getInstance(project).isUndoInProgress() ||
|
||||
!PomModelImpl.isAllowPsiModification() || document.isInBulkUpdate()) {
|
||||
return;
|
||||
}
|
||||
ReadAction.nonBlocking((Callable<Void>)() -> {
|
||||
if (editor.isDisposed() || project.isDisposed())
|
||||
|
||||
final int offset = event.getOffset();
|
||||
final int oldLength = event.getOldLength();
|
||||
final CharSequence fragment = event.getNewFragment();
|
||||
final int newLength = event.getNewLength();
|
||||
|
||||
if (document.getUserData(com.intellij.codeInsight.editorActions.XmlTagNameSynchronizer.Companion.getSKIP_COMMAND$intellij_xml_impl()) == Boolean.TRUE) {
|
||||
// xml completion inserts extra space after tag name to ensure correct parsing
|
||||
// js auto-import may change beginning of the document when component is imported
|
||||
// we need to ignore it
|
||||
return;
|
||||
}
|
||||
|
||||
Caret caret = myEditor.getCaretModel().getCurrentCaret();
|
||||
|
||||
for (int i = 0; i < newLength; i++) {
|
||||
if (!isValidTagNameChar(fragment.charAt(i))) {
|
||||
clearMarkers(caret);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Couple<RangeMarker> markers = getMarkers(caret);
|
||||
if (markers != null && !fitsInMarker(markers, offset, oldLength)) {
|
||||
clearMarkers(caret);
|
||||
markers = null;
|
||||
}
|
||||
|
||||
int caretOffset = caret.getOffset();
|
||||
Segment floor = allMarkers.floor(new TextRange(caretOffset, caretOffset));
|
||||
// Skip markers creation if cursors cover same tag area as other cursors
|
||||
if (floor != null
|
||||
&& (markers == null || (floor != markers.second && floor != markers.first))
|
||||
&& caretOffset <= floor.getEndOffset()) {
|
||||
clearMarkers(caret);
|
||||
return;
|
||||
}
|
||||
|
||||
if (markers == null) {
|
||||
final PsiFile file = myDocumentManager.getPsiFile(document);
|
||||
if (file == null || myDocumentManager.getSynchronizer().isInSynchronization(document)) return;
|
||||
|
||||
final RangeMarker leader = createTagNameMarker(caret);
|
||||
if (leader == null) return;
|
||||
leader.setGreedyToLeft(true);
|
||||
leader.setGreedyToRight(true);
|
||||
|
||||
if (myDocumentManager.isUncommited(document)) {
|
||||
myDocumentManager.commitDocument(document);
|
||||
}
|
||||
|
||||
final RangeMarker support = findSupport(leader, file, document);
|
||||
if (support == null) return;
|
||||
support.setGreedyToLeft(true);
|
||||
support.setGreedyToRight(true);
|
||||
markers = Couple.of(leader, support);
|
||||
if (!fitsInMarker(markers, offset, oldLength)) return;
|
||||
setMarkers(caret, markers);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean fitsInMarker(Couple<RangeMarker> markers, int offset, int oldLength) {
|
||||
RangeMarker leader = markers.first;
|
||||
return leader.isValid() && offset >= leader.getStartOffset() && offset + oldLength <= leader.getEndOffset();
|
||||
}
|
||||
|
||||
private static Couple<RangeMarker> getMarkers(Caret caret) {
|
||||
return caret.getUserData(MARKERS_KEY);
|
||||
}
|
||||
|
||||
private static void setMarkers(Caret caret, Couple<RangeMarker> markers) {
|
||||
caret.putUserData(MARKERS_KEY, markers);
|
||||
allMarkers.add(markers.first);
|
||||
allMarkers.add(markers.second);
|
||||
}
|
||||
|
||||
private static void clearMarkers(Caret caret) {
|
||||
Couple<RangeMarker> markers = caret.getUserData(MARKERS_KEY);
|
||||
if (markers != null) {
|
||||
allMarkers.remove(markers.first);
|
||||
allMarkers.remove(markers.second);
|
||||
markers.first.dispose();
|
||||
markers.second.dispose();
|
||||
caret.putUserData(MARKERS_KEY, null);
|
||||
}
|
||||
}
|
||||
|
||||
private RangeMarker createTagNameMarker(Caret caret) {
|
||||
final int offset = caret.getOffset();
|
||||
final Document document = myEditor.getDocument();
|
||||
final CharSequence sequence = document.getCharsSequence();
|
||||
int start = -1;
|
||||
boolean seenColon = false;
|
||||
for (int i = offset - 1; i >= Math.max(0, offset - 50); i--) {
|
||||
try {
|
||||
final char c = sequence.charAt(i);
|
||||
if (c == '<' || c == '/' && i > 0 && sequence.charAt(i - 1) == '<') {
|
||||
start = i + 1;
|
||||
break;
|
||||
}
|
||||
if (!isValidTagNameChar(c)) break;
|
||||
seenColon |= c == ':';
|
||||
}
|
||||
catch (IndexOutOfBoundsException e) {
|
||||
LOG.error("incorrect offset:" + i + ", initial: " + offset, new Attachment("document.txt", sequence.toString()));
|
||||
return null;
|
||||
Document document = editor.getDocument();
|
||||
VirtualFile file = FileDocumentManager.getInstance().getFile(document);
|
||||
Language language = findXmlLikeLanguage(project, file);
|
||||
if (language != null) {
|
||||
new TagNameSynchronizer((EditorImpl)editor, project, language).listenForDocumentChanges();
|
||||
}
|
||||
}
|
||||
if (start < 0) return null;
|
||||
int end = -1;
|
||||
for (int i = offset; i < Math.min(document.getTextLength(), offset + 50); i++) {
|
||||
final char c = sequence.charAt(i);
|
||||
if (!isValidTagNameChar(c) || seenColon && c == ':') {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
seenColon |= c == ':';
|
||||
}
|
||||
if (end < 0 || start > end) return null;
|
||||
return document.createRangeMarker(start, end, true);
|
||||
}
|
||||
|
||||
void beforeCommandFinished() {
|
||||
CaretAction action = caret -> {
|
||||
Couple<RangeMarker> markers = getMarkers(caret);
|
||||
final Document document = myEditor.getDocument();
|
||||
if (markers == null
|
||||
|| !markers.first.isValid()
|
||||
|| !markers.second.isValid()
|
||||
|| getNameToReplace(document, markers) == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Runnable apply = () -> {
|
||||
final String name = getNameToReplace(document, markers);
|
||||
if (name != null) {
|
||||
final RangeMarker support = markers.second;
|
||||
document.replaceString(support.getStartOffset(), support.getEndOffset(), name);
|
||||
}
|
||||
};
|
||||
ApplicationManager.getApplication().runWriteAction(() -> {
|
||||
final LookupImpl lookup = (LookupImpl)LookupManager.getActiveLookup(myEditor);
|
||||
if (lookup != null) {
|
||||
lookup.performGuardedChange(apply);
|
||||
}
|
||||
else {
|
||||
apply.run();
|
||||
}
|
||||
});
|
||||
};
|
||||
myApplying = true;
|
||||
try {
|
||||
if (myEditor.getCaretModel().isIteratingOverCarets()) {
|
||||
action.perform(myEditor.getCaretModel().getCurrentCaret());
|
||||
}
|
||||
else {
|
||||
myEditor.getCaretModel().runForEachCaret(action);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
myApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable String getNameToReplace(final Document document, @NotNull Couple<RangeMarker> markers) {
|
||||
final RangeMarker leader = markers.first;
|
||||
final RangeMarker support = markers.second;
|
||||
if (document.getTextLength() < leader.getEndOffset()) {
|
||||
return null;
|
||||
}
|
||||
final String name = document.getText(leader.getTextRange());
|
||||
if (document.getTextLength() >= support.getEndOffset() &&
|
||||
!name.equals(document.getText(support.getTextRange()))) {
|
||||
return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private RangeMarker findSupport(RangeMarker leader, PsiFile file, Document document) {
|
||||
final TextRange leaderRange = leader.getTextRange();
|
||||
final int offset = leader.getStartOffset();
|
||||
PsiElement element = findNameElement(InjectedLanguageUtilBase.findElementAtNoCommit(file, offset));
|
||||
TextRange support = findSupportRange(element);
|
||||
if (!isSupportRangeValid(document, leaderRange, support) &&
|
||||
file.getViewProvider() instanceof MultiplePsiFilesPerDocumentFileViewProvider) {
|
||||
element = findNameElement(file.getViewProvider().findElementAt(offset, myLanguage));
|
||||
support = findSupportRange(element);
|
||||
}
|
||||
|
||||
if (!isSupportRangeValid(document, leaderRange, support)) return findSupportForEmptyTag(leader, element, document);
|
||||
return document.createRangeMarker(support.getStartOffset(), support.getEndOffset(), true);
|
||||
}
|
||||
|
||||
private static PsiElement findNameElement(@Nullable PsiElement element) {
|
||||
return element instanceof OuterLanguageElement ? TemplateLanguageUtil.getSameLanguageTreeNext(element) : element;
|
||||
}
|
||||
|
||||
private boolean isValidTagNameChar(char c) {
|
||||
if (XmlUtil.isValidTagNameChar(c)) return true;
|
||||
final XmlExtension extension = getXmlExtension();
|
||||
if (extension == null) return false;
|
||||
return extension.isValidTagNameChar(c);
|
||||
}
|
||||
|
||||
private @Nullable XmlExtension getXmlExtension() {
|
||||
Document document = myEditor.getDocument();
|
||||
VirtualFile file = FileDocumentManager.getInstance().getFile(document);
|
||||
PsiFile psiFile = file != null && file.isValid() ? PsiManager.getInstance(myProject).findFile(file) : null;
|
||||
if (psiFile == null) {
|
||||
return null;
|
||||
}
|
||||
return XmlExtension.getExtension(psiFile);
|
||||
}
|
||||
|
||||
private static RangeMarker findSupportForEmptyTag(RangeMarker leader, PsiElement element, Document document) {
|
||||
int offset = leader.getStartOffset();
|
||||
if (offset != leader.getEndOffset() || element == null || offset == 0) return null;
|
||||
|
||||
XmlTag tag = tryCast(element.getParent(), XmlTag.class);
|
||||
CharSequence contents = document.getCharsSequence();
|
||||
if (tag != null && tag.getName().isEmpty()) {
|
||||
// Support JSX empty tags
|
||||
PsiElement startTag = tag.getFirstChild();
|
||||
PsiElement endTag = tag.getLastChild();
|
||||
if (endTag instanceof XmlToken && endTag.getNode().getElementType() == XmlTokenType.XML_TAG_END) {
|
||||
endTag = PsiTreeUtil.skipWhitespacesBackward(endTag);
|
||||
}
|
||||
if (startTag != endTag && startTag instanceof LeafPsiElement && endTag instanceof LeafPsiElement) {
|
||||
int startTagOffset = startTag.getNode().getStartOffset();
|
||||
int endTagOffset = endTag.getNode().getStartOffset();
|
||||
if (startTagOffset + 1 == offset) {
|
||||
return contents.charAt(endTagOffset) == '<' && contents.charAt(endTagOffset + 1) == '/'
|
||||
? document.createRangeMarker(endTagOffset + 2, endTagOffset + 2, true)
|
||||
: null;
|
||||
}
|
||||
else if (endTagOffset + 2 == offset) {
|
||||
return contents.charAt(startTagOffset) == '<'
|
||||
? document.createRangeMarker(startTagOffset + 1, startTagOffset + 1, true)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.coalesceBy(Pair.create(editor, XmlTagNameSynchronizer.class))
|
||||
.submit(AppExecutorUtil.getAppExecutorService());
|
||||
}
|
||||
}
|
||||
TextRange range = doIfNotNull(doIfNotNull(element.getParent(), PsiElement::getParent), PsiElement::getTextRange);
|
||||
if (range == null) return null;
|
||||
int length = contents.length();
|
||||
char prev = contents.charAt(offset - 1);
|
||||
if (prev == '<') {
|
||||
int nextCharPos = StringUtil.skipWhitespaceForward(contents, offset);
|
||||
if (nextCharPos >= 0 && nextCharPos < length && contents.charAt(nextCharPos) == '>') {
|
||||
// We look for the next opening tag and start to search for the closing empty tag after the end of the next tag's pair
|
||||
int nextTagStart = StringUtil.indexOf(contents, '<', nextCharPos, range.getEndOffset());
|
||||
int nextTagFirstChar = contents.charAt(StringUtil.skipWhitespaceForward(contents, nextTagStart + 1));
|
||||
|
||||
private static void recreateSynchronizers() {
|
||||
for (Editor editor : EditorFactory.getInstance().getAllEditors()) {
|
||||
TagNameSynchronizer synchronizer = editor.getUserData(SYNCHRONIZER_KEY);
|
||||
if (synchronizer != null) {
|
||||
Disposer.dispose(synchronizer);
|
||||
int searchStart = nextCharPos;
|
||||
if (nextTagFirstChar != '/' && nextTagFirstChar != '>') {
|
||||
PsiFile file = element.getContainingFile();
|
||||
TextRange nextTagsPair = findSupportRange(findNameElement(InjectedLanguageUtilBase.findElementAtNoCommit(file, nextTagStart)));
|
||||
if (nextTagsPair != null && nextTagsPair.getEndOffset() > nextCharPos) {
|
||||
searchStart = nextTagsPair.getEndOffset();
|
||||
}
|
||||
}
|
||||
|
||||
int endTagStart = StringUtil.indexOf(contents, '<', searchStart, range.getEndOffset());
|
||||
int endTagEnd = endTagStart > 0 && endTagStart < length - 1 && contents.charAt(endTagStart + 1) == '/'
|
||||
? StringUtil.skipWhitespaceForward(contents, endTagStart + 2) : -1;
|
||||
if (endTagEnd > 0 && endTagEnd < length && contents.charAt(endTagEnd) == '>') {
|
||||
// Sync with it
|
||||
return document.createRangeMarker(endTagStart + 2, endTagStart + 2, true);
|
||||
}
|
||||
else {
|
||||
// Otherwise create a self pointing sync to avoid code destruction
|
||||
return document.createRangeMarker(offset, offset, true);
|
||||
}
|
||||
}
|
||||
createSynchronizerFor(editor);
|
||||
}
|
||||
}
|
||||
|
||||
private static @NotNull Stream<TagNameSynchronizer> findSynchronizers(@Nullable Document document) {
|
||||
if (document == null || !WebEditorOptions.getInstance().isSyncTagEditing()) {
|
||||
return Stream.empty();
|
||||
}
|
||||
return EditorFactory.getInstance().editors(document, null)
|
||||
.map(editor -> editor.getUserData(SYNCHRONIZER_KEY))
|
||||
.filter(Objects::nonNull);
|
||||
}
|
||||
|
||||
private static Language findXmlLikeLanguage(Project project, VirtualFile file) {
|
||||
final PsiFile psiFile = file != null && file.isValid() ? PsiManager.getInstance(project).findFile(file) : null;
|
||||
if (psiFile != null) {
|
||||
for (Language language : psiFile.getViewProvider().getLanguages()) {
|
||||
if (
|
||||
(ContainerUtil.find(SUPPORTED_LANGUAGES, language::isKindOf) != null)
|
||||
&& !(language instanceof TemplateLanguage)
|
||||
&& !(language instanceof ExternallyTagSynchronizedLanguage)
|
||||
|| XmlTypedHandlersAdditionalSupport.supportsTypedHandlers(psiFile, language)
|
||||
) {
|
||||
return language;
|
||||
else if (prev == '/' && offset >= 2 && contents.charAt(offset - 2) == '<') {
|
||||
int nextCharPos = StringUtil.skipWhitespaceForward(contents, offset);
|
||||
if (nextCharPos >= 0 && nextCharPos < length && contents.charAt(nextCharPos) == '>') {
|
||||
// Try to find matching empty opening tag
|
||||
int startTagEnd = StringUtil.lastIndexOf(contents, '>', range.getStartOffset(), offset - 2);
|
||||
int startTagStart = startTagEnd > 0 ? StringUtil.skipWhitespaceBackward(contents, startTagEnd) - 1 : -1;
|
||||
if (startTagStart > 0 && startTagStart < length && contents.charAt(startTagStart) == '<') {
|
||||
return document.createRangeMarker(startTagStart + 1, startTagStart + 1, true);
|
||||
}
|
||||
else {
|
||||
// Otherwise create a self pointing sync to avoid code destruction
|
||||
return document.createRangeMarker(offset, offset, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void runWithoutCancellingSyncTagsEditing(@NotNull Document document, @NotNull Runnable runnable) {
|
||||
document.putUserData(SKIP_COMMAND, Boolean.TRUE);
|
||||
try {
|
||||
runnable.run();
|
||||
}
|
||||
finally {
|
||||
document.putUserData(SKIP_COMMAND, null);
|
||||
}
|
||||
private static boolean isSupportRangeValid(@NotNull Document document, @NotNull TextRange leader, @Nullable TextRange support) {
|
||||
if (support == null) return false;
|
||||
return document.getText(leader).equals(document.getText(support));
|
||||
}
|
||||
|
||||
public static class MyEditorFactoryListener implements EditorFactoryListener {
|
||||
@Override
|
||||
public void editorCreated(@NotNull EditorFactoryEvent event) {
|
||||
ReadAction.compute(() -> { createSynchronizerFor(event.getEditor()); return null; });
|
||||
}
|
||||
private static @Nullable TextRange findSupportRange(@Nullable PsiElement leader) {
|
||||
if (leader == null || TreeUtil.findSibling(leader.getNode(), XmlTokenType.XML_TAG_END) == null) return null;
|
||||
PsiElement support = RenameTagBeginOrEndIntentionAction.findOtherSide(leader, false);
|
||||
if (support == null || leader == support) support = RenameTagBeginOrEndIntentionAction.findOtherSide(leader, true);
|
||||
if (support == null) return null;
|
||||
final int start = findSupportRangeStart(support);
|
||||
final int end = findSupportRangeEnd(support);
|
||||
final TextRange supportRange = TextRange.create(start, end);
|
||||
return InjectedLanguageManager.getInstance(leader.getProject()).injectedToHost(leader.getContainingFile(), supportRange);
|
||||
}
|
||||
|
||||
static final class MyCommandListener implements CommandListener {
|
||||
@Override
|
||||
public void beforeCommandFinished(@NotNull CommandEvent event) {
|
||||
findSynchronizers(event.getDocument()).forEach(synchronizer -> synchronizer.beforeCommandFinished());
|
||||
private static int findSupportRangeStart(@NotNull PsiElement support) {
|
||||
PsiElement current = support;
|
||||
while (current.getPrevSibling() instanceof OuterLanguageElement) {
|
||||
current = current.getPrevSibling();
|
||||
}
|
||||
|
||||
return current.getTextRange().getStartOffset();
|
||||
}
|
||||
|
||||
public static class MyDynamicPluginListener implements DynamicPluginListener {
|
||||
@Override
|
||||
public void pluginLoaded(@NotNull IdeaPluginDescriptor pluginDescriptor) {
|
||||
recreateSynchronizers();
|
||||
private static int findSupportRangeEnd(@NotNull PsiElement support) {
|
||||
PsiElement current = support;
|
||||
while (current.getNextSibling() instanceof OuterLanguageElement) {
|
||||
current = current.getNextSibling();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pluginUnloaded(@NotNull IdeaPluginDescriptor pluginDescriptor, boolean isUpdate) {
|
||||
recreateSynchronizers();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TagNameSynchronizer implements DocumentListener, CaretListener, Disposable {
|
||||
private static final Key<Couple<RangeMarker>> MARKERS_KEY = Key.create("tag.name.synchronizer.markers");
|
||||
private static final TreeSet<Segment> allMarkers = new TreeSet<>(Comparator.comparingInt(Segment::getStartOffset));
|
||||
private final PsiDocumentManagerBase myDocumentManager;
|
||||
private final Language myLanguage;
|
||||
private final EditorImpl myEditor;
|
||||
private final Project myProject;
|
||||
private boolean myApplying;
|
||||
|
||||
private TagNameSynchronizer(EditorImpl editor, Project project, Language language) {
|
||||
myEditor = editor;
|
||||
myLanguage = language;
|
||||
myDocumentManager = (PsiDocumentManagerBase)PsiDocumentManager.getInstance(project);
|
||||
myProject = project;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
myEditor.putUserData(SYNCHRONIZER_KEY, null);
|
||||
allMarkers.clear();
|
||||
}
|
||||
|
||||
private void listenForDocumentChanges() {
|
||||
Disposer.register(myEditor.getDisposable(), this);
|
||||
myEditor.getDocument().addDocumentListener(this, this);
|
||||
myEditor.getCaretModel().addCaretListener(this, this);
|
||||
myEditor.putUserData(SYNCHRONIZER_KEY, this);
|
||||
for (Caret caret : myEditor.getCaretModel().getAllCarets()) {
|
||||
Couple<RangeMarker> markers = getMarkers(caret);
|
||||
if (markers != null) {
|
||||
allMarkers.add(markers.first);
|
||||
allMarkers.add(markers.second);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void caretRemoved(@NotNull CaretEvent event) {
|
||||
Caret caret = event.getCaret();
|
||||
clearMarkers(caret);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeDocumentChange(@NotNull DocumentEvent event) {
|
||||
if (!WebEditorOptions.getInstance().isSyncTagEditing()) return;
|
||||
|
||||
final Document document = event.getDocument();
|
||||
Project project = Objects.requireNonNull(myEditor.getProject());
|
||||
if (myApplying || project.isDefault() || UndoManager.getInstance(project).isUndoInProgress() ||
|
||||
!PomModelImpl.isAllowPsiModification() || document.isInBulkUpdate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final int offset = event.getOffset();
|
||||
final int oldLength = event.getOldLength();
|
||||
final CharSequence fragment = event.getNewFragment();
|
||||
final int newLength = event.getNewLength();
|
||||
|
||||
if (document.getUserData(SKIP_COMMAND) == Boolean.TRUE) {
|
||||
// xml completion inserts extra space after tag name to ensure correct parsing
|
||||
// js auto-import may change beginning of the document when component is imported
|
||||
// we need to ignore it
|
||||
return;
|
||||
}
|
||||
|
||||
Caret caret = myEditor.getCaretModel().getCurrentCaret();
|
||||
|
||||
for (int i = 0; i < newLength; i++) {
|
||||
if (!isValidTagNameChar(fragment.charAt(i))) {
|
||||
clearMarkers(caret);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Couple<RangeMarker> markers = getMarkers(caret);
|
||||
if (markers != null && !fitsInMarker(markers, offset, oldLength)) {
|
||||
clearMarkers(caret);
|
||||
markers = null;
|
||||
}
|
||||
|
||||
int caretOffset = caret.getOffset();
|
||||
Segment floor = allMarkers.floor(new TextRange(caretOffset, caretOffset));
|
||||
// Skip markers creation if cursors cover same tag area as other cursors
|
||||
if (floor != null
|
||||
&& (markers == null || (floor != markers.second && floor != markers.first))
|
||||
&& caretOffset <= floor.getEndOffset()) {
|
||||
clearMarkers(caret);
|
||||
return;
|
||||
}
|
||||
|
||||
if (markers == null) {
|
||||
final PsiFile file = myDocumentManager.getPsiFile(document);
|
||||
if (file == null || myDocumentManager.getSynchronizer().isInSynchronization(document)) return;
|
||||
|
||||
final RangeMarker leader = createTagNameMarker(caret);
|
||||
if (leader == null) return;
|
||||
leader.setGreedyToLeft(true);
|
||||
leader.setGreedyToRight(true);
|
||||
|
||||
if (myDocumentManager.isUncommited(document)) {
|
||||
myDocumentManager.commitDocument(document);
|
||||
}
|
||||
|
||||
final RangeMarker support = findSupport(leader, file, document);
|
||||
if (support == null) return;
|
||||
support.setGreedyToLeft(true);
|
||||
support.setGreedyToRight(true);
|
||||
markers = Couple.of(leader, support);
|
||||
if (!fitsInMarker(markers, offset, oldLength)) return;
|
||||
setMarkers(caret, markers);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean fitsInMarker(Couple<RangeMarker> markers, int offset, int oldLength) {
|
||||
RangeMarker leader = markers.first;
|
||||
return leader.isValid() && offset >= leader.getStartOffset() && offset + oldLength <= leader.getEndOffset();
|
||||
}
|
||||
|
||||
private static Couple<RangeMarker> getMarkers(Caret caret) {
|
||||
return caret.getUserData(MARKERS_KEY);
|
||||
}
|
||||
|
||||
private static void setMarkers(Caret caret, Couple<RangeMarker> markers) {
|
||||
caret.putUserData(MARKERS_KEY, markers);
|
||||
allMarkers.add(markers.first);
|
||||
allMarkers.add(markers.second);
|
||||
}
|
||||
|
||||
private static void clearMarkers(Caret caret) {
|
||||
Couple<RangeMarker> markers = caret.getUserData(MARKERS_KEY);
|
||||
if (markers != null) {
|
||||
allMarkers.remove(markers.first);
|
||||
allMarkers.remove(markers.second);
|
||||
markers.first.dispose();
|
||||
markers.second.dispose();
|
||||
caret.putUserData(MARKERS_KEY, null);
|
||||
}
|
||||
}
|
||||
|
||||
private RangeMarker createTagNameMarker(Caret caret) {
|
||||
final int offset = caret.getOffset();
|
||||
final Document document = myEditor.getDocument();
|
||||
final CharSequence sequence = document.getCharsSequence();
|
||||
int start = -1;
|
||||
boolean seenColon = false;
|
||||
for (int i = offset - 1; i >= Math.max(0, offset - 50); i--) {
|
||||
try {
|
||||
final char c = sequence.charAt(i);
|
||||
if (c == '<' || c == '/' && i > 0 && sequence.charAt(i - 1) == '<') {
|
||||
start = i + 1;
|
||||
break;
|
||||
}
|
||||
if (!isValidTagNameChar(c)) break;
|
||||
seenColon |= c == ':';
|
||||
}
|
||||
catch (IndexOutOfBoundsException e) {
|
||||
LOG.error("incorrect offset:" + i + ", initial: " + offset, new Attachment("document.txt", sequence.toString()));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (start < 0) return null;
|
||||
int end = -1;
|
||||
for (int i = offset; i < Math.min(document.getTextLength(), offset + 50); i++) {
|
||||
final char c = sequence.charAt(i);
|
||||
if (!isValidTagNameChar(c) || seenColon && c == ':') {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
seenColon |= c == ':';
|
||||
}
|
||||
if (end < 0 || start > end) return null;
|
||||
return document.createRangeMarker(start, end, true);
|
||||
}
|
||||
|
||||
void beforeCommandFinished() {
|
||||
CaretAction action = caret -> {
|
||||
Couple<RangeMarker> markers = getMarkers(caret);
|
||||
final Document document = myEditor.getDocument();
|
||||
if (markers == null
|
||||
|| !markers.first.isValid()
|
||||
|| !markers.second.isValid()
|
||||
|| getNameToReplace(document, markers) == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final Runnable apply = () -> {
|
||||
final String name = getNameToReplace(document, markers);
|
||||
if (name != null) {
|
||||
final RangeMarker support = markers.second;
|
||||
document.replaceString(support.getStartOffset(), support.getEndOffset(), name);
|
||||
}
|
||||
};
|
||||
ApplicationManager.getApplication().runWriteAction(() -> {
|
||||
final LookupImpl lookup = (LookupImpl)LookupManager.getActiveLookup(myEditor);
|
||||
if (lookup != null) {
|
||||
lookup.performGuardedChange(apply);
|
||||
}
|
||||
else {
|
||||
apply.run();
|
||||
}
|
||||
});
|
||||
};
|
||||
myApplying = true;
|
||||
try {
|
||||
if (myEditor.getCaretModel().isIteratingOverCarets()) {
|
||||
action.perform(myEditor.getCaretModel().getCurrentCaret());
|
||||
}
|
||||
else {
|
||||
myEditor.getCaretModel().runForEachCaret(action);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
myApplying = false;
|
||||
}
|
||||
}
|
||||
|
||||
private static @Nullable String getNameToReplace(final Document document, @NotNull Couple<RangeMarker> markers) {
|
||||
final RangeMarker leader = markers.first;
|
||||
final RangeMarker support = markers.second;
|
||||
if (document.getTextLength() < leader.getEndOffset()) {
|
||||
return null;
|
||||
}
|
||||
final String name = document.getText(leader.getTextRange());
|
||||
if (document.getTextLength() >= support.getEndOffset() &&
|
||||
!name.equals(document.getText(support.getTextRange()))) {
|
||||
return name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private RangeMarker findSupport(RangeMarker leader, PsiFile file, Document document) {
|
||||
final TextRange leaderRange = leader.getTextRange();
|
||||
final int offset = leader.getStartOffset();
|
||||
PsiElement element = findNameElement(InjectedLanguageUtilBase.findElementAtNoCommit(file, offset));
|
||||
TextRange support = findSupportRange(element);
|
||||
if (!isSupportRangeValid(document, leaderRange, support) &&
|
||||
file.getViewProvider() instanceof MultiplePsiFilesPerDocumentFileViewProvider) {
|
||||
element = findNameElement(file.getViewProvider().findElementAt(offset, myLanguage));
|
||||
support = findSupportRange(element);
|
||||
}
|
||||
|
||||
if (!isSupportRangeValid(document, leaderRange, support)) return findSupportForEmptyTag(leader, element, document);
|
||||
return document.createRangeMarker(support.getStartOffset(), support.getEndOffset(), true);
|
||||
}
|
||||
|
||||
private static PsiElement findNameElement(@Nullable PsiElement element) {
|
||||
return element instanceof OuterLanguageElement ? TemplateLanguageUtil.getSameLanguageTreeNext(element) : element;
|
||||
}
|
||||
|
||||
private boolean isValidTagNameChar(char c) {
|
||||
if (XmlUtil.isValidTagNameChar(c)) return true;
|
||||
final XmlExtension extension = getXmlExtension();
|
||||
if (extension == null) return false;
|
||||
return extension.isValidTagNameChar(c);
|
||||
}
|
||||
|
||||
private @Nullable XmlExtension getXmlExtension() {
|
||||
Document document = myEditor.getDocument();
|
||||
VirtualFile file = FileDocumentManager.getInstance().getFile(document);
|
||||
PsiFile psiFile = file != null && file.isValid() ? PsiManager.getInstance(myProject).findFile(file) : null;
|
||||
if (psiFile == null) {
|
||||
return null;
|
||||
}
|
||||
return XmlExtension.getExtension(psiFile);
|
||||
}
|
||||
|
||||
private static RangeMarker findSupportForEmptyTag(RangeMarker leader, PsiElement element, Document document) {
|
||||
int offset = leader.getStartOffset();
|
||||
if (offset != leader.getEndOffset() || element == null || offset == 0) return null;
|
||||
|
||||
XmlTag tag = tryCast(element.getParent(), XmlTag.class);
|
||||
CharSequence contents = document.getCharsSequence();
|
||||
if (tag != null && tag.getName().isEmpty()) {
|
||||
// Support JSX empty tags
|
||||
PsiElement startTag = tag.getFirstChild();
|
||||
PsiElement endTag = tag.getLastChild();
|
||||
if (endTag instanceof XmlToken && endTag.getNode().getElementType() == XmlTokenType.XML_TAG_END) {
|
||||
endTag = PsiTreeUtil.skipWhitespacesBackward(endTag);
|
||||
}
|
||||
if (startTag != endTag && startTag instanceof LeafPsiElement && endTag instanceof LeafPsiElement) {
|
||||
int startTagOffset = startTag.getNode().getStartOffset();
|
||||
int endTagOffset = endTag.getNode().getStartOffset();
|
||||
if (startTagOffset + 1 == offset) {
|
||||
return contents.charAt(endTagOffset) == '<' && contents.charAt(endTagOffset + 1) == '/'
|
||||
? document.createRangeMarker(endTagOffset + 2, endTagOffset + 2, true)
|
||||
: null;
|
||||
}
|
||||
else if (endTagOffset + 2 == offset) {
|
||||
return contents.charAt(startTagOffset) == '<'
|
||||
? document.createRangeMarker(startTagOffset + 1, startTagOffset + 1, true)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
TextRange range = doIfNotNull(doIfNotNull(element.getParent(), PsiElement::getParent), PsiElement::getTextRange);
|
||||
if (range == null) return null;
|
||||
int length = contents.length();
|
||||
char prev = contents.charAt(offset - 1);
|
||||
if (prev == '<') {
|
||||
int nextCharPos = StringUtil.skipWhitespaceForward(contents, offset);
|
||||
if (nextCharPos >= 0 && nextCharPos < length && contents.charAt(nextCharPos) == '>') {
|
||||
// We look for the next opening tag and start to search for the closing empty tag after the end of the next tag's pair
|
||||
int nextTagStart = StringUtil.indexOf(contents, '<', nextCharPos, range.getEndOffset());
|
||||
int nextTagFirstChar = contents.charAt(StringUtil.skipWhitespaceForward(contents, nextTagStart + 1));
|
||||
|
||||
int searchStart = nextCharPos;
|
||||
if (nextTagFirstChar != '/' && nextTagFirstChar != '>') {
|
||||
PsiFile file = element.getContainingFile();
|
||||
TextRange nextTagsPair = findSupportRange(findNameElement(InjectedLanguageUtilBase.findElementAtNoCommit(file, nextTagStart)));
|
||||
if (nextTagsPair != null && nextTagsPair.getEndOffset() > nextCharPos) {
|
||||
searchStart = nextTagsPair.getEndOffset();
|
||||
}
|
||||
}
|
||||
|
||||
int endTagStart = StringUtil.indexOf(contents, '<', searchStart, range.getEndOffset());
|
||||
int endTagEnd = endTagStart > 0 && endTagStart < length - 1 && contents.charAt(endTagStart + 1) == '/'
|
||||
? StringUtil.skipWhitespaceForward(contents, endTagStart + 2) : -1;
|
||||
if (endTagEnd > 0 && endTagEnd < length && contents.charAt(endTagEnd) == '>') {
|
||||
// Sync with it
|
||||
return document.createRangeMarker(endTagStart + 2, endTagStart + 2, true);
|
||||
}
|
||||
else {
|
||||
// Otherwise create a self pointing sync to avoid code destruction
|
||||
return document.createRangeMarker(offset, offset, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (prev == '/' && offset >= 2 && contents.charAt(offset - 2) == '<') {
|
||||
int nextCharPos = StringUtil.skipWhitespaceForward(contents, offset);
|
||||
if (nextCharPos >= 0 && nextCharPos < length && contents.charAt(nextCharPos) == '>') {
|
||||
// Try to find matching empty opening tag
|
||||
int startTagEnd = StringUtil.lastIndexOf(contents, '>', range.getStartOffset(), offset - 2);
|
||||
int startTagStart = startTagEnd > 0 ? StringUtil.skipWhitespaceBackward(contents, startTagEnd) - 1 : -1;
|
||||
if (startTagStart > 0 && startTagStart < length && contents.charAt(startTagStart) == '<') {
|
||||
return document.createRangeMarker(startTagStart + 1, startTagStart + 1, true);
|
||||
}
|
||||
else {
|
||||
// Otherwise create a self pointing sync to avoid code destruction
|
||||
return document.createRangeMarker(offset, offset, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean isSupportRangeValid(@NotNull Document document, @NotNull TextRange leader, @Nullable TextRange support) {
|
||||
if (support == null) return false;
|
||||
return document.getText(leader).equals(document.getText(support));
|
||||
}
|
||||
|
||||
private static @Nullable TextRange findSupportRange(@Nullable PsiElement leader) {
|
||||
if (leader == null || TreeUtil.findSibling(leader.getNode(), XmlTokenType.XML_TAG_END) == null) return null;
|
||||
PsiElement support = RenameTagBeginOrEndIntentionAction.findOtherSide(leader, false);
|
||||
if (support == null || leader == support) support = RenameTagBeginOrEndIntentionAction.findOtherSide(leader, true);
|
||||
if (support == null) return null;
|
||||
final int start = findSupportRangeStart(support);
|
||||
final int end = findSupportRangeEnd(support);
|
||||
final TextRange supportRange = TextRange.create(start, end);
|
||||
return InjectedLanguageManager.getInstance(leader.getProject()).injectedToHost(leader.getContainingFile(), supportRange);
|
||||
}
|
||||
|
||||
private static int findSupportRangeStart(@NotNull PsiElement support) {
|
||||
PsiElement current = support;
|
||||
while (current.getPrevSibling() instanceof OuterLanguageElement) {
|
||||
current = current.getPrevSibling();
|
||||
}
|
||||
|
||||
return current.getTextRange().getStartOffset();
|
||||
}
|
||||
|
||||
private static int findSupportRangeEnd(@NotNull PsiElement support) {
|
||||
PsiElement current = support;
|
||||
while (current.getNextSibling() instanceof OuterLanguageElement) {
|
||||
current = current.getNextSibling();
|
||||
}
|
||||
|
||||
return current.getTextRange().getEndOffset();
|
||||
}
|
||||
return current.getTextRange().getEndOffset();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.intellij.codeInsight.editorActions
|
||||
|
||||
import com.intellij.application.options.editor.WebEditorOptions
|
||||
import com.intellij.ide.plugins.DynamicPluginListener
|
||||
import com.intellij.ide.plugins.IdeaPluginDescriptor
|
||||
import com.intellij.lang.Language
|
||||
import com.intellij.lang.html.HTMLLanguage
|
||||
import com.intellij.lang.xhtml.XHTMLLanguage
|
||||
import com.intellij.lang.xml.XMLLanguage
|
||||
import com.intellij.openapi.actionSystem.AnAction
|
||||
import com.intellij.openapi.actionSystem.DataContext
|
||||
import com.intellij.openapi.actionSystem.Shortcut
|
||||
import com.intellij.openapi.actionSystem.ex.AnActionListener
|
||||
import com.intellij.openapi.application.ApplicationManager
|
||||
import com.intellij.openapi.application.EDT
|
||||
import com.intellij.openapi.application.readAction
|
||||
import com.intellij.openapi.command.CommandEvent
|
||||
import com.intellij.openapi.command.CommandListener
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.components.service
|
||||
import com.intellij.openapi.diagnostic.thisLogger
|
||||
import com.intellij.openapi.editor.Document
|
||||
import com.intellij.openapi.editor.Editor
|
||||
import com.intellij.openapi.editor.EditorFactory
|
||||
import com.intellij.openapi.editor.XmlTypedHandlersAdditionalSupport
|
||||
import com.intellij.openapi.editor.event.EditorFactoryEvent
|
||||
import com.intellij.openapi.editor.event.EditorFactoryListener
|
||||
import com.intellij.openapi.editor.impl.EditorImpl
|
||||
import com.intellij.openapi.fileEditor.FileDocumentManager
|
||||
import com.intellij.openapi.project.Project
|
||||
import com.intellij.openapi.util.Disposer
|
||||
import com.intellij.openapi.util.Key
|
||||
import com.intellij.openapi.vfs.VirtualFile
|
||||
import com.intellij.psi.PsiManager
|
||||
import com.intellij.psi.templateLanguages.TemplateLanguage
|
||||
import com.intellij.util.concurrency.annotations.RequiresEdt
|
||||
import com.intellij.util.ui.UIUtil
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import org.jetbrains.annotations.TestOnly
|
||||
import java.lang.Runnable
|
||||
import java.lang.System
|
||||
import java.lang.Thread
|
||||
import java.util.concurrent.TimeoutException
|
||||
import kotlin.streams.asSequence
|
||||
|
||||
@Service(Service.Level.PROJECT)
|
||||
class XmlTagNameSynchronizer(private val project: Project, val cs: CoroutineScope) {
|
||||
|
||||
companion object {
|
||||
|
||||
@JvmStatic
|
||||
fun runWithoutCancellingSyncTagsEditing(document: Document, runnable: Runnable) {
|
||||
document.putUserData(SKIP_COMMAND, true)
|
||||
try {
|
||||
runnable.run()
|
||||
}
|
||||
finally {
|
||||
document.putUserData(SKIP_COMMAND, null)
|
||||
}
|
||||
}
|
||||
|
||||
@TestOnly
|
||||
@JvmStatic
|
||||
fun getInstance(project: Project): XmlTagNameSynchronizer = project.service()
|
||||
|
||||
internal val SKIP_COMMAND: Key<Boolean> = Key.create("tag.name.synchronizer.skip.command")
|
||||
|
||||
internal val SYNCHRONIZER_KEY: Key<XmlTagNameSynchronizer> = Key.create("tag_name_synchronizer")
|
||||
|
||||
private val SUPPORTED_LANGUAGES = setOf(HTMLLanguage.INSTANCE, XMLLanguage.INSTANCE, XHTMLLanguage)
|
||||
|
||||
}
|
||||
|
||||
@TestOnly
|
||||
@RequiresEdt
|
||||
fun waitForSynchronizersCreation() {
|
||||
if (writeLock.availablePermits > 0) return
|
||||
// Cannot use dispatchAllInvocationEvents() is write access is allowed.
|
||||
if (ApplicationManager.getApplication().isWriteAccessAllowed) return
|
||||
val start = System.currentTimeMillis()
|
||||
val job = cs.coroutineContext.job
|
||||
while (job.children.toList().isNotEmpty() && cs.coroutineContext.isActive) {
|
||||
UIUtil.dispatchAllInvocationEvents()
|
||||
Thread.sleep(1)
|
||||
if (System.currentTimeMillis() - start > 2000) {
|
||||
thisLogger().warn("Timed out waiting for synchronizers to be created.", TimeoutException())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val writeLock = Semaphore(1)
|
||||
|
||||
init {
|
||||
@Suppress("TestOnlyProblems")
|
||||
if (ApplicationManager.getApplication().isUnitTestMode) {
|
||||
val messageBus = project.messageBus.connect()
|
||||
messageBus.subscribe(AnActionListener.TOPIC, object : AnActionListener {
|
||||
override fun beforeShortcutTriggered(shortcut: Shortcut, actions: List<AnAction?>, dataContext: DataContext) {
|
||||
waitForSynchronizersCreation()
|
||||
}
|
||||
|
||||
override fun beforeEditorTyping(c: Char, dataContext: DataContext) {
|
||||
waitForSynchronizersCreation()
|
||||
}
|
||||
})
|
||||
messageBus.subscribe(CommandListener.TOPIC, object : CommandListener {
|
||||
override fun commandStarted(event: CommandEvent) {
|
||||
waitForSynchronizersCreation()
|
||||
}
|
||||
|
||||
override fun undoTransparentActionStarted() {
|
||||
waitForSynchronizersCreation()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun recreateSynchronizersFor(editors: List<EditorImpl>) {
|
||||
cs.launch {
|
||||
writeLock.withPermit {
|
||||
editors.forEach { editor ->
|
||||
editor.getUserData(SYNCHRONIZER_KEY)?.let {
|
||||
Disposer.dispose(it)
|
||||
}
|
||||
ensureSynchronizerCreated(editor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleEnsureSynchronizerCreated(editor: EditorImpl) {
|
||||
cs.launch {
|
||||
writeLock.withPermit {
|
||||
ensureSynchronizerCreated(editor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun ensureSynchronizerCreated(editor: EditorImpl) {
|
||||
if (editor.getUserData(SYNCHRONIZER_KEY) != null) return
|
||||
readAction {
|
||||
if (editor.isDisposed || project.isDisposed()) return@readAction null
|
||||
val document = editor.document
|
||||
val file = FileDocumentManager.getInstance().getFile(document)
|
||||
findXmlLikeLanguage(project, file)
|
||||
}?.let { language ->
|
||||
withContext(Dispatchers.EDT) {
|
||||
if (editor.isDisposed || project.isDisposed()) return@withContext
|
||||
XmlTagNameSynchronizer(editor, project, language).listenForDocumentChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun findXmlLikeLanguage(project: Project, file: VirtualFile?): Language? {
|
||||
val psiFile = file?.takeIf { it.isValid }?.let { PsiManager.getInstance(project).findFile(it) }
|
||||
?: return null
|
||||
for (language in psiFile.getViewProvider().getLanguages()) {
|
||||
if (SUPPORTED_LANGUAGES.find { language.isKindOf(it) } != null
|
||||
&& language !is TemplateLanguage && language !is ExternallyTagSynchronizedLanguage
|
||||
|| XmlTypedHandlersAdditionalSupport.supportsTypedHandlers(psiFile, language)
|
||||
) {
|
||||
return language
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
class MyEditorFactoryListener : EditorFactoryListener {
|
||||
override fun editorCreated(event: EditorFactoryEvent) {
|
||||
event.editor.scheduleEnsureSynchronizerCreated()
|
||||
}
|
||||
|
||||
private fun Editor.scheduleEnsureSynchronizerCreated() {
|
||||
project?.service<XmlTagNameSynchronizer>()?.scheduleEnsureSynchronizerCreated(this as? EditorImpl ?: return)
|
||||
}
|
||||
}
|
||||
|
||||
class MyCommandListener : CommandListener {
|
||||
override fun beforeCommandFinished(event: CommandEvent) {
|
||||
findSynchronizers(event.document).forEach { it.beforeCommandFinished() }
|
||||
}
|
||||
|
||||
private fun findSynchronizers(document: Document?): Sequence<XmlTagNameSynchronizerImpl> =
|
||||
if (document == null || !WebEditorOptions.getInstance().isSyncTagEditing)
|
||||
emptySequence()
|
||||
else
|
||||
EditorFactory.getInstance().editors(document, null)
|
||||
.asSequence()
|
||||
.mapNotNull { editor -> editor.getUserData(SYNCHRONIZER_KEY) }
|
||||
}
|
||||
|
||||
class MyDynamicPluginListener : DynamicPluginListener {
|
||||
override fun pluginLoaded(pluginDescriptor: IdeaPluginDescriptor) {
|
||||
recreateSynchronizers()
|
||||
}
|
||||
|
||||
override fun pluginUnloaded(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) {
|
||||
recreateSynchronizers()
|
||||
}
|
||||
|
||||
private fun recreateSynchronizers() {
|
||||
EditorFactory.getInstance().getAllEditors().groupBy { it.project }.forEach { (project, editors) ->
|
||||
project?.service<XmlTagNameSynchronizer>()?.recreateSynchronizersFor(editors.filterIsInstance<EditorImpl>())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user