terminal: support TerminalWidget.sendCommandToExecute for not-yet-started sessions (IDEA-347591)

Now, command block creation is postponed until previous command is finished.

GitOrigin-RevId: 1fe0435338e34d5ea073a3c3882418d54ea0e8a0
This commit is contained in:
Sergey Simonchik
2024-03-17 11:30:10 +00:00
committed by intellij-monorepo-bot
parent 1a5018dacb
commit 8f303a5d28
4 changed files with 130 additions and 3 deletions
@@ -50,12 +50,18 @@ class BlockTerminalController(
promptController.reset()
outputController.insertEmptyLine()
}
else startCommand(command)
else {
session.commandManager.sendCommandToExecute(command)
outputController.doWhenNextBlockCanBeStarted {
startCommandBlock(command)
}
}
// report event even if it is an empty command, because it will be reported as a separate command type
TerminalUsageTriggerCollector.triggerCommandExecuted(project, command, isBlockTerminal = true)
}
private fun startCommand(command: String) {
@RequiresEdt(generateAssertion = false)
private fun startCommandBlock(command: String) {
outputController.startCommandBlock(command, promptController.promptRenderingInfo)
// Hide the prompt only when the new block is created, so it will look like the prompt is replaced with a block atomically.
// If the command is finished very fast, the prompt will be shown back before repainting.
@@ -67,7 +73,6 @@ class BlockTerminalController(
Disposer.dispose(disposable)
}
}, disposable)
session.commandManager.sendCommandToExecute(command)
session.model.isCommandRunning = true
}
@@ -15,6 +15,7 @@ import com.intellij.util.concurrency.annotations.RequiresEdt
import com.jediterm.terminal.TextStyle
import org.jetbrains.plugins.terminal.exp.TerminalDataContextUtils.IS_OUTPUT_EDITOR_KEY
import org.jetbrains.plugins.terminal.exp.hyperlinks.TerminalHyperlinkHighlighter
import java.util.*
class TerminalOutputController(
project: Project,
@@ -37,6 +38,8 @@ class TerminalOutputController(
private val hyperlinkHighlighter: TerminalHyperlinkHighlighter = TerminalHyperlinkHighlighter(project, outputModel, session)
private val nextBlockCanBeStartedQueue: Queue<() -> Unit> = LinkedList()
init {
editor.putUserData(IS_OUTPUT_EDITOR_KEY, true)
editor.highlighter = textHighlighter
@@ -112,6 +115,7 @@ class TerminalOutputController(
outputModel.finalizeBlock(block)
}
runningCommandContext = null
nextBlockCanBeStartedQueue.poll()?.invoke()
}
}
@@ -273,6 +277,16 @@ class TerminalOutputController(
else editor.document.addDocumentListener(listener)
}
@RequiresEdt
fun doWhenNextBlockCanBeStarted(callback: () -> Unit) {
if (runningCommandContext == null && outputModel.getActiveBlock() == null) {
callback()
}
else {
nextBlockCanBeStartedQueue.offer(callback)
}
}
private data class CommandOutput(val text: String, val highlightings: List<HighlightingInfo>)
private data class RunningCommandContext(val command: String?, val prompt: PromptRenderingInfo?)
@@ -0,0 +1,94 @@
// 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.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.exp.util.TerminalSessionTestUtil
import org.jetbrains.plugins.terminal.exp.util.TerminalSessionTestUtil.toCommandLine
import org.junit.Assert
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import java.nio.file.Path
import java.util.concurrent.CountDownLatch
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@RunsInEdt
@RunWith(Parameterized::class)
class BlockTerminalCommandExecutionTest(private val shellPath: Path) {
private val projectRule: ProjectRule = ProjectRule()
private val disposableRule = DisposableRule()
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun shells(): List<Path> = TerminalSessionTestUtil.getShellPaths()
}
@Rule
@JvmField
val ruleChain: RuleChain = RuleChain(EdtRule(), projectRule, disposableRule)
@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 count = 50
val expected = (1..count).map {
val message = "Hello, World $it"
CommandResult(listOf("echo", message).toCommandLine(session), message)
}
expected.forEach {
view.sendCommandToExecute(it.command)
}
awaitBlocksFinalized(view.outputView.controller.outputModel, count)
val actual = view.outputView.controller.outputModel.collectCommandResults()
Assert.assertEquals(expected, actual)
}
private fun awaitBlocksFinalized(outputModel: TerminalOutputModel, commandBlocks: Int, duration: Duration = 20.seconds) {
val latch = CountDownLatch(commandBlocks)
outputModel.addListener(object : TerminalOutputModel.TerminalOutputListener {
override fun blockFinalized(block: CommandBlock) {
if (block.withCommand) {
latch.countDown()
}
}
})
val error = { "Timed out waiting for command blocks: finished: ${commandBlocks - latch.count}, expected: $commandBlocks" }
PlatformTestUtil.waitWithEventsDispatching(error, { latch.count.toInt() == 0 }, duration.inWholeSeconds.toInt())
}
private fun startBlockTerminalSession(termSize: TermSize = TermSize(80, 24)) =
TerminalSessionTestUtil.startBlockTerminalSession(projectRule.project, shellPath.toString(), disposableRule.disposable, termSize)
}
private data class CommandResult(val command: String, val output: String)
private fun TerminalOutputModel.collectCommandResults(): List<CommandResult> {
return (0 until getBlocksSize()).mapNotNull {
val commandBlock = this.getByIndex(it)
check(commandBlock.isFinalized)
val command = commandBlock.command
if (it == 0 && command == null) {
null // skip the initial block
}
else {
CommandResult(command!!, editor.document.getText(TextRange(commandBlock.outputStartOffset, commandBlock.endOffset)))
}
}
}
@@ -159,6 +159,20 @@ object TerminalSessionTestUtil {
this.sendCommandlineToExecuteWithoutAddingToHistory(commandline)
}
fun List<String>.toCommandLine(session: BlockTerminalSession, preventAddingToHistory: Boolean = true): String {
return when (session.shellIntegration.shellType) {
ShellType.POWERSHELL -> {
// saving command history is disabled for PowerShell in [disableSavingHistory]
toPowerShellCommandLine(this)
}
else -> {
val commandLine = toPosixShellCommandLine(this)
// prefix with a space to prevent adding it to history for Zsh/Bash/fish (works by default)
if (preventAddingToHistory) " $commandLine" else commandLine
}
}
}
fun BlockTerminalSession.sendCommandlineToExecuteWithoutAddingToHistory(shellCommandline: String) {
if (this.shellIntegration.shellType == ShellType.POWERSHELL) {
// saving command history is disabled for PowerShell in [disableSavingHistory]