[pycharm] PY-86511 Prevent exit tag from being copied when tags are disabled

[pycharm] PY-86511 Refactor output tag logic

[pycharm] PY-86511 Prevent exit tag from being copied when tags are disabled


Merge-request: IJ-MR-195636
Merged-by: David Lysenko <david.lysenko@jetbrains.com>

(cherry picked from commit 22cf8cc64f95de4270257342f7624f0227e2c1c2)

IJ-MR-195636

GitOrigin-RevId: 30cd806c72b530dfea362121b7dc90ff3262fa5a
This commit is contained in:
David Lysenko
2026-03-12 16:08:17 +00:00
committed by intellij-monorepo-bot
parent 43a819262a
commit 74df445149
3 changed files with 140 additions and 24 deletions
@@ -34,6 +34,7 @@ import com.intellij.python.processOutput.frontend.ui.components.OutputSectionTes
import com.intellij.python.processOutput.frontend.ui.components.TreeSectionTestTags
import com.intellij.python.processOutput.frontend.ui.shortenedCommandString
import java.util.WeakHashMap
import kotlin.enums.enumEntries
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Instant
import kotlinx.coroutines.CoroutineName
@@ -284,6 +285,8 @@ internal class ProcessOutputControllerService(
val showTags = processOutputUiState.filters.active.contains(OutputFilter.Item.SHOW_TAGS)
val stringToCopy = buildString {
var lastTag: Tag? = null
loggedProcess.lines.forEach { line ->
if (showTags) {
val tag = when (line.kind) {
@@ -291,11 +294,13 @@ internal class ProcessOutputControllerService(
OutputKindDto.ERR -> Tag.ERROR
}
append("[$tag] ".padStart(Tag.maxLength + 3))
} else {
repeat(Tag.maxLength + 3) {
append(' ')
if (lastTag == tag) {
append(Tag.blankBracketTagString)
} else {
append(tag.bracketTagString)
}
lastTag = tag
}
appendLine(line.text)
@@ -307,7 +312,10 @@ internal class ProcessOutputControllerService(
}
exitData?.also { exitData ->
append("[${Tag.EXIT}] ".padStart(Tag.maxLength + 3))
if (showTags) {
append(Tag.EXIT.bracketTagString)
}
append(exitData.exitCode)
exitData.additionalMessageToUser?.also { message ->
@@ -736,15 +744,27 @@ internal class ProcessOutputControllerService(
}
}
internal object Tag {
val ERROR = message("process.output.output.tag.stdout")
val OUTPUT = message("process.output.output.tag.stderr")
val EXIT = message("process.output.output.tag.exit")
internal enum class Tag(val text: String) {
ERROR(message("process.output.output.tag.stderr")),
OUTPUT(message("process.output.output.tag.stdout")),
EXIT(message("process.output.output.tag.exit"));
val maxLength: Int =
Tag::class.java.declaredFields
.filter { it.type == String::class.java }
.fastMaxOfOrDefault(0) { (it.get(null) as String).length }
override fun toString(): String = text
val bracketTagString: String
get() = "[$text] ".padStart(bracketPadding)
val colonTagString: String
get() = "$text:".padStart(colonPadding)
companion object {
private val maxLength = enumEntries<Tag>().fastMaxOfOrDefault(0) { it.text.length }
private val bracketPadding = maxLength + 3 // opening bracket, closing bracket, and space
private val colonPadding = maxLength + 1 // colon
val blankBracketTagString: String = " ".repeat(bracketPadding)
val blankColonTagString: String = " ".repeat(colonPadding)
}
}
private fun <K, V> boundedLinkedHashMap(maxSize: Int): LinkedHashMap<K, V> =
@@ -260,15 +260,8 @@ private fun OutputLine(
) {
if (displayTags) {
DisableSelection {
val padding = Tag.maxLength + 1
Text(
text =
if (sectionIndicator != null) {
"${sectionIndicator.tag}:".padStart(padding, ' ')
} else {
" ".repeat(padding)
},
text = sectionIndicator?.tag?.colonTagString ?: Tag.blankColonTagString,
style = JewelTheme.consoleTextStyle,
fontWeight = FontWeight.Thin,
modifier =
@@ -309,7 +302,7 @@ private fun OutputLine(
}
private data class SectionIndicator(
val tag: String,
val tag: Tag,
val copyButtonTestTag: String = OutputSectionTestTags.COPY_OUTPUT_TAG_SECTION_BUTTON,
val onCopy: () -> Unit,
)
@@ -430,8 +423,8 @@ private sealed class InfoLine {
private val OutputKindDto.tag
get() =
when (this) {
OutputKindDto.OUT -> Tag.ERROR
OutputKindDto.ERR -> Tag.OUTPUT
OutputKindDto.OUT -> Tag.OUTPUT
OutputKindDto.ERR -> Tag.ERROR
}
internal object OutputSectionTestTags {
@@ -7,15 +7,18 @@ import com.intellij.python.community.execService.Args
import com.intellij.python.community.execService.BinOnEel
import com.intellij.python.community.execService.ExecService
import com.intellij.python.community.execService.impl.LoggingLimits
import com.intellij.python.community.execService.impl.LoggingProcess
import com.intellij.python.junit5Tests.framework.env.PyEnvTestCase
import com.intellij.python.junit5Tests.framework.env.PythonBinaryPath
import com.intellij.python.processOutput.common.OutputKindDto
import com.intellij.python.processOutput.common.OutputLineDto
import com.intellij.python.processOutput.frontend.CoroutineNames
import com.intellij.python.processOutput.frontend.LoggedProcess
import com.intellij.python.processOutput.frontend.OutputFilter
import com.intellij.python.processOutput.frontend.ProcessOutputControllerService
import com.intellij.python.processOutput.frontend.ProcessOutputControllerServiceLimits
import com.intellij.python.processOutput.frontend.ProcessStatus
import com.intellij.python.processOutput.frontend.ui.toggle
import com.intellij.testFramework.common.timeoutRunBlocking
import com.intellij.testFramework.common.waitUntil
import com.intellij.testFramework.junit5.fixture.projectFixture
@@ -374,6 +377,106 @@ class ProcessOutputControllerServiceTest {
)
}
@Test
fun `toolbar copy includes tags depending on whether the filter is enabled`(
@TempDir cwd: Path,
@PythonBinaryPath python: PythonBinary,
): Unit = timeoutRunBlocking {
val service = projectFixture.get().service<ProcessOutputControllerService>()
val binOnEel = BinOnEel(python, cwd)
val mainPy = Files.createFile(cwd.resolve(MAIN_PY))
edtWriteAction {
mainPy.toFile().writeText(
"""
import sys
print("out1")
print("out2")
print("out3")
print("out4")
print("out5")
print("out6")
print("err7", file=sys.stderr)
print("err8", file=sys.stderr)
print("err9", file=sys.stderr)
print("err10", file=sys.stderr)
""".trimIndent(),
)
}
val loggingProcess = withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
ExecService().executeGetProcess(
binOnEel,
Args(MAIN_PY),
CoroutineScope(coroutineContext),
).getOrThrow()
}
// reading all stdout
loggingProcess.inputStream.readAllBytes()
waitUntil {
service.loggedProcesses.value.lastOrNull()?.lines?.size == 6
}
val process = service.loggedProcesses.value.last()
// reading all stderr
loggingProcess.errorStream.readAllBytes()
waitUntil {
service.loggedProcesses.value.lastOrNull()?.lines?.size == 10
}
// copying output
service.copyOutputToClipboard(process)
// copied output should include tags
assertEquals(
"""
[stdout] out1
out2
out3
out4
out5
out6
[stderr] err7
err8
err9
err10
[exit] 0
""".trimIndent(),
CopyPasteManager.getInstance().getContents<String>(DataFlavor.stringFlavor),
)
// toggling the show tags filter
service.processOutputUiState.filters.active.toggle(OutputFilter.Item.SHOW_TAGS)
service.copyOutputToClipboard(process)
// copied output should not include tags
waitUntil("output without tags") {
CopyPasteManager.getInstance().getContents<String>(DataFlavor.stringFlavor) ==
"""
out1
out2
out3
out4
out5
out6
err7
err8
err9
err10
0
""".trimIndent()
}
}
@Test
fun `non-ascii output lines are reflected properly`(
@TempDir cwd: Path,