diff --git a/plugins/terminal/src/org/jetbrains/plugins/terminal/block/reworked/hyperlinks/TerminalHyperlinkHighlighter.kt b/plugins/terminal/src/org/jetbrains/plugins/terminal/block/reworked/hyperlinks/TerminalHyperlinkHighlighter.kt index 268ffb650339..270f3da5b717 100644 --- a/plugins/terminal/src/org/jetbrains/plugins/terminal/block/reworked/hyperlinks/TerminalHyperlinkHighlighter.kt +++ b/plugins/terminal/src/org/jetbrains/plugins/terminal/block/reworked/hyperlinks/TerminalHyperlinkHighlighter.kt @@ -3,29 +3,44 @@ package org.jetbrains.plugins.terminal.block.reworked.hyperlinks import com.intellij.execution.impl.EditorHyperlinkSupport import com.intellij.execution.impl.ExpirableTokenProvider +import com.intellij.openapi.application.EDT +import com.intellij.openapi.application.ModalityState +import com.intellij.openapi.application.asContextElement import com.intellij.openapi.editor.Document import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.event.DocumentEvent +import com.intellij.openapi.editor.event.DocumentListener import com.intellij.openapi.project.Project +import com.intellij.util.SystemProperties import com.intellij.util.asDisposable import com.intellij.util.concurrency.annotations.RequiresEdt -import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.* import org.jetbrains.annotations.ApiStatus import org.jetbrains.annotations.TestOnly import org.jetbrains.plugins.terminal.block.hyperlinks.CompositeFilterWrapper import org.jetbrains.plugins.terminal.block.reworked.TerminalOutputModel import org.jetbrains.plugins.terminal.block.reworked.TerminalOutputModelListener import kotlin.math.max +import kotlin.math.min +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds @ApiStatus.Internal class TerminalHyperlinkHighlighter private constructor( project: Project, private val editor: Editor, - coroutineScope: CoroutineScope, + private val coroutineScope: CoroutineScope, ) { private val filterWrapper: CompositeFilterWrapper = CompositeFilterWrapper(project, coroutineScope) private val tokenProvider: ExpirableTokenProvider = ExpirableTokenProvider() + // should be accessed in EDT + private var actualStartOffset: Int = INFINITE + + // should be accessed in EDT + private var delayedHighlightingJob: Job? = null + private val document: Document get() = editor.document @@ -36,6 +51,17 @@ class TerminalHyperlinkHighlighter private constructor( filterWrapper.addFiltersUpdatedListener { rehighlightAll() } + document.addDocumentListener(object : DocumentListener { + override fun documentChanged(event: DocumentEvent) { + // There are only two possible types of document changes: + // 1. Deletion from the document top (trimming). + // 2. Replacement of the document bottom. + if (event.getOffset() == 0 && event.getNewLength() == 0) { + // This is a deletion from the document top. + actualStartOffset = max(0, actualStartOffset - event.getOldLength()) + } + } + }) } private fun rehighlightAll() { @@ -46,9 +72,21 @@ class TerminalHyperlinkHighlighter private constructor( @RequiresEdt(generateAssertion = false) fun highlightHyperlinks(startOffset: Int) { + actualStartOffset = min(actualStartOffset, startOffset) + if (delayedHighlightingJob.isNotActive()) { + delayedHighlightingJob = coroutineScope.launch(Dispatchers.EDT + ModalityState.any().asContextElement()) { + delay(DELAY) + doHyperlinkHighlighting() + } + } + } + + private fun doHyperlinkHighlighting() { val filter = filterWrapper.getFilter() ?: return // if null, `rehighlightAll` will follow if (filter.isEmpty) return if (document.textLength == 0) return + val startOffset = actualStartOffset + actualStartOffset = INFINITE val startLine = document.getLineNumber(startOffset) val endLine = max(0, document.lineCount - 1) @@ -71,6 +109,11 @@ class TerminalHyperlinkHighlighter private constructor( filterWrapper.awaitFiltersComputed() } + @TestOnly + internal suspend fun awaitDelayedHighlightings() { + delayedHighlightingJob?.join() + } + @TestOnly internal fun getHyperlinkSupport(): EditorHyperlinkSupport = hyperlinkSupport @@ -84,6 +127,18 @@ class TerminalHyperlinkHighlighter private constructor( }) return hyperlinkHighlighter } - } + /** + * @see com.intellij.execution.impl.ConsoleViewImpl.DEFAULT_FLUSH_DELAY + */ + private val DELAY: Duration = SystemProperties.getIntProperty("console.flush.delay.ms", 200).milliseconds + + /** + * A value greater than possible document length. + * A document length cannot be greater than 2048 MB, because [Document.getTextLength] return type is `Int`. + */ + private const val INFINITE: Int = Integer.MAX_VALUE + + private fun Job?.isNotActive(): Boolean = this == null || !this.isActive + } } diff --git a/plugins/terminal/tests/org/jetbrains/plugins/terminal/reworked/TerminalHyperlinkHighlighterTest.kt b/plugins/terminal/tests/org/jetbrains/plugins/terminal/reworked/TerminalHyperlinkHighlighterTest.kt index 49f080ee8875..d552af8f73a7 100644 --- a/plugins/terminal/tests/org/jetbrains/plugins/terminal/reworked/TerminalHyperlinkHighlighterTest.kt +++ b/plugins/terminal/tests/org/jetbrains/plugins/terminal/reworked/TerminalHyperlinkHighlighterTest.kt @@ -254,7 +254,7 @@ internal class TerminalHyperlinkHighlighterTest : BasePlatformTestCase() { } } - private fun assertHyperlinks(linkText: String, expectedCount: Int): List { + private suspend fun assertHyperlinks(linkText: String, expectedCount: Int): List { val text = editor.document.text val expectedRanges = text.allOccurrencesOf(linkText).map { TextRange(it, it + linkText.length) @@ -268,13 +268,14 @@ internal class TerminalHyperlinkHighlighterTest : BasePlatformTestCase() { return rangeHighlighters } - private fun awaitHyperlinks(timeout: Duration): List { + private suspend fun awaitHyperlinks(timeout: Duration): List { + hyperlinkHighlighter.awaitDelayedHighlightings() val hyperlinkSupport = hyperlinkHighlighter.getHyperlinkSupport() hyperlinkSupport.waitForPendingFilters(timeout.inWholeMilliseconds) return hyperlinkSupport.getAllHyperlinks(0, editor.document.textLength) } - private fun assertInlays(inlayText: String, expectedCount: Int): List> { + private suspend fun assertInlays(inlayText: String, expectedCount: Int): List> { val text = editor.document.text val expectedOffsets = text.allOccurrencesOf(inlayText).map { it + inlayText.length }.toList() val inlays = awaitInlays(AWAIT_FILTER_TIMEOUT) @@ -287,7 +288,8 @@ internal class TerminalHyperlinkHighlighterTest : BasePlatformTestCase() { return inlays } - private fun awaitInlays(timeout: Duration): List> { + private suspend fun awaitInlays(timeout: Duration): List> { + hyperlinkHighlighter.awaitDelayedHighlightings() val hyperlinkSupport = hyperlinkHighlighter.getHyperlinkSupport() hyperlinkSupport.waitForPendingFilters(timeout.inWholeMilliseconds) return hyperlinkSupport.collectAllInlays()