Inserting "LightBulb" icon in gutter, next to lint issues

Currently, I’m gathering the diagnostics info (lint warnings, errors, etc.) this way:

  val diagnostics: List<HighlightInfo> by
      lazy(LazyThreadSafetyMode.NONE) {
        /*
        HighlightInfo contains metadata regarding highlighted lint info such as severity level,
        highlighted code (and associated offsets), lint message, associated Quick Fix Action, etc.
        */
        val warningsAndErrorsWithinSelectedCode: List<HighlightInfo> = mutableListOf()
        /*
        This seems to be the only way to get the same diagnostics as the ones displayed in the
        "Problems" tool window (linked to the "TrafficLightRenderer") for the selected code.
        Moreover, this is fetched lazily because the Daemon takes a while to complete, so the
        additional delay helps improve the probability that we actually have some warnings listed.
        However, we do not want to block on this because it's not valuable enough to delay the
        request issued by a user.
        */
        DaemonCodeAnalyzerEx.processHighlights(
            editor.document,
            project,
            HighlightSeverity.WEAK_WARNING,
            selectedOffsetStart,
            selectedOffsetEnd,
            Processors.cancelableCollectProcessor(warningsAndErrorsWithinSelectedCode))
        return@lazy warningsAndErrorsWithinSelectedCode
      }

Is there a way to simply subscribe to a new lint warning or error being resurfaced in an Editor?

My goal would be to place a LightBulb icon (with an associated QuickFix AnAction that I would provide) in the gutter next to the lines once a warning/error is detected.

Moreover, if at least one of such LightBulb is displayed, I would like to display an icon with associated AnAction in the “floating toolbar” that is used for the “Gradle Sync” hover icon when viewing a build.gradle file. I’m not entirely sure what this is called.

Please explain your use case. Warnings and errors are displayed in the editor already on the right stripe by default.

I want to resurface automated Quick Fix actions that we implemented on our side.
To do so, I need to associate each warning and error with a light bulb displayed in the gutter. Clicking this light bulb action will result in our AnAction being triggered.

Thus, that means that I should be able to register some form of subscription to document events to be able to insert those light bulbs whenever a new lint warning or error is found from the analysis that is run in the background by the IDE.

Ideally, the Gutter Icon (light bulb) would actually essentially contain all the same actions presented by the “Intention” one that floats around when we place the caret on a highlighted lint.
But then, among this list of pre-existing actions, I would like to insert our own as the top one.

Seems like I’ll have to deal with LineMarkerProvider, and declare that EP with language="any" to potentially get it to work.
On top of that, usage of MergeableLineMarkerInfo seems like the way to go to gather all other quick fix actions in the gutter.

Any further insight would be appreciated.

Question

How do I get to programmatically remove the LineMarkers I added?
Once one of the proposed actions is run, and the lint warning is gone, I want to simply remove all the other MergedLineMarkers that were also associated with this PsiElement that was being highlighted.
For example, if the user select the “Add type explicitely” quick fix, then all the gutter icons I added for that warning should be removed.

Current implementation

Here is my current draft, which comes pretty close to my requirements, but still needs some modifications:

class GutterIconProvider : LineMarkerProviderDescriptor() {
  override fun getIcon(): Icon = PluginIcons.MY_ICON

  override fun getName(): String = "Our GutterIconProvider"

  /** This traverses all PsiElement of the opened file. */
  override fun getLineMarkerInfo(element: PsiElement): LineMarkerInfo<*>? = null

