[terminal] don't modify output editor document after highlighting hyperlinks (IJPL-101373)

Otherwise, it cancels hyperlinks highlighter running in background, because of changed `document.getModificationStamp()`.

Merge-request: IJ-MR-134015
Merged-by: Sergey Simonchik <sergey.simonchik@jetbrains.com>

GitOrigin-RevId: 7a097bb634aa14875f1423304e8b023d58cd791a
This commit is contained in:
Sergey Simonchik
2024-05-10 15:39:34 +02:00
committed by intellij-monorepo-bot
parent b72dff11e8
commit f66d725971
4 changed files with 123 additions and 24 deletions

View File

@@ -29,6 +29,7 @@ import kotlin.Unit;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.TestOnly;
import java.awt.*;
import java.awt.event.MouseEvent;
@@ -217,6 +218,12 @@ public final class EditorHyperlinkSupport {
return getRangeHighlighters(startOffset, endOffset, true, false, editor);
}
@ApiStatus.Internal
@TestOnly
public @NotNull List<RangeHighlighter> getAllHyperlinks(int startOffset, int endOffset) {
return getHyperlinks(startOffset, endOffset, myEditor);
}
/**
* Retrieves hyperlinks / highlightings within the specified range in the editor (both offsets are inclusive).
*/

View File

