diff --git a/plugins/terminal/src/org/jetbrains/plugins/terminal/exp/BlockTerminalController.kt b/plugins/terminal/src/org/jetbrains/plugins/terminal/exp/BlockTerminalController.kt index 87e92653d9d4..0a023843182d 100644 --- a/plugins/terminal/src/org/jetbrains/plugins/terminal/exp/BlockTerminalController.kt +++ b/plugins/terminal/src/org/jetbrains/plugins/terminal/exp/BlockTerminalController.kt @@ -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 } diff --git a/plugins/terminal/src/org/jetbrains/plugins/terminal/exp/TerminalOutputController.kt b/plugins/terminal/src/org/jetbrains/plugins/terminal/exp/TerminalOutputController.kt index 7bb0a4de34fe..a67cc8f99ae5 100644 --- a/plugins/terminal/src/org/jetbrains/plugins/terminal/exp/TerminalOutputController.kt +++ b/plugins/terminal/src/org/jetbrains/plugins/terminal/exp/TerminalOutputController.kt @@ -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) private data class RunningCommandContext(val command: String?, val prompt: PromptRenderingInfo?) diff --git a/plugins/terminal/tests/org/jetbrains/plugins/terminal/block/BlockTerminalCommandExecutionTest.kt b/plugins/terminal/tests/org/jetbrains/plugins/terminal/block/BlockTerminalCommandExecutionTest.kt new file mode 100644 index 000000000000..107700496d49 --- /dev/null +++ b/plugins/terminal/tests/org/jetbrains/plugins/terminal/block/BlockTerminalCommandExecutionTest.kt @@ -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 = 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 { + 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))) + } + } +} diff --git a/plugins/terminal/tests/org/jetbrains/plugins/terminal/exp/util/TerminalSessionTestUtil.kt b/plugins/terminal/tests/org/jetbrains/plugins/terminal/exp/util/TerminalSessionTestUtil.kt index 85402847934c..1ea3add9a85a 100644 --- a/plugins/terminal/tests/org/jetbrains/plugins/terminal/exp/util/TerminalSessionTestUtil.kt +++ b/plugins/terminal/tests/org/jetbrains/plugins/terminal/exp/util/TerminalSessionTestUtil.kt @@ -159,6 +159,20 @@ object TerminalSessionTestUtil { this.sendCommandlineToExecuteWithoutAddingToHistory(commandline) } + fun List.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]