  override fun collectSlowLineMarkers(
      psiElements: MutableList<out PsiElement>,
      result: MutableCollection<in LineMarkerInfo<*>>
  ) {
    runReadAction {
      if (psiElements.isEmpty()) return@runReadAction

      /*
      HighlightInfo contains metadata regarding highlighted lint info such as severity level,
      highlighted code (and associated offsets), lint message, associated Quick Fix Action, etc.
      */
      val warningsAndErrorsWithinOpenedDoc: MutableList<HighlightInfo> = mutableListOf()

      // All elements are contained within the same file
      val psiElement = psiElements.first()
      val project = psiElement.project
      val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return@runReadAction
      val doc = psiElement.containingFile.viewProvider.document

      /*
      This seems to be the only way to get the same diagnostics as the ones displayed in the
      "Problems" tool window (linked to the "TrafficLightRenderer") for the selected code.
      */
      // Filling the list with results
      DaemonCodeAnalyzerEx.processHighlights(
          doc,
          project,
          HighlightSeverity.WEAK_WARNING, // this level, and above
          0, // from start
          doc.textLength, // to finish
          Processors.cancelableCollectProcessor(warningsAndErrorsWithinOpenedDoc))

      for (highlightInfo in warningsAndErrorsWithinOpenedDoc) {
        val element =
            psiElements.firstOrNull {
              it.textRange.intersects(highlightInfo.startOffset, highlightInfo.endOffset)
            } ?: continue // TODO: do for all elements that intersect

        highlightInfo.findRegisteredQuickFix { descriptor, range ->
          val action = descriptor.action
          val otherMarker =
              OtherLineMarkerInfo(
                  highlightInfo,
                  element,
                  "Run `${action.text}` to fix `${highlightInfo.description}`") { _, elt ->
                    runWriteAction {
                      CommandProcessor.getInstance().runUndoTransparentAction {
                        action.invoke(project, editor, elt.containingFile)
                        // TODO: remove all other gutter icons that were merged with this one
                      }
                    }
                  }

          result.add(otherMarker)
        }

        val ourMarker =
            OurCustomLineMarkerInfo(highlightInfo, element, "Fix `${highlightInfo.description}`") {
              doSomething()
            }
        result.add(ourMarker) // added last to impose its custom icon in the gutter
      }
    }
  }
}

The associated MergeableLineMarkerInfo classes:

class OurCustomLineMarkerInfo(
    highlightInfo: HighlightInfo,
    element: PsiElement,
    description: String,
    onClick: Runnable,
) :
    OurLineMarkerInfo(
        highlightInfo,
        element,
        { description },
        { description },
        { description },
        { _, _ -> onClick.run() },
        PluginIcons.MY_ICON)

class OtherLineMarkerInfo(
    highlightInfo: HighlightInfo,
    element: PsiElement,
    description: String,
    navigationHandler: ((MouseEvent, PsiElement) -> Unit)?,
) :
    OurLineMarkerInfo(
        highlightInfo,
        element,
        { description },
        { description },
        { description },
        { event, elt -> navigationHandler?.invoke(event, elt) },
        PluginIcons.LIGHT_BULB)

abstract class OurLineMarkerInfo(
    highlightInfo: HighlightInfo,
    element: PsiElement,
    tooltipProvider: (PsiElement) -> String, // hover description
    presentationProvider: ((PsiElement) -> String), // action description in popup
    accessibleNameProvider: (() -> String), // not sure where that's used...
    navigationHandler: ((MouseEvent, PsiElement) -> Unit)?, // "onClick"
    icon: Icon, // the last Marker inserted will determine the gutter icon
    alignment: GutterIconRenderer.Alignment = GutterIconRenderer.Alignment.RIGHT
) :
    MergeableLineMarkerInfo<PsiElement>(
        element,
        TextRange(highlightInfo.startOffset, highlightInfo.endOffset),
        icon,
        tooltipProvider,
        presentationProvider,
        { event, elt -> navigationHandler?.invoke(event, elt) },
        alignment,
        accessibleNameProvider) {

  override fun canMergeWith(info: MergeableLineMarkerInfo<*>): Boolean =  info is OurLineMarkerInfo

  override fun getCommonIcon(infos: MutableList<out MergeableLineMarkerInfo<*>>): Icon = icon
}

Alternate implementation