@@ -8,8 +8,11 @@ import com.jediterm.terminal.model.LinesBuffer
import com.jediterm.terminal.model.TerminalLine
import com.jediterm.terminal.model.TerminalTextBuffer
import org.jetbrains.plugins.terminal.TerminalUtil
import org.jetbrains.plugins.terminal.util.ShellType
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.max
import kotlin.math.min
internal class ShellCommandOutputScraper(
private val session: BlockTerminalSession,
@@ -102,7 +105,7 @@ private class OutputBuilder(private val commandEndMarker: String?) {
private fun addTextChunk(text: String, style: TextStyle) {
if (text.isNotEmpty()) {
repeat(pendingNewLines) {
output.append("\n")
output.append(NEW_LINE)
}
pendingNewLines = 0
val startOffset = output.length
@@ -143,7 +146,7 @@ private class OutputBuilder(private val commandEndMarker: String?) {
var textInd: Int = text.length
for (suffixInd in suffix.length - 1 downTo 0) {
textInd--
while (textInd >= 0 && text[textInd] == '\n') {
while (textInd >= 0 && text[textInd] == NEW_LINE) {
textInd--
}
if (textInd < 0 || text[textInd] != suffix[suffixInd]) {
@@ -160,3 +163,54 @@ internal data class StyleRange(val startOffset: Int, val endOffset: Int, val sty
internal interface ShellCommandOutputListener {
fun commandOutputChanged(output: StyledCommandOutput) {}
}
private const val NEW_LINE: Char = '\n'
/**
* Refines command output by dropping the trailing `\n` to avoid showing the last empty line in the command block.
* Also, trims tailing whitespaces in case of Zsh: they are added to show '%' character at the end of the
* last line without a newline.
* Zsh adds the whitespaces after command finish and before calling `precmd` hook, so IDE cannot
* identify correctly where command output ends exactly => trim tailing whitespaces as a workaround.
* See `PROMPT_CR` and `PROMPT_SP` Zsh options, both are enabled by default:
* https://zsh.sourceforge.io/Doc/Release/Options.html#Prompting
*
* Roughly, Zsh prints the following after each command and before prompt:
* 1. `PROMPT_EOL_MARK` (by default, '%' for a normal user or a '#' for root)
* 2. `$COLUMNS - 1` spaces
* 3. \r
* 4. A single space
* 5. \r
* https://github.com/zsh-users/zsh/blob/57248b88830ce56adc243a40c7773fb3825cab34/Src/utils.c#L1533-L1555
*
* Another workaround here is to add `unsetopt PROMPT_CR PROMPT_SP` to command-block-support.zsh,
* but it will remove '%' mark on unterminated lines which can be unexpected for users.
*/
internal fun StyledCommandOutput.dropLastBlankLine(shellType: ShellType): StyledCommandOutput {
val lastNewLineInd = this.text.lastIndexOf(NEW_LINE)
val lastLine = this.text.substring(lastNewLineInd + 1)
if (lastNewLineInd >= 0 && lastLine.isEmpty() /* output ends with \n */ ||
shellType == ShellType.ZSH && lastLine.isBlank() /* output ends with whitespaces */) {
return this.takeFirst(max(0, lastNewLineInd))
}
return this
}
private fun StyledCommandOutput.takeFirst(newLength: Int): StyledCommandOutput {
if (newLength == this.text.length) return this
return StyledCommandOutput(this.text.substring(0, newLength),
this.commandEndMarkerFound,
intersect(this.styleRanges, newLength))
}
private fun intersect(styleRanges: List<StyleRange>, newLength: Int): List<StyleRange> {
return styleRanges.mapNotNull {
val newEndOffset = min(it.endOffset, newLength)
when {
newEndOffset == it.endOffset -> it
it.startOffset < newEndOffset -> StyleRange(it.startOffset, newEndOffset, it.style)
else -> null
}
}
}

View File

@@ -9,7 +9,6 @@ import com.intellij.openapi.editor.event.DocumentListener
import com.intellij.openapi.editor.ex.EditorEx
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.TextRange
import com.intellij.terminal.JBTerminalSystemSettingsProviderBase
import com.intellij.util.Alarm
import com.intellij.util.concurrency.annotations.RequiresEdt
@@ -100,24 +99,14 @@ internal class TerminalOutputController(
}
fun finishCommandBlock(exitCode: Int) {
val output = scraper.scrapeOutput()
val output = scraper.scrapeOutput().dropLastBlankLine(session.shellIntegration.shellType)
val terminalWidth = session.model.withContentLock { session.model.width }
invokeLater(editor.getDisposed(), ModalityState.any()) {
val block = doWithScrollingAware {
updateCommandOutput(TerminalOutputSnapshot(terminalWidth, output))
}
disposeRunningCommandInteractivity()
val document = editor.document
val lastLineInd = document.getLineNumber(block.endOffset)
val lastLineStart = document.getLineStartOffset(lastLineInd)
val lastLineText = document.getText(TextRange(lastLineStart, block.endOffset))
// remove the line with empty prompt
if (lastLineText.isBlank()) {
// remove also the line break if it is not the first block
val startRemoveOffset = lastLineStart - if (lastLineStart > 0) 1 else 0
outputModel.deleteDocumentRange(block, TextRange(startRemoveOffset, block.endOffset))
}
if (document.getText(block.textRange).isBlank()) {
if (editor.document.getText(block.textRange).isBlank()) {
outputModel.removeBlock(block)
}
else {

View File

@@ -1,18 +1,23 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package org.jetbrains.plugins.terminal.block
import com.intellij.execution.filters.ConsoleFilterProvider
import com.intellij.execution.filters.Filter
import com.intellij.execution.filters.Filter.ResultItem
import com.intellij.execution.filters.HyperlinkInfo
import com.intellij.execution.impl.EditorHyperlinkSupport
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.util.TextRange
import com.intellij.terminal.TerminalTitle
import com.intellij.testFramework.*
import com.jediterm.core.util.TermSize
import org.jetbrains.plugins.terminal.JBTerminalSystemSettingsProvider
import org.jetbrains.plugins.terminal.exp.BlockTerminalView
import org.jetbrains.plugins.terminal.exp.CommandBlock
import org.jetbrains.plugins.terminal.exp.TerminalOutputModel
import org.jetbrains.plugins.terminal.block.testApps.SimpleTextRepeater
import org.jetbrains.plugins.terminal.exp.*
import org.jetbrains.plugins.terminal.exp.util.TerminalSessionTestUtil
import org.jetbrains.plugins.terminal.exp.util.TerminalSessionTestUtil.toCommandLine
import org.jetbrains.plugins.terminal.exp.withCommand
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
@@ -42,11 +47,7 @@ class BlockTerminalCommandExecutionTest(private val shellPath: Path) {
@Test
fun `commands are executed in order`() {
val session = startBlockTerminalSession()
val view = BlockTerminalView(projectRule.project, session, JBTerminalSystemSettingsProvider(), TerminalTitle())
Disposer.register(disposableRule.disposable, view)
view.outputView.controller.finishCommandBlock(0) // emulate `initialized` event as it's consumed in `startBlockTerminalSession()`
val (session, view) = startSessionAndCreateView()
val count = 50
val expected = (1..count).map {
val message = "Hello, World $it"
@@ -60,6 +61,38 @@ class BlockTerminalCommandExecutionTest(private val shellPath: Path) {
Assert.assertEquals(expected, actual)
}
@Test
fun `basic hyperlinks are found`() {
ExtensionTestUtil.maskExtensions<ConsoleFilterProvider>(
ConsoleFilterProvider.FILTER_PROVIDERS,
listOf(ConsoleFilterProvider { arrayOf(MyHyperlinkFilter("foo")) }),
disposableRule.disposable)
val (session, view) = startSessionAndCreateView()
val fooItem = SimpleTextRepeater.Item("foo", true, true, 5)
val commandLine = SimpleTextRepeater.Helper.generateCommand(listOf(fooItem)).toCommandLine(session)
view.sendCommandToExecute(commandLine)
val outputModel = view.outputView.controller.outputModel
awaitBlocksFinalized(outputModel, 1)
val expected = listOf(CommandResult(commandLine, SimpleTextRepeater.Helper.getExpectedOutput(listOf(fooItem)).trimEnd()))
val actual = view.outputView.controller.outputModel.collectCommandResults()
Assert.assertEquals(expected, actual)
val hyperlinkSupport = EditorHyperlinkSupport.get(outputModel.editor)
hyperlinkSupport.waitForPendingFilters(10_000)
val links = hyperlinkSupport.getAllHyperlinks(0, outputModel.editor.document.textLength)
Assert.assertEquals(fooItem.count, links.size)
}
private fun startSessionAndCreateView(): Pair<BlockTerminalSession, BlockTerminalView> {
val session = startBlockTerminalSession()
val view = BlockTerminalView(projectRule.project, session, JBTerminalSystemSettingsProvider(), TerminalTitle())
Disposer.register(disposableRule.disposable, view)
view.outputView.controller.finishCommandBlock(0) // emulate `initialized` event as it's consumed in `startBlockTerminalSession()`
return Pair(session, view)
}
private fun awaitBlocksFinalized(outputModel: TerminalOutputModel, commandBlocks: Int, duration: Duration = 20.seconds) {
val latch = CountDownLatch(commandBlocks)
outputModel.addListener(object : TerminalOutputModel.TerminalOutputListener {
@@ -93,3 +126,19 @@ private fun TerminalOutputModel.collectCommandResults(): List<CommandResult> {
}
}
}
private open class MyHyperlinkFilter(val linkText: String) : Filter, DumbAware {
override fun applyFilter(line: String, entireLength: Int): Filter.Result? {
val startInd = line.indexOf(linkText)
if (startInd == -1) return null
Thread.sleep(5) // emulate time-consuming hyperlink filter
val startOffset = entireLength - line.length + startInd
val endOffset = startOffset + linkText.length
return Filter.Result(listOf(ResultItem(startOffset, endOffset, NopHyperlinkInfo)))
}
private object NopHyperlinkInfo : HyperlinkInfo {
override fun navigate(project: Project) {}
}
}