PY-86256-PY-86511

[pycharm] PY-86511 Hide `exit` on tags hidden filter

Space-RevId: c46d60295a8381d12ba98e8d159eeff8726bc076
[pycharm] PY-87115 Optimize exit info observation coroutines

Space-RevId: d89e88ed5cb08ab4f0c1780f74d4825b8483ad1d
[pycharm] PY-86256 Change stderr to default color

Space-RevId: ffd3bb8f27900ded7c1197541651eb38d8f14dd6
[pycharm] PY-86256 Change error/output to stderr/stdout

Space-RevId: 7e0ed184083d72431e5cb17ad8dcebbe65afe460

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

GitOrigin-RevId: 43e8ac85d133dccb37470bd257bd3d78451fad34
This commit is contained in:
David Lysenko
2026-01-26 15:54:42 +00:00
committed by intellij-monorepo-bot
parent ca8ed8572c
commit 409fe8c4e1
6 changed files with 173 additions and 63 deletions
@@ -30,6 +30,10 @@ process.output.output.sections.info.cwd=cwd
process.output.output.sections.info.target=target
process.output.output.sections.info.env=env
process.output.output.tag.stdout=stdout
process.output.output.tag.stderr=stderr
process.output.output.tag.exit=exit
process.output.output.copySection.tooltip=Copy Section
process.output.output.sections.output=Process Output
@@ -434,6 +434,15 @@ class ProcessOutputControllerService(
list
.filter { it.traceContext == NON_INTERACTIVE_ROOT_TRACE_CONTEXT }
.forEach { process ->
val exitInfo = process.exitInfo.value
if (exitInfo != null) {
if (exitInfo.exitValue != 0) {
backgroundErrorProcesses.value += process.id
}
return@forEach
}
backgroundObservingCoroutines +=
launch(CoroutineName(CoroutineNames.EXIT_INFO_COLLECTOR)) {
process.exitInfo.collect {
@@ -445,6 +454,7 @@ class ProcessOutputControllerService(
}
}
}
}
}
}
@@ -559,9 +569,9 @@ class ProcessOutputControllerService(
}
internal object Tag {
const val ERROR = "error"
const val OUTPUT = "output"
const val EXIT = "exit"
val ERROR = message("process.output.output.tag.stdout")
val OUTPUT = message("process.output.output.tag.stderr")
val EXIT = message("process.output.output.tag.exit")
val maxLength: Int =
Tag::class.java.declaredFields
@@ -41,12 +41,16 @@ import com.intellij.python.processOutput.impl.Tag
import com.intellij.python.processOutput.impl.formatFull
import com.intellij.python.processOutput.impl.ui.Colors
import com.intellij.python.processOutput.impl.ui.collectReplayAsState
import com.intellij.python.processOutput.impl.ui.thenIfNotNull
import kotlinx.collections.immutable.persistentListOf
import org.jetbrains.jewel.foundation.theme.JewelTheme
import org.jetbrains.jewel.ui.component.scrollbarContentSafePadding
private object Styling {
val COPY_SECTION_BUTTON_SPACE_SIZE = 18.dp
val LINE_START_PADDING = 8.dp
val LINE_HORIZONTAL_ALIGNMENT = 10.dp
val LINE_SPACER_HEIGHT = 4.dp
}
@Composable
@@ -167,19 +171,17 @@ internal fun OutputSection(controller: ProcessOutputController) {
items = lines,
key = { index, _ -> index },
) { index, line ->
val outputColor = when (line.kind) {
LoggedProcessLine.Kind.OUT -> Color.Unspecified
LoggedProcessLine.Kind.ERR -> Colors.Output.ErrorText
}
// when the kind of the current line does not match the kind of the
// previous line, it means that the current line is the start of a
// new section
val startOfNewSection =
lines.getOrNull(index - 1)?.kind != line.kind
OutputLine(
displayTags = isDisplayTags,
sectionIndicator =
if (lines.getOrNull(index - 1)?.kind != line.kind) {
SectionIndicator(
line.kind.tag,
OutputSectionTestTags.COPY_OUTPUT_TAG_SECTION_BUTTON,
) {
if (startOfNewSection) {
SectionIndicator(line.kind.tag) {
controller.copyOutputTagAtIndexToClipboard(
loggedProcess,
index,
@@ -189,16 +191,13 @@ internal fun OutputSection(controller: ProcessOutputController) {
null
},
text = line.text,
textStyle = SpanStyle(
color = outputColor,
),
)
}
exitInfo?.also { exitInfo ->
item(key = "exit") {
OutputLine(
displayTags = true,
displayTags = isDisplayTags,
sectionIndicator =
SectionIndicator(
Tag.EXIT,
@@ -253,22 +252,31 @@ private fun OutputLine(
Row(
modifier = Modifier.fillMaxWidth()
.padding(end = scrollbarContentSafePadding()),
horizontalArrangement = Arrangement.spacedBy(10.dp),
.padding(
end = scrollbarContentSafePadding(),
start = Styling.LINE_START_PADDING,
),
horizontalArrangement = Arrangement.spacedBy(Styling.LINE_HORIZONTAL_ALIGNMENT),
) {
DisableSelection {
val padding = Tag.maxLength + 3
if (displayTags) {
DisableSelection {
val padding = Tag.maxLength + 1
Text(
text =
if (displayTags && sectionIndicator != null) {
"${sectionIndicator.tag}:".padStart(padding, ' ')
} else {
" ".repeat(padding)
},
style = JewelTheme.consoleTextStyle,
fontWeight = FontWeight.Thin,
)
Text(
text =
if (sectionIndicator != null) {
"${sectionIndicator.tag}:".padStart(padding, ' ')
} else {
" ".repeat(padding)
},
style = JewelTheme.consoleTextStyle,
fontWeight = FontWeight.Thin,
modifier =
Modifier.thenIfNotNull(sectionIndicator) {
testTag(OutputSectionTestTags.OUTPUT_SECTION_TAG)
},
)
}
}
Text(
@@ -286,7 +294,7 @@ private fun OutputLine(
ActionIconButton(
modifier = Modifier
.size(Styling.COPY_SECTION_BUTTON_SPACE_SIZE)
.testTag(sectionIndicator.testTag),
.testTag(sectionIndicator.copyButtonTestTag),
iconKey = Icons.Keys.Copy,
tooltipText = message("process.output.output.copySection.tooltip"),
onClick = sectionIndicator.onCopy,
@@ -302,7 +310,7 @@ private fun OutputLine(
private data class SectionIndicator(
val tag: String,
val testTag: String,
val copyButtonTestTag: String = OutputSectionTestTags.COPY_OUTPUT_TAG_SECTION_BUTTON,
val onCopy: () -> Unit,
)
@@ -338,7 +346,7 @@ private fun LazyListScope.infoLineItems(
else -> 0
}
} ?: 0
val padding = maxLength + 2
val padding = maxLength + 1
infoLines.forEach { infoLine ->
when (infoLine) {
@@ -375,8 +383,11 @@ private fun LazyListScope.infoLineItemSingle(
Row(
modifier = Modifier.fillMaxWidth()
.padding(end = scrollbarContentSafePadding()),
horizontalArrangement = Arrangement.spacedBy(10.dp),
.padding(
end = scrollbarContentSafePadding(),
start = Styling.LINE_START_PADDING,
),
horizontalArrangement = Arrangement.spacedBy(Styling.LINE_HORIZONTAL_ALIGNMENT),
) {
Text(
text =
@@ -406,7 +417,7 @@ private fun LazyListScope.infoLineItemSingle(
@Composable
private fun LineSpacer() {
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(Styling.LINE_SPACER_HEIGHT))
}
private sealed class InfoLine {
@@ -427,6 +438,7 @@ internal object OutputSectionTestTags {
const val NOT_SELECTED_TEXT = "ProcessOutput.Output.NotSelectedText"
const val INFO_SECTION = "ProcessOutput.Output.InfoSection"
const val OUTPUT_SECTION = "ProcessOutput.Output.OutputSection"
const val OUTPUT_SECTION_TAG = "ProcessOutput.Output.OutputSection.Tag"
const val FILTERS_TAGS = "ProcessOutput.Output.FiltersTags"
const val FILTERS_BUTTON = "ProcessOutput.Output.FiltersButton"
const val FILTERS_MENU = "ProcessOutput.Output.FiltersMenu"
@@ -196,7 +196,6 @@ class ProcessOutputControllerServiceTest {
watcher.cancelAndJoin()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `exit info collector coroutines get properly cleaned up`(
@TempDir cwd: Path,
@@ -213,32 +212,45 @@ class ProcessOutputControllerServiceTest {
import sys
print("test " + sys.argv[1])
sys.stdin.read(1)
""".trimIndent(),
)
}
// no exit info collector coroutines should exist
assertEquals(
0,
DebugProbes.dumpCoroutinesInfo()
.filter { it.context[CoroutineName.Key]?.name == CoroutineNames.EXIT_INFO_COLLECTOR }
.size,
)
assertEquals(0, exitInfoCollectorCoroutinesCount())
// spawn 1024 processes
repeat(1024) {
runBin(binOnEel, Args(MAIN_PY, it.toString()))
// spawn 10 processes
val processes = mutableListOf<Process>()
repeat(10) {
processes += runBinWithInput(binOnEel, Args(MAIN_PY, it.toString()))
}
sleepCancellable(1000)
// 10 collector coroutines should be active
waitUntil {
exitInfoCollectorCoroutinesCount() == 10
}
// the count of active exit info collector coroutines should match MAX_PROCESSES
assertEquals(
ProcessOutputControllerServiceLimits.MAX_PROCESSES,
DebugProbes.dumpCoroutinesInfo()
.filter { it.context[CoroutineName.Key]?.name == CoroutineNames.EXIT_INFO_COLLECTOR }
.size,
)
// spawn 1024 processes, instantly terminate them
repeat(1024) {
val process = runBinWithInput(binOnEel, Args(MAIN_PY, (it + 10).toString()))
inputAndAwaitExit(process)
}
// 10 collection coroutines should be active
waitUntil {
exitInfoCollectorCoroutinesCount() == 10
}
// terminating all the processes
for (process in processes) {
inputAndAwaitExit(process)
}
// no collection coroutines should be active
waitUntil {
exitInfoCollectorCoroutinesCount() == 0
}
}
@Test
@@ -403,6 +415,31 @@ class ProcessOutputControllerServiceTest {
companion object {
const val MAIN_PY = "main.py"
suspend fun runBinWithInput(binOnEel: BinOnEel, args: Args): Process =
ExecService().executeGetProcess(
binOnEel,
args,
CoroutineScope(NON_INTERACTIVE_ROOT_TRACE_CONTEXT),
).getOrThrow()
suspend fun inputAndAwaitExit(process: Process) {
process.outputStream.write(0)
process.outputStream.flush()
coroutineScope {
listOf(
async(Dispatchers.IO) {
process.errorStream.readAllBytes()
},
async(Dispatchers.IO) {
process.inputStream.readAllBytes()
},
).awaitAll()
process.awaitExit()
}
}
suspend fun runBin(binOnEel: BinOnEel, args: Args) {
withContext(NON_INTERACTIVE_ROOT_TRACE_CONTEXT) {
val process = ExecService().executeGetProcess(
@@ -425,5 +462,11 @@ class ProcessOutputControllerServiceTest {
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun exitInfoCollectorCoroutinesCount(): Int =
DebugProbes.dumpCoroutinesInfo()
.filter { it.context[CoroutineName.Key]?.name == CoroutineNames.EXIT_INFO_COLLECTOR }
.size
}
}
@@ -117,7 +117,7 @@ internal class OutputSectionTest : ProcessOutputTest() {
// 3..6 - stderr
// 7..9 - stdout
val process = selectTestProcess(
listOf(
lines = listOf(
outLine("out1"),
outLine("out2"),
outLine("out3"),
@@ -207,6 +207,45 @@ internal class OutputSectionTest : ProcessOutputTest() {
verify(exactly = 1) { controllerSpy.copyOutputExitInfoToClipboard(process) }
}
@Test
fun `tags are displayed or hidden depending on show tags filter`() = processOutputTest {
// selecting a process with 3 tags:
// 0..2 - stdout
// 3..5 - stderr
// 6 - exit
selectTestProcess(
lines = listOf(
outLine("out1"),
outLine("out2"),
outLine("out3"),
errLine("err4"),
errLine("err5"),
errLine("err6"),
),
exitInfo =
LoggedProcessExitInfo(
exitedAt = Clock.System.now(),
exitValue = 0,
),
)
// total displayed tags should be 3
onAllNodesWithTag(
OutputSectionTestTags.OUTPUT_SECTION_TAG,
useUnmergedTree = true,
).assertCountEquals(3)
// remove show tags filter
processOutputFilters.remove(OutputFilter.ShowTags)
// total displayed tags should be 0
onAllNodesWithTag(
OutputSectionTestTags.OUTPUT_SECTION_TAG,
useUnmergedTree = true,
).assertCountEquals(0)
}
private suspend fun selectTestProcess(
lines: List<LoggedProcessLine> = listOf(),
exitInfo: LoggedProcessExitInfo? = null,
@@ -35,17 +35,19 @@ import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme
import org.junit.Rule
internal abstract class ProcessOutputTest {
private val processTree = MutableStateFlow(buildTree<TreeNode> {})
private val processTreeFilters: SnapshotStateSet<TreeFilter> = mutableStateSetOf(
protected val processTree = MutableStateFlow(buildTree<TreeNode> {})
protected val processTreeFilters: SnapshotStateSet<TreeFilter> = mutableStateSetOf(
TreeFilter.ShowTime,
)
private val processOutputFilters: SnapshotStateSet<OutputFilter> = mutableStateSetOf()
private val processOutputInfoExpanded = MutableStateFlow(false)
private val processOutputOutputExpanded = MutableStateFlow(true)
protected val processOutputFilters: SnapshotStateSet<OutputFilter> = mutableStateSetOf(
OutputFilter.ShowTags,
)
protected val processOutputInfoExpanded = MutableStateFlow(false)
protected val processOutputOutputExpanded = MutableStateFlow(true)
private val testSelectedProcess: MutableStateFlow<LoggedProcess?> = MutableStateFlow(null)
private val testProcessTreeUiState: TreeUiState = run {
protected val testSelectedProcess: MutableStateFlow<LoggedProcess?> = MutableStateFlow(null)
protected val testProcessTreeUiState: TreeUiState = run {
val selectableLazyListState = SelectableLazyListState(LazyListState())
TreeUiState(
filters = processTreeFilters,
@@ -55,7 +57,7 @@ internal abstract class ProcessOutputTest {
tree = processTree,
)
}
private val testProcessOutputUiState: OutputUiState = OutputUiState(
protected val testProcessOutputUiState: OutputUiState = OutputUiState(
filters = processOutputFilters,
isInfoExpanded = processOutputInfoExpanded,
isOutputExpanded = processOutputOutputExpanded,