Add source highlighting and revision-checked diagnostic fixes in both hosts

This commit is contained in:
2026-11-13 18:19:39 +01:00
parent 8a4f456e38
commit 9bc4569407
18 changed files with 1033 additions and 14 deletions
@@ -0,0 +1,84 @@
package de.neitzel.fxdesigner.intellij;
import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer;
import com.intellij.openapi.components.Service;
import com.intellij.openapi.editor.Document;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.*;
import de.neitzel.fxdesigner.host.HostServices.*;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
/** Current published evidence only; annotators never build or execute target application code. */
@Service(Service.Level.PROJECT)
public final class DiagnosticAnnotations {
record Publication(
String owner,
VirtualFile file,
Document document,
long stamp,
List<Diagnostic> diagnostics,
Consumer<QuickFix> apply) {}
private final Map<String, Publication> owners = new ConcurrentHashMap<>();
private final Project project;
public DiagnosticAnnotations(Project project) {
this.project = project;
}
static DiagnosticAnnotations get(Project project) {
return project.getService(DiagnosticAnnotations.class);
}
void replace(
String owner,
VirtualFile file,
Document document,
List<Diagnostic> diagnostics,
Consumer<QuickFix> apply) {
clear(owner);
owners.put(
owner,
new Publication(
owner,
file,
document,
document.getModificationStamp(),
List.copyOf(diagnostics),
apply));
restart(file);
}
void clear(String owner) {
var old = owners.remove(owner);
if (old != null) restart(old.file());
}
private void restart(VirtualFile file) {
if (project.isDisposed() || !file.isValid()) return;
var psi = PsiManager.getInstance(project).findFile(file);
if (psi != null) DaemonCodeAnalyzer.getInstance(project).restart(psi);
}
List<Publication> current(PsiFile file) {
return owners.values().stream()
.filter(p -> p.file().equals(file.getVirtualFile()) && current(p))
.toList();
}
boolean current(Publication p) {
return owners.get(p.owner()) == p
&& p.file().isValid()
&& p.document().getModificationStamp() == p.stamp();
}
boolean available(Publication p, QuickFix fix) {
return current(p)
&& p.document().isWritable()
&& p.file().isWritable()
&& p.diagnostics().stream().anyMatch(d -> d.fixes().contains(fix));
}
}
@@ -0,0 +1,66 @@
package de.neitzel.fxdesigner.intellij;
import com.intellij.codeInsight.intention.IntentionAction;
import com.intellij.lang.annotation.*;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.TextRange;
import com.intellij.psi.*;
import de.neitzel.fxdesigner.host.HostServices.*;
/** Native XML error highlighting and Alt+Enter actions from shared diagnostics. */
public final class DiagnosticAnnotator implements Annotator {
@Override
public void annotate(PsiElement element, AnnotationHolder holder) {
if (!(element instanceof PsiFile file) || !file.getName().endsWith(".fxml")) return;
var store = DiagnosticAnnotations.get(file.getProject());
for (var publication : store.current(file))
for (var d : publication.diagnostics()) {
if (d.range().isEmpty() || d.location().isEmpty()) continue;
var r = d.range().get();
if (r.end() > file.getTextLength()) continue;
var severity =
switch (d.severity()) {
case ERROR -> HighlightSeverity.ERROR;
case WARNING -> HighlightSeverity.WARNING;
case INFO -> HighlightSeverity.INFORMATION;
};
var builder =
holder.newAnnotation(severity, d.message()).range(new TextRange(r.start(), r.end()));
for (var fix : d.fixes()) builder.withFix(new Fix(store, publication, fix));
builder.create();
}
}
private record Fix(
DiagnosticAnnotations store, DiagnosticAnnotations.Publication publication, QuickFix fix)
implements IntentionAction {
@Override
public String getText() {
return fix.label();
}
@Override
public String getFamilyName() {
return "Neitzel FX Designer diagnostic fixes";
}
@Override
public boolean isAvailable(Project project, Editor editor, PsiFile file) {
return !project.isDisposed()
&& file != null
&& publication.file().equals(file.getVirtualFile())
&& store.available(publication, fix);
}
@Override
public void invoke(Project project, Editor editor, PsiFile file) {
if (isAvailable(project, editor, file)) publication.apply().accept(fix);
}
@Override
public boolean startInWriteAction() {
return false;
}
}
}
@@ -47,6 +47,16 @@ final class IntellijDiagnostics
DocumentSnapshot source,
String context,
List<HostServices.Diagnostic> diagnostics) {
replace(owner, source, context, diagnostics, fix -> {});
}
@Override
public void replace(
String owner,
DocumentSnapshot source,
String context,
List<HostServices.Diagnostic> diagnostics,
java.util.function.Consumer<HostServices.QuickFix> apply) {
var captured = List.copyOf(diagnostics);
edt(
() -> {
@@ -69,6 +79,7 @@ final class IntellijDiagnostics
ProblemsCollector.getInstance(project).problemAppeared(p);
}
owned.put(owner, entries);
DiagnosticAnnotations.get(project).replace(owner, file, document, captured, apply);
});
}
@@ -78,6 +89,7 @@ final class IntellijDiagnostics
}
private void remove(String owner) {
DiagnosticAnnotations.get(project).clear(owner);
var old = owned.remove(owner);
if (old != null) old.forEach(ProblemsCollector.getInstance(project)::problemDisappeared);
}
@@ -3,7 +3,9 @@
<description>Shared Kit designer with isolated target previews and IDE-owned FXML documents.</description>
<depends>com.intellij.modules.platform</depends>
<depends>com.intellij.problemsView</depends>
<depends>com.intellij.modules.lang</depends>
<extensions defaultExtensionNs="com.intellij">
<annotator language="XML" implementationClass="de.neitzel.fxdesigner.intellij.DiagnosticAnnotator"/>
<fileEditorProvider implementation="de.neitzel.fxdesigner.intellij.DesignerEditorProvider"/>
</extensions>
</idea-plugin>
@@ -0,0 +1,76 @@
package de.neitzel.fxdesigner.intellij;
import com.intellij.codeInsight.daemon.impl.AnnotationHolderImpl;
import com.intellij.codeInsight.intention.IntentionAction;
import com.intellij.lang.annotation.AnnotationSession;
import com.intellij.openapi.command.WriteCommandAction;
import com.intellij.psi.PsiDocumentManager;
import com.intellij.testFramework.fixtures.BasePlatformTestCase;
import de.neitzel.fxdesigner.contracts.EditBatch.Replacement;
import de.neitzel.fxdesigner.host.HostServices.*;
import java.util.*;
public final class DiagnosticAnnotatorTest extends BasePlatformTestCase {
public void testRangesActionsAndStaleOrClosedEvidence() {
var file = myFixture.configureByText("View.fxml", "<VBox direction='bad'/>");
var document = myFixture.getEditor().getDocument();
var store = DiagnosticAnnotations.get(getProject());
int at = document.getText().indexOf("bad");
var fix = new QuickFix("Set direction", List.of(new Replacement(at, at + 3, "view")));
var d =
new Diagnostic(
"FXML",
Severity.ERROR,
"Invalid direction",
Optional.of(new Location(java.net.URI.create(file.getVirtualFile().getUrl()), at)),
Optional.of(new SourceRange(at, at + 3)),
List.of(fix));
var invoked = new java.util.concurrent.atomic.AtomicInteger();
store.replace(
"one", file.getVirtualFile(), document, List.of(d), f -> invoked.incrementAndGet());
var holder = new AnnotationHolderImpl(new AnnotationSession(file), false);
holder.runAnnotatorWithContext(file, new DiagnosticAnnotator());
assertEquals(1, holder.size());
assertEquals(at, holder.getFirst().getStartOffset());
assertEquals(at + 3, holder.getFirst().getEndOffset());
IntentionAction action = holder.getFirst().getQuickFixes().getFirst().quickFix;
assertTrue(action.isAvailable(getProject(), myFixture.getEditor(), file));
action.invoke(getProject(), myFixture.getEditor(), file);
assertEquals(1, invoked.get());
WriteCommandAction.runWriteCommandAction(getProject(), () -> document.insertString(0, " "));
PsiDocumentManager.getInstance(getProject()).commitAllDocuments();
assertFalse(action.isAvailable(getProject(), myFixture.getEditor(), file));
action.invoke(getProject(), myFixture.getEditor(), file);
assertEquals(1, invoked.get());
holder = new AnnotationHolderImpl(new AnnotationSession(file), false);
holder.runAnnotatorWithContext(file, new DiagnosticAnnotator());
assertTrue(holder.isEmpty());
store.clear("one");
assertFalse(action.isAvailable(getProject(), myFixture.getEditor(), file));
}
public void testIdenticalOwnersAndReadOnlyActionsAreIndependent() {
var file = myFixture.configureByText("Other.fxml", "<VBox/>");
var document = myFixture.getEditor().getDocument();
var store = DiagnosticAnnotations.get(getProject());
var fix = new QuickFix("Remove", List.of(new Replacement(0, 7, "")));
var d =
new Diagnostic(
"FXML",
Severity.WARNING,
"warning",
Optional.of(new Location(java.net.URI.create(file.getVirtualFile().getUrl()), 0)),
Optional.of(new SourceRange(0, 7)),
List.of(fix));
store.replace("one", file.getVirtualFile(), document, List.of(d), f -> {});
store.replace("two", file.getVirtualFile(), document, List.of(d), f -> {});
store.clear("one");
assertEquals(1, store.current(file).size());
var p = store.current(file).getFirst();
document.setReadOnly(true);
assertFalse(store.available(p, fix));
document.setReadOnly(false);
store.clear("two");
assertTrue(store.current(file).isEmpty());
}
}