This one was falling short on a few requirements, so I stopped exploring it, but it seemed like it was more in line with some of my other expectations, so I’ll show it in here as well, for posterity, and to potentially help spark more discussions.
Among other things, I like how this approach is more responsive (it inserts icons in the gutter a lot faster), but unfortunately the icons appear as static images instead of as clickable icons (e.g. the cursor remains the same when you hover it).


class MyStartupActivity : ProjectActivity {
  override suspend fun execute(project: Project) {
    val msgBusConnection = project.messageBus.connect()
    msgBusConnection.subscribe(
        DaemonCodeAnalyzer.DAEMON_EVENT_TOPIC,
        object : DaemonCodeAnalyzer.DaemonListener {
          // TODO: can use all events to always update based on latest info, instead of delaying
          override fun daemonFinished() {
            injectIcons(project)
            super.daemonFinished()
          }
        })
  }

  private fun injectIcons(project: Project) {
    val editor = FileEditorManager.getInstance(project).selectedTextEditor ?: return
    val doc = editor.document
    val psiFile = PsiDocumentManager.getInstance(project).getPsiFile(doc) ?: return

    runReadAction {
      val warningsAndErrorsWithinOpenedDoc: MutableList<HighlightInfo> = mutableListOf()

      DaemonCodeAnalyzerEx.processHighlights(
          doc,
          project,
          HighlightSeverity.WEAK_WARNING,
          0,
          doc.textLength,
          Processors.cancelableCollectProcessor(warningsAndErrorsWithinOpenedDoc))

      warningsAndErrorsWithinOpenedDoc.forEach { hi ->
        // Track existing highlighters by their range
        val existingHighlighters =
            editor.markupModel.allHighlighters
                .filter { it.gutterIconRenderer != null }
                .associateBy { TextRange(it.startOffset, it.endOffset) }

        // Remove existing one if it exists
        val existingHighlighter = existingHighlighters[TextRange(hi.startOffset, hi.endOffset)]
        existingHighlighter?.dispose()

        editor.markupModel
            .addRangeHighlighter(
                hi.startOffset,
                hi.endOffset,
                0, // Layer (use appropriate layer for your use case)
                null, // TextAttributes can be null if you only want gutter icon
                HighlighterTargetArea.EXACT_RANGE)
            .apply {
              gutterIconRenderer =
                  object : GutterIconRenderer() {
                    override fun equals(other: Any?): Boolean {
                      return this === other ||
                          (other is GutterIconRenderer && this.icon == other.icon)
                    }

                    override fun hashCode(): Int = icon.hashCode()

                    override fun getIcon(): Icon = PluginIcons.MY_ICON

                    override fun getTooltipText(): String = "TEST"

                    override fun getClickAction(): AnAction? {
                      return object : AnAction() {
                        override fun actionPerformed(e: AnActionEvent) {
                          println("Gutter icon clicked!")
                          this@apply.dispose() // removing the Highlighter that was just clicked
                        }
                      }
                    }
                  }
            }
      }

      // This adds the icon, but also duplicates warnings
      // UpdateHighlightersUtil.setHighlightersToEditor(
      //     project, doc, 0, doc.textLength, infosToAdd, editor.colorsScheme, 0)
    }
  }
}

Hello. I think the easiest way to subscribe to the new annoations would be

    MarkupModelEx modelEx = (MarkupModelEx)DocumentMarkupModel.forDocument(getDocument(getFile()), getProject(), true);
    modelEx.addMarkupModelListener(getTestRootDisposable(), new MarkupModelListener() {
      @Override
      public void afterAdded(@NotNull RangeHighlighterEx highlighter) {
        // new range highlighter appeared, it might mean the new highlighting annotation created.
       // you might want to restart your DaemonCodeAnalyzerEx.processHighlights(...) stuff
      }

      @Override
      public void afterRemoved(@NotNull RangeHighlighterEx highlighter) {
        
      }
    });