diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLens.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLens.kt new file mode 100644 index 0000000..a6abbd8 --- /dev/null +++ b/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLens.kt @@ -0,0 +1,73 @@ +package com.chylex.intellij.inspectionlens + +import com.chylex.intellij.inspectionlens.editor.EditorInlayLensManager +import com.chylex.intellij.inspectionlens.editor.LensMarkupModelListener +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.rd.createLifetime +import com.intellij.openapi.rd.createNestedDisposable +import com.jetbrains.rd.util.lifetime.intersect + +/** + * Handles installation and uninstallation of plugin features in editors. + */ +internal object InspectionLens { + const val PLUGIN_ID = "com.chylex.intellij.inspectionlens" + + /** + * Installs lenses into [editor]. + */ + fun install(editor: TextEditor) { + LensMarkupModelListener.register(editor.editor, createEditorDisposable(editor)) + } + + /** + * Installs lenses into all open editors. + */ + fun install() { + forEachOpenEditor(::install) + } + + /** + * Uninstalls lenses from all open editors. + */ + fun uninstall() { + forEachOpenEditor { + EditorInlayLensManager.remove(it.editor) + } + } + + /** + * Refreshes lenses in all open editors. + */ + fun refresh() { + forEachOpenEditor { + LensMarkupModelListener.refresh(it.editor) + } + } + + /** + * Creates a [Disposable] that will be disposed when either the [TextEditor] is disposed or the plugin is unloaded. + */ + private fun createEditorDisposable(textEditor: TextEditor): Disposable { + val pluginLifetime = ApplicationManager.getApplication().getService(InspectionLensPluginDisposableService::class.java).createLifetime() + val editorLifetime = textEditor.createLifetime() + return pluginLifetime.intersect(editorLifetime).createNestedDisposable() + } + + /** + * Executes [action] on all open editors. + */ + private inline fun forEachOpenEditor(action: (TextEditor) -> Unit) { + val projectManager = ProjectManager.getInstanceIfCreated() ?: return + + for (project in projectManager.openProjects.filterNot { it.isDisposed }) { + for (editor in FileEditorManager.getInstance(project).allEditors.filterIsInstance<TextEditor>()) { + action(editor) + } + } + } +} diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLensFileOpenedListener.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLensFileOpenedListener.kt index c5fccce..1e7bfd0 100644 --- a/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLensFileOpenedListener.kt +++ b/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLensFileOpenedListener.kt @@ -1,6 +1,5 @@ package com.chylex.intellij.inspectionlens -import com.chylex.intellij.inspectionlens.editor.LensMarkupModelListener import com.intellij.openapi.fileEditor.FileEditorManager import com.intellij.openapi.fileEditor.FileOpenedSyncListener import com.intellij.openapi.fileEditor.TextEditor @@ -8,14 +7,14 @@ import com.intellij.openapi.fileEditor.ex.FileEditorWithProvider import com.intellij.openapi.vfs.VirtualFile /** - * Listens for newly opened editors, and installs a [LensMarkupModelListener] on them. + * Installs [InspectionLens] in newly opened editors. */ class InspectionLensFileOpenedListener : FileOpenedSyncListener { override fun fileOpenedSync(source: FileEditorManager, file: VirtualFile, editorsWithProviders: List<FileEditorWithProvider>) { for (editorWrapper in editorsWithProviders) { val fileEditor = editorWrapper.fileEditor if (fileEditor is TextEditor) { - LensMarkupModelListener.install(fileEditor) + InspectionLens.install(fileEditor) } } } diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLensPluginListener.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLensPluginListener.kt index 379284c..9f29984 100644 --- a/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLensPluginListener.kt +++ b/src/main/kotlin/com/chylex/intellij/inspectionlens/InspectionLensPluginListener.kt @@ -1,45 +1,21 @@ package com.chylex.intellij.inspectionlens -import com.chylex.intellij.inspectionlens.editor.EditorInlayLensManager -import com.chylex.intellij.inspectionlens.editor.LensMarkupModelListener import com.intellij.ide.plugins.DynamicPluginListener import com.intellij.ide.plugins.IdeaPluginDescriptor -import com.intellij.openapi.fileEditor.FileEditorManager -import com.intellij.openapi.fileEditor.TextEditor -import com.intellij.openapi.project.ProjectManager /** - * Handles dynamic plugin loading. - * - * On load, it installs the [LensMarkupModelListener] to all open editors. - * On unload, it removes all lenses from all open editors. + * Installs [InspectionLens] in open editors when the plugin is loaded, and uninstalls it when the plugin is unloaded. */ class InspectionLensPluginListener : DynamicPluginListener { - companion object { - private const val PLUGIN_ID = "com.chylex.intellij.inspectionlens" - - private inline fun ProjectManager.forEachEditor(action: (TextEditor) -> Unit) { - for (project in this.openProjects.filterNot { it.isDisposed }) { - val fileEditorManager = FileEditorManager.getInstance(project) - - for (editor in fileEditorManager.allEditors.filterIsInstance<TextEditor>()) { - action(editor) - } - } - } - } - override fun pluginLoaded(pluginDescriptor: IdeaPluginDescriptor) { - if (pluginDescriptor.pluginId.idString == PLUGIN_ID) { - ProjectManager.getInstanceIfCreated()?.forEachEditor(LensMarkupModelListener.Companion::install) + if (pluginDescriptor.pluginId.idString == InspectionLens.PLUGIN_ID) { + InspectionLens.install() } } override fun beforePluginUnload(pluginDescriptor: IdeaPluginDescriptor, isUpdate: Boolean) { - if (pluginDescriptor.pluginId.idString == PLUGIN_ID) { - ProjectManager.getInstanceIfCreated()?.forEachEditor { - EditorInlayLensManager.remove(it.editor) - } + if (pluginDescriptor.pluginId.idString == InspectionLens.PLUGIN_ID) { + InspectionLens.uninstall() } } } diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorInlayLensManager.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorInlayLensManager.kt index 189847f..660055a 100644 --- a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorInlayLensManager.kt +++ b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/EditorInlayLensManager.kt @@ -12,7 +12,7 @@ import com.intellij.openapi.util.Key */ class EditorInlayLensManager private constructor(private val editor: Editor) { companion object { - private val KEY = Key<EditorInlayLensManager>(EditorInlayLensManager::class.java.name) + private val EDITOR_KEY = Key<EditorInlayLensManager>(EditorInlayLensManager::class.java.name) /** * Highest allowed severity for the purposes of sorting multiple highlights at the same offset. @@ -22,14 +22,14 @@ class EditorInlayLensManager private constructor(private val editor: Editor) { private const val MAXIMUM_POSITION = ((Int.MAX_VALUE / MAXIMUM_SEVERITY) * 2) - 1 fun getOrCreate(editor: Editor): EditorInlayLensManager { - return editor.getUserData(KEY) ?: EditorInlayLensManager(editor).also { editor.putUserData(KEY, it) } + return editor.getUserData(EDITOR_KEY) ?: EditorInlayLensManager(editor).also { editor.putUserData(EDITOR_KEY, it) } } fun remove(editor: Editor) { - val manager = editor.getUserData(KEY) + val manager = editor.getUserData(EDITOR_KEY) if (manager != null) { manager.hideAll() - editor.putUserData(KEY, null) + editor.putUserData(EDITOR_KEY, null) } } @@ -82,8 +82,10 @@ class EditorInlayLensManager private constructor(private val editor: Editor) { } fun hideAll() { - executeInInlayBatchMode(inlays.size) { inlays.values.forEach(Inlay<*>::dispose) } - inlays.clear() + if (inlays.isNotEmpty()) { + executeInInlayBatchMode(inlays.size) { inlays.values.forEach(Inlay<*>::dispose) } + inlays.clear() + } } private fun getInlayHintPriority(info: HighlightInfo): Int { diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensMarkupModelListener.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensMarkupModelListener.kt index 603ea65..c9a91df 100644 --- a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensMarkupModelListener.kt +++ b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensMarkupModelListener.kt @@ -1,25 +1,22 @@ package com.chylex.intellij.inspectionlens.editor -import com.chylex.intellij.inspectionlens.InspectionLensPluginDisposableService import com.chylex.intellij.inspectionlens.utils.DebouncingInvokeOnDispatchThread import com.intellij.codeInsight.daemon.impl.HighlightInfo import com.intellij.lang.annotation.HighlightSeverity -import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.Disposable import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.ex.MarkupModelEx import com.intellij.openapi.editor.ex.RangeHighlighterEx import com.intellij.openapi.editor.impl.DocumentMarkupModel import com.intellij.openapi.editor.impl.event.MarkupModelListener import com.intellij.openapi.editor.markup.RangeHighlighter -import com.intellij.openapi.fileEditor.TextEditor -import com.intellij.openapi.rd.createLifetime -import com.intellij.openapi.rd.createNestedDisposable -import com.jetbrains.rd.util.lifetime.intersect +import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.Key /** * Listens for inspection highlights and reports them to [EditorInlayLensManager]. */ -class LensMarkupModelListener private constructor(editor: Editor) : MarkupModelListener { +internal class LensMarkupModelListener private constructor(editor: Editor) : MarkupModelListener { private val lens = EditorInlayLensManager.getOrCreate(editor) private val showOnDispatchThread = DebouncingInvokeOnDispatchThread(lens::showAll) @@ -51,7 +48,12 @@ class LensMarkupModelListener private constructor(editor: Editor) : MarkupModelL } } + private fun showAllValid(highlighters: Array<RangeHighlighter>) { + highlighters.forEach(::showIfValid) + } + companion object { + private val EDITOR_KEY = Key<LensMarkupModelListener>(LensMarkupModelListener::class.java.name) private val MINIMUM_SEVERITY = HighlightSeverity.TEXT_ATTRIBUTES.myVal + 1 private fun getFilteredHighlightInfo(highlighter: RangeHighlighter): HighlightInfo? { @@ -74,25 +76,36 @@ class LensMarkupModelListener private constructor(editor: Editor) : MarkupModelL } } + private fun getMarkupModel(editor: Editor): MarkupModelEx? { + return DocumentMarkupModel.forDocument(editor.document, editor.project, false) as? MarkupModelEx + } + /** - * Attaches a new [LensMarkupModelListener] to the document model of the provided [TextEditor], and reports all existing inspection highlights to [EditorInlayLensManager]. - * - * The [LensMarkupModelListener] will be disposed when either the [TextEditor] is disposed, or via [InspectionLensPluginDisposableService] when the plugin is unloaded. + * Attaches a new [LensMarkupModelListener] to the [Editor], and reports all existing inspection highlights to [EditorInlayLensManager]. */ - fun install(textEditor: TextEditor) { - val editor = textEditor.editor - val markupModel = DocumentMarkupModel.forDocument(editor.document, editor.project, false) - if (markupModel is MarkupModelEx) { - val pluginLifetime = ApplicationManager.getApplication().getService(InspectionLensPluginDisposableService::class.java).createLifetime() - val editorLifetime = textEditor.createLifetime() - - val listener = LensMarkupModelListener(editor) - markupModel.addMarkupModelListener(pluginLifetime.intersect(editorLifetime).createNestedDisposable(), listener) - - for (highlighter in markupModel.allHighlighters) { - listener.showIfValid(highlighter) - } + fun register(editor: Editor, disposable: Disposable) { + if (editor.getUserData(EDITOR_KEY) != null) { + return } + + val markupModel = getMarkupModel(editor) ?: return + val listener = LensMarkupModelListener(editor) + + editor.putUserData(EDITOR_KEY, listener) + Disposer.register(disposable) { editor.putUserData(EDITOR_KEY, null) } + + markupModel.addMarkupModelListener(disposable, listener) + listener.showAllValid(markupModel.allHighlighters) + } + + /** + * Recreates all inspection highlights in the [Editor]. + */ + fun refresh(editor: Editor) { + val listener = editor.getUserData(EDITOR_KEY) ?: return + val markupModel = getMarkupModel(editor) ?: return + + listener.showAllValid(markupModel.allHighlighters) } } } diff --git a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensSeverity.kt b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensSeverity.kt index c19dd36..afc42ff 100644 --- a/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensSeverity.kt +++ b/src/main/kotlin/com/chylex/intellij/inspectionlens/editor/LensSeverity.kt @@ -1,5 +1,7 @@ package com.chylex.intellij.inspectionlens.editor +import com.chylex.intellij.inspectionlens.InspectionLens +import com.chylex.intellij.inspectionlens.utils.DebouncingInvokeOnDispatchThread import com.intellij.lang.annotation.HighlightSeverity import com.intellij.spellchecker.SpellCheckerSeveritiesProvider import com.intellij.ui.ColorUtil @@ -40,13 +42,20 @@ enum class LensSeverity(baseColor: Color, lightThemeDarkening: Int, darkThemeBri SpellCheckerSeveritiesProvider.TYPO to TYPO, )) + private val refreshLater = DebouncingInvokeOnDispatchThread<Unit> { InspectionLens.refresh() } + /** - * Registers a mapping from a [HighlightSeverity] to a [LensSeverity]. Does not refresh existing editors. + * Registers a mapping from a [HighlightSeverity] to a [LensSeverity], and refreshes all open editors. */ internal fun registerMapping(severity: HighlightSeverity, lensSeverity: LensSeverity) { - mapping[severity] = lensSeverity + if (mapping.put(severity, lensSeverity) != lensSeverity) { + refreshLater.enqueue(Unit) + } } + /** + * Returns the [LensSeverity] associated with the [HighlightSeverity], or [OTHER] if there no explicit mapping is found. + */ fun from(severity: HighlightSeverity): LensSeverity { return mapping.getOrDefault(severity, OTHER) }